@storyshelf/app 0.10.0-next-e021f6b → 0.10.0-next-42d3da2

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/index.mjs CHANGED
@@ -380,7 +380,7 @@ function registerDocs(app) {
380
380
  * from source (dev server, tests).
381
381
  */
382
382
  function packageVersion() {
383
- return "0.10.0-next-e021f6b";
383
+ return "0.10.0-next-42d3da2";
384
384
  }
385
385
  export { createAuthSystemHook, createShelfApp, createShelfLogger };
386
386
 
@@ -17,6 +17,13 @@ async function resolveRequestUser(c, auth) {
17
17
  */
18
18
  const PUBLISHED_STORYBOOK = /^\/projects\/[^/]+\/storybook(?:\/|$)/u;
19
19
  /**
20
+ * Short links to a published Storybook (`/_/<build id or project slug>/...`). The handler decides
21
+ * access itself (a public build opens, anything else answers like a private one), so a signed-out
22
+ * visitor with a link to a public build is not sent to login first (ADR 0011). Anchored: `/_s/` is
23
+ * the signed-URL prefix and `/_/` alone is not a link.
24
+ */
25
+ const SHORT_LINK = /^\/_\/[^/]+(?:\/|$)/u;
26
+ /**
20
27
  * Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML
21
28
  * requests redirect to login. API/auth routes answer 401/403 themselves, and
22
29
  * published-Storybook routes enforce their own auth (public builds are
@@ -27,7 +34,7 @@ function authGate() {
27
34
  return async (c, next) => {
28
35
  const { user, authEnabled: isAuthEnabled } = getStore();
29
36
  const { path } = c.req;
30
- if (!isAuthEnabled || user || path.startsWith("/api/") || path.startsWith("/auth/") || path.startsWith("/assets/") || path.startsWith(STORYBOOK_TOKEN_PREFIX) || path.startsWith("/.well-known/") || PUBLISHED_STORYBOOK.test(path) || /^\/projects\/[^/]+\/?$/u.test(path)) {
37
+ if (!isAuthEnabled || user || path.startsWith("/api/") || path.startsWith("/auth/") || path.startsWith("/assets/") || path.startsWith(STORYBOOK_TOKEN_PREFIX) || path.startsWith("/.well-known/") || PUBLISHED_STORYBOOK.test(path) || SHORT_LINK.test(path) || /^\/projects\/[^/]+\/?$/u.test(path)) {
31
38
  await next();
32
39
  return;
33
40
  }
@@ -1 +1 @@
1
- {"version":3,"file":"auth-gate.mjs","names":[],"sources":["../../src/middleware/auth-gate.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport { STORYBOOK_TOKEN_PREFIX } from \"@storyshelf/core/utils\";\nimport type { Context, Next } from \"hono\";\nimport { getStore } from \"../store.ts\";\n\n/** Resolve the request user from the auth adapter (null when auth is off or a token is used). */\nexport async function resolveRequestUser(\n c: Pick<Context, \"req\">,\n auth?: Auth,\n): Promise<AuthUser | null> {\n if (!auth) {\n return null;\n }\n if (c.req.header(\"authorization\")) {\n return null;\n }\n return await auth.check(c.req.raw);\n}\n\n/**\n * The published-Storybook routes, which enforce their own auth. Only a `storybook` segment right\n * after the project counts: a `/storybook` anywhere else in a path (a wildcard tail of a component\n * or label page, with a query naming another page) must not open the gate.\n */\nconst PUBLISHED_STORYBOOK = /^\\/projects\\/[^/]+\\/storybook(?:\\/|$)/u;\n\n/**\n * Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML\n * requests redirect to login. API/auth routes answer 401/403 themselves, and\n * published-Storybook routes enforce their own auth (public builds are\n * viewable without a session, ADR 0011), and `/_s/<token>` is answered by its signed token\n * alone (ADR 0007).\n */\nexport function authGate() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const { user, authEnabled: isAuthEnabled } = getStore();\n const { path } = c.req;\n if (\n !isAuthEnabled ||\n user ||\n path.startsWith(\"/api/\") ||\n path.startsWith(\"/auth/\") ||\n path.startsWith(\"/assets/\") ||\n path.startsWith(STORYBOOK_TOKEN_PREFIX) ||\n path.startsWith(\"/.well-known/\") ||\n PUBLISHED_STORYBOOK.test(path) ||\n /^\\/projects\\/[^/]+\\/?$/u.test(path)\n ) {\n await next();\n return;\n }\n if (c.req.header(\"HX-Request\") === \"true\") {\n c.header(\"HX-Redirect\", \"/auth/login\");\n return c.body(null, 204);\n }\n return c.redirect(\"/auth/login\", 302);\n };\n}\n"],"mappings":";;;;;;;AAOA,eAAsB,mBACpB,GACA,MAC0B;CAC1B,IAAI,CAAC,MACH,OAAO;CAET,IAAI,EAAE,IAAI,OAAO,eAAe,GAC9B,OAAO;CAET,OAAO,MAAM,KAAK,MAAM,EAAE,IAAI,GAAG;AACnC;;;;;;AAOA,MAAM,sBAAsB;;;;;;;;AAS5B,SAAgB,WAAW;CAEzB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,EAAE,MAAM,aAAa,kBAAkB,SAAS;EACtD,MAAM,EAAE,SAAS,EAAE;EACnB,IACE,CAAC,iBACD,QACA,KAAK,WAAW,OAAO,KACvB,KAAK,WAAW,QAAQ,KACxB,KAAK,WAAW,UAAU,KAC1B,KAAK,WAAW,sBAAsB,KACtC,KAAK,WAAW,eAAe,KAC/B,oBAAoB,KAAK,IAAI,KAC7B,0BAA0B,KAAK,IAAI,GACnC;GACA,MAAM,KAAK;GACX;EACF;EACA,IAAI,EAAE,IAAI,OAAO,YAAY,MAAM,QAAQ;GACzC,EAAE,OAAO,eAAe,aAAa;GACrC,OAAO,EAAE,KAAK,MAAM,GAAG;EACzB;EACA,OAAO,EAAE,SAAS,eAAe,GAAG;CACtC;AACF"}
1
+ {"version":3,"file":"auth-gate.mjs","names":[],"sources":["../../src/middleware/auth-gate.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport { STORYBOOK_TOKEN_PREFIX } from \"@storyshelf/core/utils\";\nimport type { Context, Next } from \"hono\";\nimport { getStore } from \"../store.ts\";\n\n/** Resolve the request user from the auth adapter (null when auth is off or a token is used). */\nexport async function resolveRequestUser(\n c: Pick<Context, \"req\">,\n auth?: Auth,\n): Promise<AuthUser | null> {\n if (!auth) {\n return null;\n }\n if (c.req.header(\"authorization\")) {\n return null;\n }\n return await auth.check(c.req.raw);\n}\n\n/**\n * The published-Storybook routes, which enforce their own auth. Only a `storybook` segment right\n * after the project counts: a `/storybook` anywhere else in a path (a wildcard tail of a component\n * or label page, with a query naming another page) must not open the gate.\n */\nconst PUBLISHED_STORYBOOK = /^\\/projects\\/[^/]+\\/storybook(?:\\/|$)/u;\n\n/**\n * Short links to a published Storybook (`/_/<build id or project slug>/...`). The handler decides\n * access itself (a public build opens, anything else answers like a private one), so a signed-out\n * visitor with a link to a public build is not sent to login first (ADR 0011). Anchored: `/_s/` is\n * the signed-URL prefix and `/_/` alone is not a link.\n */\nconst SHORT_LINK = /^\\/_\\/[^/]+(?:\\/|$)/u;\n\n/**\n * Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML\n * requests redirect to login. API/auth routes answer 401/403 themselves, and\n * published-Storybook routes enforce their own auth (public builds are\n * viewable without a session, ADR 0011), and `/_s/<token>` is answered by its signed token\n * alone (ADR 0007).\n */\nexport function authGate() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const { user, authEnabled: isAuthEnabled } = getStore();\n const { path } = c.req;\n if (\n !isAuthEnabled ||\n user ||\n path.startsWith(\"/api/\") ||\n path.startsWith(\"/auth/\") ||\n path.startsWith(\"/assets/\") ||\n path.startsWith(STORYBOOK_TOKEN_PREFIX) ||\n path.startsWith(\"/.well-known/\") ||\n PUBLISHED_STORYBOOK.test(path) ||\n SHORT_LINK.test(path) ||\n /^\\/projects\\/[^/]+\\/?$/u.test(path)\n ) {\n await next();\n return;\n }\n if (c.req.header(\"HX-Request\") === \"true\") {\n c.header(\"HX-Redirect\", \"/auth/login\");\n return c.body(null, 204);\n }\n return c.redirect(\"/auth/login\", 302);\n };\n}\n"],"mappings":";;;;;;;AAOA,eAAsB,mBACpB,GACA,MAC0B;CAC1B,IAAI,CAAC,MACH,OAAO;CAET,IAAI,EAAE,IAAI,OAAO,eAAe,GAC9B,OAAO;CAET,OAAO,MAAM,KAAK,MAAM,EAAE,IAAI,GAAG;AACnC;;;;;;AAOA,MAAM,sBAAsB;;;;;;;AAQ5B,MAAM,aAAa;;;;;;;;AASnB,SAAgB,WAAW;CAEzB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,EAAE,MAAM,aAAa,kBAAkB,SAAS;EACtD,MAAM,EAAE,SAAS,EAAE;EACnB,IACE,CAAC,iBACD,QACA,KAAK,WAAW,OAAO,KACvB,KAAK,WAAW,QAAQ,KACxB,KAAK,WAAW,UAAU,KAC1B,KAAK,WAAW,sBAAsB,KACtC,KAAK,WAAW,eAAe,KAC/B,oBAAoB,KAAK,IAAI,KAC7B,WAAW,KAAK,IAAI,KACpB,0BAA0B,KAAK,IAAI,GACnC;GACA,MAAM,KAAK;GACX;EACF;EACA,IAAI,EAAE,IAAI,OAAO,YAAY,MAAM,QAAQ;GACzC,EAAE,OAAO,eAAe,aAAa;GACrC,OAAO,EAAE,KAAK,MAAM,GAAG;EACzB;EACA,OAAO,EAAE,SAAS,eAAe,GAAG;CACtC;AACF"}
@@ -116,21 +116,27 @@ const HealthStatus = ({ channel }) => {
116
116
  tone: "success",
117
117
  children: "ok"
118
118
  });
