g1t/apps/status/src/index.ts

890 lines42,242 bytesCodeBlame
1/**
2 * status.g1t.sh: whether each part of g1t is working, how it has done over
3 * 90 days, and what staff have said about incidents and maintenance.
4 *
5 * A Worker of its own, apart from the site, so it stays up when g1t does
6 * not. Every minute a cron checks each part over the public internet, as
7 * people reach it, and keeps the result in D1. The same run moves planned
8 * maintenance along, and drafts an incident for staff when a part keeps
9 * failing (detect.ts). Pages are drawn from what is kept, never by
10 * checking on the spot, and kept at the edge for 30 seconds.
11 *
12 * Staff run incidents from sudo, through the `StatusAdmin` entrypoint,
13 * which only a service binding reaches. Every change there is audited.
14 *
15 * GET / the page
16 * GET /status.json the same as JSON (snake_case, CORS open)
17 * GET /badge.svg a small badge
18 * GET /incidents/<id> an incident's updates and postmortem
19 * GET /maintenance/<id> a maintenance window's updates
20 * GET /history the last 12 months, by month
21 * GET /feed.xml, /feed.json every public update, newest first
22 * GET /subscribe subscribing by email
23 * POST /subscribe asks for a subscription: a confirmation email
24 * GET|POST /subscribe/confirm?token= confirms (GET shows a button: link scanners must not confirm)
25 * GET|POST /unsubscribe?token= leaves (POST also takes RFC 8058 one-click)
26 * POST /deploys the deploy tool: a deploy started or finished (bearer STATUS_DEPLOY_TOKEN)
27 */
28import { WorkerEntrypoint } from "cloudflare:workers";
29import {
30 type AdminIncident,
31 type AdminIncidentDetail,
32 type AdminMaintenance,
33 type DeclareIncident,
34 type FollowUp,
35 type IncidentChange,
36 type MaintenanceChange,
37 type NewMaintenance,
38 type Postmortem,
39 type PostmortemFields,
40 type PublishIncident,
41 type Result,
42 type RolesChange,
43 type StatusAdminApi,
44 type StatusAuditEntry,
45 type StatusBoard,
46 billingClient,
47 fail,
48 ok,
49} from "@g1t/contracts";
50import bricolage from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2";
51import hanken from "@g1t/theme/fonts/hanken-grotesk-latin.woff2";
52import plexMono from "@g1t/theme/fonts/ibm-plex-mono-latin-400.woff2";
53
54import { type Targets, components } from "./components.ts";
55import {
56 DEPLOY_GRACE_MS,
57 DEPLOY_MAX_MS,
58 autoDismissText,
59 deployChange,
60 deployQuiet,
61 detect,
62 detectedImpact,
63 draftTitle,
64 recoverySentence,
65 settleDrafts,
66 troubleSentence,
67} from "./detect.ts";
68import { type EmailBinding, type Sender, alertLetter, bindingSender, confirmLetter, recoveredLetter, render as renderMail, unsubscribeHeaders, updateLetter } from "./email.ts";
69import { atom, feedItems, jsonFeed } from "./feed.ts";
70import {
71 type Entry,
72 applyChange,
73 applyRoles,
74 checkChange,
75 checkDeclare,
76 checkFollowUp,
77 checkMaintenance,
78 checkMaintenanceChange,
79 checkPostmortem,
80 checkPublish,
81 checkRoles,
82 postmortemReady,
83 SEVERITY_LABEL,
84 shortId,
85} from "./incidents.ts";
86import { INCIDENT_STATUS, type PageModel, SLOW_MS, buildPage, classify, underMaintenance } from "./model.ts";
87import { stamp } from "./postmortem.ts";
88import { runCheck } from "./probe.ts";
89import { readZone } from "./time.ts";
90import {
91 FAVICON,
92 SCRIPT,
93 type PageOptions,
94 renderBadge,
95 renderHistory,
96 renderIncident,
97 renderMaintenance,
98 renderMessage,
99 renderPage,
100 renderSubscribe,
101} from "./render.ts";
102import {
103 type Observation,
104 addFollowUp,
105 addSystemLines,
106 auditLog,
107 autoDismiss,
108 board,
109 confirmSubscription,
110 createIncident,
111 dueMaintenance,
112 facts,
113 incidentDetail,
114 load,
115 loadDeploy,
116 loadHistory,
117 loadPublicIncident,
118 loadPublicMaintenance,
119 loadStreaks,
120 maintenanceById,
121 maintenanceUrl,
122 maintenanceUpdate,
123 openCount,
124 openRefs,
125 publishPostmortem,
126 recipients,
127 record,
128 requestSubscription,
129 saveDeploy,
130 saveHealthy,
131 saveIncident,
132 savePostmortem,
133 saveStreaks,
134 scheduleMaintenance,
135 setFollowUp,
136 unsubscribe,
137 watchedDrafts,
138} from "./store.ts";
139import { CONFIRM_TTL_MS, RESEND_AFTER_MS, chosenParts, hashToken, newToken, normalizeEmail, readUnsubscribeToken, unsubscribeToken } from "./subscribers.ts";
140
141export interface Env extends Partial<Targets> {
142 DB: D1Database;
143 /** Billing, for reading its price book. Optional: without it, billing is not listed. */
144 BILLING?: Fetcher;
145 /** Where help is. */
146 SUPPORT_URL?: string;
147 /**
148 * The site's address as people's browsers reach it, for the page's links,
149 * when the checks reach it by another (self-hosted: `http://g1t:8787`
150 * inside Compose). SITE_URL when empty.
151 */
152 PUBLIC_SITE_URL?: string;
153 /** The page's share card; empty for none. */
154 OG_IMAGE?: string;
155 /** This page's own address, for links in email and feeds made outside a request. */
156 STATUS_URL?: string;
157 /** Where sudo is, for the staff alert's link. */
158 SUDO_URL?: string;
159 /** Who hears about detected drafts. Empty sends none. */
160 STATUS_ALERT_EMAIL?: string;
161 /** The From of every email. */
162 STATUS_FROM?: string;
163 /** Signs unsubscribe links (a secret). Without it, email subscriptions are off; the feeds still work. */
164 STATUS_SECRET?: string;
165 /** Cloudflare Email Sending (`send_email`). Without it, nothing is emailed. */
166 EMAIL?: EmailBinding;
167 /**
168 * The deploy tool's bearer token for `POST /deploys` (a secret). Without
169 * it, deploys are not announced and detection does not hold off for them.
170 */
171 STATUS_DEPLOY_TOKEN?: string;
172}
173
174/** How long the edge keeps a page or the JSON. */
175const CACHE_SECONDS = 30;
176/** Older than this, a visit asks for a round of checks too (a missed cron, or `wrangler dev`). */
177const BEHIND_MS = 3 * 60 * 1000;
178/** How many subscribers one update emails at most, within a Worker's limits. */
179const MAX_RECIPIENTS = 900;
180
181function parts(env: Env) {
182 return components(env, env.BILLING != null);
183}
184
185function names(env: Env): Map<string, string> {
186 return new Map(parts(env).map((p) => [p.key, p.name]));
187}
188
189/** This page's address: the request's own, or STATUS_URL outside a request. */
190function originOf(env: Env, url?: URL): string {
191 return (url?.origin ?? env.STATUS_URL ?? "https://status.g1t.sh").replace(/\/+$/, "");
192}
193
194function sender(env: Env): Sender | null {
195 return bindingSender(env.EMAIL, env.STATUS_FROM || undefined);
196}
197
198/** Whether subscribers can sign up: a way to send, and a secret to sign their links. */
199function emailOn(env: Env): boolean {
200 return sender(env) != null && !!env.STATUS_SECRET;
201}
202
203/** One round of checks, kept. Parts under maintenance are checked but not tallied. */
204export async function checkAll(env: Env, now = new Date()): Promise<Observation[]> {
205 const billing = env.BILLING;
206 const list = parts(env);
207 const observations = await Promise.all(
208 list.map(async (info): Promise<Observation> => {
209 const result = await runCheck(info.check, {
210 fetch: (url, init) => fetch(url, init),
211 billing: billing ? () => billingClient(billing).prices() : null,
212 });
213 const { state, detail } = classify(result, info.slowMs);
214 return { component: info.key, state, detail, latency_ms: result ? Math.round(result.ms) : null };
215 }),
216 );
217 const { maintenance } = await load(env.DB, now, originOf(env));
218 await record(env.DB, observations, now, underMaintenance(maintenance, now));
219 return observations;
220}
221
222// --- Email ---------------------------------------------------------------------------
223
224/**
225 * Emails confirmed subscribers who want news about `about`, in the
226 * background. Returns how many it will email, or null when email is off.
227 */
228async function notify(
229 env: Env,
230 ctx: { waitUntil(p: Promise<unknown>): void },
231 about: string[],
232 mail: { heading: string; text: string; url: string },
233): Promise<number | null> {
234 const send = sender(env);
235 const secret = env.STATUS_SECRET;
236 if (!send || !secret) return null;
237 const list = (await recipients(env.DB, about)).slice(0, MAX_RECIPIENTS);
238 const origin = originOf(env);
239 const affects = about.map((k) => names(env).get(k) ?? k);
240 ctx.waitUntil(
241 (async () => {
242 let failed = 0;
243 for (let i = 0; i < list.length; i += 6) {
244 await Promise.all(
245 list.slice(i, i + 6).map(async (r) => {
246 const link = `${origin}/unsubscribe?token=${encodeURIComponent(await unsubscribeToken(secret, r.id))}`;
247 const { text, html } = renderMail(updateLetter({ heading: mail.heading, text: mail.text, url: mail.url, affects, unsubscribe: link }));
248 await send.send({ to: r.email, subject: mail.heading, text, html, headers: unsubscribeHeaders(link) }).catch(() => void (failed += 1));
249 }),
250 );
251 }
252 console.log(JSON.stringify({ event: "status.notified", sent: list.length - failed, failed }));
253 })(),
254 );
255 return list.length;
256}
257
258// --- The cron: detection and maintenance --------------------------------------------------
259
260async function afterChecks(env: Env, ctx: { waitUntil(p: Promise<unknown>): void }, observations: Observation[], now: Date): Promise<void> {
261 const origin = originOf(env);
262 const named = names(env);
263 // Maintenance whose window opened or closed.
264 for (const m of await dueMaintenance(env.DB, now, origin)) {
265 const ended = Date.parse(m.ends_at) <= now.getTime();
266 const text = ended ? "The maintenance is complete." : "The maintenance has begun.";
267 const notified = m.notify ? await notify(env, ctx, m.components, { heading: `${ended ? "Completed" : "In progress"}: ${m.title}`, text, url: m.url }) : null;
268 await maintenanceUpdate(env.DB, m.id, ended ? "completed" : "in_progress", text, "status", notified, now, {
269 action: ended ? "maintenance_completed" : "maintenance_started",
270 detail: `${m.title} (on schedule)`,
271 });
272 }
273 // Detection. A deploy restarts services: during one, and briefly after, trouble is counted but not drafted.
274 const [streaks, open, page, deploy] = await Promise.all([loadStreaks(env.DB), openRefs(env.DB), load(env.DB, now, origin), loadDeploy(env.DB)]);
275 const quiet = deployQuiet(deploy, now);
276 const found = detect(streaks, observations, open, underMaintenance(page.maintenance, now), now, { quiet });
277 await saveStreaks(env.DB, found.streaks);
278 if (found.held.length) console.log(JSON.stringify({ event: "status.held_for_deploy", parts: found.held, deploy: deploy?.id ?? null }));
279 const name = (key: string) => named.get(key) ?? key;
280 const slowMs = (key: string) => parts(env).find((p) => p.key === key)?.slowMs ?? SLOW_MS;
281 const lines = [
282 ...found.failing.map((f) => ({ incident: f.incident, kind: "failing" as const, text: troubleSentence(name(f.key), f, stamp(f.since), slowMs(f.key)) })),
283 ...found.recovered.map((r) => ({ incident: r.incident, kind: "recovered" as const, text: recoverySentence(name(r.key), r, stamp(r.since)) })),
284 ];
285 await addSystemLines(env.DB, lines, now);
286 const sudo = (id: string) => `${(env.SUDO_URL || "https://sudo.g1t.sh").replace(/\/+$/, "")}/incidents/${id}`;
287 const alertTo = (env.STATUS_ALERT_EMAIL ?? "").trim();
288 const send = sender(env);
289 if (found.draft.length) {
290 const core = new Set(parts(env).filter((p) => p.core).map((p) => p.key));
291 const title = draftTitle(found.draft.map((d) => ({ name: name(d.key), state: d.state })));
292 const since = found.draft.map((d) => d.since).sort()[0]!;
293 const said = found.draft.map((d) => troubleSentence(name(d.key), d, stamp(d.since), slowMs(d.key)));
294 // Trouble that began in a deploy and outlasted it: say so, it is the first thing to rule out.
295 const note = deploy && deployQuiet(deploy, new Date(since)) ? `It began during a deploy (started ${stamp(deploy.started_at)}) and outlasted it.` : null;
296 const id = await createIncident(
297 env.DB,
298 {
299 title,
300 severity: found.draft.some((d) => d.state === "down" && core.has(d.key)) ? "sev2" : "sev3",
301 status: "investigating",
302 visibility: "draft",
303 source: "detected",
304 components: found.draft.map((d) => ({ key: d.key, impact: detectedImpact(d.state) })),
305 started_at: since,
306 acknowledged_at: null,
307 commander: null,
308 communications: null,
309 by: "status",
310 },
311 [
312 {
313 kind: "detected",
314 public: false,
315 status: null,
316 text: `${said.join(" ")}${note ? ` ${note}` : ""} Not on the status page until it is published.`,
317 },
318 ],
319 now,
320 { action: "incident_detected", detail: title },
321 );
322 console.warn(JSON.stringify({ event: "status.detected", id, parts: found.draft.map((d) => d.key), since }));
323 if (alertTo && send) {
324 const { text, html } = renderMail(alertLetter({ title, lines: said, link: sudo(id), ...(note ? { note } : {}) }));
325 ctx.waitUntil(send.send({ to: alertTo, subject: `[g1t status] ${title}`, text, html }).catch((e) => console.error(JSON.stringify({ event: "status.alert_failed", error: String(e) }))));
326 }
327 }
328 // Detected drafts no one picked up, whose parts have stayed healthy long enough: dismissed, with a word to staff.
329 const troubled = new Set(found.streaks.map((s) => s.component));
330 const settled = settleDrafts(await watchedDrafts(env.DB), troubled, now);
331 await saveHealthy(env.DB, settled.healthy);
332 for (const d of settled.dismiss) {
333 const text = autoDismissText(d.lasted_ms, stamp(d.recovered_at));
334 if (!(await autoDismiss(env.DB, d.id, d.recovered_at, text, now))) continue;
335 console.log(JSON.stringify({ event: "status.auto_dismissed", id: d.id, lasted_ms: d.lasted_ms }));
336 if (alertTo && send) {
337 const letter = recoveredLetter({ title: d.title, text, link: sudo(d.id) });
338 const { text: body, html } = renderMail(letter);
339 ctx.waitUntil(send.send({ to: alertTo, subject: `[g1t status] ${letter.heading}`, text: body, html }).catch((e) => console.error(JSON.stringify({ event: "status.alert_failed", error: String(e) }))));
340 }
341 }
342}
343
344/**
345 * The deploy tool's word that a deploy started or finished:
346 * `POST /deploys` with `Authorization: Bearer <STATUS_DEPLOY_TOKEN>` and
347 * `{"phase": "started" | "finished", "id": "<run or commit>"}`. Without
348 * the secret set, there is no such address.
349 */
350async function deployHook(request: Request, env: Env): Promise<Response> {
351 const json = (body: unknown, status = 200) => Response.json(body, { status, headers: { "cache-control": "no-store", ...COMMON } });
352 const token = (env.STATUS_DEPLOY_TOKEN ?? "").trim();
353 if (!token) return json({ error: { code: "not_found", message: "Not found." } }, 404);
354 const given = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "").trim();
355 if (!(await sameSecret(given, token))) return json({ error: { code: "unauthorized", message: "A valid deploy token is required." } }, 401);
356 let body: { phase?: unknown; id?: unknown } = {};
357 try {
358 body = (await request.json()) as typeof body;
359 } catch {
360 // Checked below.
361 }
362 const phase = body.phase === "started" || body.phase === "finished" ? body.phase : null;
363 if (!phase) return json({ error: { code: "invalid", message: 'phase must be "started" or "finished".' } }, 400);
364 const id = typeof body.id === "string" && body.id.trim() ? body.id.trim().slice(0, 100) : null;
365 const now = new Date();
366 const window = deployChange(await loadDeploy(env.DB), phase, id, now);
367 await saveDeploy(env.DB, window);
368 console.log(JSON.stringify({ event: `status.deploy_${phase}`, id }));
369 const quietUntil = window.finished_at ? Date.parse(window.finished_at) + DEPLOY_GRACE_MS : Date.parse(window.started_at) + DEPLOY_MAX_MS;
370 return json({ deploy: window, quiet_until: new Date(quietUntil).toISOString() });
371}
372
373/** Compares two secrets in constant time, by their hashes. */
374async function sameSecret(a: string, b: string): Promise<boolean> {
375 const digest = async (v: string) => new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(v)));
376 const [x, y] = await Promise.all([digest(a), digest(b)]);
377 let diff = a.length === 0 ? 1 : 0;
378 for (let i = 0; i < x.length; i++) diff |= x[i]! ^ y[i]!;
379 return diff === 0;
380}
381
382// --- Pages -------------------------------------------------------------------------------
383
384async function model(env: Env, now: Date, origin: string): Promise<PageModel> {
385 const stored = await load(env.DB, now, origin);
386 return buildPage({
387 parts: parts(env),
388 current: stored.current,
389 checkedAt: stored.checkedAt,
390 days: stored.days,
391 incidents: stored.incidents,
392 maintenance: stored.maintenance,
393 now,
394 });
395}
396
397/** When this isolate last asked for a catch-up round, so a busy page asks once. */
398let caughtUpAt = 0;
399
400function catchUp(env: Env, ctx: ExecutionContext, page: PageModel, now: Date) {
401 const checked = page.report.checked_at ? Date.parse(page.report.checked_at) : 0;
402 if (now.getTime() - checked < BEHIND_MS || now.getTime() - caughtUpAt < BEHIND_MS) return;
403 caughtUpAt = now.getTime();
404 ctx.waitUntil(checkAll(env, now).catch((error) => console.error(JSON.stringify({ event: "status.catch_up_failed", error: String(error) }))));
405}
406
407const PAGE_POLICY = [
408 "default-src 'none'",
409 "script-src 'self'",
410 "style-src 'unsafe-inline'",
411 "font-src 'self'",
412 "img-src 'self' data:",
413 "base-uri 'none'",
414 "form-action 'self'",
415 "frame-ancestors 'none'",
416].join("; ");
417
418const COMMON = {
419 "x-content-type-options": "nosniff",
420 "referrer-policy": "strict-origin-when-cross-origin",
421};
422
423function edgeCache(): Cache | null {
424 return (globalThis as unknown as { caches?: { default?: Cache } }).caches?.default ?? null;
425}
426
427/**
428 * A response from the edge cache, or made and kept there. Pages that say
429 * times pass the reader's zone, and are kept once per zone.
430 */
431async function cached(request: Request, ctx: ExecutionContext, make: () => Promise<Response>, zone?: string): Promise<Response> {
432 const cache = edgeCache();
433 const url = new URL(request.url);
434 const key = new Request(`${url.origin}${url.pathname}${zone ? `?zone=${encodeURIComponent(zone)}` : ""}`, { method: "GET" });
435 if (cache) {
436 const hit = await cache.match(key).catch(() => undefined);
437 if (hit) return hit;
438 }
439 const response = await make();
440 if (cache && response.ok) ctx.waitUntil(cache.put(key, response.clone()).catch(() => undefined));
441 return response;
442}
443
444const FONTS: Record<string, ArrayBuffer> = {
445 "/fonts/hanken-grotesk.woff2": hanken,
446 "/fonts/bricolage-grotesque.woff2": bricolage,
447 "/fonts/ibm-plex-mono.woff2": plexMono,
448};
449
450function html(body: string, cacheControl: string, status = 200): Response {
451 return new Response(body, {
452 status,
453 headers: { "content-type": "text/html; charset=utf-8", "cache-control": cacheControl, "content-security-policy": PAGE_POLICY, ...COMMON },
454 });
455}
456
457async function form(request: Request): Promise<FormData> {
458 try {
459 return await request.formData();
460 } catch {
461 return new FormData();
462 }
463}
464
465/** Subscribing, confirming and leaving: the page's only writes. */
466async function subscriptions(request: Request, env: Env, ctx: ExecutionContext, url: URL, options: PageOptions): Promise<Response | null> {
467 const path = url.pathname;
468 const noStore = "no-store";
469 const message = (title: string, text: string, status = 200, f?: { action: string; fields: Record<string, string>; button: string }) =>
470 html(renderMessage({ ...options, selfUrl: `${url.origin}${path}` }, { title, text, form: f }), noStore, status);
471 const post = request.method === "POST";
472
473 if (path === "/subscribe" && post) {
474 if (!emailOn(env)) return message("Email updates are not available", "Follow the Atom or JSON feed instead.", 503);
475 const data = await form(request);
476 if (String(data.get("website") ?? "")) return message("Check your inbox", "If the address is right, a confirmation link is on its way.");
477 const email = normalizeEmail(data.get("email"));
478 if (!email) return message("That is not an email address", "Go back and check it.", 400);
479 const chosen = chosenParts(data.getAll("components").map(String), parts(env).map((p) => p.key));
480 const token = newToken();
481 const { send } = await requestSubscription(env.DB, email, chosen, await hashToken(token), new Date(), CONFIRM_TTL_MS, RESEND_AFTER_MS);
482 if (send) {
483 const link = `${url.origin}/subscribe/confirm?token=${encodeURIComponent(token)}`;
484 const { text, html: body } = renderMail(confirmLetter(link, chosen ? chosen.map((k) => names(env).get(k) ?? k) : null));
485 ctx.waitUntil(sender(env)!.send({ to: email, subject: "Confirm your subscription to g1t status", text, html: body }).catch((e) => console.error(JSON.stringify({ event: "status.confirm_failed", error: String(e) }))));
486 }
487 return message("Check your inbox", "If the address is right, a confirmation link is on its way. It works for 24 hours.");
488 }
489 if (path === "/subscribe/confirm") {
490 const token = url.searchParams.get("token") ?? (post ? String((await form(request)).get("token") ?? "") : "");
491 if (!token) return message("That link is incomplete", "Copy the whole link from the email.", 400);
492 if (!post) return message("Confirm your subscription", "One more step: confirm to start getting emails about incidents and maintenance.", 200, { action: "/subscribe/confirm", fields: { token }, button: "Confirm subscription" });
493 const done = await confirmSubscription(env.DB, await hashToken(token), new Date());
494 if (!done) return message("That link has expired", "Confirmation links work for 24 hours and once. Subscribe again for a new one.", 410);
495 return message("You are subscribed", `${done.email} will get an email when g1t posts an incident or maintenance${done.parts ? ` affecting ${done.parts.map((k) => names(env).get(k) ?? k).join(", ")}` : ""}. Every email has a link to unsubscribe.`);
496 }
497 if (path === "/unsubscribe") {
498 const token = url.searchParams.get("token") ?? (post ? String((await form(request)).get("token") ?? "") : "");
499 const id = env.STATUS_SECRET && token ? await readUnsubscribeToken(env.STATUS_SECRET, token) : null;
500 if (!id) return message("That link is not valid", "Use the unsubscribe link at the bottom of any email from g1t status.", 400);
501 if (!post) return message("Unsubscribe", "Stop getting emails from g1t status?", 200, { action: "/unsubscribe", fields: { token }, button: "Unsubscribe" });
502 await unsubscribe(env.DB, id);
503 return message("You are unsubscribed", "You will not get any more emails from g1t status. You can subscribe again at any time.");
504 }
505 return null;
506}
507
508async function handle(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
509 const url = new URL(request.url);
510 const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : url.pathname;
511 if (request.method === "OPTIONS" && (path === "/status.json" || path === "/feed.json")) {
512 return new Response(null, {
513 status: 204,
514 headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, HEAD", "access-control-max-age": "86400" },
515 });
516 }
517 const now = new Date();
518 const origin = originOf(env, url);
519 const site = env.PUBLIC_SITE_URL || env.SITE_URL || url.origin;
520 const options: PageOptions = {
521 siteUrl: site,
522 supportUrl: env.SUPPORT_URL || `${site}/support`,
523 ogImage: env.OG_IMAGE ?? "",
524 selfUrl: `${url.origin}${path === "/" ? "/" : path}`,
525 now,
526 email: emailOn(env),
527 zone: readZone(request.headers.get("cookie"), (request as { cf?: { timezone?: unknown } }).cf?.timezone),
528 };
529
530 if (request.method === "POST" && path === "/deploys") return deployHook(request, env);
531 if (request.method === "POST") {
532 const answer = await subscriptions(request, env, ctx, url, options);
533 return answer ?? new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD", ...COMMON } });
534 }
535 if (request.method !== "GET" && request.method !== "HEAD") {
536 return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD, POST", ...COMMON } });
537 }
538 const fresh = (cacheSeconds = CACHE_SECONDS) => `public, max-age=${cacheSeconds}`;
539 const notFound = () => html(renderMessage(options, { title: "Not found", text: "There is nothing at this address." }), fresh(), 404);
540
541 switch (path) {
542 case "/":
543 return cached(request, ctx, async () => {
544 const page = await model(env, now, origin);
545 catchUp(env, ctx, page, now);
546 return html(renderPage(page, options), fresh());
547 }, options.zone);
548 case "/status.json":
549 return cached(request, ctx, async () => {
550 const page = await model(env, now, origin);
551 catchUp(env, ctx, page, now);
552 return Response.json(page.report, { headers: { "cache-control": fresh(), "access-control-allow-origin": "*", ...COMMON } });
553 });
554 case "/badge.svg":
555 return cached(request, ctx, async () => {
556 const page = await model(env, now, origin);
557 return new Response(renderBadge(page.report.overall.state, page.report.overall.title), {
558 headers: { "content-type": "image/svg+xml", "cache-control": fresh(), "access-control-allow-origin": "*", ...COMMON },
559 });
560 });
561 case "/history":
562 return cached(request, ctx, async () => {
563 const since = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1));
564 const { incidents, maintenance } = await loadHistory(env.DB, since, origin);
565 return html(renderHistory(incidents, maintenance, options), fresh());
566 }, options.zone);
567 case "/feed.xml":
568 case "/feed.json":
569 return cached(request, ctx, async () => {
570 const { incidents, maintenance } = await loadHistory(env.DB, new Date(now.getTime() - 365 * 86_400_000), origin);
571 const items = feedItems(incidents, maintenance);
572 const feed = { origin, title: "g1t status", updated: now.toISOString() };
573 const headers = { "cache-control": fresh(60), "access-control-allow-origin": "*", ...COMMON };
574 return path === "/feed.xml"
575 ? new Response(atom(items, feed), { headers: { "content-type": "application/atom+xml; charset=utf-8", ...headers } })
576 : Response.json(jsonFeed(items, feed), { headers: { "content-type": "application/feed+json; charset=utf-8", ...headers } });
577 });
578 case "/subscribe":
579 return html(renderSubscribe(options, parts(env).map(({ key, name }) => ({ key, name }))), fresh(300));
580 case "/subscribe/confirm":
581 case "/unsubscribe":
582 return (await subscriptions(request, env, ctx, url, options))!;
583 case "/status.js":
584 return new Response(SCRIPT, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": fresh(3600), ...COMMON } });
585 case "/favicon.svg":
586 case "/favicon.ico":
587 return new Response(FAVICON, { headers: { "content-type": "image/svg+xml", "cache-control": fresh(86400), ...COMMON } });
588 case "/robots.txt":
589 return new Response("User-agent: *\nAllow: /\nDisallow: /subscribe/confirm\nDisallow: /unsubscribe\n", {
590 headers: { "content-type": "text/plain", "cache-control": fresh(86400) },
591 });
592 }
593 const incident = /^\/incidents\/([a-z0-9-]{1,64})$/.exec(path);
594 if (incident) {
595 return cached(request, ctx, async () => {
596 const found = await loadPublicIncident(env.DB, incident[1]!, origin);
597 return found ? html(renderIncident(found.incident, found.postmortem, names(env), options), fresh()) : notFound();
598 }, options.zone);
599 }
600 const maintenance = /^\/maintenance\/([a-z0-9-]{1,64})$/.exec(path);
601 if (maintenance) {
602 return cached(request, ctx, async () => {
603 const found = await loadPublicMaintenance(env.DB, maintenance[1]!, origin);
604 return found ? html(renderMaintenance(found, names(env), options), fresh()) : notFound();
605 }, options.zone);
606 }
607 const font = FONTS[path];
608 if (font) {
609 return new Response(font, { headers: { "content-type": "font/woff2", "cache-control": "public, max-age=31536000, immutable", ...COMMON } });
610 }
611 return notFound();
612}
613
614export default {
615 async fetch(request, env, ctx) {
616 try {
617 return await handle(request, env, ctx);
618 } catch (error) {
619 console.error(JSON.stringify({ event: "status.failed", path: new URL(request.url).pathname, error: String(error) }));
620 return new Response("The status page could not be drawn. Try again in a minute.", {
621 status: 503,
622 headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "60", ...COMMON },
623 });
624 }
625 },
626 async scheduled(_controller, env, ctx) {
627 const now = new Date();
628 ctx.waitUntil(
629 checkAll(env, now)
630 .then(async (observations) => {
631 const failing = observations.filter((o) => o.state === "down" || o.state === "degraded");
632 if (failing.length) console.warn(JSON.stringify({ event: "status.trouble", parts: failing }));
633 await afterChecks(env, ctx, observations, now);
634 })
635 .catch((error) => console.error(JSON.stringify({ event: "status.cron_failed", error: String(error) }))),
636 );
637 },
638} satisfies ExportedHandler<Env>;
639
640// --- Staff ---------------------------------------------------------------------------------
641
642/**
643 * Staff only: running incidents and maintenance. Reached only through a
644 * service binding (sudo's `STATUS`); status.g1t.sh's own address cannot.
645 */
646export class StatusAdmin extends WorkerEntrypoint<Env> implements StatusAdminApi {
647 private known() {
648 return parts(this.env).map((p) => p.key);
649 }
650
651 private origin() {
652 return originOf(this.env);
653 }
654
655 private async detail(id: string): Promise<AdminIncidentDetail | null> {
656 return incidentDetail(this.env.DB, String(id), this.origin(), names(this.env));
657 }
658
659 private async summary(id: string): Promise<AdminIncident> {
660 const { timeline: _t, followups: _f, postmortem: _p, postmortem_draft: _d, url: _u, ...incident } = (await this.detail(id))!;
661 return incident;
662 }
663
664 /** Emails a public update to subscribers when asked; the count to keep with it. */
665 private async announce(incident: { id: string; title: string; components: { key: string; impact: string }[] }, status: keyof typeof INCIDENT_STATUS, text: string, wanted: boolean) {
666 if (!wanted) return null;
667 const about = incident.components.filter((c) => c.impact !== "operational").map((c) => c.key);
668 return notify(this.env, this.ctx, about, {
669 heading: `${INCIDENT_STATUS[status]}: ${incident.title}`,
670 text,
671 url: `${this.origin()}/incidents/${incident.id}`,
672 });
673 }
674
675 async components(): Promise<{ key: string; name: string }[]> {
676 return parts(this.env).map(({ key, name }) => ({ key, name }));
677 }
678
679 async board(): Promise<StatusBoard> {
680 return { ...(await board(this.env.DB, new Date(), this.origin())), email: emailOn(this.env) };
681 }
682
683 async incident(id: string): Promise<AdminIncidentDetail | null> {
684 return this.detail(id);
685 }
686
687 async openCount(): Promise<number> {
688 return openCount(this.env.DB);
689 }
690
691 async declare(input: DeclareIncident): Promise<Result<AdminIncident>> {
692 const checked = checkDeclare(input, this.known());
693 if (!checked.ok) return fail("invalid", checked.error);
694 const v = checked.value;
695 const now = new Date();
696 const entries: (Entry & { notified?: number | null })[] = [
697 { kind: "declared", public: false, status: null, text: `Declared ${SEVERITY_LABEL[v.severity]}.` },
698 ];
699 if (v.commander) entries.push({ kind: "role", public: false, status: null, text: `Incident commander: ${v.commander}.` });
700 if (v.communications) entries.push({ kind: "role", public: false, status: null, text: `Communications: ${v.communications}.` });
701 const id = shortId();
702 const status = v.status ?? "investigating";
703 const notified = await this.announce({ id, title: v.title, components: v.components }, status, v.message, v.notify);
704 entries.push({ kind: "update", public: true, status, text: v.message, notified });
705 await createIncident(
706 this.env.DB,
707 {
708 title: v.title,
709 severity: v.severity,
710 status,
711 visibility: "public",
712 source: "declared",
713 components: v.components,
714 started_at: v.started_at ?? now.toISOString(),
715 acknowledged_at: now.toISOString(),
716 commander: v.commander ?? null,
717 communications: v.communications ?? null,
718 by: v.by,
719 },
720 entries,
721 now,
722 { action: "incident_declared", detail: `${SEVERITY_LABEL[v.severity]}: ${v.title}` },
723 id,
724 );
725 console.log(JSON.stringify({ event: "status.incident_declared", id, by: v.by }));
726 return ok(await this.summary(id));
727 }
728
729 async update(id: string, change: IncidentChange): Promise<Result<AdminIncident>> {
730 const checked = checkChange(change, this.known());
731 if (!checked.ok) return fail("invalid", checked.error);
732 const existing = await this.detail(id);
733 if (!existing) return fail("not_found", "No such incident.");
734 const now = new Date();
735 const applied = applyChange(facts(existing), checked.value, now, names(this.env));
736 if (!applied.ok) return fail("invalid", applied.error);
737 const { next, entries } = applied.value;
738 const update = entries.find((e) => e.kind === "update");
739 const notified = update
740 ? await this.announce({ id: existing.id, title: existing.title, components: next.components }, next.status, update.text, checked.value.notify === true)
741 : null;
742 const lines = entries.map((e) => (e === update ? { ...e, notified } : e));
743 const action = next.status === "resolved" && existing.status !== "resolved" ? "incident_resolved" : update ? "incident_update" : "incident_note";
744 await saveIncident(this.env.DB, existing.id, next, lines, now, checked.value.by, {
745 action,
746 detail: entries.map((e) => (e.kind === "update" || e.kind === "note" ? `${e.kind === "update" ? "Public" : "Note"}: ${e.text}` : e.text)).join(" ").slice(0, 500),
747 });
748 return ok(await this.summary(existing.id));
749 }
750
751 async roles(id: string, change: RolesChange): Promise<Result<AdminIncident>> {
752 const checked = checkRoles(change);
753 if (!checked.ok) return fail("invalid", checked.error);
754 const existing = await this.detail(id);
755 if (!existing) return fail("not_found", "No such incident.");
756 const now = new Date();
757 const { next, entries } = applyRoles(facts(existing), checked.value, now);
758 if (!entries.length) return ok(await this.summary(existing.id));
759 await saveIncident(this.env.DB, existing.id, next, entries, now, checked.value.by, { action: "incident_roles", detail: entries.map((e) => e.text).join(" ") });
760 return ok(await this.summary(existing.id));
761 }
762
763 async publish(id: string, input: PublishIncident): Promise<Result<AdminIncident>> {
764 const checked = checkPublish(input);
765 if (!checked.ok) return fail("invalid", checked.error);
766 const existing = await this.detail(id);
767 if (!existing) return fail("not_found", "No such incident.");
768 if (existing.visibility !== "draft") return fail("conflict", "Only a draft can be published.");
769 const now = new Date();
770 const at = now.toISOString();
771 const title = checked.value.title ?? existing.title;
772 const next = { ...facts(existing), visibility: "public" as const, acknowledged_at: existing.acknowledged_at ?? at, title, published_at: at };
773 const notified = await this.announce({ id: existing.id, title, components: existing.components }, existing.status, checked.value.message, checked.value.notify);
774 await saveIncident(
775 this.env.DB,
776 existing.id,
777 next,
778 [
779 ...(existing.acknowledged_at ? [] : [{ kind: "acknowledged" as const, public: false, status: null, text: "Acknowledged." }]),
780 { kind: "published", public: false, status: null, text: title !== existing.title ? `Published as “${title}”.` : "Published to the status page." },
781 { kind: "update", public: true, status: existing.status, text: checked.value.message, notified },
782 ],
783 now,
784 checked.value.by,
785 { action: "incident_published", detail: title },
786 );
787 return ok(await this.summary(existing.id));
788 }
789
790 async dismiss(id: string, input: { reason: string; by: string }): Promise<Result<AdminIncident>> {
791 const existing = await this.detail(id);
792 if (!existing) return fail("not_found", "No such incident.");
793 if (existing.visibility !== "draft") return fail("conflict", "Only a draft can be dismissed; resolve a published incident instead.");
794 const by = String(input?.by ?? "").trim();
795 if (!by) return fail("invalid", "Who is making the change is missing.");
796 const reason = String(input?.reason ?? "").trim().slice(0, 500) || "No reason given.";
797 const now = new Date();
798 const at = now.toISOString();
799 const next = { ...facts(existing), visibility: "dismissed" as const, status: "resolved" as const, acknowledged_at: existing.acknowledged_at ?? at, resolved_at: at };
800 await saveIncident(this.env.DB, existing.id, next, [{ kind: "dismissed", public: false, status: null, text: `Dismissed: ${reason}` }], now, by, {
801 action: "incident_dismissed",
802 detail: `${existing.title}: ${reason}`,
803 });
804 return ok(await this.summary(existing.id));
805 }
806
807 async addFollowUp(id: string, input: { title: string; owner: string | null; by: string }): Promise<Result<FollowUp>> {
808 const checked = checkFollowUp(input);
809 if (!checked.ok) return fail("invalid", checked.error);
810 if (!(await this.detail(id))) return fail("not_found", "No such incident.");
811 return ok(await addFollowUp(this.env.DB, String(id), checked.value, new Date()));
812 }
813
814 async setFollowUp(id: string, followUp: string, input: { done: boolean; by: string }): Promise<Result<FollowUp>> {
815 const by = String(input?.by ?? "").trim();
816 if (!by) return fail("invalid", "Who is making the change is missing.");
817 const done = await setFollowUp(this.env.DB, String(id), String(followUp), input.done === true, by, new Date());
818 return done ? ok(done) : fail("not_found", "No such follow-up.");
819 }
820
821 async savePostmortem(id: string, input: PostmortemFields & { by: string }): Promise<Result<Postmortem>> {
822 const checked = checkPostmortem(input);
823 if (!checked.ok) return fail("invalid", checked.error);
824 const existing = await this.detail(id);
825 if (!existing) return fail("not_found", "No such incident.");
826 if (existing.visibility !== "public") return fail("conflict", "Only a published incident has a postmortem.");
827 const { by, ...fields } = checked.value;
828 await savePostmortem(this.env.DB, existing.id, fields, by, new Date());
829 return ok((await this.detail(existing.id))!.postmortem!);
830 }
831
832 async publishPostmortem(id: string, input: { publish: boolean; by: string }): Promise<Result<Postmortem>> {
833 const by = String(input?.by ?? "").trim();
834 if (!by) return fail("invalid", "Who is making the change is missing.");
835 const existing = await this.detail(id);
836 if (!existing) return fail("not_found", "No such incident.");
837 if (!existing.postmortem) return fail("conflict", "Save the postmortem before publishing it.");
838 if (input.publish) {
839 if (!existing.resolved_at) return fail("conflict", "Resolve the incident before publishing its postmortem.");
840 const missing = postmortemReady(existing.postmortem);
841 if (missing) return fail("invalid", missing);
842 }
843 await publishPostmortem(this.env.DB, existing.id, input.publish === true, by, new Date());
844 return ok((await this.detail(existing.id))!.postmortem!);
845 }
846
847 async scheduleMaintenance(input: NewMaintenance): Promise<Result<AdminMaintenance>> {
848 const checked = checkMaintenance(input, this.known());
849 if (!checked.ok) return fail("invalid", checked.error);
850 const v = checked.value;
851 const now = new Date();
852 const id = shortId();
853 const notified = v.notify
854 ? await notify(this.env, this.ctx, v.components, {
855 heading: `Planned maintenance: ${v.title}`,
856 text: `${v.message}\n\nWhen: ${stamp(v.starts_at)} to ${stamp(v.ends_at)}.`,
857 url: maintenanceUrl(this.origin(), id),
858 })
859 : null;
860 const made = await scheduleMaintenance(this.env.DB, v, notified, now, id);
861 return ok((await maintenanceById(this.env.DB, made, this.origin()))!);
862 }
863
864 async changeMaintenance(id: string, change: MaintenanceChange): Promise<Result<AdminMaintenance>> {
865 const checked = checkMaintenanceChange(change);
866 if (!checked.ok) return fail("invalid", checked.error);
867 const existing = await maintenanceById(this.env.DB, String(id), this.origin());
868 if (!existing) return fail("not_found", "No such maintenance.");
869 const v = checked.value;
870 if (existing.state === "completed" || existing.state === "cancelled") return fail("conflict", `This maintenance is ${existing.state}.`);
871 if (v.action === "start" && existing.state !== "scheduled") return fail("conflict", "It has already started.");
872 const state = v.action === "start" ? "in_progress" : v.action === "complete" ? "completed" : v.action === "cancel" ? "cancelled" : null;
873 const text =
874 v.message ||
875 (v.action === "start" ? "The maintenance has begun." : v.action === "complete" ? "The maintenance is complete." : "This maintenance is cancelled.");
876 const word = { update: "Update", start: "In progress", complete: "Completed", cancel: "Cancelled" }[v.action];
877 const notified = v.notify ? await notify(this.env, this.ctx, existing.components, { heading: `${word}: ${existing.title}`, text, url: existing.url }) : null;
878 await maintenanceUpdate(this.env.DB, existing.id, state, text, v.by, notified, new Date(), {
879 action: `maintenance_${v.action === "update" ? "update" : v.action === "start" ? "started" : v.action === "complete" ? "completed" : "cancelled"}`,
880 detail: `${existing.title}: ${text}`,
881 });
882 return ok((await maintenanceById(this.env.DB, existing.id, this.origin()))!);
883 }
884
885 async audit(filter?: { before?: string | null }): Promise<StatusAuditEntry[]> {
886 const before = filter?.before && !Number.isNaN(Date.parse(filter.before)) ? filter.before : null;
887 return auditLog(this.env.DB, before);
888 }
889}
890