g1t/apps/status/src/index.ts

890 lines42,242 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1/**
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)
Fast pages, required checks on the branch, self-hosted runners, honest incidents26 * POST /deploys the deploy tool: a deploy started or finished (bearer STATUS_DEPLOY_TOKEN)
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas27 */
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";
Fast pages, required checks on the branch, self-hosted runners, honest incidents55import {
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";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas69import { 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";
Fast pages, required checks on the branch, self-hosted runners, honest incidents86import { INCIDENT_STATUS, type PageModel, SLOW_MS, buildPage, classify, underMaintenance } from "./model.ts";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas87import { stamp } from "./postmortem.ts";
88import { runCheck } from "./probe.ts";
Fast pages, required checks on the branch, self-hosted runners, honest incidents89import { readZone } from "./time.ts";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas90import {
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,
Fast pages, required checks on the branch, self-hosted runners, honest incidents107 autoDismiss,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas108 board,
109 confirmSubscription,
110 createIncident,
111 dueMaintenance,
112 facts,
113 incidentDetail,
114 load,
Fast pages, required checks on the branch, self-hosted runners, honest incidents115 loadDeploy,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas116 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,
Fast pages, required checks on the branch, self-hosted runners, honest incidents129 saveDeploy,
130 saveHealthy,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas131 saveIncident,
132 savePostmortem,
133 saveStreaks,
134 scheduleMaintenance,
135 setFollowUp,
136 unsubscribe,
Fast pages, required checks on the branch, self-hosted runners, honest incidents137 watchedDrafts,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas138} 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;
Fast pages, required checks on the branch, self-hosted runners, honest incidents167 /**
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;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas172}
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 });
Fast pages, required checks on the branch, self-hosted runners, honest incidents213 const { state, detail } = classify(result, info.slowMs);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas214 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 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents273 // 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 });
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas277 await saveStreaks(env.DB, found.streaks);
Fast pages, required checks on the branch, self-hosted runners, honest incidents278 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;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas281 const lines = [
Fast pages, required checks on the branch, self-hosted runners, honest incidents282 ...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)) })),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas284 ];
285 await addSystemLines(env.DB, lines, now);
Fast pages, required checks on the branch, self-hosted runners, honest incidents286 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);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas289 if (found.draft.length) {
290 const core = new Set(parts(env).filter((p) => p.core).map((p) => p.key));
Fast pages, required checks on the branch, self-hosted runners, honest incidents291 const title = draftTitle(found.draft.map((d) => ({ name: name(d.key), state: d.state })));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas292 const since = found.draft.map((d) => d.since).sort()[0]!;
Fast pages, required checks on the branch, self-hosted runners, honest incidents293 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;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas296 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,
Fast pages, required checks on the branch, self-hosted runners, honest incidents316 text: `${said.join(" ")}${note ? ` ${note}` : ""} Not on the status page until it is published.`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas317 },
318 ],
319 now,
320 { action: "incident_detected", detail: title },
321 );
Fast pages, required checks on the branch, self-hosted runners, honest incidents322 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) }))));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas340 }
341 }
342}
343
Fast pages, required checks on the branch, self-hosted runners, honest incidents344/**
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
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas382// --- 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
Fast pages, required checks on the branch, self-hosted runners, honest incidents427/**
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> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas432 const cache = edgeCache();
433 const url = new URL(request.url);
Fast pages, required checks on the branch, self-hosted runners, honest incidents434 const key = new Request(`${url.origin}${url.pathname}${zone ? `?zone=${encodeURIComponent(zone)}` : ""}`, { method: "GET" });
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas435 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),
Fast pages, required checks on the branch, self-hosted runners, honest incidents527 zone: readZone(request.headers.get("cookie"), (request as { cf?: { timezone?: unknown } }).cf?.timezone),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas528 };
529
Fast pages, required checks on the branch, self-hosted runners, honest incidents530 if (request.method === "POST" && path === "/deploys") return deployHook(request, env);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas531 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());
Fast pages, required checks on the branch, self-hosted runners, honest incidents547 }, options.zone);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas548 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());
Fast pages, required checks on the branch, self-hosted runners, honest incidents566 }, options.zone);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas567 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();
Fast pages, required checks on the branch, self-hosted runners, honest incidents598 }, options.zone);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas599 }
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();
Fast pages, required checks on the branch, self-hosted runners, honest incidents605 }, options.zone);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas606 }
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