119
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Badge, {
120
- tone: "danger",
121
- children: "failing"
122
- }), /* @__PURE__ */ jsxs(Meta, {
123
- as: "span",
124
- children: [
125
- "Failed ",
126
- channel.lastErrorAt ? /* @__PURE__ */ jsx(RelativeTime, { value: channel.lastErrorAt }) : null,
127
- /* @__PURE__ */ jsxs("span", {
128
- class: "truncate max-w-cell",
129
- title: channel.lastError,
130
- children: [" ", channel.lastError]
119
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
120
+ /* @__PURE__ */ jsx(Badge, {
121
+ tone: "danger",
122
+ children: "failing"
123
+ }),
124
+ /* @__PURE__ */ jsx("span", {
125
+ class: "truncate max-w-cell",
126
+ children: /* @__PURE__ */ jsxs(Meta, {
127
+ as: "span",
128
+ children: ["Failed ", channel.lastErrorAt ? /* @__PURE__ */ jsx(RelativeTime, { value: channel.lastErrorAt }) : null]
131
129
  })
132
- ]
133
- })] });
130
+ }),
131
+ /* @__PURE__ */ jsx("span", {
132
+ class: "truncate max-w-cell",
133
+ title: channel.lastError,
134
+ children: /* @__PURE__ */ jsx(Meta, {
135
+ as: "span",
136
+ children: channel.lastError
137
+ })
138
+ })
139
+ ] });
134
140
  };
135
141
  /** Delivery health, and when the channel last delivered. */
136
142
  const ChannelHealth = ({ channel }) => /* @__PURE__ */ jsxs(VStack, { children: [/* @__PURE__ */ jsx(HealthStatus, { channel }), /* @__PURE__ */ jsx(LastDelivered, { at: channel.lastDeliveredAt })] });
