@storyshelf/app 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/dist/app-types.d.mts +1 -3
  2. package/dist/asset-manifest.mjs +1 -2
  3. package/dist/assets/htmx.mjs +1 -2
  4. package/dist/assets/mark.mjs +1 -2
  5. package/dist/boot-tasks.mjs +1 -2
  6. package/dist/build-state-flags.mjs +1 -2
  7. package/dist/build-state-setup.mjs +1 -2
  8. package/dist/capture-alerts.mjs +1 -2
  9. package/dist/capture-process.mjs +1 -2
  10. package/dist/capture-setup.mjs +1 -2
  11. package/dist/connections.mjs +1 -2
  12. package/dist/consumer-setup.mjs +1 -2
  13. package/dist/credential-keys.mjs +1 -2
  14. package/dist/credentials.mjs +1 -2
  15. package/dist/git-status.mjs +1 -2
  16. package/dist/hidden-data.mjs +1 -2
  17. package/dist/index.d.mts +0 -2
  18. package/dist/index.mjs +2 -3
  19. package/dist/insights/budget.mjs +1 -2
  20. package/dist/insights/deps.mjs +1 -2
  21. package/dist/insights/errors.mjs +1 -2
  22. package/dist/insights/health-panel.mjs +0 -1
  23. package/dist/insights/job.mjs +1 -2
  24. package/dist/insights/load-health.mjs +1 -2
  25. package/dist/insights/load-triage.mjs +1 -2
  26. package/dist/insights/panel.mjs +1 -2
  27. package/dist/insights/request.mjs +1 -2
  28. package/dist/insights/stale.mjs +1 -2
  29. package/dist/insights/usage-report.mjs +1 -2
  30. package/dist/insights/view.mjs +1 -2
  31. package/dist/job-canceller.mjs +1 -2
  32. package/dist/job-queue.mjs +1 -2
  33. package/dist/jobs-setup.mjs +1 -2
  34. package/dist/lifecycle.mjs +2 -3
  35. package/dist/lifecycle.mjs.map +1 -1
  36. package/dist/middleware/auth-gate.mjs +1 -2
  37. package/dist/middleware/csrf.mjs +1 -2
  38. package/dist/middleware/rate-limit.mjs +1 -2
  39. package/dist/middleware/request-log.mjs +1 -2
  40. package/dist/middleware/require-review.mjs +1 -2
  41. package/dist/middleware/require-testing.mjs +1 -2
  42. package/dist/middleware/server-timing.mjs +1 -2
  43. package/dist/middleware/setup-gate.mjs +1 -2
  44. package/dist/middleware/store-scope.mjs +1 -2
  45. package/dist/notifications-pause.mjs +1 -2
  46. package/dist/notify.d.mts +1 -3
  47. package/dist/notify.mjs +1 -2
  48. package/dist/pages/a11y-violations.mjs +1 -2
  49. package/dist/pages/admin-ai.mjs +1 -2
  50. package/dist/pages/admin-system-origin.mjs +19 -10
  51. package/dist/pages/admin-system-origin.mjs.map +1 -1
  52. package/dist/pages/admin-system-targets.mjs +1 -2
  53. package/dist/pages/admin-system.mjs +1 -2
  54. package/dist/pages/build-detail.mjs +1 -2
  55. package/dist/pages/build-diff-actions.mjs +1 -2
  56. package/dist/pages/build-diff-comments.mjs +1 -2
  57. package/dist/pages/build-diff-default.mjs +53 -0
  58. package/dist/pages/build-diff-default.mjs.map +1 -0
  59. package/dist/pages/build-diff-environment.mjs +1 -2
  60. package/dist/pages/build-diff-header.mjs +1 -2
  61. package/dist/pages/build-diff-insight.mjs +1 -2
  62. package/dist/pages/build-diff-model.mjs +20 -3
  63. package/dist/pages/build-diff-model.mjs.map +1 -1
  64. package/dist/pages/build-diff-nav.mjs +1 -2
  65. package/dist/pages/build-diff-reference.mjs +84 -0
  66. package/dist/pages/build-diff-reference.mjs.map +1 -0
  67. package/dist/pages/build-diff-shortcuts.mjs +1 -2
  68. package/dist/pages/build-diff-stale.mjs +1 -2
  69. package/dist/pages/build-diff-usage.mjs +1 -2
  70. package/dist/pages/build-diff-viewer.mjs +38 -12
  71. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  72. package/dist/pages/build-diff.mjs +6 -12
  73. package/dist/pages/build-diff.mjs.map +1 -1
  74. package/dist/pages/build-tests.mjs +1 -2
  75. package/dist/pages/component-detail.mjs +1 -2
  76. package/dist/pages/component-stories.mjs +1 -2
  77. package/dist/pages/compute-jobs.mjs +1 -2
  78. package/dist/pages/design-links.mjs +1 -2
  79. package/dist/pages/environment-change.mjs +1 -2
  80. package/dist/pages/health-panel.mjs +1 -2
  81. package/dist/pages/invite.mjs +1 -2
  82. package/dist/pages/label-detail.mjs +1 -2
  83. package/dist/pages/library-grid.mjs +1 -2
  84. package/dist/pages/library-model.mjs +1 -2
  85. package/dist/pages/library-snapshots.mjs +1 -2
  86. package/dist/pages/library.mjs +1 -2
  87. package/dist/pages/login.mjs +1 -2
  88. package/dist/pages/notifications-paused.mjs +1 -2
  89. package/dist/pages/passkey-ceremony.mjs +1 -2
  90. package/dist/pages/profile.mjs +1 -2
  91. package/dist/pages/project-builds-table.mjs +1 -2
  92. package/dist/pages/project-builds.mjs +1 -2
  93. package/dist/pages/project-create.mjs +1 -2
  94. package/dist/pages/project-settings.mjs +1 -2
  95. package/dist/pages/projects-card.mjs +1 -2
  96. package/dist/pages/projects.mjs +1 -2
  97. package/dist/pages/review-changeset.mjs +1 -2
  98. package/dist/pages/review-comments.mjs +1 -2
  99. package/dist/pages/review-verdict.mjs +1 -2
  100. package/dist/pages/root.mjs +1 -2
  101. package/dist/pages/settings-ai.mjs +1 -2
  102. package/dist/pages/settings-capture.mjs +1 -2
  103. package/dist/pages/settings-design-credential.mjs +1 -2
  104. package/dist/pages/settings-design-links.mjs +1 -2
  105. package/dist/pages/settings-design.mjs +1 -2
  106. package/dist/pages/settings-general.mjs +1 -2
  107. package/dist/pages/settings-labels.mjs +1 -2
  108. package/dist/pages/settings-links.mjs +1 -2
  109. package/dist/pages/settings-members.mjs +1 -2
  110. package/dist/pages/settings-notifications-git.mjs +1 -2
  111. package/dist/pages/settings-notifications.mjs +1 -2
  112. package/dist/pages/settings-tests.mjs +1 -2
  113. package/dist/pages/settings-tokens.mjs +1 -2
  114. package/dist/pages/settings-webhooks.mjs +1 -2
  115. package/dist/pages/storybook.mjs +1 -2
  116. package/dist/pages/target-choices.mjs +1 -2
  117. package/dist/publish-setup.mjs +1 -2
  118. package/dist/retention-timer.mjs +10 -11
  119. package/dist/retention-timer.mjs.map +1 -1
  120. package/dist/review-events.mjs +1 -2
  121. package/dist/routers/admin-pages.mjs +1 -2
  122. package/dist/routers/admin-public-origin.mjs +10 -3
  123. package/dist/routers/admin-public-origin.mjs.map +1 -1
  124. package/dist/routers/admin.mjs +8 -4
  125. package/dist/routers/admin.mjs.map +1 -1
  126. package/dist/routers/assets.mjs +1 -2
  127. package/dist/routers/attempts.mjs +1 -2
  128. package/dist/routers/auth-sync.mjs +1 -2
  129. package/dist/routers/auth.mjs +1 -2
  130. package/dist/routers/baseline-guard.mjs +2 -3
  131. package/dist/routers/baseline-guard.mjs.map +1 -1
  132. package/dist/routers/baseline-reference.mjs +72 -0
  133. package/dist/routers/baseline-reference.mjs.map +1 -0
  134. package/dist/routers/baselines.handlers.mjs +1 -2
  135. package/dist/routers/baselines.mjs +1 -2
  136. package/dist/routers/build-cancel.mjs +1 -2
  137. package/dist/routers/build-changeset.mjs +1 -2
  138. package/dist/routers/build-reviews.handlers.mjs +1 -2
  139. package/dist/routers/build-reviews.mjs +1 -2
  140. package/dist/routers/builds.handlers.mjs +23 -9
  141. package/dist/routers/builds.handlers.mjs.map +1 -1
  142. package/dist/routers/builds.mjs +7 -3
  143. package/dist/routers/builds.mjs.map +1 -1
  144. package/dist/routers/capture-cache.mjs +29 -0
  145. package/dist/routers/capture-cache.mjs.map +1 -0
  146. package/dist/routers/capture-config.mjs +1 -2
  147. package/dist/routers/comments.mjs +1 -2
  148. package/dist/routers/comparison-image.mjs +29 -0
  149. package/dist/routers/comparison-image.mjs.map +1 -0
  150. package/dist/routers/component-catalog.handlers.mjs +1 -2
  151. package/dist/routers/component.handlers.mjs +1 -2
  152. package/dist/routers/connections.mjs +1 -2
  153. package/dist/routers/design-connect.handlers.mjs +1 -2
  154. package/dist/routers/design.handlers.mjs +1 -2
  155. package/dist/routers/flash.mjs +1 -2
  156. package/dist/routers/health-report.mjs +1 -2
  157. package/dist/routers/health.mjs +1 -2
  158. package/dist/routers/helpers.mjs +1 -2
  159. package/dist/routers/htmx.mjs +1 -2
  160. package/dist/routers/insights-ui.mjs +1 -2
  161. package/dist/routers/insights.handlers.mjs +1 -2
  162. package/dist/routers/insights.mjs +1 -2
  163. package/dist/routers/labels.mjs +1 -2
  164. package/dist/routers/links.handlers.mjs +1 -2
  165. package/dist/routers/links.mjs +1 -2
  166. package/dist/routers/media.mjs +14 -5
  167. package/dist/routers/media.mjs.map +1 -1
  168. package/dist/routers/members.mjs +1 -2
  169. package/dist/routers/notification-channels.mjs +1 -2
  170. package/dist/routers/notification-subscriptions.mjs +1 -2
  171. package/dist/routers/oauth-state.mjs +1 -2
  172. package/dist/routers/preview-base.mjs +1 -2
  173. package/dist/routers/preview-token.mjs +1 -2
  174. package/dist/routers/profile.mjs +1 -2
  175. package/dist/routers/projects.mjs +1 -2
  176. package/dist/routers/publish-upload.mjs +19 -12
  177. package/dist/routers/publish-upload.mjs.map +1 -1
  178. package/dist/routers/published-access.mjs +1 -2
  179. package/dist/routers/published-compression.mjs +109 -0
  180. package/dist/routers/published-compression.mjs.map +1 -0
  181. package/dist/routers/published-files.mjs +26 -9
  182. package/dist/routers/published-files.mjs.map +1 -1
  183. package/dist/routers/review-forms.mjs +1 -2
  184. package/dist/routers/review-next.mjs +1 -2
  185. package/dist/routers/review-section.handlers.mjs +1 -2
  186. package/dist/routers/review-switch-off.mjs +1 -2
  187. package/dist/routers/schemas.mjs +1 -2
  188. package/dist/routers/scoped-models.mjs +1 -2
  189. package/dist/routers/settings-ai.mjs +1 -2
  190. package/dist/routers/settings-capture.mjs +1 -2
  191. package/dist/routers/settings-design.mjs +1 -2
  192. package/dist/routers/settings-general.mjs +1 -2
  193. package/dist/routers/settings-git-channel.mjs +1 -2
  194. package/dist/routers/settings-labels.mjs +1 -2
  195. package/dist/routers/settings-links.mjs +1 -2
  196. package/dist/routers/settings-members.mjs +1 -2
  197. package/dist/routers/settings-notifications.mjs +1 -2
  198. package/dist/routers/settings-tokens.mjs +1 -2
  199. package/dist/routers/settings-webhooks.mjs +1 -2
  200. package/dist/routers/settings.handlers.mjs +1 -2
  201. package/dist/routers/settings.mjs +1 -2
  202. package/dist/routers/signed-storybook.mjs +5 -3
  203. package/dist/routers/signed-storybook.mjs.map +1 -1
  204. package/dist/routers/snapshot-groups.mjs +1 -2
  205. package/dist/routers/snapshots.mjs +1 -2
  206. package/dist/routers/storybook.mjs +21 -17
  207. package/dist/routers/storybook.mjs.map +1 -1
  208. package/dist/routers/tokens.mjs +1 -2
  209. package/dist/routers/ui.mjs +4 -2
  210. package/dist/routers/ui.mjs.map +1 -1
  211. package/dist/routers/webhooks.mjs +1 -2
  212. package/dist/routers/well-known.mjs +1 -2
  213. package/dist/routes/api-routes.mjs +1 -2
  214. package/dist/routes/classification.mjs +1 -2
  215. package/dist/routes/openapi-classes.mjs +1 -2
  216. package/dist/routes/page-routes.mjs +2 -3
  217. package/dist/routes/page-routes.mjs.map +1 -1
  218. package/dist/runtime.mjs +1 -2
  219. package/dist/serverless.mjs +1 -2
  220. package/dist/store.mjs +1 -2
  221. package/dist/targets.mjs +1 -2
  222. package/dist/ui/avatar.mjs +1 -2
  223. package/dist/ui/buttons.mjs +1 -2
  224. package/dist/ui/code-block.mjs +1 -2
  225. package/dist/ui/compare-stage.mjs +86 -54
  226. package/dist/ui/compare-stage.mjs.map +1 -1
  227. package/dist/ui/csrf-field.mjs +1 -2
  228. package/dist/ui/css.mjs +1 -2
  229. package/dist/ui/design-reference.mjs +1 -2
  230. package/dist/ui/document.mjs +1 -2
  231. package/dist/ui/dropdown.mjs +1 -2
  232. package/dist/ui/feedback.mjs +1 -2
  233. package/dist/ui/filter-input.mjs +1 -2
  234. package/dist/ui/forms.mjs +1 -2
  235. package/dist/ui/icons/icon.mjs +1 -2
  236. package/dist/ui/icons/paths.mjs +1 -2
  237. package/dist/ui/icons/sprite.mjs +1 -2
  238. package/dist/ui/kbd.mjs +1 -2
  239. package/dist/ui/layout.mjs +1 -2
  240. package/dist/ui/preview-embed.mjs +1 -2
  241. package/dist/ui/progress.mjs +1 -2
  242. package/dist/ui/segmented.mjs +1 -2
  243. package/dist/ui/shell/account.mjs +1 -2
  244. package/dist/ui/shell/bare.mjs +1 -2
  245. package/dist/ui/shell/boot-script.mjs +1 -2
  246. package/dist/ui/shell/client-script.mjs +1 -2
  247. package/dist/ui/shell/nav.mjs +1 -2
  248. package/dist/ui/shell/review-script.mjs +17 -3
  249. package/dist/ui/shell/review-script.mjs.map +1 -1
  250. package/dist/ui/shell/sidebar.mjs +1 -2
  251. package/dist/ui/shell/topbar.mjs +1 -2
  252. package/dist/ui/stacks.mjs +1 -2
  253. package/dist/ui/styles/base.mjs +1 -2
  254. package/dist/ui/styles/behavior.mjs +1 -2
  255. package/dist/ui/styles/review-layout.mjs +1 -2
  256. package/dist/ui/styles/review.mjs +1 -2
  257. package/dist/ui/styles/tokens.mjs +1 -2
  258. package/dist/ui/styles.mjs +1 -2
  259. package/dist/ui/subnav.mjs +1 -2
  260. package/dist/ui/table.mjs +1 -2
  261. package/dist/ui/theme.mjs +1 -2
  262. package/dist/ui/thumbnail.mjs +1 -2
  263. package/dist/ui/time.mjs +1 -2
  264. package/package.json +9 -9
