From 52f483487ad0bb45784526fe5479ce69cb77143a Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:21:39 +0200 Subject: [PATCH] feat: status page markdown content (#2291) * feat: status-page markdown content * fix: whitelabel * feat: make it shine * wip: status-page markdown * wip: * wip: * fix: build * fix: review * chore: add base url * chore: more ai content * chore: rework frontmatter * fix: review * refactor: llm.txt route * fix: order * fix: review * wip: * wip: * fix: missing urls * wip: * fix: * chore: machine-readable links * fix: test * chore: review * fix: review * fix: review * fix: review * refactor: remvoe agent from * chore: blog post * wip: * fix: typo * fix: review * ci: apply automated fixes * wip: --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- apps/status-page/package.json | 1 + .../(public)/events/(list)/layout.tsx | 16 + .../events/(view)/maintenance/[id]/layout.tsx | 16 + .../events/(view)/report/[id]/layout.tsx | 16 + .../[locale]/(public)/feed/[type]/route.ts | 10 +- .../[locale]/(public)/feed/json/route.ts | 10 +- .../[locale]/(public)/llms.txt/route.ts | 122 +++ .../(public)/monitors/[id]/layout.tsx | 17 + .../[locale]/(public)/monitors/layout.tsx | 16 + .../src/app/(status-page)/[domain]/layout.tsx | 12 +- .../src/app/api/markdown/[[...path]]/route.ts | 213 ++++++ .../src/app/api/status/[[...path]]/route.ts | 127 ++++ apps/status-page/src/app/robots.ts | 39 +- apps/status-page/src/app/sitemap.ts | 75 +- .../components/status-page/status-updates.tsx | 24 +- .../src/content/markdown/generators.test.ts | 699 ++++++++++++++++++ .../src/content/markdown/generators.ts | 681 +++++++++++++++++ .../src/content/markdown/helpers.ts | 467 ++++++++++++ .../status-page/src/content/markdown/index.ts | 18 + .../src/content/markdown/match-route.test.ts | 93 +++ .../src/content/markdown/match-route.ts | 61 ++ .../src/content/status-json.test.ts | 154 ++++ apps/status-page/src/content/status-json.ts | 111 +++ .../src/content/status-vocab.test.ts | 92 +++ apps/status-page/src/content/status-vocab.ts | 90 +++ .../src/lib/alternates-metadata.ts | 24 + apps/status-page/src/lib/alternates.test.ts | 64 ++ apps/status-page/src/lib/alternates.ts | 34 + apps/status-page/src/lib/domain.ts | 5 + .../src/lib/http/client-ip.test.ts | 39 + apps/status-page/src/lib/http/client-ip.ts | 15 + apps/status-page/src/lib/http/etag.test.ts | 50 ++ apps/status-page/src/lib/http/etag.ts | 15 + .../src/lib/http/markdown-response.test.ts | 108 +++ .../src/lib/http/markdown-response.ts | 39 + .../src/lib/proxy/access-predicates.ts | 62 ++ .../src/lib/proxy/detect-markdown.test.ts | 68 ++ .../src/lib/proxy/detect-markdown.ts | 42 ++ .../lib/proxy/evaluate-markdown-gate.test.ts | 120 +++ .../src/lib/proxy/evaluate-markdown-gate.ts | 48 ++ .../lib/proxy/markdown-cache-control.test.ts | 32 + .../src/lib/proxy/markdown-cache-control.ts | 14 + .../lib/proxy/resolve-email-domain-action.ts | 7 +- .../status-page/src/lib/proxy/resolve-gate.ts | 54 ++ .../proxy/resolve-ip-restriction-action.ts | 4 +- .../src/lib/proxy/resolve-password-action.ts | 13 +- apps/status-page/src/proxy.ts | 42 +- .../status-page-markdown-for-agents.png | Bin 0 -> 61922 bytes .../blog/status-page-markdown-for-agents.mdx | 119 +++ packages/api/src/router/statusPage.ts | 108 ++- packages/db/src/schema/shared.ts | 46 +- pnpm-lock.yaml | 33 +- 52 files changed, 4296 insertions(+), 89 deletions(-) create mode 100644 apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(list)/layout.tsx create mode 100644 apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/llms.txt/route.ts create mode 100644 apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/layout.tsx create mode 100644 apps/status-page/src/app/api/markdown/[[...path]]/route.ts create mode 100644 apps/status-page/src/app/api/status/[[...path]]/route.ts create mode 100644 apps/status-page/src/content/markdown/generators.test.ts create mode 100644 apps/status-page/src/content/markdown/generators.ts create mode 100644 apps/status-page/src/content/markdown/helpers.ts create mode 100644 apps/status-page/src/content/markdown/index.ts create mode 100644 apps/status-page/src/content/markdown/match-route.test.ts create mode 100644 apps/status-page/src/content/markdown/match-route.ts create mode 100644 apps/status-page/src/content/status-json.test.ts create mode 100644 apps/status-page/src/content/status-json.ts create mode 100644 apps/status-page/src/content/status-vocab.test.ts create mode 100644 apps/status-page/src/content/status-vocab.ts create mode 100644 apps/status-page/src/lib/alternates-metadata.ts create mode 100644 apps/status-page/src/lib/alternates.test.ts create mode 100644 apps/status-page/src/lib/alternates.ts create mode 100644 apps/status-page/src/lib/http/client-ip.test.ts create mode 100644 apps/status-page/src/lib/http/client-ip.ts create mode 100644 apps/status-page/src/lib/http/etag.test.ts create mode 100644 apps/status-page/src/lib/http/etag.ts create mode 100644 apps/status-page/src/lib/http/markdown-response.test.ts create mode 100644 apps/status-page/src/lib/http/markdown-response.ts create mode 100644 apps/status-page/src/lib/proxy/access-predicates.ts create mode 100644 apps/status-page/src/lib/proxy/detect-markdown.test.ts create mode 100644 apps/status-page/src/lib/proxy/detect-markdown.ts create mode 100644 apps/status-page/src/lib/proxy/evaluate-markdown-gate.test.ts create mode 100644 apps/status-page/src/lib/proxy/evaluate-markdown-gate.ts create mode 100644 apps/status-page/src/lib/proxy/markdown-cache-control.test.ts create mode 100644 apps/status-page/src/lib/proxy/markdown-cache-control.ts create mode 100644 apps/status-page/src/lib/proxy/resolve-gate.ts create mode 100644 apps/web/public/assets/posts/status-page-markdown-for-agents/status-page-markdown-for-agents.png create mode 100644 apps/web/src/content/pages/blog/status-page-markdown-for-agents.mdx diff --git a/apps/status-page/package.json b/apps/status-page/package.json index deb6110b..cd336827 100644 --- a/apps/status-page/package.json +++ b/apps/status-page/package.json @@ -83,6 +83,7 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "bun-types": "catalog:", "shadcn": "catalog:", "tailwindcss": "catalog:", "tw-animate-css": "catalog:", diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(list)/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(list)/layout.tsx new file mode 100644 index 00000000..32ea5a79 --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(list)/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; + +import { statusPageAlternatesMetadata } from "@/lib/alternates-metadata"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ domain: string }>; +}): Promise { + const { domain } = await params; + return statusPageAlternatesMetadata({ domain, markdownPath: "/events.md" }); +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(view)/maintenance/[id]/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(view)/maintenance/[id]/layout.tsx index 8ebac902..140e18c5 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(view)/maintenance/[id]/layout.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(view)/maintenance/[id]/layout.tsx @@ -1,7 +1,23 @@ +import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { statusPageAlternatesMetadata } from "@/lib/alternates-metadata"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string; domain: string }>; +}): Promise { + const { id, domain } = await params; + const numericId = Number(id); + if (Number.isNaN(numericId)) return {}; + return statusPageAlternatesMetadata({ + domain, + markdownPath: `/events/maintenance/${numericId}.md`, + }); +} + export default async function Layout({ children, params, diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(view)/report/[id]/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(view)/report/[id]/layout.tsx index 5711e11c..124f989e 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(view)/report/[id]/layout.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/events/(view)/report/[id]/layout.tsx @@ -1,7 +1,23 @@ +import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { statusPageAlternatesMetadata } from "@/lib/alternates-metadata"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string; domain: string }>; +}): Promise { + const { id, domain } = await params; + const numericId = Number(id); + if (Number.isNaN(numericId)) return {}; + return statusPageAlternatesMetadata({ + domain, + markdownPath: `/events/report/${numericId}.md`, + }); +} + export default async function Layout({ children, params, diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts index ad5ee407..a3fbb463 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts @@ -32,9 +32,13 @@ export async function GET( if (_page.accessType === "password") { const url = new URL(_request.url); - const password = url.searchParams.get("pw"); - console.log({ url, _page, password }); - if (password !== _page.password) return unauthorized(); + const authorized = await queryClient.fetchQuery( + trpc.statusPage.isPasswordAuthorized.queryOptions({ + slug: _page.slug, + queryPassword: url.searchParams.get("pw"), + }), + ); + if (!authorized) return unauthorized(); } if (_page.accessType === "email-domain") { diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts index 3c3a851b..885246ea 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts @@ -21,9 +21,13 @@ export async function GET( if (_page.accessType === "password") { const url = new URL(_request.url); - const password = url.searchParams.get("pw"); - console.log({ url, _page, password }); - if (password !== _page.password) return unauthorized(); + const authorized = await queryClient.fetchQuery( + trpc.statusPage.isPasswordAuthorized.queryOptions({ + slug: _page.slug, + queryPassword: url.searchParams.get("pw"), + }), + ); + if (!authorized) return unauthorized(); } if (_page.accessType === "email-domain") { diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/llms.txt/route.ts b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/llms.txt/route.ts new file mode 100644 index 00000000..060d94fc --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/llms.txt/route.ts @@ -0,0 +1,122 @@ +import { db, sql } from "@openstatus/db"; +import { page } from "@openstatus/db/src/schema"; +import { NextResponse } from "next/server"; + +import { escapeLinkLabel, type OverviewPage } from "@/content/markdown"; +import { getBaseUrl } from "@/lib/base-url"; +import { getQueryClient, trpc } from "@/lib/trpc/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function notFound() { + return new NextResponse("Not Found", { + status: 404, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} + +function render({ + data, + baseUrl, +}: { + data: OverviewPage; + baseUrl: string; +}): string { + const now = Date.now(); + const sections = [ + `# ${data.title}`, + "", + `> ${data.description || "Operational status."} Agent-readable markdown, JSON, and feeds are linked below.`, + "", + "## Status", + `- [Overview](${baseUrl}/.md): overall status, components, active incidents`, + `- [Monitors](${baseUrl}/monitors.md): per-service status`, + `- [Events](${baseUrl}/events.md): incident & maintenance history`, + ]; + + if (data.monitors.length > 0) { + sections.push("", "## Monitors"); + for (const monitor of data.monitors) { + sections.push( + `- [${escapeLinkLabel(monitor.name)}](${baseUrl}/monitors/${monitor.id}.md)`, + ); + } + } + + const activeReports = data.statusReports.filter( + (r) => r.status !== "resolved", + ); + const activeMaintenance = data.maintenances.filter( + (m) => m.to && new Date(m.to).getTime() >= now, + ); + if (activeReports.length > 0 || activeMaintenance.length > 0) { + sections.push("", "## Active incidents & maintenance"); + for (const report of activeReports) { + sections.push( + `- [${escapeLinkLabel(report.title)}](${baseUrl}/events/report/${report.id}.md)`, + ); + } + for (const maintenance of activeMaintenance) { + sections.push( + `- [${escapeLinkLabel(maintenance.title)}](${baseUrl}/events/maintenance/${maintenance.id}.md)`, + ); + } + } + + sections.push( + "", + "## Data", + `- [summary.json](${baseUrl}/api/status/summary.json): machine summary, Statuspage-compatible`, + `- [current.json](${baseUrl}/api/status/current.json): single overall indicator — cheapest "is it up?"`, + `- [Unresolved incidents](${baseUrl}/api/status/incidents.json)`, + `- [RSS](${baseUrl}/feed/rss) · [Atom](${baseUrl}/feed/atom) · [JSON feed](${baseUrl}/feed/json)`, + "", + "## Notes", + "- Any page URL also serves markdown via a `.md` suffix or `Accept: text/markdown`.", + "", + ); + + return sections.join("\n"); +} + +export async function GET( + _request: Request, + props: { params: Promise<{ domain: string }> }, +) { + const { domain } = await props.params; + const prefix = domain.toLowerCase(); + + const row = await db + .select({ + slug: page.slug, + accessType: page.accessType, + }) + .from(page) + .where( + sql`lower(${page.slug}) = ${prefix} OR lower(${page.customDomain}) = ${prefix}`, + ) + .get(); + if (!row) return notFound(); + // Only public pages get a discovery doc — don't leak title/description of + // password/email/IP-gated pages. Gate on the cheap row before the full fetch. + if (row.accessType !== "public") return notFound(); + + const data = await getQueryClient().fetchQuery( + trpc.statusPage.get.queryOptions({ slug: row.slug }), + ); + if (!data) return notFound(); + + const baseUrl = getBaseUrl({ + slug: data.slug, + customDomain: data.customDomain ?? undefined, + }); + + return new NextResponse(render({ data, baseUrl }), { + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=300, stale-while-revalidate=600", + }, + }); +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/layout.tsx index a9bf0973..5a45b960 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/layout.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/layout.tsx @@ -1,5 +1,22 @@ +import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { statusPageAlternatesMetadata } from "@/lib/alternates-metadata"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string; domain: string }>; +}): Promise { + const { id, domain } = await params; + const numericId = Number(id); + if (Number.isNaN(numericId)) return {}; + return statusPageAlternatesMetadata({ + domain, + markdownPath: `/monitors/${numericId}.md`, + }); +} + export default async function Layout({ children, params, diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/layout.tsx new file mode 100644 index 00000000..61d96811 --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; + +import { statusPageAlternatesMetadata } from "@/lib/alternates-metadata"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ domain: string }>; +}): Promise { + const { domain } = await params; + return statusPageAlternatesMetadata({ domain, markdownPath: "/monitors.md" }); +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/layout.tsx index dc3edf0d..79acbe4c 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/layout.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/layout.tsx @@ -12,7 +12,8 @@ import { } from "@/components/status-page/floating-button"; import { FloatingTheme } from "@/components/status-page/floating-theme"; import { ThemeProvider } from "@/components/themes/theme-provider"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { statusPageAlternates } from "@/lib/alternates"; +import { getQueryClient, HydrateClient, trpc } from "@/lib/trpc/server"; // Canonical schema — guarantees concrete enum output (never null/undefined). @@ -110,11 +111,10 @@ export async function generateMetadata({ icons: page?.icon?.toLowerCase().endsWith(".svg") ? { icon: { url: page.icon, type: "image/svg+xml" } } : page?.icon, - alternates: { - canonical: page?.customDomain - ? `https://${page.customDomain}` - : `https://${page.slug}.openstatus.dev`, - }, + alternates: statusPageAlternates({ + slug: page.slug, + customDomain: page.customDomain, + }), twitter: { ...twitterMetadata, images: [`/api/og/page?slug=${page?.slug}`], diff --git a/apps/status-page/src/app/api/markdown/[[...path]]/route.ts b/apps/status-page/src/app/api/markdown/[[...path]]/route.ts new file mode 100644 index 00000000..43a151d4 --- /dev/null +++ b/apps/status-page/src/app/api/markdown/[[...path]]/route.ts @@ -0,0 +1,213 @@ +import { cookies, headers } from "next/headers"; +import { type NextRequest, NextResponse } from "next/server"; + +import { + generateEventsList, + generateMaintenance, + generateMonitor, + generateMonitorsList, + generateOverview, + generateReport, + matchMarkdownRoute, + parseMarkdownPath, +} from "@/content/markdown"; +import { getBaseUrl } from "@/lib/base-url"; +import { resolveClientIp } from "@/lib/http/client-ip"; +import { resolveMarkdownResponse } from "@/lib/http/markdown-response"; +import { type GatePage, resolveGate } from "@/lib/proxy/resolve-gate"; +import { getQueryClient, trpc } from "@/lib/trpc/server"; + +// Match the feed route: getQueryClient/httpBatchLink needs Node, not Edge. +export const runtime = "nodejs"; + +const PLAIN = "text/plain; charset=utf-8"; + +function textResponse(body: string, status: number) { + return new NextResponse(body, { + status, + headers: { "Content-Type": PLAIN, "Cache-Control": "no-store" }, + }); +} + +function markdownResponse( + request: NextRequest, + body: string, + source: string | null, + whiteLabel: boolean, + accessType: string | null | undefined, +) { + const { + status, + body: finalBody, + headers, + } = resolveMarkdownResponse(request, { + body, + source, + whiteLabel, + accessType, + }); + return new NextResponse(finalBody, { status, headers }); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> }, +) { + try { + const { path = [] } = await params; + const parsed = parseMarkdownPath(path); + if (!parsed) return textResponse("Not Found", 404); + const { slug, rest } = parsed; + + const target = matchMarkdownRoute(rest); + if (!target) return textResponse("Not Found", 404); + + const source = request.headers.get("x-md-source"); + const queryClient = getQueryClient(); + const url = new URL(request.url); + const cookieStore = await cookies(); + const headerStore = await headers(); + const clientIp = resolveClientIp(headerStore); + + // Returns a response when the gate denies, null when it passes. + async function denyResponse(gatePage: GatePage) { + const gate = await resolveGate({ + page: gatePage, + queryClient, + url, + cookieStore, + clientIp, + }); + return gate.ok ? null : textResponse(gate.body, gate.status); + } + + switch (target.kind) { + // List pages render from `get`, which also carries the access fields — so + // we gate off the same payload instead of a second full-graph getLight. + case "overview": + case "monitors": + case "events": { + const page = await queryClient.fetchQuery( + trpc.statusPage.get.queryOptions({ slug }), + ); + if (!page) return textResponse("Not Found", 404); + const denied = await denyResponse(page); + if (denied) return denied; + + const baseUrl = getBaseUrl({ + slug: page.slug, + customDomain: page.customDomain, + }); + + if (target.kind === "monitors") { + return markdownResponse( + request, + generateMonitorsList(page, baseUrl), + source, + page.whiteLabel, + page.accessType, + ); + } + if (target.kind === "events") { + return markdownResponse( + request, + generateEventsList(page, baseUrl), + source, + page.whiteLabel, + page.accessType, + ); + } + // Mirror what the live page renders: bar/card type and the uptime + // toggle come from the page configuration, not hardcoded defaults. + const cardType = page.configuration?.value ?? "requests"; + const barType = page.configuration?.type ?? "absolute"; + const showUptime = page.configuration?.uptime ?? true; + // Per-day uptime series (Tinybird) — only after the gate passes. + const uptime = + (await queryClient.fetchQuery( + trpc.statusPage.getUptime.queryOptions({ + slug, + pageComponentIds: page.pageComponents.map((c) => c.id.toString()), + cardType, + barType, + }), + )) ?? []; + return markdownResponse( + request, + generateOverview(page, uptime, baseUrl, showUptime), + source, + page.whiteLabel, + page.accessType, + ); + } + // Detail pages: the detail queries don't carry access fields, so gate via + // getGate — a narrow access-only query — before fetching the (heavier) + // detail payload. + case "monitor": + case "report": + case "maintenance": { + const light = await queryClient.fetchQuery( + trpc.statusPage.getGate.queryOptions({ slug }), + ); + if (!light) return textResponse("Not Found", 404); + const denied = await denyResponse(light); + if (denied) return denied; + + const baseUrl = getBaseUrl({ + slug: light.slug, + customDomain: light.customDomain, + }); + + if (target.kind === "monitor") { + const monitor = await queryClient.fetchQuery( + trpc.statusPage.getMonitor.queryOptions({ slug, id: target.id }), + ); + if (!monitor) return textResponse("Not Found", 404); + return markdownResponse( + request, + generateMonitor(monitor, baseUrl, { + homepageUrl: light.homepageUrl, + contactUrl: light.contactUrl, + }), + source, + light.whiteLabel, + light.accessType, + ); + } + if (target.kind === "report") { + const report = await queryClient.fetchQuery( + trpc.statusPage.getReport.queryOptions({ slug, id: target.id }), + ); + if (!report) return textResponse("Not Found", 404); + return markdownResponse( + request, + generateReport(report, baseUrl, { + homepageUrl: light.homepageUrl, + contactUrl: light.contactUrl, + }), + source, + light.whiteLabel, + light.accessType, + ); + } + const maintenance = await queryClient.fetchQuery( + trpc.statusPage.getMaintenance.queryOptions({ slug, id: target.id }), + ); + if (!maintenance) return textResponse("Not Found", 404); + return markdownResponse( + request, + generateMaintenance(maintenance, baseUrl, { + homepageUrl: light.homepageUrl, + contactUrl: light.contactUrl, + }), + source, + light.whiteLabel, + light.accessType, + ); + } + } + } catch (error) { + console.error("Error serving status-page markdown:", error); + return textResponse("Internal Server Error", 500); + } +} diff --git a/apps/status-page/src/app/api/status/[[...path]]/route.ts b/apps/status-page/src/app/api/status/[[...path]]/route.ts new file mode 100644 index 00000000..37255f80 --- /dev/null +++ b/apps/status-page/src/app/api/status/[[...path]]/route.ts @@ -0,0 +1,127 @@ +import { db, sql } from "@openstatus/db"; +import { page } from "@openstatus/db/src/schema"; +import { cookies, headers } from "next/headers"; +import { type NextRequest, NextResponse } from "next/server"; + +import { + matchEndpoint, + toStatus, + toSummary, + toUnresolvedIncidents, +} from "@/content/status-json"; +import { getBaseUrl } from "@/lib/base-url"; +import { stripHostPort } from "@/lib/domain"; +import { resolveClientIp } from "@/lib/http/client-ip"; +import { computeETag, isNotModified } from "@/lib/http/etag"; +import { resolveGate } from "@/lib/proxy/resolve-gate"; +import { resolveRoute } from "@/lib/resolve-route"; +import { getQueryClient, trpc } from "@/lib/trpc/server"; + +// trpc httpBatchLink needs Node, matching the markdown route. +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function json(body: unknown, status: number, extraHeaders?: HeadersInit) { + return new NextResponse(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...extraHeaders, + }, + }); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> }, +) { + try { + const { path = [] } = await params; + const matched = matchEndpoint(path); + if (!matched) return json({ error: "Not Found" }, 404); + const { endpoint, slug: pathSlug } = matched; + + const url = new URL(request.url); + // Strip the port: custom-domain lookups exact-match the port-less + // page.customDomain (parity with sitemap.ts / robots.ts). + const host = stripHostPort(request.headers.get("x-forwarded-host")); + // Host-keyed deploys (subdomain/custom domain) resolve from the host; a + // path-based deploy carries the slug in the URL (`{slug}/summary.json`), + // so feed it through resolveRoute as the pathname prefix. + const route = resolveRoute({ + host, + urlHost: host ?? url.host, + pathname: pathSlug ? `/${pathSlug}` : "/", + }); + if (!route) return json({ error: "Not Found" }, 404); + + const row = await db + .select({ slug: page.slug }) + .from(page) + .where( + sql`lower(${page.slug}) = ${route.prefix} OR lower(${page.customDomain}) = ${route.prefix}`, + ) + .get(); + if (!row) return json({ error: "Not Found" }, 404); + + const queryClient = getQueryClient(); + const data = await queryClient.fetchQuery( + trpc.statusPage.get.queryOptions({ slug: row.slug }), + ); + if (!data) return json({ error: "Not Found" }, 404); + + const headerStore = await headers(); + const cookieStore = await cookies(); + const clientIp = resolveClientIp(headerStore); + const gate = await resolveGate({ + page: data, + queryClient, + url, + cookieStore, + clientIp, + }); + if (!gate.ok) return json({ error: gate.body }, gate.status); + + const baseUrl = getBaseUrl({ + slug: data.slug, + customDomain: data.customDomain ?? undefined, + }); + + const payload = + endpoint === "status" + ? toStatus(data, baseUrl) + : endpoint === "incidents" + ? toUnresolvedIncidents(data, baseUrl) + : toSummary(data, baseUrl); + + const body = JSON.stringify(payload); + const etag = computeETag(body); + const cacheControl = + data.accessType === "public" + ? "public, max-age=30, stale-while-revalidate=60" + : "private, no-store"; + + if (isNotModified(request, etag)) { + return new NextResponse(null, { + status: 304, + headers: { + ETag: etag, + "Cache-Control": cacheControl, + "Content-Type": "application/json; charset=utf-8", + }, + }); + } + + return new NextResponse(body, { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": cacheControl, + ETag: etag, + }, + }); + } catch (error) { + console.error("Error serving status-page status JSON:", error); + return json({ error: "Internal Server Error" }, 500); + } +} diff --git a/apps/status-page/src/app/robots.ts b/apps/status-page/src/app/robots.ts index c7828a99..4df99d6d 100644 --- a/apps/status-page/src/app/robots.ts +++ b/apps/status-page/src/app/robots.ts @@ -1,6 +1,42 @@ +import { db, sql } from "@openstatus/db"; +import { page } from "@openstatus/db/src/schema"; import type { MetadataRoute } from "next"; +import { headers } from "next/headers"; + +import { getBaseUrl } from "@/lib/base-url"; +import { stripHostPort } from "@/lib/domain"; +import { resolveRoute } from "@/lib/resolve-route"; + +// trpc/db lookup needs Node, matching the sitemap and other content routes. +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export default async function robots(): Promise { + const headerStore = await headers(); + const host = stripHostPort( + headerStore.get("x-forwarded-host") ?? headerStore.get("host"), + ); + + // Only advertise a sitemap when the host resolves to a real page; the host + // header is attacker-controlled, so resolve the page and build the sitemap URL + // from its canonical base — never reflect the raw host (validating the slug + // prefix alone lets a forged host like `acme.openstatus.dev.evil.com` through). + const route = host + ? resolveRoute({ host, urlHost: host, pathname: "/" }) + : null; + const row = route + ? await db + .select({ slug: page.slug, customDomain: page.customDomain }) + .from(page) + .where( + sql`lower(${page.slug}) = ${route.prefix} OR lower(${page.customDomain}) = ${route.prefix}`, + ) + .get() + : undefined; + const sitemap = row + ? `${getBaseUrl({ slug: row.slug, customDomain: row.customDomain ?? undefined })}/sitemap.xml` + : undefined; -export default function robots(): MetadataRoute.Robots { return { rules: [ { @@ -8,5 +44,6 @@ export default function robots(): MetadataRoute.Robots { allow: "/", }, ], + ...(sitemap ? { sitemap } : {}), }; } diff --git a/apps/status-page/src/app/sitemap.ts b/apps/status-page/src/app/sitemap.ts index 57e96755..b6e31bef 100644 --- a/apps/status-page/src/app/sitemap.ts +++ b/apps/status-page/src/app/sitemap.ts @@ -1,24 +1,77 @@ -import { db, eq } from "@openstatus/db"; +import { db, sql } from "@openstatus/db"; import { page } from "@openstatus/db/src/schema"; import type { MetadataRoute } from "next"; +import { headers } from "next/headers"; +import { getBaseUrl } from "@/lib/base-url"; +import { stripHostPort } from "@/lib/domain"; +import { resolveRoute } from "@/lib/resolve-route"; +import { getQueryClient, trpc } from "@/lib/trpc/server"; + +// One sitemap per host: a custom domain must never enumerate other tenants' +// pages. trpc httpBatchLink needs Node, matching the other content routes. +export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export default async function sitemap(): Promise { - const pages = await db + const headerStore = await headers(); + const host = stripHostPort( + headerStore.get("x-forwarded-host") ?? headerStore.get("host"), + ); + const route = resolveRoute({ host, urlHost: host ?? "", pathname: "/" }); + if (!route) return []; + + const row = await db .select({ slug: page.slug, customDomain: page.customDomain, - updatedAt: page.updatedAt, + allowIndex: page.allowIndex, + accessType: page.accessType, }) .from(page) - .where(eq(page.allowIndex, true)) - .all(); - - return pages.map((p) => { - const url = p.customDomain - ? `https://${p.customDomain}` - : `https://${p.slug}.openstatus.dev`; - return { url, lastModified: p.updatedAt ?? undefined }; + .where( + sql`lower(${page.slug}) = ${route.prefix} OR lower(${page.customDomain}) = ${route.prefix}`, + ) + .get(); + // Only public, indexable pages belong in a sitemap — gated content must not + // be advertised to crawlers. + if (!row || !row.allowIndex || row.accessType !== "public") return []; + + const data = await getQueryClient().fetchQuery( + trpc.statusPage.get.queryOptions({ slug: row.slug }), + ); + if (!data) return []; + + const baseUrl = getBaseUrl({ + slug: data.slug, + customDomain: data.customDomain ?? undefined, }); + const lastModified = data.updatedAt ?? undefined; + + const entries: MetadataRoute.Sitemap = [ + { url: baseUrl, lastModified }, + { url: `${baseUrl}/events`, lastModified }, + ]; + + if (data.monitors.length > 0) { + entries.push({ url: `${baseUrl}/monitors`, lastModified }); + } + + for (const monitor of data.monitors) { + entries.push({ url: `${baseUrl}/monitors/${monitor.id}`, lastModified }); + } + for (const report of data.statusReports) { + entries.push({ + url: `${baseUrl}/events/report/${report.id}`, + lastModified, + }); + } + for (const maintenance of data.maintenances) { + entries.push({ + url: `${baseUrl}/events/maintenance/${maintenance.id}`, + lastModified, + }); + } + + return entries; } diff --git a/apps/status-page/src/components/status-page/status-updates.tsx b/apps/status-page/src/components/status-page/status-updates.tsx index a344e1ab..f1a38198 100644 --- a/apps/status-page/src/components/status-page/status-updates.tsx +++ b/apps/status-page/src/components/status-page/status-updates.tsx @@ -19,9 +19,11 @@ import { TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; +import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { cn } from "@openstatus/ui/lib/utils"; import { Inbox } from "lucide-react"; import { useExtracted } from "next-intl"; +import { useParams } from "next/navigation"; import { useState } from "react"; import { @@ -29,19 +31,26 @@ import { type FormValues, } from "@/components/forms/form-subscribe-email"; import { getBaseUrl } from "@/lib/base-url"; +import { createProtectedCookieKey } from "@/lib/protected"; export type StatusUpdateType = "email" | "rss" | "ssh" | "json" | "slack"; type Page = NonNullable; -function getUpdateLink(type: "rss" | "json" | "atom", page?: Page | null) { +function getUpdateLink( + type: "rss" | "json" | "atom", + page?: Page | null, + password?: string, +) { const baseUrl = getBaseUrl({ slug: page?.slug, customDomain: page?.customDomain, }); return `${baseUrl}/feed/${type}${ - page?.accessType === "password" ? `?pw=${page?.password}` : "" + page?.accessType === "password" && password + ? `?pw=${encodeURIComponent(password)}` + : "" }`; } @@ -60,12 +69,17 @@ export function StatusUpdates({ }: StatusUpdatesProps) { const t = useExtracted(); const [success, setSuccess] = useState(false); + const params = useParams(); + const domain = typeof params.domain === "string" ? params.domain : ""; + // The password lives in the cookie this browser set at login — not in the + // page payload, which intentionally omits it. + const [password] = useCookieState(createProtectedCookieKey(domain)); if (types.length === 0) return null; - const rssUrl = getUpdateLink("rss", page); - const atomUrl = getUpdateLink("atom", page); - const jsonUrl = getUpdateLink("json", page); + const rssUrl = getUpdateLink("rss", page, password); + const atomUrl = getUpdateLink("atom", page, password); + const jsonUrl = getUpdateLink("json", page, password); const sshCommand = `ssh ${page?.slug}@ssh.openstatus.dev`; return ( diff --git a/apps/status-page/src/content/markdown/generators.test.ts b/apps/status-page/src/content/markdown/generators.test.ts new file mode 100644 index 00000000..05c074d5 --- /dev/null +++ b/apps/status-page/src/content/markdown/generators.test.ts @@ -0,0 +1,699 @@ +import { describe, expect, test } from "bun:test"; + +import { + escapeCell, + escapeLinkLabel, + eventLog, + frontmatter, + navLine, + statusLabel, + withPoweredBy, +} from "./helpers"; +import { + generateEventsList, + generateMaintenance, + generateMonitor, + generateMonitorsList, + generateOverview, + generateReport, + type MaintenanceDetail, + type MonitorDetail, + type OverviewPage, + type ReportDetail, + type UptimeComponent, +} from "./index"; + +const BASE = "https://acme.openstatus.dev"; + +// Fixtures are partial — generators only read the fields asserted below. +const overview = { + title: "Acme Status", + description: "Acme service status", + status: "degraded", + statusReports: [ + { + id: 1, + title: "API latency", + status: "investigating", + createdAt: new Date("2026-06-18T10:00:00.000Z"), + statusReportsToPageComponents: [ + { pageComponentId: 100, pageComponent: { name: "API" } }, + ], + statusReportUpdates: [ + { + status: "investigating", + message: "Looking into it.", + date: new Date("2026-06-18T10:00:00.000Z"), + statusReportUpdateToPageComponents: [ + { pageComponentId: 100, impact: "major_outage" }, + ], + }, + ], + }, + { + id: 2, + title: "Old outage", + status: "resolved", + createdAt: new Date("2026-05-01T10:00:00.000Z"), + statusReportsToPageComponents: [], + statusReportUpdates: [ + { + status: "resolved", + message: "Fixed.", + date: new Date("2026-05-02T10:00:00.000Z"), + }, + { + status: "investigating", + message: "Down.", + date: new Date("2026-05-01T10:00:00.000Z"), + }, + ], + }, + ], + maintenances: [ + { + id: 5, + title: "DB upgrade", + message: "Brief downtime.", + from: new Date("2026-06-20T00:00:00.000Z"), + to: new Date("2099-06-20T01:00:00.000Z"), + maintenancesToPageComponents: [{ pageComponent: { name: "Database" } }], + }, + ], + monitors: [{ id: 9, name: "API monitor", status: "success" }], + trackers: [], + homepageUrl: "https://acme.com", + contactUrl: "mailto:status@acme.com", +} as unknown as OverviewPage; + +const components = [ + { + name: "laser pointer tracker", + pageComponentId: 100, + uptime: "97.8%", + data: [ + { bar: [{ status: "success", height: 100 }] }, + { bar: [{ status: "error", height: 100 }] }, + { bar: [{ status: "info", height: 100 }] }, + ], + }, +] as unknown as UptimeComponent[]; + +describe("generateOverview", () => { + const md = generateOverview(overview, components, BASE); + + test("frontmatter + no generated-at", () => { + expect(md).toContain('title: "Acme Status"'); + expect(md).toContain(`base_url: "${BASE}"`); + expect(md).toContain(`canonical: "${BASE}"`); + expect(md).not.toContain("generated-at"); + }); + + test("frontmatter carries page homepage + contact urls", () => { + expect(md).toContain('homepage_url: "https://acme.com"'); + expect(md).toContain('contact_url: "mailto:status@acme.com"'); + }); + + test("peer nav links to monitors + events docs", () => { + expect(md).toContain( + `**Status** · [Monitors](/monitors.md) · [Events](/events.md)`, + ); + }); + + test("overall status line with glyph + timestamp", () => { + expect(md).toContain("`~` **Degraded** · "); + expect(md).toMatch(/\(GMT\+0\)/); + }); + + test("active incident with affected components (resolved excluded)", () => { + expect(md).toContain("**API latency**"); + expect(md).toContain("affects: API"); + expect(md).toContain("Looking into it."); + expect(md).not.toContain("Old outage"); + }); + + test("active & upcoming maintenance with affected components", () => { + expect(md).toContain("## Active & upcoming maintenance"); + expect(md).toContain("= **DB upgrade**"); + expect(md).toContain("affects: Database"); + }); + + test("per-component ascii uptime bar in a code span", () => { + expect(md).toContain("**laser pointer tracker** — 97.8%"); + expect(md).toContain("`3d ago → today`"); + expect(md).toContain("`+x=`"); + }); + + test("per-component event links to affecting reports", () => { + expect(md).toContain(`Events: [API latency](/events/report/1.md)`); + }); + + test("showUptime=false renders current status instead of percentage", () => { + const off = generateOverview(overview, components, BASE, false); + // last fixture day is "info" → Maintenance; percentage must not appear + expect(off).toContain("**laser pointer tracker** — Maintenance"); + expect(off).not.toContain("97.8%"); + }); + + test("legend lists every status in severity order", () => { + expect(md).toContain( + "Legend: `+` Operational · `~` Degraded · `x` Outage · `=` Maintenance · `.` No data", + ); + }); +}); + +describe("generateOverview machine-readable pointer", () => { + test("public page points to json endpoints + llms.txt", () => { + const pub = { + ...overview, + trackers: [], + accessType: "public", + } as unknown as OverviewPage; + expect(generateOverview(pub, components, BASE)).toContain( + "Machine-readable: [current.json](/api/status/current.json) · [summary.json](/api/status/summary.json) · [more](/llms.txt)", + ); + }); + + test("gated page omits the pointer", () => { + const gated = { + ...overview, + trackers: [], + accessType: "password", + } as unknown as OverviewPage; + expect(generateOverview(gated, components, BASE)).not.toContain( + "Machine-readable:", + ); + }); +}); + +describe("generateOverview live frontmatter", () => { + const page = { + title: "Acme", + description: "", + status: "degraded", + updatedAt: new Date("2026-06-18T14:03:00.000Z"), + statusReports: [ + { + id: 1, + title: "x", + status: "investigating", + createdAt: new Date("2026-06-18T10:00:00.000Z"), + statusReportsToPageComponents: [], + statusReportUpdates: [], + }, + ], + maintenances: [], + monitors: [], + trackers: [ + { + type: "component", + component: { name: "API", status: "error" }, + order: 0, + }, + { + type: "component", + component: { name: "Web", status: "success" }, + order: 1, + }, + { + type: "group", + groupName: "g", + components: [{ name: "DB", status: "success" }], + status: "success", + order: 2, + }, + ], + } as unknown as OverviewPage; + const comps = [ + { + name: "API", + pageComponentId: 1, + uptime: "99.0%", + data: [{ bar: [{ status: "success", height: 100 }] }], + }, + ] as unknown as UptimeComponent[]; + + const count = (s: string, sub: string) => s.split(sub).length - 1; + + test("frontmatter carries live machine state", () => { + const md = generateOverview(page, comps, BASE); + expect(md).toContain('status: "degraded"'); + expect(md).toContain("active_reports: 1"); + expect(md).toContain("active_maintenance: 0"); + expect(md).toContain("components_operational: 2"); + expect(md).toContain("components_total: 3"); + expect(md).toContain('worst_component: "API"'); + // fetched_at is the generation time, minute-granular ISO; no misleading + // page-mtime updated_at, no cryptic Statuspage indicator. + expect(md).toMatch(/fetched_at: "\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00\.000Z"/); + expect(md).not.toContain("indicator:"); + expect(md).not.toContain("updated_at:"); + }); + + test("always renders the ascii uptime bar + legend", () => { + const md = generateOverview(page, comps, BASE); + // The `+` bar is a code span (`` `+` ``); `(GMT+0)` has a bare `+`, so match + // the wrapped form to isolate the bar. + expect(count(md, "`+`")).toBeGreaterThan(0); + expect(md).toContain("Legend:"); + }); +}); + +describe("generateOverview component ordering + grouping", () => { + const page = { + title: "Acme", + description: "", + status: "success", + statusReports: [], + maintenances: [], + monitors: [], + trackers: [ + { + type: "component", + component: { id: 1, name: "API", status: "success" }, + order: 0, + }, + { + type: "component", + component: { id: 2, name: "Web", status: "success" }, + order: 1, + }, + { + type: "group", + groupName: "Databases", + components: [ + { id: 3, name: "Primary DB", status: "success" }, + { id: 4, name: "Replica DB", status: "success" }, + ], + status: "success", + order: 2, + }, + ], + } as unknown as OverviewPage; + + // Uptime arrives in a different (DB-arbitrary) order than trackers. + const day = { bar: [{ status: "success", height: 100 }] }; + const comps = [ + { name: "Replica DB", pageComponentId: 4, uptime: "99%", data: [day] }, + { name: "API", pageComponentId: 1, uptime: "100%", data: [day] }, + { name: "Primary DB", pageComponentId: 3, uptime: "98%", data: [day] }, + { name: "Web", pageComponentId: 2, uptime: "100%", data: [day] }, + ] as unknown as UptimeComponent[]; + + const md = generateOverview(page, comps, BASE); + + test("renders components in tracker order, not uptime-array order", () => { + const order = ["API", "Web", "Primary DB", "Replica DB"].map((n) => + md.indexOf(`**${n}**`), + ); + expect(order.every((i) => i >= 0)).toBe(true); + expect(order).toEqual([...order].sort((a, b) => a - b)); + }); + + test("emits a heading for grouped components, not for ungrouped", () => { + expect(md).toContain("### Databases"); + expect(md.indexOf("### Databases")).toBeLessThan( + md.indexOf("**Primary DB**"), + ); + expect(md).not.toContain("### API"); + }); +}); + +describe("generateOverview empty components", () => { + const empty = { + title: "Empty", + description: "", + status: "success", + statusReports: [], + maintenances: [], + monitors: [], + trackers: [], + } as unknown as OverviewPage; + const md = generateOverview(empty, [], BASE); + + test("no components copy + operational", () => { + expect(md).toContain("`+` **Operational**"); + expect(md).toContain("No components."); + }); +}); + +describe("generateMonitorsList", () => { + test("monitor row with status glyph + .md link", () => { + const md = generateMonitorsList(overview, BASE); + expect(md).toContain(`| + API monitor | Operational | /monitors/9.md |`); + }); +}); + +describe("generateEventsList", () => { + const md = generateEventsList(overview, BASE); + + test("report heading is a link, no glyph", () => { + expect(md).toContain(`### [Old outage](/events/report/2.md)`); + expect(md).toContain(`### [API latency](/events/report/1.md)`); + expect(md).not.toContain("### x"); + }); + + test("affects includes per-component impact; bullets have no bold", () => { + expect(md).toContain("affects: API (major outage)"); + expect(md).toContain("- x Investigating —"); + expect(md).toContain("- + Resolved —"); + expect(md).not.toContain("**Investigating**"); + }); + + test("update bullet carries its own per-component impact, no body", () => { + expect(md).toContain("· API (major outage)"); + expect(md).not.toContain("Looking into it."); + }); + + test("greppable event log: fenced, sortable stamp, ref, trailing glyph", () => { + expect(md).toContain("## Event log"); + expect(md).toContain("```text"); + expect(md).toContain("# timestamp"); + expect(md).toMatch( + /2026-06-18 10:00 {2}INVESTIGATING {2}report\/1 +x API latency/, + ); + expect(md).toMatch( + /2026-05-02 10:00 {2}RESOLVED {2,}report\/2 +\+ Old outage/, + ); + // newest update sorts above older ones + expect(md.indexOf("2026-06-18 10:00")).toBeLessThan( + md.indexOf("2026-05-02 10:00"), + ); + }); + + test("maintenance heading is a link, no glyph", () => { + expect(md).toContain("## Maintenance"); + expect(md).toContain(`### [DB upgrade](/events/maintenance/5.md)`); + }); +}); + +describe("generateEventsList open-ended maintenance", () => { + // Regression: `m.to` is nullable; the old `new Date(m.to)` resolved to the + // epoch and produced a phantom 1970 "COMPLETED" row + a ~56-year duration. + const page = { + title: "X", + description: "", + monitors: [], + statusReports: [], + maintenances: [ + { + id: 7, + title: "Rolling upgrade", + from: new Date("2026-06-18T00:00:00.000Z"), + to: null, + maintenancesToPageComponents: [], + }, + ], + } as unknown as OverviewPage; + const md = generateEventsList(page, BASE); + + test("no phantom COMPLETED row, no 1970 timestamp", () => { + expect(md).not.toContain("COMPLETED"); + expect(md).not.toContain("1970"); + }); +}); + +describe("escapeCell", () => { + test("escapes backslash before pipe (CodeQL: incomplete escaping)", () => { + // input: backslash, pipe → both must end up escaped + expect(escapeCell(String.raw`\|`)).toBe(String.raw`\\\|`); + expect(escapeCell("plain")).toBe("plain"); + }); +}); + +describe("withPoweredBy", () => { + test("appends attribution footer when not white-labeled", () => { + const out = withPoweredBy("# Title\n", false); + expect(out).toContain( + "_Powered by [openstatus.dev](https://openstatus.dev)_", + ); + }); + + test("omits footer when white-labeled", () => { + expect(withPoweredBy("# Title\n", true)).toBe("# Title\n"); + }); +}); + +describe("statusLabel mapping", () => { + test("component + report statuses", () => { + expect(statusLabel("success")).toBe("Operational"); + expect(statusLabel("error")).toBe("Outage"); + expect(statusLabel("investigating")).toBe("Investigating"); + expect(statusLabel("resolved")).toBe("Resolved"); + }); +}); + +describe("generateReport", () => { + const report = { + id: 1, + title: "API latency", + status: "monitoring", + createdAt: new Date("2026-06-18T10:00:00.000Z"), + statusReportsToPageComponents: [ + { pageComponentId: 100, pageComponent: { name: "API" } }, + ], + statusReportUpdates: [ + { + status: "resolved", + message: "All good.", + date: new Date("2026-06-18T13:00:00.000Z"), + statusReportUpdateToPageComponents: [ + { pageComponentId: 100, impact: "operational" }, + ], + }, + { + status: "monitoring", + message: "Recovering.", + date: new Date("2026-06-18T12:00:00.000Z"), + statusReportUpdateToPageComponents: [ + { pageComponentId: 100, impact: "degraded_performance" }, + ], + }, + { + status: "investigating", + message: "Started.", + date: new Date("2026-06-18T10:00:00.000Z"), + statusReportUpdateToPageComponents: [ + { pageComponentId: 100, impact: "major_outage" }, + ], + }, + ], + } as unknown as ReportDetail; + const md = generateReport(report, BASE); + + test("breadcrumb roots back to Status › Events", () => { + expect(md).toContain(`[Status](/.md) › [Events](/events.md) › API latency`); + }); + + test("lifecycle heading, affects, timeline", () => { + expect(md).toContain("# API latency"); + expect(md).toContain("affects: API"); + expect(md).toContain("### x Monitoring — Jun 18, 12:00 PM"); + expect(md).toContain("Recovering."); + expect(md).toContain("### x Investigating — Jun 18, 10:00 AM"); + }); + + test("each update lists its own impact, operational shown explicitly", () => { + expect(md).toContain("affects: API (degraded performance)"); + expect(md).toContain("affects: API (major outage)"); + expect(md).toContain("affects: API (operational)"); + }); + + test("frontmatter carries page homepage + contact urls", () => { + const withUrls = generateReport(report, BASE, { + homepageUrl: "https://acme.com", + contactUrl: "mailto:status@acme.com", + }); + expect(withUrls).toContain('homepage_url: "https://acme.com"'); + expect(withUrls).toContain('contact_url: "mailto:status@acme.com"'); + }); +}); + +describe("generateMaintenance", () => { + const maintenance = { + id: 5, + title: "DB upgrade", + message: "Brief downtime.", + from: new Date("2026-06-20T00:00:00.000Z"), + to: new Date("2026-06-20T01:00:00.000Z"), + maintenancesToPageComponents: [{ pageComponent: { name: "Database" } }], + } as unknown as MaintenanceDetail; + const md = generateMaintenance(maintenance, BASE); + + test("heading, window, components", () => { + expect(md).toContain("# DB upgrade"); + expect(md).toContain( + "**Window:** Jun 20, 12:00 AM → Jun 20, 1:00 AM · 1 hour", + ); + expect(md).toContain("**Affected components:** Database"); + expect(md).toContain("Brief downtime."); + }); + + test("frontmatter carries page homepage + contact urls", () => { + const withUrls = generateMaintenance(maintenance, BASE, { + homepageUrl: "https://acme.com", + contactUrl: "mailto:status@acme.com", + }); + expect(withUrls).toContain('homepage_url: "https://acme.com"'); + expect(withUrls).toContain('contact_url: "mailto:status@acme.com"'); + }); + + test("null `to` renders 'ongoing', not a bogus duration", () => { + const openEnded = { + ...maintenance, + to: null, + } as unknown as MaintenanceDetail; + const out = generateMaintenance(openEnded, BASE); + expect(out).toContain("**Window:** Jun 20, 12:00 AM → — · ongoing"); + expect(out).not.toContain("1970"); + }); +}); + +describe("generateMonitor", () => { + const monitor = { + id: 9, + name: "API monitor", + description: "Main API", + url: "https://api.acme.com", + data: { + latency: { + data: [ + { + timestamp: 1_717_372_800_000, + p50Latency: 100, + p75Latency: 200, + p95Latency: 300, + p99Latency: 400, + }, + { + timestamp: 1_717_459_200_000, + p50Latency: 200, + p75Latency: 300, + p95Latency: 400, + p99Latency: 500, + }, + ], + }, + regions: { + data: [ + { region: "ams", p75Latency: 200 }, + { region: "ams", p75Latency: 400 }, + { region: "iad", p75Latency: 100 }, + ], + }, + uptime: { + data: [ + { interval: new Date(), success: 90, degraded: 5, error: 5 }, + { interval: new Date(), success: 100, degraded: 0, error: 0 }, + ], + }, + }, + } as unknown as MonitorDetail; + const md = generateMonitor(monitor, BASE); + + test("KPI table", () => { + expect(md).toContain("| Global latency (p75) | 200ms – 300ms |"); + expect(md).toContain("| Region latency | 2 regions · fastest: iad |"); + expect(md).toContain("| Uptime (last 7 days) | 97.50% · 200 checks |"); + }); + + test("percentile table", () => { + expect(md).toContain("| p50 | 150ms |"); + expect(md).toContain("| p99 | 450ms |"); + }); + + test("breadcrumb roots back to Status › Monitors", () => { + expect(md).toContain( + `[Status](/.md) › [Monitors](/monitors.md) › API monitor`, + ); + }); + + test("per-region p75, slowest first", () => { + expect(md).toContain("| ams | 300ms |"); + expect(md).toContain("| iad | 100ms |"); + expect(md.indexOf("| ams |")).toBeLessThan(md.indexOf("| iad |")); + }); + + test("no generated-at", () => { + expect(md).not.toContain("generated-at"); + }); + + test("frontmatter carries page homepage + contact urls", () => { + const withUrls = generateMonitor(monitor, BASE, { + homepageUrl: "https://acme.com", + contactUrl: "mailto:status@acme.com", + }); + expect(withUrls).toContain('homepage_url: "https://acme.com"'); + expect(withUrls).toContain('contact_url: "mailto:status@acme.com"'); + }); +}); + +describe("frontmatter YAML escaping", () => { + test("escapes newlines so a title cannot break out of its scalar", () => { + const md = frontmatter({ + title: 'Acme\nstatus" page\r\nv2', + description: "line1\nline2", + baseUrl: BASE, + canonical: BASE, + }); + expect(md).toContain('title: "Acme\\nstatus\\" page\\r\\nv2"'); + expect(md).toContain('description: "line1\\nline2"'); + // The title value must stay on a single physical line. + const titleLine = md.split("\n").find((l) => l.startsWith("title:")); + expect(titleLine).toBe('title: "Acme\\nstatus\\" page\\r\\nv2"'); + }); + + test("escapes tabs and other C0 control characters", () => { + const md = frontmatter({ + title: "a\tbc", + description: "", + baseUrl: BASE, + canonical: BASE, + }); + expect(md).toContain('title: "a\\tb\\x01c"'); + }); +}); + +describe("escapeLinkLabel", () => { + test("escapes brackets so a title cannot break a link label", () => { + expect(escapeLinkLabel("[API] is down")).toBe("\\[API\\] is down"); + }); + + test("leaves bracket-free labels untouched", () => { + expect(escapeLinkLabel("API latency")).toBe("API latency"); + }); +}); + +describe("navLine", () => { + test("escapes the label of a linked item", () => { + expect(navLine([{ label: "[x](evil)", url: "/safe" }])).toBe( + "[\\[x\\](evil)](/safe)", + ); + }); + + test("leaves plain (unlinked) items untouched", () => { + expect( + navLine([{ label: "Status" }, { label: "Events", url: "/e.md" }]), + ).toBe("Status › [Events](/e.md)"); + }); +}); + +describe("eventLog", () => { + test("strips a lone CR so a title cannot close the code fence", () => { + const out = eventLog([ + { + timestamp: new Date("2026-06-18T14:50:00.000Z"), + label: "Outage", + glyph: "x", + ref: "R-1", + title: "down\r```\n# injected", + }, + ]); + // CommonMark treats a lone CR as a line ending; if it survived, the trailing + // ``` would start a new line and close the fence early. + expect(out).not.toContain("\r"); + expect(out.startsWith("```text\n")).toBe(true); + expect(out.endsWith("\n```")).toBe(true); + expect(out).toContain("down ``` # injected"); + }); +}); diff --git a/apps/status-page/src/content/markdown/generators.ts b/apps/status-page/src/content/markdown/generators.ts new file mode 100644 index 00000000..101c3e83 --- /dev/null +++ b/apps/status-page/src/content/markdown/generators.ts @@ -0,0 +1,681 @@ +import type { RouterOutputs } from "@openstatus/api"; + +import { flattenComponents, worstComponent } from "../status-vocab"; +import { + canonicalUrl, + componentImpact, + componentImpactExplicit, + dominantDayStatus, + escapeLinkLabel, + type EventLogRow, + eventLog, + formatDay, + formatDayTime, + formatMs, + formatPercent, + formatStamp, + frontmatter, + humanDuration, + legend, + machineReadable, + mdUrl, + navLine, + relativeTime, + reportStatusGlyph, + statusGlyph, + statusLabel, + table, + uptimeBar, + worstImpact, +} from "./helpers"; + +export type OverviewPage = NonNullable; +export type UptimeComponent = NonNullable< + RouterOutputs["statusPage"]["getUptime"] +>[number]; +export type MonitorDetail = NonNullable< + RouterOutputs["statusPage"]["getMonitor"] +>; +export type ReportDetail = NonNullable< + RouterOutputs["statusPage"]["getReport"] +>; +export type MaintenanceDetail = NonNullable< + RouterOutputs["statusPage"]["getMaintenance"] +>; + +// Detail payloads don't carry the page's homepage/contact URLs — thread them in +// from getLight so a directly-fetched detail page has those nav anchors. +type PageUrls = { homepageUrl?: string | null; contactUrl?: string | null }; + +function avg(values: number[]): number | null { + if (values.length === 0) return null; + return values.reduce((a, b) => a + b, 0) / values.length; +} + +export function generateOverview( + page: OverviewPage, + components: UptimeComponent[], + baseUrl: string, + showUptime = true, +): string { + const now = Date.now(); + const out: string[] = []; + + const activeReports = page.statusReports.filter( + (r) => r.status !== "resolved", + ); + const activeMaintenance = page.maintenances.filter( + (m) => m.to && new Date(m.to).getTime() >= now, + ); + const flatComponents = flattenComponents(page.trackers); + + out.push( + frontmatter({ + title: page.title, + description: page.description, + baseUrl, + canonical: canonicalUrl(baseUrl), + homepageUrl: page.homepageUrl, + contactUrl: page.contactUrl, + live: { + status: page.status, + // Minute-granular so the body (and its ETag) stays stable within the + // minute, matching the visible timestamp — full precision would bust + // the conditional-GET window on every request. + fetchedAt: Math.floor(now / 60_000) * 60_000, + activeReports: activeReports.length, + activeMaintenance: activeMaintenance.length, + componentsOperational: flatComponents.filter( + (c) => c.status === "success", + ).length, + componentsTotal: flatComponents.length, + worstComponent: worstComponent(flatComponents), + }, + }), + ); + out.push(`# ${page.title}\n`); + out.push( + `${navLine( + [ + { label: "**Status**" }, + ...(page.monitors.length > 0 + ? [{ label: "Monitors", url: mdUrl("monitors") }] + : []), + { label: "Events", url: mdUrl("events") }, + ], + " · ", + )}\n`, + ); + if (page.description) out.push(`> ${page.description}\n`); + out.push( + `\`${statusGlyph(page.status)}\` **${statusLabel(page.status)}** · ${formatStamp(now)}\n`, + ); + + const componentNames = ( + links: { pageComponent?: { name?: string | null } | null }[], + ) => + links + .map((l) => l.pageComponent?.name) + .filter((name): name is string => Boolean(name)); + + if (activeReports.length > 0) { + out.push("## Active incidents\n"); + for (const report of activeReports) { + const latest = report.statusReportUpdates[0]; + const affects = componentNames(report.statusReportsToPageComponents); + const head = [ + `- ${reportStatusGlyph(report.status)} **${report.title}** — ${statusLabel(report.status)}`, + affects.length ? `affects: ${affects.join(", ")}` : null, + mdUrl(`events/report/${report.id}`), + ].filter(Boolean); + out.push(head.join(" · ")); + if (latest) out.push(` ${latest.message}`); + } + out.push(""); + } + + if (activeMaintenance.length > 0) { + out.push("## Active & upcoming maintenance\n"); + for (const m of activeMaintenance) { + const affects = componentNames(m.maintenancesToPageComponents); + const head = [ + `- ${statusGlyph("info")} **${m.title}** — ${formatDay(m.from)} → ${formatDay(m.to)}`, + affects.length ? `affects: ${affects.join(", ")}` : null, + mdUrl(`events/maintenance/${m.id}`), + ].filter(Boolean); + out.push(head.join(" · ")); + if (m.message) out.push(` ${m.message}`); + } + out.push(""); + } + + out.push("## Components\n"); + + // `components` (uptime data) has no stable order and is flat. Drive + // iteration from `page.trackers` instead — the same ordered, grouped + // structure the HTML page renders — and join uptime data by id. + const uptimeById = new Map(components.map((c) => [c.pageComponentId, c])); + const rows: { groupName: string | null; component: UptimeComponent }[] = []; + for (const tracker of page.trackers) { + if (tracker.type === "component") { + const u = uptimeById.get(tracker.component.id); + if (u) rows.push({ groupName: null, component: u }); + } else { + for (const comp of tracker.components) { + const u = uptimeById.get(comp.id); + if (u) rows.push({ groupName: tracker.groupName, component: u }); + } + } + } + // Defensive fallback: if trackers and uptime diverge, don't drop components. + if (rows.length === 0 && components.length > 0) { + for (const c of components) rows.push({ groupName: null, component: c }); + } + + if (rows.length === 0) { + out.push("No components.\n"); + } else { + out.push(`${legend()}\n`); + + const lastActivity = (r: OverviewPage["statusReports"][number]) => { + const dates = r.statusReportUpdates.map((u) => + new Date(u.date).getTime(), + ); + return dates.length + ? Math.max(...dates) + : r.createdAt + ? new Date(r.createdAt).getTime() + : 0; + }; + + let currentGroup: string | null = null; + for (const { groupName, component: c } of rows) { + if (groupName && groupName !== currentGroup) + out.push(`### ${groupName}\n`); + currentGroup = groupName; + const days = c.data.length; + const metric = showUptime + ? c.uptime + : statusLabel(dominantDayStatus(c.data[c.data.length - 1]?.bar ?? [])); + out.push(`**${c.name}** — ${metric} · \`${days}d ago → today\``); + out.push(uptimeBar(c.data)); + + // Only events within the chart window (c.data is oldest → newest); older + // ones fall off the bar and live on the /events page. + const windowStart = c.data[0]?.day + ? new Date(c.data[0].day).getTime() + : 0; + + // Link the reports/maintenances that explain this component's colored days. + const events = [ + ...page.statusReports + .filter((r) => + r.statusReportsToPageComponents.some( + (x) => x.pageComponentId === c.pageComponentId, + ), + ) + .map((r) => ({ + sort: lastActivity(r), + link: `[${escapeLinkLabel(r.title)}](${mdUrl(`events/report/${r.id}`)})`, + })), + ...page.maintenances + .filter((m) => + m.maintenancesToPageComponents.some( + (x) => x.pageComponentId === c.pageComponentId, + ), + ) + .map((m) => ({ + sort: new Date(m.from).getTime(), + link: `[${escapeLinkLabel(m.title)}](${mdUrl(`events/maintenance/${m.id}`)})`, + })), + ] + .filter((e) => e.sort >= windowStart) + .sort((a, b) => b.sort - a.sort); + + if (events.length > 0) { + const shown = events.slice(0, 5).map((e) => e.link); + const extra = events.length - shown.length; + const more = extra > 0 ? ` · [+${extra} more](${mdUrl("events")})` : ""; + out.push(`Events: ${shown.join(" · ")}${more}`); + } + out.push(""); + } + } + + if (page.accessType === "public") out.push(machineReadable()); + + return `${out.join("\n").trimEnd()}\n`; +} + +export function generateMonitorsList( + page: OverviewPage, + baseUrl: string, +): string { + const out: string[] = []; + out.push( + frontmatter({ + title: `${page.title} — Monitors`, + description: `Monitors for ${page.title}`, + baseUrl, + canonical: canonicalUrl(baseUrl, "monitors"), + homepageUrl: page.homepageUrl, + contactUrl: page.contactUrl, + }), + ); + out.push(`# ${page.title} — Monitors\n`); + out.push( + `${navLine( + [ + { label: "Status", url: mdUrl() }, + { label: "**Monitors**" }, + { label: "Events", url: mdUrl("events") }, + ], + " · ", + )}\n`, + ); + + if (page.monitors.length === 0) { + out.push("No public monitors.\n"); + } else { + const rows = page.monitors.map((m) => [ + `${statusGlyph(m.status)} ${m.name}`, + statusLabel(m.status), + mdUrl(`monitors/${m.id}`), + ]); + out.push(table(["Monitor", "Status", "Details"], rows)); + out.push(""); + } + + return `${out.join("\n").trimEnd()}\n`; +} + +export function generateEventsList( + page: OverviewPage, + baseUrl: string, +): string { + const now = Date.now(); + const out: string[] = []; + out.push( + frontmatter({ + title: `${page.title} — Events`, + description: `Incident history and maintenance for ${page.title}`, + baseUrl, + canonical: canonicalUrl(baseUrl, "events"), + homepageUrl: page.homepageUrl, + contactUrl: page.contactUrl, + }), + ); + out.push(`# ${page.title} — Events · Reports\n`); + out.push( + `${navLine( + [ + { label: "Status", url: mdUrl() }, + ...(page.monitors.length > 0 + ? [{ label: "Monitors", url: mdUrl("monitors") }] + : []), + { label: "**Events**" }, + ], + " · ", + )}\n`, + ); + + const logRows: EventLogRow[] = []; + for (const report of page.statusReports) { + for (const update of report.statusReportUpdates) { + logRows.push({ + timestamp: update.date, + label: statusLabel(update.status).toUpperCase(), + glyph: reportStatusGlyph(update.status), + ref: `report/${report.id}`, + title: report.title, + }); + } + } + for (const m of page.maintenances) { + logRows.push({ + timestamp: m.from, + label: "MAINTENANCE", + glyph: statusGlyph("info"), + ref: `maintenance/${m.id}`, + title: m.title, + }); + // Only log a COMPLETED entry once the window has actually ended. (`m.to` + // is non-null per schema; the truthy check is just defensive.) + if (m.to && new Date(m.to).getTime() <= now) { + logRows.push({ + timestamp: m.to, + label: "COMPLETED", + glyph: statusGlyph("success"), + ref: `maintenance/${m.id}`, + title: m.title, + }); + } + } + if (logRows.length > 0) { + out.push("## Event log\n"); + out.push(`${eventLog(logRows)}\n`); + } + + if (page.statusReports.length === 0) { + out.push("No status reports.\n"); + } else { + for (const report of page.statusReports) { + const updates = report.statusReportUpdates; + const oldest = updates[updates.length - 1]; + const start = oldest?.date ?? report.createdAt; + + const componentName = new Map(); + for (const c of report.statusReportsToPageComponents) { + if (c.pageComponent?.name) + componentName.set(c.pageComponentId, c.pageComponent.name); + } + + // Worst impact each component reached over the report's lifetime. + const impactByComponent = new Map(); + for (const u of updates) { + for (const ci of u.statusReportUpdateToPageComponents ?? []) { + const prev = impactByComponent.get(ci.pageComponentId); + impactByComponent.set( + ci.pageComponentId, + prev ? (worstImpact([prev, ci.impact]) ?? ci.impact) : ci.impact, + ); + } + } + const affects = report.statusReportsToPageComponents + .map((c) => { + const name = c.pageComponent?.name; + if (!name) return null; + return componentImpact( + name, + impactByComponent.get(c.pageComponentId), + ); + }) + .filter((v): v is string => Boolean(v)); + + const meta = [ + start ? formatDay(start) : null, + start ? relativeTime(start, now) : null, + affects.length ? `affects: ${affects.join(", ")}` : null, + oldest && updates[0] + ? humanDuration(oldest.date, updates[0].date) + : null, + ].filter(Boolean); + + out.push( + `### [${escapeLinkLabel(report.title)}](${mdUrl(`events/report/${report.id}`)})`, + ); + if (meta.length) out.push(`${meta.join(" · ")}`); + for (const update of updates) { + const updateAffects = (update.statusReportUpdateToPageComponents ?? []) + .map((ci) => { + const name = componentName.get(ci.pageComponentId); + return name ? componentImpactExplicit(name, ci.impact) : null; + }) + .filter((v): v is string => Boolean(v)); + const head = `- ${reportStatusGlyph(update.status)} ${statusLabel(update.status)} — ${formatDayTime(update.date)}`; + out.push( + updateAffects.length ? `${head} · ${updateAffects.join(", ")}` : head, + ); + } + out.push(""); + } + } + + if (page.maintenances.length > 0) { + out.push("## Maintenance\n"); + for (const m of page.maintenances) { + const affects = m.maintenancesToPageComponents + .map((c) => c.pageComponent?.name) + .filter((name): name is string => Boolean(name)); + const meta = [ + formatDay(m.from), + m.to ? humanDuration(m.from, m.to) : null, + affects.length ? `affects: ${affects.join(", ")}` : null, + ].filter(Boolean); + out.push( + `### [${escapeLinkLabel(m.title)}](${mdUrl(`events/maintenance/${m.id}`)})`, + ); + out.push(meta.join(" · ")); + out.push(""); + } + } + + return `${out.join("\n").trimEnd()}\n`; +} + +export function generateReport( + report: ReportDetail, + baseUrl: string, + page?: PageUrls, +): string { + const now = Date.now(); + const updates = report.statusReportUpdates; + const oldest = updates[updates.length - 1]; + const latest = updates[0]; + + const componentName = new Map(); + for (const c of report.statusReportsToPageComponents) { + if (c.pageComponent?.name) + componentName.set(c.pageComponentId, c.pageComponent.name); + } + const components = report.statusReportsToPageComponents + .map((c) => c.pageComponent?.name) + .filter((name): name is string => Boolean(name)); + + const description = + [ + latest ? statusLabel(latest.status) : null, + components.length ? `affects ${components.join(", ")}` : null, + oldest ? formatDay(oldest.date) : null, + ] + .filter(Boolean) + .join(" · ") || `Status report: ${report.title}`; + + const out: string[] = []; + out.push( + frontmatter({ + title: report.title, + description, + baseUrl, + canonical: canonicalUrl(baseUrl, `events/report/${report.id}`), + homepageUrl: page?.homepageUrl, + contactUrl: page?.contactUrl, + }), + ); + out.push(`# ${report.title}\n`); + out.push( + `${navLine([ + { label: "Status", url: mdUrl() }, + { label: "Events", url: mdUrl("events") }, + { label: report.title }, + ])}\n`, + ); + const meta = [ + oldest + ? `${formatDay(oldest.date)} · ${relativeTime(oldest.date, now)}` + : null, + components.length ? `affects: ${components.join(", ")}` : null, + oldest && updates[0] ? humanDuration(oldest.date, updates[0].date) : null, + ].filter(Boolean); + if (meta.length) out.push(`${meta.join(" · ")}\n`); + + out.push("## Updates\n"); + if (updates.length === 0) { + out.push("No updates.\n"); + } else { + for (const update of updates) { + const updateAffects = (update.statusReportUpdateToPageComponents ?? []) + .map((ci) => { + const name = componentName.get(ci.pageComponentId); + return name ? componentImpactExplicit(name, ci.impact) : null; + }) + .filter((v): v is string => Boolean(v)); + out.push( + `### ${reportStatusGlyph(update.status)} ${statusLabel(update.status)} — ${formatDayTime(update.date)}\n`, + ); + if (updateAffects.length) + out.push(`affects: ${updateAffects.join(", ")}\n`); + out.push(`${update.message}\n`); + } + } + + return `${out.join("\n").trimEnd()}\n`; +} + +export function generateMaintenance( + maintenance: MaintenanceDetail, + baseUrl: string, + page?: PageUrls, +): string { + const out: string[] = []; + out.push( + frontmatter({ + title: maintenance.title, + description: `Maintenance: ${maintenance.title}`, + baseUrl, + canonical: canonicalUrl(baseUrl, `events/maintenance/${maintenance.id}`), + homepageUrl: page?.homepageUrl, + contactUrl: page?.contactUrl, + }), + ); + out.push(`# ${maintenance.title}\n`); + out.push( + `${navLine([ + { label: "Status", url: mdUrl() }, + { label: "Events", url: mdUrl("events") }, + { label: maintenance.title }, + ])}\n`, + ); + const windowLine = [ + `${formatDayTime(maintenance.from)} → ${formatDayTime(maintenance.to)}`, + maintenance.to + ? humanDuration(maintenance.from, maintenance.to) + : "ongoing", + ].join(" · "); + out.push(`**Window:** ${windowLine}\n`); + + const components = maintenance.maintenancesToPageComponents + .map((c) => c.pageComponent?.name) + .filter((name): name is string => Boolean(name)); + if (components.length > 0) { + out.push(`**Affected components:** ${components.join(", ")}\n`); + } + + out.push("## Details\n"); + out.push(`${maintenance.message}\n`); + + return `${out.join("\n").trimEnd()}\n`; +} + +export function generateMonitor( + monitor: MonitorDetail, + baseUrl: string, + page?: PageUrls, +): string { + const out: string[] = []; + out.push( + frontmatter({ + title: monitor.name, + description: `Monitor metrics for ${monitor.name} (last 7 days)`, + baseUrl, + canonical: canonicalUrl(baseUrl, `monitors/${monitor.id}`), + homepageUrl: page?.homepageUrl, + contactUrl: page?.contactUrl, + }), + ); + out.push(`# ${monitor.name}\n`); + out.push( + `${navLine([ + { label: "Status", url: mdUrl() }, + { label: "Monitors", url: mdUrl("monitors") }, + { label: monitor.name }, + ])}\n`, + ); + if (monitor.description) out.push(`> ${monitor.description}\n`); + + const latencyData = [...(monitor.data.latency?.data ?? [])].sort( + (a, b) => a.timestamp - b.timestamp, + ); + const p75Series = latencyData.map((d) => d.p75Latency); + + // Uptime over the hardcoded 7-day window. + const uptimeData = monitor.data.uptime?.data ?? []; + const totals = uptimeData.reduce( + (acc, d) => ({ + success: acc.success + d.success, + degraded: acc.degraded + d.degraded, + error: acc.error + d.error, + }), + { success: 0, degraded: 0, error: 0 }, + ); + const totalChecks = totals.success + totals.degraded + totals.error; + const uptime = + totalChecks > 0 + ? formatPercent((totals.success + totals.degraded) / totalChecks) + : "N/A"; + + // Per-region p75 (mean across the window). + const regionData = monitor.data.regions?.data ?? []; + const byRegion = new Map(); + for (const d of regionData) { + const list = byRegion.get(d.region) ?? []; + if (d.p75Latency !== null && d.p75Latency !== undefined) { + list.push(d.p75Latency); + } + byRegion.set(d.region, list); + } + const regionAverages = Array.from(byRegion.entries()) + .map(([region, values]) => ({ region, p75: avg(values) })) + .sort((a, b) => (a.p75 ?? Infinity) - (b.p75 ?? Infinity)); + const fastest = regionAverages[0]?.region; + + const p75Min = p75Series.length ? Math.min(...p75Series) : null; + const p75Max = p75Series.length ? Math.max(...p75Series) : null; + + out.push( + table( + ["Metric", "Value"], + [ + [ + "Global latency (p75)", + p75Min !== null ? `${formatMs(p75Min)} – ${formatMs(p75Max)}` : "—", + ], + [ + "Region latency", + byRegion.size + ? `${byRegion.size} regions · fastest: ${fastest}` + : "—", + ], + ["Uptime (last 7 days)", `${uptime} · ${totalChecks} checks`], + ], + ), + ); + out.push(""); + + out.push("## Latency percentiles (last 7 days)\n"); + out.push( + table( + ["Quantile", "Latency"], + [ + ["p50", formatMs(avg(latencyData.map((d) => d.p50Latency)))], + ["p75", formatMs(avg(p75Series))], + ["p95", formatMs(avg(latencyData.map((d) => d.p95Latency)))], + ["p99", formatMs(avg(latencyData.map((d) => d.p99Latency)))], + ], + ), + ); + out.push(""); + + out.push("## Latency by region — p75 (last 7 days)\n"); + if (regionAverages.length === 0) { + out.push("No regional data.\n"); + } else { + const rows = [...regionAverages] + .sort((a, b) => (b.p75 ?? 0) - (a.p75 ?? 0)) + .map((r) => [r.region, formatMs(r.p75)]); + out.push(table(["Region", "p75"], rows)); + out.push(""); + } + + return `${out.join("\n").trimEnd()}\n`; +} diff --git a/apps/status-page/src/content/markdown/helpers.ts b/apps/status-page/src/content/markdown/helpers.ts new file mode 100644 index 00000000..ce00d1dd --- /dev/null +++ b/apps/status-page/src/content/markdown/helpers.ts @@ -0,0 +1,467 @@ +import { isoOrNull } from "../status-vocab"; + +export type ComponentStatus = "success" | "degraded" | "error" | "info"; + +export type ReportStatus = + | "investigating" + | "identified" + | "monitoring" + | "resolved" + | "maintenance"; + +const COMPONENT_STATUS_LABELS: Record = { + success: "Operational", + degraded: "Degraded", + error: "Outage", + info: "Maintenance", +}; + +const REPORT_STATUS_LABELS: Record = { + investigating: "Investigating", + identified: "Identified", + monitoring: "Monitoring", + resolved: "Resolved", + maintenance: "Maintenance", +}; + +export function statusLabel(status: string): string { + if (status in COMPONENT_STATUS_LABELS) { + return COMPONENT_STATUS_LABELS[status as ComponentStatus]; + } + if (status in REPORT_STATUS_LABELS) { + return REPORT_STATUS_LABELS[status as ReportStatus]; + } + return status; +} + +/** Quote a value as a double-quoted YAML scalar. Every C0 control char must be + * escaped — a literal newline/tab splits or corrupts the scalar and parsers + * disagree on the result. */ +function escapeYaml(value: string): string { + let out = '"'; + for (const ch of value) { + const code = ch.charCodeAt(0); + if (ch === "\\") out += "\\\\"; + else if (ch === '"') out += '\\"'; + else if (ch === "\n") out += "\\n"; + else if (ch === "\r") out += "\\r"; + else if (ch === "\t") out += "\\t"; + else if (code < 0x20) out += `\\x${code.toString(16).padStart(2, "0")}`; + else out += ch; + } + return `${out}"`; +} + +/** + * Live page state carried in frontmatter so an agent gets the answer from the + * top ~10 lines without tokenizing the body. `fetched_at` is the snapshot's + * generation time (truthful freshness signal); HTTP ETag/Cache-Control carry + * the rest. + */ +export interface FrontmatterLive { + status: string; + fetchedAt: Date | string | number | null | undefined; + activeReports: number; + activeMaintenance: number; + componentsOperational: number; + componentsTotal: number; + worstComponent?: string | null; +} + +export function frontmatter(fields: { + title: string; + description: string; + baseUrl: string; + canonical: string; + homepageUrl?: string | null; + contactUrl?: string | null; + live?: FrontmatterLive; +}): string { + const lines = [ + "---", + `title: ${escapeYaml(fields.title)}`, + `description: ${escapeYaml(fields.description)}`, + // Origin the root-relative in-body links resolve against. + `base_url: ${escapeYaml(fields.baseUrl)}`, + `canonical: ${escapeYaml(fields.canonical)}`, + ]; + if (fields.homepageUrl) + lines.push(`homepage_url: ${escapeYaml(fields.homepageUrl)}`); + if (fields.contactUrl) + lines.push(`contact_url: ${escapeYaml(fields.contactUrl)}`); + if (fields.live) { + const fetched = isoOrNull(fields.live.fetchedAt); + lines.push(`status: ${escapeYaml(fields.live.status)}`); + if (fetched) lines.push(`fetched_at: ${escapeYaml(fetched)}`); + lines.push(`active_reports: ${fields.live.activeReports}`); + lines.push(`active_maintenance: ${fields.live.activeMaintenance}`); + lines.push(`components_operational: ${fields.live.componentsOperational}`); + lines.push(`components_total: ${fields.live.componentsTotal}`); + if (fields.live.worstComponent) + lines.push(`worst_component: ${escapeYaml(fields.live.worstComponent)}`); + } + lines.push("---"); + return `${lines.join("\n")}\n`; +} + +/** Escape a markdown table cell — pipes and newlines would break the row. */ +export function escapeCell(value: string): string { + // Escape backslash first so the pipe-escaping we add isn't re-escaped. + return value + .replace(/\\/g, "\\\\") + .replace(/\|/g, "\\|") + .replace(/[\r\n]+/g, " ") + .trim(); +} + +/** Escape a value used as a markdown link label — `[`/`]` would break `[text](url)`. */ +export function escapeLinkLabel(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/[[\]]/g, "\\$&"); +} + +export function table(headers: string[], rows: string[][]): string { + const head = `| ${headers.map((cell) => escapeCell(cell)).join(" | ")} |`; + const divider = `| ${headers.map(() => "---").join(" | ")} |`; + const body = rows + .map((row) => `| ${row.map((cell) => escapeCell(cell)).join(" | ")} |`) + .join("\n"); + return body ? `${head}\n${divider}\n${body}` : `${head}\n${divider}`; +} + +export function formatDate( + date: Date | string | number | null | undefined, +): string { + if (date === null || date === undefined) return "—"; + const d = date instanceof Date ? date : new Date(date); + if (Number.isNaN(d.getTime())) return "—"; + return d.toISOString(); +} + +export function formatMs(value: number | null | undefined): string { + if (value === null || value === undefined || Number.isNaN(value)) return "—"; + return `${Math.round(value)}ms`; +} + +export function formatPercent(ratio: number): string { + return `${(ratio * 100).toFixed(2)}%`; +} + +/** Build the public-facing canonical (HTML) URL for a path under a page. */ +export function canonicalUrl(baseUrl: string, path = ""): string { + return path ? `${baseUrl}/${path.replace(/^\//, "")}` : baseUrl; +} + +/** + * Root-relative `.md` link for a path under a page (root → `/.md`). Relative on + * purpose: the frontmatter `canonical` carries the absolute base, so in-body + * links need not repeat the host. + */ +export function mdUrl(path = ""): string { + const clean = path.replace(/^\//, ""); + return clean ? `/${clean}.md` : "/.md"; +} + +/** + * A breadcrumb / peer-nav line. Items with a `url` render as links; the current + * item (no `url`) renders as plain text. Use ` › ` for hierarchy, ` · ` for peers. + */ +export function navLine( + items: { label: string; url?: string }[], + separator = " › ", +): string { + return items + .map((i) => + i.url + ? `[${escapeLinkLabel(i.label)}](${i.url})` + : escapeLinkLabel(i.label), + ) + .join(separator); +} + +export type DayStatus = "success" | "degraded" | "error" | "info" | "empty"; + +// ASCII status markers: 1 column, monospace-stable, markdown-inert. Markdown has +// no color, so shape carries the meaning — `x` reads as down/outage. +const STATUS_GLYPH: Record = { + success: "+", + degraded: "~", + error: "x", + info: "=", + empty: ".", +}; + +const DAY_PRIORITY: Record = { + error: 3, + degraded: 2, + info: 1, + success: 0, + empty: -1, +}; + +export function statusGlyph(status: string): string { + return STATUS_GLYPH[status as DayStatus] ?? STATUS_GLYPH.empty; +} + +const LEGEND_LABELS: Record = { + success: "Operational", + degraded: "Degraded", + error: "Outage", + info: "Maintenance", + empty: "No data", +}; + +const LEGEND_ORDER: DayStatus[] = [ + "success", + "degraded", + "error", + "info", + "empty", +]; + +/** Legend line covering every status, in severity order. */ +export function legend(): string { + // Glyphs in code spans so they render in the same monospace as the bar they + // explain — a bare `.` / `~` is otherwise easy to miss or misread. + const parts = LEGEND_ORDER.map( + (s) => `\`${STATUS_GLYPH[s]}\` ${LEGEND_LABELS[s]}`, + ); + return `Legend: ${parts.join(" · ")}`; +} + +// Severity order mirrors `pageComponentImpact` in @openstatus/db; kept local so +// the generators stay types-only against the API and don't value-import the schema. +const IMPACT_ORDER = [ + "operational", + "degraded_performance", + "partial_outage", + "major_outage", +]; + +const IMPACT_LABELS: Record = { + operational: "operational", + degraded_performance: "degraded performance", + partial_outage: "partial outage", + major_outage: "major outage", +}; + +export function impactLabel(impact: string): string { + return IMPACT_LABELS[impact] ?? impact; +} + +/** "name (impact label)", dropping the impact when operational/absent. */ +export function componentImpact(name: string, impact?: string | null): string { + return impact && impact !== "operational" + ? `${name} (${impactLabel(impact)})` + : name; +} + +/** + * Like `componentImpact` but always shows the impact label, even operational — + * for a single update's affects line, where every component has a declared + * impact and dropping operational would read as inconsistent against siblings. + */ +export function componentImpactExplicit( + name: string, + impact?: string | null, +): string { + return impact ? `${name} (${impactLabel(impact)})` : name; +} + +/** Most severe impact among the given values, or null if empty. */ +export function worstImpact(impacts: string[]): string | null { + let worst: string | null = null; + for (const i of impacts) { + if ( + worst === null || + IMPACT_ORDER.indexOf(i) > IMPACT_ORDER.indexOf(worst) + ) { + worst = i; + } + } + return worst; +} + +/** Status glyph for a report/update lifecycle status (resolved → `+`, etc.). */ +export function reportStatusGlyph(status: string): string { + if (status === "resolved") return STATUS_GLYPH.success; + if (status === "maintenance") return STATUS_GLYPH.info; + return STATUS_GLYPH.error; +} + +/** Collapse a day's stacked bar segments to the single worst (most severe) status. */ +export function dominantDayStatus( + bar: { status: string; height: number }[], +): DayStatus { + let best: DayStatus = "empty"; + for (const seg of bar) { + const status = seg.status as DayStatus; + if (seg.height > 0 && (DAY_PRIORITY[status] ?? -1) > DAY_PRIORITY[best]) { + best = status; + } + } + return best; +} + +/** + * One ASCII cell per day, oldest → newest, in a code span. The code span pins + * the bar to monospace (cells stay aligned) and keeps it markdown-inert — a run + * of `~~` days would otherwise parse as strikethrough. + */ +export function uptimeBar( + days: { bar: { status: string; height: number }[] }[], +): string { + if (days.length === 0) return ""; + return `\`${days.map((d) => statusGlyph(dominantDayStatus(d.bar))).join("")}\``; +} + +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +function toDate(date: Date | string | number): Date { + return date instanceof Date ? date : new Date(date); +} + +/** "Jan 25, 2026" (UTC, deterministic). */ +export function formatDay(date: Date | string | number): string { + const d = toDate(date); + return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`; +} + +/** "Feb 3, 1:42 PM" (UTC, deterministic). Null/invalid → "—". */ +export function formatDayTime( + date: Date | string | number | null | undefined, +): string { + if (date === null || date === undefined) return "—"; + const d = toDate(date); + if (Number.isNaN(d.getTime())) return "—"; + const h = d.getUTCHours(); + const period = h < 12 ? "AM" : "PM"; + const hour12 = h % 12 === 0 ? 12 : h % 12; + const minutes = String(d.getUTCMinutes()).padStart(2, "0"); + return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${hour12}:${minutes} ${period}`; +} + +/** "2026-06-18 14:50" (UTC, sortable, fixed-width). */ +export function formatLogStamp(date: Date | string | number): string { + const d = toDate(date); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const min = String(d.getUTCMinutes()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd} ${hh}:${min}`; +} + +export type EventLogRow = { + timestamp: Date | string | number; + label: string; + glyph: string; + ref: string; + title: string; +}; + +/** + * Greppable event log: one update per line, sortable stamp first, newest first. + * Structured columns (stamp/status/ref/glyph) precede the user-authored title, + * which trails as free text. Newlines are stripped from the title so it can't + * inject a line that closes the fenced block. + */ +export function eventLog(rows: EventLogRow[]): string { + if (rows.length === 0) return ""; + const sorted = [...rows].sort( + (a, b) => toDate(b.timestamp).getTime() - toDate(a.timestamp).getTime(), + ); + const statusW = Math.max(6, ...sorted.map((r) => r.label.length)); + const refW = Math.max(5, ...sorted.map((r) => r.ref.length)); + const header = `# ${"timestamp".padEnd(16)} ${"status".padEnd(statusW)} event`; + const lines = sorted.map( + (r) => + `${formatLogStamp(r.timestamp)} ${r.label.padEnd(statusW)} ${r.ref.padEnd(refW)} ${r.glyph} ${r.title.replace(/[\r\n]+/g, " ")}`, + ); + return ["```text", header, ...lines, "```"].join("\n"); +} + +/** "Jun 18, 2026 14:50 (GMT+0)" (UTC, deterministic). */ +export function formatStamp(date: Date | string | number): string { + const d = toDate(date); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const mm = String(d.getUTCMinutes()).padStart(2, "0"); + return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()} ${hh}:${mm} (GMT+0)`; +} + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const MONTH = 30 * DAY; +const YEAR = 365 * DAY; + +function coarseSpan(ms: number): string { + if (ms < HOUR) { + const m = Math.max(1, Math.round(ms / MINUTE)); + return `${m} ${m === 1 ? "minute" : "minutes"}`; + } + if (ms < DAY) { + const h = Math.round(ms / HOUR); + return `${h} ${h === 1 ? "hour" : "hours"}`; + } + if (ms < MONTH) { + const d = Math.round(ms / DAY); + return `${d} ${d === 1 ? "day" : "days"}`; + } + if (ms < YEAR) { + const m = Math.round(ms / MONTH); + return `${m} ${m === 1 ? "month" : "months"}`; + } + const y = Math.round(ms / YEAR); + return `${y} ${y === 1 ? "year" : "years"}`; +} + +/** "4 months ago" relative to `now`. */ +export function relativeTime( + date: Date | string | number, + now: number, +): string { + const ms = now - toDate(date).getTime(); + if (ms < MINUTE) return "just now"; + return `${coarseSpan(ms)} ago`; +} + +/** Human duration between two instants: "9 days", "23 hours". */ +export function humanDuration( + from: Date | string | number, + to: Date | string | number, +): string { + return coarseSpan(Math.max(0, toDate(to).getTime() - toDate(from).getTime())); +} + +/** + * Pointer to machine-readable formats, for agents that enter via a `.md` link + * and never see `llms.txt`. Public pages only — a gated page must not advertise + * JSON endpoints that may not honor its access gate. + */ +export function machineReadable(): string { + return "Machine-readable: [current.json](/api/status/current.json) · [summary.json](/api/status/summary.json) · [more](/llms.txt)"; +} + +/** Attribution footer appended to every doc unless the page is white-labeled. */ +export function poweredByFooter(): string { + return "---\n\n_Powered by [openstatus.dev](https://openstatus.dev)_\n"; +} + +/** Append the attribution footer to rendered markdown unless white-labeled. */ +export function withPoweredBy(markdown: string, whiteLabel: boolean): string { + if (whiteLabel) return markdown; + return `${markdown}\n${poweredByFooter()}`; +} diff --git a/apps/status-page/src/content/markdown/index.ts b/apps/status-page/src/content/markdown/index.ts new file mode 100644 index 00000000..b3179509 --- /dev/null +++ b/apps/status-page/src/content/markdown/index.ts @@ -0,0 +1,18 @@ +export { matchMarkdownRoute, parseMarkdownPath } from "./match-route"; +export type { MarkdownTarget } from "./match-route"; +export { escapeLinkLabel, poweredByFooter, withPoweredBy } from "./helpers"; +export { + generateEventsList, + generateMaintenance, + generateMonitor, + generateMonitorsList, + generateOverview, + generateReport, +} from "./generators"; +export type { + MaintenanceDetail, + MonitorDetail, + OverviewPage, + ReportDetail, + UptimeComponent, +} from "./generators"; diff --git a/apps/status-page/src/content/markdown/match-route.test.ts b/apps/status-page/src/content/markdown/match-route.test.ts new file mode 100644 index 00000000..7000f38d --- /dev/null +++ b/apps/status-page/src/content/markdown/match-route.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; + +import { matchMarkdownRoute, parseMarkdownPath } from "./match-route"; + +describe("parseMarkdownPath", () => { + test("missing slug → null", () => { + expect(parseMarkdownPath([])).toBeNull(); + }); + + test("slug only → overview rest", () => { + expect(parseMarkdownPath(["acme"])).toEqual({ slug: "acme", rest: [] }); + }); + + test("drops a present locale segment", () => { + expect(parseMarkdownPath(["acme", "de", "monitors", "123"])).toEqual({ + slug: "acme", + rest: ["monitors", "123"], + }); + }); + + test("keeps a non-locale second segment (locale-less request)", () => { + expect(parseMarkdownPath(["acme", "monitors", "123"])).toEqual({ + slug: "acme", + rest: ["monitors", "123"], + }); + }); + + test("locale only, no rest", () => { + expect(parseMarkdownPath(["acme", "fr"])).toEqual({ + slug: "acme", + rest: [], + }); + }); +}); + +describe("matchMarkdownRoute", () => { + test("empty → overview", () => { + expect(matchMarkdownRoute([])).toEqual({ kind: "overview" }); + }); + + test("monitors list", () => { + expect(matchMarkdownRoute(["monitors"])).toEqual({ kind: "monitors" }); + }); + + test("monitor detail with numeric id", () => { + expect(matchMarkdownRoute(["monitors", "123"])).toEqual({ + kind: "monitor", + id: 123, + }); + }); + + test("events list", () => { + expect(matchMarkdownRoute(["events"])).toEqual({ kind: "events" }); + }); + + test("report detail", () => { + expect(matchMarkdownRoute(["events", "report", "7"])).toEqual({ + kind: "report", + id: 7, + }); + }); + + test("maintenance detail", () => { + expect(matchMarkdownRoute(["events", "maintenance", "42"])).toEqual({ + kind: "maintenance", + id: 42, + }); + }); + + test("non-numeric monitor id → null", () => { + expect(matchMarkdownRoute(["monitors", "abc"])).toBeNull(); + }); + + test("report missing id → null", () => { + expect(matchMarkdownRoute(["events", "report"])).toBeNull(); + }); + + test("report with trailing extra segment → null", () => { + expect(matchMarkdownRoute(["events", "report", "1", "extra"])).toBeNull(); + }); + + test("unknown tail → null", () => { + expect(matchMarkdownRoute(["foo"])).toBeNull(); + }); + + test("monitor id zero → null", () => { + expect(matchMarkdownRoute(["monitors", "0"])).toBeNull(); + }); + + test("unknown event subtype → null", () => { + expect(matchMarkdownRoute(["events", "incident", "1"])).toBeNull(); + }); +}); diff --git a/apps/status-page/src/content/markdown/match-route.ts b/apps/status-page/src/content/markdown/match-route.ts new file mode 100644 index 00000000..2f9b1b0a --- /dev/null +++ b/apps/status-page/src/content/markdown/match-route.ts @@ -0,0 +1,61 @@ +import { locales } from "@openstatus/locales"; + +export type MarkdownTarget = + | { kind: "overview" | "monitors" | "events" } + | { kind: "monitor" | "report" | "maintenance"; id: number }; + +/** + * Split the catch-all path into the page slug and rest-segments. Markdown is + * locale-agnostic, so a locale segment is dropped — but only when actually + * present, otherwise a real path segment would be consumed. Returns null when + * the slug is missing. + */ +export function parseMarkdownPath( + path: string[], +): { slug: string; rest: string[] } | null { + const [slug, second, ...tail] = path; + if (!slug) return null; + const isLocale = (locales as readonly string[]).includes(second); + const rest = isLocale ? tail : [second, ...tail].filter(Boolean); + return { slug, rest }; +} + +function parseId(value: string | undefined): number | null { + if (!value || !/^\d+$/.test(value)) return null; + const id = Number(value); + return Number.isSafeInteger(id) && id > 0 ? id : null; +} + +/** + * Pure dispatch over the catch-all rest-segments (everything after slug/locale). + * Returns null for unknown shapes so the handler can 404. + */ +export function matchMarkdownRoute(rest: string[]): MarkdownTarget | null { + const [first, second, third] = rest; + + if (rest.length === 0) return { kind: "overview" }; + + if (first === "monitors") { + if (rest.length === 1) return { kind: "monitors" }; + if (rest.length === 2) { + const id = parseId(second); + return id === null ? null : { kind: "monitor", id }; + } + return null; + } + + if (first === "events") { + if (rest.length === 1) return { kind: "events" }; + if ( + rest.length === 3 && + (second === "report" || second === "maintenance") + ) { + const id = parseId(third); + if (id === null) return null; + return { kind: second === "report" ? "report" : "maintenance", id }; + } + return null; + } + + return null; +} diff --git a/apps/status-page/src/content/status-json.test.ts b/apps/status-page/src/content/status-json.test.ts new file mode 100644 index 00000000..70e450c2 --- /dev/null +++ b/apps/status-page/src/content/status-json.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; + +import type { RouterOutputs } from "@openstatus/api"; + +import { + matchEndpoint, + toStatus, + toSummary, + toUnresolvedIncidents, +} from "./status-json"; + +describe("matchEndpoint", () => { + test("single endpoint segment, no slug", () => { + expect(matchEndpoint(["summary.json"])).toEqual({ + endpoint: "summary", + slug: null, + }); + expect(matchEndpoint(["current.json"])).toEqual({ + endpoint: "status", + slug: null, + }); + expect(matchEndpoint(["incidents.json"])).toEqual({ + endpoint: "incidents", + slug: null, + }); + }); + + test("path-based slug + endpoint", () => { + expect(matchEndpoint(["acme", "summary.json"])).toEqual({ + endpoint: "summary", + slug: "acme", + }); + }); + + test("unknown endpoint → null", () => { + expect(matchEndpoint(["acme", "unknown.json"])).toBeNull(); + expect(matchEndpoint(["summary"])).toBeNull(); + }); + + test("empty or too-long paths → null", () => { + expect(matchEndpoint([])).toBeNull(); + expect(matchEndpoint(["a", "b", "summary.json"])).toBeNull(); + }); +}); + +type Page = NonNullable; + +const BASE = "https://acme.openstatus.dev"; +const NOW = new Date("2026-06-19T12:00:00.000Z").getTime(); + +const page = { + title: "Acme Status", + status: "degraded", + slug: "acme", + customDomain: null, + updatedAt: new Date("2026-06-19T11:00:00.000Z"), + trackers: [ + { + type: "component", + component: { name: "API", status: "error" }, + order: 0, + }, + { + type: "group", + groupName: "Edge", + components: [{ name: "CDN", status: "success" }], + status: "success", + order: 1, + }, + ], + statusReports: [ + { + id: 1, + title: "API latency", + status: "investigating", + createdAt: new Date("2026-06-19T10:00:00.000Z"), + statusReportUpdates: [ + { + status: "investigating", + message: "Looking into it.", + date: new Date("2026-06-19T10:30:00.000Z"), + }, + ], + }, + { + id: 2, + title: "Old", + status: "resolved", + createdAt: new Date("2026-06-01T10:00:00.000Z"), + statusReportUpdates: [], + }, + ], + maintenances: [ + { + id: 5, + title: "DB upgrade", + from: new Date("2026-06-20T00:00:00.000Z"), + to: new Date("2026-06-20T01:00:00.000Z"), + }, + ], +} as unknown as Page; + +describe("toStatus", () => { + test("page block + Statuspage indicator", () => { + expect(toStatus(page, BASE)).toEqual({ + page: { + name: "Acme Status", + url: BASE, + updated_at: "2026-06-19T11:00:00.000Z", + }, + status: { indicator: "minor", description: "Degraded Performance" }, + }); + }); +}); + +describe("toSummary", () => { + const summary = toSummary(page, BASE, NOW); + + test("components carry Statuspage statuses", () => { + expect(summary.components).toEqual([ + { name: "API", status: "major_outage" }, + { name: "CDN", status: "operational" }, + ]); + }); + + test("only unresolved incidents, with updates", () => { + expect(summary.incidents).toHaveLength(1); + expect(summary.incidents[0]).toMatchObject({ + id: "1", + name: "API latency", + status: "investigating", + updated_at: "2026-06-19T10:30:00.000Z", + }); + expect(summary.incidents[0].incident_updates[0].body).toBe( + "Looking into it.", + ); + }); + + test("upcoming maintenance scheduled", () => { + expect(summary.scheduled_maintenances).toHaveLength(1); + expect(summary.scheduled_maintenances[0]).toMatchObject({ + id: "5", + status: "scheduled", + scheduled_for: "2026-06-20T00:00:00.000Z", + }); + }); +}); + +describe("toUnresolvedIncidents", () => { + test("excludes resolved", () => { + const out = toUnresolvedIncidents(page, BASE); + expect(out.incidents.map((i) => i.id)).toEqual(["1"]); + }); +}); diff --git a/apps/status-page/src/content/status-json.ts b/apps/status-page/src/content/status-json.ts new file mode 100644 index 00000000..62f325cb --- /dev/null +++ b/apps/status-page/src/content/status-json.ts @@ -0,0 +1,111 @@ +import type { RouterOutputs } from "@openstatus/api"; + +import { + componentStatus, + flattenComponents, + isoOrNull, + pageIndicator, +} from "./status-vocab"; + +type Page = NonNullable; + +export type StatusEndpoint = "summary" | "status" | "incidents"; + +const ENDPOINTS: Record = { + "summary.json": "summary", + "current.json": "status", + "incidents.json": "incidents", +}; + +/** + * The endpoint is always the last path segment; an optional leading segment is a + * path-based slug (`/{slug}/summary.json`), mirroring the markdown route. Returns + * null for unknown shapes so the handler can 404. + */ +export function matchEndpoint( + path: string[], +): { endpoint: StatusEndpoint; slug: string | null } | null { + if (path.length === 0 || path.length > 2) return null; + const endpoint = ENDPOINTS[path[path.length - 1]]; + if (!endpoint) return null; + return { endpoint, slug: path.length === 2 ? path[0] : null }; +} + +function pageBlock(page: Page, baseUrl: string) { + return { + name: page.title, + url: baseUrl, + updated_at: isoOrNull(page.updatedAt), + }; +} + +/** `current.json` (Statuspage `status.json` shape) — the cheapest "is it up?" payload. */ +export function toStatus(page: Page, baseUrl: string) { + return { + page: pageBlock(page, baseUrl), + status: pageIndicator(page.status), + }; +} + +function unresolvedIncidents(page: Page) { + return page.statusReports + .filter((report) => report.status !== "resolved") + .map((report) => ({ + id: String(report.id), + name: report.title, + // openstatus report statuses already match Statuspage's incident enum. + status: report.status, + updated_at: isoOrNull( + report.statusReportUpdates[0]?.date ?? report.createdAt, + ), + incident_updates: report.statusReportUpdates.map((update) => ({ + status: update.status, + body: update.message, + created_at: isoOrNull(update.date), + })), + })); +} + +// `completed` is intentionally absent: scheduledMaintenances only feeds windows +// with `to >= now`, so a window is always either upcoming or live. +function maintenanceState( + from: Date | string | number, + now: number, +): "scheduled" | "in_progress" { + return now < new Date(from).getTime() ? "scheduled" : "in_progress"; +} + +function scheduledMaintenances(page: Page, now: number) { + return page.maintenances + .filter((m) => m.to && new Date(m.to).getTime() >= now) + .map((m) => ({ + id: String(m.id), + name: m.title, + status: maintenanceState(m.from, now), + scheduled_for: isoOrNull(m.from), + scheduled_until: isoOrNull(m.to), + })); +} + +/** Statuspage `summary.json` — page status, components, active incidents/maintenance. */ +export function toSummary(page: Page, baseUrl: string, now = Date.now()) { + const components = flattenComponents(page.trackers).map((c) => ({ + name: c.name, + status: componentStatus(c.status), + })); + return { + page: pageBlock(page, baseUrl), + status: pageIndicator(page.status), + components, + incidents: unresolvedIncidents(page), + scheduled_maintenances: scheduledMaintenances(page, now), + }; +} + +/** `incidents.json` (Statuspage `incidents/unresolved.json` shape). */ +export function toUnresolvedIncidents(page: Page, baseUrl: string) { + return { + page: pageBlock(page, baseUrl), + incidents: unresolvedIncidents(page), + }; +} diff --git a/apps/status-page/src/content/status-vocab.test.ts b/apps/status-page/src/content/status-vocab.test.ts new file mode 100644 index 00000000..5769f458 --- /dev/null +++ b/apps/status-page/src/content/status-vocab.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; + +import { + componentStatus, + flattenComponents, + isoOrNull, + pageIndicator, + worstComponent, +} from "./status-vocab"; + +describe("pageIndicator", () => { + test("maps live status to Statuspage indicator + description", () => { + expect(pageIndicator("success")).toEqual({ + indicator: "none", + description: "All Systems Operational", + }); + expect(pageIndicator("degraded").indicator).toBe("minor"); + expect(pageIndicator("error").indicator).toBe("major"); + expect(pageIndicator("info").indicator).toBe("maintenance"); + }); + + test("unknown falls back to operational", () => { + expect(pageIndicator("???").indicator).toBe("none"); + }); +}); + +describe("componentStatus", () => { + test("maps to Statuspage component vocabulary", () => { + expect(componentStatus("success")).toBe("operational"); + expect(componentStatus("degraded")).toBe("degraded_performance"); + expect(componentStatus("error")).toBe("major_outage"); + expect(componentStatus("info")).toBe("under_maintenance"); + expect(componentStatus("???")).toBe("operational"); + }); +}); + +describe("flattenComponents", () => { + test("flattens components and grouped components", () => { + const flat = flattenComponents([ + { type: "component", component: { name: "API", status: "error" } }, + { + type: "group", + components: [ + { name: "DB", status: "success" }, + { name: "Cache", status: "degraded" }, + ], + }, + ]); + expect(flat).toEqual([ + { name: "API", status: "error" }, + { name: "DB", status: "success" }, + { name: "Cache", status: "degraded" }, + ]); + }); + + test("tolerates null/undefined", () => { + expect(flattenComponents(null)).toEqual([]); + expect(flattenComponents(undefined)).toEqual([]); + }); +}); + +describe("worstComponent", () => { + test("returns the most severely impacted name", () => { + expect( + worstComponent([ + { name: "API", status: "degraded" }, + { name: "Web", status: "error" }, + { name: "DB", status: "success" }, + ]), + ).toBe("Web"); + }); + + test("null when all operational", () => { + expect( + worstComponent([ + { name: "API", status: "success" }, + { name: "Web", status: "success" }, + ]), + ).toBeNull(); + }); +}); + +describe("isoOrNull", () => { + test("ISO for valid dates, null otherwise", () => { + expect(isoOrNull(new Date("2026-06-18T14:03:00.000Z"))).toBe( + "2026-06-18T14:03:00.000Z", + ); + expect(isoOrNull(null)).toBeNull(); + expect(isoOrNull(undefined)).toBeNull(); + expect(isoOrNull("not a date")).toBeNull(); + }); +}); diff --git a/apps/status-page/src/content/status-vocab.ts b/apps/status-page/src/content/status-vocab.ts new file mode 100644 index 00000000..67c7091d --- /dev/null +++ b/apps/status-page/src/content/status-vocab.ts @@ -0,0 +1,90 @@ +/** + * Maps openstatus's internal status vocabulary onto Atlassian Statuspage's + * de-facto public schema, so agents and tooling that already know that shape + * work against an openstatus page zero-shot. `page.status` and component + * `status` are both `success|degraded|error|info` in @openstatus/db; the + * component-impact enum (`operational|degraded_performance|…`) already matches + * Statuspage, so only the live rollup needs translating. + */ + +export type PageStatus = "success" | "degraded" | "error" | "info"; + +const PAGE_INDICATOR: Record< + PageStatus, + { indicator: string; description: string } +> = { + success: { indicator: "none", description: "All Systems Operational" }, + degraded: { indicator: "minor", description: "Degraded Performance" }, + error: { indicator: "major", description: "Major Outage" }, + info: { indicator: "maintenance", description: "Under Maintenance" }, +}; + +const COMPONENT_STATUS: Record = { + success: "operational", + degraded: "degraded_performance", + error: "major_outage", + info: "under_maintenance", +}; + +const SEVERITY: Record = { + error: 3, + degraded: 2, + info: 1, + success: 0, +}; + +/** Statuspage page-level indicator + human description for a live page status. */ +export function pageIndicator(status: string): { + indicator: string; + description: string; +} { + return PAGE_INDICATOR[status as PageStatus] ?? PAGE_INDICATOR.success; +} + +/** Statuspage component status for a live component status. */ +export function componentStatus(status: string): string { + return COMPONENT_STATUS[status as PageStatus] ?? "operational"; +} + +type TrackerLike = + | { type: "component"; component: { name: string; status: string } } + | { type: "group"; components: { name: string; status: string }[] }; + +/** Flatten the tracker tree (components + grouped components) to a flat list. */ +export function flattenComponents( + trackers: TrackerLike[] | null | undefined, +): { name: string; status: string }[] { + const out: { name: string; status: string }[] = []; + for (const t of trackers ?? []) { + if (t.type === "component") { + out.push({ name: t.component.name, status: t.component.status }); + } else { + for (const c of t.components) + out.push({ name: c.name, status: c.status }); + } + } + return out; +} + +/** Name of the most severely impacted component, or null if all operational. */ +export function worstComponent( + components: { name: string; status: string }[], +): string | null { + let worst: { name: string; rank: number } | null = null; + for (const c of components) { + const rank = SEVERITY[c.status as PageStatus] ?? 0; + if (rank > 0 && (worst === null || rank > worst.rank)) { + worst = { name: c.name, rank }; + } + } + return worst?.name ?? null; +} + +/** ISO-8601 (RFC3339) timestamp, or null — for machine-facing JSON. */ +export function isoOrNull( + date: Date | string | number | null | undefined, +): string | null { + if (date === null || date === undefined) return null; + const d = date instanceof Date ? date : new Date(date); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} diff --git a/apps/status-page/src/lib/alternates-metadata.ts b/apps/status-page/src/lib/alternates-metadata.ts new file mode 100644 index 00000000..ae4d6655 --- /dev/null +++ b/apps/status-page/src/lib/alternates-metadata.ts @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; + +import { statusPageAlternates } from "@/lib/alternates"; +import { getQueryClient, trpc } from "@/lib/trpc/server"; + +export async function statusPageAlternatesMetadata({ + domain, + markdownPath, +}: { + domain: string; + markdownPath?: string; +}): Promise { + const page = await getQueryClient().fetchQuery( + trpc.statusPage.get.queryOptions({ slug: domain }), + ); + if (!page) return {}; + return { + alternates: statusPageAlternates({ + slug: page.slug, + customDomain: page.customDomain, + markdownPath, + }), + }; +} diff --git a/apps/status-page/src/lib/alternates.test.ts b/apps/status-page/src/lib/alternates.test.ts new file mode 100644 index 00000000..900502ee --- /dev/null +++ b/apps/status-page/src/lib/alternates.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; + +import { statusPageAlternates } from "./alternates"; + +describe("statusPageAlternates", () => { + test("subdomain, default markdown path → overview", () => { + expect(statusPageAlternates({ slug: "acme" })).toEqual({ + canonical: "https://acme.openstatus.dev", + types: { + "text/markdown": "https://acme.openstatus.dev/.md", + "application/json": + "https://acme.openstatus.dev/api/status/summary.json", + }, + }); + }); + + test("custom domain wins over subdomain", () => { + expect( + statusPageAlternates({ slug: "acme", customDomain: "status.acme.com" }), + ).toEqual({ + canonical: "https://status.acme.com", + types: { + "text/markdown": "https://status.acme.com/.md", + "application/json": "https://status.acme.com/api/status/summary.json", + }, + }); + }); + + test("null customDomain falls back to subdomain", () => { + expect(statusPageAlternates({ slug: "acme", customDomain: null })).toEqual({ + canonical: "https://acme.openstatus.dev", + types: { + "text/markdown": "https://acme.openstatus.dev/.md", + "application/json": + "https://acme.openstatus.dev/api/status/summary.json", + }, + }); + }); + + test("per-page markdown path, subdomain", () => { + const result = statusPageAlternates({ + slug: "acme", + markdownPath: "/monitors/123.md", + }); + expect(result?.types?.["text/markdown"]).toBe( + "https://acme.openstatus.dev/monitors/123.md", + ); + // canonical and json alternate stay at the page root regardless of md path + expect(result?.canonical).toBe("https://acme.openstatus.dev"); + expect(result?.types?.["application/json"]).toBe( + "https://acme.openstatus.dev/api/status/summary.json", + ); + }); + + test("per-page markdown path, custom domain", () => { + expect( + statusPageAlternates({ + slug: "acme", + customDomain: "status.acme.com", + markdownPath: "/events/report/7.md", + })?.types?.["text/markdown"], + ).toBe("https://status.acme.com/events/report/7.md"); + }); +}); diff --git a/apps/status-page/src/lib/alternates.ts b/apps/status-page/src/lib/alternates.ts new file mode 100644 index 00000000..6d679dab --- /dev/null +++ b/apps/status-page/src/lib/alternates.ts @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; + +function host({ + slug, + customDomain, +}: { + slug: string; + customDomain?: string | null; +}) { + return customDomain + ? `https://${customDomain}` + : `https://${slug}.openstatus.dev`; +} + +// Next.js merges `alternates` shallowly, so a deeper segment that sets it +// replaces the parent's entirely — always return the full object. +export function statusPageAlternates({ + slug, + customDomain, + markdownPath = "/.md", +}: { + slug: string; + customDomain?: string | null; + markdownPath?: string; +}): Metadata["alternates"] { + const base = host({ slug, customDomain }); + return { + canonical: base, + types: { + "text/markdown": `${base}${markdownPath}`, + "application/json": `${base}/api/status/summary.json`, + }, + }; +} diff --git a/apps/status-page/src/lib/domain.ts b/apps/status-page/src/lib/domain.ts index 6dbc9351..7d3fa2e3 100644 --- a/apps/status-page/src/lib/domain.ts +++ b/apps/status-page/src/lib/domain.ts @@ -1,5 +1,10 @@ import type { NextRequest } from "next/server"; +// Custom-domain lookups exact-match page.customDomain, which is stored without a +// port; an inbound host like "status.acme.com:8080" must be normalized first. +export const stripHostPort = (host?: string | null) => + host ? host.replace(/:\d+$/, "") : (host ?? null); + export const getValidSubdomain = (host?: string | null) => { let subdomain: string | null = null; if (!host && typeof window !== "undefined") { diff --git a/apps/status-page/src/lib/http/client-ip.test.ts b/apps/status-page/src/lib/http/client-ip.test.ts new file mode 100644 index 00000000..684c07a5 --- /dev/null +++ b/apps/status-page/src/lib/http/client-ip.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; + +import { resolveClientIp } from "./client-ip"; + +function h(map: Record) { + return { get: (name: string) => map[name] ?? null }; +} + +describe("resolveClientIp", () => { + test("prefers x-real-ip over x-forwarded-for", () => { + expect( + resolveClientIp( + h({ "x-real-ip": "9.9.9.9", "x-forwarded-for": "1.2.3.4" }), + ), + ).toBe("9.9.9.9"); + }); + + test("ignores a spoofed leftmost x-forwarded-for when x-real-ip is present", () => { + // Attacker injects an allowed IP at the head of the chain; x-real-ip wins. + expect( + resolveClientIp( + h({ + "x-real-ip": "203.0.113.7", + "x-forwarded-for": "10.0.0.1, 203.0.113.7", + }), + ), + ).toBe("203.0.113.7"); + }); + + test("falls back to leftmost x-forwarded-for when x-real-ip is absent", () => { + expect(resolveClientIp(h({ "x-forwarded-for": "1.2.3.4, 5.6.7.8" }))).toBe( + "1.2.3.4", + ); + }); + + test("returns null when neither header is present", () => { + expect(resolveClientIp(h({}))).toBeNull(); + }); +}); diff --git a/apps/status-page/src/lib/http/client-ip.ts b/apps/status-page/src/lib/http/client-ip.ts new file mode 100644 index 00000000..2f346c07 --- /dev/null +++ b/apps/status-page/src/lib/http/client-ip.ts @@ -0,0 +1,15 @@ +/** + * Client IP for access-gate checks. Prefer `x-real-ip`: Vercel's edge sets it to + * the single verified client IP, with no comma-chain to parse. `x-forwarded-for`'s + * leftmost entry is the classic spoof vector on paths that reach a route directly + * — `/api/*` bypasses the proxy middleware — so only fall back to it when + * `x-real-ip` is absent (e.g. local dev). A null result denies ip-restricted pages. + */ +export function resolveClientIp(headers: { + get(name: string): string | null; +}): string | null { + const real = headers.get("x-real-ip"); + if (real) return real.trim(); + const xff = headers.get("x-forwarded-for"); + return xff?.split(",")[0]?.trim() ?? null; +} diff --git a/apps/status-page/src/lib/http/etag.test.ts b/apps/status-page/src/lib/http/etag.test.ts new file mode 100644 index 00000000..66e86bd8 --- /dev/null +++ b/apps/status-page/src/lib/http/etag.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; + +import { computeETag, isNotModified } from "./etag"; + +function withINM(value: string | null): Request { + return new Request("https://acme.openstatus.dev/.md", { + headers: value ? { "if-none-match": value } : {}, + }); +} + +describe("computeETag", () => { + test("quoted, stable for the same body", () => { + const etag = computeETag("# Status"); + expect(etag).toMatch(/^"[0-9a-f]{64}"$/); + expect(computeETag("# Status")).toBe(etag); + }); + + test("differs when the body differs", () => { + expect(computeETag("a")).not.toBe(computeETag("b")); + }); +}); + +describe("isNotModified", () => { + test("no If-None-Match → false", () => { + expect(isNotModified(withINM(null), computeETag("x"))).toBe(false); + }); + + test("exact match → true", () => { + const etag = computeETag("x"); + expect(isNotModified(withINM(etag), etag)).toBe(true); + }); + + test("non-matching tag → false", () => { + expect(isNotModified(withINM(computeETag("x")), computeETag("y"))).toBe( + false, + ); + }); + + test("matches one tag in a comma-separated list with whitespace", () => { + const etag = computeETag("x"); + expect(isNotModified(withINM(`"deadbeef", ${etag} , "cafe"`), etag)).toBe( + true, + ); + }); + + test("wildcard `*` matches any current ETag", () => { + expect(isNotModified(withINM("*"), computeETag("x"))).toBe(true); + expect(isNotModified(withINM(" * "), computeETag("x"))).toBe(true); + }); +}); diff --git a/apps/status-page/src/lib/http/etag.ts b/apps/status-page/src/lib/http/etag.ts new file mode 100644 index 00000000..b7c4b396 --- /dev/null +++ b/apps/status-page/src/lib/http/etag.ts @@ -0,0 +1,15 @@ +import { createHash } from "node:crypto"; + +/** Strong ETag from a content hash. Node runtime only (uses node:crypto). */ +export function computeETag(body: string): string { + return `"${createHash("sha256").update(body).digest("hex")}"`; +} + +/** Whether the request's If-None-Match matches the ETag — i.e. serve a 304. */ +export function isNotModified(request: Request, etag: string): boolean { + const header = request.headers.get("if-none-match"); + if (!header) return false; + // RFC 7232 §3.2: `*` matches any current representation. + if (header.trim() === "*") return true; + return header.split(",").some((tag) => tag.trim() === etag); +} diff --git a/apps/status-page/src/lib/http/markdown-response.test.ts b/apps/status-page/src/lib/http/markdown-response.test.ts new file mode 100644 index 00000000..c519398e --- /dev/null +++ b/apps/status-page/src/lib/http/markdown-response.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; + +import { withPoweredBy } from "@/content/markdown"; +import { computeETag } from "@/lib/http/etag"; +import { resolveMarkdownResponse } from "@/lib/http/markdown-response"; + +const PUBLIC_CACHE = + "public, max-age=60, s-maxage=60, stale-while-revalidate=300"; +const NO_STORE = "private, no-store"; + +function req(ifNoneMatch?: string): Request { + return new Request("https://acme.openstatus.dev/.md", { + headers: ifNoneMatch ? { "if-none-match": ifNoneMatch } : {}, + }); +} + +describe("resolveMarkdownResponse", () => { + test("200: appends powered-by footer, markdown content-type, Vary: Accept", () => { + const res = resolveMarkdownResponse(req(), { + body: "# Status", + source: "suffix", + whiteLabel: false, + accessType: "public", + }); + expect(res.status).toBe(200); + expect(res.body).toBe(withPoweredBy("# Status", false)); + expect(res.body).toContain("Powered by"); + expect(res.headers["Content-Type"]).toBe("text/markdown; charset=utf-8"); + expect(res.headers.Vary).toBe("Accept"); + expect(res.headers.ETag).toMatch(/^"[0-9a-f]{64}"$/); + }); + + test("ETag is hashed over the footer-appended body, not the raw body", () => { + const res = resolveMarkdownResponse(req(), { + body: "# Status", + source: "suffix", + whiteLabel: false, + accessType: "public", + }); + expect(res.headers.ETag).toBe( + computeETag(withPoweredBy("# Status", false)), + ); + }); + + test("white-labeled: no footer, and a different ETag", () => { + const plain = resolveMarkdownResponse(req(), { + body: "# Status", + source: "suffix", + whiteLabel: false, + accessType: "public", + }); + const wl = resolveMarkdownResponse(req(), { + body: "# Status", + source: "suffix", + whiteLabel: true, + accessType: "public", + }); + expect(wl.body).toBe("# Status"); + expect(wl.body).not.toContain("Powered by"); + expect(wl.headers.ETag).not.toBe(plain.headers.ETag); + }); + + test("304 when If-None-Match matches the footer-appended ETag, body null", () => { + const etag = computeETag(withPoweredBy("# Status", false)); + const res = resolveMarkdownResponse(req(etag), { + body: "# Status", + source: "suffix", + whiteLabel: false, + accessType: "public", + }); + expect(res.status).toBe(304); + expect(res.body).toBeNull(); + // Cache + ETag + Vary still ride along on the 304. + expect(res.headers.ETag).toBe(etag); + expect(res.headers.Vary).toBe("Accept"); + expect(res.headers["Cache-Control"]).toBe(PUBLIC_CACHE); + }); + + test("public .md suffix is edge-cacheable", () => { + const res = resolveMarkdownResponse(req(), { + body: "x", + source: "suffix", + whiteLabel: false, + accessType: "public", + }); + expect(res.headers["Cache-Control"]).toBe(PUBLIC_CACHE); + }); + + test("gated page is never edge-cacheable, even via .md suffix", () => { + const res = resolveMarkdownResponse(req(), { + body: "x", + source: "suffix", + whiteLabel: false, + accessType: "password", + }); + expect(res.headers["Cache-Control"]).toBe(NO_STORE); + }); + + test("header-negotiated public page is not edge-cached", () => { + const res = resolveMarkdownResponse(req(), { + body: "x", + source: "header", + whiteLabel: false, + accessType: "public", + }); + expect(res.headers["Cache-Control"]).toBe(NO_STORE); + }); +}); diff --git a/apps/status-page/src/lib/http/markdown-response.ts b/apps/status-page/src/lib/http/markdown-response.ts new file mode 100644 index 00000000..ae27ed3e --- /dev/null +++ b/apps/status-page/src/lib/http/markdown-response.ts @@ -0,0 +1,39 @@ +import { withPoweredBy } from "@/content/markdown"; +import { computeETag, isNotModified } from "@/lib/http/etag"; +import { markdownCacheControl } from "@/lib/proxy/markdown-cache-control"; + +const MARKDOWN = "text/markdown; charset=utf-8"; + +export interface MarkdownResponseInit { + body: string; + source: string | null; + whiteLabel: boolean; + accessType: string | null | undefined; +} + +export interface ResolvedMarkdownResponse { + status: number; + body: string | null; + headers: Record; +} + +// Pure response shaping: append the powered-by footer, hash a strong ETag, and +// return a 304 (null body) when If-None-Match matches. `Vary: Accept` always — +// the same URL serves HTML or markdown by negotiation, so caches must split. +export function resolveMarkdownResponse( + request: Request, + { body, source, whiteLabel, accessType }: MarkdownResponseInit, +): ResolvedMarkdownResponse { + const finalBody = withPoweredBy(body, whiteLabel); + const etag = computeETag(finalBody); + const headers = { + "Content-Type": MARKDOWN, + "Cache-Control": markdownCacheControl(source, accessType), + ETag: etag, + Vary: "Accept", + }; + if (isNotModified(request, etag)) { + return { status: 304, body: null, headers }; + } + return { status: 200, body: finalBody, headers }; +} diff --git a/apps/status-page/src/lib/proxy/access-predicates.ts b/apps/status-page/src/lib/proxy/access-predicates.ts new file mode 100644 index 00000000..a73c70cc --- /dev/null +++ b/apps/status-page/src/lib/proxy/access-predicates.ts @@ -0,0 +1,62 @@ +import { isIpAllowed } from "./is-ip-allowed"; + +/** + * Pure allow/deny predicates shared by the proxy gate chain (which wraps them in + * redirects) and the markdown route gate (which wraps them in status codes). + * Keeping the authorization core here is the guarantee that the two surfaces + * cannot drift — only their wrapping behaviour differs. + */ + +// Length-independent comparison so a wrong guess can't be timed by length or +// character. Pure JS (no node:crypto): must be Edge-safe for the proxy, so it +// can't import the twin in packages/api (not in the middleware bundle) — hence +// the duplication; keep both implementations in sync. +function constantTimeEqual( + a: string | null | undefined, + b: string | null | undefined, +): boolean { + if (a == null || b == null) return false; + // Iterate over the max length and fold the length delta into the accumulator + // so we never early-return or branch on length. + const max = Math.max(a.length, b.length); + let mismatch = a.length ^ b.length; + for (let i = 0; i < max; i++) { + // out-of-range indices read as 0; mismatch already non-zero on length diff. + mismatch |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); + } + return mismatch === 0; +} + +/** + * Stored page password vs submitted. The query param wins over the cookie (a + * present-but-wrong `?pw=` must not fall through to a valid cookie), matching + * the proxy's established behaviour. Empty/absent stored never authorizes. + */ +export function isPasswordAuthorized(input: { + stored: string | null | undefined; + queryPassword: string | null | undefined; + cookiePassword: string | null | undefined; +}): boolean { + if (!input.stored) return false; + const submitted = input.queryPassword ?? input.cookiePassword; + return constantTimeEqual(submitted, input.stored); +} + +/** Authenticated email's domain is in the page's allow-list. */ +export function isEmailDomainAuthorized( + authEmail: string | null | undefined, + authEmailDomains: string[] | null | undefined, +): boolean { + // DNS domains are case-insensitive — normalise both sides before matching. + const domain = authEmail?.split("@")[1]?.toLowerCase(); + const allowed = (authEmailDomains ?? []).map((d) => d.toLowerCase()); + return !!(domain && allowed.includes(domain)); +} + +/** Client IP falls within one of the page's allowed CIDR ranges. */ +export function isIpAuthorized( + clientIp: string | null | undefined, + allowedIpRanges: string[] | null | undefined, +): boolean { + return !!(clientIp && isIpAllowed(clientIp, allowedIpRanges ?? [])); +} diff --git a/apps/status-page/src/lib/proxy/detect-markdown.test.ts b/apps/status-page/src/lib/proxy/detect-markdown.test.ts new file mode 100644 index 00000000..10dbc1f9 --- /dev/null +++ b/apps/status-page/src/lib/proxy/detect-markdown.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; + +import { detectMarkdown } from "./detect-markdown"; + +describe("detectMarkdown", () => { + test(".md suffix stripped, source suffix", () => { + expect( + detectMarkdown({ pathname: "/monitors/123.md", accept: null }), + ).toEqual({ + wantsMarkdown: true, + source: "suffix", + pathname: "/monitors/123", + }); + }); + + test("root /.md → /", () => { + expect(detectMarkdown({ pathname: "/.md", accept: null })).toEqual({ + wantsMarkdown: true, + source: "suffix", + pathname: "/", + }); + }); + + test("Accept: text/markdown → source header, pathname untouched", () => { + expect( + detectMarkdown({ pathname: "/monitors", accept: "text/markdown" }), + ).toEqual({ wantsMarkdown: true, source: "header", pathname: "/monitors" }); + }); + + test("case-insensitive + multi-type Accept", () => { + expect( + detectMarkdown({ + pathname: "/", + accept: "text/html, text/Markdown;q=0.9", + }), + ).toEqual({ wantsMarkdown: true, source: "header", pathname: "/" }); + }); + + test("plain HTML accept → not markdown", () => { + expect( + detectMarkdown({ pathname: "/monitors", accept: "text/html" }), + ).toEqual({ wantsMarkdown: false, source: null, pathname: "/monitors" }); + }); + + test("no accept header → not markdown", () => { + expect(detectMarkdown({ pathname: "/events", accept: null })).toEqual({ + wantsMarkdown: false, + source: null, + pathname: "/events", + }); + }); + + test(".md that also matches Accept resolves to suffix", () => { + expect( + detectMarkdown({ pathname: "/monitors/1.md", accept: "text/markdown" }), + ).toEqual({ + wantsMarkdown: true, + source: "suffix", + pathname: "/monitors/1", + }); + }); + + test("partial token (text/markdownish) does not match", () => { + expect( + detectMarkdown({ pathname: "/", accept: "text/markdownish" }), + ).toEqual({ wantsMarkdown: false, source: null, pathname: "/" }); + }); +}); diff --git a/apps/status-page/src/lib/proxy/detect-markdown.ts b/apps/status-page/src/lib/proxy/detect-markdown.ts new file mode 100644 index 00000000..27e649b0 --- /dev/null +++ b/apps/status-page/src/lib/proxy/detect-markdown.ts @@ -0,0 +1,42 @@ +export interface DetectMarkdownResult { + wantsMarkdown: boolean; + source: "suffix" | "header" | null; + /** Pathname with a trailing `.md` stripped, so `resolveRoute` runs unchanged. */ + pathname: string; +} + +function acceptsMarkdown(accept: string | null): boolean { + if (!accept) return false; + // Match the media type as a whole token, tolerating params/q-values and casing. + return accept + .toLowerCase() + .split(",") + .some((part) => part.trim().split(";")[0].trim() === "text/markdown"); +} + +/** + * Proxy-side detection of a markdown request. The `.md` suffix wins over the + * Accept header so a `.md` URL keeps the cache-safe suffix policy. + */ +export function detectMarkdown(input: { + pathname: string; + accept: string | null; +}): DetectMarkdownResult { + const { pathname, accept } = input; + + if (pathname.endsWith(".md")) { + const stripped = pathname.slice(0, -".md".length); + // `/.md` (root) → `/`; otherwise keep the stripped path. + return { + wantsMarkdown: true, + source: "suffix", + pathname: stripped === "" ? "/" : stripped, + }; + } + + if (acceptsMarkdown(accept)) { + return { wantsMarkdown: true, source: "header", pathname }; + } + + return { wantsMarkdown: false, source: null, pathname }; +} diff --git a/apps/status-page/src/lib/proxy/evaluate-markdown-gate.test.ts b/apps/status-page/src/lib/proxy/evaluate-markdown-gate.test.ts new file mode 100644 index 00000000..02c7f46b --- /dev/null +++ b/apps/status-page/src/lib/proxy/evaluate-markdown-gate.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; + +import { evaluateMarkdownGate } from "./evaluate-markdown-gate"; + +const base = { + passwordAuthorized: false, + authEmail: null, + authEmailDomains: null, + clientIp: null, + allowedIpRanges: null, +} as const; + +describe("evaluateMarkdownGate", () => { + test("public → ok", () => { + expect(evaluateMarkdownGate({ ...base, accessType: "public" })).toEqual({ + ok: true, + }); + }); + + describe("password", () => { + test("authorized → ok", () => { + expect( + evaluateMarkdownGate({ + ...base, + accessType: "password", + passwordAuthorized: true, + }), + ).toEqual({ ok: true }); + }); + + test("not authorized → 401", () => { + expect( + evaluateMarkdownGate({ + ...base, + accessType: "password", + passwordAuthorized: false, + }), + ).toMatchObject({ ok: false, status: 401 }); + }); + }); + + describe("email-domain", () => { + test("allowed domain → ok", () => { + expect( + evaluateMarkdownGate({ + ...base, + accessType: "email-domain", + authEmail: "alice@acme.com", + authEmailDomains: ["acme.com"], + }), + ).toEqual({ ok: true }); + }); + + test("no session → 403", () => { + expect( + evaluateMarkdownGate({ + ...base, + accessType: "email-domain", + authEmailDomains: ["acme.com"], + }), + ).toMatchObject({ ok: false, status: 403 }); + }); + + test("wrong domain → 403", () => { + expect( + evaluateMarkdownGate({ + ...base, + accessType: "email-domain", + authEmail: "bob@evil.com", + authEmailDomains: ["acme.com"], + }), + ).toMatchObject({ ok: false, status: 403 }); + }); + + test("empty authEmailDomains → 403", () => { + expect( + evaluateMarkdownGate({ + ...base, + accessType: "email-domain", + authEmail: "alice@acme.com", + authEmailDomains: [], + }), + ).toMatchObject({ ok: false, status: 403 }); + }); + }); + + describe("ip-restriction", () => { + test("allowed IP → ok", () => { + expect( + evaluateMarkdownGate({ + ...base, + accessType: "ip-restriction", + clientIp: "10.0.0.5", + allowedIpRanges: ["10.0.0.0/24"], + }), + ).toEqual({ ok: true }); + }); + + test("disallowed IP → 403", () => { + expect( + evaluateMarkdownGate({ + ...base, + accessType: "ip-restriction", + clientIp: "192.168.1.1", + allowedIpRanges: ["10.0.0.0/24"], + }), + ).toMatchObject({ ok: false, status: 403 }); + }); + + test("missing IP → 403 (gap the feed route has, asserted closed)", () => { + expect( + evaluateMarkdownGate({ + ...base, + accessType: "ip-restriction", + allowedIpRanges: ["10.0.0.0/24"], + }), + ).toMatchObject({ ok: false, status: 403 }); + }); + }); +}); diff --git a/apps/status-page/src/lib/proxy/evaluate-markdown-gate.ts b/apps/status-page/src/lib/proxy/evaluate-markdown-gate.ts new file mode 100644 index 00000000..8847e9ed --- /dev/null +++ b/apps/status-page/src/lib/proxy/evaluate-markdown-gate.ts @@ -0,0 +1,48 @@ +import type { Page } from "@openstatus/db/src/schema"; + +import { isEmailDomainAuthorized, isIpAuthorized } from "./access-predicates"; + +export type MarkdownGateResult = + | { ok: true } + | { ok: false; status: 401 | 403; body: string }; + +/** + * Pure access-control decision for a markdown request, mirroring the proxy gate + * chain. Security-critical: the markdown route is reachable directly via `/api`, + * which bypasses the proxy, so this is the only enforcement on that path. + * + * `passwordAuthorized` is resolved server-side (via `statusPage.isPasswordAuthorized`) + * so the stored password never reaches this surface. + */ +export function evaluateMarkdownGate(input: { + accessType: Page["accessType"]; + passwordAuthorized: boolean; + authEmail: string | null | undefined; + authEmailDomains: string[] | null; + clientIp: string | null | undefined; + allowedIpRanges: string[] | null; +}): MarkdownGateResult { + switch (input.accessType) { + case "public": + return { ok: true }; + + case "password": + return input.passwordAuthorized + ? { ok: true } + : { ok: false, status: 401, body: "Unauthorized" }; + + case "email-domain": + return isEmailDomainAuthorized(input.authEmail, input.authEmailDomains) + ? { ok: true } + : { ok: false, status: 403, body: "Forbidden" }; + + case "ip-restriction": + return isIpAuthorized(input.clientIp, input.allowedIpRanges) + ? { ok: true } + : { ok: false, status: 403, body: "Forbidden" }; + + default: + // Unknown access type → deny. + return { ok: false, status: 403, body: "Forbidden" }; + } +} diff --git a/apps/status-page/src/lib/proxy/markdown-cache-control.test.ts b/apps/status-page/src/lib/proxy/markdown-cache-control.test.ts new file mode 100644 index 00000000..9b6f9053 --- /dev/null +++ b/apps/status-page/src/lib/proxy/markdown-cache-control.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; + +import { markdownCacheControl } from "./markdown-cache-control"; + +const PUBLIC_CACHE = + "public, max-age=60, s-maxage=60, stale-while-revalidate=300"; +const NO_STORE = "private, no-store"; + +describe("markdownCacheControl", () => { + test("public page via .md suffix is edge-cacheable", () => { + expect(markdownCacheControl("suffix", "public")).toBe(PUBLIC_CACHE); + }); + + test("gated pages are never cacheable, even via .md suffix", () => { + for (const accessType of [ + "password", + "email-domain", + "ip-restriction", + ] as const) { + expect(markdownCacheControl("suffix", accessType)).toBe(NO_STORE); + } + }); + + test("header-negotiated requests are never edge-cached", () => { + expect(markdownCacheControl("header", "public")).toBe(NO_STORE); + }); + + test("unknown/absent access type is not cacheable", () => { + expect(markdownCacheControl("suffix", null)).toBe(NO_STORE); + expect(markdownCacheControl("suffix", undefined)).toBe(NO_STORE); + }); +}); diff --git a/apps/status-page/src/lib/proxy/markdown-cache-control.ts b/apps/status-page/src/lib/proxy/markdown-cache-control.ts new file mode 100644 index 00000000..43bf49a9 --- /dev/null +++ b/apps/status-page/src/lib/proxy/markdown-cache-control.ts @@ -0,0 +1,14 @@ +/** + * Cache-Control for a markdown response. Only PUBLIC pages served via the `.md` + * suffix are edge-cacheable; gated pages (password/email-domain/ip-restriction) + * must always be `private, no-store` — a `.md` suffix is not a license to cache + * authorized content where the CDN could serve it to an unauthorized request. + */ +export function markdownCacheControl( + source: string | null, + accessType: string | null | undefined, +): string { + return source === "suffix" && accessType === "public" + ? "public, max-age=60, s-maxage=60, stale-while-revalidate=300" + : "private, no-store"; +} diff --git a/apps/status-page/src/lib/proxy/resolve-email-domain-action.ts b/apps/status-page/src/lib/proxy/resolve-email-domain-action.ts index 8ee116d9..99c8b60b 100644 --- a/apps/status-page/src/lib/proxy/resolve-email-domain-action.ts +++ b/apps/status-page/src/lib/proxy/resolve-email-domain-action.ts @@ -1,5 +1,6 @@ import type { Page } from "@openstatus/db/src/schema"; +import { isEmailDomainAuthorized } from "./access-predicates"; import { buildExternalPath } from "./build-external-path"; import type { Action, ComposeInput } from "./types"; @@ -28,9 +29,9 @@ export function resolveEmailDomainAction({ }: Input): Action | null { if (page.accessType !== "email-domain") return null; - const emailDomain = authEmail?.split("@")[1]; - const isAuthorised = !!( - emailDomain && page.authEmailDomains.includes(emailDomain) + const isAuthorised = isEmailDomainAuthorized( + authEmail, + page.authEmailDomains, ); const isOnLogin = pathname.endsWith("/login"); diff --git a/apps/status-page/src/lib/proxy/resolve-gate.ts b/apps/status-page/src/lib/proxy/resolve-gate.ts new file mode 100644 index 00000000..7e56564e --- /dev/null +++ b/apps/status-page/src/lib/proxy/resolve-gate.ts @@ -0,0 +1,54 @@ +import type { Page } from "@openstatus/db/src/schema"; + +import { auth } from "@/lib/auth"; +import { createProtectedCookieKey } from "@/lib/protected"; +import type { getQueryClient } from "@/lib/trpc/server"; +import { trpc } from "@/lib/trpc/server"; + +import { + evaluateMarkdownGate, + type MarkdownGateResult, +} from "./evaluate-markdown-gate"; + +export type GatePage = { + accessType: Page["accessType"]; + authEmailDomains: string[] | null; + allowedIpRanges: string[] | null; + slug: string; +}; + +/** + * Server-side orchestration around `evaluateMarkdownGate`: resolves the session + * (email-domain pages) and password authorization (password pages) before + * delegating to the pure decision. Shared by the markdown and status JSON API + * routes, both reachable directly via `/api` — bypassing the proxy gate. + */ +export async function resolveGate(args: { + page: GatePage; + queryClient: ReturnType; + url: URL; + cookieStore: { get(name: string): { value: string } | undefined }; + clientIp: string | null | undefined; +}): Promise { + const { page, queryClient, url, cookieStore, clientIp } = args; + const session = page.accessType === "email-domain" ? await auth() : null; + const passwordAuthorized = + page.accessType === "password" + ? await queryClient.fetchQuery( + trpc.statusPage.isPasswordAuthorized.queryOptions({ + slug: page.slug, + queryPassword: url.searchParams.get("pw"), + cookiePassword: cookieStore.get(createProtectedCookieKey(page.slug)) + ?.value, + }), + ) + : false; + return evaluateMarkdownGate({ + accessType: page.accessType, + passwordAuthorized, + authEmail: session?.user?.email, + authEmailDomains: page.authEmailDomains, + clientIp, + allowedIpRanges: page.allowedIpRanges, + }); +} diff --git a/apps/status-page/src/lib/proxy/resolve-ip-restriction-action.ts b/apps/status-page/src/lib/proxy/resolve-ip-restriction-action.ts index 59ffa5b1..494e27b1 100644 --- a/apps/status-page/src/lib/proxy/resolve-ip-restriction-action.ts +++ b/apps/status-page/src/lib/proxy/resolve-ip-restriction-action.ts @@ -1,7 +1,7 @@ import type { Page } from "@openstatus/db/src/schema"; +import { isIpAuthorized } from "./access-predicates"; import { buildExternalPath } from "./build-external-path"; -import { isIpAllowed } from "./is-ip-allowed"; import type { Action, ComposeInput } from "./types"; type Input = Pick< @@ -27,7 +27,7 @@ export function resolveIpRestrictionAction({ }: Input): Action | null { if (page.accessType !== "ip-restriction") return null; - const allowed = !!(clientIp && isIpAllowed(clientIp, page.allowedIpRanges)); + const allowed = isIpAuthorized(clientIp, page.allowedIpRanges); const isOnRestricted = pathname.endsWith("/restricted"); // Gate-in: disallowed and not already on /restricted → send to /restricted diff --git a/apps/status-page/src/lib/proxy/resolve-password-action.ts b/apps/status-page/src/lib/proxy/resolve-password-action.ts index f8357a71..07f46ce8 100644 --- a/apps/status-page/src/lib/proxy/resolve-password-action.ts +++ b/apps/status-page/src/lib/proxy/resolve-password-action.ts @@ -1,5 +1,6 @@ import type { Page } from "@openstatus/db/src/schema"; +import { isPasswordAuthorized } from "./access-predicates"; import { buildExternalPath } from "./build-external-path"; import type { Action, ComposeInput } from "./types"; @@ -43,15 +44,17 @@ export function resolvePasswordAction({ }: Input): Action | null { if (page.accessType !== "password") return null; - // ?? rather than ||: an empty-string `?pw=` shouldn't silently fall through - // to the cookie (the query param is "present, empty" — not absent). - const password = queryPassword ?? cookiePassword; + const authorized = isPasswordAuthorized({ + stored: page.password, + queryPassword, + cookiePassword, + }); const isOnLogin = pathname.endsWith("/login"); const needsCustomDomainRedirect = !isSelfHosted && !!page.customDomain && host !== `${page.slug}.stpg.dev`; // Gate-in: wrong password and not already on /login → send to login - if (password !== page.password && !isOnLogin) { + if (!authorized && !isOnLogin) { if (needsCustomDomainRedirect) { const leading = `/${page.customDomain}`; const redirect = pathname.startsWith(leading) @@ -75,7 +78,7 @@ export function resolvePasswordAction({ } // Gate-out: correct password and on /login → send to page root - if (password === page.password && isOnLogin) { + if (authorized && isOnLogin) { if (needsCustomDomainRedirect) { return { type: "redirect", diff --git a/apps/status-page/src/proxy.ts b/apps/status-page/src/proxy.ts index cddbab15..ab8511f2 100644 --- a/apps/status-page/src/proxy.ts +++ b/apps/status-page/src/proxy.ts @@ -3,10 +3,12 @@ import { page, selectPageSchema } from "@openstatus/db/src/schema"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; +import { resolveClientIp } from "@/lib/http/client-ip"; import { createProtectedCookieKey } from "./lib/protected"; import { applyPageLocaleOverride } from "./lib/proxy/apply-page-locale-override"; import { composePageAction } from "./lib/proxy/compose-page-action"; +import { detectMarkdown } from "./lib/proxy/detect-markdown"; import { sanitizeRedirectParam } from "./lib/proxy/sanitize-redirect-param"; import { resolveRoute } from "./lib/resolve-route"; @@ -15,18 +17,42 @@ const isSelfHosted = process.env.SELF_HOST === "true"; export default auth(async (req) => { const url = req.nextUrl.clone(); const passthroughResponse = NextResponse.next(); + // HTML and markdown share the same URL (negotiated by Accept) — tell shared + // caches to key on it so a markdown variant is never served to a browser. + passthroughResponse.headers.set("Vary", "Accept"); const host = req.headers.get("x-forwarded-host"); + // Strip a `.md` suffix before route resolution so path-based markdown + // (`/foo/en/monitors/123.md`) parses slug/locale correctly. + const { wantsMarkdown, source, pathname } = detectMarkdown({ + pathname: url.pathname, + accept: req.headers.get("accept"), + }); + const initialRoute = resolveRoute({ host, urlHost: url.host, - pathname: url.pathname, + pathname, }); if (!initialRoute) { return passthroughResponse; } + // Markdown requests bypass the proxy's DB lookup and gate chain: the route is + // reachable directly via `/api` anyway, so it re-validates every gate itself. + // Short-circuiting before the gates avoids 307-redirecting a gated `.md` to + // /login (it would never reach the route). + if (wantsMarkdown) { + const rewriteUrl = url.clone(); + rewriteUrl.pathname = `/api/markdown${initialRoute.rewritePath}`; + const requestHeaders = new Headers(req.headers); + requestHeaders.set("x-md-source", source ?? "header"); + return NextResponse.rewrite(rewriteUrl, { + request: { headers: requestHeaders }, + }); + } + const query = await db .select() .from(page) @@ -44,10 +70,7 @@ export default auth(async (req) => { const _page = validation.data; const route = applyPageLocaleOverride(initialRoute, _page); - // Vercel overwrites x-forwarded-for with the verified client IP — not spoofable. - // https://vercel.com/docs/headers/request-headers#x-forwarded-for - const xff = req.headers.get("x-forwarded-for"); - const clientIp = xff?.split(",")[0]?.trim() ?? req.headers.get("x-real-ip"); + const clientIp = resolveClientIp(req.headers); console.log("[proxy] request", { host, @@ -88,8 +111,13 @@ export default auth(async (req) => { switch (action.type) { case "redirect": return NextResponse.redirect(action.url); - case "rewrite": - return NextResponse.rewrite(action.url); + case "rewrite": { + // HTML served via internal rewrite shares its URL with the markdown + // variant — carry the same Vary so caches don't cross them. + const rewriteResponse = NextResponse.rewrite(action.url); + rewriteResponse.headers.set("Vary", "Accept"); + return rewriteResponse; + } case "passthrough": return passthroughResponse; } diff --git a/apps/web/public/assets/posts/status-page-markdown-for-agents/status-page-markdown-for-agents.png b/apps/web/public/assets/posts/status-page-markdown-for-agents/status-page-markdown-for-agents.png new file mode 100644 index 0000000000000000000000000000000000000000..564e63524b8589c2f75406acf80a790f5b45e0a4 GIT binary patch literal 61922 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 z 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 l11mT!jMlT*8WmgqGn_dV+P(8{*#b}~dAj everyone is tokenmaxxing, we are tokenminning + +An agent asking "is acme.com up?" should not have to render React, hunt for the right `
`, and guess what green means. + +But that's all we ever gave it: a status page built for eyeballs. Color, animation, a 60,000-token DOM. Great for a human at 2am. Useless for the LLM that human just asked. + +So we gave the status page a second reader. Same URL. The human gets the page. The agent gets markdown. + +## One URL, two readers + +You don't get a new API. You get the page you already share, in the format the caller asks for: + +```bash +curl https://acme.openstatus.dev/monitors # HTML, for humans +curl -H "Accept: text/markdown" https://acme.openstatus.dev/monitors # markdown +curl https://acme.openstatus.dev/monitors.md # markdown (byte-identical) +``` + +Two ways in - the `Accept` header for agents that set it, the `.md` suffix for everything that can't (a link in a doc, a crawler, you in a terminal at 2am). The suffix wins over the header on purpose: a `.md` URL is a stable cache key, so it keeps the cache-safe path even when a client also sends `Accept`. Everything else carries `Vary: Accept` so a shared cache never hands an agent the human page or a human the agent page. + +We didn't invent this. `Accept: text/markdown` is quietly becoming the handshake - Cloudflare now does [the same negotiation at the edge](https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/) for any site behind it, and calls markdown "the lingua franca for agents." The difference is *where the markdown comes from*. Edge conversion turns your **HTML** into markdown - generic, best-effort, working from whatever DOM it happens to see. We generate markdown from the **data**, which is the whole reason the next part works. + +## The numbers (or: why we bother) + +Same status page, fetched both ways, then tokenized: + +| Request | What you get | Bytes | Tokens | +| --- | --- | --- | --- | +| `GET /status` | HTML | ~166 KB | ~59,500 | +| `GET /status.md` *(or `Accept: text/markdown`)* | markdown | ~4.7 KB | ~1,225 | +| **Shrinks by** | | **~36×** | **~49×** | + +That's a status page going from ~59,500 tokens to ~1,225 - roughly **49× lighter**. The `.md` suffix and the `Accept` header return byte-identical markdown; how you ask doesn't change what you get. The homepage tells the same story: ~181 KB / ~60,000 tokens of HTML collapse to ~8.8 KB / ~2,100 tokens as `/index.md`, about **28× lighter**. + +Either way it's not an optimization, it's a threshold. At ~60k tokens a status page is a real bite out of a context window and an agent will skip it. At ~1k it's basically free to read - and since the answer lives in the frontmatter, the agent often never reads the body at all. + +And notice tokens shrink *more* than bytes do. The status page is a ~36× byte cut (166 KB → 4.7 KB) but a ~49× token cut - because HTML spends tokens on class soup, escaped entities, and inline `