@@ -1 +1 @@
1
- {"version":3,"file":"settings-notifications.mjs","names":[],"sources":["../../src/pages/settings-notifications.tsx"],"sourcesContent":["import type { Project } from \"@storyshelf/core/schema\";\nimport { NOTIFICATION_EVENTS } from \"@storyshelf/core/utils\";\nimport type { FC } from \"hono/jsx\";\nimport { csrfField } from \"../shell/csrf-field.tsx\";\nimport {\n Alert,\n Badge,\n Button,\n Card,\n HStack,\n CheckField,\n Field,\n FormActions,\n Meta,\n RelativeTime,\n SectionTitle,\n SelectField,\n Table,\n VStack,\n} from \"../ui/components.tsx\";\nimport { NotificationsPausedNotice } from \"./notifications-paused.tsx\";\nimport { CreateGitChannelCard } from \"./settings-notifications-git.tsx\";\n\n/** A wired provider as offered by the create forms. */\nexport interface SettingsNotifyProvider {\n kind: string;\n name: string;\n /** `message` providers send a message once; `state` providers (git hosts) show a build's state. */\n delivery: \"message\" | \"state\";\n}\n\n/** Channel row as rendered in the notifications settings tab. */\nexport interface SettingsNotificationChannel {\n id: string;\n provider: string;\n delivery: \"message\" | \"state\";\n config: Record<string, unknown>;\n hasSecret: boolean;\n events: string[];\n enabled: boolean;\n /** Last delivery error, null while the channel delivers cleanly. */\n lastError: string | null;\n lastErrorAt: string | null;\n /** When a delivery last succeeded, null if none has. */\n lastDeliveredAt: string | null;\n}\n\n/** The viewer's own subscription for this project (null when opted out). */\nexport interface SettingsMySubscription {\n events: string[];\n via: string[];\n enabled: boolean;\n /** Whether `review:requested` reaches them (on unless they turned it off). */\n reviewRequests: boolean;\n}\n\n/** Form state for the notifications tab (field errors, global error). */\nexport interface NotificationsFormState {\n errors?: Record<string, string>;\n globalError?: string;\n}\n\n/* eslint-disable promise-function-async -- JSX components return HtmlEscapedString */\n\n/** The one topic the \"Send me review requests\" switch controls, so it has no checkbox of its own. */\nexport const REVIEW_REQUESTED_EVENT = \"review:requested\";\n\n/** The topics the card lists: every subscribable event but the one the review switch owns. */\nexport const LISTED_TOPICS = NOTIFICATION_EVENTS.filter(\n (entry) => entry.event !== REVIEW_REQUESTED_EVENT,\n);\n\n/** Whether a topic checkbox renders checked (empty allowlist means all). */\nfunction topicChecked(events: string[], event: string): boolean {\n return events.length === 0 || events.includes(event);\n}\n\n/**\n * Whether the review-request switch shows on: the stored switch, and (for a subscription saved\n * before the switch owned the topic) a topic list that still admits it.\n */\nfunction reviewRequestsOn(subscription: SettingsMySubscription | null): boolean {\n if (subscription === null) {\n return true;\n }\n const { events, reviewRequests } = subscription;\n return reviewRequests && (events.length === 0 || events.includes(REVIEW_REQUESTED_EVENT));\n}\n\n/** One checkbox per listed topic (an empty allowlist means all are ticked). */\nconst TopicChecks: FC<{ events: string[] }> = ({ events }) => (\n <>\n {LISTED_TOPICS.map((topic) => (\n <CheckField\n label={topic.label}\n name=\"events\"\n value={topic.event}\n checked={topicChecked(events, topic.event)}\n />\n ))}\n </>\n);\n\n/** The viewer's own preferences card (visible to every member). */\nconst MyNotificationsCard: FC<{\n project: Project;\n mySubscription: SettingsMySubscription | null;\n}> = ({ project, mySubscription }) => {\n const events = mySubscription?.events ?? [];\n const emailOff = mySubscription !== null && !mySubscription.enabled;\n return (\n <Card>\n <SectionTitle>My notifications for this project</SectionTitle>\n <Meta>\n Approvers and admins get review requests by email by default; turn off Send me review\n requests to stop them. The options below add other email alerts for this project.\n </Meta>\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/notifications/me`}\n hx-post={`/projects/${project.slug}/settings/notifications/me`}\n hx-target=\"body\"\n >\n {csrfField()}\n <CheckField\n label=\"Email me about this project\"\n name=\"enabled\"\n checked={mySubscription?.enabled ?? false}\n />\n <CheckField\n label=\"Send me review requests\"\n name=\"reviewRequests\"\n checked={reviewRequestsOn(mySubscription)}\n hint={\n emailOff\n ? \"Email me is off, so no review requests reach you until you turn it on.\"\n : \"Emails you when a build waits for a verdict. It changes nothing else below.\"\n }\n />\n <TopicChecks events={events} />\n <p>\n <Meta as=\"span\">Ticking every box, or none, means all topics, including new ones.</Meta>\n </p>\n {project.reviewEnabled === false ? null : (\n <p>\n <Meta as=\"span\">Reviews assigned to you as a component owner are always sent.</Meta>\n </p>\n )}\n <FormActions>\n <Button variant=\"primary\" type=\"submit\" icon=\"check\">\n Save my preferences\n </Button>\n </FormActions>\n </form>\n </Card>\n );\n};\n\n/** A config value as text; anything but a string reads as empty. */\nfunction textOf(value: unknown): string {\n return typeof value === \"string\" ? value : \"\";\n}\n\n/** Human-readable channel target (git repository, email address or masked webhook host). */\nfunction targetOf(channel: SettingsNotificationChannel): string {\n if (channel.delivery === \"state\") {\n const where = `${textOf(channel.config[\"owner\"])}/${textOf(channel.config[\"repo\"])}`;\n const prefix = textOf(channel.config[\"prefix\"]);\n return prefix ? `${where} (${prefix})` : where;\n }\n const to = channel.config[\"to\"];\n if (typeof to === \"string\" && to) {\n return to;\n }\n return channel.hasSecret ? `${channel.provider} webhook` : \"—\";\n}\n\n/** What a channel reacts to: its topic filter, or the build state for git channels. */\nfunction reactsTo(channel: SettingsNotificationChannel): string {\n if (channel.delivery === \"state\") {\n return \"build state\";\n }\n return channel.events.length === 0 ? \"all events\" : channel.events.join(\", \");\n}\n\n/** When a channel last delivered, or that it never has. */\nconst LastDelivered: FC<{ at: string | null }> = ({ at }) => (\n <Meta as=\"span\">\n {at === null ? (\n \"Never delivered\"\n ) : (\n <>\n Last delivered <RelativeTime value={at} />\n </>\n )}\n </Meta>\n);\n\n/** The health badge: failing with its time and error, ok, or no delivery yet. */\nconst HealthStatus: FC<{ channel: SettingsNotificationChannel }> = ({ channel }) => {\n if (channel.lastError === null) {\n return channel.lastDeliveredAt === null ? (\n <Badge tone=\"neutral\">no delivery yet</Badge>\n ) : (\n <Badge tone=\"success\">ok</Badge>\n );\n }\n return (\n <>\n <Badge tone=\"danger\">failing</Badge>\n <Meta as=\"span\">\n Failed {channel.lastErrorAt ? <RelativeTime value={channel.lastErrorAt} /> : null}\n <span class=\"truncate max-w-cell\" title={channel.lastError}>\n {\" \"}\n {channel.lastError}\n </span>\n </Meta>\n </>\n );\n};\n\n/** Delivery health, and when the channel last delivered. */\nconst ChannelHealth: FC<{ channel: SettingsNotificationChannel }> = ({ channel }) => (\n <VStack>\n <HealthStatus channel={channel} />\n <LastDelivered at={channel.lastDeliveredAt} />\n </VStack>\n);\n\n/** Git channels only, admins only: ask again for the open builds' state on this channel. Disabled while notifications are paused. */\nconst RetryForm: FC<{\n project: Project;\n channel: SettingsNotificationChannel;\n paused: boolean;\n}> = ({ project, channel, paused }) => (\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/notifications/${channel.id}/retry`}\n hx-post={`/projects/${project.slug}/settings/notifications/${channel.id}/retry`}\n hx-target=\"body\"\n hx-disabled-elt=\"find button\"\n >\n {csrfField()}\n <HStack>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n type=\"submit\"\n disabled={paused}\n aria-label={`Retry current state of ${targetOf(channel)}`}\n aria-describedby={paused ? \"notifications-paused\" : undefined}\n >\n Retry current state\n </Button>\n {paused ? <Meta as=\"span\">Paused: no confirmed public origin</Meta> : null}\n </HStack>\n </form>\n);\n\n/** The channel's delete action (admins only). */\nconst DeleteForm: FC<{ project: Project; channel: SettingsNotificationChannel }> = ({\n project,\n channel,\n}) => (\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/notifications/${channel.id}/delete`}\n hx-post={`/projects/${project.slug}/settings/notifications/${channel.id}/delete`}\n hx-target=\"body\"\n hx-confirm=\"Remove this channel?\"\n >\n {csrfField()}\n <Button variant=\"ghost\" size=\"sm\" type=\"submit\">\n Delete\n </Button>\n </form>\n);\n\n/** One channel table row; Retry and Delete (admins only) sit side by side under the delivery content. */\nconst ChannelRow: FC<{\n project: Project;\n channel: SettingsNotificationChannel;\n isAdmin: boolean;\n paused: boolean;\n}> = ({ project, channel, isAdmin, paused }) => (\n <tr key={channel.id} id={`channel-${channel.id}`} tabIndex={-1}>\n <td>\n <Badge tone=\"neutral\">{channel.provider}</Badge>\n </td>\n <td class=\"truncate max-w-cell\" title={targetOf(channel)}>\n {targetOf(channel)}\n </td>\n <td>\n <Meta as=\"span\">{reactsTo(channel)}</Meta>\n </td>\n <td>\n <VStack>\n <ChannelHealth channel={channel} />\n {isAdmin ? (\n <HStack>\n {channel.delivery === \"state\" && channel.enabled ? (\n <RetryForm project={project} channel={channel} paused={paused} />\n ) : null}\n <DeleteForm project={project} channel={channel} />\n </HStack>\n ) : null}\n </VStack>\n </td>\n </tr>\n);\n\n/** Channel list card: chat, email and git channels side by side, each with its delivery health. */\nconst ChannelsCard: FC<{\n project: Project;\n channels: SettingsNotificationChannel[];\n isAdmin: boolean;\n paused: boolean;\n}> = ({ project, channels, isAdmin, paused }) => (\n <Card>\n <SectionTitle>Channels</SectionTitle>\n <Meta>\n Project admins route build events to Slack, Teams or email, and show each build's state on\n GitHub or GitLab as a commit status. Secrets and tokens are stored encrypted and decrypted\n only in memory at delivery time. Git channels follow the build state, not a topic list, and\n their delivery is best-effort: a failure shows here, and the next build state retries it.\n </Meta>\n {isAdmin && channels.some((channel) => channel.delivery === \"state\") ? (\n <Meta>\n Retry re-sends the current state of this project's open builds to one git channel. It never\n replays chat, email or webhooks.\n </Meta>\n ) : null}\n <Table label=\"Channels\">\n <table>\n <thead>\n <tr>\n <th>Provider</th>\n <th>Target</th>\n <th>Reacts to</th>\n <th>Delivery</th>\n </tr>\n </thead>\n <tbody>\n {channels.map((channel) => (\n <ChannelRow project={project} channel={channel} isAdmin={isAdmin} paused={paused} />\n ))}\n </tbody>\n </table>\n </Table>\n {channels.length === 0 ? <Meta>No channels configured.</Meta> : null}\n </Card>\n);\n\n/** Channel creation card (admins only). */\nconst CreateChannelCard: FC<{\n project: Project;\n providers: SettingsNotifyProvider[];\n formState?: NotificationsFormState;\n}> = ({ project, providers, formState }) => (\n <Card>\n <SectionTitle level={3}>Add a message channel</SectionTitle>\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/notifications`}\n hx-post={`/projects/${project.slug}/settings/notifications`}\n hx-target=\"body\"\n >\n {csrfField()}\n <SelectField\n label=\"Provider\"\n name=\"provider\"\n options={providers.map((provider) => ({ value: provider.kind, label: provider.name }))}\n hint={formState?.errors?.[\"provider\"]}\n />\n <Field\n label=\"Target\"\n name=\"target\"\n type=\"text\"\n required\n placeholder=\"team@example.com or https://hooks.slack.com/…\"\n hint=\"Email address for email channels, webhook URL otherwise.\"\n error={formState?.errors?.[\"target\"]}\n />\n <Field\n label=\"Events\"\n name=\"events\"\n placeholder=\"build.reviewing, build.approved\"\n hint=\"Comma-separated. Leave blank to receive all events.\"\n />\n <SelectField\n label=\"Style\"\n name=\"style\"\n options={[\n { value: \"compact\", label: \"Compact\" },\n { value: \"verbose\", label: \"Verbose\" },\n ]}\n />\n <FormActions>\n <Button variant=\"primary\" type=\"submit\" icon=\"plus\">\n Add channel\n </Button>\n </FormActions>\n </form>\n </Card>\n);\n\n/** Notifications settings tab: channel list, create forms, and my preferences. */\nexport function renderSettingsNotifications(\n project: Project,\n channels: SettingsNotificationChannel[],\n providers: SettingsNotifyProvider[],\n mySubscription: SettingsMySubscription | null,\n isAdmin: boolean,\n formState?: NotificationsFormState,\n paused?: boolean,\n): unknown {\n const messageProviders = providers.filter((provider) => provider.delivery === \"message\");\n const gitProviders = providers.filter((provider) => provider.delivery === \"state\");\n return (\n <div class=\"grid max-w-form\">\n <NotificationsPausedNotice paused={paused} />\n {formState?.globalError ? <Alert tone=\"danger\">{formState.globalError}</Alert> : null}\n <MyNotificationsCard project={project} mySubscription={mySubscription} />\n <ChannelsCard\n project={project}\n channels={channels}\n isAdmin={isAdmin}\n paused={paused === true}\n />\n {isAdmin && messageProviders.length > 0 ? (\n <CreateChannelCard project={project} providers={messageProviders} formState={formState} />\n ) : null}\n {isAdmin && gitProviders.length > 0 ? (\n <CreateGitChannelCard project={project} providers={gitProviders} formState={formState} />\n ) : null}\n </div>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"settings-notifications.mjs","names":[],"sources":["../../src/pages/settings-notifications.tsx"],"sourcesContent":["import type { Project } from \"@storyshelf/core/schema\";\nimport { NOTIFICATION_EVENTS } from \"@storyshelf/core/utils\";\nimport type { FC } from \"hono/jsx\";\nimport { csrfField } from \"../shell/csrf-field.tsx\";\nimport {\n Alert,\n Badge,\n Button,\n Card,\n HStack,\n CheckField,\n Field,\n FormActions,\n Meta,\n RelativeTime,\n SectionTitle,\n SelectField,\n Table,\n VStack,\n} from \"../ui/components.tsx\";\nimport { NotificationsPausedNotice } from \"./notifications-paused.tsx\";\nimport { CreateGitChannelCard } from \"./settings-notifications-git.tsx\";\n\n/** A wired provider as offered by the create forms. */\nexport interface SettingsNotifyProvider {\n kind: string;\n name: string;\n /** `message` providers send a message once; `state` providers (git hosts) show a build's state. */\n delivery: \"message\" | \"state\";\n}\n\n/** Channel row as rendered in the notifications settings tab. */\nexport interface SettingsNotificationChannel {\n id: string;\n provider: string;\n delivery: \"message\" | \"state\";\n config: Record<string, unknown>;\n hasSecret: boolean;\n events: string[];\n enabled: boolean;\n /** Last delivery error, null while the channel delivers cleanly. */\n lastError: string | null;\n lastErrorAt: string | null;\n /** When a delivery last succeeded, null if none has. */\n lastDeliveredAt: string | null;\n}\n\n/** The viewer's own subscription for this project (null when opted out). */\nexport interface SettingsMySubscription {\n events: string[];\n via: string[];\n enabled: boolean;\n /** Whether `review:requested` reaches them (on unless they turned it off). */\n reviewRequests: boolean;\n}\n\n/** Form state for the notifications tab (field errors, global error). */\nexport interface NotificationsFormState {\n errors?: Record<string, string>;\n globalError?: string;\n}\n\n/* eslint-disable promise-function-async -- JSX components return HtmlEscapedString */\n\n/** The one topic the \"Send me review requests\" switch controls, so it has no checkbox of its own. */\nexport const REVIEW_REQUESTED_EVENT = \"review:requested\";\n\n/** The topics the card lists: every subscribable event but the one the review switch owns. */\nexport const LISTED_TOPICS = NOTIFICATION_EVENTS.filter(\n (entry) => entry.event !== REVIEW_REQUESTED_EVENT,\n);\n\n/** Whether a topic checkbox renders checked (empty allowlist means all). */\nfunction topicChecked(events: string[], event: string): boolean {\n return events.length === 0 || events.includes(event);\n}\n\n/**\n * Whether the review-request switch shows on: the stored switch, and (for a subscription saved\n * before the switch owned the topic) a topic list that still admits it.\n */\nfunction reviewRequestsOn(subscription: SettingsMySubscription | null): boolean {\n if (subscription === null) {\n return true;\n }\n const { events, reviewRequests } = subscription;\n return reviewRequests && (events.length === 0 || events.includes(REVIEW_REQUESTED_EVENT));\n}\n\n/** One checkbox per listed topic (an empty allowlist means all are ticked). */\nconst TopicChecks: FC<{ events: string[] }> = ({ events }) => (\n <>\n {LISTED_TOPICS.map((topic) => (\n <CheckField\n label={topic.label}\n name=\"events\"\n value={topic.event}\n checked={topicChecked(events, topic.event)}\n />\n ))}\n </>\n);\n\n/** The viewer's own preferences card (visible to every member). */\nconst MyNotificationsCard: FC<{\n project: Project;\n mySubscription: SettingsMySubscription | null;\n}> = ({ project, mySubscription }) => {\n const events = mySubscription?.events ?? [];\n const emailOff = mySubscription !== null && !mySubscription.enabled;\n return (\n <Card>\n <SectionTitle>My notifications for this project</SectionTitle>\n <Meta>\n Approvers and admins get review requests by email by default; turn off Send me review\n requests to stop them. The options below add other email alerts for this project.\n </Meta>\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/notifications/me`}\n hx-post={`/projects/${project.slug}/settings/notifications/me`}\n hx-target=\"body\"\n >\n {csrfField()}\n <CheckField\n label=\"Email me about this project\"\n name=\"enabled\"\n checked={mySubscription?.enabled ?? false}\n />\n <CheckField\n label=\"Send me review requests\"\n name=\"reviewRequests\"\n checked={reviewRequestsOn(mySubscription)}\n hint={\n emailOff\n ? \"Email me is off, so no review requests reach you until you turn it on.\"\n : \"Emails you when a build waits for a verdict. It changes nothing else below.\"\n }\n />\n <TopicChecks events={events} />\n <p>\n <Meta as=\"span\">Ticking every box, or none, means all topics, including new ones.</Meta>\n </p>\n {project.reviewEnabled === false ? null : (\n <p>\n <Meta as=\"span\">Reviews assigned to you as a component owner are always sent.</Meta>\n </p>\n )}\n <FormActions>\n <Button variant=\"primary\" type=\"submit\" icon=\"check\">\n Save my preferences\n </Button>\n </FormActions>\n </form>\n </Card>\n );\n};\n\n/** A config value as text; anything but a string reads as empty. */\nfunction textOf(value: unknown): string {\n return typeof value === \"string\" ? value : \"\";\n}\n\n/** Human-readable channel target (git repository, email address or masked webhook host). */\nfunction targetOf(channel: SettingsNotificationChannel): string {\n if (channel.delivery === \"state\") {\n const where = `${textOf(channel.config[\"owner\"])}/${textOf(channel.config[\"repo\"])}`;\n const prefix = textOf(channel.config[\"prefix\"]);\n return prefix ? `${where} (${prefix})` : where;\n }\n const to = channel.config[\"to\"];\n if (typeof to === \"string\" && to) {\n return to;\n }\n return channel.hasSecret ? `${channel.provider} webhook` : \"—\";\n}\n\n/** What a channel reacts to: its topic filter, or the build state for git channels. */\nfunction reactsTo(channel: SettingsNotificationChannel): string {\n if (channel.delivery === \"state\") {\n return \"build state\";\n }\n return channel.events.length === 0 ? \"all events\" : channel.events.join(\", \");\n}\n\n/** When a channel last delivered, or that it never has. */\nconst LastDelivered: FC<{ at: string | null }> = ({ at }) => (\n <Meta as=\"span\">\n {at === null ? (\n \"Never delivered\"\n ) : (\n <>\n Last delivered <RelativeTime value={at} />\n </>\n )}\n </Meta>\n);\n\n/** The health badge: failing with its time and error, ok, or no delivery yet. */\nconst HealthStatus: FC<{ channel: SettingsNotificationChannel }> = ({ channel }) => {\n if (channel.lastError === null) {\n return channel.lastDeliveredAt === null ? (\n <Badge tone=\"neutral\">no delivery yet</Badge>\n ) : (\n <Badge tone=\"success\">ok</Badge>\n );\n }\n return (\n <>\n <Badge tone=\"danger\">failing</Badge>\n <span class=\"truncate max-w-cell\">\n <Meta as=\"span\">\n Failed {channel.lastErrorAt ? <RelativeTime value={channel.lastErrorAt} /> : null}\n </Meta>\n </span>\n <span class=\"truncate max-w-cell\" title={channel.lastError}>\n <Meta as=\"span\">{channel.lastError}</Meta>\n </span>\n </>\n );\n};\n\n/** Delivery health, and when the channel last delivered. */\nconst ChannelHealth: FC<{ channel: SettingsNotificationChannel }> = ({ channel }) => (\n <VStack>\n <HealthStatus channel={channel} />\n <LastDelivered at={channel.lastDeliveredAt} />\n </VStack>\n);\n\n/** Git channels only, admins only: ask again for the open builds' state on this channel. Disabled while notifications are paused. */\nconst RetryForm: FC<{\n project: Project;\n channel: SettingsNotificationChannel;\n paused: boolean;\n}> = ({ project, channel, paused }) => (\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/notifications/${channel.id}/retry`}\n hx-post={`/projects/${project.slug}/settings/notifications/${channel.id}/retry`}\n hx-target=\"body\"\n hx-disabled-elt=\"find button\"\n >\n {csrfField()}\n <HStack>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n type=\"submit\"\n disabled={paused}\n aria-label={`Retry current state of ${targetOf(channel)}`}\n aria-describedby={paused ? \"notifications-paused\" : undefined}\n >\n Retry current state\n </Button>\n {paused ? <Meta as=\"span\">Paused: no confirmed public origin</Meta> : null}\n </HStack>\n </form>\n);\n\n/** The channel's delete action (admins only). */\nconst DeleteForm: FC<{ project: Project; channel: SettingsNotificationChannel }> = ({\n project,\n channel,\n}) => (\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/notifications/${channel.id}/delete`}\n hx-post={`/projects/${project.slug}/settings/notifications/${channel.id}/delete`}\n hx-target=\"body\"\n hx-confirm=\"Remove this channel?\"\n >\n {csrfField()}\n <Button variant=\"ghost\" size=\"sm\" type=\"submit\">\n Delete\n </Button>\n </form>\n);\n\n/** One channel table row; Retry and Delete (admins only) sit side by side under the delivery content. */\nconst ChannelRow: FC<{\n project: Project;\n channel: SettingsNotificationChannel;\n isAdmin: boolean;\n paused: boolean;\n}> = ({ project, channel, isAdmin, paused }) => (\n <tr key={channel.id} id={`channel-${channel.id}`} tabIndex={-1}>\n <td>\n <Badge tone=\"neutral\">{channel.provider}</Badge>\n </td>\n <td class=\"truncate max-w-cell\" title={targetOf(channel)}>\n {targetOf(channel)}\n </td>\n <td>\n <Meta as=\"span\">{reactsTo(channel)}</Meta>\n </td>\n <td>\n <VStack>\n <ChannelHealth channel={channel} />\n {isAdmin ? (\n <HStack>\n {channel.delivery === \"state\" && channel.enabled ? (\n <RetryForm project={project} channel={channel} paused={paused} />\n ) : null}\n <DeleteForm project={project} channel={channel} />\n </HStack>\n ) : null}\n </VStack>\n </td>\n </tr>\n);\n\n/** Channel list card: chat, email and git channels side by side, each with its delivery health. */\nconst ChannelsCard: FC<{\n project: Project;\n channels: SettingsNotificationChannel[];\n isAdmin: boolean;\n paused: boolean;\n}> = ({ project, channels, isAdmin, paused }) => (\n <Card>\n <SectionTitle>Channels</SectionTitle>\n <Meta>\n Project admins route build events to Slack, Teams or email, and show each build's state on\n GitHub or GitLab as a commit status. Secrets and tokens are stored encrypted and decrypted\n only in memory at delivery time. Git channels follow the build state, not a topic list, and\n their delivery is best-effort: a failure shows here, and the next build state retries it.\n </Meta>\n {isAdmin && channels.some((channel) => channel.delivery === \"state\") ? (\n <Meta>\n Retry re-sends the current state of this project's open builds to one git channel. It never\n replays chat, email or webhooks.\n </Meta>\n ) : null}\n <Table label=\"Channels\">\n <table>\n <thead>\n <tr>\n <th>Provider</th>\n <th>Target</th>\n <th>Reacts to</th>\n <th>Delivery</th>\n </tr>\n </thead>\n <tbody>\n {channels.map((channel) => (\n <ChannelRow project={project} channel={channel} isAdmin={isAdmin} paused={paused} />\n ))}\n </tbody>\n </table>\n </Table>\n {channels.length === 0 ? <Meta>No channels configured.</Meta> : null}\n </Card>\n);\n\n/** Channel creation card (admins only). */\nconst CreateChannelCard: FC<{\n project: Project;\n providers: SettingsNotifyProvider[];\n formState?: NotificationsFormState;\n}> = ({ project, providers, formState }) => (\n <Card>\n <SectionTitle level={3}>Add a message channel</SectionTitle>\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/notifications`}\n hx-post={`/projects/${project.slug}/settings/notifications`}\n hx-target=\"body\"\n >\n {csrfField()}\n <SelectField\n label=\"Provider\"\n name=\"provider\"\n options={providers.map((provider) => ({ value: provider.kind, label: provider.name }))}\n hint={formState?.errors?.[\"provider\"]}\n />\n <Field\n label=\"Target\"\n name=\"target\"\n type=\"text\"\n required\n placeholder=\"team@example.com or https://hooks.slack.com/…\"\n hint=\"Email address for email channels, webhook URL otherwise.\"\n error={formState?.errors?.[\"target\"]}\n />\n <Field\n label=\"Events\"\n name=\"events\"\n placeholder=\"build.reviewing, build.approved\"\n hint=\"Comma-separated. Leave blank to receive all events.\"\n />\n <SelectField\n label=\"Style\"\n name=\"style\"\n options={[\n { value: \"compact\", label: \"Compact\" },\n { value: \"verbose\", label: \"Verbose\" },\n ]}\n />\n <FormActions>\n <Button variant=\"primary\" type=\"submit\" icon=\"plus\">\n Add channel\n </Button>\n </FormActions>\n </form>\n </Card>\n);\n\n/** Notifications settings tab: channel list, create forms, and my preferences. */\nexport function renderSettingsNotifications(\n project: Project,\n channels: SettingsNotificationChannel[],\n providers: SettingsNotifyProvider[],\n mySubscription: SettingsMySubscription | null,\n isAdmin: boolean,\n formState?: NotificationsFormState,\n paused?: boolean,\n): unknown {\n const messageProviders = providers.filter((provider) => provider.delivery === \"message\");\n const gitProviders = providers.filter((provider) => provider.delivery === \"state\");\n return (\n <div class=\"grid max-w-form\">\n <NotificationsPausedNotice paused={paused} />\n {formState?.globalError ? <Alert tone=\"danger\">{formState.globalError}</Alert> : null}\n <MyNotificationsCard project={project} mySubscription={mySubscription} />\n <ChannelsCard\n project={project}\n channels={channels}\n isAdmin={isAdmin}\n paused={paused === true}\n />\n {isAdmin && messageProviders.length > 0 ? (\n <CreateChannelCard project={project} providers={messageProviders} formState={formState} />\n ) : null}\n {isAdmin && gitProviders.length > 0 ? (\n <CreateGitChannelCard project={project} providers={gitProviders} formState={formState} />\n ) : null}\n </div>\n );\n}\n"],"mappings":"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"}
@@ -14,6 +14,40 @@ async function staticsReady(projectId, buildId) {
14
14
  if (await getStore().storage.exists(posix.join(storybookDir(projectId, buildId), "manifest.json"))) return true;
15
15
  return await getStore().storage.exists(posix.join(storybookDir(projectId, buildId), "iframe.html"));
16
16
  }
