Skip to content
974 linesCodeBlameRaw

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";
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails50import { LIMIT_PERIOD_SECONDS, type RateLimitBinding, clientAddress, isLimited, secretKey } from "@g1t/contracts/rate-limits";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas51import bricolage from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2";
52import hanken from "@g1t/theme/fonts/hanken-grotesk-latin.woff2";
53import plexMono from "@g1t/theme/fonts/ibm-plex-mono-latin-400.woff2";
54
55import { type Targets, components } from "./components.ts";
Fast pages, required checks on the branch, self-hosted runners, honest incidents56import {
57 autoDismissText,
58 deployChange,
59 deployQuiet,
60 detect,
61 detectedImpact,
62 draftTitle,
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders63 minutesWords,
64 quietUntil,
Fast pages, required checks on the branch, self-hosted runners, honest incidents65 recoverySentence,
66 settleDrafts,
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders67 staleDrafts,
68 staleText,
Fast pages, required checks on the branch, self-hosted runners, honest incidents69 troubleSentence,
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders70 troubledNow,
Fast pages, required checks on the branch, self-hosted runners, honest incidents71} from "./detect.ts";
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders72import {
73 type EmailBinding,
74 type Sender,
75 alertLetter,
76 bindingSender,
77 confirmLetter,
78 recoveredLetter,
79 render as renderMail,
80 staleLetter,
81 unsubscribeHeaders,
82 updateLetter,
83} from "./email.ts";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas84import { atom, feedItems, jsonFeed } from "./feed.ts";
85import {
86 type Entry,
87 applyChange,
88 applyRoles,
89 checkChange,
90 checkDeclare,
91 checkFollowUp,
92 checkMaintenance,
93 checkMaintenanceChange,
94 checkPostmortem,
95 checkPublish,
96 checkRoles,
97 postmortemReady,
98 SEVERITY_LABEL,
99 shortId,
100} from "./incidents.ts";
Fast pages, required checks on the branch, self-hosted runners, honest incidents101import { 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 quotas102import { stamp } from "./postmortem.ts";
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders103import { type StorageReport, probe } from "./probe.ts";
Fast pages, required checks on the branch, self-hosted runners, honest incidents104import { readZone } from "./time.ts";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas105import {
106 FAVICON,
107 SCRIPT,
108 type PageOptions,
109 renderBadge,
110 renderHistory,
111 renderIncident,
112 renderMaintenance,
113 renderMessage,
114 renderPage,
115 renderSubscribe,
116} from "./render.ts";
117import {
118 type Observation,
119 addFollowUp,
120 addSystemLines,
121 auditLog,
Fast pages, required checks on the branch, self-hosted runners, honest incidents122 autoDismiss,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas123 board,
124 confirmSubscription,
125 createIncident,
126 dueMaintenance,
127 facts,
128 incidentDetail,
129 load,
Fast pages, required checks on the branch, self-hosted runners, honest incidents130 loadDeploy,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas131 loadHistory,
132 loadPublicIncident,
133 loadPublicMaintenance,
134 loadStreaks,
135 maintenanceById,
136 maintenanceUrl,
137 maintenanceUpdate,
138 openCount,
139 openRefs,
140 publishPostmortem,
141 recipients,
142 record,
143 requestSubscription,
Fast pages, required checks on the branch, self-hosted runners, honest incidents144 saveDeploy,
145 saveHealthy,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas146 saveIncident,
147 savePostmortem,
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders148 saveReminders,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas149 saveStreaks,
150 scheduleMaintenance,
151 setFollowUp,
152 unsubscribe,
Fast pages, required checks on the branch, self-hosted runners, honest incidents153 watchedDrafts,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas154} from "./store.ts";
155import { CONFIRM_TTL_MS, RESEND_AFTER_MS, chosenParts, hashToken, newToken, normalizeEmail, readUnsubscribeToken, unsubscribeToken } from "./subscribers.ts";
156
157export interface Env extends Partial<Targets> {
158 DB: D1Database;
159 /** Billing, for reading its price book. Optional: without it, billing is not listed. */
160 BILLING?: Fetcher;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily161 /** The repos service, for git storage's recent health. Optional: without it, git storage is not listed. */
162 REPOS?: Fetcher;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas163 /** Where help is. */
164 SUPPORT_URL?: string;
165 /**
166 * The site's address as people's browsers reach it, for the page's links,
167 * when the checks reach it by another (self-hosted: `http://g1t:8787`
168 * inside Compose). SITE_URL when empty.
169 */
170 PUBLIC_SITE_URL?: string;
171 /** The page's share card; empty for none. */
172 OG_IMAGE?: string;
173 /** This page's own address, for links in email and feeds made outside a request. */
174 STATUS_URL?: string;
175 /** Where sudo is, for the staff alert's link. */
176 SUDO_URL?: string;
177 /** Who hears about detected drafts. Empty sends none. */
178 STATUS_ALERT_EMAIL?: string;
179 /** The From of every email. */
180 STATUS_FROM?: string;
181 /** Signs unsubscribe links (a secret). Without it, email subscriptions are off; the feeds still work. */
182 STATUS_SECRET?: string;
183 /** Cloudflare Email Sending (`send_email`). Without it, nothing is emailed. */
184 EMAIL?: EmailBinding;
Fast pages, required checks on the branch, self-hosted runners, honest incidents185 /**
186 * The deploy tool's bearer token for `POST /deploys` (a secret). Without
187 * it, deploys are not announced and detection does not hold off for them.
188 */
189 STATUS_DEPLOY_TOKEN?: string;
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails190 /**
191 * Asking for a subscription, per client address and per email address
192 * (RATE_LIMITS in packages/contracts). Without them, only the resend
193 * window (RESEND_AFTER_MS) holds back repeated emails to one address.
194 */
195 STATUS_SUBSCRIBE_LIMIT?: RateLimitBinding;
196 STATUS_EMAIL_LIMIT?: RateLimitBinding;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas197}
198
199/** How long the edge keeps a page or the JSON. */
200const CACHE_SECONDS = 30;
201/** Older than this, a visit asks for a round of checks too (a missed cron, or `wrangler dev`). */
202const BEHIND_MS = 3 * 60 * 1000;
203/** How many subscribers one update emails at most, within a Worker's limits. */
204const MAX_RECIPIENTS = 900;
205
206function parts(env: Env) {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily207 return components(env, env.BILLING != null, env.REPOS != null);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas208}
209
210function names(env: Env): Map<string, string> {
211 return new Map(parts(env).map((p) => [p.key, p.name]));
212}
213
214/** This page's address: the request's own, or STATUS_URL outside a request. */
215function originOf(env: Env, url?: URL): string {
216 return (url?.origin ?? env.STATUS_URL ?? "https://status.g1t.sh").replace(/\/+$/, "");
217}
218
219function sender(env: Env): Sender | null {
220 return bindingSender(env.EMAIL, env.STATUS_FROM || undefined);
221}
222
223/** Whether subscribers can sign up: a way to send, and a secret to sign their links. */
224function emailOn(env: Env): boolean {
225 return sender(env) != null && !!env.STATUS_SECRET;
226}
227
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily228/** Git storage's last five minutes, from the repos service (`store_health`). */
229async function storeHealth(repos: Fetcher): Promise<StorageReport> {
230 const response = await repos.fetch("https://service/rpc/store_health", {
231 method: "POST",
232 headers: { "content-type": "application/json" },
233 body: JSON.stringify({ minutes: 5 }),
234 });
235 if (!response.ok) throw new Error(`store_health failed with status ${response.status}`);
236 return (await response.json()) as StorageReport;
237}
238
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas239/** One round of checks, kept. Parts under maintenance are checked but not tallied. */
240export async function checkAll(env: Env, now = new Date()): Promise<Observation[]> {
241 const billing = env.BILLING;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily242 const repos = env.REPOS;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas243 const list = parts(env);
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders244 const results = await Promise.all(
245 list.map(async (info) => {
246 // A slow answer is asked again at once before it counts (probe.ts `probe`).
247 const result = await probe(
248 info.check,
249 {
250 fetch: (url, init) => fetch(url, init),
251 billing: billing ? () => billingClient(billing).prices() : null,
252 storage: repos ? () => storeHealth(repos) : null,
253 },
254 info.slowMs ?? SLOW_MS,
255 );
256 return { info, result };
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas257 }),
258 );
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders259 // Checks through a binding have no cf-ray of their own: they ran where the others did.
260 const roundColo = results.find((r) => r.result?.colo)?.result?.colo ?? null;
261 const observations = results.map(({ info, result }): Observation => {
262 const { state, detail } = classify(result, info.slowMs);
263 return {
264 component: info.key,
265 state,
266 detail,
267 latency_ms: result ? Math.round(result.ms) : null,
268 colo: result ? (result.colo ?? roundColo) : null,
269 first_ms: result?.first_ms != null ? Math.round(result.first_ms) : null,
270 };
271 });
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas272 const { maintenance } = await load(env.DB, now, originOf(env));
273 await record(env.DB, observations, now, underMaintenance(maintenance, now));
274 return observations;
275}
276
277// --- Email ---------------------------------------------------------------------------
278
279/**
280 * Emails confirmed subscribers who want news about `about`, in the
281 * background. Returns how many it will email, or null when email is off.
282 */
283async function notify(
284 env: Env,
285 ctx: { waitUntil(p: Promise<unknown>): void },
286 about: string[],
287 mail: { heading: string; text: string; url: string },
288): Promise<number | null> {
289 const send = sender(env);
290 const secret = env.STATUS_SECRET;
291 if (!send || !secret) return null;
292 const list = (await recipients(env.DB, about)).slice(0, MAX_RECIPIENTS);
293 const origin = originOf(env);
294 const affects = about.map((k) => names(env).get(k) ?? k);
295 ctx.waitUntil(
296 (async () => {
297 let failed = 0;
298 for (let i = 0; i < list.length; i += 6) {
299 await Promise.all(
300 list.slice(i, i + 6).map(async (r) => {
301 const link = `${origin}/unsubscribe?token=${encodeURIComponent(await unsubscribeToken(secret, r.id))}`;
302 const { text, html } = renderMail(updateLetter({ heading: mail.heading, text: mail.text, url: mail.url, affects, unsubscribe: link }));
303 await send.send({ to: r.email, subject: mail.heading, text, html, headers: unsubscribeHeaders(link) }).catch(() => void (failed += 1));
304 }),
305 );
306 }
307 console.log(JSON.stringify({ event: "status.notified", sent: list.length - failed, failed }));
308 })(),
309 );
310 return list.length;
311}
312
313// --- The cron: detection and maintenance --------------------------------------------------
314
315async function afterChecks(env: Env, ctx: { waitUntil(p: Promise<unknown>): void }, observations: Observation[], now: Date): Promise<void> {
316 const origin = originOf(env);
317 const named = names(env);
318 // Maintenance whose window opened or closed.
319 for (const m of await dueMaintenance(env.DB, now, origin)) {
320 const ended = Date.parse(m.ends_at) <= now.getTime();
321 const text = ended ? "The maintenance is complete." : "The maintenance has begun.";
322 const notified = m.notify ? await notify(env, ctx, m.components, { heading: `${ended ? "Completed" : "In progress"}: ${m.title}`, text, url: m.url }) : null;
323 await maintenanceUpdate(env.DB, m.id, ended ? "completed" : "in_progress", text, "status", notified, now, {
324 action: ended ? "maintenance_completed" : "maintenance_started",
325 detail: `${m.title} (on schedule)`,
326 });
327 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents328 // Detection. A deploy restarts services: during one, and briefly after, trouble is counted but not drafted.
329 const [streaks, open, page, deploy] = await Promise.all([loadStreaks(env.DB), openRefs(env.DB), load(env.DB, now, origin), loadDeploy(env.DB)]);
330 const quiet = deployQuiet(deploy, now);
331 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 quotas332 await saveStreaks(env.DB, found.streaks);
Fast pages, required checks on the branch, self-hosted runners, honest incidents333 if (found.held.length) console.log(JSON.stringify({ event: "status.held_for_deploy", parts: found.held, deploy: deploy?.id ?? null }));
334 const name = (key: string) => named.get(key) ?? key;
335 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 quotas336 const lines = [
Fast pages, required checks on the branch, self-hosted runners, honest incidents337 ...found.failing.map((f) => ({ incident: f.incident, kind: "failing" as const, text: troubleSentence(name(f.key), f, stamp(f.since), slowMs(f.key)) })),
338 ...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 quotas339 ];
340 await addSystemLines(env.DB, lines, now);
Fast pages, required checks on the branch, self-hosted runners, honest incidents341 const sudo = (id: string) => `${(env.SUDO_URL || "https://sudo.g1t.sh").replace(/\/+$/, "")}/incidents/${id}`;
342 const alertTo = (env.STATUS_ALERT_EMAIL ?? "").trim();
343 const send = sender(env);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas344 if (found.draft.length) {
345 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 incidents346 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 quotas347 const since = found.draft.map((d) => d.since).sort()[0]!;
Fast pages, required checks on the branch, self-hosted runners, honest incidents348 const said = found.draft.map((d) => troubleSentence(name(d.key), d, stamp(d.since), slowMs(d.key)));
349 // Trouble that began in a deploy and outlasted it: say so, it is the first thing to rule out.
350 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 quotas351 const id = await createIncident(
352 env.DB,
353 {
354 title,
355 severity: found.draft.some((d) => d.state === "down" && core.has(d.key)) ? "sev2" : "sev3",
356 status: "investigating",
357 visibility: "draft",
358 source: "detected",
359 components: found.draft.map((d) => ({ key: d.key, impact: detectedImpact(d.state) })),
360 started_at: since,
361 acknowledged_at: null,
362 commander: null,
363 communications: null,
364 by: "status",
365 },
366 [
367 {
368 kind: "detected",
369 public: false,
370 status: null,
Fast pages, required checks on the branch, self-hosted runners, honest incidents371 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 quotas372 },
373 ],
374 now,
375 { action: "incident_detected", detail: title },
376 );
Fast pages, required checks on the branch, self-hosted runners, honest incidents377 console.warn(JSON.stringify({ event: "status.detected", id, parts: found.draft.map((d) => d.key), since }));
378 if (alertTo && send) {
379 const { text, html } = renderMail(alertLetter({ title, lines: said, link: sudo(id), ...(note ? { note } : {}) }));
380 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) }))));
381 }
382 }
383 // Detected drafts no one picked up, whose parts have stayed healthy long enough: dismissed, with a word to staff.
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders384 // A part counts as healthy from its first good check; a run that is still going but answered well last time does not hold a draft up.
385 const troubled = new Set(found.streaks.filter(troubledNow).map((s) => s.component));
386 const watched = await watchedDrafts(env.DB);
387 const settled = settleDrafts(watched, troubled, now);
Fast pages, required checks on the branch, self-hosted runners, honest incidents388 await saveHealthy(env.DB, settled.healthy);
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders389 const dismissed = new Set<string>();
Fast pages, required checks on the branch, self-hosted runners, honest incidents390 for (const d of settled.dismiss) {
391 const text = autoDismissText(d.lasted_ms, stamp(d.recovered_at));
392 if (!(await autoDismiss(env.DB, d.id, d.recovered_at, text, now))) continue;
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders393 dismissed.add(d.id);
Fast pages, required checks on the branch, self-hosted runners, honest incidents394 console.log(JSON.stringify({ event: "status.auto_dismissed", id: d.id, lasted_ms: d.lasted_ms }));
395 if (alertTo && send) {
396 const letter = recoveredLetter({ title: d.title, text, link: sudo(d.id) });
397 const { text: body, html } = renderMail(letter);
398 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 quotas399 }
400 }
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders401 // Drafts still waiting for someone: the alert goes out again after 45 minutes, then every 6 hours.
402 const waiting = watched.filter((d) => !dismissed.has(d.id));
403 const stale = staleDrafts(waiting, now);
404 const emailed = !!(alertTo && send);
405 await saveReminders(
406 env.DB,
407 waiting.map((d) => d.id),
408 stale.map((d) => ({ id: d.id, text: staleText(d.waiting_ms, emailed) })),
409 now,
410 );
411 for (const d of stale) {
412 console.warn(JSON.stringify({ event: "status.draft_waiting", id: d.id, waiting_ms: d.waiting_ms }));
413 if (!emailed) continue;
414 const letter = staleLetter({ title: d.title, waiting: minutesWords(d.waiting_ms), link: sudo(d.id) });
415 const { text: body, html } = renderMail(letter);
416 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) }))));
417 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas418}
419
Fast pages, required checks on the branch, self-hosted runners, honest incidents420/**
421 * The deploy tool's word that a deploy started or finished:
422 * `POST /deploys` with `Authorization: Bearer <STATUS_DEPLOY_TOKEN>` and
423 * `{"phase": "started" | "finished", "id": "<run or commit>"}`. Without
424 * the secret set, there is no such address.
425 */
426async function deployHook(request: Request, env: Env): Promise<Response> {
427 const json = (body: unknown, status = 200) => Response.json(body, { status, headers: { "cache-control": "no-store", ...COMMON } });
428 const token = (env.STATUS_DEPLOY_TOKEN ?? "").trim();
429 if (!token) return json({ error: { code: "not_found", message: "Not found." } }, 404);
430 const given = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "").trim();
431 if (!(await sameSecret(given, token))) return json({ error: { code: "unauthorized", message: "A valid deploy token is required." } }, 401);
432 let body: { phase?: unknown; id?: unknown } = {};
433 try {
434 body = (await request.json()) as typeof body;
435 } catch {
436 // Checked below.
437 }
438 const phase = body.phase === "started" || body.phase === "finished" ? body.phase : null;
439 if (!phase) return json({ error: { code: "invalid", message: 'phase must be "started" or "finished".' } }, 400);
440 const id = typeof body.id === "string" && body.id.trim() ? body.id.trim().slice(0, 100) : null;
441 const now = new Date();
442 const window = deployChange(await loadDeploy(env.DB), phase, id, now);
443 await saveDeploy(env.DB, window);
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders444 console.log(JSON.stringify({ event: `status.deploy_${phase}`, id, running: window.running }));
445 return json({ deploy: window, quiet_until: quietUntil(window) });
Fast pages, required checks on the branch, self-hosted runners, honest incidents446}
447
448/** Compares two secrets in constant time, by their hashes. */
449async function sameSecret(a: string, b: string): Promise<boolean> {
450 const digest = async (v: string) => new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(v)));
451 const [x, y] = await Promise.all([digest(a), digest(b)]);
452 let diff = a.length === 0 ? 1 : 0;
453 for (let i = 0; i < x.length; i++) diff |= x[i]! ^ y[i]!;
454 return diff === 0;
455}
456
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas457// --- Pages -------------------------------------------------------------------------------
458
459async function model(env: Env, now: Date, origin: string): Promise<PageModel> {
460 const stored = await load(env.DB, now, origin);
461 return buildPage({
462 parts: parts(env),
463 current: stored.current,
464 checkedAt: stored.checkedAt,
465 days: stored.days,
466 incidents: stored.incidents,
467 maintenance: stored.maintenance,
468 now,
469 });
470}
471
472/** When this isolate last asked for a catch-up round, so a busy page asks once. */
473let caughtUpAt = 0;
474
475function catchUp(env: Env, ctx: ExecutionContext, page: PageModel, now: Date) {
476 const checked = page.report.checked_at ? Date.parse(page.report.checked_at) : 0;
477 if (now.getTime() - checked < BEHIND_MS || now.getTime() - caughtUpAt < BEHIND_MS) return;
478 caughtUpAt = now.getTime();
479 ctx.waitUntil(checkAll(env, now).catch((error) => console.error(JSON.stringify({ event: "status.catch_up_failed", error: String(error) }))));
480}
481
482const PAGE_POLICY = [
483 "default-src 'none'",
484 "script-src 'self'",
485 "style-src 'unsafe-inline'",
486 "font-src 'self'",
487 "img-src 'self' data:",
488 "base-uri 'none'",
489 "form-action 'self'",
490 "frame-ancestors 'none'",
491].join("; ");
492
493const COMMON = {
494 "x-content-type-options": "nosniff",
495 "referrer-policy": "strict-origin-when-cross-origin",
496};
497
498function edgeCache(): Cache | null {
499 return (globalThis as unknown as { caches?: { default?: Cache } }).caches?.default ?? null;
500}
501
Fast pages, required checks on the branch, self-hosted runners, honest incidents502/**
503 * A response from the edge cache, or made and kept there. Pages that say
504 * times pass the reader's zone, and are kept once per zone.
505 */
506async 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 quotas507 const cache = edgeCache();
508 const url = new URL(request.url);
Fast pages, required checks on the branch, self-hosted runners, honest incidents509 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 quotas510 if (cache) {
511 const hit = await cache.match(key).catch(() => undefined);
512 if (hit) return hit;
513 }
514 const response = await make();
515 if (cache && response.ok) ctx.waitUntil(cache.put(key, response.clone()).catch(() => undefined));
516 return response;
517}
518
519const FONTS: Record<string, ArrayBuffer> = {
520 "/fonts/hanken-grotesk.woff2": hanken,
521 "/fonts/bricolage-grotesque.woff2": bricolage,
522 "/fonts/ibm-plex-mono.woff2": plexMono,
523};
524
525function html(body: string, cacheControl: string, status = 200): Response {
526 return new Response(body, {
527 status,
528 headers: { "content-type": "text/html; charset=utf-8", "cache-control": cacheControl, "content-security-policy": PAGE_POLICY, ...COMMON },
529 });
530}
531
532async function form(request: Request): Promise<FormData> {
533 try {
534 return await request.formData();
535 } catch {
536 return new FormData();
537 }
538}
539
540/** Subscribing, confirming and leaving: the page's only writes. */
541async function subscriptions(request: Request, env: Env, ctx: ExecutionContext, url: URL, options: PageOptions): Promise<Response | null> {
542 const path = url.pathname;
543 const noStore = "no-store";
544 const message = (title: string, text: string, status = 200, f?: { action: string; fields: Record<string, string>; button: string }) =>
545 html(renderMessage({ ...options, selfUrl: `${url.origin}${path}` }, { title, text, form: f }), noStore, status);
546 const post = request.method === "POST";
547
548 if (path === "/subscribe" && post) {
549 if (!emailOn(env)) return message("Email updates are not available", "Follow the Atom or JSON feed instead.", 503);
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails550 // Each request can send an email: limited per client, then per address.
551 const tooMany = () => {
552 const answer = message("Too many requests", "Wait a minute and try again.", 429);
553 answer.headers.set("retry-after", String(LIMIT_PERIOD_SECONDS));
554 return answer;
555 };
556 if (await isLimited(env.STATUS_SUBSCRIBE_LIMIT, `ip:${clientAddress(request)}`)) return tooMany();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas557 const data = await form(request);
558 if (String(data.get("website") ?? "")) return message("Check your inbox", "If the address is right, a confirmation link is on its way.");
559 const email = normalizeEmail(data.get("email"));
560 if (!email) return message("That is not an email address", "Go back and check it.", 400);
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails561 if (await isLimited(env.STATUS_EMAIL_LIMIT, await secretKey("email", email))) return tooMany();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas562 const chosen = chosenParts(data.getAll("components").map(String), parts(env).map((p) => p.key));
563 const token = newToken();
564 const { send } = await requestSubscription(env.DB, email, chosen, await hashToken(token), new Date(), CONFIRM_TTL_MS, RESEND_AFTER_MS);
565 if (send) {
566 const link = `${url.origin}/subscribe/confirm?token=${encodeURIComponent(token)}`;
567 const { text, html: body } = renderMail(confirmLetter(link, chosen ? chosen.map((k) => names(env).get(k) ?? k) : null));
568 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) }))));
569 }
570 return message("Check your inbox", "If the address is right, a confirmation link is on its way. It works for 24 hours.");
571 }
572 if (path === "/subscribe/confirm") {
573 const token = url.searchParams.get("token") ?? (post ? String((await form(request)).get("token") ?? "") : "");
574 if (!token) return message("That link is incomplete", "Copy the whole link from the email.", 400);
575 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" });
576 const done = await confirmSubscription(env.DB, await hashToken(token), new Date());
577 if (!done) return message("That link has expired", "Confirmation links work for 24 hours and once. Subscribe again for a new one.", 410);
578 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.`);
579 }
580 if (path === "/unsubscribe") {
581 const token = url.searchParams.get("token") ?? (post ? String((await form(request)).get("token") ?? "") : "");
582 const id = env.STATUS_SECRET && token ? await readUnsubscribeToken(env.STATUS_SECRET, token) : null;
583 if (!id) return message("That link is not valid", "Use the unsubscribe link at the bottom of any email from g1t status.", 400);
584 if (!post) return message("Unsubscribe", "Stop getting emails from g1t status?", 200, { action: "/unsubscribe", fields: { token }, button: "Unsubscribe" });
585 await unsubscribe(env.DB, id);
586 return message("You are unsubscribed", "You will not get any more emails from g1t status. You can subscribe again at any time.");
587 }
588 return null;
589}
590
591async function handle(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
592 const url = new URL(request.url);
593 const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : url.pathname;
594 if (request.method === "OPTIONS" && (path === "/status.json" || path === "/feed.json")) {
595 return new Response(null, {
596 status: 204,
597 headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, HEAD", "access-control-max-age": "86400" },
598 });
599 }
600 const now = new Date();
601 const origin = originOf(env, url);
602 const site = env.PUBLIC_SITE_URL || env.SITE_URL || url.origin;
603 const options: PageOptions = {
604 siteUrl: site,
605 supportUrl: env.SUPPORT_URL || `${site}/support`,
606 ogImage: env.OG_IMAGE ?? "",
607 selfUrl: `${url.origin}${path === "/" ? "/" : path}`,
608 now,
609 email: emailOn(env),
Fast pages, required checks on the branch, self-hosted runners, honest incidents610 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 quotas611 };
612
Fast pages, required checks on the branch, self-hosted runners, honest incidents613 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 quotas614 if (request.method === "POST") {
615 const answer = await subscriptions(request, env, ctx, url, options);
616 return answer ?? new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD", ...COMMON } });
617 }
618 if (request.method !== "GET" && request.method !== "HEAD") {
619 return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD, POST", ...COMMON } });
620 }
621 const fresh = (cacheSeconds = CACHE_SECONDS) => `public, max-age=${cacheSeconds}`;
622 const notFound = () => html(renderMessage(options, { title: "Not found", text: "There is nothing at this address." }), fresh(), 404);
623
624 switch (path) {
625 case "/":
626 return cached(request, ctx, async () => {
627 const page = await model(env, now, origin);
628 catchUp(env, ctx, page, now);
629 return html(renderPage(page, options), fresh());
Fast pages, required checks on the branch, self-hosted runners, honest incidents630 }, options.zone);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas631 case "/status.json":
632 return cached(request, ctx, async () => {
633 const page = await model(env, now, origin);
634 catchUp(env, ctx, page, now);
635 return Response.json(page.report, { headers: { "cache-control": fresh(), "access-control-allow-origin": "*", ...COMMON } });
636 });
637 case "/badge.svg":
638 return cached(request, ctx, async () => {
639 const page = await model(env, now, origin);
640 return new Response(renderBadge(page.report.overall.state, page.report.overall.title), {
641 headers: { "content-type": "image/svg+xml", "cache-control": fresh(), "access-control-allow-origin": "*", ...COMMON },
642 });
643 });
644 case "/history":
645 return cached(request, ctx, async () => {
646 const since = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1));
647 const { incidents, maintenance } = await loadHistory(env.DB, since, origin);
648 return html(renderHistory(incidents, maintenance, options), fresh());
Fast pages, required checks on the branch, self-hosted runners, honest incidents649 }, options.zone);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas650 case "/feed.xml":
651 case "/feed.json":
652 return cached(request, ctx, async () => {
653 const { incidents, maintenance } = await loadHistory(env.DB, new Date(now.getTime() - 365 * 86_400_000), origin);
654 const items = feedItems(incidents, maintenance);
655 const feed = { origin, title: "g1t status", updated: now.toISOString() };
656 const headers = { "cache-control": fresh(60), "access-control-allow-origin": "*", ...COMMON };
657 return path === "/feed.xml"
658 ? new Response(atom(items, feed), { headers: { "content-type": "application/atom+xml; charset=utf-8", ...headers } })
659 : Response.json(jsonFeed(items, feed), { headers: { "content-type": "application/feed+json; charset=utf-8", ...headers } });
660 });
661 case "/subscribe":
662 return html(renderSubscribe(options, parts(env).map(({ key, name }) => ({ key, name }))), fresh(300));
663 case "/subscribe/confirm":
664 case "/unsubscribe":
665 return (await subscriptions(request, env, ctx, url, options))!;
666 case "/status.js":
667 return new Response(SCRIPT, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": fresh(3600), ...COMMON } });
668 case "/favicon.svg":
669 case "/favicon.ico":
670 return new Response(FAVICON, { headers: { "content-type": "image/svg+xml", "cache-control": fresh(86400), ...COMMON } });
671 case "/robots.txt":
672 return new Response("User-agent: *\nAllow: /\nDisallow: /subscribe/confirm\nDisallow: /unsubscribe\n", {
673 headers: { "content-type": "text/plain", "cache-control": fresh(86400) },
674 });
675 }
676 const incident = /^\/incidents\/([a-z0-9-]{1,64})$/.exec(path);
677 if (incident) {
678 return cached(request, ctx, async () => {
679 const found = await loadPublicIncident(env.DB, incident[1]!, origin);
680 return found ? html(renderIncident(found.incident, found.postmortem, names(env), options), fresh()) : notFound();
Fast pages, required checks on the branch, self-hosted runners, honest incidents681 }, options.zone);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas682 }
683 const maintenance = /^\/maintenance\/([a-z0-9-]{1,64})$/.exec(path);
684 if (maintenance) {
685 return cached(request, ctx, async () => {
686 const found = await loadPublicMaintenance(env.DB, maintenance[1]!, origin);
687 return found ? html(renderMaintenance(found, names(env), options), fresh()) : notFound();
Fast pages, required checks on the branch, self-hosted runners, honest incidents688 }, options.zone);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas689 }
690 const font = FONTS[path];
691 if (font) {
692 return new Response(font, { headers: { "content-type": "font/woff2", "cache-control": "public, max-age=31536000, immutable", ...COMMON } });
693 }
694 return notFound();
695}
696
697export default {
698 async fetch(request, env, ctx) {
699 try {
700 return await handle(request, env, ctx);
701 } catch (error) {
702 console.error(JSON.stringify({ event: "status.failed", path: new URL(request.url).pathname, error: String(error) }));
703 return new Response("The status page could not be drawn. Try again in a minute.", {
704 status: 503,
705 headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "60", ...COMMON },
706 });
707 }
708 },
709 async scheduled(_controller, env, ctx) {
710 const now = new Date();
711 ctx.waitUntil(
712 checkAll(env, now)
713 .then(async (observations) => {
714 const failing = observations.filter((o) => o.state === "down" || o.state === "degraded");
715 if (failing.length) console.warn(JSON.stringify({ event: "status.trouble", parts: failing }));
716 await afterChecks(env, ctx, observations, now);
717 })
718 .catch((error) => console.error(JSON.stringify({ event: "status.cron_failed", error: String(error) }))),
719 );
720 },
721} satisfies ExportedHandler<Env>;
722
723// --- Staff ---------------------------------------------------------------------------------
724
725/**
726 * Staff only: running incidents and maintenance. Reached only through a
727 * service binding (sudo's `STATUS`); status.g1t.sh's own address cannot.
728 */
729export class StatusAdmin extends WorkerEntrypoint<Env> implements StatusAdminApi {
730 private known() {
731 return parts(this.env).map((p) => p.key);
732 }
733
734 private origin() {
735 return originOf(this.env);
736 }
737
738 private async detail(id: string): Promise<AdminIncidentDetail | null> {
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders739 const limits = new Map(parts(this.env).map((p) => [p.key, p.slowMs ?? SLOW_MS]));
740 return incidentDetail(this.env.DB, String(id), this.origin(), names(this.env), { limits });
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas741 }
742
743 private async summary(id: string): Promise<AdminIncident> {
Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders744 const { timeline: _t, followups: _f, postmortem: _p, postmortem_draft: _d, url: _u, checks: _c, ...incident } = (await this.detail(id))!;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas745 return incident;
746 }
747
748 /** Emails a public update to subscribers when asked; the count to keep with it. */
749 private async announce(incident: { id: string; title: string; components: { key: string; impact: string }[] }, status: keyof typeof INCIDENT_STATUS, text: string, wanted: boolean) {
750 if (!wanted) return null;
751 const about = incident.components.filter((c) => c.impact !== "operational").map((c) => c.key);
752 return notify(this.env, this.ctx, about, {
753 heading: `${INCIDENT_STATUS[status]}: ${incident.title}`,
754 text,
755 url: `${this.origin()}/incidents/${incident.id}`,
756 });
757 }
758
759 async components(): Promise<{ key: string; name: string }[]> {
760 return parts(this.env).map(({ key, name }) => ({ key, name }));
761 }
762
763 async board(): Promise<StatusBoard> {
764 return { ...(await board(this.env.DB, new Date(), this.origin())), email: emailOn(this.env) };
765 }
766
767 async incident(id: string): Promise<AdminIncidentDetail | null> {
768 return this.detail(id);
769 }
770
771 async openCount(): Promise<number> {
772 return openCount(this.env.DB);
773 }
774
775 async declare(input: DeclareIncident): Promise<Result<AdminIncident>> {
776 const checked = checkDeclare(input, this.known());
777 if (!checked.ok) return fail("invalid", checked.error);
778 const v = checked.value;
779 const now = new Date();
780 const entries: (Entry & { notified?: number | null })[] = [
781 { kind: "declared", public: false, status: null, text: `Declared ${SEVERITY_LABEL[v.severity]}.` },
782 ];
783 if (v.commander) entries.push({ kind: "role", public: false, status: null, text: `Incident commander: ${v.commander}.` });
784 if (v.communications) entries.push({ kind: "role", public: false, status: null, text: `Communications: ${v.communications}.` });
785 const id = shortId();
786 const status = v.status ?? "investigating";
787 const notified = await this.announce({ id, title: v.title, components: v.components }, status, v.message, v.notify);
788 entries.push({ kind: "update", public: true, status, text: v.message, notified });
789 await createIncident(
790 this.env.DB,
791 {
792 title: v.title,
793 severity: v.severity,
794 status,
795 visibility: "public",
796 source: "declared",
797 components: v.components,
798 started_at: v.started_at ?? now.toISOString(),
799 acknowledged_at: now.toISOString(),
800 commander: v.commander ?? null,
801 communications: v.communications ?? null,
802 by: v.by,
803 },
804 entries,
805 now,
806 { action: "incident_declared", detail: `${SEVERITY_LABEL[v.severity]}: ${v.title}` },
807 id,
808 );
809 console.log(JSON.stringify({ event: "status.incident_declared", id, by: v.by }));
810 return ok(await this.summary(id));
811 }
812
813 async update(id: string, change: IncidentChange): Promise<Result<AdminIncident>> {
814 const checked = checkChange(change, this.known());
815 if (!checked.ok) return fail("invalid", checked.error);
816 const existing = await this.detail(id);
817 if (!existing) return fail("not_found", "No such incident.");
818 const now = new Date();
819 const applied = applyChange(facts(existing), checked.value, now, names(this.env));
820 if (!applied.ok) return fail("invalid", applied.error);
821 const { next, entries } = applied.value;
822 const update = entries.find((e) => e.kind === "update");
823 const notified = update
824 ? await this.announce({ id: existing.id, title: existing.title, components: next.components }, next.status, update.text, checked.value.notify === true)
825 : null;
826 const lines = entries.map((e) => (e === update ? { ...e, notified } : e));
827 const action = next.status === "resolved" && existing.status !== "resolved" ? "incident_resolved" : update ? "incident_update" : "incident_note";
828 await saveIncident(this.env.DB, existing.id, next, lines, now, checked.value.by, {
829 action,
830 detail: entries.map((e) => (e.kind === "update" || e.kind === "note" ? `${e.kind === "update" ? "Public" : "Note"}: ${e.text}` : e.text)).join(" ").slice(0, 500),
831 });
832 return ok(await this.summary(existing.id));
833 }
834
835 async roles(id: string, change: RolesChange): Promise<Result<AdminIncident>> {
836 const checked = checkRoles(change);
837 if (!checked.ok) return fail("invalid", checked.error);
838 const existing = await this.detail(id);
839 if (!existing) return fail("not_found", "No such incident.");
840 const now = new Date();
841 const { next, entries } = applyRoles(facts(existing), checked.value, now);
842 if (!entries.length) return ok(await this.summary(existing.id));
843 await saveIncident(this.env.DB, existing.id, next, entries, now, checked.value.by, { action: "incident_roles", detail: entries.map((e) => e.text).join(" ") });
844 return ok(await this.summary(existing.id));
845 }
846
847 async publish(id: string, input: PublishIncident): Promise<Result<AdminIncident>> {
848 const checked = checkPublish(input);
849 if (!checked.ok) return fail("invalid", checked.error);
850 const existing = await this.detail(id);
851 if (!existing) return fail("not_found", "No such incident.");
852 if (existing.visibility !== "draft") return fail("conflict", "Only a draft can be published.");
853 const now = new Date();
854 const at = now.toISOString();
855 const title = checked.value.title ?? existing.title;
856 const next = { ...facts(existing), visibility: "public" as const, acknowledged_at: existing.acknowledged_at ?? at, title, published_at: at };
857 const notified = await this.announce({ id: existing.id, title, components: existing.components }, existing.status, checked.value.message, checked.value.notify);
858 await saveIncident(
859 this.env.DB,
860 existing.id,
861 next,
862 [
863 ...(existing.acknowledged_at ? [] : [{ kind: "acknowledged" as const, public: false, status: null, text: "Acknowledged." }]),
864 { kind: "published", public: false, status: null, text: title !== existing.title ? `Published as “${title}”.` : "Published to the status page." },
865 { kind: "update", public: true, status: existing.status, text: checked.value.message, notified },
866 ],
867 now,
868 checked.value.by,
869 { action: "incident_published", detail: title },
870 );
871 return ok(await this.summary(existing.id));
872 }
873
874 async dismiss(id: string, input: { reason: string; by: string }): Promise<Result<AdminIncident>> {
875 const existing = await this.detail(id);
876 if (!existing) return fail("not_found", "No such incident.");
877 if (existing.visibility !== "draft") return fail("conflict", "Only a draft can be dismissed; resolve a published incident instead.");
878 const by = String(input?.by ?? "").trim();
879 if (!by) return fail("invalid", "Who is making the change is missing.");
880 const reason = String(input?.reason ?? "").trim().slice(0, 500) || "No reason given.";
881 const now = new Date();
882 const at = now.toISOString();
883 const next = { ...facts(existing), visibility: "dismissed" as const, status: "resolved" as const, acknowledged_at: existing.acknowledged_at ?? at, resolved_at: at };
884 await saveIncident(this.env.DB, existing.id, next, [{ kind: "dismissed", public: false, status: null, text: `Dismissed: ${reason}` }], now, by, {
885 action: "incident_dismissed",
886 detail: `${existing.title}: ${reason}`,
887 });
888 return ok(await this.summary(existing.id));
889 }
890
891 async addFollowUp(id: string, input: { title: string; owner: string | null; by: string }): Promise<Result<FollowUp>> {
892 const checked = checkFollowUp(input);
893 if (!checked.ok) return fail("invalid", checked.error);
894 if (!(await this.detail(id))) return fail("not_found", "No such incident.");
895 return ok(await addFollowUp(this.env.DB, String(id), checked.value, new Date()));
896 }
897
898 async setFollowUp(id: string, followUp: string, input: { done: boolean; by: string }): Promise<Result<FollowUp>> {
899 const by = String(input?.by ?? "").trim();
900 if (!by) return fail("invalid", "Who is making the change is missing.");
901 const done = await setFollowUp(this.env.DB, String(id), String(followUp), input.done === true, by, new Date());
902 return done ? ok(done) : fail("not_found", "No such follow-up.");
903 }
904
905 async savePostmortem(id: string, input: PostmortemFields & { by: string }): Promise<Result<Postmortem>> {
906 const checked = checkPostmortem(input);
907 if (!checked.ok) return fail("invalid", checked.error);
908 const existing = await this.detail(id);
909 if (!existing) return fail("not_found", "No such incident.");
910 if (existing.visibility !== "public") return fail("conflict", "Only a published incident has a postmortem.");
911 const { by, ...fields } = checked.value;
912 await savePostmortem(this.env.DB, existing.id, fields, by, new Date());
913 return ok((await this.detail(existing.id))!.postmortem!);
914 }
915
916 async publishPostmortem(id: string, input: { publish: boolean; by: string }): Promise<Result<Postmortem>> {
917 const by = String(input?.by ?? "").trim();
918 if (!by) return fail("invalid", "Who is making the change is missing.");
919 const existing = await this.detail(id);
920 if (!existing) return fail("not_found", "No such incident.");
921 if (!existing.postmortem) return fail("conflict", "Save the postmortem before publishing it.");
922 if (input.publish) {
923 if (!existing.resolved_at) return fail("conflict", "Resolve the incident before publishing its postmortem.");
924 const missing = postmortemReady(existing.postmortem);
925 if (missing) return fail("invalid", missing);
926 }
927 await publishPostmortem(this.env.DB, existing.id, input.publish === true, by, new Date());
928 return ok((await this.detail(existing.id))!.postmortem!);
929 }
930
931 async scheduleMaintenance(input: NewMaintenance): Promise<Result<AdminMaintenance>> {
932 const checked = checkMaintenance(input, this.known());
933 if (!checked.ok) return fail("invalid", checked.error);
934 const v = checked.value;
935 const now = new Date();
936 const id = shortId();
937 const notified = v.notify
938 ? await notify(this.env, this.ctx, v.components, {
939 heading: `Planned maintenance: ${v.title}`,
940 text: `${v.message}\n\nWhen: ${stamp(v.starts_at)} to ${stamp(v.ends_at)}.`,
941 url: maintenanceUrl(this.origin(), id),
942 })
943 : null;
944 const made = await scheduleMaintenance(this.env.DB, v, notified, now, id);
945 return ok((await maintenanceById(this.env.DB, made, this.origin()))!);
946 }
947
948 async changeMaintenance(id: string, change: MaintenanceChange): Promise<Result<AdminMaintenance>> {
949 const checked = checkMaintenanceChange(change);
950 if (!checked.ok) return fail("invalid", checked.error);
951 const existing = await maintenanceById(this.env.DB, String(id), this.origin());
952 if (!existing) return fail("not_found", "No such maintenance.");
953 const v = checked.value;
954 if (existing.state === "completed" || existing.state === "cancelled") return fail("conflict", `This maintenance is ${existing.state}.`);
955 if (v.action === "start" && existing.state !== "scheduled") return fail("conflict", "It has already started.");
956 const state = v.action === "start" ? "in_progress" : v.action === "complete" ? "completed" : v.action === "cancel" ? "cancelled" : null;
957 const text =
958 v.message ||
959 (v.action === "start" ? "The maintenance has begun." : v.action === "complete" ? "The maintenance is complete." : "This maintenance is cancelled.");
960 const word = { update: "Update", start: "In progress", complete: "Completed", cancel: "Cancelled" }[v.action];
961 const notified = v.notify ? await notify(this.env, this.ctx, existing.components, { heading: `${word}: ${existing.title}`, text, url: existing.url }) : null;
962 await maintenanceUpdate(this.env.DB, existing.id, state, text, v.by, notified, new Date(), {
963 action: `maintenance_${v.action === "update" ? "update" : v.action === "start" ? "started" : v.action === "complete" ? "completed" : "cancelled"}`,
964 detail: `${existing.title}: ${text}`,
965 });
966 return ok((await maintenanceById(this.env.DB, existing.id, this.origin()))!);
967 }
968
969 async audit(filter?: { before?: string | null }): Promise<StatusAuditEntry[]> {
970 const before = filter?.before && !Number.isNaN(Date.parse(filter.before)) ? filter.before : null;
971 return auditLog(this.env.DB, before);
972 }
973}
974

This file's history is long; its oldest lines are credited to the oldest commit read.