@storyshelf/app 0.6.1 → 0.7.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 +1 -3
- package/dist/asset-manifest.mjs +13 -5
- package/dist/asset-manifest.mjs.map +1 -1
- package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
- package/dist/assets/htmx.mjs.map +1 -0
- package/dist/assets/mark.mjs +10 -0
- package/dist/assets/mark.mjs.map +1 -0
- package/dist/boot-tasks.mjs +80 -0
- package/dist/boot-tasks.mjs.map +1 -0
- package/dist/build-state-flags.mjs +18 -0
- package/dist/build-state-flags.mjs.map +1 -0
- package/dist/build-state-setup.mjs +75 -0
- package/dist/build-state-setup.mjs.map +1 -0
- package/dist/capture-alerts.mjs +46 -0
- package/dist/capture-alerts.mjs.map +1 -0
- package/dist/capture-process.mjs +59 -0
- package/dist/capture-process.mjs.map +1 -0
- package/dist/capture-setup.mjs +95 -117
- package/dist/capture-setup.mjs.map +1 -1
- package/dist/connections.mjs +34 -0
- package/dist/connections.mjs.map +1 -0
- package/dist/consumer-setup.mjs +58 -0
- package/dist/consumer-setup.mjs.map +1 -0
- package/dist/credentials.mjs +3 -2
- package/dist/credentials.mjs.map +1 -1
- package/dist/git-status.mjs +46 -0
- package/dist/git-status.mjs.map +1 -0
- package/dist/hidden-data.mjs +24 -0
- package/dist/hidden-data.mjs.map +1 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +202 -124
- package/dist/index.mjs.map +1 -1
- package/dist/insights/budget.mjs +49 -0
- package/dist/insights/budget.mjs.map +1 -0
- package/dist/insights/deps.mjs +21 -0
- package/dist/insights/deps.mjs.map +1 -0
- package/dist/insights/errors.mjs +22 -0
- package/dist/insights/errors.mjs.map +1 -0
- package/dist/insights/health-panel.mjs +40 -0
- package/dist/insights/health-panel.mjs.map +1 -0
- package/dist/insights/job.mjs +103 -0
- package/dist/insights/job.mjs.map +1 -0
- package/dist/insights/load-health.mjs +59 -0
- package/dist/insights/load-health.mjs.map +1 -0
- package/dist/insights/load-triage.mjs +98 -0
- package/dist/insights/load-triage.mjs.map +1 -0
- package/dist/insights/panel.mjs +28 -0
- package/dist/insights/panel.mjs.map +1 -0
- package/dist/insights/request.mjs +113 -0
- package/dist/insights/request.mjs.map +1 -0
- package/dist/insights/stale.mjs +21 -0
- package/dist/insights/stale.mjs.map +1 -0
- package/dist/insights/usage-report.mjs +60 -0
- package/dist/insights/usage-report.mjs.map +1 -0
- package/dist/insights/view.mjs +48 -0
- package/dist/insights/view.mjs.map +1 -0
- package/dist/job-canceller.mjs +14 -0
- package/dist/job-canceller.mjs.map +1 -0
- package/dist/job-queue.mjs +51 -0
- package/dist/job-queue.mjs.map +1 -0
- package/dist/jobs-setup.mjs +132 -0
- package/dist/jobs-setup.mjs.map +1 -0
- package/dist/lifecycle.mjs +109 -19
- package/dist/lifecycle.mjs.map +1 -1
- package/dist/middleware/auth-gate.mjs +4 -2
- package/dist/middleware/auth-gate.mjs.map +1 -1
- package/dist/middleware/rate-limit.mjs +41 -33
- package/dist/middleware/rate-limit.mjs.map +1 -1
- package/dist/middleware/request-log.mjs +3 -2
- package/dist/middleware/request-log.mjs.map +1 -1
- package/dist/middleware/require-review.mjs +51 -0
- package/dist/middleware/require-review.mjs.map +1 -0
- package/dist/middleware/require-testing.mjs +41 -0
- package/dist/middleware/require-testing.mjs.map +1 -0
- package/dist/middleware/setup-gate.mjs.map +1 -1
- package/dist/middleware/store-scope.mjs +12 -2
- package/dist/middleware/store-scope.mjs.map +1 -1
- package/dist/notifications-pause.mjs +33 -0
- package/dist/notifications-pause.mjs.map +1 -0
- package/dist/notify.d.mts +2 -1
- package/dist/notify.mjs +78 -39
- package/dist/notify.mjs.map +1 -1
- package/dist/pages/a11y-violations.mjs +116 -0
- package/dist/pages/a11y-violations.mjs.map +1 -0
- package/dist/pages/admin-ai.mjs +78 -0
- package/dist/pages/admin-ai.mjs.map +1 -0
- package/dist/pages/admin-system-origin.mjs +123 -0
- package/dist/pages/admin-system-origin.mjs.map +1 -0
- package/dist/pages/admin-system-targets.mjs +53 -0
- package/dist/pages/admin-system-targets.mjs.map +1 -0
- package/dist/pages/admin-system.mjs +63 -13
- package/dist/pages/admin-system.mjs.map +1 -1
- package/dist/pages/build-detail.mjs +104 -76
- package/dist/pages/build-detail.mjs.map +1 -1
- package/dist/pages/build-diff-actions.mjs +87 -33
- package/dist/pages/build-diff-actions.mjs.map +1 -1
- package/dist/pages/build-diff-comments.mjs +16 -14
- package/dist/pages/build-diff-comments.mjs.map +1 -1
- package/dist/pages/build-diff-environment.mjs +82 -0
- package/dist/pages/build-diff-environment.mjs.map +1 -0
- package/dist/pages/build-diff-header.mjs +22 -4
- package/dist/pages/build-diff-header.mjs.map +1 -1
- package/dist/pages/build-diff-insight.mjs +113 -0
- package/dist/pages/build-diff-insight.mjs.map +1 -0
- package/dist/pages/build-diff-model.mjs +55 -10
- package/dist/pages/build-diff-model.mjs.map +1 -1
- package/dist/pages/build-diff-nav.mjs +28 -12
- package/dist/pages/build-diff-nav.mjs.map +1 -1
- package/dist/pages/build-diff-shortcuts.mjs +5 -1
- package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
- package/dist/pages/build-diff-usage.mjs +63 -0
- package/dist/pages/build-diff-usage.mjs.map +1 -0
- package/dist/pages/build-diff-viewer.mjs +62 -5
- package/dist/pages/build-diff-viewer.mjs.map +1 -1
- package/dist/pages/build-diff.mjs +34 -13
- package/dist/pages/build-diff.mjs.map +1 -1
- package/dist/pages/build-tests.mjs +74 -0
- package/dist/pages/build-tests.mjs.map +1 -0
- package/dist/pages/component-detail.mjs +249 -0
- package/dist/pages/component-detail.mjs.map +1 -0
- package/dist/pages/component-stories.mjs +168 -0
- package/dist/pages/component-stories.mjs.map +1 -0
- package/dist/pages/compute-jobs.mjs +13 -26
- package/dist/pages/compute-jobs.mjs.map +1 -1
- package/dist/pages/design-links.mjs +182 -0
- package/dist/pages/design-links.mjs.map +1 -0
- package/dist/pages/environment-change.mjs +53 -0
- package/dist/pages/environment-change.mjs.map +1 -0
- package/dist/pages/health-panel.mjs +126 -0
- package/dist/pages/health-panel.mjs.map +1 -0
- package/dist/pages/library-grid.mjs +105 -0
- package/dist/pages/library-grid.mjs.map +1 -0
- package/dist/pages/library-model.mjs +47 -0
- package/dist/pages/library-model.mjs.map +1 -0
- package/dist/pages/library-snapshots.mjs +130 -0
- package/dist/pages/library-snapshots.mjs.map +1 -0
- package/dist/pages/library.mjs +112 -218
- package/dist/pages/library.mjs.map +1 -1
- package/dist/pages/notifications-paused.mjs +22 -0
- package/dist/pages/notifications-paused.mjs.map +1 -0
- package/dist/pages/profile.mjs +53 -48
- package/dist/pages/profile.mjs.map +1 -1
- package/dist/pages/project-builds-table.mjs +2 -2
- package/dist/pages/project-builds-table.mjs.map +1 -1
- package/dist/pages/project-builds.mjs +51 -21
- package/dist/pages/project-builds.mjs.map +1 -1
- package/dist/pages/project-settings.mjs +51 -12
- package/dist/pages/project-settings.mjs.map +1 -1
- package/dist/pages/projects-card.mjs +14 -1
- package/dist/pages/projects-card.mjs.map +1 -1
- package/dist/pages/projects.mjs +6 -2
- package/dist/pages/projects.mjs.map +1 -1
- package/dist/pages/review-changeset.mjs +116 -0
- package/dist/pages/review-changeset.mjs.map +1 -0
- package/dist/pages/review-comments.mjs +103 -0
- package/dist/pages/review-comments.mjs.map +1 -0
- package/dist/pages/review-verdict.mjs +102 -0
- package/dist/pages/review-verdict.mjs.map +1 -0
- package/dist/pages/settings-ai.mjs +69 -0
- package/dist/pages/settings-ai.mjs.map +1 -0
- package/dist/pages/settings-capture.mjs +124 -0
- package/dist/pages/settings-capture.mjs.map +1 -0
- package/dist/pages/settings-design-credential.mjs +99 -0
- package/dist/pages/settings-design-credential.mjs.map +1 -0
- package/dist/pages/settings-design-links.mjs +179 -0
- package/dist/pages/settings-design-links.mjs.map +1 -0
- package/dist/pages/settings-design.mjs +48 -0
- package/dist/pages/settings-design.mjs.map +1 -0
- package/dist/pages/settings-general.mjs +46 -21
- package/dist/pages/settings-general.mjs.map +1 -1
- package/dist/pages/settings-labels.mjs +43 -2
- package/dist/pages/settings-labels.mjs.map +1 -1
- package/dist/pages/settings-links.mjs +112 -0
- package/dist/pages/settings-links.mjs.map +1 -0
- package/dist/pages/settings-notifications-git.mjs +128 -0
- package/dist/pages/settings-notifications-git.mjs.map +1 -0
- package/dist/pages/settings-notifications.mjs +79 -42
- package/dist/pages/settings-notifications.mjs.map +1 -1
- package/dist/pages/settings-tests.mjs +33 -2
- package/dist/pages/settings-tests.mjs.map +1 -1
- package/dist/pages/target-choices.mjs +39 -0
- package/dist/pages/target-choices.mjs.map +1 -0
- package/dist/publish-setup.mjs +89 -0
- package/dist/publish-setup.mjs.map +1 -0
- package/dist/retention-timer.mjs +2 -1
- package/dist/retention-timer.mjs.map +1 -1
- package/dist/review-events.mjs +48 -0
- package/dist/review-events.mjs.map +1 -0
- package/dist/routers/admin-pages.mjs +45 -3
- package/dist/routers/admin-pages.mjs.map +1 -1
- package/dist/routers/admin-public-origin.mjs +85 -0
- package/dist/routers/admin-public-origin.mjs.map +1 -0
- package/dist/routers/assets.mjs +9 -2
- package/dist/routers/assets.mjs.map +1 -1
- package/dist/routers/auth.mjs +2 -2
- package/dist/routers/auth.mjs.map +1 -1
- package/dist/routers/baseline-guard.mjs +2 -2
- package/dist/routers/baseline-guard.mjs.map +1 -1
- package/dist/routers/baselines.handlers.mjs +64 -0
- package/dist/routers/baselines.handlers.mjs.map +1 -0
- package/dist/routers/baselines.mjs +125 -0
- package/dist/routers/baselines.mjs.map +1 -0
- package/dist/routers/build-cancel.mjs +68 -0
- package/dist/routers/build-cancel.mjs.map +1 -0
- package/dist/routers/build-changeset.mjs +70 -0
- package/dist/routers/build-changeset.mjs.map +1 -0
- package/dist/routers/build-reviews.handlers.mjs +86 -0
- package/dist/routers/build-reviews.handlers.mjs.map +1 -0
- package/dist/routers/build-reviews.mjs +74 -0
- package/dist/routers/build-reviews.mjs.map +1 -0
- package/dist/routers/builds.handlers.mjs +46 -55
- package/dist/routers/builds.handlers.mjs.map +1 -1
- package/dist/routers/builds.mjs +71 -17
- package/dist/routers/builds.mjs.map +1 -1
- package/dist/routers/capture-config.mjs +35 -0
- package/dist/routers/capture-config.mjs.map +1 -0
- package/dist/routers/comments.mjs +41 -15
- package/dist/routers/comments.mjs.map +1 -1
- package/dist/routers/component-catalog.handlers.mjs +71 -0
- package/dist/routers/component-catalog.handlers.mjs.map +1 -0
- package/dist/routers/component.handlers.mjs +121 -0
- package/dist/routers/component.handlers.mjs.map +1 -0
- package/dist/routers/connections.mjs +118 -0
- package/dist/routers/connections.mjs.map +1 -0
- package/dist/routers/design-connect.handlers.mjs +44 -0
- package/dist/routers/design-connect.handlers.mjs.map +1 -0
- package/dist/routers/design.handlers.mjs +114 -0
- package/dist/routers/design.handlers.mjs.map +1 -0
- package/dist/routers/health-report.mjs +2 -3
- package/dist/routers/health-report.mjs.map +1 -1
- package/dist/routers/health.mjs +4 -3
- package/dist/routers/health.mjs.map +1 -1
- package/dist/routers/helpers.mjs +19 -3
- package/dist/routers/helpers.mjs.map +1 -1
- package/dist/routers/insights-ui.mjs +64 -0
- package/dist/routers/insights-ui.mjs.map +1 -0
- package/dist/routers/insights.handlers.mjs +45 -0
- package/dist/routers/insights.handlers.mjs.map +1 -0
- package/dist/routers/insights.mjs +189 -0
- package/dist/routers/insights.mjs.map +1 -0
- package/dist/routers/links.handlers.mjs +80 -0
- package/dist/routers/links.handlers.mjs.map +1 -0
- package/dist/routers/links.mjs +139 -0
- package/dist/routers/links.mjs.map +1 -0
- package/dist/routers/media.mjs +18 -4
- package/dist/routers/media.mjs.map +1 -1
- package/dist/routers/members.mjs +2 -1
- package/dist/routers/members.mjs.map +1 -1
- package/dist/routers/notification-channels.mjs +17 -23
- package/dist/routers/notification-channels.mjs.map +1 -1
- package/dist/routers/oauth-state.mjs +45 -0
- package/dist/routers/oauth-state.mjs.map +1 -0
- package/dist/routers/preview-base.mjs +38 -0
- package/dist/routers/preview-base.mjs.map +1 -0
- package/dist/routers/preview-token.mjs +51 -0
- package/dist/routers/preview-token.mjs.map +1 -0
- package/dist/routers/profile.mjs +4 -2
- package/dist/routers/profile.mjs.map +1 -1
- package/dist/routers/projects.mjs +17 -1
- package/dist/routers/projects.mjs.map +1 -1
- package/dist/routers/publish-upload.mjs +78 -0
- package/dist/routers/publish-upload.mjs.map +1 -0
- package/dist/routers/published-access.mjs +52 -0
- package/dist/routers/published-access.mjs.map +1 -0
- package/dist/routers/published-files.mjs +52 -0
- package/dist/routers/published-files.mjs.map +1 -0
- package/dist/routers/review-forms.mjs +72 -0
- package/dist/routers/review-forms.mjs.map +1 -0
- package/dist/routers/review-section.handlers.mjs +85 -0
- package/dist/routers/review-section.handlers.mjs.map +1 -0
- package/dist/routers/review-switch-off.mjs +29 -0
- package/dist/routers/review-switch-off.mjs.map +1 -0
- package/dist/routers/schemas.mjs +140 -22
- package/dist/routers/schemas.mjs.map +1 -1
- package/dist/routers/scoped-models.mjs +22 -0
- package/dist/routers/scoped-models.mjs.map +1 -0
- package/dist/routers/settings-ai.mjs +35 -0
- package/dist/routers/settings-ai.mjs.map +1 -0
- package/dist/routers/settings-capture.mjs +77 -0
- package/dist/routers/settings-capture.mjs.map +1 -0
- package/dist/routers/settings-design.mjs +103 -0
- package/dist/routers/settings-design.mjs.map +1 -0
- package/dist/routers/settings-general.mjs +55 -10
- package/dist/routers/settings-general.mjs.map +1 -1
- package/dist/routers/settings-git-channel.mjs +77 -0
- package/dist/routers/settings-git-channel.mjs.map +1 -0
- package/dist/routers/settings-labels.mjs +27 -1
- package/dist/routers/settings-labels.mjs.map +1 -1
- package/dist/routers/settings-links.mjs +63 -0
- package/dist/routers/settings-links.mjs.map +1 -0
- package/dist/routers/settings-members.mjs +4 -7
- package/dist/routers/settings-members.mjs.map +1 -1
- package/dist/routers/settings-notifications.mjs +15 -10
- package/dist/routers/settings-notifications.mjs.map +1 -1
- package/dist/routers/settings-tokens.mjs +2 -5
- package/dist/routers/settings-tokens.mjs.map +1 -1
- package/dist/routers/settings-webhooks.mjs +7 -24
- package/dist/routers/settings-webhooks.mjs.map +1 -1
- package/dist/routers/settings.handlers.mjs +107 -26
- package/dist/routers/settings.handlers.mjs.map +1 -1
- package/dist/routers/settings.mjs +16 -4
- package/dist/routers/settings.mjs.map +1 -1
- package/dist/routers/signed-storybook.mjs +53 -0
- package/dist/routers/signed-storybook.mjs.map +1 -0
- package/dist/routers/snapshot-groups.mjs +63 -0
- package/dist/routers/snapshot-groups.mjs.map +1 -0
- package/dist/routers/snapshots.mjs +103 -5
- package/dist/routers/snapshots.mjs.map +1 -1
- package/dist/routers/storybook.mjs +37 -88
- package/dist/routers/storybook.mjs.map +1 -1
- package/dist/routers/tokens.mjs +2 -5
- package/dist/routers/tokens.mjs.map +1 -1
- package/dist/routers/ui.mjs +34 -5
- package/dist/routers/ui.mjs.map +1 -1
- package/dist/routers/webhooks.mjs +5 -11
- package/dist/routers/webhooks.mjs.map +1 -1
- package/dist/routers/well-known.mjs +11 -4
- package/dist/routers/well-known.mjs.map +1 -1
- package/dist/routes/api-routes.mjs +93 -0
- package/dist/routes/api-routes.mjs.map +1 -0
- package/dist/routes/classification.mjs +38 -0
- package/dist/routes/classification.mjs.map +1 -0
- package/dist/routes/openapi-classes.mjs +22 -0
- package/dist/routes/openapi-classes.mjs.map +1 -0
- package/dist/routes/page-routes.mjs +104 -0
- package/dist/routes/page-routes.mjs.map +1 -0
- package/dist/runtime.mjs +18 -13
- package/dist/runtime.mjs.map +1 -1
- package/dist/serverless.mjs +5 -0
- package/dist/store.mjs +19 -1
- package/dist/store.mjs.map +1 -1
- package/dist/targets.mjs +46 -0
- package/dist/targets.mjs.map +1 -0
- package/dist/ui/buttons.mjs +16 -3
- package/dist/ui/buttons.mjs.map +1 -1
- package/dist/ui/design-reference.mjs +97 -0
- package/dist/ui/design-reference.mjs.map +1 -0
- package/dist/ui/document.mjs +1 -2
- package/dist/ui/document.mjs.map +1 -1
- package/dist/ui/forms.mjs +6 -5
- package/dist/ui/forms.mjs.map +1 -1
- package/dist/ui/icons/paths.mjs +1 -0
- package/dist/ui/icons/paths.mjs.map +1 -1
- package/dist/ui/preview-embed.mjs +82 -0
- package/dist/ui/preview-embed.mjs.map +1 -0
- package/dist/ui/shell/client-script.mjs +85 -0
- package/dist/ui/shell/client-script.mjs.map +1 -1
- package/dist/ui/shell/nav.mjs +12 -8
- package/dist/ui/shell/nav.mjs.map +1 -1
- package/dist/ui/shell/review-script.mjs +8 -0
- package/dist/ui/shell/review-script.mjs.map +1 -1
- package/dist/ui/shell/sidebar.mjs +2 -2
- package/dist/ui/shell/sidebar.mjs.map +1 -1
- package/package.json +11 -6
- 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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { forbidden, notFound, unauthorized } from "./helpers.mjs";
|
|
6
|
+
import { flash } from "./flash.mjs";
|
|
7
|
+
import { connectionModel, findConnectable } from "../connections.mjs";
|
|
8
|
+
import { safeReturnPath, signState, verifyState } from "./oauth-state.mjs";
|
|
9
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
10
|
+
//#region src/routers/connections.ts
|
|
11
|
+
const COOKIE = "storyshelf_oauth_state";
|
|
12
|
+
const COOKIE_PATH = "/connections";
|
|
13
|
+
/**
|
|
14
|
+
* "Connect your account" for any service exposing OAuth (see `connectableServices`):
|
|
15
|
+
* start → consent at the service → callback stores the user's own connection.
|
|
16
|
+
* Services stay unaware of routes, state signing and storage.
|
|
17
|
+
*/
|
|
18
|
+
function registerConnections(app) {
|
|
19
|
+
app.get("/connections/:service/start", handleStart);
|
|
20
|
+
app.get("/connections/:service/callback", handleCallback);
|
|
21
|
+
app.post("/connections/:service/disconnect", handleDisconnect);
|
|
22
|
+
}
|
|
23
|
+
function serviceOf(c) {
|
|
24
|
+
const service = findConnectable(c.req.param("service") ?? "");
|
|
25
|
+
if (!service) notFound("Service does not support connecting accounts");
|
|
26
|
+
return service;
|
|
27
|
+
}
|
|
28
|
+
function sessionUser() {
|
|
29
|
+
const { user, authEnabled } = getStore();
|
|
30
|
+
if (!authEnabled) notFound("Connecting accounts requires authentication");
|
|
31
|
+
if (!user) unauthorized();
|
|
32
|
+
return user;
|
|
33
|
+
}
|
|
34
|
+
function signingSecret() {
|
|
35
|
+
const { secret } = getStore().config;
|
|
36
|
+
if (!secret) notFound("Connecting accounts requires a configured server secret");
|
|
37
|
+
return secret;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Callback URL of an interactive, request-bound flow (ADR 0007): the confirmed public origin wins,
|
|
41
|
+
* the request's own address is the fallback authentication is allowed.
|
|
42
|
+
*/
|
|
43
|
+
async function redirectUri(c, kind) {
|
|
44
|
+
const { publicOrigin, config } = getStore();
|
|
45
|
+
const requestOrigin = new URL(c.req.url).origin;
|
|
46
|
+
return `${(publicOrigin ? await publicOrigin.authOrigin(requestOrigin) : config.publicBaseUrl ?? requestOrigin).replace(/\/+$/u, "")}/connections/${kind}/callback`;
|
|
47
|
+
}
|
|
48
|
+
async function handleStart(c) {
|
|
49
|
+
const service = serviceOf(c);
|
|
50
|
+
const user = sessionUser();
|
|
51
|
+
const kind = service.metadata.kind;
|
|
52
|
+
const { state, nonce } = signState(signingSecret(), {
|
|
53
|
+
userId: user.id,
|
|
54
|
+
service: kind,
|
|
55
|
+
returnTo: safeReturnPath(c.req.query("return"), "/")
|
|
56
|
+
});
|
|
57
|
+
setCookie(c, COOKIE, nonce, {
|
|
58
|
+
path: COOKIE_PATH,
|
|
59
|
+
httpOnly: true,
|
|
60
|
+
sameSite: "Lax",
|
|
61
|
+
maxAge: 600,
|
|
62
|
+
secure: new URL(c.req.url).protocol === "https:"
|
|
63
|
+
});
|
|
64
|
+
return c.redirect(service.oauth.authorizeUrl({
|
|
65
|
+
state,
|
|
66
|
+
redirectUri: await redirectUri(c, kind)
|
|
67
|
+
}), 302);
|
|
68
|
+
}
|
|
69
|
+
async function handleCallback(c) {
|
|
70
|
+
const service = serviceOf(c);
|
|
71
|
+
const user = sessionUser();
|
|
72
|
+
const verified = verifiedState(c, service.metadata.kind, user.id);
|
|
73
|
+
const code = c.req.query("code");
|
|
74
|
+
if (!code || c.req.query("error")) flash(c, `${service.metadata.name} connection was cancelled`, "warning");
|
|
75
|
+
else await completeConnect(c, service, user.id, code);
|
|
76
|
+
return c.redirect(verified.returnTo, 302);
|
|
77
|
+
}
|
|
78
|
+
/** Check the callback's signed state against the service, the signed-in user and this browser's cookie. */
|
|
79
|
+
function verifiedState(c, kind, userId) {
|
|
80
|
+
const verified = verifyState(signingSecret(), c.req.query("state") ?? "");
|
|
81
|
+
const nonce = getCookie(c, COOKIE);
|
|
82
|
+
deleteCookie(c, COOKIE, { path: COOKIE_PATH });
|
|
83
|
+
if (!verified || verified.service !== kind || verified.userId !== userId) forbidden();
|
|
84
|
+
if (verified.nonce !== nonce) forbidden();
|
|
85
|
+
return verified;
|
|
86
|
+
}
|
|
87
|
+
async function completeConnect(c, service, userId, code) {
|
|
88
|
+
const kind = service.metadata.kind;
|
|
89
|
+
try {
|
|
90
|
+
const tokens = await service.oauth.exchangeCode({
|
|
91
|
+
code,
|
|
92
|
+
redirectUri: await redirectUri(c, kind)
|
|
93
|
+
});
|
|
94
|
+
await connectionModel().connect(userId, kind, tokens);
|
|
95
|
+
flash(c, `${service.metadata.name} connected`);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
getStore().logger.warn({
|
|
98
|
+
err: error,
|
|
99
|
+
service: kind
|
|
100
|
+
}, "oauth connect failed");
|
|
101
|
+
flash(c, `Could not connect ${service.metadata.name}`, "danger");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function handleDisconnect(c) {
|
|
105
|
+
const service = serviceOf(c);
|
|
106
|
+
const user = sessionUser();
|
|
107
|
+
const form = await c.req.formData();
|
|
108
|
+
await connectionModel().remove(user.id, service.metadata.kind);
|
|
109
|
+
flash(c, `${service.metadata.name} disconnected`);
|
|
110
|
+
return c.redirect(safeReturnPath(formString(form.get("return")), "/"), 303);
|
|
111
|
+
}
|
|
112
|
+
function formString(value) {
|
|
113
|
+
return typeof value === "string" ? value : "";
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
export { registerConnections };
|
|
117
|
+
|
|
118
|
+
//# sourceMappingURL=connections.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connections.mjs","names":[],"sources":["../../src/routers/connections.ts"],"sourcesContent":["import type { Context } from \"hono\";\nimport { deleteCookie, getCookie, setCookie } from \"hono/cookie\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { connectionModel, findConnectable, type Connectable } from \"../connections.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { forbidden, notFound, unauthorized } from \"./helpers.ts\";\nimport { safeReturnPath, signState, verifyState, type OAuthState } from \"./oauth-state.ts\";\n\nconst COOKIE = \"storyshelf_oauth_state\";\nconst COOKIE_PATH = \"/connections\";\n\n/**\n * \"Connect your account\" for any service exposing OAuth (see `connectableServices`):\n * start → consent at the service → callback stores the user's own connection.\n * Services stay unaware of routes, state signing and storage.\n */\nexport function registerConnections(app: ShelfRouter): void {\n app.get(\"/connections/:service/start\", handleStart);\n app.get(\"/connections/:service/callback\", handleCallback);\n app.post(\"/connections/:service/disconnect\", handleDisconnect);\n}\n\nfunction serviceOf(c: Context): Connectable {\n const service = findConnectable(c.req.param(\"service\") ?? \"\");\n if (!service) notFound(\"Service does not support connecting accounts\");\n return service;\n}\n\nfunction sessionUser(): { id: string } {\n const { user, authEnabled } = getStore();\n if (!authEnabled) notFound(\"Connecting accounts requires authentication\");\n if (!user) unauthorized();\n return user;\n}\n\nfunction signingSecret(): string {\n const { secret } = getStore().config;\n if (!secret) notFound(\"Connecting accounts requires a configured server secret\");\n return secret;\n}\n\n/**\n * Callback URL of an interactive, request-bound flow (ADR 0007): the confirmed public origin wins,\n * the request's own address is the fallback authentication is allowed.\n */\nasync function redirectUri(c: Context, kind: string): Promise<string> {\n const { publicOrigin, config } = getStore();\n const requestOrigin = new URL(c.req.url).origin;\n const base = publicOrigin\n ? await publicOrigin.authOrigin(requestOrigin)\n : (config.publicBaseUrl ?? requestOrigin);\n return `${base.replace(/\\/+$/u, \"\")}/connections/${kind}/callback`;\n}\n\nasync function handleStart(c: Context): Promise<Response> {\n const service = serviceOf(c);\n const user = sessionUser();\n const kind = service.metadata.kind;\n const { state, nonce } = signState(signingSecret(), {\n userId: user.id,\n service: kind,\n returnTo: safeReturnPath(c.req.query(\"return\"), \"/\"),\n });\n setCookie(c, COOKIE, nonce, {\n path: COOKIE_PATH,\n httpOnly: true,\n sameSite: \"Lax\",\n maxAge: 600,\n secure: new URL(c.req.url).protocol === \"https:\",\n });\n return c.redirect(\n service.oauth.authorizeUrl({ state, redirectUri: await redirectUri(c, kind) }),\n 302,\n );\n}\n\nasync function handleCallback(c: Context): Promise<Response> {\n const service = serviceOf(c);\n const user = sessionUser();\n const verified = verifiedState(c, service.metadata.kind, user.id);\n const code = c.req.query(\"code\");\n if (!code || c.req.query(\"error\")) {\n flash(c, `${service.metadata.name} connection was cancelled`, \"warning\");\n } else {\n await completeConnect(c, service, user.id, code);\n }\n return c.redirect(verified.returnTo, 302);\n}\n\n/** Check the callback's signed state against the service, the signed-in user and this browser's cookie. */\nfunction verifiedState(c: Context, kind: string, userId: string): OAuthState {\n const verified = verifyState(signingSecret(), c.req.query(\"state\") ?? \"\");\n const nonce = getCookie(c, COOKIE);\n deleteCookie(c, COOKIE, { path: COOKIE_PATH });\n if (!verified || verified.service !== kind || verified.userId !== userId) forbidden();\n if (verified.nonce !== nonce) forbidden();\n return verified;\n}\n\nasync function completeConnect(\n c: Context,\n service: Connectable,\n userId: string,\n code: string,\n): Promise<void> {\n const kind = service.metadata.kind;\n try {\n const tokens = await service.oauth.exchangeCode({\n code,\n redirectUri: await redirectUri(c, kind),\n });\n await connectionModel().connect(userId, kind, tokens);\n flash(c, `${service.metadata.name} connected`);\n } catch (error) {\n getStore().logger.warn({ err: error, service: kind }, \"oauth connect failed\");\n flash(c, `Could not connect ${service.metadata.name}`, \"danger\");\n }\n}\n\nasync function handleDisconnect(c: Context): Promise<Response> {\n const service = serviceOf(c);\n const user = sessionUser();\n const form = await c.req.formData();\n await connectionModel().remove(user.id, service.metadata.kind);\n flash(c, `${service.metadata.name} disconnected`);\n return c.redirect(safeReturnPath(formString(form.get(\"return\")), \"/\"), 303);\n}\n\nfunction formString(value: FormDataEntryValue | null): string {\n return typeof value === \"string\" ? value : \"\";\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { credentialKeys } from "../credential-keys.mjs";
|
|
5
|
+
import { getStore } from "../store.mjs";
|
|
6
|
+
import { canConnect, connectionModel } from "../connections.mjs";
|
|
7
|
+
import { DesignCredentialModel } from "@storyshelf/core/models";
|
|
8
|
+
//#region src/routers/design-connect.handlers.ts
|
|
9
|
+
/** The signed-in user whose own connection may serve previews (none when auth is off). */
|
|
10
|
+
function viewerIdOf() {
|
|
11
|
+
const { user, authEnabled } = getStore();
|
|
12
|
+
return authEnabled ? user?.id : void 0;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Per provider kind, whether to offer "Connect your account": only when the operator
|
|
16
|
+
* enabled OAuth, the viewer is signed in, and the project has no shared credential.
|
|
17
|
+
*/
|
|
18
|
+
async function loadConnectStates(projectId, kinds, returnTo) {
|
|
19
|
+
const viewerId = viewerIdOf();
|
|
20
|
+
if (!viewerId || !canConnect()) return /* @__PURE__ */ new Map();
|
|
21
|
+
const { db, config, design } = getStore();
|
|
22
|
+
const credentials = new DesignCredentialModel(db, credentialKeys(config));
|
|
23
|
+
const candidates = design.filter((p) => p.oauth && kinds.includes(p.metadata.kind));
|
|
24
|
+
const states = await Promise.all(candidates.map(async (provider) => {
|
|
25
|
+
const kind = provider.metadata.kind;
|
|
26
|
+
if (await credentials.info(projectId, kind)) return null;
|
|
27
|
+
return [kind, connectOffer(kind, await connectionModel().info(viewerId, kind), returnTo)];
|
|
28
|
+
}));
|
|
29
|
+
return new Map(states.filter((entry) => entry !== null));
|
|
30
|
+
}
|
|
31
|
+
function connectOffer(kind, row, returnTo) {
|
|
32
|
+
const base = `/connections/${kind}`;
|
|
33
|
+
const expired = row?.status === "expired";
|
|
34
|
+
return {
|
|
35
|
+
state: row ? expired ? "reconnect" : "connected" : "connect",
|
|
36
|
+
startUrl: `${base}/start?return=${encodeURIComponent(returnTo)}`,
|
|
37
|
+
disconnectUrl: `${base}/disconnect`,
|
|
38
|
+
returnTo
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
export { loadConnectStates, viewerIdOf };
|
|
43
|
+
|
|
44
|
+
//# sourceMappingURL=design-connect.handlers.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"design-connect.handlers.mjs","names":[],"sources":["../../src/routers/design-connect.handlers.ts"],"sourcesContent":["import { DesignCredentialModel } from \"@storyshelf/core/models\";\nimport type { UserConnection } from \"@storyshelf/core/schema\";\nimport { canConnect, connectionModel } from \"../connections.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport type { DesignConnect } from \"../pages/design-links.tsx\";\nimport { getStore } from \"../store.ts\";\n\n/** The signed-in user whose own connection may serve previews (none when auth is off). */\nexport function viewerIdOf(): string | undefined {\n const { user, authEnabled } = getStore();\n return authEnabled ? user?.id : undefined;\n}\n\n/**\n * Per provider kind, whether to offer \"Connect your account\": only when the operator\n * enabled OAuth, the viewer is signed in, and the project has no shared credential.\n */\nexport async function loadConnectStates(\n projectId: string,\n kinds: readonly string[],\n returnTo: string,\n): Promise<Map<string, DesignConnect>> {\n const viewerId = viewerIdOf();\n if (!viewerId || !canConnect()) return new Map();\n const { db, config, design } = getStore();\n const credentials = new DesignCredentialModel(db, credentialKeys(config));\n const candidates = design.filter((p) => p.oauth && kinds.includes(p.metadata.kind));\n const states = await Promise.all(\n candidates.map(async (provider) => {\n const kind = provider.metadata.kind;\n const shared = await credentials.info(projectId, kind);\n if (shared) return null;\n return [\n kind,\n connectOffer(kind, await connectionModel().info(viewerId, kind), returnTo),\n ] as const;\n }),\n );\n return new Map(states.filter((entry) => entry !== null));\n}\n\nfunction connectOffer(kind: string, row: UserConnection | null, returnTo: string): DesignConnect {\n const base = `/connections/${kind}`;\n const expired = row?.status === \"expired\";\n return {\n state: row ? (expired ? \"reconnect\" : \"connected\") : \"connect\",\n startUrl: `${base}/start?return=${encodeURIComponent(returnTo)}`,\n disconnectUrl: `${base}/disconnect`,\n returnTo,\n };\n}\n"],"mappings":";;;;;;;;;AAQA,SAAgB,aAAiC;CAC/C,MAAM,EAAE,MAAM,gBAAgB,SAAS;CACvC,OAAO,cAAc,MAAM,KAAK,KAAA;AAClC;;;;;AAMA,eAAsB,kBACpB,WACA,OACA,UACqC;CACrC,MAAM,WAAW,WAAW;CAC5B,IAAI,CAAC,YAAY,CAAC,WAAW,GAAG,uBAAO,IAAI,IAAI;CAC/C,MAAM,EAAE,IAAI,QAAQ,WAAW,SAAS;CACxC,MAAM,cAAc,IAAI,sBAAsB,IAAI,eAAe,MAAM,CAAC;CACxE,MAAM,aAAa,OAAO,QAAQ,MAAM,EAAE,SAAS,MAAM,SAAS,EAAE,SAAS,IAAI,CAAC;CAClF,MAAM,SAAS,MAAM,QAAQ,IAC3B,WAAW,IAAI,OAAO,aAAa;EACjC,MAAM,OAAO,SAAS,SAAS;EAE/B,IAAI,MADiB,YAAY,KAAK,WAAW,IAAI,GACzC,OAAO;EACnB,OAAO,CACL,MACA,aAAa,MAAM,MAAM,gBAAgB,CAAC,CAAC,KAAK,UAAU,IAAI,GAAG,QAAQ,CAC3E;CACF,CAAC,CACH;CACA,OAAO,IAAI,IAAI,OAAO,QAAQ,UAAU,UAAU,IAAI,CAAC;AACzD;AAEA,SAAS,aAAa,MAAc,KAA4B,UAAiC;CAC/F,MAAM,OAAO,gBAAgB;CAC7B,MAAM,UAAU,KAAK,WAAW;CAChC,OAAO;EACL,OAAO,MAAO,UAAU,cAAc,cAAe;EACrD,UAAU,GAAG,KAAK,gBAAgB,mBAAmB,QAAQ;EAC7D,eAAe,GAAG,KAAK;EACvB;CACF;AACF"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { credentialKeys } from "../credential-keys.mjs";
|
|
5
|
+
import { getStore } from "../store.mjs";
|
|
6
|
+
import { connectionModel } from "../connections.mjs";
|
|
7
|
+
import { loadConnectStates, viewerIdOf } from "./design-connect.handlers.mjs";
|
|
8
|
+
import { DesignCredentialModel, DesignModel, DesignNodeModel, isDesignStale, normalizeImportPath, resolveDesigns } from "@storyshelf/core/models";
|
|
9
|
+
import { parseDesignUrl } from "@storyshelf/core/adapter/design";
|
|
10
|
+
//#region src/routers/design.handlers.ts
|
|
11
|
+
/** Most a page render waits for fresh design metadata; slower fetches finish into the cache for next time. */
|
|
12
|
+
const REFRESH_BUDGET_MS = 1500;
|
|
13
|
+
/** Path + query of the current request, to return to after a connect round trip. */
|
|
14
|
+
function returnPathOf(c) {
|
|
15
|
+
const url = new URL(c.req.url);
|
|
16
|
+
return `${url.pathname}${url.search}`;
|
|
17
|
+
}
|
|
18
|
+
/** Component (story file) → design link(s) for a build's snapshots; files without a link are absent. */
|
|
19
|
+
async function loadDesignResolutions(project, snapshots) {
|
|
20
|
+
const { db, design } = getStore();
|
|
21
|
+
if (design.length === 0) return /* @__PURE__ */ new Map();
|
|
22
|
+
const model = new DesignModel(db, design);
|
|
23
|
+
return resolveDesigns(await model.componentLinks(project.id), snapshots);
|
|
24
|
+
}
|
|
25
|
+
/** Design URLs of one snapshot's component. */
|
|
26
|
+
function designUrlsFor(resolutions, snapshot) {
|
|
27
|
+
const key = snapshot.storyImportPath ? normalizeImportPath(snapshot.storyImportPath) : "";
|
|
28
|
+
return resolutions.get(key)?.urls ?? [];
|
|
29
|
+
}
|
|
30
|
+
/** Project-level design file links for the library header. */
|
|
31
|
+
async function loadProjectDesignLinks(project) {
|
|
32
|
+
const { db, design } = getStore();
|
|
33
|
+
if (design.length === 0) return [];
|
|
34
|
+
return (await new DesignModel(db, design).projectLinks(project.id)).map((link) => ({
|
|
35
|
+
label: link.label ?? "Design",
|
|
36
|
+
url: link.url
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Design items for the review page: each link with cached name/state and a
|
|
41
|
+
* stale flag against the snapshot's baseline. Refreshes expired metadata
|
|
42
|
+
* within a short budget; failures degrade to plain deep links, never blocking review.
|
|
43
|
+
*/
|
|
44
|
+
async function loadReviewDesign(project, snapshot, urls, returnTo = "/") {
|
|
45
|
+
if (urls.length === 0) return [];
|
|
46
|
+
const nodes = nodeModel();
|
|
47
|
+
const viewerId = viewerIdOf();
|
|
48
|
+
await refreshWithinBudget(nodes, project.id, urls, viewerId);
|
|
49
|
+
const cached = await nodes.cachedFor(project.id, urls, viewerId).catch(() => /* @__PURE__ */ new Map());
|
|
50
|
+
const connects = await loadConnectStates(project.id, urls.map((url) => kindOf(url)), returnTo).catch(() => /* @__PURE__ */ new Map());
|
|
51
|
+
return urls.map((url) => {
|
|
52
|
+
const node = cached.get(url);
|
|
53
|
+
const connect = connects.get(kindOf(url));
|
|
54
|
+
return {
|
|
55
|
+
...connect ? { connect } : {},
|
|
56
|
+
url,
|
|
57
|
+
name: node?.name ?? null,
|
|
58
|
+
providerName: providerNameFor(url),
|
|
59
|
+
renderUrl: renderUrlOf(project, node),
|
|
60
|
+
state: node?.state ?? "unknown",
|
|
61
|
+
stale: isDesignStale(node, snapshot.baselineVersion)
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** Most distinct design links a library render refreshes (the model bounds file concurrency). */
|
|
66
|
+
const WARM_LIMIT = 10;
|
|
67
|
+
/**
|
|
68
|
+
* Cached name/render per design URL for library popovers. Missing or expired
|
|
69
|
+
* entries are refreshed first, within the page-render budget and with bounded
|
|
70
|
+
* concurrency, so a slow design API never holds the library up or fans out unbounded.
|
|
71
|
+
*/
|
|
72
|
+
async function loadDesignPreviews(project, urls) {
|
|
73
|
+
const distinct = [...new Set(urls)];
|
|
74
|
+
if (distinct.length === 0) return /* @__PURE__ */ new Map();
|
|
75
|
+
const nodes = nodeModel();
|
|
76
|
+
const viewerId = viewerIdOf();
|
|
77
|
+
await refreshWithinBudget(nodes, project.id, distinct.slice(0, WARM_LIMIT), viewerId);
|
|
78
|
+
const cached = await nodes.cachedFor(project.id, distinct, viewerId).catch(() => /* @__PURE__ */ new Map());
|
|
79
|
+
return new Map([...cached].map(([url, node]) => [url, {
|
|
80
|
+
name: node.name,
|
|
81
|
+
renderUrl: renderUrlOf(project, node)
|
|
82
|
+
}]));
|
|
83
|
+
}
|
|
84
|
+
function nodeModel() {
|
|
85
|
+
const { db, config, logger, storage, design } = getStore();
|
|
86
|
+
const credentials = new DesignCredentialModel(db, credentialKeys(config));
|
|
87
|
+
return new DesignNodeModel(db, credentials, design, {
|
|
88
|
+
logger,
|
|
89
|
+
storage,
|
|
90
|
+
connections: connectionModel()
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function kindOf(url) {
|
|
94
|
+
return parseDesignUrl(getStore().design, url)?.provider.metadata.kind ?? "";
|
|
95
|
+
}
|
|
96
|
+
/** Display name of the provider that owns a design link ("Figma"), or a generic fallback. */
|
|
97
|
+
function providerNameFor(url) {
|
|
98
|
+
return parseDesignUrl(getStore().design, url)?.provider.metadata.name ?? "Design";
|
|
99
|
+
}
|
|
100
|
+
/** API URL of a node's cached render (version-busted), or null when none is cached. */
|
|
101
|
+
function renderUrlOf(project, node) {
|
|
102
|
+
return node?.renderPath ? `/api/v1/projects/${project.slug}/design/renders/${node.id}?v=${encodeURIComponent(node.renderVersion ?? "")}` : null;
|
|
103
|
+
}
|
|
104
|
+
/** Refresh the links, aborting every provider call once the budget is spent (the cache keeps what landed). */
|
|
105
|
+
async function refreshWithinBudget(nodes, projectId, urls, viewerId) {
|
|
106
|
+
await nodes.refreshMany(projectId, urls, {
|
|
107
|
+
signal: AbortSignal.timeout(REFRESH_BUDGET_MS),
|
|
108
|
+
...viewerId ? { viewerId } : {}
|
|
109
|
+
}).catch(() => /* @__PURE__ */ new Map());
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
export { designUrlsFor, loadDesignPreviews, loadDesignResolutions, loadProjectDesignLinks, loadReviewDesign, providerNameFor, returnPathOf };
|
|
113
|
+
|
|
114
|
+
//# sourceMappingURL=design.handlers.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"design.handlers.mjs","names":[],"sources":["../../src/routers/design.handlers.ts"],"sourcesContent":["import { parseDesignUrl } from \"@storyshelf/core/adapter/design\";\nimport {\n DesignCredentialModel,\n DesignModel,\n DesignNodeModel,\n isDesignStale,\n resolveDesigns,\n normalizeImportPath,\n type DesignResolution,\n} from \"@storyshelf/core/models\";\nimport type { DesignNode, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport { connectionModel } from \"../connections.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport type {\n DesignConnect,\n DesignItem,\n DesignPreviewInfo,\n ProjectDesignLink,\n} from \"../pages/design-links.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { loadConnectStates, viewerIdOf } from \"./design-connect.handlers.ts\";\n\n/** Most a page render waits for fresh design metadata; slower fetches finish into the cache for next time. */\nconst REFRESH_BUDGET_MS = 1500;\n\n/** Path + query of the current request, to return to after a connect round trip. */\nexport function returnPathOf(c: { req: { url: string } }): string {\n const url = new URL(c.req.url);\n return `${url.pathname}${url.search}`;\n}\n\n/** Component (story file) → design link(s) for a build's snapshots; files without a link are absent. */\nexport async function loadDesignResolutions(\n project: Project,\n snapshots: readonly Snapshot[],\n): Promise<Map<string, DesignResolution>> {\n const { db, design } = getStore();\n if (design.length === 0) return new Map();\n const model = new DesignModel(db, design);\n return resolveDesigns(await model.componentLinks(project.id), snapshots);\n}\n\n/** Design URLs of one snapshot's component. */\nexport function designUrlsFor(\n resolutions: Map<string, DesignResolution>,\n snapshot: Pick<Snapshot, \"storyImportPath\">,\n): string[] {\n const key = snapshot.storyImportPath ? normalizeImportPath(snapshot.storyImportPath) : \"\";\n return resolutions.get(key)?.urls ?? [];\n}\n\n/** Project-level design file links for the library header. */\nexport async function loadProjectDesignLinks(project: Project): Promise<ProjectDesignLink[]> {\n const { db, design } = getStore();\n if (design.length === 0) return [];\n const links = await new DesignModel(db, design).projectLinks(project.id);\n return links.map((link) => ({ label: link.label ?? \"Design\", url: link.url }));\n}\n\n/**\n * Design items for the review page: each link with cached name/state and a\n * stale flag against the snapshot's baseline. Refreshes expired metadata\n * within a short budget; failures degrade to plain deep links, never blocking review.\n */\nexport async function loadReviewDesign(\n project: Project,\n snapshot: Snapshot,\n urls: readonly string[],\n returnTo = \"/\",\n): Promise<DesignItem[]> {\n if (urls.length === 0) return [];\n const nodes = nodeModel();\n const viewerId = viewerIdOf();\n await refreshWithinBudget(nodes, project.id, urls, viewerId);\n const cached = await nodes\n .cachedFor(project.id, urls, viewerId)\n .catch(() => new Map<string, DesignNode>());\n const connects = await loadConnectStates(\n project.id,\n urls.map((url) => kindOf(url)),\n returnTo,\n ).catch(() => new Map<string, DesignConnect>());\n return urls.map((url) => {\n const node = cached.get(url);\n const connect = connects.get(kindOf(url));\n return {\n ...(connect ? { connect } : {}),\n url,\n name: node?.name ?? null,\n providerName: providerNameFor(url),\n renderUrl: renderUrlOf(project, node),\n state: node?.state ?? \"unknown\",\n stale: isDesignStale(node, snapshot.baselineVersion),\n };\n });\n}\n\n/** Most distinct design links a library render refreshes (the model bounds file concurrency). */\nconst WARM_LIMIT = 10;\n\n/**\n * Cached name/render per design URL for library popovers. Missing or expired\n * entries are refreshed first, within the page-render budget and with bounded\n * concurrency, so a slow design API never holds the library up or fans out unbounded.\n */\nexport async function loadDesignPreviews(\n project: Project,\n urls: readonly string[],\n): Promise<Map<string, DesignPreviewInfo>> {\n const distinct = [...new Set(urls)];\n if (distinct.length === 0) return new Map();\n const nodes = nodeModel();\n const viewerId = viewerIdOf();\n await refreshWithinBudget(nodes, project.id, distinct.slice(0, WARM_LIMIT), viewerId);\n const cached = await nodes\n .cachedFor(project.id, distinct, viewerId)\n .catch(() => new Map<string, DesignNode>());\n return new Map(\n [...cached].map(([url, node]) => [\n url,\n { name: node.name, renderUrl: renderUrlOf(project, node) },\n ]),\n );\n}\n\nfunction nodeModel(): DesignNodeModel {\n const { db, config, logger, storage, design } = getStore();\n const credentials = new DesignCredentialModel(db, credentialKeys(config));\n return new DesignNodeModel(db, credentials, design, {\n logger,\n storage,\n connections: connectionModel(),\n });\n}\n\nfunction kindOf(url: string): string {\n return parseDesignUrl(getStore().design, url)?.provider.metadata.kind ?? \"\";\n}\n\n/** Display name of the provider that owns a design link (\"Figma\"), or a generic fallback. */\nexport function providerNameFor(url: string): string {\n return parseDesignUrl(getStore().design, url)?.provider.metadata.name ?? \"Design\";\n}\n\n/** API URL of a node's cached render (version-busted), or null when none is cached. */\nfunction renderUrlOf(project: Project, node: DesignNode | undefined): string | null {\n return node?.renderPath\n ? `/api/v1/projects/${project.slug}/design/renders/${node.id}?v=${encodeURIComponent(node.renderVersion ?? \"\")}`\n : null;\n}\n\n/** Refresh the links, aborting every provider call once the budget is spent (the cache keeps what landed). */\nasync function refreshWithinBudget(\n nodes: DesignNodeModel,\n projectId: string,\n urls: readonly string[],\n viewerId?: string,\n): Promise<void> {\n await nodes\n .refreshMany(projectId, urls, {\n signal: AbortSignal.timeout(REFRESH_BUDGET_MS),\n ...(viewerId ? { viewerId } : {}),\n })\n .catch(() => new Map());\n}\n"],"mappings":"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"}
|
|
@@ -16,10 +16,9 @@ function targetsOf(sources) {
|
|
|
16
16
|
pushAll(targets, [
|
|
17
17
|
sources.database,
|
|
18
18
|
sources.storage,
|
|
19
|
-
sources.captureRunner
|
|
20
|
-
sources.captureQueue
|
|
19
|
+
sources.captureRunner
|
|
21
20
|
]);
|
|
22
|
-
pushAll(targets, sources.
|
|
21
|
+
pushAll(targets, sources.design ?? []);
|
|
23
22
|
pushAll(targets, sources.notifiers ?? []);
|
|
24
23
|
pushTarget(targets, sources.emailSender);
|
|
25
24
|
return targets;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"health-report.mjs","names":[],"sources":["../../src/routers/health-report.ts"],"sourcesContent":["import type {\n AdapterHealth,\n AdapterLifecycle,\n AdapterMetadata,\n} from \"@storyshelf/core/adapter/metadata\";\n/**\n * Shared adapter health probing for the JSON API and the admin System page.\n *\n * `POST /api/v1/health` and `GET /admin` both consume\n * {@link collectHealthReport}; the API strips the page-only metadata fields\n * so its response shape never changes. Probes stay parallel, time-bounded,\n * and secret-free (details are truncated, never raw config).\n */\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { CredentialProbe } from \"@storyshelf/core/models\";\nimport { sanitizeErrorText } from \"@storyshelf/core/utils\";\n\nexport type AdapterState = \"ok\" | \"starting\" | \"failed\";\n\n/** Per-adapter readiness entry with identity for the admin page. */\nexport interface AdapterReportFull {\n category: string;\n kind: string;\n name: string;\n version: string;\n description?: string;\n hasLifecycle: boolean;\n state: AdapterState;\n latencyMs?: number;\n detail?: string;\n}\n\n/** Collected health report: overall status plus one entry per adapter. */\nexport interface HealthReport {\n status: \"ok\" | \"starting\" | \"degraded\";\n uptimeSecs: number;\n version: string;\n adapters: AdapterReportFull[];\n}\n\ninterface ProbeTarget {\n readonly metadata: AdapterMetadata;\n readonly lifecycle?: AdapterLifecycle;\n}\n\nconst PROBE_TIMEOUT_MS = 2000;\nconst DETAIL_MAX_LENGTH = 500;\n\nfunction pushTarget(targets: ProbeTarget[], adapter: ProbeTarget | undefined): void {\n if (adapter) {\n targets.push(adapter);\n }\n}\n\nfunction pushAll(targets: ProbeTarget[], adapters: ReadonlyArray<ProbeTarget | undefined>): void {\n for (const adapter of adapters) {\n pushTarget(targets, adapter);\n }\n}\n\nfunction targetsOf(sources: AdapterSetupSources): ProbeTarget[] {\n const targets: ProbeTarget[] = [];\n pushAll(targets, [\n sources.database,\n sources.storage,\n sources.captureRunner,\n sources.captureQueue,\n ]);\n pushAll(targets, sources.gitHosts ?? []);\n pushAll(targets, sources.notifiers ?? []);\n pushTarget(targets, sources.emailSender);\n return targets;\n}\n\nfunction sanitize(detail: string): string {\n return sanitizeErrorText(detail, DETAIL_MAX_LENGTH);\n}\n\nfunction messageOf(reason: unknown): string {\n const message = reason instanceof Error ? reason.message : String(reason);\n return sanitize(message);\n}\n\nasync function timeoutError(): Promise<never> {\n await new Promise<void>((resolve) => {\n setTimeout(resolve, PROBE_TIMEOUT_MS);\n });\n throw new Error(\"health probe timed out\");\n}\n\nfunction baseReport(\n target: ProbeTarget,\n): Omit<AdapterReportFull, \"state\" | \"latencyMs\" | \"detail\"> {\n const { category, kind, name, version, description } = target.metadata;\n return {\n category,\n kind,\n name,\n version,\n ...(description === undefined ? {} : { description }),\n hasLifecycle: target.lifecycle?.health !== undefined,\n };\n}\n\nfunction okReport(\n target: ProbeTarget,\n latencyMs: number,\n detail: string | undefined,\n): AdapterReportFull {\n return {\n ...baseReport(target),\n state: \"ok\",\n latencyMs,\n ...(detail === undefined ? {} : { detail }),\n };\n}\n\nfunction failedReport(target: ProbeTarget, latencyMs: number, detail: string): AdapterReportFull {\n return { ...baseReport(target), state: \"failed\", latencyMs, detail };\n}\n\nfunction setupFailedReport(target: ProbeTarget, detail: string): AdapterReportFull {\n return { ...baseReport(target), state: \"failed\", detail };\n}\n\nfunction startingReport(target: ProbeTarget): AdapterReportFull {\n return { ...baseReport(target), state: \"starting\" };\n}\n\nfunction noLifecycleReport(target: ProbeTarget): AdapterReportFull {\n return { ...baseReport(target), state: \"ok\" };\n}\n\nfunction detailOf(result: AdapterHealth, fallback: string): string {\n return result.detail === undefined ? fallback : sanitize(result.detail);\n}\n\nasync function raceProbe(\n target: ProbeTarget,\n probe: () => Promise<AdapterHealth>,\n started: number,\n): Promise<AdapterReportFull> {\n try {\n const result = await Promise.race([probe(), timeoutError()]);\n const latencyMs = Date.now() - started;\n if (!result.ok) {\n return failedReport(target, latencyMs, detailOf(result, \"probe reported failure\"));\n }\n return okReport(\n target,\n latencyMs,\n result.detail === undefined ? undefined : sanitize(result.detail),\n );\n } catch (error) {\n return failedReport(target, Date.now() - started, messageOf(error));\n }\n}\n\nasync function probeHealth(target: ProbeTarget): Promise<AdapterReportFull> {\n const probe = target.lifecycle?.health;\n if (!probe) {\n return noLifecycleReport(target);\n }\n return await raceProbe(target, probe, Date.now());\n}\n\nasync function probeTarget(\n target: ProbeTarget,\n setupErrors: ReadonlyMap<string, string>,\n starting: boolean,\n): Promise<AdapterReportFull> {\n const setupError = setupErrors.get(`${target.metadata.category}/${target.metadata.kind}`);\n if (setupError !== undefined) {\n return setupFailedReport(target, sanitize(setupError));\n }\n if (starting) {\n return startingReport(target);\n }\n return await probeHealth(target);\n}\n\nfunction collectSetupErrors(settled: AdapterSetupResult | null): ReadonlyMap<string, string> {\n const setupErrors = new Map<string, string>();\n if (settled) {\n for (const failure of settled.failures) {\n setupErrors.set(`${failure.category}/${failure.kind}`, failure.error);\n }\n }\n return setupErrors;\n}\n\nfunction overallStatus(reports: AdapterReportFull[]): HealthReport[\"status\"] {\n if (reports.some((report) => report.state === \"failed\")) {\n return \"degraded\";\n }\n if (reports.some((report) => report.state === \"starting\")) {\n return \"starting\";\n }\n return \"ok\";\n}\n\n/** Pseudo-adapter entry reporting whether stored credentials decrypt with the configured secret. */\nfunction credentialsEntry(probe: CredentialProbe): AdapterReportFull {\n const unreadable = probe.unreadable.length;\n let detail: string | undefined;\n if (unreadable > 0) {\n detail = `${unreadable} stored credential(s) cannot be decrypted; check SECRET and SECRET_PREVIOUS`;\n } else if (probe.previous > 0) {\n detail = `${probe.previous} credential(s) still encrypted with the previous secret; re-encrypt from the System page`;\n }\n return {\n category: \"credentials\",\n kind: \"secret\",\n name: \"Credential encryption\",\n version: \"-\",\n hasLifecycle: false,\n state: unreadable > 0 ? \"failed\" : \"ok\",\n ...(detail === undefined ? {} : { detail }),\n };\n}\n\n/**\n * Probe every wired adapter and assemble the full health report.\n *\n * @param sources - Live adapter instances to probe.\n * @param settled - Setup settlement (null while the eager run is in flight).\n * @param bootTimeMs - Process boot timestamp for uptime.\n * @param version - Server version for the report header.\n * @param credentials - Stored-credential probe (omit when no secret is configured).\n */\nexport async function collectHealthReport(\n sources: AdapterSetupSources,\n settled: AdapterSetupResult | null,\n bootTimeMs: number,\n version: string,\n credentials?: CredentialProbe | null,\n): Promise<HealthReport> {\n const setupErrors = collectSetupErrors(settled);\n const targets = targetsOf(sources);\n const adapters = await Promise.all(\n targets.map(async (target) => await probeTarget(target, setupErrors, settled === null)),\n );\n if (credentials) {\n adapters.push(credentialsEntry(credentials));\n }\n return {\n status: overallStatus(adapters),\n uptimeSecs: Math.floor((Date.now() - bootTimeMs) / 1000),\n version,\n adapters,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"health-report.mjs","names":[],"sources":["../../src/routers/health-report.ts"],"sourcesContent":["import type {\n AdapterHealth,\n AdapterLifecycle,\n AdapterMetadata,\n} from \"@storyshelf/core/adapter/metadata\";\n/**\n * Shared adapter health probing for the JSON API and the admin System page.\n *\n * `POST /api/v1/health` and `GET /admin` both consume\n * {@link collectHealthReport}; the API strips the page-only metadata fields\n * so its response shape never changes. Probes stay parallel, time-bounded,\n * and secret-free (details are truncated, never raw config).\n */\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { CredentialProbe } from \"@storyshelf/core/models\";\nimport { sanitizeErrorText } from \"@storyshelf/core/utils\";\n\nexport type AdapterState = \"ok\" | \"starting\" | \"failed\";\n\n/** Per-adapter readiness entry with identity for the admin page. */\nexport interface AdapterReportFull {\n category: string;\n kind: string;\n name: string;\n version: string;\n description?: string;\n hasLifecycle: boolean;\n state: AdapterState;\n latencyMs?: number;\n detail?: string;\n}\n\n/** Collected health report: overall status plus one entry per adapter. */\nexport interface HealthReport {\n status: \"ok\" | \"starting\" | \"degraded\";\n uptimeSecs: number;\n version: string;\n adapters: AdapterReportFull[];\n}\n\ninterface ProbeTarget {\n readonly metadata: AdapterMetadata;\n readonly lifecycle?: AdapterLifecycle;\n}\n\nconst PROBE_TIMEOUT_MS = 2000;\nconst DETAIL_MAX_LENGTH = 500;\n\nfunction pushTarget(targets: ProbeTarget[], adapter: ProbeTarget | undefined): void {\n if (adapter) {\n targets.push(adapter);\n }\n}\n\nfunction pushAll(targets: ProbeTarget[], adapters: ReadonlyArray<ProbeTarget | undefined>): void {\n for (const adapter of adapters) {\n pushTarget(targets, adapter);\n }\n}\n\nfunction targetsOf(sources: AdapterSetupSources): ProbeTarget[] {\n const targets: ProbeTarget[] = [];\n pushAll(targets, [sources.database, sources.storage, sources.captureRunner]);\n pushAll(targets, sources.design ?? []);\n pushAll(targets, sources.notifiers ?? []);\n pushTarget(targets, sources.emailSender);\n return targets;\n}\n\nfunction sanitize(detail: string): string {\n return sanitizeErrorText(detail, DETAIL_MAX_LENGTH);\n}\n\nfunction messageOf(reason: unknown): string {\n const message = reason instanceof Error ? reason.message : String(reason);\n return sanitize(message);\n}\n\nasync function timeoutError(): Promise<never> {\n await new Promise<void>((resolve) => {\n setTimeout(resolve, PROBE_TIMEOUT_MS);\n });\n throw new Error(\"health probe timed out\");\n}\n\nfunction baseReport(\n target: ProbeTarget,\n): Omit<AdapterReportFull, \"state\" | \"latencyMs\" | \"detail\"> {\n const { category, kind, name, version, description } = target.metadata;\n return {\n category,\n kind,\n name,\n version,\n ...(description === undefined ? {} : { description }),\n hasLifecycle: target.lifecycle?.health !== undefined,\n };\n}\n\nfunction okReport(\n target: ProbeTarget,\n latencyMs: number,\n detail: string | undefined,\n): AdapterReportFull {\n return {\n ...baseReport(target),\n state: \"ok\",\n latencyMs,\n ...(detail === undefined ? {} : { detail }),\n };\n}\n\nfunction failedReport(target: ProbeTarget, latencyMs: number, detail: string): AdapterReportFull {\n return { ...baseReport(target), state: \"failed\", latencyMs, detail };\n}\n\nfunction setupFailedReport(target: ProbeTarget, detail: string): AdapterReportFull {\n return { ...baseReport(target), state: \"failed\", detail };\n}\n\nfunction startingReport(target: ProbeTarget): AdapterReportFull {\n return { ...baseReport(target), state: \"starting\" };\n}\n\nfunction noLifecycleReport(target: ProbeTarget): AdapterReportFull {\n return { ...baseReport(target), state: \"ok\" };\n}\n\nfunction detailOf(result: AdapterHealth, fallback: string): string {\n return result.detail === undefined ? fallback : sanitize(result.detail);\n}\n\nasync function raceProbe(\n target: ProbeTarget,\n probe: () => Promise<AdapterHealth>,\n started: number,\n): Promise<AdapterReportFull> {\n try {\n const result = await Promise.race([probe(), timeoutError()]);\n const latencyMs = Date.now() - started;\n if (!result.ok) {\n return failedReport(target, latencyMs, detailOf(result, \"probe reported failure\"));\n }\n return okReport(\n target,\n latencyMs,\n result.detail === undefined ? undefined : sanitize(result.detail),\n );\n } catch (error) {\n return failedReport(target, Date.now() - started, messageOf(error));\n }\n}\n\nasync function probeHealth(target: ProbeTarget): Promise<AdapterReportFull> {\n const probe = target.lifecycle?.health;\n if (!probe) {\n return noLifecycleReport(target);\n }\n return await raceProbe(target, probe, Date.now());\n}\n\nasync function probeTarget(\n target: ProbeTarget,\n setupErrors: ReadonlyMap<string, string>,\n starting: boolean,\n): Promise<AdapterReportFull> {\n const setupError = setupErrors.get(`${target.metadata.category}/${target.metadata.kind}`);\n if (setupError !== undefined) {\n return setupFailedReport(target, sanitize(setupError));\n }\n if (starting) {\n return startingReport(target);\n }\n return await probeHealth(target);\n}\n\nfunction collectSetupErrors(settled: AdapterSetupResult | null): ReadonlyMap<string, string> {\n const setupErrors = new Map<string, string>();\n if (settled) {\n for (const failure of settled.failures) {\n setupErrors.set(`${failure.category}/${failure.kind}`, failure.error);\n }\n }\n return setupErrors;\n}\n\nfunction overallStatus(reports: AdapterReportFull[]): HealthReport[\"status\"] {\n if (reports.some((report) => report.state === \"failed\")) {\n return \"degraded\";\n }\n if (reports.some((report) => report.state === \"starting\")) {\n return \"starting\";\n }\n return \"ok\";\n}\n\n/** Pseudo-adapter entry reporting whether stored credentials decrypt with the configured secret. */\nfunction credentialsEntry(probe: CredentialProbe): AdapterReportFull {\n const unreadable = probe.unreadable.length;\n let detail: string | undefined;\n if (unreadable > 0) {\n detail = `${unreadable} stored credential(s) cannot be decrypted; check SECRET and SECRET_PREVIOUS`;\n } else if (probe.previous > 0) {\n detail = `${probe.previous} credential(s) still encrypted with the previous secret; re-encrypt from the System page`;\n }\n return {\n category: \"credentials\",\n kind: \"secret\",\n name: \"Credential encryption\",\n version: \"-\",\n hasLifecycle: false,\n state: unreadable > 0 ? \"failed\" : \"ok\",\n ...(detail === undefined ? {} : { detail }),\n };\n}\n\n/**\n * Probe every wired adapter and assemble the full health report.\n *\n * @param sources - Live adapter instances to probe.\n * @param settled - Setup settlement (null while the eager run is in flight).\n * @param bootTimeMs - Process boot timestamp for uptime.\n * @param version - Server version for the report header.\n * @param credentials - Stored-credential probe (omit when no secret is configured).\n */\nexport async function collectHealthReport(\n sources: AdapterSetupSources,\n settled: AdapterSetupResult | null,\n bootTimeMs: number,\n version: string,\n credentials?: CredentialProbe | null,\n): Promise<HealthReport> {\n const setupErrors = collectSetupErrors(settled);\n const targets = targetsOf(sources);\n const adapters = await Promise.all(\n targets.map(async (target) => await probeTarget(target, setupErrors, settled === null)),\n );\n if (credentials) {\n adapters.push(credentialsEntry(credentials));\n }\n return {\n status: overallStatus(adapters),\n uptimeSecs: Math.floor((Date.now() - bootTimeMs) / 1000),\n version,\n adapters,\n };\n}\n"],"mappings":";;;;;AA6CA,MAAM,mBAAmB;AACzB,MAAM,oBAAoB;AAE1B,SAAS,WAAW,SAAwB,SAAwC;CAClF,IAAI,SACF,QAAQ,KAAK,OAAO;AAExB;AAEA,SAAS,QAAQ,SAAwB,UAAwD;CAC/F,KAAK,MAAM,WAAW,UACpB,WAAW,SAAS,OAAO;AAE/B;AAEA,SAAS,UAAU,SAA6C;CAC9D,MAAM,UAAyB,CAAC;CAChC,QAAQ,SAAS;EAAC,QAAQ;EAAU,QAAQ;EAAS,QAAQ;CAAa,CAAC;CAC3E,QAAQ,SAAS,QAAQ,UAAU,CAAC,CAAC;CACrC,QAAQ,SAAS,QAAQ,aAAa,CAAC,CAAC;CACxC,WAAW,SAAS,QAAQ,WAAW;CACvC,OAAO;AACT;AAEA,SAAS,SAAS,QAAwB;CACxC,OAAO,kBAAkB,QAAQ,iBAAiB;AACpD;AAEA,SAAS,UAAU,QAAyB;CAE1C,OAAO,SADS,kBAAkB,QAAQ,OAAO,UAAU,OAAO,MAAM,CACjD;AACzB;AAEA,eAAe,eAA+B;CAC5C,MAAM,IAAI,SAAe,YAAY;EACnC,WAAW,SAAS,gBAAgB;CACtC,CAAC;CACD,MAAM,IAAI,MAAM,wBAAwB;AAC1C;AAEA,SAAS,WACP,QAC2D;CAC3D,MAAM,EAAE,UAAU,MAAM,MAAM,SAAS,gBAAgB,OAAO;CAC9D,OAAO;EACL;EACA;EACA;EACA;EACA,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;EACnD,cAAc,OAAO,WAAW,WAAW,KAAA;CAC7C;AACF;AAEA,SAAS,SACP,QACA,WACA,QACmB;CACnB,OAAO;EACL,GAAG,WAAW,MAAM;EACpB,OAAO;EACP;EACA,GAAI,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO;CAC3C;AACF;AAEA,SAAS,aAAa,QAAqB,WAAmB,QAAmC;CAC/F,OAAO;EAAE,GAAG,WAAW,MAAM;EAAG,OAAO;EAAU;EAAW;CAAO;AACrE;AAEA,SAAS,kBAAkB,QAAqB,QAAmC;CACjF,OAAO;EAAE,GAAG,WAAW,MAAM;EAAG,OAAO;EAAU;CAAO;AAC1D;AAEA,SAAS,eAAe,QAAwC;CAC9D,OAAO;EAAE,GAAG,WAAW,MAAM;EAAG,OAAO;CAAW;AACpD;AAEA,SAAS,kBAAkB,QAAwC;CACjE,OAAO;EAAE,GAAG,WAAW,MAAM;EAAG,OAAO;CAAK;AAC9C;AAEA,SAAS,SAAS,QAAuB,UAA0B;CACjE,OAAO,OAAO,WAAW,KAAA,IAAY,WAAW,SAAS,OAAO,MAAM;AACxE;AAEA,eAAe,UACb,QACA,OACA,SAC4B;CAC5B,IAAI;EACF,MAAM,SAAS,MAAM,QAAQ,KAAK,CAAC,MAAM,GAAG,aAAa,CAAC,CAAC;EAC3D,MAAM,YAAY,KAAK,IAAI,IAAI;EAC/B,IAAI,CAAC,OAAO,IACV,OAAO,aAAa,QAAQ,WAAW,SAAS,QAAQ,wBAAwB,CAAC;EAEnF,OAAO,SACL,QACA,WACA,OAAO,WAAW,KAAA,IAAY,KAAA,IAAY,SAAS,OAAO,MAAM,CAClE;CACF,SAAS,OAAO;EACd,OAAO,aAAa,QAAQ,KAAK,IAAI,IAAI,SAAS,UAAU,KAAK,CAAC;CACpE;AACF;AAEA,eAAe,YAAY,QAAiD;CAC1E,MAAM,QAAQ,OAAO,WAAW;CAChC,IAAI,CAAC,OACH,OAAO,kBAAkB,MAAM;CAEjC,OAAO,MAAM,UAAU,QAAQ,OAAO,KAAK,IAAI,CAAC;AAClD;AAEA,eAAe,YACb,QACA,aACA,UAC4B;CAC5B,MAAM,aAAa,YAAY,IAAI,GAAG,OAAO,SAAS,SAAS,GAAG,OAAO,SAAS,MAAM;CACxF,IAAI,eAAe,KAAA,GACjB,OAAO,kBAAkB,QAAQ,SAAS,UAAU,CAAC;CAEvD,IAAI,UACF,OAAO,eAAe,MAAM;CAE9B,OAAO,MAAM,YAAY,MAAM;AACjC;AAEA,SAAS,mBAAmB,SAAiE;CAC3F,MAAM,8BAAc,IAAI,IAAoB;CAC5C,IAAI,SACF,KAAK,MAAM,WAAW,QAAQ,UAC5B,YAAY,IAAI,GAAG,QAAQ,SAAS,GAAG,QAAQ,QAAQ,QAAQ,KAAK;CAGxE,OAAO;AACT;AAEA,SAAS,cAAc,SAAsD;CAC3E,IAAI,QAAQ,MAAM,WAAW,OAAO,UAAU,QAAQ,GACpD,OAAO;CAET,IAAI,QAAQ,MAAM,WAAW,OAAO,UAAU,UAAU,GACtD,OAAO;CAET,OAAO;AACT;;AAGA,SAAS,iBAAiB,OAA2C;CACnE,MAAM,aAAa,MAAM,WAAW;CACpC,IAAI;CACJ,IAAI,aAAa,GACf,SAAS,GAAG,WAAW;MAClB,IAAI,MAAM,WAAW,GAC1B,SAAS,GAAG,MAAM,SAAS;CAE7B,OAAO;EACL,UAAU;EACV,MAAM;EACN,MAAM;EACN,SAAS;EACT,cAAc;EACd,OAAO,aAAa,IAAI,WAAW;EACnC,GAAI,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO;CAC3C;AACF;;;;;;;;;;AAWA,eAAsB,oBACpB,SACA,SACA,YACA,SACA,aACuB;CACvB,MAAM,cAAc,mBAAmB,OAAO;CAC9C,MAAM,UAAU,UAAU,OAAO;CACjC,MAAM,WAAW,MAAM,QAAQ,IAC7B,QAAQ,IAAI,OAAO,WAAW,MAAM,YAAY,QAAQ,aAAa,YAAY,IAAI,CAAC,CACxF;CACA,IAAI,aACF,SAAS,KAAK,iBAAiB,WAAW,CAAC;CAE7C,OAAO;EACL,QAAQ,cAAc,QAAQ;EAC9B,YAAY,KAAK,OAAO,KAAK,IAAI,IAAI,cAAc,GAAI;EACvD;EACA;CACF;AACF"}
|
package/dist/routers/health.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import "node:path";
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
|
-
import { getStore } from "../store.mjs";
|
|
4
|
+
import { getStore, testingEnabled } from "../store.mjs";
|
|
5
5
|
import { loadCredentialProbe } from "../credentials.mjs";
|
|
6
|
-
import { collectHealthReport } from "./health-report.mjs";
|
|
7
6
|
import { requireSiteAdmin } from "./helpers.mjs";
|
|
7
|
+
import { collectHealthReport } from "./health-report.mjs";
|
|
8
8
|
//#region src/routers/health.ts
|
|
9
9
|
/** Register `GET /api/v1/health` (open liveness) and `POST` (authed deep readiness). */
|
|
10
10
|
function registerHealth(app, deps) {
|
|
@@ -12,7 +12,8 @@ function registerHealth(app, deps) {
|
|
|
12
12
|
const body = {
|
|
13
13
|
status: "ok",
|
|
14
14
|
uptimeSecs: Math.floor((Date.now() - deps.bootTimeMs) / 1e3),
|
|
15
|
-
version: deps.version
|
|
15
|
+
version: deps.version,
|
|
16
|
+
testing: testingEnabled()
|
|
16
17
|
};
|
|
17
18
|
c.header("Cache-Control", "no-store");
|
|
18
19
|
return c.json(body);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"health.mjs","names":[],"sources":["../../src/routers/health.ts"],"sourcesContent":["/**\n * Two-tier health: lightweight liveness (GET, no auth) and deep readiness\n * (POST, site-admin). Neither is gated by adapter setup — liveness must\n * answer mid-migration so orchestrators don't kill the machine, and deep\n * reports the setup settlement state instead of being masked by the gate.\n *\n * Probing lives in `health-report.ts` (shared with the admin System page);\n * this module keeps the API response shape stable.\n */\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { loadCredentialProbe } from \"../credentials.ts\";\nimport { getStore } from \"../store.ts\";\nimport { collectHealthReport, type AdapterState } from \"./health-report.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\n\n/** Dependencies for the health routes (no adapter I/O on the GET path). */\nexport interface HealthDeps {\n sources: AdapterSetupSources;\n getSettled: () => AdapterSetupResult | null;\n bootTimeMs: number;\n version: string;\n}\n\n/** Liveness body: process-level only, safe for unauthenticated probes. */\nexport interface LivenessBody {\n status: \"ok\";\n uptimeSecs: number;\n version: string;\n}\n\n/** Per-adapter readiness entry (error text sanitized — no secrets). */\nexport interface AdapterReport {\n category: string;\n kind: string;\n name: string;\n state: AdapterState;\n latencyMs?: number;\n detail?: string;\n}\n\n/** Deep readiness body. */\nexport interface ReadinessBody {\n status: \"ok\" | \"starting\" | \"degraded\";\n adapters: AdapterReport[];\n}\n\n/** Register `GET /api/v1/health` (open liveness) and `POST` (authed deep readiness). */\nexport function registerHealth(app: ShelfRouter, deps: HealthDeps): void {\n app.get(\"/api/v1/health\", (c) => {\n const body: LivenessBody = {\n status: \"ok\",\n uptimeSecs: Math.floor((Date.now() - deps.bootTimeMs) / 1000),\n version: deps.version,\n };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body);\n });\n\n app.post(\"/api/v1/health\", async (c) => {\n requireSiteAdmin(c);\n const { db, config } = getStore();\n const report = await collectHealthReport(\n deps.sources,\n deps.getSettled(),\n deps.bootTimeMs,\n deps.version,\n await loadCredentialProbe(db, config),\n );\n const adapters: AdapterReport[] = report.adapters.map((adapter) => {\n const entry: AdapterReport = {\n category: adapter.category,\n kind: adapter.kind,\n name: adapter.name,\n state: adapter.state,\n };\n if (adapter.latencyMs !== undefined) {\n entry.latencyMs = adapter.latencyMs;\n }\n if (adapter.detail !== undefined) {\n entry.detail = adapter.detail;\n }\n return entry;\n });\n const body: ReadinessBody = { status: report.status, adapters };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body, body.status === \"ok\" ? 200 : 503);\n });\n}\n"],"mappings":";;;;;;;;;
|
|
1
|
+
{"version":3,"file":"health.mjs","names":[],"sources":["../../src/routers/health.ts"],"sourcesContent":["/**\n * Two-tier health: lightweight liveness (GET, no auth) and deep readiness\n * (POST, site-admin). Neither is gated by adapter setup — liveness must\n * answer mid-migration so orchestrators don't kill the machine, and deep\n * reports the setup settlement state instead of being masked by the gate.\n *\n * Probing lives in `health-report.ts` (shared with the admin System page);\n * this module keeps the API response shape stable.\n */\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { loadCredentialProbe } from \"../credentials.ts\";\nimport { getStore, testingEnabled } from \"../store.ts\";\nimport { collectHealthReport, type AdapterState } from \"./health-report.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\n\n/** Dependencies for the health routes (no adapter I/O on the GET path). */\nexport interface HealthDeps {\n sources: AdapterSetupSources;\n getSettled: () => AdapterSetupResult | null;\n bootTimeMs: number;\n version: string;\n}\n\n/** Liveness body: process-level only, safe for unauthenticated probes. */\nexport interface LivenessBody {\n status: \"ok\";\n uptimeSecs: number;\n version: string;\n /** Whether this instance has the `testing` option (capture, compare, baselines; ADR 0006). */\n testing: boolean;\n}\n\n/** Per-adapter readiness entry (error text sanitized — no secrets). */\nexport interface AdapterReport {\n category: string;\n kind: string;\n name: string;\n state: AdapterState;\n latencyMs?: number;\n detail?: string;\n}\n\n/** Deep readiness body. */\nexport interface ReadinessBody {\n status: \"ok\" | \"starting\" | \"degraded\";\n adapters: AdapterReport[];\n}\n\n/** Register `GET /api/v1/health` (open liveness) and `POST` (authed deep readiness). */\nexport function registerHealth(app: ShelfRouter, deps: HealthDeps): void {\n app.get(\"/api/v1/health\", (c) => {\n const body: LivenessBody = {\n status: \"ok\",\n uptimeSecs: Math.floor((Date.now() - deps.bootTimeMs) / 1000),\n version: deps.version,\n testing: testingEnabled(),\n };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body);\n });\n\n app.post(\"/api/v1/health\", async (c) => {\n requireSiteAdmin(c);\n const { db, config } = getStore();\n const report = await collectHealthReport(\n deps.sources,\n deps.getSettled(),\n deps.bootTimeMs,\n deps.version,\n await loadCredentialProbe(db, config),\n );\n const adapters: AdapterReport[] = report.adapters.map((adapter) => {\n const entry: AdapterReport = {\n category: adapter.category,\n kind: adapter.kind,\n name: adapter.name,\n state: adapter.state,\n };\n if (adapter.latencyMs !== undefined) {\n entry.latencyMs = adapter.latencyMs;\n }\n if (adapter.detail !== undefined) {\n entry.detail = adapter.detail;\n }\n return entry;\n });\n const body: ReadinessBody = { status: report.status, adapters };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body, body.status === \"ok\" ? 200 : 503);\n });\n}\n"],"mappings":";;;;;;;;;AAkDA,SAAgB,eAAe,KAAkB,MAAwB;CACvE,IAAI,IAAI,mBAAmB,MAAM;EAC/B,MAAM,OAAqB;GACzB,QAAQ;GACR,YAAY,KAAK,OAAO,KAAK,IAAI,IAAI,KAAK,cAAc,GAAI;GAC5D,SAAS,KAAK;GACd,SAAS,eAAe;EAC1B;EACA,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,IAAI;CACpB,CAAC;CAED,IAAI,KAAK,kBAAkB,OAAO,MAAM;EACtC,iBAAiB,CAAC;EAClB,MAAM,EAAE,IAAI,WAAW,SAAS;EAChC,MAAM,SAAS,MAAM,oBACnB,KAAK,SACL,KAAK,WAAW,GAChB,KAAK,YACL,KAAK,SACL,MAAM,oBAAoB,IAAI,MAAM,CACtC;EACA,MAAM,WAA4B,OAAO,SAAS,KAAK,YAAY;GACjE,MAAM,QAAuB;IAC3B,UAAU,QAAQ;IAClB,MAAM,QAAQ;IACd,MAAM,QAAQ;IACd,OAAO,QAAQ;GACjB;GACA,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,YAAY,QAAQ;GAE5B,IAAI,QAAQ,WAAW,KAAA,GACrB,MAAM,SAAS,QAAQ;GAEzB,OAAO;EACT,CAAC;EACD,MAAM,OAAsB;GAAE,QAAQ,OAAO;GAAQ;EAAS;EAC9D,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,MAAM,KAAK,WAAW,OAAO,MAAM,GAAG;CACtD,CAAC;AACH"}
|
package/dist/routers/helpers.mjs
CHANGED
|
@@ -2,8 +2,8 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { getStore } from "../store.mjs";
|
|
5
|
-
import { MemberModel, ProjectModel, TokenModel, UserModel } from "@storyshelf/core/models";
|
|
6
5
|
import { sha256, timingSafeEqualString } from "@storyshelf/core/utils";
|
|
6
|
+
import { MemberModel, ProjectModel, TokenModel, UserModel } from "@storyshelf/core/models";
|
|
7
7
|
import { HTTPException } from "hono/http-exception";
|
|
8
8
|
//#region src/routers/helpers.ts
|
|
9
9
|
/** Throw a 401 Unauthorized error for unauthenticated requests. */
|
|
@@ -31,7 +31,7 @@ async function findProjectBySlug(slug) {
|
|
|
31
31
|
}
|
|
32
32
|
async function resolveProjectByToken(c, slug) {
|
|
33
33
|
const authHeader = c.req.header("authorization");
|
|
34
|
-
if (authHeader?.startsWith("Bearer ")) {
|
|
34
|
+
if (authHeader?.startsWith("Bearer ") && !requestHasAdminToken(c)) {
|
|
35
35
|
const token = authHeader.slice(7);
|
|
36
36
|
const found = await new TokenModel(getStore().db).findByHash(sha256(token));
|
|
37
37
|
if (!found) unauthorized();
|
|
@@ -97,6 +97,22 @@ async function resolveAuthorizedProject(c, slug, ...minRoles) {
|
|
|
97
97
|
await assertRole(found.id, ...minRoles);
|
|
98
98
|
return found;
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Who a verdict is attributed to (ADR 0013, decision 3): the session user, or the user who owns
|
|
102
|
+
* the bearer token. `null` only when the instance has no auth adapter at all (one anonymous
|
|
103
|
+
* reviewer). With auth on, a caller that cannot be traced to a user (the bootstrap admin token, a
|
|
104
|
+
* token without an owner) is refused.
|
|
105
|
+
*/
|
|
106
|
+
async function resolveReviewer(c) {
|
|
107
|
+
const { authEnabled, user, db } = getStore();
|
|
108
|
+
if (!authEnabled) return null;
|
|
109
|
+
if (user) return user.id;
|
|
110
|
+
const header = c.req.header("authorization");
|
|
111
|
+
if (!header?.startsWith("Bearer ")) return unauthorized();
|
|
112
|
+
const token = requestHasAdminToken(c) ? null : await new TokenModel(db).findByHash(sha256(header.slice(7)));
|
|
113
|
+
if (!token?.userId || !await new UserModel(db).get(token.userId)) throw new HTTPException(403, { message: "A verdict needs a user: use a token created by the reviewer or sign in" });
|
|
114
|
+
return token.userId;
|
|
115
|
+
}
|
|
100
116
|
/** Require a site-level admin (or permit when auth is disabled). */
|
|
101
117
|
function requireSiteAdmin(c) {
|
|
102
118
|
if (!getStore().authEnabled) return;
|
|
@@ -105,6 +121,6 @@ function requireSiteAdmin(c) {
|
|
|
105
121
|
forbidden();
|
|
106
122
|
}
|
|
107
123
|
//#endregion
|
|
108
|
-
export { assertRole, currentProjectRole, findProjectBySlug, forbidden, notFound, requestHasAdminToken, requireSiteAdmin, resolveAuthorizedProject, unauthorized };
|
|
124
|
+
export { assertRole, currentProjectRole, findProjectBySlug, forbidden, notFound, requestHasAdminToken, requireSiteAdmin, resolveAuthorizedProject, resolveReviewer, unauthorized };
|
|
109
125
|
|
|
110
126
|
//# sourceMappingURL=helpers.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"helpers.mjs","names":[],"sources":["../../src/routers/helpers.ts"],"sourcesContent":["import { MemberModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { TokenModel } from \"@storyshelf/core/models\";\nimport { UserModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Token } from \"@storyshelf/core/schema\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport { sha256, timingSafeEqualString } from \"@storyshelf/core/utils\";\nimport type { Context, Next } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ContentfulStatusCode } from \"hono/utils/http-status\";\nimport type { ZodType } from \"zod\";\nimport { getStore } from \"../store.ts\";\n/** Send a JSON response with the given status code. */\nexport function json(c: Context, data: unknown, status: ContentfulStatusCode = 200): Response {\n return c.json(data, status);\n}\n\n/** Parse the request body as JSON and validate it against a Zod schema. */\nexport async function validJson<T>(c: Context, schema: ZodType<T>): Promise<T> {\n const result = schema.safeParse(await c.req.json());\n if (!result.success) {\n throw new HTTPException(400, { message: result.error.message });\n }\n return result.data;\n}\n\n/** Throw a 401 Unauthorized error for unauthenticated requests. */\nexport function unauthorized(): never {\n throw new HTTPException(401, { message: \"Unauthorized\" });\n}\n\n/** Throw a 403 Forbidden error for requests lacking the required role. */\nexport function forbidden(): never {\n throw new HTTPException(403, { message: \"Forbidden\" });\n}\n\n/** Throw a 404 Not Found error with a custom message. */\nexport function notFound(message = \"Not found\"): never {\n throw new HTTPException(404, { message });\n}\n\n/** Resolve the current session user's effective role on a project. */\nexport async function currentProjectRole(projectId: string): Promise<ProjectRole | null> {\n const { db, user } = getStore();\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\n/** Build middleware that requires one of the given project roles. */\nexport function requireRole(...roles: ProjectRole[]) {\n return async (c: Context, next: Next): Promise<void> => {\n const projectId = c.req.param(\"projectId\") ?? c.req.param(\"slug\");\n if (!projectId) {\n forbidden();\n }\n const role = await currentProjectRole(projectId);\n if (!role || !roles.includes(role)) {\n forbidden();\n }\n await next();\n };\n}\n\n/** Look up a project by its URL slug, returning null when absent. */\nexport async function findProjectBySlug(slug: string): Promise<Project | null> {\n const { db } = getStore();\n return await new ProjectModel(db).getBySlug(slug);\n}\n\nasync function resolveProjectByToken(\n c: Context,\n slug: string,\n): Promise<{ project: Project; token: Token } | null> {\n const authHeader = c.req.header(\"authorization\");\n if (authHeader?.startsWith(\"Bearer \")) {\n const token = authHeader.slice(\"Bearer \".length);\n const found = await new TokenModel(getStore().db).findByHash(sha256(token));\n if (!found) {\n unauthorized();\n }\n const project = await new ProjectModel(getStore().db).get(found.projectId);\n if (!project || project.slug !== slug) {\n forbidden();\n }\n return { project, token: found };\n }\n return null;\n}\n\n/**\n * Resolve a bearer token to an effective project role.\n * Tokens without an owner (legacy pre-binding rows) resolve as viewer;\n * tokens whose owner no longer exists resolve to null (deny).\n */\nasync function tokenProjectRole(token: Token, projectId: string): Promise<ProjectRole | null> {\n if (!token.userId) {\n return \"viewer\";\n }\n const { db } = getStore();\n const user = await new UserModel(db).get(token.userId);\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\n/** Resolve a project by slug, honoring CLI bearer-token access. */\nexport async function resolveProject(c: Context, slug: string): Promise<Project> {\n const viaToken = await resolveProjectByToken(c, slug);\n if (viaToken) {\n return viaToken.project;\n }\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n return found;\n}\n\n/**\n * True when the request bears the configured bootstrap admin token.\n * Covers site-admin routes AND project routes (as admin) so a fresh\n * instance is fully operable before any user exists. Timing-safe;\n * unset `adminToken` never matches.\n */\nexport function requestHasAdminToken(c: Context): boolean {\n const adminToken = getStore().config.adminToken;\n if (!adminToken) {\n return false;\n }\n const header = c.req.header(\"authorization\");\n if (!header?.startsWith(\"Bearer \")) {\n return false;\n }\n return timingSafeEqualString(header.slice(\"Bearer \".length), adminToken);\n}\n\n/**\n * Enforce the caller's project role for a given project.\n *\n * Authorization model (ADR 0008):\n * - No auth adapter configured -> all operations permitted (development mode).\n * - Bootstrap admin token -> grants access as admin (see `requestHasAdminToken`).\n * - Bearer token (CLI) -> grants access to its own project regardless of role.\n * - Session user -> must have an effective role in `minRoles`.\n */\nexport async function assertRole(projectId: string, ...minRoles: ProjectRole[]): Promise<void> {\n if (!getStore().authEnabled) {\n return;\n }\n const role = await currentProjectRole(projectId);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n}\n\n/** Resolve a project by slug and enforce the caller's minimum role. */\nexport async function resolveAuthorizedProject(\n c: Context,\n slug: string,\n ...minRoles: ProjectRole[]\n): Promise<Project> {\n const viaToken = await resolveProjectByToken(c, slug);\n if (viaToken) {\n const role = await tokenProjectRole(viaToken.token, viaToken.project.id);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n return viaToken.project;\n }\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n if (requestHasAdminToken(c)) {\n return found;\n }\n await assertRole(found.id, ...minRoles);\n return found;\n}\n\n/** Require a site-level admin (or permit when auth is disabled). */\nexport function requireSiteAdmin(c: Context): void {\n if (!getStore().authEnabled) {\n return;\n }\n const { user } = getStore();\n if (user?.role === \"admin\" || requestHasAdminToken(c)) {\n return;\n }\n forbidden();\n}\n\n/** Require the current session user to hold one of the given project roles. */\nexport async function requireProjectRole(\n projectId: string,\n ...minRoles: ProjectRole[]\n): Promise<void> {\n await assertRole(projectId, ...minRoles);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"helpers.mjs","names":[],"sources":["../../src/routers/helpers.ts"],"sourcesContent":["import { MemberModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { TokenModel } from \"@storyshelf/core/models\";\nimport { UserModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Token } from \"@storyshelf/core/schema\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport { sha256, timingSafeEqualString } from \"@storyshelf/core/utils\";\nimport type { Context, Next } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ContentfulStatusCode } from \"hono/utils/http-status\";\nimport type { ZodType } from \"zod\";\nimport { getStore } from \"../store.ts\";\n/** Send a JSON response with the given status code. */\nexport function json(c: Context, data: unknown, status: ContentfulStatusCode = 200): Response {\n return c.json(data, status);\n}\n\n/** Parse the request body as JSON and validate it against a Zod schema. */\nexport async function validJson<T>(c: Context, schema: ZodType<T>): Promise<T> {\n const result = schema.safeParse(await c.req.json());\n if (!result.success) {\n throw new HTTPException(400, { message: result.error.message });\n }\n return result.data;\n}\n\n/** Throw a 401 Unauthorized error for unauthenticated requests. */\nexport function unauthorized(): never {\n throw new HTTPException(401, { message: \"Unauthorized\" });\n}\n\n/** Throw a 403 Forbidden error for requests lacking the required role. */\nexport function forbidden(): never {\n throw new HTTPException(403, { message: \"Forbidden\" });\n}\n\n/** Throw a 404 Not Found error with a custom message. */\nexport function notFound(message = \"Not found\"): never {\n throw new HTTPException(404, { message });\n}\n\n/** Resolve the current session user's effective role on a project. */\nexport async function currentProjectRole(projectId: string): Promise<ProjectRole | null> {\n const { db, user } = getStore();\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\n/** Build middleware that requires one of the given project roles. */\nexport function requireRole(...roles: ProjectRole[]) {\n return async (c: Context, next: Next): Promise<void> => {\n const projectId = c.req.param(\"projectId\") ?? c.req.param(\"slug\");\n if (!projectId) {\n forbidden();\n }\n const role = await currentProjectRole(projectId);\n if (!role || !roles.includes(role)) {\n forbidden();\n }\n await next();\n };\n}\n\n/** Look up a project by its URL slug, returning null when absent. */\nexport async function findProjectBySlug(slug: string): Promise<Project | null> {\n const { db } = getStore();\n return await new ProjectModel(db).getBySlug(slug);\n}\n\nasync function resolveProjectByToken(\n c: Context,\n slug: string,\n): Promise<{ project: Project; token: Token } | null> {\n const authHeader = c.req.header(\"authorization\");\n // The bootstrap admin token is not a project token: let callers grant it as admin.\n if (authHeader?.startsWith(\"Bearer \") && !requestHasAdminToken(c)) {\n const token = authHeader.slice(\"Bearer \".length);\n const found = await new TokenModel(getStore().db).findByHash(sha256(token));\n if (!found) {\n unauthorized();\n }\n const project = await new ProjectModel(getStore().db).get(found.projectId);\n if (!project || project.slug !== slug) {\n forbidden();\n }\n return { project, token: found };\n }\n return null;\n}\n\n/**\n * Resolve a bearer token to an effective project role.\n * Tokens without an owner (legacy pre-binding rows) resolve as viewer;\n * tokens whose owner no longer exists resolve to null (deny).\n */\nasync function tokenProjectRole(token: Token, projectId: string): Promise<ProjectRole | null> {\n if (!token.userId) {\n return \"viewer\";\n }\n const { db } = getStore();\n const user = await new UserModel(db).get(token.userId);\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\n/** Resolve a project by slug, honoring CLI bearer-token access. */\nexport async function resolveProject(c: Context, slug: string): Promise<Project> {\n const viaToken = await resolveProjectByToken(c, slug);\n if (viaToken) {\n return viaToken.project;\n }\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n return found;\n}\n\n/**\n * True when the request bears the configured bootstrap admin token.\n * Covers site-admin routes AND project routes (as admin) so a fresh\n * instance is fully operable before any user exists. Timing-safe;\n * unset `adminToken` never matches.\n */\nexport function requestHasAdminToken(c: Context): boolean {\n const adminToken = getStore().config.adminToken;\n if (!adminToken) {\n return false;\n }\n const header = c.req.header(\"authorization\");\n if (!header?.startsWith(\"Bearer \")) {\n return false;\n }\n return timingSafeEqualString(header.slice(\"Bearer \".length), adminToken);\n}\n\n/**\n * Enforce the caller's project role for a given project.\n *\n * Authorization model (ADR 0008):\n * - No auth adapter configured -> all operations permitted (development mode).\n * - Bootstrap admin token -> grants access as admin (see `requestHasAdminToken`).\n * - Bearer token (CLI) -> grants access to its own project regardless of role.\n * - Session user -> must have an effective role in `minRoles`.\n */\nexport async function assertRole(projectId: string, ...minRoles: ProjectRole[]): Promise<void> {\n if (!getStore().authEnabled) {\n return;\n }\n const role = await currentProjectRole(projectId);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n}\n\n/** Resolve a project by slug and enforce the caller's minimum role. */\nexport async function resolveAuthorizedProject(\n c: Context,\n slug: string,\n ...minRoles: ProjectRole[]\n): Promise<Project> {\n const viaToken = await resolveProjectByToken(c, slug);\n if (viaToken) {\n const role = await tokenProjectRole(viaToken.token, viaToken.project.id);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n return viaToken.project;\n }\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n if (requestHasAdminToken(c)) {\n return found;\n }\n await assertRole(found.id, ...minRoles);\n return found;\n}\n\n/**\n * Who a verdict is attributed to (ADR 0013, decision 3): the session user, or the user who owns\n * the bearer token. `null` only when the instance has no auth adapter at all (one anonymous\n * reviewer). With auth on, a caller that cannot be traced to a user (the bootstrap admin token, a\n * token without an owner) is refused.\n */\nexport async function resolveReviewer(c: Context): Promise<string | null> {\n const { authEnabled, user, db } = getStore();\n if (!authEnabled) {\n return null;\n }\n if (user) {\n return user.id;\n }\n const header = c.req.header(\"authorization\");\n if (!header?.startsWith(\"Bearer \")) {\n return unauthorized();\n }\n const token = requestHasAdminToken(c)\n ? null\n : await new TokenModel(db).findByHash(sha256(header.slice(\"Bearer \".length)));\n if (!token?.userId || !(await new UserModel(db).get(token.userId))) {\n throw new HTTPException(403, {\n message: \"A verdict needs a user: use a token created by the reviewer or sign in\",\n });\n }\n return token.userId;\n}\n\n/** Require a site-level admin (or permit when auth is disabled). */\nexport function requireSiteAdmin(c: Context): void {\n if (!getStore().authEnabled) {\n return;\n }\n const { user } = getStore();\n if (user?.role === \"admin\" || requestHasAdminToken(c)) {\n return;\n }\n forbidden();\n}\n\n/** Require the current session user to hold one of the given project roles. */\nexport async function requireProjectRole(\n projectId: string,\n ...minRoles: ProjectRole[]\n): Promise<void> {\n await assertRole(projectId, ...minRoles);\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { notFound } from "./helpers.mjs";
|
|
5
|
+
import { buildForProject } from "./builds.handlers.mjs";
|
|
6
|
+
import { loadHealthPanel } from "../insights/health-panel.mjs";
|
|
7
|
+
import { loadInsightPanel } from "../insights/panel.mjs";
|
|
8
|
+
import { requestHealth, requestTriage } from "../insights/request.mjs";
|
|
9
|
+
import { InsightPanel } from "../pages/build-diff-insight.mjs";
|
|
10
|
+
import { HealthPanel } from "../pages/health-panel.mjs";
|
|
11
|
+
import { projectForRegenerate, projectForView } from "./insights.handlers.mjs";
|
|
12
|
+
import { jsx } from "hono/jsx/jsx-runtime";
|
|
13
|
+
//#region src/routers/insights-ui.tsx
|
|
14
|
+
/** Register the HTMX fragments for the project health panel. */
|
|
15
|
+
function registerHealthFragments(app) {
|
|
16
|
+
app.get("/projects/:slug/insights/health/panel", async (c) => {
|
|
17
|
+
const { project } = await projectForView(c, c.req.param("slug"));
|
|
18
|
+
const data = await loadHealthPanel(project);
|
|
19
|
+
return data ? c.html(/* @__PURE__ */ jsx(HealthPanel, {
|
|
20
|
+
project,
|
|
21
|
+
data
|
|
22
|
+
})) : notFound();
|
|
23
|
+
});
|
|
24
|
+
app.post("/projects/:slug/insights/health/generate", async (c) => {
|
|
25
|
+
const { deps, project } = await projectForRegenerate(c, c.req.param("slug"));
|
|
26
|
+
const form = await c.req.formData();
|
|
27
|
+
await requestHealth(deps, project, "30d", { force: form.get("force") === "true" });
|
|
28
|
+
const data = await loadHealthPanel(project);
|
|
29
|
+
return data ? c.html(/* @__PURE__ */ jsx(HealthPanel, {
|
|
30
|
+
project,
|
|
31
|
+
data
|
|
32
|
+
})) : notFound();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Register the HTMX fragments for the build-review AI triage panel. */
|
|
36
|
+
function registerInsightFragments(app) {
|
|
37
|
+
registerHealthFragments(app);
|
|
38
|
+
app.get("/projects/:slug/builds/:buildId/insights/panel", async (c) => {
|
|
39
|
+
const { project } = await projectForView(c, c.req.param("slug"));
|
|
40
|
+
const build = await buildForProject(project.id, c.req.param("buildId"));
|
|
41
|
+
const data = await loadInsightPanel(project, build);
|
|
42
|
+
return data ? c.html(/* @__PURE__ */ jsx(InsightPanel, {
|
|
43
|
+
project,
|
|
44
|
+
build,
|
|
45
|
+
data
|
|
46
|
+
})) : notFound();
|
|
47
|
+
});
|
|
48
|
+
app.post("/projects/:slug/builds/:buildId/insights/generate", async (c) => {
|
|
49
|
+
const { deps, project } = await projectForRegenerate(c, c.req.param("slug"));
|
|
50
|
+
const build = await buildForProject(project.id, c.req.param("buildId"));
|
|
51
|
+
const form = await c.req.formData();
|
|
52
|
+
await requestTriage(deps, project, build, { force: form.get("force") === "true" });
|
|
53
|
+
const data = await loadInsightPanel(project, build);
|
|
54
|
+
return data ? c.html(/* @__PURE__ */ jsx(InsightPanel, {
|
|
55
|
+
project,
|
|
56
|
+
build,
|
|
57
|
+
data
|
|
58
|
+
})) : notFound();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { registerInsightFragments };
|
|
63
|
+
|
|
64
|
+
//# sourceMappingURL=insights-ui.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"insights-ui.mjs","names":[],"sources":["../../src/routers/insights-ui.tsx"],"sourcesContent":["import type { ShelfRouter } from \"../app-types.ts\";\nimport { HEALTH_UI_WINDOW, loadHealthPanel } from \"../insights/health-panel.ts\";\nimport { loadInsightPanel } from \"../insights/panel.ts\";\nimport { requestHealth, requestTriage } from \"../insights/request.ts\";\nimport { InsightPanel } from \"../pages/build-diff-insight.tsx\";\nimport { HealthPanel } from \"../pages/health-panel.tsx\";\nimport { buildForProject } from \"./builds.handlers.ts\";\nimport { notFound } from \"./helpers.ts\";\nimport { projectForRegenerate, projectForView } from \"./insights.handlers.ts\";\n\n/** Register the HTMX fragments for the project health panel. */\nfunction registerHealthFragments(app: ShelfRouter): void {\n app.get(\"/projects/:slug/insights/health/panel\", async (c) => {\n const { project } = await projectForView(c, c.req.param(\"slug\"));\n const data = await loadHealthPanel(project);\n return data ? c.html(<HealthPanel project={project} data={data} />) : notFound();\n });\n\n app.post(\"/projects/:slug/insights/health/generate\", async (c) => {\n const { deps, project } = await projectForRegenerate(c, c.req.param(\"slug\"));\n const form = await c.req.formData();\n await requestHealth(deps, project, HEALTH_UI_WINDOW, { force: form.get(\"force\") === \"true\" });\n const data = await loadHealthPanel(project);\n return data ? c.html(<HealthPanel project={project} data={data} />) : notFound();\n });\n}\n\n/** Register the HTMX fragments for the build-review AI triage panel. */\nexport function registerInsightFragments(app: ShelfRouter): void {\n registerHealthFragments(app);\n app.get(\"/projects/:slug/builds/:buildId/insights/panel\", async (c) => {\n const { project } = await projectForView(c, c.req.param(\"slug\"));\n const build = await buildForProject(project.id, c.req.param(\"buildId\"));\n const data = await loadInsightPanel(project, build);\n return data ? c.html(<InsightPanel project={project} build={build} data={data} />) : notFound();\n });\n\n app.post(\"/projects/:slug/builds/:buildId/insights/generate\", async (c) => {\n const { deps, project } = await projectForRegenerate(c, c.req.param(\"slug\"));\n const build = await buildForProject(project.id, c.req.param(\"buildId\"));\n const form = await c.req.formData();\n await requestTriage(deps, project, build, { force: form.get(\"force\") === \"true\" });\n const data = await loadInsightPanel(project, build);\n return data ? c.html(<InsightPanel project={project} build={build} data={data} />) : notFound();\n });\n}\n"],"mappings":";;;;;;;;;;;;;;AAWA,SAAS,wBAAwB,KAAwB;CACvD,IAAI,IAAI,yCAAyC,OAAO,MAAM;EAC5D,MAAM,EAAE,YAAY,MAAM,eAAe,GAAG,EAAE,IAAI,MAAM,MAAM,CAAC;EAC/D,MAAM,OAAO,MAAM,gBAAgB,OAAO;EAC1C,OAAO,OAAO,EAAE,KAAK,oBAAC,aAAD;GAAsB;GAAe;EAAO,CAAA,CAAC,IAAI,SAAS;CACjF,CAAC;CAED,IAAI,KAAK,4CAA4C,OAAO,MAAM;EAChE,MAAM,EAAE,MAAM,YAAY,MAAM,qBAAqB,GAAG,EAAE,IAAI,MAAM,MAAM,CAAC;EAC3E,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;EAClC,MAAM,cAAc,MAAM,SAAA,OAA2B,EAAE,OAAO,KAAK,IAAI,OAAO,MAAM,OAAO,CAAC;EAC5F,MAAM,OAAO,MAAM,gBAAgB,OAAO;EAC1C,OAAO,OAAO,EAAE,KAAK,oBAAC,aAAD;GAAsB;GAAe;EAAO,CAAA,CAAC,IAAI,SAAS;CACjF,CAAC;AACH;;AAGA,SAAgB,yBAAyB,KAAwB;CAC/D,wBAAwB,GAAG;CAC3B,IAAI,IAAI,kDAAkD,OAAO,MAAM;EACrE,MAAM,EAAE,YAAY,MAAM,eAAe,GAAG,EAAE,IAAI,MAAM,MAAM,CAAC;EAC/D,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,EAAE,IAAI,MAAM,SAAS,CAAC;EACtE,MAAM,OAAO,MAAM,iBAAiB,SAAS,KAAK;EAClD,OAAO,OAAO,EAAE,KAAK,oBAAC,cAAD;GAAuB;GAAgB;GAAa;EAAO,CAAA,CAAC,IAAI,SAAS;CAChG,CAAC;CAED,IAAI,KAAK,qDAAqD,OAAO,MAAM;EACzE,MAAM,EAAE,MAAM,YAAY,MAAM,qBAAqB,GAAG,EAAE,IAAI,MAAM,MAAM,CAAC;EAC3E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,EAAE,IAAI,MAAM,SAAS,CAAC;EACtE,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;EAClC,MAAM,cAAc,MAAM,SAAS,OAAO,EAAE,OAAO,KAAK,IAAI,OAAO,MAAM,OAAO,CAAC;EACjF,MAAM,OAAO,MAAM,iBAAiB,SAAS,KAAK;EAClD,OAAO,OAAO,EAAE,KAAK,oBAAC,cAAD;GAAuB;GAAgB;GAAa;EAAO,CAAA,CAAC,IAAI,SAAS;CAChG,CAAC;AACH"}
|