17
+ /** Whether a short-link id names one build (a 26-character ULID) rather than a project slug. */
18
+ function isBuildId(id) {
19
+ return id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);
20
+ }
21
+ /**
22
+ * The build a short link opens: the build itself for a ULID, whatever its status; for a project
23
+ * slug the latest approved build on the default branch (never a feature branch or an unreviewed
24
+ * build). Null when nothing matches.
25
+ */
26
+ async function resolveShortLink(id) {
27
+ const { db } = getStore();
28
+ if (isBuildId(id)) {
29
+ const build = await new BuildModel(db).get(id);
30
+ const project = build ? await new ProjectModel(db).get(build.projectId) : null;
31
+ return build && project ? {
32
+ project,
33
+ build
34
+ } : null;
35
+ }
36
+ const project = await new ProjectModel(db).getBySlug(id);
37
+ const build = project ? await new BuildModel(db).latestApprovedDefault(project) : null;
38
+ return project && build ? {
39
+ project,
40
+ build
41
+ } : null;
42
+ }
43
+ /**
44
+ * The answer for a short link that names nothing the caller may open: a missing build or project
45
+ * and a private one look the same, so the link never confirms that a private id exists. Without
46
+ * sign-in every build is open, so a missing id is a plain 404.
47
+ */
48
+ function unreachable(c) {
49
+ return getStore().authEnabled ? c.redirect("/auth/login", 302) : c.notFound();
50
+ }
17
51
  /** Register the published Storybook resolver and static-asset routes. */
