From 69f323f7a35b3310f7ba448ac602147ff2f3a919 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 28 Oct 2026 16:04:42 +0100 Subject: [PATCH 1/7] feat(status-pages): edge Worker and renderers serving snapshots from KV, R2 and cache @maple/status-page renders a page snapshot as server-side HTML, a statuspage.io-style status.json, a shields-style badge, an Atom feed and the break-glass form. All customer text is escaped, and a small inline script ages the "updated" text and delayed banner on cached copies. apps/status is a Worker that only reads edge storage: host or /s/ resolution, then colo cache, KV, R2 and finally any cached copy. It merges the break-glass overlay and judges freshness from the publisher heartbeat. Break-glass posts are rate limited, checked against the token digest in constant time, validated and appended to the KV overlay. The KV namespace and R2 bucket live in apps/status/src/resources.ts for the publisher to bind. A lint override keeps backend, db, ORM and auth imports out of the Worker. --- .oxlintrc.json | 17 ++ alchemy.run.ts | 5 + apps/status/bunfig.toml | 3 + apps/status/package.json | 23 ++ apps/status/src/break-glass.ts | 150 +++++++++++++ apps/status/src/handler.test.ts | 285 ++++++++++++++++++++++++ apps/status/src/handler.ts | 138 ++++++++++++ apps/status/src/ports.ts | 36 +++ apps/status/src/resources.ts | 21 ++ apps/status/src/responses.ts | 70 ++++++ apps/status/src/snapshot.ts | 154 +++++++++++++ apps/status/src/storage.ts | 67 ++++++ apps/status/src/test-support.ts | 80 +++++++ apps/status/src/worker.ts | 76 +++++++ apps/status/tsconfig.json | 24 ++ apps/status/vitest.config.ts | 9 + bun.lock | 33 +++ knip.json | 3 + packages/infra/src/dev-urls.ts | 1 + packages/status-page/bunfig.toml | 3 + packages/status-page/package.json | 23 ++ packages/status-page/src/badge.ts | 28 +++ packages/status-page/src/document.ts | 56 +++++ packages/status-page/src/escape.ts | 24 ++ packages/status-page/src/feed.ts | 20 ++ packages/status-page/src/fixtures.ts | 96 ++++++++ packages/status-page/src/format.ts | 97 ++++++++ packages/status-page/src/index.ts | 7 + packages/status-page/src/json.ts | 76 +++++++ packages/status-page/src/manage.ts | 75 +++++++ packages/status-page/src/page.ts | 144 ++++++++++++ packages/status-page/src/render.test.ts | 231 +++++++++++++++++++ packages/status-page/src/select.ts | 60 +++++ packages/status-page/src/styles.ts | 60 +++++ packages/status-page/tsconfig.json | 23 ++ packages/status-page/vitest.config.ts | 9 + 36 files changed, 2227 insertions(+) create mode 100644 apps/status/bunfig.toml create mode 100644 apps/status/package.json create mode 100644 apps/status/src/break-glass.ts create mode 100644 apps/status/src/handler.test.ts create mode 100644 apps/status/src/handler.ts create mode 100644 apps/status/src/ports.ts create mode 100644 apps/status/src/resources.ts create mode 100644 apps/status/src/responses.ts create mode 100644 apps/status/src/snapshot.ts create mode 100644 apps/status/src/storage.ts create mode 100644 apps/status/src/test-support.ts create mode 100644 apps/status/src/worker.ts create mode 100644 apps/status/tsconfig.json create mode 100644 apps/status/vitest.config.ts create mode 100644 packages/status-page/bunfig.toml create mode 100644 packages/status-page/package.json create mode 100644 packages/status-page/src/badge.ts create mode 100644 packages/status-page/src/document.ts create mode 100644 packages/status-page/src/escape.ts create mode 100644 packages/status-page/src/feed.ts create mode 100644 packages/status-page/src/fixtures.ts create mode 100644 packages/status-page/src/format.ts create mode 100644 packages/status-page/src/index.ts create mode 100644 packages/status-page/src/json.ts create mode 100644 packages/status-page/src/manage.ts create mode 100644 packages/status-page/src/page.ts create mode 100644 packages/status-page/src/render.test.ts create mode 100644 packages/status-page/src/select.ts create mode 100644 packages/status-page/src/styles.ts create mode 100644 packages/status-page/tsconfig.json create mode 100644 packages/status-page/vitest.config.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index ed148c8edd..d19b795be0 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -309,6 +309,23 @@ "excludeFiles": ["**/*.test.ts"], "rules": { "maple/mcp-tracer-anticipated-errors": "error" } }, + // The status Worker must keep serving while Maple is down: no Postgres, auth or backend services. + { + "files": ["apps/status/**/*.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "regex": "^(?:@maple/backend|@maple/db|@maple-dev/effect-orm|@maple/auth|@clerk/)", + "message": "apps/status serves pages while Maple is down; it reads only KV, R2 and the colo cache. Share pure code through @maple/domain or @maple/status-page." + } + ] + } + ] + } + }, // Customer SDK bundle boundaries (were source-grepping architecture tests). Later overrides // replace earlier rule options, so the narrower tiers repeat the broader patterns. { diff --git a/alchemy.run.ts b/alchemy.run.ts index f673f1e624..37c0fdf95b 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -50,6 +50,7 @@ import MapleApi from "./apps/api/src/worker.ts" import MapleSandbox from "./apps/sandbox/alchemy.run.ts" import { createMapleElectric } from "./apps/electric/alchemy.run.ts" import ElectricSync from "./apps/electric-sync/src/worker.ts" +import StatusWorker from "./apps/status/src/worker.ts" import { createMapleIngest } from "./apps/ingest/alchemy.run.ts" import { createMapleScraper } from "./apps/scraper/alchemy.run.ts" import Landing from "./apps/landing/src/worker.ts" @@ -364,6 +365,10 @@ export default Alchemy.Stack( const electricSync = yield* ElectricSync yield* serveWorker("electric-sync", electricSync) + // Public status pages, served from edge storage alone so they outlive a Maple outage. + const statusWorker = yield* StatusWorker + yield* serveWorker("status", statusWorker) + // A vite-source Worker: alchemy owns its build and dev server. Binds api as `API`. const web = yield* Effect.provideService(Web, ApiWorker, api) yield* serveWorker("web", web) diff --git a/apps/status/bunfig.toml b/apps/status/bunfig.toml new file mode 100644 index 0000000000..8e9e807026 --- /dev/null +++ b/apps/status/bunfig.toml @@ -0,0 +1,3 @@ +# Vitest package: `bun test` fails fast here (scripts/bun-test-guard.ts). +[test] +preload = ["../../scripts/bun-test-guard.ts"] diff --git a/apps/status/package.json b/apps/status/package.json new file mode 100644 index 0000000000..2af7b36619 --- /dev/null +++ b/apps/status/package.json @@ -0,0 +1,23 @@ +{ + "name": "@maple/status", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsrs --noEmit" + }, + "dependencies": { + "@alchemy.run/cloudflare": "catalog:alchemy", + "@maple/domain": "workspace:*", + "@maple/infra": "workspace:*", + "@maple/status-page": "workspace:*", + "alchemy": "catalog:alchemy", + "effect": "catalog:effect" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", + "@types/node": "catalog:tooling", + "typescript": "catalog:tooling", + "vitest": "catalog:" + } +} diff --git a/apps/status/src/break-glass.ts b/apps/status/src/break-glass.ts new file mode 100644 index 0000000000..54509340f5 --- /dev/null +++ b/apps/status/src/break-glass.ts @@ -0,0 +1,150 @@ +import { StatusPageIncidentImpact, StatusPageIncidentStatus } from "@maple/domain/primitives" +import { + decodeStatusPageOverlayJson, + sha256Base64Url, + STATUS_PAGE_OVERLAY_MAX_ENTRIES, + StatusPageKeys, + type StatusPageOverlayEntry, + type StatusPageSnapshot, +} from "@maple/domain/status-page" +import { MANAGE_FIELDS, NEW_INCIDENT } from "@maple/status-page" +import { Effect, Option, Schema } from "effect" +import type { StatusEnv } from "./ports" + +/** A break-glass post that was refused; `status` is the HTTP status the form page answers with. */ +export class BreakGlassRejected extends Schema.TaggedError()( + "@maple/status/BreakGlassRejected", + { message: Schema.String, status: Schema.Number }, +) {} + +/** The overlay could not be read or written, so the update was not saved. */ +export class BreakGlassNotSaved extends Schema.TaggedError()( + "@maple/status/BreakGlassNotSaved", + { message: Schema.String, cause: Schema.optionalKey(Schema.Defect()) }, +) {} + +/** Posted forms are small; anything larger is not a person typing an update. */ +export const MAX_FORM_BYTES = 32 * 1024 + +export const BreakGlassForm = Schema.Struct({ + token: Schema.String.check(Schema.isMinLength(1)), + incidentId: Schema.String, + title: Schema.String.check(Schema.isMaxLength(200)), + status: StatusPageIncidentStatus, + impact: StatusPageIncidentImpact, + body: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(5000)), + componentIds: Schema.Array(Schema.String), +}) +export type BreakGlassForm = typeof BreakGlassForm.Type + +const decodeForm = Schema.decodeUnknownOption(BreakGlassForm) + +const reject = (status: number, message: string) => Effect.fail(new BreakGlassRejected({ status, message })) + +/** Compares every character whatever the first mismatch, so timing does not leak the digest. */ +export const constantTimeEqual = (a: string, b: string): boolean => + Array.from( + { length: Math.max(a.length, b.length) }, + (_, index) => a.charCodeAt(index) ^ b.charCodeAt(index), + ).reduce((diff, bits) => diff | bits, a.length ^ b.length) === 0 + +const text = (form: FormData, name: string): string => { + const value = form.get(name) + return typeof value === "string" ? value.trim() : "" +} + +/** Reads the posted form into a validated update, or says what is wrong with it. */ +export const parseForm = (request: Request): Effect.Effect => + Effect.gen(function* () { + const length = Number(request.headers.get("content-length") ?? "0") + if (length > MAX_FORM_BYTES) return yield* reject(413, "The update is too large.") + const form = yield* Effect.tryPromise({ + try: () => request.formData(), + catch: () => new BreakGlassRejected({ status: 400, message: "The form could not be read." }), + }) + const decoded = decodeForm({ + token: text(form, MANAGE_FIELDS.token), + incidentId: text(form, MANAGE_FIELDS.incidentId), + title: text(form, MANAGE_FIELDS.title), + status: text(form, MANAGE_FIELDS.status), + impact: text(form, MANAGE_FIELDS.impact), + body: text(form, MANAGE_FIELDS.body), + componentIds: form + .getAll(MANAGE_FIELDS.componentIds) + .filter((value) => typeof value === "string"), + }) + if (Option.isNone(decoded)) { + return yield* reject( + 400, + "Check the form: a token, a status, an impact and a message of 1 to 5000 characters are required, and titles are at most 200 characters.", + ) + } + return decoded.value + }) + +/** Checks the token and the form against the page, then appends the update to the KV overlay. */ +export const postBreakGlassUpdate = ( + env: StatusEnv, + snapshot: StatusPageSnapshot, + form: BreakGlassForm, +): Effect.Effect => + Effect.gen(function* () { + const expected = snapshot.breakGlassTokenSha256 + if (expected === null) return yield* reject(403, "Break-glass updates are not enabled for this page.") + const digest = yield* Effect.promise(() => sha256Base64Url(form.token)) + if (!constantTimeEqual(digest, expected)) + return yield* reject(403, "That token is not valid for this page.") + + const isNew = form.incidentId === NEW_INCIDENT || form.incidentId === "" + if (isNew && form.title === "") return yield* reject(400, "Give the new incident a title.") + if (!isNew && !snapshot.incidents.some((incident) => incident.id === form.incidentId)) { + return yield* reject(400, "That incident is not on this page.") + } + const componentIds = new Set(snapshot.components.map((component) => component.id)) + if (!form.componentIds.every((id) => componentIds.has(id))) { + return yield* reject(400, "One of the selected components is not on this page.") + } + + const key = StatusPageKeys.overlay(snapshot.pageId) + const raw = yield* env.storage + .kvGet(key) + .pipe( + Effect.mapError( + (cause) => new BreakGlassNotSaved({ message: "The overlay could not be read.", cause }), + ), + ) + const entries = + raw === null + ? [] + : Option.match(decodeStatusPageOverlayJson(raw), { + onNone: () => [], + onSome: (overlay) => overlay.entries, + }) + if (entries.length >= STATUS_PAGE_OVERLAY_MAX_ENTRIES) { + return yield* reject( + 409, + "This page holds the most break-glass updates it can until Maple imports them.", + ) + } + + const entry: StatusPageOverlayEntry = { + id: env.randomId(), + at: env.nowMs, + incidentId: isNew ? null : form.incidentId, + title: form.title === "" ? null : form.title, + status: form.status, + impact: form.impact, + body: form.body, + componentIds: form.componentIds, + } + // Read-modify-write: two posts in the same instant can drop one. Break-glass has one or two writers. + yield* env.storage + .kvPut(key, JSON.stringify({ entries: [...entries, entry] })) + .pipe( + Effect.mapError( + (cause) => + new BreakGlassNotSaved({ message: "The overlay could not be written.", cause }), + ), + ) + return entry + }) diff --git a/apps/status/src/handler.test.ts b/apps/status/src/handler.test.ts new file mode 100644 index 0000000000..0821c7ce5b --- /dev/null +++ b/apps/status/src/handler.test.ts @@ -0,0 +1,285 @@ +import { decodeStatusPageOverlayJson, sha256Base64Url, StatusPageKeys } from "@maple/domain/status-page" +import { + FIXTURE_NOW, + FIXTURE_PAGE_ID, + fixtureComponentId, + makeIncident, + makeSnapshot, +} from "@maple/status-page/fixtures" +import { Option } from "effect" +import { beforeEach, describe, expect, it } from "vitest" +import { cacheKey } from "./snapshot" +import { type FakeStorage, makeFakeStorage, postForm, publish, send } from "./test-support" + +const PAGE = "https://status.acme.example/" +const TOKEN = "mstat_correct-horse-battery-staple" + +const delayedBanner = (html: string): string => + html.match(/
]*>/u)?.[0] ?? "" +const overlayEntries = (fake: FakeStorage) => + Option.match(decodeStatusPageOverlayJson(fake.kv.get(StatusPageKeys.overlay(FIXTURE_PAGE_ID)) ?? ""), { + onNone: () => [], + onSome: (overlay) => overlay.entries, + }) + +describe("serving a page", () => { + let fake: FakeStorage + beforeEach(() => { + fake = makeFakeStorage() + publish(fake, makeSnapshot()) + }) + + it("serves the page from KV with cache and security headers, and warms the colo cache", async () => { + const response = await send(fake, PAGE) + const html = await response.text() + expect(response.status).toBe(200) + expect(html).toContain("Acme status") + expect(response.headers.get("cache-control")).toBe( + "public, max-age=15, stale-while-revalidate=60, stale-if-error=86400", + ) + expect(response.headers.get("x-content-type-options")).toBe("nosniff") + const nonce = response.headers + .get("content-security-policy") + ?.match(/script-src 'nonce-([^']+)'/u)?.[1] + expect(nonce).toBeDefined() + expect(html).toContain(``, + ``, + ].join("") +} diff --git a/packages/status-page/src/escape.ts b/packages/status-page/src/escape.ts new file mode 100644 index 0000000000..28b6557840 --- /dev/null +++ b/packages/status-page/src/escape.ts @@ -0,0 +1,24 @@ +/** Escapes text for HTML element content and quoted attributes alike. */ +export const escapeHtml = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") + +/** XML 1.0 forbids most control characters even when escaped, so they are dropped. */ +export const escapeXml = (value: string): string => + // oxlint-disable-next-line no-control-regex + escapeHtml(value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/gu, "")) + +/** A customer URL usable as a link or image: only absolute http(s), never `javascript:`. */ +export const safeUrl = (value: string | null, protocols: ReadonlyArray): string | null => { + if (value === null || !URL.canParse(value)) return null + const url = new URL(value) + return protocols.includes(url.protocol) ? url.toString() : null +} + +/** Only plain hex colours reach the stylesheet. */ +export const safeColor = (value: string | null): string | null => + value !== null && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu.test(value) ? value : null diff --git a/packages/status-page/src/feed.ts b/packages/status-page/src/feed.ts new file mode 100644 index 0000000000..c5006642d4 --- /dev/null +++ b/packages/status-page/src/feed.ts @@ -0,0 +1,20 @@ +import type { StatusPageSnapshot } from "@maple/domain/status-page" +import { escapeXml } from "./escape" +import { INCIDENT_STATUS_LABEL } from "./format" +import { latestActivityAt } from "./select" + +const iso = (ms: number): string => new Date(ms).toISOString() + +/** Atom feed with one entry per incident update, newest first. `pageUrl` is the page's absolute URL. */ +export const renderAtomFeed = (snapshot: StatusPageSnapshot, pageUrl: string): string => { + const base = pageUrl.replace(/\/+$/u, "") + const entries = snapshot.incidents + .flatMap((incident) => incident.updates.map((update) => ({ incident, update }))) + .sort((a, b) => b.update.at - a.update.at) + .map( + ({ incident, update }) => + `urn:maple:status:${escapeXml(snapshot.pageId)}:${escapeXml(incident.id)}:${escapeXml(update.id)}${escapeXml(`${incident.title}: ${INCIDENT_STATUS_LABEL[update.status]}`)}${iso(update.at)}${iso(update.at)}${escapeXml(update.body)}`, + ) + .join("") + return `urn:maple:status:${escapeXml(snapshot.pageId)}${escapeXml(`${snapshot.page.name} status`)}${iso(latestActivityAt(snapshot))}${escapeXml(snapshot.page.name)}${entries}` +} diff --git a/packages/status-page/src/fixtures.ts b/packages/status-page/src/fixtures.ts new file mode 100644 index 0000000000..eeca7e0be0 --- /dev/null +++ b/packages/status-page/src/fixtures.ts @@ -0,0 +1,96 @@ +import { Schema } from "effect" +import { + lastDays, + StatusPageSnapshot, + STATUS_PAGE_HISTORY_DAYS, + type StatusPageDayState, +} from "@maple/domain/status-page" + +// Snapshot builders for tests and sample renders. Values are decoded, so brands are real. + +type Encoded = typeof StatusPageSnapshot.Encoded +type EncodedComponent = Encoded["components"][number] +type EncodedIncident = Encoded["incidents"][number] +type EncodedDay = EncodedComponent["days"][number] + +export const FIXTURE_PAGE_ID = "0b4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1a01" +export const FIXTURE_NOW = Date.UTC(2026, 9, 28, 12, 0, 0) + +const COMPONENT_IDS = [ + "1c4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1a01", + "1c4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1a02", + "1c4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1a03", + "1c4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1a04", +] as const + +export const fixtureComponentId = (index: number): string => + COMPONENT_IDS[index] ?? "1c4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1aff" + +const DAY_COUNTS = { + none: { checks: 0, failures: 0, downChecks: 0 }, + ok: { checks: 1440, failures: 0, downChecks: 0 }, + degraded: { checks: 1440, failures: 3, downChecks: 0 }, + down: { checks: 1440, failures: 30, downChecks: 30 }, +} as const satisfies Record> + +/** 90 days ending on `nowMs`, all `ok` except the given overrides (index 0 is the oldest). */ +export const makeDays = ( + nowMs: number = FIXTURE_NOW, + overrides: Readonly> = {}, +): ReadonlyArray => + lastDays(nowMs, STATUS_PAGE_HISTORY_DAYS).map((day, index) => ({ + day, + ...DAY_COUNTS[overrides[index] ?? "ok"], + })) + +export const makeComponent = (index: number, fields: Partial = {}): EncodedComponent => ({ + id: fixtureComponentId(index), + name: `Component ${index + 1}`, + description: null, + group: null, + status: "operational", + showUptime: true, + days: makeDays(), + ...fields, +}) + +export const makeIncident = (id: string, fields: Partial = {}): EncodedIncident => ({ + id, + kind: "incident", + title: `Incident ${id}`, + impact: "minor", + status: "investigating", + startedAt: FIXTURE_NOW - 30 * 60_000, + resolvedAt: null, + scheduledStart: null, + scheduledEnd: null, + componentIds: [], + updates: [], + ...fields, +}) + +export const makeSnapshot = (fields: Partial = {}): StatusPageSnapshot => + Schema.decodeUnknownSync(StatusPageSnapshot)({ + schemaVersion: 1, + pageId: FIXTURE_PAGE_ID, + revision: 1, + generatedAt: FIXTURE_NOW - 60_000, + probesSeenAt: FIXTURE_NOW - 30_000, + page: { + name: "Acme", + slug: "acme", + description: "Status of Acme services", + websiteUrl: "https://acme.example", + logoUrl: null, + brandColor: null, + showPoweredBy: true, + }, + breakGlassTokenSha256: null, + components: [ + makeComponent(0, { name: "API", group: "Platform" }), + makeComponent(1, { name: "Dashboard", group: "Platform" }), + makeComponent(2, { name: "Docs", showUptime: false, days: [] }), + ], + incidents: [], + ...fields, + }) diff --git a/packages/status-page/src/format.ts b/packages/status-page/src/format.ts new file mode 100644 index 0000000000..fcd8ae0abe --- /dev/null +++ b/packages/status-page/src/format.ts @@ -0,0 +1,97 @@ +import type { + StatusPageComponentStatus, + StatusPageIncidentImpact, + StatusPageIncidentStatus, +} from "@maple/domain/primitives" +import type { StatusPageDayBar, StatusPageDayState } from "@maple/domain/status-page" + +export const COMPONENT_STATUS_LABEL = { + operational: "Operational", + maintenance: "Under maintenance", + degraded: "Degraded performance", + partial_outage: "Partial outage", + major_outage: "Major outage", +} as const satisfies Record + +export const OVERALL_STATUS_LABEL = { + operational: "All systems operational", + maintenance: "Maintenance in progress", + degraded: "Degraded performance", + partial_outage: "Partial system outage", + major_outage: "Major system outage", +} as const satisfies Record + +export const INCIDENT_STATUS_LABEL = { + investigating: "Investigating", + identified: "Identified", + monitoring: "Monitoring", + resolved: "Resolved", + scheduled: "Scheduled", + in_progress: "In progress", + completed: "Completed", +} as const satisfies Record + +export const IMPACT_LABEL = { + none: "No impact", + minor: "Minor", + major: "Major", + critical: "Critical", +} as const satisfies Record + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + +const pad = (value: number): string => String(value).padStart(2, "0") + +/** `Oct 28, 2026`, UTC: rendering at the edge must not depend on the colo's locale. */ +export const formatDate = (ms: number): string => { + const date = new Date(ms) + return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}` +} + +/** `Oct 28, 2026, 14:05 UTC`. */ +export const formatDateTime = (ms: number): string => { + const date = new Date(ms) + return `${formatDate(ms)}, ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())} UTC` +} + +/** `YYYY-MM-DD` day of a bar, as `Oct 28, 2026`. */ +export const formatDay = (day: string): string => { + const [year = 1970, month = 1, date = 1] = day.split("-").map(Number) + return formatDate(Date.UTC(year, month - 1, date)) +} + +/** `just now`, `4 min ago`, `3 h ago`, `2 d ago`. The inline page script mirrors this. */ +export const formatAge = (ageMs: number): string => { + const minutes = Math.floor(Math.max(0, ageMs) / 60_000) + if (minutes < 1) return "just now" + if (minutes < 60) return `${minutes} min ago` + const hours = Math.floor(minutes / 60) + if (hours < 48) return `${hours} h ago` + return `${Math.floor(hours / 24)} d ago` +} + +/** Floors so a day with any downtime never reads as 100%. */ +export const formatPercent = (percent: number): string => { + const floored = Math.floor(percent * 100) / 100 + return `${floored.toFixed(2).replace(/\.?0+$/u, "")}%` +} + +export const DAY_STATE_LABEL = { + none: "No data", + ok: "No downtime", + degraded: "Some checks failed", + down: "Downtime recorded", +} as const satisfies Record + +/** The tooltip of one bar: the day and what happened on it. */ +export const dayTitle = (bar: StatusPageDayBar, state: StatusPageDayState): string => { + const detail = + state === "none" + ? DAY_STATE_LABEL.none + : state === "ok" + ? `${DAY_STATE_LABEL.ok}, ${bar.checks} checks` + : state === "degraded" + ? `${bar.failures} of ${bar.checks} checks failed` + : `Down during ${bar.downChecks} of ${bar.checks} checks` + return `${formatDay(bar.day)}: ${detail}` +} diff --git a/packages/status-page/src/index.ts b/packages/status-page/src/index.ts new file mode 100644 index 0000000000..a0dfc30319 --- /dev/null +++ b/packages/status-page/src/index.ts @@ -0,0 +1,7 @@ +export { renderBadgeSvg } from "./badge" +export type { RenderOptions } from "./document" +export { escapeHtml, escapeXml } from "./escape" +export { renderAtomFeed } from "./feed" +export { renderStatusJson, STATUS_INDICATOR } from "./json" +export { MANAGE_FIELDS, type ManageMessage, NEW_INCIDENT, renderManagePage } from "./manage" +export { PAGE_SCRIPT, renderPage } from "./page" diff --git a/packages/status-page/src/json.ts b/packages/status-page/src/json.ts new file mode 100644 index 0000000000..f89ed24f10 --- /dev/null +++ b/packages/status-page/src/json.ts @@ -0,0 +1,76 @@ +import type { StatusPageComponentStatus } from "@maple/domain/primitives" +import { + overallStatus, + type StatusPageFreshness, + type StatusPageSnapshot, + type StatusPageSnapshotIncident, +} from "@maple/domain/status-page" +import { activeIncidents } from "./select" + +/** statuspage.io's page indicator for the overall status. */ +export const STATUS_INDICATOR = { + operational: { indicator: "none", description: "All Systems Operational" }, + maintenance: { indicator: "maintenance", description: "Service Under Maintenance" }, + degraded: { indicator: "minor", description: "Partially Degraded Service" }, + partial_outage: { indicator: "major", description: "Partial System Outage" }, + major_outage: { indicator: "critical", description: "Major System Outage" }, +} as const satisfies Record + +const COMPONENT_STATUS = { + operational: "operational", + maintenance: "under_maintenance", + degraded: "degraded_performance", + partial_outage: "partial_outage", + major_outage: "major_outage", +} as const satisfies Record + +const iso = (ms: number | null): string | null => (ms === null ? null : new Date(ms).toISOString()) + +const incidentJson = (incident: StatusPageSnapshotIncident) => ({ + id: incident.id, + name: incident.title, + status: incident.status, + impact: incident.impact, + created_at: iso(incident.startedAt), + updated_at: iso(incident.updates[0]?.at ?? incident.startedAt), + resolved_at: iso(incident.resolvedAt), + component_ids: incident.componentIds, + incident_updates: incident.updates.map((update) => ({ + id: update.id, + status: update.status, + body: update.body, + created_at: iso(update.at), + })), +}) + +/** A statuspage.io-style `summary.json`, plus whether live checks are delayed. */ +export const renderStatusJson = (snapshot: StatusPageSnapshot, freshness: StatusPageFreshness) => { + const overall = overallStatus(snapshot) + const active = activeIncidents(snapshot) + return { + page: { + id: snapshot.pageId, + name: snapshot.page.name, + url: snapshot.page.websiteUrl, + time_zone: "Etc/UTC", + updated_at: iso(freshness.updatedAt), + }, + status: { ...STATUS_INDICATOR[overall], delayed: freshness._tag === "delayed" }, + components: snapshot.components.map((component) => ({ + id: component.id, + name: component.name, + description: component.description, + group: component.group, + status: COMPONENT_STATUS[component.status], + })), + incidents: active.filter((incident) => incident.kind === "incident").map(incidentJson), + scheduled_maintenances: snapshot.incidents + .filter((incident) => incident.kind === "maintenance" && incident.status !== "completed") + .map((incident) => ({ + ...incidentJson(incident), + scheduled_for: iso(incident.scheduledStart), + scheduled_until: iso(incident.scheduledEnd), + })), + updated_at: iso(freshness.updatedAt), + } +} diff --git a/packages/status-page/src/manage.ts b/packages/status-page/src/manage.ts new file mode 100644 index 0000000000..694bab7a4d --- /dev/null +++ b/packages/status-page/src/manage.ts @@ -0,0 +1,75 @@ +import { StatusPageIncidentImpact, StatusPageIncidentStatus } from "@maple/domain/primitives" +import type { StatusPageSnapshot } from "@maple/domain/status-page" +import { pageHref, type RenderOptions, renderDocument, renderHeader } from "./document" +import { escapeHtml } from "./escape" +import { IMPACT_LABEL, INCIDENT_STATUS_LABEL } from "./format" +import { activeIncidents, upcomingMaintenance } from "./select" +import { manageStyles } from "./styles" + +/** Form field names, shared with the Worker that parses the POST. */ +export const MANAGE_FIELDS = { + token: "token", + incidentId: "incidentId", + title: "title", + status: "status", + impact: "impact", + componentIds: "componentIds", + body: "body", +} as const + +/** `incidentId` value that opens a new incident. */ +export const NEW_INCIDENT = "new" + +export interface ManageMessage { + readonly tone: "ok" | "error" + readonly text: string +} + +const option = (value: string, label: string): string => + `` + +/** Break-glass form: post an incident update straight to the edge while Maple is down. */ +export const renderManagePage = ( + snapshot: StatusPageSnapshot, + message?: ManageMessage, + options: RenderOptions = {}, +): string => { + const f = MANAGE_FIELDS + const incidents = [...activeIncidents(snapshot), ...upcomingMaintenance(snapshot)] + const notice = + message === undefined + ? "" + : `
${escapeHtml(message.text)}
` + const components = + snapshot.components.length === 0 + ? "" + : `
Affected components${snapshot.components + .map( + (component) => + ``, + ) + .join("")}
` + const body = [ + renderHeader(snapshot, options), + notice, + `

Post an update

For use while Maple is unreachable. The update shows on the page at once and is imported into Maple when it recovers.

`, + `
`, + ``, + ``, + ``, + ``, + ``, + components, + ``, + `
`, + ``, + ].join("") + return renderDocument({ + snapshot, + options, + title: `Post an update: ${snapshot.page.name}`, + styles: manageStyles, + body, + head: ``, + }) +} diff --git a/packages/status-page/src/page.ts b/packages/status-page/src/page.ts new file mode 100644 index 0000000000..6f34ae9da1 --- /dev/null +++ b/packages/status-page/src/page.ts @@ -0,0 +1,144 @@ +import { + dayState, + incidentComponentStatus, + overallStatus, + STATUS_PAGE_STALE_AFTER_MS, + type StatusPageFreshness, + type StatusPageSnapshot, + type StatusPageSnapshotComponent, + type StatusPageSnapshotIncident, + uptimePercent, +} from "@maple/domain/status-page" +import { pageHref, type RenderOptions, renderDocument, renderHeader } from "./document" +import { escapeHtml } from "./escape" +import { + COMPONENT_STATUS_LABEL, + dayTitle, + formatAge, + formatDateTime, + INCIDENT_STATUS_LABEL, + OVERALL_STATUS_LABEL, + formatPercent, +} from "./format" +import { activeIncidents, groupComponents, pastIncidents, upcomingMaintenance } from "./select" +import { pageStyles } from "./styles" + +/** Recomputes "Updated ..." and reveals the delayed banner, so a cached copy ages honestly. */ +export const PAGE_SCRIPT = `(function(){var b=document.body,u=+b.getAttribute("data-updated-at"),s=+b.getAttribute("data-stale-after");function a(ms){var m=Math.floor(Math.max(0,ms)/6e4);if(m<1)return"just now";if(m<60)return m+" min ago";var h=Math.floor(m/60);return h<48?h+" h ago":Math.floor(h/24)+" d ago"}function t(){var g=Date.now()-u,e=document.getElementById("updated"),d=document.getElementById("delayed");if(e)e.textContent="Updated "+a(g);if(d&&g>s)d.hidden=false}t();setInterval(t,3e4)})();` + +const timeTag = (ms: number): string => + `` + +const renderUpdates = (incident: StatusPageSnapshotIncident): string => + incident.updates.length === 0 + ? "" + : `
    ${incident.updates + .map( + (update) => + `
  1. ${INCIDENT_STATUS_LABEL[update.status]}

    ${escapeHtml(update.body)}

    ${timeTag(update.at)}
  2. `, + ) + .join("")}
` + +const incidentMeta = (incident: StatusPageSnapshotIncident): string => { + if (incident.kind === "maintenance" && incident.scheduledStart !== null) { + const end = incident.scheduledEnd === null ? "" : ` to ${timeTag(incident.scheduledEnd)}` + return `Scheduled for ${timeTag(incident.scheduledStart)}${end}` + } + if (incident.resolvedAt !== null) { + return `${timeTag(incident.startedAt)} to ${timeTag(incident.resolvedAt)}` + } + return `Started ${timeTag(incident.startedAt)}` +} + +const renderIncident = (incident: StatusPageSnapshotIncident, withUpdates: boolean): string => + `

${escapeHtml(incident.title)}

${INCIDENT_STATUS_LABEL[incident.status]} · ${incidentMeta(incident)}
${withUpdates ? renderUpdates(incident) : ""}
` + +const renderBars = (component: StatusPageSnapshotComponent): string => { + if (!component.showUptime || component.days.length === 0) return "" + const percent = uptimePercent(component.days) + const bars = component.days + .map((bar) => { + const state = dayState(bar) + return `
  • ` + }) + .join("") + const uptime = percent === null ? "No checks yet" : `${formatPercent(percent)} uptime` + return `
      ${bars}
    ${component.days.length} days ago${uptime}Today
    ` +} + +const renderComponent = (component: StatusPageSnapshotComponent): string => { + const description = + component.description === null ? "" : `

    ${escapeHtml(component.description)}

    ` + return `
    ${escapeHtml(component.name)}${COMPONENT_STATUS_LABEL[component.status]}
    ${description}${renderBars(component)}
    ` +} + +const renderComponents = (snapshot: StatusPageSnapshot): string => { + if (snapshot.components.length === 0) return "" + const groups = groupComponents(snapshot.components) + .map((group) => { + const head = + group.name === null + ? "" + : `
    ${escapeHtml(group.name)}${COMPONENT_STATUS_LABEL[group.status]}
    ` + return `
    ${head}${group.components.map(renderComponent).join("")}
    ` + }) + .join("") + return `

    Components

    ${groups}
    ` +} + +const renderSection = (id: string, title: string, inner: string): string => + `

    ${title}

    ${inner}
    ` + +/** The public status page: server-rendered, self-contained, readable with scripts off. */ +export const renderPage = ( + snapshot: StatusPageSnapshot, + freshness: StatusPageFreshness, + nowMs: number, + options: RenderOptions = {}, +): string => { + const overall = overallStatus(snapshot) + const active = activeIncidents(snapshot) + const upcoming = upcomingMaintenance(snapshot) + const past = pastIncidents(snapshot) + const delayed = freshness._tag === "delayed" + + const body = [ + renderHeader(snapshot, options), + `
    Live checks are delayed. Showing the last known state from ${timeTag(freshness.updatedAt)}.
    `, + ``, + active.length === 0 + ? "" + : renderSection( + "active", + "Active incidents", + active.map((i) => renderIncident(i, true)).join(""), + ), + upcoming.length === 0 + ? "" + : renderSection( + "upcoming", + "Upcoming maintenance", + upcoming.map((i) => renderIncident(i, true)).join(""), + ), + renderComponents(snapshot), + renderSection( + "past", + "Past incidents", + past.length === 0 + ? `

    No incidents reported recently.

    ` + : past.map((i) => renderIncident(i, true)).join(""), + ), + `
    Updated ${formatAge(nowMs - freshness.updatedAt)}Atom feed${snapshot.page.showPoweredBy ? ` · Powered by Maple` : ""}
    `, + ].join("") + + return renderDocument({ + snapshot, + options, + title: `${snapshot.page.name} status`, + styles: pageStyles, + body, + bodyAttrs: ` data-generated-at="${snapshot.generatedAt}" data-updated-at="${freshness.updatedAt}" data-stale-after="${STATUS_PAGE_STALE_AFTER_MS}" data-freshness="${freshness._tag}"`, + head: ``, + script: PAGE_SCRIPT, + }) +} diff --git a/packages/status-page/src/render.test.ts b/packages/status-page/src/render.test.ts new file mode 100644 index 0000000000..d9c9d400d4 --- /dev/null +++ b/packages/status-page/src/render.test.ts @@ -0,0 +1,231 @@ +import type { StatusPageComponentStatus } from "@maple/domain/primitives" +import type { StatusPageFreshness } from "@maple/domain/status-page" +import { describe, expect, it } from "vitest" +import { + FIXTURE_NOW, + fixtureComponentId, + makeComponent, + makeDays, + makeIncident, + makeSnapshot, +} from "./fixtures" +import { renderAtomFeed, renderBadgeSvg, renderManagePage, renderPage, renderStatusJson } from "./index" + +const HOSTILE = `&'` +const live: StatusPageFreshness = { _tag: "live", updatedAt: FIXTURE_NOW - 2 * 60_000 } +const delayed: StatusPageFreshness = { _tag: "delayed", updatedAt: Date.UTC(2026, 9, 28, 11, 0) } + +const update = ( + id: string, + at: number, + body: string, + status: "investigating" | "resolved" = "investigating", +) => ({ + id, + status, + body, + at, + source: "dashboard" as const, +}) + +const hostileSnapshot = makeSnapshot({ + page: { + name: HOSTILE, + slug: "acme", + description: HOSTILE, + websiteUrl: "javascript:alert(1)", + logoUrl: "http://insecure.example/logo.png", + brandColor: "red;}body{display:none", + showPoweredBy: false, + }, + components: [makeComponent(0, { name: HOSTILE, group: HOSTILE, description: HOSTILE })], + incidents: [ + makeIncident("inc-1", { + title: HOSTILE, + updates: [update("u-1", FIXTURE_NOW - 60_000, HOSTILE)], + }), + ], +}) + +/** The `
  • ` bars of one component, in order. */ +const barsOf = (html: string, componentId: string) => { + const block = html.split(`data-component="${componentId}"`)[1]?.split(`data-component="`)[0] ?? "" + return [ + ...block.matchAll(/
  • /gu), + ].map((match) => ({ state: match[1], day: match[2], title: match[3] })) +} + +const delayedBanner = (html: string): string => + html.match(/
    ]*>/u)?.[0] ?? "" + +describe("renderPage", () => { + it("escapes every piece of customer text and drops unsafe URLs and colours", () => { + const html = renderPage(hostileSnapshot, live, FIXTURE_NOW) + expect(html).not.toContain(`