@@ -5,7 +5,7 @@ import { getStore } from "../store.mjs";
5
5
  import { sha256, timingSafeEqualString } from "@storyshelf/core/utils";
6
6
  import { MemberModel, ProjectModel, TokenModel, UserModel } from "@storyshelf/core/models";
7
7
  import { HTTPException } from "hono/http-exception";
8
- //#region src/routers/helpers.ts
8
+ // src/routers/helpers.ts
9
9
  /** Throw a 401 Unauthorized error for unauthenticated requests. */
10
10
  function unauthorized() {
11
11
  throw new HTTPException(401, { message: "Unauthorized" });
@@ -120,7 +120,6 @@ function requireSiteAdmin(c) {
120
120
  if (user?.role === "admin" || requestHasAdminToken(c)) return;
121
121
  forbidden();
122
122
  }
123
- //#endregion
124
123
  export { assertRole, currentProjectRole, findProjectBySlug, forbidden, notFound, requestHasAdminToken, requireSiteAdmin, resolveAuthorizedProject, resolveReviewer, unauthorized };
125
124
 
126
125
  //# sourceMappingURL=helpers.mjs.map
@@ -1,7 +1,7 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- //#region src/routers/htmx.ts
4
+ // src/routers/htmx.ts
5
5
  /** Check whether the request came from HTMX (HX-Request header). */
6
6
  function isHxRequest(c) {
7
7
  return c.req.header("HX-Request") === "true";
@@ -18,7 +18,6 @@ function hxRedirect(c, url) {
18
18
  function hxRefresh(c) {
19
19
  if (isHxRequest(c)) c.header("HX-Refresh", "true");
20
20
  }
21
- //#endregion
22
21
  export { hxRedirect, hxRefresh, isHxRequest };
23
22
 
24
23
  //# sourceMappingURL=htmx.mjs.map
@@ -10,7 +10,7 @@ import { InsightPanel } from "../pages/build-diff-insight.mjs";
10
10
  import { HealthPanel } from "../pages/health-panel.mjs";
11
11
  import { projectForRegenerate, projectForView } from "./insights.handlers.mjs";
12
12
  import { jsx } from "hono/jsx/jsx-runtime";
13
- //#region src/routers/insights-ui.tsx
13
+ // src/routers/insights-ui.tsx
14
14
  /** Register the HTMX fragments for the project health panel. */
15
15
  function registerHealthFragments(app) {
16
16
  app.get("/projects/:slug/insights/health/panel", async (c) => {
@@ -58,7 +58,6 @@ function registerInsightFragments(app) {
58
58
  })) : notFound();
59
59
  });
60
60
  }
61
- //#endregion
62
61
  export { registerInsightFragments };
63
62
 
64
63
  //# sourceMappingURL=insights-ui.mjs.map
@@ -6,7 +6,7 @@ import { failWith } from "../insights/errors.mjs";
6
6
  import { assertRole, requestHasAdminToken, resolveAuthorizedProject } from "./helpers.mjs";
7
7
  import { insightDepsFromStore } from "../insights/deps.mjs";
8
8
  import { APPROVER_ROLES, VIEW_ROLES } from "./builds.handlers.mjs";
9
- //#region src/routers/insights.handlers.ts
9
+ // src/routers/insights.handlers.ts
10
10
  /** AI deps or `501 ai-disabled` when the site has no AI configured. */
11
11
  function requireAi() {
12
12
  const deps = insightDepsFromStore();
@@ -39,7 +39,6 @@ async function projectForRegenerate(c, slug) {
39
39
  project
40
40
  };
41
41
  }
42
- //#endregion
43
42
  export { isSiteAdmin, projectForRegenerate, projectForView, requireAi, requireProjectAi };
44
43
 
45
44
  //# sourceMappingURL=insights.handlers.mjs.map
@@ -11,7 +11,7 @@ import { requestHealth, requestTriage } from "../insights/request.mjs";
11
11
  import { isSiteAdmin, projectForRegenerate, projectForView } from "./insights.handlers.mjs";
12
12
  import { createRoute, z } from "@hono/zod-openapi";
13
13
  import { InsightModel } from "@storyshelf/core/models";
14
- //#region src/routers/insights.ts
14
+ // src/routers/insights.ts
15
15
  const BUILD_PARAMS = z.object({
16
16
  slug: z.string(),
17
17
  buildId: z.string()
@@ -183,7 +183,6 @@ function registerInsights(app) {
183
183
  registerBuildInsights(app);
184
184
  registerHealthInsights(app);
185
185
  }
186
- //#endregion
187
186
  export { registerInsights };
188
187
 
189
188
  //# sourceMappingURL=insights.mjs.map
@@ -7,7 +7,7 @@ import { labelTypeCreateSchema, labelTypeSchema, labelTypeUpdateSchema, notFound
7
7
  import { createRoute, z } from "@hono/zod-openapi";
8
8
  import { LabelModel } from "@storyshelf/core/models";
9
9
  import { HTTPException } from "hono/http-exception";
10
- //#region src/routers/labels.ts
10
+ // src/routers/labels.ts
11
11
  const VIEW_ROLES = [
12
12
  "viewer",
13
13
  "developer",
@@ -112,7 +112,6 @@ function registerLabels(app) {
112
112
  }
113
113
  });
114
114
  }
115
- //#endregion
116
115
  export { registerLabels };
117
116
 
118
117
  //# sourceMappingURL=labels.mjs.map
@@ -4,7 +4,7 @@ import.meta.url;
4
4
  import { catalogOf, getStore } from "../store.mjs";
5
5
  import { resolveAuthorizedProject } from "./helpers.mjs";
6
6
  import { BuildModel, PackageUsageModel, ProjectModel, SnapshotModel, matchUpstreamStory } from "@storyshelf/core/models";
7
- //#region src/routers/links.handlers.ts
7
+ // src/routers/links.handlers.ts
8
8
  const VIEW_ROLES = [
9
9
  "viewer",
10
10
  "developer",
@@ -74,7 +74,6 @@ async function linkedUsageFor(c, project, buildId, storyImportPath) {
74
74
  if (!storyImportPath) return [];
75
75
  return (await linkedUsageByStory(c, project, buildId, storyImportPath)).get(storyImportPath) ?? [];
76
76
  }
77
- //#endregion
78
77
  export { linkedUsageByStory, linkedUsageFor };
79
78
 
80
79
  //# sourceMappingURL=links.handlers.mjs.map
@@ -9,7 +9,7 @@ import { linkedUsageFor } from "./links.handlers.mjs";
9
9
  import { createRoute, z } from "@hono/zod-openapi";
10
10
  import { PackageUsageModel, ProjectModel } from "@storyshelf/core/models";
11
11
  import { HTTPException } from "hono/http-exception";
12
- //#region src/routers/links.ts
12
+ // src/routers/links.ts
13
13
  const VIEW_ROLES = [
14
14
  "viewer",
15
15
  "developer",
@@ -133,7 +133,6 @@ function registerLinks(app) {
133
133
  return c.json(await linkedUsageFor(c, project, build.id, snapshot.storyImportPath));
134
134
  });
135
135
  }
136
- //#endregion
137
136
  export { registerLinks };
138
137
 
139
138
  //# sourceMappingURL=links.mjs.map
@@ -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 { BaselineModel, BuildModel, DesignCredentialModel, DesignNodeModel, SnapshotModel } from "@storyshelf/core/models";
8
- //#region src/routers/media.ts
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
- return imageResponse(await baselines.read(baseline));
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 { BaselineModel, DesignCredentialModel, DesignNodeModel } 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 { 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 const baselines = new BaselineModel(getStore().db, undefined, getStore().storage);\n const baseline = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n snapshot.browser,\n );\n if (!baseline) {\n notFound(\"No baseline for this snapshot\");\n }\n return imageResponse(await baselines.read(baseline));\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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"}
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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"}
@@ -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
- //#region src/routers/members.ts
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
- //#region src/routers/notification-channels.ts
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
- //#region src/routers/notification-subscriptions.ts
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
- //#region src/routers/oauth-state.ts
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
- //#region src/routers/preview-base.ts
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
- //#region src/routers/preview-token.ts
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
@@ -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
- //#region src/routers/profile.ts
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
- //#region src/routers/projects.ts
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
- //#region src/routers/publish-upload.ts
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
- const message = error instanceof Error ? error.message : String(error);
65
- logger.error({
66
- err: error,
67
- buildId: build.id
68
- }, "inline publish failed");
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
- //#endregion
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 logger.error({ err: error, buildId: build.id }, \"inline publish failed\");\n await publish?.completer.complete(build.id, { ok: false, error: message });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAoBA,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,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACrE,OAAO,MAAM;GAAE,KAAK;GAAO,SAAS,MAAM;EAAG,GAAG,uBAAuB;EACvE,MAAM,SAAS,UAAU,SAAS,MAAM,IAAI;GAAE,IAAI;GAAO,OAAO;EAAQ,CAAC;CAC3E;AACF"}
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
- //#region src/routers/published-access.ts
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":";;;;;;;;AAOA,MAAM,WAAW,UAAU,IAAI;AAC/B,MAAM,aAAa,UAAU,MAAM;AACnC,MAAM,iBAAiB;AACvB,MAAM,WAAW;AACjB,MAAM,YAAY;AAClB,MAAM,kBAAkB;AACxB,MAAM,yBAAS,IAAI,QAA+B;;AAQlD,SAAgB,YAAY,QAA0B;CACpD,IAAI,CAAC,QAAQ,OAAO;CACpB,MAAM,UAAU,OAAO,MAAM,GAAG,CAAC,CAAC,KAAK,SAAS,KAAK,KAAK,CAAC,CAAC,MAAM,GAAG,CAAC;CACtE,MAAM,QACJ,QAAQ,MAAM,CAAC,UAAU,MAAM,KAAK,CAAC,CAAC,YAAY,MAAM,MAAM,KAC9D,QAAQ,MAAM,CAAC,UAAU,MAAM,KAAK,MAAM,GAAG;CAC/C,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,UAAU,MAAM,MAAM,CAAC,CAAC,CAAC,MAAM,SAAS,cAAc,KAAK,IAAI,CAAC;CACtE,IAAI,YAAY,KAAA,GAAW,OAAO;CAClC,MAAM,QAAQ,OAAO,QAAQ,MAAM,GAAG,CAAC,CAAC,EAAE;CAC1C,OAAO,OAAO,SAAS,KAAK,KAAK,QAAQ,KAAK,SAAS;AACzD;;AAGA,eAAsB,cACpB,SACA,MACA,UACA,MAC6B;CAC7B,IAAI,OAAO,SAAS,YAAY,CAAC,SAAS,MAAM,UAAU,IAAI,GAAG,OAAO,KAAA;CACxE,MAAM,QAAQ,QAAQ,OAAO;CAC7B,IAAI,MAAM,SAAS,WAAW,OAAO,KAAA;CACrC,MAAM,SAAS;CAIf,MAAM,QAAQ,MAAM,aAHP,YAAY,SAAS,MAAM,QAAQ,CAAC,CAAC,cAAc;EAC9D,MAAM,SAAS;CACjB,CACoC,CAAC;CACrC,IAAI,UAAU,WAAW,OAAO,KAAA;CAChC,IAAI,OAAO,OAAO;CAClB,SAAS,SAAS,OAAO,MAAM,QAAQ;AAEzC;AAEA,SAAS,SAAS,MAA0B,UAAkB,MAAuB;CACnF,OACE,OAAO,SAAS,YAChB,kBAAkB,KAAK,IAAI,KAC3B,SAAS,SAAS,KAClB,SAAS,UAAU,kBACnB,0EAA0E,KAAK,IAAI;AAEvF;AAEA,SAAS,QAAQ,SAAgC;CAC/C,IAAI,QAAQ,OAAO,IAAI,OAAO;CAC9B,IAAI,CAAC,OAAO;EACV,QAAQ;GAAE,sBAAM,IAAI,IAAI;GAAG,OAAO;EAAE;EACpC,OAAO,IAAI,SAAS,KAAK;CAC3B;CACA,OAAO;AACT;AAEA,eAAe,YACb,SACA,MACA,UAC6B;CAC7B,IAAI;EACF,MAAM,OAAO,kBAAkB,IAAI;EACnC,IAAI,CAAE,MAAM,QAAQ,OAAO,IAAI,GAAI,OAAO,KAAA;EAC1C,MAAM,QAAQ,MAAM,YAAY,SAAS,MAAM,SAAS,MAAM;EAC9D,IAAI,CAAC,SAAS,MAAM,WAAW,GAAG,OAAO,KAAA;EAEzC,QAAO,MADe,WAAW,OAAO,EAAE,iBAAiB,SAAS,OAAO,CAAC,EAAA,CAC7D,OAAO,QAAQ,IAAI,QAAQ,KAAA;CAC5C,QAAQ;EACN;CACF;AACF;;AAGA,eAAe,YACb,SACA,MACA,OAC6B;CAC7B,MAAM,SAAmB,CAAC;CAC1B,IAAI,OAAO;CACX,WAAW,MAAM,SAAS,MAAM,QAAQ,WAAW,IAAI,GAAG;EACxD,MAAM,QAAQ,OAAO,SAAS,KAAK,IAAI,QAAQ,OAAO,KAAK,KAAmB;EAC9E,QAAQ,MAAM;EACd,IAAI,QAAQ,OACV;EAEF,OAAO,KAAK,KAAK;CACnB;CACA,OAAO,OAAO,OAAO,QAAQ,IAAI;AACnC;;AAGA,SAAS,SAAS,SAAyB,OAAc,MAAc,UAAwB;CAC7F,IAAI,MAAM,KAAK,IAAI,IAAI,KAAK,MAAM,KAAK,QAAQ,UAAU;CACzD,MAAM,MAAM,SAAS,SAAS,MAAM,QAAQ,CAAC,CAC1C,YAAY,CAAC,CAAC,CAAC,CACf,cAAc;EACb,MAAM,KAAK,OAAO,IAAI;CACxB,CAAC;CACH,MAAM,KAAK,IAAI,MAAM,GAAG;AAC1B;AAEA,eAAe,SAAS,SAAyB,MAAc,UAAiC;CAC9F,IAAI,WAAW,QAAQ,CAAC,CAAC,OAAO,QAAQ,CAAC,CAAC,OAAO,KAAK,MAAM,MAAM;CAClE,MAAM,SAAS,WAAW;CAC1B,IAAI,CAAE,MAAM,QAAQ,OAAO,MAAM,GAAI;CACrC,MAAM,aAAa,MAAM,SAAS,QAAQ;CAC1C,IAAI,WAAW,UAAU,SAAS,UAAU,CAAE,MAAM,QAAQ,OAAO,MAAM,GAAI;CAC7E,MAAM,OAAO,kBAAkB,IAAI;CACnC,MAAM,QAAQ,MAAM,MAAM,UAAU;CAEpC,IAAI,CAAE,MAAM,QAAQ,OAAO,MAAM,GAAI,MAAM,QAAQ,OAAO,IAAI;AAChE;AAEA,eAAe,aAAgB,SAA6C;CAC1E,IAAI;CACJ,IAAI;EACF,OAAO,MAAM,QAAQ,KAAK,CACxB,SACA,IAAI,SAAoB,YAAY;GAClC,QAAQ,iBAAiB;IACvB,QAAQ,SAAS;GACnB,GAAG,eAAe;EACpB,CAAC,CACH,CAAC;CACH,UAAU;EACR,IAAI,OAAO,aAAa,KAAK;CAC/B;AACF"}