18
52
  function registerStorybook(app) {
19
53
  app.get("/_", (c) => c.redirect("/", 302));
@@ -29,29 +63,18 @@ function registerStorybook(app) {
29
63
  });
30
64
  app.get("/_/:id", async (c) => {
31
65
  const id = c.req.param("id");
32
- if (id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id)) {
33
- if (!await new BuildModel(getStore().db).get(id)) notFound();
34
- } else if (!await new ProjectModel(getStore().db).getBySlug(id)) notFound();
66
+ const target = await resolveShortLink(id);
67
+ if (!target || !await publishedAccess(target.build, target.project)) return unreachable(c);
35
68
  return c.redirect(`/_/${id}/`, 302);
36
69
  });
37
70
  app.get("/_/:id/*", async (c) => {
38
71
  const id = c.req.param("id");
39
- const isUlid26 = id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);
72
+ const isUlid26 = isBuildId(id);
40
73
  const rest = publishedRelativePath(c.req.path, `/_/${id}/`);
41
74
  if (rest === null) return c.notFound();
42
- let project = null;
43
- let build = null;
44
- if (isUlid26) {
45
- build = await new BuildModel(getStore().db).get(id);
46
- if (!build) return c.notFound();
47
- project = await new ProjectModel(getStore().db).get(build.projectId);
48
- if (!project) return c.notFound();
49
- } else {
50
- project = await new ProjectModel(getStore().db).getBySlug(id);
51
- if (!project) return c.notFound();
52
- build = await new BuildModel(getStore().db).latestApprovedDefault(project);
53
- if (!build) return c.notFound();
54
- }
75
+ const target = await resolveShortLink(id);
76
+ if (!target) return unreachable(c);
77
+ const { project, build } = target;
55
78
  const access = await publishedAccess(build, project);
56
79
  if (!access) return c.redirect("/auth/login", 302);
57
80
  if (rest === "" && !await staticsReady(project.id, build.id)) return c.html(renderStorybookPreparingPage(project, build, project.slug), 200);
@@ -1 +1 @@
1
- {"version":3,"file":"storybook.mjs","names":[],"sources":["../../src/routers/storybook.ts"],"sourcesContent":["import { BuildModel } from \"@storyshelf/core/models\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { storybookDir } from \"@storyshelf/core/utils\";\nimport { posix } from \"node:path\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { renderStorybookPage, renderStorybookPreparingPage } from \"../pages/storybook.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { notFound } from \"./helpers.ts\";\nimport { publishedAccess } from \"./published-access.ts\";\nimport { publishedRelativePath, servePublishedFile } from \"./published-files.ts\";\n\n/** Check whether a build's extracted statics are available for serving. */\nasync function staticsReady(projectId: string, buildId: string): Promise<boolean> {\n // New path: manifest.json indicates content-addressed storage; fallback to legacy iframe.html\n if (\n await getStore().storage.exists(posix.join(storybookDir(projectId, buildId), \"manifest.json\"))\n ) {\n return true;\n }\n return await getStore().storage.exists(\n posix.join(storybookDir(projectId, buildId), \"iframe.html\"),\n );\n}\n\n/** Register the published Storybook resolver and static-asset routes. */\nexport function registerStorybook(app: ShelfRouter): void {\n app.get(\"/_\", (c) => c.redirect(\"/\", 302));\n app.get(\"/_/\", (c) => c.redirect(\"/\", 302));\n\n // Resolver: latest approved build on the default branch (never feature branches).\n app.get(\"/projects/:slug/storybook\", async (c) => {\n const slug = c.req.param(\"slug\");\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const build = await new BuildModel(getStore().db).latestApprovedDefault(project);\n if (!build) {\n notFound(\"No approved build on the default branch yet\");\n }\n if (!(await publishedAccess(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n return c.redirect(`/projects/${slug}/storybook/build/${build.id}/`, 302);\n });\n\n // Short link canonical is /_/:id/ with trailing slash (so relative ./sb-manager/... resolves).\n // 26-char ULID → that exact build, whatever its status (visibility check only).\n // Any other id → project slug → latest approved build on the default branch, else 404\n // (no fallback to feature-branch or unreviewed builds). No fall-through on ULID.\n app.get(\"/_/:id\", async (c) => {\n const id = c.req.param(\"id\");\n const isUlid26 = id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);\n if (isUlid26) {\n const build = await new BuildModel(getStore().db).get(id);\n if (!build) notFound();\n } else {\n const project = await new ProjectModel(getStore().db).getBySlug(id);\n if (!project) notFound();\n }\n return c.redirect(`/_/${id}/`, 302);\n });\n\n app.get(\"/_/:id/*\", async (c) => {\n const id = c.req.param(\"id\");\n const isUlid26 = id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);\n const rest = publishedRelativePath(c.req.path, `/_/${id}/`);\n if (rest === null) return c.notFound();\n let project: Project | null = null;\n let build: Build | null = null;\n if (isUlid26) {\n build = await new BuildModel(getStore().db).get(id);\n if (!build) return c.notFound();\n project = await new ProjectModel(getStore().db).get(build.projectId);\n if (!project) return c.notFound();\n } else {\n project = await new ProjectModel(getStore().db).getBySlug(id);\n if (!project) return c.notFound();\n build = await new BuildModel(getStore().db).latestApprovedDefault(project);\n if (!build) return c.notFound();\n }\n const access = await publishedAccess(build, project);\n if (!access) return c.redirect(\"/auth/login\", 302);\n if (rest === \"\" && !(await staticsReady(project.id, build.id))) {\n return c.html(renderStorybookPreparingPage(project, build, project.slug), 200);\n }\n const fallback = isUlid26\n ? `/projects/${project.slug}/builds/${build.id}`\n : `/projects/${project.slug}`;\n const served = await servePublishedFile(c, {\n projectId: project.id,\n buildId: build.id,\n rel: rest === \"\" ? \"index.html\" : rest,\n access,\n cacheControl: shortLinkCache(isUlid26, rest === \"\"),\n });\n if (served) return served;\n return rest === \"\" || rest === \"index.html\" ? c.redirect(fallback, 302) : c.notFound();\n });\n\n // Canonical build route: serves the static Storybook and the landing page.\n app.get(\"/projects/:slug/storybook/build/:buildId/*\", async (c) => {\n const slug = c.req.param(\"slug\");\n const buildId = c.req.param(\"buildId\");\n // Hono's bare `/*` wildcard is not exposed through `param()`, so derive the\n // remainder from the raw request path (un-encoded) and decode it ourselves.\n const rest = publishedRelativePath(c.req.path, `/projects/${slug}/storybook/build/${buildId}/`);\n if (rest === null) {\n return c.notFound();\n }\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== project.id) {\n notFound(\"Build not found\");\n }\n const access = await publishedAccess(build, project);\n if (!access) {\n return c.redirect(\"/auth/login\", 302);\n }\n\n if (rest === \"\") {\n if (!(await staticsReady(project.id, build.id))) {\n return c.html(renderStorybookPreparingPage(project, build, slug), 200);\n }\n const storyId = c.req.query(\"storyId\") ?? c.req.query(\"id\") ?? undefined;\n const viewMode = c.req.query(\"viewMode\") === \"docs\" ? \"docs\" : undefined;\n return c.html(renderStorybookPage(project, build, slug, { storyId, viewMode }), 200);\n }\n\n return (\n (await servePublishedFile(c, {\n projectId: project.id,\n buildId: build.id,\n rel: rest,\n access,\n cacheControl: \"public, max-age=31536000, immutable\",\n })) ?? c.notFound()\n );\n });\n\n // Resolver: latest build bearing a label (`build` is a reserved label key).\n app.get(\"/projects/:slug/storybook/:key/*\", async (c) => {\n const slug = c.req.param(\"slug\");\n const key = c.req.param(\"key\");\n if (key === \"build\") {\n return c.notFound();\n }\n let value: string;\n try {\n value = decodeURIComponent(c.req.path.split(\"/\").slice(5).join(\"/\"));\n } catch {\n return c.notFound();\n }\n if (!value) return c.notFound();\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const buildId = await new LabelModel(getStore().db).latestBuildId(project.id, key, value);\n if (!buildId) {\n notFound(\"No build carries that label\");\n }\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || !(await publishedAccess(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n return c.redirect(`/projects/${slug}/storybook/build/${buildId}/`, 302);\n });\n}\n\n/** Cache policy of the short link: a ULID names one build forever; a slug follows the latest. */\nfunction shortLinkCache(isUlid26: boolean, isRoot: boolean): string {\n if (isUlid26) {\n return \"public, max-age=31536000, immutable\";\n }\n return isRoot ? \"public, max-age=60\" : \"public, max-age=3600\";\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"storybook.mjs","names":[],"sources":["../../src/routers/storybook.ts"],"sourcesContent":["import { BuildModel } from \"@storyshelf/core/models\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { storybookDir } from \"@storyshelf/core/utils\";\nimport type { Context } from \"hono\";\nimport { posix } from \"node:path\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { renderStorybookPage, renderStorybookPreparingPage } from \"../pages/storybook.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { notFound } from \"./helpers.ts\";\nimport { publishedAccess } from \"./published-access.ts\";\nimport { publishedRelativePath, servePublishedFile } from \"./published-files.ts\";\n\n/** Check whether a build's extracted statics are available for serving. */\nasync function staticsReady(projectId: string, buildId: string): Promise<boolean> {\n // New path: manifest.json indicates content-addressed storage; fallback to legacy iframe.html\n if (\n await getStore().storage.exists(posix.join(storybookDir(projectId, buildId), \"manifest.json\"))\n ) {\n return true;\n }\n return await getStore().storage.exists(\n posix.join(storybookDir(projectId, buildId), \"iframe.html\"),\n );\n}\n\n/** Whether a short-link id names one build (a 26-character ULID) rather than a project slug. */\nfunction isBuildId(id: string): boolean {\n return id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);\n}\n\n/**\n * The build a short link opens: the build itself for a ULID, whatever its status; for a project\n * slug the latest approved build on the default branch (never a feature branch or an unreviewed\n * build). Null when nothing matches.\n */\nasync function resolveShortLink(id: string): Promise<{ project: Project; build: Build } | null> {\n const { db } = getStore();\n if (isBuildId(id)) {\n const build = await new BuildModel(db).get(id);\n const project = build ? await new ProjectModel(db).get(build.projectId) : null;\n return build && project ? { project, build } : null;\n }\n const project = await new ProjectModel(db).getBySlug(id);\n const build = project ? await new BuildModel(db).latestApprovedDefault(project) : null;\n return project && build ? { project, build } : null;\n}\n\n/**\n * The answer for a short link that names nothing the caller may open: a missing build or project\n * and a private one look the same, so the link never confirms that a private id exists. Without\n * sign-in every build is open, so a missing id is a plain 404.\n */\nfunction unreachable(c: Context): Response | Promise<Response> {\n return getStore().authEnabled ? c.redirect(\"/auth/login\", 302) : c.notFound();\n}\n\n/** Register the published Storybook resolver and static-asset routes. */\nexport function registerStorybook(app: ShelfRouter): void {\n app.get(\"/_\", (c) => c.redirect(\"/\", 302));\n app.get(\"/_/\", (c) => c.redirect(\"/\", 302));\n\n // Resolver: latest approved build on the default branch (never feature branches).\n app.get(\"/projects/:slug/storybook\", async (c) => {\n const slug = c.req.param(\"slug\");\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const build = await new BuildModel(getStore().db).latestApprovedDefault(project);\n if (!build) {\n notFound(\"No approved build on the default branch yet\");\n }\n if (!(await publishedAccess(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n return c.redirect(`/projects/${slug}/storybook/build/${build.id}/`, 302);\n });\n\n // Short link canonical is /_/:id/ with trailing slash (so relative ./sb-manager/... resolves).\n // 26-char ULID → that exact build, whatever its status (visibility check only).\n // Any other id → project slug → latest approved build on the default branch, else 404\n // (no fallback to feature-branch or unreviewed builds). No fall-through on ULID.\n app.get(\"/_/:id\", async (c) => {\n const id = c.req.param(\"id\");\n const target = await resolveShortLink(id);\n // The same access check as the canonical form, so an id the caller may not open answers like\n // one that does not exist (no private id is confirmed by a redirect).\n if (!target || !(await publishedAccess(target.build, target.project))) return unreachable(c);\n return c.redirect(`/_/${id}/`, 302);\n });\n\n app.get(\"/_/:id/*\", async (c) => {\n const id = c.req.param(\"id\");\n const isUlid26 = isBuildId(id);\n const rest = publishedRelativePath(c.req.path, `/_/${id}/`);\n if (rest === null) return c.notFound();\n const target = await resolveShortLink(id);\n if (!target) return unreachable(c);\n const { project, build } = target;\n const access = await publishedAccess(build, project);\n if (!access) return c.redirect(\"/auth/login\", 302);\n if (rest === \"\" && !(await staticsReady(project.id, build.id))) {\n return c.html(renderStorybookPreparingPage(project, build, project.slug), 200);\n }\n const fallback = isUlid26\n ? `/projects/${project.slug}/builds/${build.id}`\n : `/projects/${project.slug}`;\n const served = await servePublishedFile(c, {\n projectId: project.id,\n buildId: build.id,\n rel: rest === \"\" ? \"index.html\" : rest,\n access,\n cacheControl: shortLinkCache(isUlid26, rest === \"\"),\n });\n if (served) return served;\n return rest === \"\" || rest === \"index.html\" ? c.redirect(fallback, 302) : c.notFound();\n });\n\n // Canonical build route: serves the static Storybook and the landing page.\n app.get(\"/projects/:slug/storybook/build/:buildId/*\", async (c) => {\n const slug = c.req.param(\"slug\");\n const buildId = c.req.param(\"buildId\");\n // Hono's bare `/*` wildcard is not exposed through `param()`, so derive the\n // remainder from the raw request path (un-encoded) and decode it ourselves.\n const rest = publishedRelativePath(c.req.path, `/projects/${slug}/storybook/build/${buildId}/`);\n if (rest === null) {\n return c.notFound();\n }\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== project.id) {\n notFound(\"Build not found\");\n }\n const access = await publishedAccess(build, project);\n if (!access) {\n return c.redirect(\"/auth/login\", 302);\n }\n\n if (rest === \"\") {\n if (!(await staticsReady(project.id, build.id))) {\n return c.html(renderStorybookPreparingPage(project, build, slug), 200);\n }\n const storyId = c.req.query(\"storyId\") ?? c.req.query(\"id\") ?? undefined;\n const viewMode = c.req.query(\"viewMode\") === \"docs\" ? \"docs\" : undefined;\n return c.html(renderStorybookPage(project, build, slug, { storyId, viewMode }), 200);\n }\n\n return (\n (await servePublishedFile(c, {\n projectId: project.id,\n buildId: build.id,\n rel: rest,\n access,\n cacheControl: \"public, max-age=31536000, immutable\",\n })) ?? c.notFound()\n );\n });\n\n // Resolver: latest build bearing a label (`build` is a reserved label key).\n app.get(\"/projects/:slug/storybook/:key/*\", async (c) => {\n const slug = c.req.param(\"slug\");\n const key = c.req.param(\"key\");\n if (key === \"build\") {\n return c.notFound();\n }\n let value: string;\n try {\n value = decodeURIComponent(c.req.path.split(\"/\").slice(5).join(\"/\"));\n } catch {\n return c.notFound();\n }\n if (!value) return c.notFound();\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const buildId = await new LabelModel(getStore().db).latestBuildId(project.id, key, value);\n if (!buildId) {\n notFound(\"No build carries that label\");\n }\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || !(await publishedAccess(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n return c.redirect(`/projects/${slug}/storybook/build/${buildId}/`, 302);\n });\n}\n\n/** Cache policy of the short link: a ULID names one build forever; a slug follows the latest. */\nfunction shortLinkCache(isUlid26: boolean, isRoot: boolean): string {\n if (isUlid26) {\n return \"public, max-age=31536000, immutable\";\n }\n return isRoot ? \"public, max-age=60\" : \"public, max-age=3600\";\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@storyshelf/app",
3
- "version": "0.10.0-next-e021f6b",
3
+ "version": "0.10.0-next-42d3da2",
4
4
  "description": "StoryShelf app: Hono app, API routes, and server-rendered UI over @storyshelf/core.",
5
5
  "keywords": [
6
6
  "chromatic-alternative",
@@ -61,8 +61,8 @@
61
61
  "dependencies": {
62
62
  "@hono/swagger-ui": "^0.6.1",
63
63
  "@hono/zod-openapi": "^1.1.5",
64
- "@storyshelf/core": "0.10.0-next-e021f6b",
65
- "@storyshelf/observability": "0.10.0-next-e021f6b",
64
+ "@storyshelf/core": "0.10.0-next-42d3da2",
65
+ "@storyshelf/observability": "0.10.0-next-42d3da2",
66
66
  "hono": "^4.11.0",
67
67
  "zod": "^4.6.7"
68
68
  },
@@ -72,9 +72,9 @@
72
72
  "@opentelemetry/core": "^2.11.0",
73
73
  "@opentelemetry/sdk-trace-base": "^2.11.0",
74
74
  "@storybook/html-vite": "10.6.5",
75
- "@storyshelf/auth": "0.10.0-next-e021f6b",
76
- "@storyshelf/db-sqlite": "0.10.0-next-e021f6b",
77
- "@storyshelf/design-figma": "0.10.0-next-e021f6b",
75
+ "@storyshelf/auth": "0.10.0-next-42d3da2",
76
+ "@storyshelf/db-sqlite": "0.10.0-next-42d3da2",
77
+ "@storyshelf/design-figma": "0.10.0-next-42d3da2",
78
78
  "@types/node": "^26.0.0",
79
79
  "@vitest/coverage-v8": "^4.0.0",
80
80
  "adm-zip": "0.6.1",