@storyshelf/app 0.7.0 → 0.9.0-next-d2cc082
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/dist/app-types.d.mts +1 -3
- package/dist/asset-manifest.mjs +1 -2
- package/dist/assets/htmx.mjs +1 -2
- package/dist/assets/mark.mjs +1 -2
- package/dist/boot-tasks.mjs +1 -2
- package/dist/build-state-flags.mjs +1 -2
- package/dist/build-state-setup.mjs +1 -2
- package/dist/capture-alerts.mjs +1 -2
- package/dist/capture-process.mjs +1 -2
- package/dist/capture-setup.mjs +1 -2
- package/dist/connections.mjs +1 -2
- package/dist/consumer-setup.mjs +1 -2
- package/dist/credential-keys.mjs +1 -2
- package/dist/credentials.mjs +1 -2
- package/dist/design-events.mjs +71 -0
- package/dist/design-events.mjs.map +1 -0
- package/dist/git-status.mjs +1 -2
- package/dist/hidden-data.mjs +1 -2
- package/dist/index.d.mts +0 -2
- package/dist/index.mjs +2 -3
- package/dist/insights/budget.mjs +1 -2
- package/dist/insights/deps.mjs +1 -2
- package/dist/insights/errors.mjs +1 -2
- package/dist/insights/health-panel.mjs +0 -1
- package/dist/insights/job.mjs +1 -2
- package/dist/insights/load-health.mjs +1 -2
- package/dist/insights/load-triage.mjs +1 -2
- package/dist/insights/panel.mjs +1 -2
- package/dist/insights/request.mjs +1 -2
- package/dist/insights/stale.mjs +1 -2
- package/dist/insights/usage-report.mjs +1 -2
- package/dist/insights/view.mjs +1 -2
- package/dist/job-canceller.mjs +1 -2
- package/dist/job-queue.mjs +1 -2
- package/dist/jobs-setup.mjs +1 -2
- package/dist/lifecycle.mjs +2 -3
- package/dist/lifecycle.mjs.map +1 -1
- package/dist/middleware/auth-gate.mjs +1 -2
- package/dist/middleware/csrf.mjs +1 -2
- package/dist/middleware/rate-limit.mjs +1 -2
- package/dist/middleware/request-log.mjs +1 -2
- package/dist/middleware/require-review.mjs +1 -2
- package/dist/middleware/require-testing.mjs +1 -2
- package/dist/middleware/server-timing.mjs +1 -2
- package/dist/middleware/setup-gate.mjs +1 -2
- package/dist/middleware/store-scope.mjs +1 -2
- package/dist/notifications-pause.mjs +1 -2
- package/dist/notify.d.mts +1 -3
- package/dist/notify.mjs +5 -3
- package/dist/notify.mjs.map +1 -1
- package/dist/pages/a11y-violations.mjs +1 -2
- package/dist/pages/admin-ai.mjs +1 -2
- package/dist/pages/admin-system-origin.mjs +19 -10
- package/dist/pages/admin-system-origin.mjs.map +1 -1
- package/dist/pages/admin-system-targets.mjs +1 -2
- package/dist/pages/admin-system.mjs +1 -2
- package/dist/pages/build-detail.mjs +1 -2
- package/dist/pages/build-diff-actions.mjs +1 -2
- package/dist/pages/build-diff-comments.mjs +1 -2
- package/dist/pages/build-diff-default.mjs +53 -0
- package/dist/pages/build-diff-default.mjs.map +1 -0
- package/dist/pages/build-diff-environment.mjs +34 -5
- package/dist/pages/build-diff-environment.mjs.map +1 -1
- package/dist/pages/build-diff-header.mjs +1 -2
- package/dist/pages/build-diff-insight.mjs +1 -2
- package/dist/pages/build-diff-model.mjs +20 -3
- package/dist/pages/build-diff-model.mjs.map +1 -1
- package/dist/pages/build-diff-nav.mjs +1 -2
- package/dist/pages/build-diff-reference.mjs +84 -0
- package/dist/pages/build-diff-reference.mjs.map +1 -0
- package/dist/pages/build-diff-shortcuts.mjs +1 -2
- package/dist/pages/build-diff-stale.mjs +1 -2
- package/dist/pages/build-diff-usage.mjs +1 -2
- package/dist/pages/build-diff-viewer.mjs +38 -12
- package/dist/pages/build-diff-viewer.mjs.map +1 -1
- package/dist/pages/build-diff.mjs +6 -12
- package/dist/pages/build-diff.mjs.map +1 -1
- package/dist/pages/build-tests.mjs +1 -2
- package/dist/pages/component-detail.mjs +1 -2
- package/dist/pages/component-stories.mjs +1 -2
- package/dist/pages/compute-jobs.mjs +1 -2
- package/dist/pages/design-links.mjs +1 -2
- package/dist/pages/environment-change.mjs +16 -4
- package/dist/pages/environment-change.mjs.map +1 -1
- package/dist/pages/health-panel.mjs +1 -2
- package/dist/pages/invite.mjs +1 -2
- package/dist/pages/label-detail.mjs +1 -2
- package/dist/pages/library-grid.mjs +1 -2
- package/dist/pages/library-model.mjs +1 -2
- package/dist/pages/library-snapshots.mjs +1 -2
- package/dist/pages/library.mjs +1 -2
- package/dist/pages/login.mjs +1 -2
- package/dist/pages/notifications-paused.mjs +1 -2
- package/dist/pages/passkey-ceremony.mjs +1 -2
- package/dist/pages/profile.mjs +1 -2
- package/dist/pages/project-builds-table.mjs +1 -2
- package/dist/pages/project-builds.mjs +1 -2
- package/dist/pages/project-create.mjs +1 -2
- package/dist/pages/project-settings.mjs +1 -2
- package/dist/pages/projects-card.mjs +1 -2
- package/dist/pages/projects.mjs +1 -2
- package/dist/pages/review-changeset.mjs +1 -2
- package/dist/pages/review-comments.mjs +1 -2
- package/dist/pages/review-verdict.mjs +1 -2
- package/dist/pages/root.mjs +1 -2
- package/dist/pages/settings-ai.mjs +1 -2
- package/dist/pages/settings-capture.mjs +1 -2
- package/dist/pages/settings-design-credential.mjs +1 -2
- package/dist/pages/settings-design-links.mjs +1 -2
- package/dist/pages/settings-design.mjs +1 -2
- package/dist/pages/settings-general.mjs +1 -2
- package/dist/pages/settings-labels.mjs +1 -2
- package/dist/pages/settings-links.mjs +1 -2
- package/dist/pages/settings-members.mjs +1 -2
- package/dist/pages/settings-notifications-git.mjs +1 -2
- package/dist/pages/settings-notifications.mjs +1 -2
- package/dist/pages/settings-tests.mjs +1 -2
- package/dist/pages/settings-tokens.mjs +1 -2
- package/dist/pages/settings-webhooks.mjs +1 -2
- package/dist/pages/storybook.mjs +1 -2
- package/dist/pages/target-choices.mjs +1 -2
- package/dist/publish-setup.mjs +1 -2
- package/dist/retention-timer.mjs +10 -11
- package/dist/retention-timer.mjs.map +1 -1
- package/dist/review-events.mjs +1 -2
- package/dist/routers/admin-pages.mjs +1 -2
- package/dist/routers/admin-public-origin.mjs +10 -3
- package/dist/routers/admin-public-origin.mjs.map +1 -1
- package/dist/routers/admin.mjs +8 -4
- package/dist/routers/admin.mjs.map +1 -1
- package/dist/routers/assets.mjs +1 -2
- package/dist/routers/attempts.mjs +1 -2
- package/dist/routers/auth-sync.mjs +1 -2
- package/dist/routers/auth.mjs +1 -2
- package/dist/routers/baseline-guard.mjs +2 -3
- package/dist/routers/baseline-guard.mjs.map +1 -1
- package/dist/routers/baseline-reference.mjs +72 -0
- package/dist/routers/baseline-reference.mjs.map +1 -0
- package/dist/routers/baselines.handlers.mjs +1 -2
- package/dist/routers/baselines.mjs +1 -2
- package/dist/routers/build-cancel.mjs +1 -2
- package/dist/routers/build-changeset.mjs +1 -2
- package/dist/routers/build-reviews.handlers.mjs +1 -2
- package/dist/routers/build-reviews.mjs +1 -2
- package/dist/routers/builds.handlers.mjs +23 -9
- package/dist/routers/builds.handlers.mjs.map +1 -1
- package/dist/routers/builds.mjs +7 -3
- package/dist/routers/builds.mjs.map +1 -1
- package/dist/routers/capture-cache.mjs +29 -0
- package/dist/routers/capture-cache.mjs.map +1 -0
- package/dist/routers/capture-config.mjs +1 -2
- package/dist/routers/comments.mjs +1 -2
- package/dist/routers/comparison-image.mjs +29 -0
- package/dist/routers/comparison-image.mjs.map +1 -0
- package/dist/routers/component-catalog.handlers.mjs +1 -2
- package/dist/routers/component.handlers.mjs +1 -2
- package/dist/routers/connections.mjs +1 -2
- package/dist/routers/design-connect.handlers.mjs +1 -2
- package/dist/routers/design.handlers.mjs +8 -2
- package/dist/routers/design.handlers.mjs.map +1 -1
- package/dist/routers/flash.mjs +1 -2
- package/dist/routers/health-report.mjs +1 -2
- package/dist/routers/health.mjs +1 -2
- package/dist/routers/helpers.mjs +1 -2
- package/dist/routers/htmx.mjs +1 -2
- package/dist/routers/insights-ui.mjs +1 -2
- package/dist/routers/insights.handlers.mjs +1 -2
- package/dist/routers/insights.mjs +1 -2
- package/dist/routers/labels.mjs +1 -2
- package/dist/routers/links.handlers.mjs +1 -2
- package/dist/routers/links.mjs +1 -2
- package/dist/routers/media.mjs +14 -5
- package/dist/routers/media.mjs.map +1 -1
- package/dist/routers/members.mjs +1 -2
- package/dist/routers/notification-channels.mjs +1 -2
- package/dist/routers/notification-subscriptions.mjs +1 -2
- package/dist/routers/oauth-state.mjs +1 -2
- package/dist/routers/preview-base.mjs +1 -2
- package/dist/routers/preview-token.mjs +1 -2
- package/dist/routers/profile.mjs +1 -2
- package/dist/routers/projects.mjs +1 -2
- package/dist/routers/publish-upload.mjs +19 -12
- package/dist/routers/publish-upload.mjs.map +1 -1
- package/dist/routers/published-access.mjs +1 -2
- package/dist/routers/published-compression.mjs +109 -0
- package/dist/routers/published-compression.mjs.map +1 -0
- package/dist/routers/published-files.mjs +26 -9
- package/dist/routers/published-files.mjs.map +1 -1
- package/dist/routers/review-forms.mjs +1 -2
- package/dist/routers/review-next.mjs +1 -2
- package/dist/routers/review-section.handlers.mjs +1 -2
- package/dist/routers/review-switch-off.mjs +1 -2
- package/dist/routers/schemas.mjs +1 -2
- package/dist/routers/scoped-models.mjs +1 -2
- package/dist/routers/settings-ai.mjs +1 -2
- package/dist/routers/settings-capture.mjs +1 -2
- package/dist/routers/settings-design.mjs +1 -2
- package/dist/routers/settings-general.mjs +1 -2
- package/dist/routers/settings-git-channel.mjs +1 -2
- package/dist/routers/settings-labels.mjs +1 -2
- package/dist/routers/settings-links.mjs +1 -2
- package/dist/routers/settings-members.mjs +1 -2
- package/dist/routers/settings-notifications.mjs +1 -2
- package/dist/routers/settings-tokens.mjs +1 -2
- package/dist/routers/settings-webhooks.mjs +1 -2
- package/dist/routers/settings.handlers.mjs +1 -2
- package/dist/routers/settings.mjs +1 -2
- package/dist/routers/signed-storybook.mjs +5 -3
- package/dist/routers/signed-storybook.mjs.map +1 -1
- package/dist/routers/snapshot-groups.mjs +1 -2
- package/dist/routers/snapshots.mjs +1 -2
- package/dist/routers/storybook.mjs +21 -17
- package/dist/routers/storybook.mjs.map +1 -1
- package/dist/routers/tokens.mjs +1 -2
- package/dist/routers/ui.mjs +4 -2
- package/dist/routers/ui.mjs.map +1 -1
- package/dist/routers/webhooks.mjs +1 -2
- package/dist/routers/well-known.mjs +1 -2
- package/dist/routes/api-routes.mjs +1 -2
- package/dist/routes/classification.mjs +1 -2
- package/dist/routes/openapi-classes.mjs +1 -2
- package/dist/routes/page-routes.mjs +2 -3
- package/dist/routes/page-routes.mjs.map +1 -1
- package/dist/runtime.mjs +1 -2
- package/dist/serverless.mjs +1 -2
- package/dist/store.mjs +1 -2
- package/dist/targets.mjs +1 -2
- package/dist/ui/avatar.mjs +1 -2
- package/dist/ui/buttons.mjs +1 -2
- package/dist/ui/code-block.mjs +1 -2
- package/dist/ui/compare-stage.mjs +86 -54
- package/dist/ui/compare-stage.mjs.map +1 -1
- package/dist/ui/csrf-field.mjs +1 -2
- package/dist/ui/css.mjs +1 -2
- package/dist/ui/design-reference.mjs +1 -2
- package/dist/ui/document.mjs +1 -2
- package/dist/ui/dropdown.mjs +1 -2
- package/dist/ui/feedback.mjs +1 -2
- package/dist/ui/filter-input.mjs +1 -2
- package/dist/ui/forms.mjs +1 -2
- package/dist/ui/icons/icon.mjs +1 -2
- package/dist/ui/icons/paths.mjs +1 -2
- package/dist/ui/icons/sprite.mjs +1 -2
- package/dist/ui/kbd.mjs +1 -2
- package/dist/ui/layout.mjs +1 -2
- package/dist/ui/preview-embed.mjs +1 -2
- package/dist/ui/progress.mjs +1 -2
- package/dist/ui/segmented.mjs +1 -2
- package/dist/ui/shell/account.mjs +1 -2
- package/dist/ui/shell/bare.mjs +1 -2
- package/dist/ui/shell/boot-script.mjs +1 -2
- package/dist/ui/shell/client-script.mjs +1 -2
- package/dist/ui/shell/nav.mjs +1 -2
- package/dist/ui/shell/review-script.mjs +17 -3
- package/dist/ui/shell/review-script.mjs.map +1 -1
- package/dist/ui/shell/sidebar.mjs +1 -2
- package/dist/ui/shell/topbar.mjs +1 -2
- package/dist/ui/stacks.mjs +1 -2
- package/dist/ui/styles/base.mjs +1 -2
- package/dist/ui/styles/behavior.mjs +1 -2
- package/dist/ui/styles/review-layout.mjs +1 -2
- package/dist/ui/styles/review.mjs +1 -2
- package/dist/ui/styles/tokens.mjs +1 -2
- package/dist/ui/styles.mjs +1 -2
- package/dist/ui/subnav.mjs +1 -2
- package/dist/ui/table.mjs +1 -2
- package/dist/ui/theme.mjs +1 -2
- package/dist/ui/thumbnail.mjs +1 -2
- package/dist/ui/time.mjs +1 -2
- package/package.json +9 -18
package/dist/routers/media.mjs
CHANGED
|
@@ -4,8 +4,9 @@ import.meta.url;
|
|
|
4
4
|
import { credentialKeys } from "../credential-keys.mjs";
|
|
5
5
|
import { getStore } from "../store.mjs";
|
|
6
6
|
import { notFound, resolveAuthorizedProject } from "./helpers.mjs";
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import { readComparisonImage, validFrozenReference } from "./comparison-image.mjs";
|
|
8
|
+
import { BaselineModel, BuildModel, DesignCredentialModel, DesignNodeModel, SnapshotModel, baselineStatus } from "@storyshelf/core/models";
|
|
9
|
+
// src/routers/media.ts
|
|
9
10
|
const VIEW_ROLES = [
|
|
10
11
|
"viewer",
|
|
11
12
|
"developer",
|
|
@@ -50,10 +51,19 @@ function registerMedia(app) {
|
|
|
50
51
|
const snapshot = await findSnapshot(c.req.param("buildId"), c.req.param("snapshotId"), project.id);
|
|
51
52
|
const build = await new BuildModel(getStore().db).get(snapshot.buildId);
|
|
52
53
|
if (!build) notFound("Build not found");
|
|
54
|
+
if (c.req.query("reference") !== "default" && (snapshot.comparisonImagePath || snapshot.comparisonMetadata)) {
|
|
55
|
+
const frozen = validFrozenReference(snapshot, project.id, build.id);
|
|
56
|
+
if (!frozen) notFound("Original baseline is unavailable");
|
|
57
|
+
const image = await readComparisonImage(frozen.path, frozen.reference.imageSha256);
|
|
58
|
+
if (!image) notFound("Original baseline image is unavailable");
|
|
59
|
+
return imageResponse(image, "private, no-store");
|
|
60
|
+
}
|
|
53
61
|
const baselines = new BaselineModel(getStore().db, void 0, getStore().storage);
|
|
54
|
-
const baseline = await baselines.resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch, snapshot.browser);
|
|
62
|
+
const baseline = c.req.query("reference") === "default" ? await baselines.getFor(project.id, snapshot.storyId, snapshot.viewportName, project.gitDefaultBranch, snapshot.browser) : await baselines.resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch, snapshot.browser);
|
|
63
|
+
if (c.req.query("reference") === "default" && (baseline?.id !== c.req.query("id") || baseline?.updatedAt !== c.req.query("version"))) notFound("Default baseline changed; reload the comparison");
|
|
55
64
|
if (!baseline) notFound("No baseline for this snapshot");
|
|
56
|
-
|
|
65
|
+
if (c.req.query("reference") !== "default" && snapshot.baselineVersion !== null && baselineStatus(snapshot, baseline) !== "current") notFound("Original baseline is no longer available");
|
|
66
|
+
return imageResponse(await baselines.read(baseline), "private, no-store");
|
|
57
67
|
});
|
|
58
68
|
}
|
|
59
69
|
async function findSnapshot(buildId, snapshotId, projectId) {
|
|
@@ -61,7 +71,6 @@ async function findSnapshot(buildId, snapshotId, projectId) {
|
|
|
61
71
|
if (!snapshot || snapshot.buildId !== buildId || snapshot.projectId !== projectId) notFound("Snapshot not found");
|
|
62
72
|
return snapshot;
|
|
63
73
|
}
|
|
64
|
-
//#endregion
|
|
65
74
|
export { registerMedia };
|
|
66
75
|
|
|
67
76
|
//# sourceMappingURL=media.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"media.mjs","names":[],"sources":["../../src/routers/media.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"file":"media.mjs","names":[],"sources":["../../src/routers/media.ts"],"sourcesContent":["import {\n BaselineModel,\n DesignCredentialModel,\n DesignNodeModel,\n baselineStatus,\n} from \"@storyshelf/core/models\";\nimport { BuildModel } from \"@storyshelf/core/models\";\nimport { SnapshotModel } from \"@storyshelf/core/models\";\nimport type { Snapshot } from \"@storyshelf/core/schema\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { getStore } from \"../store.ts\";\nimport { readComparisonImage, validFrozenReference } from \"./comparison-image.ts\";\nimport { notFound, resolveAuthorizedProject } from \"./helpers.ts\";\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\n\nconst PNG = \"image/png\";\n\nfunction imageResponse(buffer: Buffer, cacheControl = \"private, max-age=3600\"): Response {\n return new Response(new Uint8Array(buffer), {\n headers: { \"content-type\": PNG, \"cache-control\": cacheControl },\n });\n}\n\n/** Design renders are addressed by file version (`?v=`): immutable once the version matches the row. */\nconst RENDER_IMMUTABLE = \"private, max-age=31536000, immutable\";\n\n/** Register the PNG image, diff, and baseline media endpoints for snapshots. */\nexport function registerMedia(app: ShelfRouter): void {\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/image\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(\n c.req.param(\"buildId\"),\n c.req.param(\"snapshotId\"),\n project.id,\n );\n return imageResponse(await getStore().storage.read(snapshot.screenshotPath));\n });\n\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/diff\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(\n c.req.param(\"buildId\"),\n c.req.param(\"snapshotId\"),\n project.id,\n );\n if (!snapshot.diffPath) {\n notFound(\"No diff for this snapshot\");\n }\n return imageResponse(await getStore().storage.read(snapshot.diffPath));\n });\n\n // `:renderId` is the internal design_nodes row id (a ULID), not a provider node id.\n app.get(\"/api/v1/projects/:slug/design/renders/:renderId\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const { db, config, storage, design } = getStore();\n const credentials = new DesignCredentialModel(db, credentialKeys(config));\n const nodes = new DesignNodeModel(db, credentials, design, { storage });\n const found = await nodes.get(project.id, c.req.param(\"renderId\"));\n // A render fetched with a member's own connection is theirs alone (it may show files others cannot open).\n const node =\n found && (found.viewerId === \"\" || found.viewerId === getStore().user?.id) ? found : null;\n const png = node ? await nodes.readRender(node) : null;\n if (!png) {\n notFound(\"No design render\");\n }\n const versioned = node?.renderVersion && c.req.query(\"v\") === node.renderVersion;\n return imageResponse(png, versioned ? RENDER_IMMUTABLE : undefined);\n });\n\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/baseline\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(\n c.req.param(\"buildId\"),\n c.req.param(\"snapshotId\"),\n project.id,\n );\n const build = await new BuildModel(getStore().db).get(snapshot.buildId);\n if (!build) {\n notFound(\"Build not found\");\n }\n if (\n c.req.query(\"reference\") !== \"default\" &&\n (snapshot.comparisonImagePath || snapshot.comparisonMetadata)\n ) {\n const frozen = validFrozenReference(snapshot, project.id, build.id);\n if (!frozen) {\n notFound(\"Original baseline is unavailable\");\n }\n const image = await readComparisonImage(frozen.path, frozen.reference.imageSha256);\n if (!image) {\n notFound(\"Original baseline image is unavailable\");\n }\n return imageResponse(image, \"private, no-store\");\n }\n const baselines = new BaselineModel(getStore().db, undefined, getStore().storage);\n const baseline =\n c.req.query(\"reference\") === \"default\"\n ? await baselines.getFor(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n project.gitDefaultBranch,\n snapshot.browser,\n )\n : await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n snapshot.browser,\n );\n if (\n c.req.query(\"reference\") === \"default\" &&\n (baseline?.id !== c.req.query(\"id\") || baseline?.updatedAt !== c.req.query(\"version\"))\n ) {\n notFound(\"Default baseline changed; reload the comparison\");\n }\n if (!baseline) {\n notFound(\"No baseline for this snapshot\");\n }\n if (\n c.req.query(\"reference\") !== \"default\" &&\n snapshot.baselineVersion !== null &&\n baselineStatus(snapshot, baseline) !== \"current\"\n ) {\n notFound(\"Original baseline is no longer available\");\n }\n return imageResponse(await baselines.read(baseline), \"private, no-store\");\n });\n}\n\nasync function findSnapshot(\n buildId: string,\n snapshotId: string,\n projectId: string,\n): Promise<Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== buildId || snapshot.projectId !== projectId) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}\n"],"mappings":"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"}
|
package/dist/routers/members.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { groupMappingCreateSchema, groupMappingSchema, memberRoleSchema, memberS
|
|
|
7
7
|
import { createRoute, z } from "@hono/zod-openapi";
|
|
8
8
|
import { MemberModel, ProjectGroupMappingModel } from "@storyshelf/core/models";
|
|
9
9
|
import { HTTPException } from "hono/http-exception";
|
|
10
|
-
|
|
10
|
+
// src/routers/members.ts
|
|
11
11
|
const VIEW_ROLES = [
|
|
12
12
|
"viewer",
|
|
13
13
|
"developer",
|
|
@@ -172,7 +172,6 @@ function registerGroupMappingRoutes(app) {
|
|
|
172
172
|
return c.body(null, 204);
|
|
173
173
|
});
|
|
174
174
|
}
|
|
175
|
-
//#endregion
|
|
176
175
|
export { registerMembers };
|
|
177
176
|
|
|
178
177
|
//# sourceMappingURL=members.mjs.map
|
|
@@ -7,7 +7,7 @@ import { notFound as notFound$1, notificationChannelCreateSchema, notificationCh
|
|
|
7
7
|
import { notificationChannelModel } from "./scoped-models.mjs";
|
|
8
8
|
import { createRoute, z } from "@hono/zod-openapi";
|
|
9
9
|
import { NotificationChannelModel } from "@storyshelf/core/models";
|
|
10
|
-
|
|
10
|
+
// src/routers/notification-channels.ts
|
|
11
11
|
const ADMIN_ROLES = ["admin"];
|
|
12
12
|
/** Public channel view (secret redacted, config/events decoded). */
|
|
13
13
|
function toPublic(row) {
|
|
@@ -178,7 +178,6 @@ function registerSystemChannels(app) {
|
|
|
178
178
|
return c.body(null, 204);
|
|
179
179
|
});
|
|
180
180
|
}
|
|
181
|
-
//#endregion
|
|
182
181
|
export { registerNotificationChannels, registerSystemChannels };
|
|
183
182
|
|
|
184
183
|
//# sourceMappingURL=notification-channels.mjs.map
|
|
@@ -6,7 +6,7 @@ import { resolveAuthorizedProject, unauthorized } from "./helpers.mjs";
|
|
|
6
6
|
import { notFound, notificationSubscriptionPutSchema, notificationSubscriptionSchema, unauthorized as unauthorized$1 } from "./schemas.mjs";
|
|
7
7
|
import { createRoute, z } from "@hono/zod-openapi";
|
|
8
8
|
import { NotificationSubscriptionModel } from "@storyshelf/core/models";
|
|
9
|
-
|
|
9
|
+
// src/routers/notification-subscriptions.ts
|
|
10
10
|
const VIEW_ROLES = [
|
|
11
11
|
"viewer",
|
|
12
12
|
"developer",
|
|
@@ -114,7 +114,6 @@ function registerNotificationSubscriptions(app) {
|
|
|
114
114
|
return c.json(rows.map((row) => toPublic(row)));
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
|
-
//#endregion
|
|
118
117
|
export { registerNotificationSubscriptions };
|
|
119
118
|
|
|
120
119
|
//# sourceMappingURL=notification-subscriptions.mjs.map
|
|
@@ -3,7 +3,7 @@ import "node:url";
|
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { timingSafeEqualString } from "@storyshelf/core/utils";
|
|
5
5
|
import { createHmac, randomBytes } from "node:crypto";
|
|
6
|
-
|
|
6
|
+
// src/routers/oauth-state.ts
|
|
7
7
|
/** Signed, expiring OAuth `state` for "connect your account" flows (binds user, service and browser). */
|
|
8
8
|
/** State lifetime: long enough to sign in at the service, short enough to limit replay. */
|
|
9
9
|
const STATE_TTL_MS = 6e5;
|
|
@@ -39,7 +39,6 @@ function safeReturnPath(value, fallback) {
|
|
|
39
39
|
function mac(secret, payload) {
|
|
40
40
|
return createHmac("sha256", secret).update(`oauth-connect:${payload}`).digest("base64url");
|
|
41
41
|
}
|
|
42
|
-
//#endregion
|
|
43
42
|
export { safeReturnPath, signState, verifyState };
|
|
44
43
|
|
|
45
44
|
//# sourceMappingURL=oauth-state.mjs.map
|
|
@@ -6,7 +6,7 @@ import { getStore, testingEnabled } from "../store.mjs";
|
|
|
6
6
|
import { isAnonymouslyViewable, publishedAccess } from "./published-access.mjs";
|
|
7
7
|
import { STORYBOOK_TOKEN_PREFIX, signStorybookToken } from "@storyshelf/core/utils";
|
|
8
8
|
import { createUrlBuilder } from "@storyshelf/core/urls";
|
|
9
|
-
|
|
9
|
+
// src/routers/preview-base.ts
|
|
10
10
|
/** Lifetime of the token in a page: long enough to open the frame, short enough to be harmless. */
|
|
11
11
|
const PREVIEW_TOKEN_TTL = 3600;
|
|
12
12
|
/**
|
|
@@ -32,7 +32,6 @@ async function previewBaseOf(project, build) {
|
|
|
32
32
|
});
|
|
33
33
|
return `${STORYBOOK_TOKEN_PREFIX}${token}`;
|
|
34
34
|
}
|
|
35
|
-
//#endregion
|
|
36
35
|
export { PREVIEW_TOKEN_REFRESH, PREVIEW_TOKEN_TTL, isSignedPreviewBase, previewBaseOf };
|
|
37
36
|
|
|
38
37
|
//# sourceMappingURL=preview-base.mjs.map
|
|
@@ -6,7 +6,7 @@ import { isSignedPreviewBase, previewBaseOf } from "./preview-base.mjs";
|
|
|
6
6
|
import { SIGNED_URL_HEADERS } from "./published-files.mjs";
|
|
7
7
|
import { createRoute, z } from "@hono/zod-openapi";
|
|
8
8
|
import { BuildModel, ProjectModel } from "@storyshelf/core/models";
|
|
9
|
-
|
|
9
|
+
// src/routers/preview-token.ts
|
|
10
10
|
/**
|
|
11
11
|
* `GET /api/v1/projects/:slug/builds/:buildId/preview-token`: a fresh root for a component page's preview frame
|
|
12
12
|
* (ADR 0006, decision 9). A frame holding a signed `/_s/<token>` URL (ADR 0007, decision 5) outlives
|
|
@@ -45,7 +45,6 @@ function registerPreviewToken(app) {
|
|
|
45
45
|
return c.json({ base }, 200, SIGNED_URL_HEADERS);
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
-
//#endregion
|
|
49
48
|
export { registerPreviewToken };
|
|
50
49
|
|
|
51
50
|
//# sourceMappingURL=preview-token.mjs.map
|
package/dist/routers/profile.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { NotificationSubscriptionModel, ProjectModel, UserModel } from "@storysh
|
|
|
11
11
|
import { SESSION_COOKIE } from "@storyshelf/core/types";
|
|
12
12
|
import { MIN_PASSWORD_LENGTH, unsignedToken } from "@storyshelf/core/auth";
|
|
13
13
|
import { eq, getTableColumns } from "@storyshelf/core/orm";
|
|
14
|
-
|
|
14
|
+
// src/routers/profile.ts
|
|
15
15
|
function field(form, name) {
|
|
16
16
|
const raw = form.get(name);
|
|
17
17
|
return typeof raw === "string" ? raw : "";
|
|
@@ -272,7 +272,6 @@ function readPasswordChange(form) {
|
|
|
272
272
|
next
|
|
273
273
|
};
|
|
274
274
|
}
|
|
275
|
-
//#endregion
|
|
276
275
|
export { registerProfile };
|
|
277
276
|
|
|
278
277
|
//# sourceMappingURL=profile.mjs.map
|
|
@@ -11,7 +11,7 @@ import { createRoute, z } from "@hono/zod-openapi";
|
|
|
11
11
|
import { resolveBrowsers } from "@storyshelf/core/capture";
|
|
12
12
|
import { LabelModel, MemberModel, ProjectModel } from "@storyshelf/core/models";
|
|
13
13
|
import { HTTPException } from "hono/http-exception";
|
|
14
|
-
|
|
14
|
+
// src/routers/projects.ts
|
|
15
15
|
const VIEW_ROLES = [
|
|
16
16
|
"viewer",
|
|
17
17
|
"developer",
|
|
@@ -143,7 +143,6 @@ function registerProjects(app) {
|
|
|
143
143
|
return c.body(null, 204);
|
|
144
144
|
});
|
|
145
145
|
}
|
|
146
|
-
//#endregion
|
|
147
146
|
export { registerProjects };
|
|
148
147
|
|
|
149
148
|
//# sourceMappingURL=projects.mjs.map
|
|
@@ -4,10 +4,10 @@ import.meta.url;
|
|
|
4
4
|
import { getStore } from "../store.mjs";
|
|
5
5
|
import { DEFAULT_SCRATCH_DIR } from "@storyshelf/core/config";
|
|
6
6
|
import { storybookZipPath } from "@storyshelf/core/utils";
|
|
7
|
-
import { FILE_BY_FILE_HINT, extractionLimitsOf, publishRoute, publishStoredZip, resolvePublishLimits } from "@storyshelf/core/capture";
|
|
7
|
+
import { FILE_BY_FILE_HINT, ZipInvalidError, ZipLimitError, extractionLimitsOf, publishRoute, publishStoredZip, resolvePublishLimits } from "@storyshelf/core/capture";
|
|
8
8
|
import { BuildModel } from "@storyshelf/core/models";
|
|
9
9
|
import { HTTPException } from "hono/http-exception";
|
|
10
|
-
|
|
10
|
+
// src/routers/publish-upload.ts
|
|
11
11
|
/**
|
|
12
12
|
* What the zip upload route does once the zip is stored (ADR 0006, decision 10): extract it
|
|
13
13
|
* inline when it is small, hand it to a `publish` job when it is larger, or refuse it with a
|
|
@@ -61,18 +61,25 @@ async function publishInline(build) {
|
|
|
61
61
|
logger.info({ buildId: build.id }, "upload statics persisted inline");
|
|
62
62
|
await publish?.completer.complete(build.id, { ok: true });
|
|
63
63
|
} catch (error) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
await publish?.completer.complete(build.id, {
|
|
70
|
-
ok: false,
|
|
71
|
-
error: message
|
|
72
|
-
});
|
|
64
|
+
if (error instanceof ZipInvalidError || error instanceof ZipLimitError) {
|
|
65
|
+
await discardZip(build.projectId, build.id);
|
|
66
|
+
throw new HTTPException(422, { message: error.message });
|
|
67
|
+
}
|
|
68
|
+
await failInlinePublish(build, error);
|
|
73
69
|
}
|
|
74
70
|
}
|
|
75
|
-
|
|
71
|
+
async function failInlinePublish(build, error) {
|
|
72
|
+
const { logger, publish } = getStore();
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
logger.error({
|
|
75
|
+
err: error,
|
|
76
|
+
buildId: build.id
|
|
77
|
+
}, "inline publish failed");
|
|
78
|
+
await publish?.completer.complete(build.id, {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: message
|
|
81
|
+
});
|
|
82
|
+
}
|
|
76
83
|
export { discardZip, planUpload, publishUpload };
|
|
77
84
|
|
|
78
85
|
//# sourceMappingURL=publish-upload.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"publish-upload.mjs","names":[],"sources":["../../src/routers/publish-upload.ts"],"sourcesContent":["/**\n * What the zip upload route does once the zip is stored (ADR 0006, decision 10): extract it\n * inline when it is small, hand it to a `publish` job when it is larger, or refuse it with a\n * pointer to the file-by-file route on a host that runs no consumer.\n */\nimport {\n extractionLimitsOf,\n FILE_BY_FILE_HINT,\n publishRoute,\n publishStoredZip,\n resolvePublishLimits,\n} from \"@storyshelf/core/capture\";\nimport { DEFAULT_SCRATCH_DIR } from \"@storyshelf/core/config\";\nimport { BuildModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport { storybookZipPath } from \"@storyshelf/core/utils\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { getStore } from \"../store.ts\";\n\n/** How an upload of `zipBytes` will be published, or a 413 when this host cannot publish it. */\nexport function planUpload(zipBytes: number): \"inline\" | \"job\" {\n const { config, publish } = getStore();\n const route = publishRoute(zipBytes, resolvePublishLimits(config));\n if (route === \"job\" && !publish?.enqueuePublish) {\n throw new HTTPException(413, { message: FILE_BY_FILE_HINT });\n }\n return route;\n}\n\n/** Remove a stored zip that was refused (best effort), so no orphan is left behind. */\nexport async function discardZip(projectId: string, buildId: string): Promise<void> {\n try {\n await getStore().storage.delete(storybookZipPath(projectId, buildId));\n } catch (error) {\n getStore().logger.warn({ err: error, buildId }, \"could not remove a refused zip\");\n }\n}\n\n/**\n * Publish the stored zip of `build` per `route`. Inline: extract now and complete the build (a\n * failure fails it, or, with a capture to come, is left for capture to backfill). Job: queue it.\n * Resolves to the build as it stands afterwards.\n */\nexport async function publishUpload(\n route: \"inline\" | \"job\",\n build: Build,\n reqId: string | undefined,\n): Promise<Build> {\n const { publish } = getStore();\n if (route === \"job\") {\n await publish?.enqueuePublish?.(build.id, reqId);\n return build;\n }\n await publishInline(build);\n return (await new BuildModel(getStore().db).get(build.id)) ?? build;\n}\n\nasync function publishInline(build: Build): Promise<void> {\n const { storage, config, logger, publish, db } = getStore();\n try {\n await publishStoredZip(\n {\n storage,\n scratchDir: config.scratchDir ?? DEFAULT_SCRATCH_DIR,\n limits: extractionLimitsOf(resolvePublishLimits(config)),\n db,\n logger,\n },\n { projectId: build.projectId, buildId: build.id },\n );\n logger.info({ buildId: build.id }, \"upload statics persisted inline\");\n await publish?.completer.complete(build.id, { ok: true });\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n
|
|
1
|
+
{"version":3,"file":"publish-upload.mjs","names":[],"sources":["../../src/routers/publish-upload.ts"],"sourcesContent":["/**\n * What the zip upload route does once the zip is stored (ADR 0006, decision 10): extract it\n * inline when it is small, hand it to a `publish` job when it is larger, or refuse it with a\n * pointer to the file-by-file route on a host that runs no consumer.\n */\nimport {\n extractionLimitsOf,\n FILE_BY_FILE_HINT,\n publishRoute,\n publishStoredZip,\n resolvePublishLimits,\n ZipInvalidError,\n ZipLimitError,\n} from \"@storyshelf/core/capture\";\nimport { DEFAULT_SCRATCH_DIR } from \"@storyshelf/core/config\";\nimport { BuildModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport { storybookZipPath } from \"@storyshelf/core/utils\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { getStore } from \"../store.ts\";\n\n/** How an upload of `zipBytes` will be published, or a 413 when this host cannot publish it. */\nexport function planUpload(zipBytes: number): \"inline\" | \"job\" {\n const { config, publish } = getStore();\n const route = publishRoute(zipBytes, resolvePublishLimits(config));\n if (route === \"job\" && !publish?.enqueuePublish) {\n throw new HTTPException(413, { message: FILE_BY_FILE_HINT });\n }\n return route;\n}\n\n/** Remove a stored zip that was refused (best effort), so no orphan is left behind. */\nexport async function discardZip(projectId: string, buildId: string): Promise<void> {\n try {\n await getStore().storage.delete(storybookZipPath(projectId, buildId));\n } catch (error) {\n getStore().logger.warn({ err: error, buildId }, \"could not remove a refused zip\");\n }\n}\n\n/**\n * Publish the stored zip of `build` per `route`. Inline: extract now and complete the build (a\n * failure fails it, or, with a capture to come, is left for capture to backfill). Job: queue it.\n * Resolves to the build as it stands afterwards.\n */\nexport async function publishUpload(\n route: \"inline\" | \"job\",\n build: Build,\n reqId: string | undefined,\n): Promise<Build> {\n const { publish } = getStore();\n if (route === \"job\") {\n await publish?.enqueuePublish?.(build.id, reqId);\n return build;\n }\n await publishInline(build);\n return (await new BuildModel(getStore().db).get(build.id)) ?? build;\n}\n\nasync function publishInline(build: Build): Promise<void> {\n const { storage, config, logger, publish, db } = getStore();\n try {\n await publishStoredZip(\n {\n storage,\n scratchDir: config.scratchDir ?? DEFAULT_SCRATCH_DIR,\n limits: extractionLimitsOf(resolvePublishLimits(config)),\n db,\n logger,\n },\n { projectId: build.projectId, buildId: build.id },\n );\n logger.info({ buildId: build.id }, \"upload statics persisted inline\");\n await publish?.completer.complete(build.id, { ok: true });\n } catch (error) {\n if (error instanceof ZipInvalidError || error instanceof ZipLimitError) {\n await discardZip(build.projectId, build.id);\n throw new HTTPException(422, { message: error.message });\n }\n await failInlinePublish(build, error);\n }\n}\n\nasync function failInlinePublish(build: Build, error: unknown): Promise<void> {\n const { logger, publish } = getStore();\n const message = error instanceof Error ? error.message : String(error);\n logger.error({ err: error, buildId: build.id }, \"inline publish failed\");\n await publish?.completer.complete(build.id, { ok: false, error: message });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAsBA,SAAgB,WAAW,UAAoC;CAC7D,MAAM,EAAE,QAAQ,YAAY,SAAS;CACrC,MAAM,QAAQ,aAAa,UAAU,qBAAqB,MAAM,CAAC;CACjE,IAAI,UAAU,SAAS,CAAC,SAAS,gBAC/B,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,kBAAkB,CAAC;CAE7D,OAAO;AACT;;AAGA,eAAsB,WAAW,WAAmB,SAAgC;CAClF,IAAI;EACF,MAAM,SAAS,CAAC,CAAC,QAAQ,OAAO,iBAAiB,WAAW,OAAO,CAAC;CACtE,SAAS,OAAO;EACd,SAAS,CAAC,CAAC,OAAO,KAAK;GAAE,KAAK;GAAO;EAAQ,GAAG,gCAAgC;CAClF;AACF;;;;;;AAOA,eAAsB,cACpB,OACA,OACA,OACgB;CAChB,MAAM,EAAE,YAAY,SAAS;CAC7B,IAAI,UAAU,OAAO;EACnB,MAAM,SAAS,iBAAiB,MAAM,IAAI,KAAK;EAC/C,OAAO;CACT;CACA,MAAM,cAAc,KAAK;CACzB,OAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,EAAE,KAAM;AAChE;AAEA,eAAe,cAAc,OAA6B;CACxD,MAAM,EAAE,SAAS,QAAQ,QAAQ,SAAS,OAAO,SAAS;CAC1D,IAAI;EACF,MAAM,iBACJ;GACE;GACA,YAAY,OAAO,cAAc;GACjC,QAAQ,mBAAmB,qBAAqB,MAAM,CAAC;GACvD;GACA;EACF,GACA;GAAE,WAAW,MAAM;GAAW,SAAS,MAAM;EAAG,CAClD;EACA,OAAO,KAAK,EAAE,SAAS,MAAM,GAAG,GAAG,iCAAiC;EACpE,MAAM,SAAS,UAAU,SAAS,MAAM,IAAI,EAAE,IAAI,KAAK,CAAC;CAC1D,SAAS,OAAO;EACd,IAAI,iBAAiB,mBAAmB,iBAAiB,eAAe;GACtE,MAAM,WAAW,MAAM,WAAW,MAAM,EAAE;GAC1C,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,MAAM,QAAQ,CAAC;EACzD;EACA,MAAM,kBAAkB,OAAO,KAAK;CACtC;AACF;AAEA,eAAe,kBAAkB,OAAc,OAA+B;CAC5E,MAAM,EAAE,QAAQ,YAAY,SAAS;CACrC,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;CACrE,OAAO,MAAM;EAAE,KAAK;EAAO,SAAS,MAAM;CAAG,GAAG,uBAAuB;CACvE,MAAM,SAAS,UAAU,SAAS,MAAM,IAAI;EAAE,IAAI;EAAO,OAAO;CAAQ,CAAC;AAC3E"}
|
|
@@ -4,7 +4,7 @@ import.meta.url;
|
|
|
4
4
|
import { getStore } from "../store.mjs";
|
|
5
5
|
import { currentProjectRole } from "./helpers.mjs";
|
|
6
6
|
import { isPublicBuild } from "@storyshelf/core/models";
|
|
7
|
-
|
|
7
|
+
// src/routers/published-access.ts
|
|
8
8
|
/**
|
|
9
9
|
* The one policy for reading a build's published files (ADR 0007, decision 5; ADR 0011): a signed
|
|
10
10
|
* token naming the build, or a build anyone may open (public, or an instance without sign-in), or
|
|
@@ -46,7 +46,6 @@ function isAnonymouslyViewable(build, project) {
|
|
|
46
46
|
function publishedCorsHeaders(access) {
|
|
47
47
|
return access === "session" ? {} : { "access-control-allow-origin": "*" };
|
|
48
48
|
}
|
|
49
|
-
//#endregion
|
|
50
49
|
export { isAnonymouslyViewable, publishedAccess, publishedCorsHeaders };
|
|
51
50
|
|
|
52
51
|
//# sourceMappingURL=published-access.mjs.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { publishedGzipPath } from "@storyshelf/core/utils";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { gunzip, gzip } from "node:zlib";
|
|
8
|
+
// src/routers/published-compression.ts
|
|
9
|
+
const compress = promisify(gzip);
|
|
10
|
+
const decompress = promisify(gunzip);
|
|
11
|
+
const MAX_FILE_BYTES = 8388608;
|
|
12
|
+
const MAX_JOBS = 2;
|
|
13
|
+
const MAX_READS = 4;
|
|
14
|
+
const READ_TIMEOUT_MS = 250;
|
|
15
|
+
const states = /* @__PURE__ */ new WeakMap();
|
|
16
|
+
/** Gzip is chosen only if explicitly accepted, or by a nonzero wildcard. */
|
|
17
|
+
function acceptsGzip(header) {
|
|
18
|
+
if (!header) return false;
|
|
19
|
+
const entries = header.split(",").map((part) => part.trim().split(";"));
|
|
20
|
+
const entry = entries.find(([name]) => name?.trim().toLowerCase() === "gzip") ?? entries.find(([name]) => name?.trim() === "*");
|
|
21
|
+
if (!entry) return false;
|
|
22
|
+
const quality = entry.slice(1).find((part) => /^\s*q\s*=/iu.test(part));
|
|
23
|
+
if (quality === void 0) return true;
|
|
24
|
+
const value = Number(quality.split("=")[1]);
|
|
25
|
+
return Number.isFinite(value) && value > 0 && value <= 1;
|
|
26
|
+
}
|
|
27
|
+
/** Return a verified existing variant, or the original while a bounded background job runs. */
|
|
28
|
+
async function publishedGzip(storage, hash, original, mime) {
|
|
29
|
+
if (typeof hash !== "string" || !eligible(hash, original, mime)) return void 0;
|
|
30
|
+
const state = stateOf(storage);
|
|
31
|
+
if (state.reads >= MAX_READS) return void 0;
|
|
32
|
+
state.reads += 1;
|
|
33
|
+
const found = await withDeadline(findVariant(storage, hash, original).finally(() => {
|
|
34
|
+
state.reads -= 1;
|
|
35
|
+
}));
|
|
36
|
+
if (found === "timeout") return void 0;
|
|
37
|
+
if (found) return found;
|
|
38
|
+
schedule(storage, state, hash, original);
|
|
39
|
+
}
|
|
40
|
+
function eligible(hash, original, mime) {
|
|
41
|
+
return typeof hash === "string" && /^[a-f0-9]{64}$/u.test(hash) && original.length > 0 && original.length <= MAX_FILE_BYTES && /^(?:text\/|application\/(?:javascript|json|xml|wasm)|image\/svg\+xml)/iu.test(mime);
|
|
42
|
+
}
|
|
43
|
+
function stateOf(storage) {
|
|
44
|
+
let state = states.get(storage);
|
|
45
|
+
if (!state) {
|
|
46
|
+
state = {
|
|
47
|
+
jobs: /* @__PURE__ */ new Map(),
|
|
48
|
+
reads: 0
|
|
49
|
+
};
|
|
50
|
+
states.set(storage, state);
|
|
51
|
+
}
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
async function findVariant(storage, hash, original) {
|
|
55
|
+
try {
|
|
56
|
+
const path = publishedGzipPath(hash);
|
|
57
|
+
if (!await storage.exists(path)) return void 0;
|
|
58
|
+
const bytes = await readBounded(storage, path, original.length);
|
|
59
|
+
if (!bytes || bytes.length === 0) return void 0;
|
|
60
|
+
return (await decompress(bytes, { maxOutputLength: original.length })).equals(original) ? bytes : void 0;
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Do not allocate an unbounded buffer when a damaged storage object is oversized. */
|
|
66
|
+
async function readBounded(storage, path, limit) {
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
for await (const chunk of await storage.readStream(path)) {
|
|
70
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
71
|
+
size += bytes.length;
|
|
72
|
+
if (size >= limit) return;
|
|
73
|
+
chunks.push(bytes);
|
|
74
|
+
}
|
|
75
|
+
return Buffer.concat(chunks, size);
|
|
76
|
+
}
|
|
77
|
+
/** No queue: at capacity a future request may try again. */
|
|
78
|
+
function schedule(storage, state, hash, original) {
|
|
79
|
+
if (state.jobs.has(hash) || state.jobs.size >= MAX_JOBS) return;
|
|
80
|
+
const job = generate(storage, hash, original).catch(() => {}).finally(() => {
|
|
81
|
+
state.jobs.delete(hash);
|
|
82
|
+
});
|
|
83
|
+
state.jobs.set(hash, job);
|
|
84
|
+
}
|
|
85
|
+
async function generate(storage, hash, original) {
|
|
86
|
+
if (createHash("sha256").update(original).digest("hex") !== hash) return;
|
|
87
|
+
const source = `content/${hash}`;
|
|
88
|
+
if (!await storage.exists(source)) return;
|
|
89
|
+
const compressed = await compress(original);
|
|
90
|
+
if (compressed.length >= original.length || !await storage.exists(source)) return;
|
|
91
|
+
const path = publishedGzipPath(hash);
|
|
92
|
+
await storage.write(path, compressed);
|
|
93
|
+
if (!await storage.exists(source)) await storage.delete(path);
|
|
94
|
+
}
|
|
95
|
+
async function withDeadline(promise) {
|
|
96
|
+
let timer;
|
|
97
|
+
try {
|
|
98
|
+
return await Promise.race([promise, new Promise((resolve) => {
|
|
99
|
+
timer = setTimeout(() => {
|
|
100
|
+
resolve("timeout");
|
|
101
|
+
}, READ_TIMEOUT_MS);
|
|
102
|
+
})]);
|
|
103
|
+
} finally {
|
|
104
|
+
if (timer) clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export { acceptsGzip, publishedGzip };
|
|
108
|
+
|
|
109
|
+
//# sourceMappingURL=published-compression.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"published-compression.mjs","names":[],"sources":["../../src/routers/published-compression.ts"],"sourcesContent":["/** Disposable, content-addressed gzip variants of authorized published files. */\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport { publishedGzipPath } from \"@storyshelf/core/utils\";\nimport { createHash } from \"node:crypto\";\nimport { promisify } from \"node:util\";\nimport { gunzip, gzip } from \"node:zlib\";\n\nconst compress = promisify(gzip);\nconst decompress = promisify(gunzip);\nconst MAX_FILE_BYTES = 8 * 1024 * 1024;\nconst MAX_JOBS = 2;\nconst MAX_READS = 4;\nconst READ_TIMEOUT_MS = 250;\nconst states = new WeakMap<StorageAdapter, State>();\n\ninterface State {\n jobs: Map<string, Promise<void>>;\n reads: number;\n}\n\n/** Gzip is chosen only if explicitly accepted, or by a nonzero wildcard. */\nexport function acceptsGzip(header?: string): boolean {\n if (!header) return false;\n const entries = header.split(\",\").map((part) => part.trim().split(\";\"));\n const entry =\n entries.find(([name]) => name?.trim().toLowerCase() === \"gzip\") ??\n entries.find(([name]) => name?.trim() === \"*\");\n if (!entry) return false;\n const quality = entry.slice(1).find((part) => /^\\s*q\\s*=/iu.test(part));\n if (quality === undefined) return true;\n const value = Number(quality.split(\"=\")[1]);\n return Number.isFinite(value) && value > 0 && value <= 1;\n}\n\n/** Return a verified existing variant, or the original while a bounded background job runs. */\nexport async function publishedGzip(\n storage: StorageAdapter,\n hash: string | undefined,\n original: Buffer,\n mime: string,\n): Promise<Buffer | undefined> {\n if (typeof hash !== \"string\" || !eligible(hash, original, mime)) return undefined;\n const state = stateOf(storage);\n if (state.reads >= MAX_READS) return undefined;\n state.reads += 1;\n const read = findVariant(storage, hash, original).finally(() => {\n state.reads -= 1;\n });\n const found = await withDeadline(read);\n if (found === \"timeout\") return undefined;\n if (found) return found;\n schedule(storage, state, hash, original);\n return undefined;\n}\n\nfunction eligible(hash: string | undefined, original: Buffer, mime: string): boolean {\n return (\n typeof hash === \"string\" &&\n /^[a-f0-9]{64}$/u.test(hash) &&\n original.length > 0 &&\n original.length <= MAX_FILE_BYTES &&\n /^(?:text\\/|application\\/(?:javascript|json|xml|wasm)|image\\/svg\\+xml)/iu.test(mime)\n );\n}\n\nfunction stateOf(storage: StorageAdapter): State {\n let state = states.get(storage);\n if (!state) {\n state = { jobs: new Map(), reads: 0 };\n states.set(storage, state);\n }\n return state;\n}\n\nasync function findVariant(\n storage: StorageAdapter,\n hash: string,\n original: Buffer,\n): Promise<Buffer | undefined> {\n try {\n const path = publishedGzipPath(hash);\n if (!(await storage.exists(path))) return undefined;\n const bytes = await readBounded(storage, path, original.length);\n if (!bytes || bytes.length === 0) return undefined;\n const decoded = await decompress(bytes, { maxOutputLength: original.length });\n return decoded.equals(original) ? bytes : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** Do not allocate an unbounded buffer when a damaged storage object is oversized. */\nasync function readBounded(\n storage: StorageAdapter,\n path: string,\n limit: number,\n): Promise<Buffer | undefined> {\n const chunks: Buffer[] = [];\n let size = 0;\n for await (const chunk of await storage.readStream(path)) {\n const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);\n size += bytes.length;\n if (size >= limit) {\n return undefined;\n }\n chunks.push(bytes);\n }\n return Buffer.concat(chunks, size);\n}\n\n/** No queue: at capacity a future request may try again. */\nfunction schedule(storage: StorageAdapter, state: State, hash: string, original: Buffer): void {\n if (state.jobs.has(hash) || state.jobs.size >= MAX_JOBS) return;\n const job = generate(storage, hash, original)\n .catch(() => {})\n .finally(() => {\n state.jobs.delete(hash);\n });\n state.jobs.set(hash, job);\n}\n\nasync function generate(storage: StorageAdapter, hash: string, original: Buffer): Promise<void> {\n if (createHash(\"sha256\").update(original).digest(\"hex\") !== hash) return;\n const source = `content/${hash}`;\n if (!(await storage.exists(source))) return;\n const compressed = await compress(original);\n if (compressed.length >= original.length || !(await storage.exists(source))) return;\n const path = publishedGzipPath(hash);\n await storage.write(path, compressed);\n // A purge can remove the original while compression is in flight.\n if (!(await storage.exists(source))) await storage.delete(path);\n}\n\nasync function withDeadline<T>(promise: Promise<T>): Promise<T | \"timeout\"> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<\"timeout\">((resolve) => {\n timer = setTimeout(() => {\n resolve(\"timeout\");\n }, READ_TIMEOUT_MS);\n }),\n ]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n"],"mappings":"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"}
|
|
@@ -3,9 +3,11 @@ import "node:url";
|
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { getStore } from "../store.mjs";
|
|
5
5
|
import { publishedCorsHeaders } from "./published-access.mjs";
|
|
6
|
+
import { SIGNED_NO_STORE, signedCacheControl } from "./capture-cache.mjs";
|
|
7
|
+
import { acceptsGzip, publishedGzip } from "./published-compression.mjs";
|
|
6
8
|
import { isSafeSegment, mimeFor } from "@storyshelf/core/utils";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
+
import { readPublishedFileInfo } from "@storyshelf/core/capture";
|
|
10
|
+
// src/routers/published-files.ts
|
|
9
11
|
/**
|
|
10
12
|
* Serving a build's published files (ADR 0010, ADR 0007 decision 5): the single place that decodes
|
|
11
13
|
* and checks a request path, reads the file through the build's manifest and sets the headers.
|
|
@@ -13,9 +15,13 @@ import { readPublishedFile } from "@storyshelf/core/capture";
|
|
|
13
15
|
* signed URL route (`/_s/:token/*`) all end here, each bringing the access {@link publishedAccess}
|
|
14
16
|
* granted.
|
|
15
17
|
*/
|
|
16
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Headers of every signed URL answer: a token URL must not be indexed or leaked by `Referer`, and
|
|
20
|
+
* is `no-store` here. Only a successful answer for a capture grant may replace `cache-control`
|
|
21
|
+
* (`signedCacheControl`); an error always sends these as they are.
|
|
22
|
+
*/
|
|
17
23
|
const SIGNED_URL_HEADERS = {
|
|
18
|
-
"cache-control":
|
|
24
|
+
"cache-control": SIGNED_NO_STORE,
|
|
19
25
|
"referrer-policy": "no-referrer",
|
|
20
26
|
"x-robots-tag": "noindex, nofollow",
|
|
21
27
|
"x-content-type-options": "nosniff"
|
|
@@ -37,16 +43,27 @@ function publishedRelativePath(path, base) {
|
|
|
37
43
|
/** The file as a response, or `null` when the build has no such file. */
|
|
38
44
|
async function servePublishedFile(c, request) {
|
|
39
45
|
const { projectId, buildId, rel, access } = request;
|
|
40
|
-
const
|
|
46
|
+
const { storage, config } = getStore();
|
|
47
|
+
const file = await readPublishedFileInfo(storage, projectId, buildId, rel);
|
|
41
48
|
if (!file) return null;
|
|
42
|
-
const headers = access === "token" ?
|
|
43
|
-
|
|
49
|
+
const headers = access === "token" ? {
|
|
50
|
+
...SIGNED_URL_HEADERS,
|
|
51
|
+
"cache-control": signedCacheControl({
|
|
52
|
+
cacheMaxAgeSeconds: config.captureAssets?.cacheMaxAgeSeconds,
|
|
53
|
+
grant: request.grant,
|
|
54
|
+
immutable: file.contentHash !== void 0
|
|
55
|
+
})
|
|
56
|
+
} : { "cache-control": request.cacheControl };
|
|
57
|
+
const mime = mimeFor(rel);
|
|
58
|
+
const compressed = config.publishedCompression && acceptsGzip(c.req.header("accept-encoding")) ? await publishedGzip(storage, file.contentHash, file.bytes, mime) : void 0;
|
|
59
|
+
return c.body(new Uint8Array(compressed ?? file.bytes), 200, {
|
|
44
60
|
...headers,
|
|
45
61
|
...publishedCorsHeaders(access),
|
|
46
|
-
"content-type":
|
|
62
|
+
"content-type": mime,
|
|
63
|
+
...config.publishedCompression ? { vary: "Accept-Encoding" } : {},
|
|
64
|
+
...compressed ? { "content-encoding": "gzip" } : {}
|
|
47
65
|
});
|
|
48
66
|
}
|
|
49
|
-
//#endregion
|
|
50
67
|
export { SIGNED_URL_HEADERS, publishedRelativePath, servePublishedFile };
|
|
51
68
|
|
|
52
69
|
//# sourceMappingURL=published-files.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"published-files.mjs","names":[],"sources":["../../src/routers/published-files.ts"],"sourcesContent":["/**\n * Serving a build's published files (ADR 0010, ADR 0007 decision 5): the single place that decodes\n * and checks a request path, reads the file through the build's manifest and sets the headers.\n * The published Storybook routes (`/projects/:slug/storybook/build/:id/*`, `/_/:id/*`) and the\n * signed URL route (`/_s/:token/*`) all end here, each bringing the access {@link publishedAccess}\n * granted.\n */\nimport {
|
|
1
|
+
{"version":3,"file":"published-files.mjs","names":[],"sources":["../../src/routers/published-files.ts"],"sourcesContent":["/**\n * Serving a build's published files (ADR 0010, ADR 0007 decision 5): the single place that decodes\n * and checks a request path, reads the file through the build's manifest and sets the headers.\n * The published Storybook routes (`/projects/:slug/storybook/build/:id/*`, `/_/:id/*`) and the\n * signed URL route (`/_s/:token/*`) all end here, each bringing the access {@link publishedAccess}\n * granted.\n */\nimport { readPublishedFileInfo } from \"@storyshelf/core/capture\";\nimport { isSafeSegment, mimeFor, type StorybookTokenClaims } from \"@storyshelf/core/utils\";\nimport type { Context } from \"hono\";\nimport { getStore } from \"../store.ts\";\nimport { signedCacheControl, SIGNED_NO_STORE } from \"./capture-cache.ts\";\nimport { publishedCorsHeaders, type PublishedAccess } from \"./published-access.ts\";\nimport { acceptsGzip, publishedGzip } from \"./published-compression.ts\";\n\n/**\n * Headers of every signed URL answer: a token URL must not be indexed or leaked by `Referer`, and\n * is `no-store` here. Only a successful answer for a capture grant may replace `cache-control`\n * (`signedCacheControl`); an error always sends these as they are.\n */\nexport const SIGNED_URL_HEADERS = {\n \"cache-control\": SIGNED_NO_STORE,\n \"referrer-policy\": \"no-referrer\",\n \"x-robots-tag\": \"noindex, nofollow\",\n \"x-content-type-options\": \"nosniff\",\n} as const;\n\n/**\n * The file path after `base`, decoded once. `\"\"` is the root; `null` when the path is not under\n * `base` or could leave the build: empty, `.` or `..` segments, backslashes and NUL are refused.\n */\nexport function publishedRelativePath(path: string, base: string): string | null {\n if (!path.startsWith(base)) {\n return null;\n }\n let rest: string;\n try {\n rest = decodeURIComponent(path.slice(base.length));\n } catch {\n return null;\n }\n const safe =\n rest === \"\" ||\n rest\n .split(\"/\")\n .every(\n (segment) =>\n segment !== \"\" && segment !== \".\" && !segment.includes(\"\\0\") && isSafeSegment(segment),\n );\n return safe ? rest : null;\n}\n\n/** Where a file of a build lives and how the request got to it. */\nexport interface PublishedFileRequest {\n projectId: string;\n buildId: string;\n /** A path accepted by {@link publishedRelativePath}, not the root. */\n rel: string;\n access: PublishedAccess;\n /** `Cache-Control` of a normal answer; a signed URL answers `signedCacheControl` instead. */\n cacheControl: string;\n /** The verified claims of a signed URL's token; decides whether its answer may be fresh. */\n grant?: StorybookTokenClaims | undefined;\n}\n\n/** The file as a response, or `null` when the build has no such file. */\nexport async function servePublishedFile(\n c: Context,\n request: PublishedFileRequest,\n): Promise<Response | null> {\n const { projectId, buildId, rel, access } = request;\n const { storage, config } = getStore();\n const file = await readPublishedFileInfo(storage, projectId, buildId, rel);\n if (!file) {\n return null;\n }\n const headers =\n access === \"token\"\n ? {\n ...SIGNED_URL_HEADERS,\n \"cache-control\": signedCacheControl({\n cacheMaxAgeSeconds: config.captureAssets?.cacheMaxAgeSeconds,\n grant: request.grant,\n immutable: file.contentHash !== undefined,\n }),\n }\n : { \"cache-control\": request.cacheControl };\n const mime = mimeFor(rel);\n const compressed =\n config.publishedCompression && acceptsGzip(c.req.header(\"accept-encoding\"))\n ? await publishedGzip(storage, file.contentHash, file.bytes, mime)\n : undefined;\n return c.body(new Uint8Array(compressed ?? file.bytes), 200, {\n ...headers,\n ...publishedCorsHeaders(access),\n \"content-type\": mime,\n ...(config.publishedCompression ? { vary: \"Accept-Encoding\" } : {}),\n ...(compressed ? { \"content-encoding\": \"gzip\" } : {}),\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAoBA,MAAa,qBAAqB;CAChC,iBAAiB;CACjB,mBAAmB;CACnB,gBAAgB;CAChB,0BAA0B;AAC5B;;;;;AAMA,SAAgB,sBAAsB,MAAc,MAA6B;CAC/E,IAAI,CAAC,KAAK,WAAW,IAAI,GACvB,OAAO;CAET,IAAI;CACJ,IAAI;EACF,OAAO,mBAAmB,KAAK,MAAM,KAAK,MAAM,CAAC;CACnD,QAAQ;EACN,OAAO;CACT;CASA,OAPE,SAAS,MACT,KACG,MAAM,GAAG,CAAC,CACV,OACE,YACC,YAAY,MAAM,YAAY,OAAO,CAAC,QAAQ,SAAS,IAAI,KAAK,cAAc,OAAO,CACzF,IACU,OAAO;AACvB;;AAgBA,eAAsB,mBACpB,GACA,SAC0B;CAC1B,MAAM,EAAE,WAAW,SAAS,KAAK,WAAW;CAC5C,MAAM,EAAE,SAAS,WAAW,SAAS;CACrC,MAAM,OAAO,MAAM,sBAAsB,SAAS,WAAW,SAAS,GAAG;CACzE,IAAI,CAAC,MACH,OAAO;CAET,MAAM,UACJ,WAAW,UACP;EACE,GAAG;EACH,iBAAiB,mBAAmB;GAClC,oBAAoB,OAAO,eAAe;GAC1C,OAAO,QAAQ;GACf,WAAW,KAAK,gBAAgB,KAAA;EAClC,CAAC;CACH,IACA,EAAE,iBAAiB,QAAQ,aAAa;CAC9C,MAAM,OAAO,QAAQ,GAAG;CACxB,MAAM,aACJ,OAAO,wBAAwB,YAAY,EAAE,IAAI,OAAO,iBAAiB,CAAC,IACtE,MAAM,cAAc,SAAS,KAAK,aAAa,KAAK,OAAO,IAAI,IAC/D,KAAA;CACN,OAAO,EAAE,KAAK,IAAI,WAAW,cAAc,KAAK,KAAK,GAAG,KAAK;EAC3D,GAAG;EACH,GAAG,qBAAqB,MAAM;EAC9B,gBAAgB;EAChB,GAAI,OAAO,uBAAuB,EAAE,MAAM,kBAAkB,IAAI,CAAC;EACjE,GAAI,aAAa,EAAE,oBAAoB,OAAO,IAAI,CAAC;CACrD,CAAC;AACH"}
|
|
@@ -9,7 +9,7 @@ import { APPROVER_ROLES, DEVELOPER_ROLES, buildForProject } from "./builds.handl
|
|
|
9
9
|
import { giveVerdict } from "./build-reviews.handlers.mjs";
|
|
10
10
|
import { createComment, resolveBuildComment } from "./comments.mjs";
|
|
11
11
|
import { HTTPException } from "hono/http-exception";
|
|
12
|
-
|
|
12
|
+
// src/routers/review-forms.ts
|
|
13
13
|
/** Register the form routes of review (`review` class). */
|
|
14
14
|
function registerReviewForms(app) {
|
|
15
15
|
const base = "/projects/:slug/builds/:buildId";
|
|
@@ -66,7 +66,6 @@ function returnPath(c, buildId) {
|
|
|
66
66
|
return fallback;
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
-
//#endregion
|
|
70
69
|
export { registerReviewForms };
|
|
71
70
|
|
|
72
71
|
//# sourceMappingURL=review-forms.mjs.map
|
|
@@ -5,7 +5,7 @@ import { getStore } from "../store.mjs";
|
|
|
5
5
|
import { hxRefresh, isHxRequest } from "./htmx.mjs";
|
|
6
6
|
import { isOpenSnapshot } from "./baseline-guard.mjs";
|
|
7
7
|
import { SnapshotModel } from "@storyshelf/core/models";
|
|
8
|
-
|
|
8
|
+
// src/routers/review-next.ts
|
|
9
9
|
/**
|
|
10
10
|
* The next snapshot still waiting for a decision after `currentId`, in list
|
|
11
11
|
* order, wrapping to the start. Null when nothing else is open.
|
|
@@ -41,7 +41,6 @@ async function hxAdvance(c, target) {
|
|
|
41
41
|
}
|
|
42
42
|
c.header("HX-Redirect", url);
|
|
43
43
|
}
|
|
44
|
-
//#endregion
|
|
45
44
|
export { hxAdvance, nextOpenSnapshotId };
|
|
46
45
|
|
|
47
46
|
//# sourceMappingURL=review-next.mjs.map
|
|
@@ -6,7 +6,7 @@ import { currentProjectRole } from "./helpers.mjs";
|
|
|
6
6
|
import { reviewStateOf } from "./build-reviews.handlers.mjs";
|
|
7
7
|
import { publishingOf } from "@storyshelf/core/capture";
|
|
8
8
|
import { CommentModel, UserModel, changesetOf } from "@storyshelf/core/models";
|
|
9
|
-
|
|
9
|
+
// src/routers/review-section.handlers.ts
|
|
10
10
|
/** Data behind the review section of the build page (ADR 0013): verdicts, changeset and comments. */
|
|
11
11
|
/** Whether review is on for the project (a row without the column counts as on). */
|
|
12
12
|
function reviewIsOn(project) {
|
|
@@ -79,7 +79,6 @@ async function loadComponentReview(project, build) {
|
|
|
79
79
|
canComment: capabilities.canComment
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
-
//#endregion
|
|
83
82
|
export { loadComponentReview, loadReviewSection, namesOf, reviewCapabilities, reviewIsOn };
|
|
84
83
|
|
|
85
84
|
//# sourceMappingURL=review-section.handlers.mjs.map
|
|
@@ -4,7 +4,7 @@ import.meta.url;
|
|
|
4
4
|
import { getStore } from "../store.mjs";
|
|
5
5
|
import { requestBuildState } from "../git-status.mjs";
|
|
6
6
|
import { acceptPendingWithoutWork } from "@storyshelf/core/models";
|
|
7
|
-
|
|
7
|
+
// src/routers/review-switch-off.ts
|
|
8
8
|
/** The switch-off pass at the app level (ADR 0013, decision 1): accept the queue review leaves behind. */
|
|
9
9
|
/**
|
|
10
10
|
* When a save turned review off (`before` had it on), accept the project's pending builds that no
|
|
@@ -23,7 +23,6 @@ async function runSwitchOffPass(before, after) {
|
|
|
23
23
|
}, "review turned off: accepted pending builds");
|
|
24
24
|
return accepted.length;
|
|
25
25
|
}
|
|
26
|
-
//#endregion
|
|
27
26
|
export { runSwitchOffPass };
|
|
28
27
|
|
|
29
28
|
//# sourceMappingURL=review-switch-off.mjs.map
|
package/dist/routers/schemas.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { storybookUrlTtlOverrideError } from "@storyshelf/core/utils";
|
|
|
6
6
|
import { WebhookModel } from "@storyshelf/core/models";
|
|
7
7
|
import { BUILD_STATUSES, PROJECT_ROLES, REVIEW_VERDICTS, SNAPSHOT_STATUSES } from "@storyshelf/core/types";
|
|
8
8
|
import { BROWSER_NAMES, TARGET_NAME_PATTERN } from "@storyshelf/core/adapter/capture-runner";
|
|
9
|
-
|
|
9
|
+
// src/routers/schemas.ts
|
|
10
10
|
/** A capture target: an engine (`chromium`) or a remote one, `<engine>@<name>` (ADR 0007). */
|
|
11
11
|
const captureTargetSchema = z.string().regex(new RegExp(`^(?:${BROWSER_NAMES.join("|")})(?:@${TARGET_NAME_PATTERN.source.slice(1, -1)})?$`, "u"), "Expected an engine (chromium, firefox, webkit, chrome) or <engine>@<name>");
|
|
12
12
|
const reviewApprovalsSchema = z.number().int().min(1).max(20);
|
|
@@ -449,7 +449,6 @@ const healthRequestSchema = z.object({
|
|
|
449
449
|
window: z.string().optional(),
|
|
450
450
|
force: z.boolean().optional()
|
|
451
451
|
}).openapi("HealthInsightRequest");
|
|
452
|
-
//#endregion
|
|
453
452
|
export { approveAllResultSchema, attemptLogSchema, attemptSchema, badRequest, baselineChangedSchema, buildReviewCreateSchema, buildReviewSchema, buildReviewStateSchema, buildSchema, commentCreateSchema, commentSchema, errorSchema, forbidden, groupDecisionResultSchema, groupMappingCreateSchema, groupMappingSchema, healthRequestSchema, insightRequestSchema, insightSchema, labelTypeCreateSchema, labelTypeSchema, labelTypeUpdateSchema, memberRoleSchema, memberSchema, memberSetSchema, notFound, notificationChannelCreateSchema, notificationChannelSchema, notificationSubscriptionPutSchema, notificationSubscriptionSchema, okSchema, projectCreateSchema, projectSchema, projectUpdateSchema, purgeInputSchema, purgeSchema, rediffResultSchema, snapshotSchema, tokenCreateSchema, tokenCreatedSchema, tokenPublicSchema, unauthorized, webhookCreateSchema, webhookCreatedSchema, webhookPublicSchema };
|
|
454
453
|
|
|
455
454
|
//# sourceMappingURL=schemas.mjs.map
|