Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

1 commit

188 files+9178−15430/188 viewed
+11−3
3232 | `icon-transparent-1024.png` | 1024×1024, transparent | Placing the icon on a dark surface of your own |
3333 | `logo-on-dark.png` | 1056×642, dark background | Stripe branding logo (wide), docs and decks on dark |
3434 | `logo-on-light.png` | 1056×642, white background | Invoices, receipts and anywhere the background is light |
35−| `og-1200x630.png` | 1200×630 | Social and Open Graph card |
3635
3736 ## Sources
3837
4948 | `svg/favicon.svg` | The 16px cut with no tile, coloured for a light or dark browser (`favicon.svg` in the apps and `packages/theme`) |
5049 | `svg/logo.svg`, `svg/logo-on-light.svg` | The wordmark, transparent and cropped to its ink, for dark and light backgrounds |
5150 | `svg/logo-dark-bg.svg`, `svg/logo-light-bg.svg` | The wordmark with padding and a background, on 40px cells |
52−| `svg/og.svg` | The social card; its tagline uses Hanken Grotesk and IBM Plex Mono as live text |
5351
5452 `favicon.ico` in the apps holds the 16, 32 and 48px cuts of the icon on a
5553 rounded dark tile. The pixels are squares, so no file needs a font.
6260 - `g1t-mark.svg`, `g1t-mark.png` (256px): the icon on a rounded dark tile, readable on any background
6361 - `g1t-logo.svg`, `g1t-logo.png` (300×140, shown at 60×28): the wordmark for light backgrounds, used in g1t's emails
6462 - `g1t-logo-on-dark.svg`, `g1t-logo-on-dark.png` (300×140): the wordmark for dark backgrounds
65−- `g1t-og.png`: the brand social card as `services/og` draws it, which that service falls back to if drawing fails
6663
6764 ## Social cards
6865
7067 drawn by `services/og` in the same colours, type and pixels: the wordmark and
7168 the page's address along the top, its subject in the middle, its facts along
7269 the bottom, the pixel 1 standing large and faded off the right edge.
70+
71+There is no static social card. The brand card, the one for g1t.sh itself
72+and for any page without a card of its own, is `https://og.g1t.sh/image?path=%2F`,
73+with the lockup and the line "Where people and agents ship software
74+together." When a card cannot be drawn, og draws the brand card instead.
75+
76+Every card's address carries the render version in its `v` parameter
77+(`OG_RENDER_VERSION` in `packages/contracts/src/og.ts`), which is also part
78+of og's cache key. Bump it in the same change as any change to the cards'
79+design, the logo or the brand line: otherwise og, browsers and every site
80+that has fetched a card keep showing the old one.
+0−0

Binary or large file; its contents are not shown.

+0−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630"><rect width="1200" height="630" fill="#0f0f11"/><g fill="#2a2a30"><circle cx="20" cy="15" r="1.4"/><circle cx="20" cy="55" r="1.4"/><circle cx="20" cy="95" r="1.4"/><circle cx="20" cy="135" r="1.4"/><circle cx="20" cy="175" r="1.4"/><circle cx="20" cy="215" r="1.4"/><circle cx="20" cy="255" r="1.4"/><circle cx="20" cy="295" r="1.4"/><circle cx="20" cy="335" r="1.4"/><circle cx="20" cy="375" r="1.4"/><circle cx="20" cy="415" r="1.4"/><circle cx="20" cy="455" r="1.4"/><circle cx="20" cy="495" r="1.4"/><circle cx="20" cy="535" r="1.4"/><circle cx="20" cy="575" r="1.4"/><circle cx="20" cy="615" r="1.4"/><circle cx="60" cy="15" r="1.4"/><circle cx="60" cy="55" r="1.4"/><circle cx="60" cy="95" r="1.4"/><circle cx="60" cy="135" r="1.4"/><circle cx="60" cy="175" r="1.4"/><circle cx="60" cy="215" r="1.4"/><circle cx="60" cy="255" r="1.4"/><circle cx="60" cy="295" r="1.4"/><circle cx="60" cy="335" r="1.4"/><circle cx="60" cy="375" r="1.4"/><circle cx="60" cy="415" r="1.4"/><circle cx="60" cy="455" r="1.4"/><circle cx="60" cy="495" r="1.4"/><circle cx="60" cy="535" r="1.4"/><circle cx="60" cy="575" r="1.4"/><circle cx="60" cy="615" r="1.4"/><circle cx="100" cy="15" r="1.4"/><circle cx="100" cy="55" r="1.4"/><circle cx="100" cy="95" r="1.4"/><circle cx="100" cy="135" r="1.4"/><circle cx="100" cy="175" r="1.4"/><circle cx="100" cy="215" r="1.4"/><circle cx="100" cy="255" r="1.4"/><circle cx="100" cy="295" r="1.4"/><circle cx="100" cy="335" r="1.4"/><circle cx="100" cy="375" r="1.4"/><circle cx="100" cy="415" r="1.4"/><circle cx="100" cy="455" r="1.4"/><circle cx="100" cy="495" r="1.4"/><circle cx="100" cy="535" r="1.4"/><circle cx="100" cy="575" r="1.4"/><circle cx="100" cy="615" r="1.4"/><circle cx="140" cy="15" r="1.4"/><circle cx="140" cy="55" r="1.4"/><circle cx="140" cy="95" r="1.4"/><circle cx="140" cy="135" r="1.4"/><circle cx="140" cy="175" r="1.4"/><circle cx="140" cy="215" r="1.4"/><circle cx="140" cy="255" r="1.4"/><circle cx="140" cy="295" r="1.4"/><circle cx="140" cy="335" r="1.4"/><circle cx="140" cy="375" r="1.4"/><circle cx="140" cy="415" r="1.4"/><circle cx="140" cy="455" r="1.4"/><circle cx="140" cy="495" r="1.4"/><circle cx="140" cy="535" r="1.4"/><circle cx="140" cy="575" r="1.4"/><circle cx="140" cy="615" r="1.4"/><circle cx="180" cy="15" r="1.4"/><circle cx="180" cy="55" r="1.4"/><circle cx="180" cy="95" r="1.4"/><circle cx="180" cy="135" r="1.4"/><circle cx="180" cy="175" r="1.4"/><circle cx="180" cy="215" r="1.4"/><circle cx="180" cy="255" r="1.4"/><circle cx="180" cy="295" r="1.4"/><circle cx="180" cy="335" r="1.4"/><circle cx="180" cy="375" r="1.4"/><circle cx="180" cy="415" r="1.4"/><circle cx="180" cy="455" r="1.4"/><circle cx="180" cy="495" r="1.4"/><circle cx="180" cy="535" r="1.4"/><circle cx="180" cy="575" r="1.4"/><circle cx="180" cy="615" r="1.4"/><circle cx="220" cy="15" r="1.4"/><circle cx="220" cy="55" r="1.4"/><circle cx="220" cy="95" r="1.4"/><circle cx="220" cy="135" r="1.4"/><circle cx="220" cy="175" r="1.4"/><circle cx="220" cy="215" r="1.4"/><circle cx="220" cy="255" r="1.4"/><circle cx="220" cy="295" r="1.4"/><circle cx="220" cy="335" r="1.4"/><circle cx="220" cy="375" r="1.4"/><circle cx="220" cy="415" r="1.4"/><circle cx="220" cy="455" r="1.4"/><circle cx="220" cy="495" r="1.4"/><circle cx="220" cy="535" r="1.4"/><circle cx="220" cy="575" r="1.4"/><circle cx="220" cy="615" r="1.4"/><circle cx="260" cy="15" r="1.4"/><circle cx="260" cy="55" r="1.4"/><circle cx="260" cy="95" r="1.4"/><circle cx="260" cy="135" r="1.4"/><circle cx="260" cy="175" r="1.4"/><circle cx="260" cy="215" r="1.4"/><circle cx="260" cy="255" r="1.4"/><circle cx="260" cy="295" r="1.4"/><circle cx="260" cy="335" r="1.4"/><circle cx="260" cy="375" r="1.4"/><circle cx="260" cy="415" r="1.4"/><circle cx="260" cy="455" r="1.4"/><circle cx="260" cy="495" r="1.4"/><circle cx="260" cy="535" r="1.4"/><circle cx="260" cy="575" r="1.4"/><circle cx="260" cy="615" r="1.4"/><circle cx="300" cy="15" r="1.4"/><circle cx="300" cy="55" r="1.4"/><circle cx="300" cy="95" r="1.4"/><circle cx="300" cy="135" r="1.4"/><circle cx="300" cy="175" r="1.4"/><circle cx="300" cy="215" r="1.4"/><circle cx="300" cy="255" r="1.4"/><circle cx="300" cy="295" r="1.4"/><circle cx="300" cy="335" r="1.4"/><circle cx="300" cy="375" r="1.4"/><circle cx="300" cy="415" r="1.4"/><circle cx="300" cy="455" r="1.4"/><circle cx="300" cy="495" r="1.4"/><circle cx="300" cy="535" r="1.4"/><circle cx="300" cy="575" r="1.4"/><circle cx="300" cy="615" r="1.4"/><circle cx="340" cy="15" r="1.4"/><circle cx="340" cy="55" r="1.4"/><circle cx="340" cy="95" r="1.4"/><circle cx="340" cy="135" r="1.4"/><circle cx="340" cy="175" r="1.4"/><circle cx="340" cy="215" r="1.4"/><circle cx="340" cy="255" r="1.4"/><circle cx="340" cy="295" r="1.4"/><circle cx="340" cy="335" r="1.4"/><circle cx="340" cy="375" r="1.4"/><circle cx="340" cy="415" r="1.4"/><circle cx="340" cy="455" r="1.4"/><circle cx="340" cy="495" r="1.4"/><circle cx="340" cy="535" r="1.4"/><circle cx="340" cy="575" r="1.4"/><circle cx="340" cy="615" r="1.4"/><circle cx="380" cy="15" r="1.4"/><circle cx="380" cy="55" r="1.4"/><circle cx="380" cy="95" r="1.4"/><circle cx="380" cy="135" r="1.4"/><circle cx="380" cy="175" r="1.4"/><circle cx="380" cy="215" r="1.4"/><circle cx="380" cy="255" r="1.4"/><circle cx="380" cy="295" r="1.4"/><circle cx="380" cy="335" r="1.4"/><circle cx="380" cy="375" r="1.4"/><circle cx="380" cy="415" r="1.4"/><circle cx="380" cy="455" r="1.4"/><circle cx="380" cy="495" r="1.4"/><circle cx="380" cy="535" r="1.4"/><circle cx="380" cy="575" r="1.4"/><circle cx="380" cy="615" r="1.4"/><circle cx="420" cy="15" r="1.4"/><circle cx="420" cy="55" r="1.4"/><circle cx="420" cy="95" r="1.4"/><circle cx="420" cy="135" r="1.4"/><circle cx="420" cy="175" r="1.4"/><circle cx="420" cy="215" r="1.4"/><circle cx="420" cy="255" r="1.4"/><circle cx="420" cy="295" r="1.4"/><circle cx="420" cy="335" r="1.4"/><circle cx="420" cy="375" r="1.4"/><circle cx="420" cy="415" r="1.4"/><circle cx="420" cy="455" r="1.4"/><circle cx="420" cy="495" r="1.4"/><circle cx="420" cy="535" r="1.4"/><circle cx="420" cy="575" r="1.4"/><circle cx="420" cy="615" r="1.4"/><circle cx="460" cy="15" r="1.4"/><circle cx="460" cy="55" r="1.4"/><circle cx="460" cy="95" r="1.4"/><circle cx="460" cy="135" r="1.4"/><circle cx="460" cy="175" r="1.4"/><circle cx="460" cy="215" r="1.4"/><circle cx="460" cy="255" r="1.4"/><circle cx="460" cy="295" r="1.4"/><circle cx="460" cy="335" r="1.4"/><circle cx="460" cy="375" r="1.4"/><circle cx="460" cy="415" r="1.4"/><circle cx="460" cy="455" r="1.4"/><circle cx="460" cy="495" r="1.4"/><circle cx="460" cy="535" r="1.4"/><circle cx="460" cy="575" r="1.4"/><circle cx="460" cy="615" r="1.4"/><circle cx="500" cy="15" r="1.4"/><circle cx="500" cy="55" r="1.4"/><circle cx="500" cy="95" r="1.4"/><circle cx="500" cy="135" r="1.4"/><circle cx="500" cy="175" r="1.4"/><circle cx="500" cy="215" r="1.4"/><circle cx="500" cy="255" r="1.4"/><circle cx="500" cy="295" r="1.4"/><circle cx="500" cy="335" r="1.4"/><circle cx="500" cy="375" r="1.4"/><circle cx="500" cy="415" r="1.4"/><circle cx="500" cy="455" r="1.4"/><circle cx="500" cy="495" r="1.4"/><circle cx="500" cy="535" r="1.4"/><circle cx="500" cy="575" r="1.4"/><circle cx="500" cy="615" r="1.4"/><circle cx="540" cy="15" r="1.4"/><circle cx="540" cy="55" r="1.4"/><circle cx="540" cy="95" r="1.4"/><circle cx="540" cy="135" r="1.4"/><circle cx="540" cy="175" r="1.4"/><circle cx="540" cy="215" r="1.4"/><circle cx="540" cy="255" r="1.4"/><circle cx="540" cy="295" r="1.4"/><circle cx="540" cy="335" r="1.4"/><circle cx="540" cy="375" r="1.4"/><circle cx="540" cy="415" r="1.4"/><circle cx="540" cy="455" r="1.4"/><circle cx="540" cy="495" r="1.4"/><circle cx="540" cy="535" r="1.4"/><circle cx="540" cy="575" r="1.4"/><circle cx="540" cy="615" r="1.4"/><circle cx="580" cy="15" r="1.4"/><circle cx="580" cy="55" r="1.4"/><circle cx="580" cy="95" r="1.4"/><circle cx="580" cy="135" r="1.4"/><circle cx="580" cy="175" r="1.4"/><circle cx="580" cy="215" r="1.4"/><circle cx="580" cy="255" r="1.4"/><circle cx="580" cy="295" r="1.4"/><circle cx="580" cy="335" r="1.4"/><circle cx="580" cy="375" r="1.4"/><circle cx="580" cy="415" r="1.4"/><circle cx="580" cy="455" r="1.4"/><circle cx="580" cy="495" r="1.4"/><circle cx="580" cy="535" r="1.4"/><circle cx="580" cy="575" r="1.4"/><circle cx="580" cy="615" r="1.4"/><circle cx="620" cy="15" r="1.4"/><circle cx="620" cy="55" r="1.4"/><circle cx="620" cy="95" r="1.4"/><circle cx="620" cy="135" r="1.4"/><circle cx="620" cy="175" r="1.4"/><circle cx="620" cy="215" r="1.4"/><circle cx="620" cy="255" r="1.4"/><circle cx="620" cy="295" r="1.4"/><circle cx="620" cy="335" r="1.4"/><circle cx="620" cy="375" r="1.4"/><circle cx="620" cy="415" r="1.4"/><circle cx="620" cy="455" r="1.4"/><circle cx="620" cy="495" r="1.4"/><circle cx="620" cy="535" r="1.4"/><circle cx="620" cy="575" r="1.4"/><circle cx="620" cy="615" r="1.4"/><circle cx="660" cy="15" r="1.4"/><circle cx="660" cy="55" r="1.4"/><circle cx="660" cy="95" r="1.4"/><circle cx="660" cy="135" r="1.4"/><circle cx="660" cy="175" r="1.4"/><circle cx="660" cy="215" r="1.4"/><circle cx="660" cy="255" r="1.4"/><circle cx="660" cy="295" r="1.4"/><circle cx="660" cy="335" r="1.4"/><circle cx="660" cy="375" r="1.4"/><circle cx="660" cy="415" r="1.4"/><circle cx="660" cy="455" r="1.4"/><circle cx="660" cy="495" r="1.4"/><circle cx="660" cy="535" r="1.4"/><circle cx="660" cy="575" r="1.4"/><circle cx="660" cy="615" r="1.4"/><circle cx="700" cy="15" r="1.4"/><circle cx="700" cy="55" r="1.4"/><circle cx="700" cy="95" r="1.4"/><circle cx="700" cy="135" r="1.4"/><circle cx="700" cy="175" r="1.4"/><circle cx="700" cy="215" r="1.4"/><circle cx="700" cy="255" r="1.4"/><circle cx="700" cy="295" r="1.4"/><circle cx="700" cy="335" r="1.4"/><circle cx="700" cy="375" r="1.4"/><circle cx="700" cy="415" r="1.4"/><circle cx="700" cy="455" r="1.4"/><circle cx="700" cy="495" r="1.4"/><circle cx="700" cy="535" r="1.4"/><circle cx="700" cy="575" r="1.4"/><circle cx="700" cy="615" r="1.4"/><circle cx="740" cy="15" r="1.4"/><circle cx="740" cy="55" r="1.4"/><circle cx="740" cy="95" r="1.4"/><circle cx="740" cy="135" r="1.4"/><circle cx="740" cy="175" r="1.4"/><circle cx="740" cy="215" r="1.4"/><circle cx="740" cy="255" r="1.4"/><circle cx="740" cy="295" r="1.4"/><circle cx="740" cy="335" r="1.4"/><circle cx="740" cy="375" r="1.4"/><circle cx="740" cy="415" r="1.4"/><circle cx="740" cy="455" r="1.4"/><circle cx="740" cy="495" r="1.4"/><circle cx="740" cy="535" r="1.4"/><circle cx="740" cy="575" r="1.4"/><circle cx="740" cy="615" r="1.4"/><circle cx="780" cy="15" r="1.4"/><circle cx="780" cy="55" r="1.4"/><circle cx="780" cy="95" r="1.4"/><circle cx="780" cy="135" r="1.4"/><circle cx="780" cy="175" r="1.4"/><circle cx="780" cy="215" r="1.4"/><circle cx="780" cy="255" r="1.4"/><circle cx="780" cy="295" r="1.4"/><circle cx="780" cy="335" r="1.4"/><circle cx="780" cy="375" r="1.4"/><circle cx="780" cy="415" r="1.4"/><circle cx="780" cy="455" r="1.4"/><circle cx="780" cy="495" r="1.4"/><circle cx="780" cy="535" r="1.4"/><circle cx="780" cy="575" r="1.4"/><circle cx="780" cy="615" r="1.4"/><circle cx="820" cy="15" r="1.4"/><circle cx="820" cy="55" r="1.4"/><circle cx="820" cy="95" r="1.4"/><circle cx="820" cy="135" r="1.4"/><circle cx="820" cy="175" r="1.4"/><circle cx="820" cy="215" r="1.4"/><circle cx="820" cy="255" r="1.4"/><circle cx="820" cy="295" r="1.4"/><circle cx="820" cy="335" r="1.4"/><circle cx="820" cy="375" r="1.4"/><circle cx="820" cy="415" r="1.4"/><circle cx="820" cy="455" r="1.4"/><circle cx="820" cy="495" r="1.4"/><circle cx="820" cy="535" r="1.4"/><circle cx="820" cy="575" r="1.4"/><circle cx="820" cy="615" r="1.4"/><circle cx="860" cy="15" r="1.4"/><circle cx="860" cy="55" r="1.4"/><circle cx="860" cy="95" r="1.4"/><circle cx="860" cy="135" r="1.4"/><circle cx="860" cy="175" r="1.4"/><circle cx="860" cy="215" r="1.4"/><circle cx="860" cy="255" r="1.4"/><circle cx="860" cy="295" r="1.4"/><circle cx="860" cy="335" r="1.4"/><circle cx="860" cy="375" r="1.4"/><circle cx="860" cy="415" r="1.4"/><circle cx="860" cy="455" r="1.4"/><circle cx="860" cy="495" r="1.4"/><circle cx="860" cy="535" r="1.4"/><circle cx="860" cy="575" r="1.4"/><circle cx="860" cy="615" r="1.4"/><circle cx="900" cy="15" r="1.4"/><circle cx="900" cy="55" r="1.4"/><circle cx="900" cy="95" r="1.4"/><circle cx="900" cy="135" r="1.4"/><circle cx="900" cy="175" r="1.4"/><circle cx="900" cy="215" r="1.4"/><circle cx="900" cy="255" r="1.4"/><circle cx="900" cy="295" r="1.4"/><circle cx="900" cy="335" r="1.4"/><circle cx="900" cy="375" r="1.4"/><circle cx="900" cy="415" r="1.4"/><circle cx="900" cy="455" r="1.4"/><circle cx="900" cy="495" r="1.4"/><circle cx="900" cy="535" r="1.4"/><circle cx="900" cy="575" r="1.4"/><circle cx="900" cy="615" r="1.4"/><circle cx="940" cy="15" r="1.4"/><circle cx="940" cy="55" r="1.4"/><circle cx="940" cy="95" r="1.4"/><circle cx="940" cy="135" r="1.4"/><circle cx="940" cy="175" r="1.4"/><circle cx="940" cy="215" r="1.4"/><circle cx="940" cy="255" r="1.4"/><circle cx="940" cy="295" r="1.4"/><circle cx="940" cy="335" r="1.4"/><circle cx="940" cy="375" r="1.4"/><circle cx="940" cy="415" r="1.4"/><circle cx="940" cy="455" r="1.4"/><circle cx="940" cy="495" r="1.4"/><circle cx="940" cy="535" r="1.4"/><circle cx="940" cy="575" r="1.4"/><circle cx="940" cy="615" r="1.4"/><circle cx="980" cy="15" r="1.4"/><circle cx="980" cy="55" r="1.4"/><circle cx="980" cy="95" r="1.4"/><circle cx="980" cy="135" r="1.4"/><circle cx="980" cy="175" r="1.4"/><circle cx="980" cy="215" r="1.4"/><circle cx="980" cy="255" r="1.4"/><circle cx="980" cy="295" r="1.4"/><circle cx="980" cy="335" r="1.4"/><circle cx="980" cy="375" r="1.4"/><circle cx="980" cy="415" r="1.4"/><circle cx="980" cy="455" r="1.4"/><circle cx="980" cy="495" r="1.4"/><circle cx="980" cy="535" r="1.4"/><circle cx="980" cy="575" r="1.4"/><circle cx="980" cy="615" r="1.4"/><circle cx="1020" cy="15" r="1.4"/><circle cx="1020" cy="55" r="1.4"/><circle cx="1020" cy="95" r="1.4"/><circle cx="1020" cy="135" r="1.4"/><circle cx="1020" cy="175" r="1.4"/><circle cx="1020" cy="215" r="1.4"/><circle cx="1020" cy="255" r="1.4"/><circle cx="1020" cy="295" r="1.4"/><circle cx="1020" cy="335" r="1.4"/><circle cx="1020" cy="375" r="1.4"/><circle cx="1020" cy="415" r="1.4"/><circle cx="1020" cy="455" r="1.4"/><circle cx="1020" cy="495" r="1.4"/><circle cx="1020" cy="535" r="1.4"/><circle cx="1020" cy="575" r="1.4"/><circle cx="1020" cy="615" r="1.4"/><circle cx="1060" cy="15" r="1.4"/><circle cx="1060" cy="55" r="1.4"/><circle cx="1060" cy="95" r="1.4"/><circle cx="1060" cy="135" r="1.4"/><circle cx="1060" cy="175" r="1.4"/><circle cx="1060" cy="215" r="1.4"/><circle cx="1060" cy="255" r="1.4"/><circle cx="1060" cy="295" r="1.4"/><circle cx="1060" cy="335" r="1.4"/><circle cx="1060" cy="375" r="1.4"/><circle cx="1060" cy="415" r="1.4"/><circle cx="1060" cy="455" r="1.4"/><circle cx="1060" cy="495" r="1.4"/><circle cx="1060" cy="535" r="1.4"/><circle cx="1060" cy="575" r="1.4"/><circle cx="1060" cy="615" r="1.4"/><circle cx="1100" cy="15" r="1.4"/><circle cx="1100" cy="55" r="1.4"/><circle cx="1100" cy="95" r="1.4"/><circle cx="1100" cy="135" r="1.4"/><circle cx="1100" cy="175" r="1.4"/><circle cx="1100" cy="215" r="1.4"/><circle cx="1100" cy="255" r="1.4"/><circle cx="1100" cy="295" r="1.4"/><circle cx="1100" cy="335" r="1.4"/><circle cx="1100" cy="375" r="1.4"/><circle cx="1100" cy="415" r="1.4"/><circle cx="1100" cy="455" r="1.4"/><circle cx="1100" cy="495" r="1.4"/><circle cx="1100" cy="535" r="1.4"/><circle cx="1100" cy="575" r="1.4"/><circle cx="1100" cy="615" r="1.4"/><circle cx="1140" cy="15" r="1.4"/><circle cx="1140" cy="55" r="1.4"/><circle cx="1140" cy="95" r="1.4"/><circle cx="1140" cy="135" r="1.4"/><circle cx="1140" cy="175" r="1.4"/><circle cx="1140" cy="215" r="1.4"/><circle cx="1140" cy="255" r="1.4"/><circle cx="1140" cy="295" r="1.4"/><circle cx="1140" cy="335" r="1.4"/><circle cx="1140" cy="375" r="1.4"/><circle cx="1140" cy="415" r="1.4"/><circle cx="1140" cy="455" r="1.4"/><circle cx="1140" cy="495" r="1.4"/><circle cx="1140" cy="535" r="1.4"/><circle cx="1140" cy="575" r="1.4"/><circle cx="1140" cy="615" r="1.4"/><circle cx="1180" cy="15" r="1.4"/><circle cx="1180" cy="55" r="1.4"/><circle cx="1180" cy="95" r="1.4"/><circle cx="1180" cy="135" r="1.4"/><circle cx="1180" cy="175" r="1.4"/><circle cx="1180" cy="215" r="1.4"/><circle cx="1180" cy="255" r="1.4"/><circle cx="1180" cy="295" r="1.4"/><circle cx="1180" cy="335" r="1.4"/><circle cx="1180" cy="375" r="1.4"/><circle cx="1180" cy="415" r="1.4"/><circle cx="1180" cy="455" r="1.4"/><circle cx="1180" cy="495" r="1.4"/><circle cx="1180" cy="535" r="1.4"/><circle cx="1180" cy="575" r="1.4"/><circle cx="1180" cy="615" r="1.4"/></g><g shape-rendering="crispEdges"><rect x="472" y="172" width="16" height="16" fill="#ededef"/><rect x="492" y="172" width="16" height="16" fill="#ededef"/><rect x="512" y="172" width="16" height="16" fill="#ededef"/><rect x="452" y="192" width="16" height="16" fill="#ededef"/><rect x="532" y="192" width="16" height="16" fill="#ededef"/><rect x="452" y="212" width="16" height="16" fill="#ededef"/><rect x="452" y="232" width="16" height="16" fill="#ededef"/><rect x="492" y="232" width="16" height="16" fill="#ededef"/><rect x="512" y="232" width="16" height="16" fill="#ededef"/><rect x="532" y="232" width="16" height="16" fill="#ededef"/><rect x="452" y="252" width="16" height="16" fill="#ededef"/><rect x="532" y="252" width="16" height="16" fill="#ededef"/><rect x="452" y="272" width="16" height="16" fill="#ededef"/><rect x="532" y="272" width="16" height="16" fill="#ededef"/><rect x="472" y="292" width="16" height="16" fill="#ededef"/><rect x="492" y="292" width="16" height="16" fill="#ededef"/><rect x="512" y="292" width="16" height="16" fill="#ededef"/><rect x="612" y="172" width="16" height="16" fill="#b6a8ff"/><rect x="592" y="192" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="192" width="16" height="16" fill="#b6a8ff"/><rect x="572" y="212" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="212" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="232" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="252" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="272" width="16" height="16" fill="#b6a8ff"/><rect x="572" y="292" width="16" height="16" fill="#b6a8ff"/><rect x="592" y="292" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="292" width="16" height="16" fill="#b6a8ff"/><rect x="632" y="292" width="16" height="16" fill="#b6a8ff"/><rect x="652" y="292" width="16" height="16" fill="#b6a8ff"/><rect x="652" y="172" width="16" height="16" fill="#ededef"/><rect x="672" y="172" width="16" height="16" fill="#ededef"/><rect x="692" y="172" width="16" height="16" fill="#ededef"/><rect x="712" y="172" width="16" height="16" fill="#ededef"/><rect x="732" y="172" width="16" height="16" fill="#ededef"/><rect x="692" y="192" width="16" height="16" fill="#ededef"/><rect x="692" y="212" width="16" height="16" fill="#ededef"/><rect x="692" y="232" width="16" height="16" fill="#ededef"/><rect x="692" y="252" width="16" height="16" fill="#ededef"/><rect x="692" y="272" width="16" height="16" fill="#ededef"/><rect x="692" y="292" width="16" height="16" fill="#ededef"/></g><text x="600" y="420" text-anchor="middle" font-family="Hanken Grotesk, system-ui, sans-serif" font-size="40" font-weight="500" fill="#a0a0a8" letter-spacing="-0.5">A git forge for teams of agents</text><text x="600" y="500" text-anchor="middle" font-family="IBM Plex Mono, ui-monospace, monospace" font-size="26" fill="#b6a8ff">g1t.sh</text></svg>
+2−1
1212 integrations: [
1313 starlight({
1414 title: 'g1t docs',
15− description: 'Guides and reference for g1t, the git forge for teams of agents.',
15+ description: 'Guides and reference for g1t, where people and agents ship software together.',
1616 components: {
1717 Footer: './src/components/Footer.astro',
1818 Head: './src/components/Head.astro',
6161 { label: 'Quickstart', slug: 'quickstart' },
6262 { label: 'How g1t works', slug: 'concepts/overview' },
6363 { label: 'Search and Explore', slug: 'guides/search' },
64+ { label: 'Status and incidents', slug: 'guides/status' },
6465 ],
6566 },
6667 {
+1−0
1010 },
1111 "dependencies": {
1212 "@astrojs/starlight": "^0.42.5",
13+ "@g1t/contracts": "*",
1314 "@g1t/theme": "*",
1415 "@lucide/astro": "^1.52.0",
1516 "astro": "^7.2.10",
+1−1
55
66 const year = new Date().getUTCFullYear();
77 const links: [string, string][] = [
8− ['Status', 'https://g1t.sh/status'],
8+ ['Status', 'https://status.g1t.sh/'],
99 ['Support', 'https://g1t.sh/support'],
1010 ['Policies', 'https://g1t.sh/policies'],
1111 ['Privacy', 'https://g1t.sh/policies/privacy'],
+7−6
33 description: What g1t is for, and how issues, pull requests, checks, review, merging and sessions fit together.
44 ---
55
6−g1t is a git forge for teams of agents. You hand g1t an outcome, and a team
7−of agents converges it onto `main`: each change is made in a pull request
8−of its own, checked in a clean sandbox, reviewed, revised and merged under
9−your repository's rules. People work alongside the agents in the same
10−repositories, issues, pull requests and reviews.
6+g1t is where people and agents ship software together. You hand g1t an
7+outcome, and agents converge it onto `main`: each change is made in a pull
8+request of its own, checked in a clean sandbox, reviewed, revised and merged
9+under your repository's rules. People work alongside the agents in the same
10+repositories, issues, pull requests and reviews, and every change can deploy
11+to the edge.
1112
1213 Underneath it is ordinary git: repositories, commits, branches, clone, push
1314 and pull all work as they do anywhere. On top of that it has the two things
14−you already know from other forges, **issues** and **pull requests**, built
15+you already know, **issues** and **pull requests**, built
1516 so that many agents can work at once without getting in each other's way.
1617
1718 | | What it is |
+65−16
1515 design: it keeps passwords out of scripts and agents, and lets g1t protect
1616 the one place accounts are made.
1717
18+## Your settings
19+
20+Your own settings are at [g1t.sh/settings](https://g1t.sh/settings), one
21+page each. Open them from your account menu at the bottom of the sidebar,
22+under **Your settings**; the sidebar then lists every page.
23+
24+| Page | Address | What is on it |
25+| --- | --- | --- |
26+| Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location and website. |
27+| Emails | [`/settings/emails`](https://g1t.sh/settings/emails) | Your [email addresses](#email-addresses), the backup address, and [keeping your address private](#keeping-your-address-private). |
28+| Invites | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites](#invites). |
29+| SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/). |
30+| Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens). |
31+| GitHub | [`/settings/github`](https://g1t.sh/settings/github) | [Linking and unlinking GitHub](/guides/github/#link-and-unlink-github). |
32+| Connected applications | [`/settings/applications`](https://g1t.sh/settings/applications) | Tools you [signed in to with OAuth](#signing-in-with-oauth), such as an agent using the MCP server. |
33+| Security log | [`/settings/security-log`](https://g1t.sh/settings/security-log) | [What happened to your account](#security-log). |
34+
35+`g1t.sh/settings` opens Profile.
36+
1837 ## Signing in with GitHub
1938
2039 **Continue with GitHub** on the sign-in and sign-up pages signs you in with
2140 your GitHub account, and makes a g1t account the first time. Link or
22−unlink GitHub in [Settings](https://g1t.sh/settings#github). See
41+unlink GitHub in [Settings → GitHub](https://g1t.sh/settings/github). See
2342 [GitHub](/guides/github/#sign-in-with-github).
2443
2544 Making an account with GitHub needs an invite too: start from your invite
3655 - when it was made for an email address, works only with that address;
3756 - can be revoked by whoever made it until it is used.
3857
39−An invite link, `g1t.sh/invite/<code>`, shows who sent it and continues to
40−sign-up with the code filled in.
58+### Using an invite
59+
60+Every invite email links to `g1t.sh/invite/<code>`. That one page shows
61+who sent it and what it is for (joining a workspace, collaborating on a
62+repository, or just making an account), and finishes the job there:
63+
64+1. **No account yet**: sign up on the page. When the invite was sent to
65+ your address, the email field is filled in and locked, and the address
66+ is confirmed already, so no confirmation email follows. Choose a
67+ username (one is suggested from your address) and a password, or select
68+ **Continue with GitHub**: the invite rides along, and the account uses
69+ the invited address when GitHub has verified it too.
70+2. **The address already has an account**: select **Sign in to accept**.
71+ After you sign in, the invite is accepted for you.
72+3. **Signed in as someone else**: an invite sent to one address works only
73+ for an account that has confirmed that address. The page says so and
74+ offers **Sign out and continue**.
4175
76+Once the account exists or you have signed in, you land in the workspace
77+(or the repository) the invite was for, already a member, with a one-time
78+"You're in" banner, and it becomes the workspace your sidebar shows. A
79+code typed at [g1t.sh/register](https://g1t.sh/register) goes to the
80+same page.
81+
82+An expired, revoked or used invite says which, and who sent it, so you
83+can ask them for a new one; or ask for access from the same page.
84+
4285 ### Making invites
4386
44−1. Open [Settings → Invites](https://g1t.sh/settings#invites).
87+1. Open [Settings → Invites](https://g1t.sh/settings/invites).
4588 2. Optionally enter the email address of the person you are inviting.
4689 With one, g1t emails them the invite, and only that address can use it.
4790 Without one, anyone with the link can, once.
67110 Write to [hey@flagon.io](mailto:hey@flagon.io?subject=%5Bg1t%20Invites%5D%20)
68111 with the subject `[g1t Invites]` and say who you would like to bring. g1t
69112 can give more invites to you, or to a workspace, whose owners then share
70−them. Invites given to a workspace appear under **Settings → Invites** for
113+them. Invites given to a workspace appear under
114+[Settings → Invites](https://g1t.sh/settings/invites) for
71115 each of its owners, as a choice of whose invites to use.
72116
73117 ### Asking for access
74118
75119 Without an invite, [g1t.sh/register](https://g1t.sh/register) asks for your
76−email address and, if you like, what you will build. g1t emails you an
77−invite as places open. Asking again with the same address updates your
78−request; it does not move you down the list.
120+email address and, if you like, what you will build. g1t emails that
121+address once to confirm you are on the list, and staff see the request
122+straight away. When they approve it, the invite comes to the same address,
123+sometimes with a note, and its link opens sign-up with the address filled
124+in. There is no fixed date: g1t opens up a few people at a time. Asking
125+again with the same address updates your request without another email; it
126+does not move you down the list.
79127
80128 ### Invites through the API
81129
98146 ## Email addresses
99147
100148 An account can have up to 10 email addresses. Manage them in
101−[Settings → Emails](https://g1t.sh/settings#emails).
149+[Settings → Emails](https://g1t.sh/settings/emails).
102150
103151 | An address that is | Can |
104152 | --- | --- |
114162
115163 ### Add an address
116164
117−1. Open [Settings → Emails](https://g1t.sh/settings#emails).
165+1. Open [Settings → Emails](https://g1t.sh/settings/emails).
118166 2. Enter the address under **Add an email address** and select **Add**.
119167 3. Follow the link g1t sends it. The link works for 24 hours; **Resend
120168 link** sends a new one, at most once a minute and 10 times an hour.
169217 ```
170218 remote: push declined: commit 3f9a1c2 would publish s***@gmail.com while your email is private.
171219 remote: Commit with 6c1d0efg+sam@users.noreply.g1t.sh (git config user.email 6c1d0efg+sam@users.noreply.g1t.sh) and amend,
172−remote: or change this in g1t.sh/settings#emails.
220+remote: or change this in g1t.sh/settings/emails.
173221 ```
174222
175223 To push those commits:
226274 | API | `Authorization: Bearer g1t_…` |
227275 | MCP | The same header, set when you add the server. |
228276
229−Create one in [Settings](https://g1t.sh/settings). A token is shown once,
277+Create one in [Settings → Access tokens](https://g1t.sh/settings/tokens). A token is shown once,
230278 when it is created; g1t stores only a hash of it. If you lose one, delete it
231279 and create another. Delete a token the moment you think someone else has
232280 seen it.
245293 back, and you approve or deny. The application never sees your password and
246294 there is no token to copy.
247295
248−Applications you have approved are listed under **Connected applications**
249−in [Settings](https://g1t.sh/settings). Signing one out ends its access at
296+Applications you have approved are listed in
297+[Settings → Connected applications](https://g1t.sh/settings/applications). Signing one out ends its access at
250298 once.
251299
252300 For people building a client:
293341
294342 The poll answers with a `status` of `pending`, `approved`, `denied` or
295343 `expired`. An approved answer carries the token, once. Codes expire after 15
296−minutes. The token appears in your settings under the tool's name, where you
344+minutes. The token appears in
345+[Settings → Access tokens](https://g1t.sh/settings/tokens) under the tool's name, where you
297346 can delete it.
298347
299348 Only approve a code you asked for. Approving gives the tool the full rights
327376
328377 ## Security log
329378
330−[Settings → Security log](https://g1t.sh/settings#security-log) lists what
379+[Settings → Security log](https://g1t.sh/settings/security-log) lists what
331380 happened to your account: addresses added, confirmed, removed or made
332381 primary, your backup and privacy settings, password changes, and pauses
333382 after too many wrong passwords. Changes g1t staff made, such as removing an
+3−3
1616 <AgentSetup />
1717
1818 Signing in opens g1t in your browser; you approve, and the agent is
19−connected. There is no token to copy. It shows up under **Connected
20−applications** in [Settings](https://g1t.sh/settings), where you can sign
21−it out.
19+connected. There is no token to copy. It shows up in
20+[Settings → Connected applications](https://g1t.sh/settings/applications),
21+where you can sign it out.
2222
2323 The agent also needs to push with git, which asks for a username and a
2424 password: use your g1t username and an
+35−36
192192 **Issuing certificate** to **Active** by itself; **Check now** asks
193193 again at once.
194194
195−Custom domains need the workspace's g1t plan. Adding or removing one
196−needs the Admin [role](/guides/access-and-roles/) on the project's repository.
195+Custom domains need the workspace's g1t plan, and are $0.12 a month each
196+(their cost plus 20%), as many as you like. Adding or removing one needs
197+the Admin [role](/guides/access-and-roles/) on the project's repository.
197198
198199 ### A subdomain, such as www
199200
318319
319320 ## What it costs
320321
321−Deployments are never free. They come with the
322+Deployments come with the
322323 [g1t plan](/guides/usage-and-billing/#the-g1t-plan), $20 a month per
323−workspace, which includes, each calendar month (UTC):
324+workspace with $10 of usage included. There are **no quotas**: as many
325+projects and previews as you like, and no count of builds, requests or
326+domains ever stops or pauses an app. What deployments cost g1t is metered
327+from the first unit, at Cloudflare's price plus 20%, and paid from the
328+plan's $10 of included usage first; past it, it is charged up to your
329+[spend limit](/guides/usage-and-billing/#limits).
324330
325−| Included | |
326−| --- | --- |
327−| 10 apps | The most apps up at once: production and previews together, across the workspace's projects. |
328−| 1 million requests | To all of the workspace's apps. |
329−| 3 million CPU milliseconds | Time your code spends computing. Waiting on the network is not counted. |
330−| 3 custom domains | Across the workspace's projects, with their certificates. A www/apex pair is two. |
331−| 200 build minutes | Every build's time, whether it succeeds or fails. |
332−
333−**Usage past that** is charged at Cloudflare's price plus 20%, paid from
334−the plan's $10 of included usage first:
335−
336−| | Price |
337−| --- | --- |
338−| A build past the 200 minutes | $0.0015 a minute, by the second, whether it succeeds or fails. |
339−| Each app past 10 | $0.024 a month |
340−| Each million requests past 1 million | $0.36 |
341−| Each million CPU milliseconds past 3 million | $0.024 |
342−| Each custom domain past 3 | $0.12 a month, by the most the workspace had at once that month |
331+| | Costs g1t | You pay |
332+| --- | --- | --- |
333+| Projects, previews and the apps behind them | Next to nothing | Not charged |
334+| Builds, by the second, whether they succeed or fail | About $0.001 a minute | About $0.0012 a minute |
335+| Requests, to all of the workspace's apps | $0.30 a million | $0.36 a million |
336+| CPU time your code spends computing (waiting on the network is not counted) | $0.02 a million ms | $0.024 a million ms |
337+| Custom domains, with their certificates (a www/apex pair is two) | $0.10 a month | $0.12 a month, by the most the workspace had at once that month |
343338
344−Builds are recorded when they finish: the plan's minutes pay for them
345−first, and a build that runs past them is charged only for the seconds
346−past them. Other usage past the allowance is charged once, on the first
347−sweep after the month ends, as one line: *Deployments in 2026-10 past the
348−plan*.
339+Builds are charged when they finish. Requests, CPU time and custom domains
340+are counted through the month, so your limit and **Billing** follow them as
341+they happen, and charged once, on the first sweep after the month ends, as
342+one line: *Deployments in 2026-10: 1,240,000 requests, 3,100,000 CPU ms, 2
343+custom domains*.
349344
350345 A static site, or plain HTML, runs no code when it is visited: its files
351346 are served as they are, so serving it adds no requests or CPU time. Its
352347 builds are metered. A Workers project's code runs on every request, so its
353348 requests and CPU time are metered too.
354349
355−An app that no one visits costs nothing beyond counting toward the 10. That
356−is why previews come down when their pull request closes and after their
357−idle days.
350+An app that no one visits costs nothing. Previews still come down when
351+their pull request closes and after their idle days, to keep the list of
352+what is up short.
353+
354+The only things that stop a workspace's apps are its own spend limit
355+(apps pause with a notice, and come back once there is room) and the plan
356+ending (they come down).
358357
359358 Deployments used to be a $5 plan of their own. A Deployments subscription
360359 bought before keeps working until its current period ends, and is not
362361
363362 ### Seeing what you use
364363
365−- **Billing**, under the plan, shows this month's apps, build
366− minutes, requests and CPU time against what the plan includes.
367− Requests and CPU time are counted from Cloudflare's analytics
368− every 10 minutes.
364+- **Billing**, under the plan, shows **This month's usage**: *Builds*
365+ (build minutes and their cost), *Requests & CPU* and *Custom domains*,
366+ in dollars, beside the rest of the workspace's usage. Requests and CPU
367+ time are counted from Cloudflare's analytics every 10 minutes.
369368 - The **statement** on Billing lists every build (*Building acme/web to
370− production (48 s), all of it included in the plan*) and every month's
371− usage past the plan.
369+ production (48 s), paid by your plan's included usage*) and every
370+ month's requests, CPU time and custom domains.
372371 - Each build's page shows how long it ran.
373372
374373 ## Turn it off
+10−7
2929
3030 Pushing, and reading private repositories, needs credentials. Use your
3131 username, and as the password either your account password or an
32−[access token](https://g1t.sh/settings). Tokens are recommended: they can be revoked
32+[access token](https://g1t.sh/settings/tokens). Tokens are recommended: they can be revoked
3333 individually and they also work for the API.
3434
3535 To avoid typing it each time, let git store it:
6060
6161 | You are | The page says |
6262 | --- | --- |
63−| Signed out | **Sign in to see this project**: it may be private, or it may not exist. Signing in brings you back to the same address. |
64−| Signed in | **This project doesn't exist, or you don't have access to it**, which account you are signed in as, and a link to switch account. If you should have access, ask someone with the Admin role on it to add you. |
63+| Signed out | **Nothing here**: this page doesn't exist, or it's private; sign in if it's yours. **Sign in** brings you back to the same address. |
64+| Signed in | **Nothing here**: this page doesn't exist, or you don't have access to it, with which account you are signed in as and a link to switch account. If you should have access, ask someone with the Admin role on it to add you. |
6565
66−Issues, pull requests and workspace pages work the same way. A profile
67−that does not exist says **No one goes by that name**, since profiles are
68−public.
66+Issues, pull requests and workspace pages work the same way. The sidebar
67+does not open the project or workspace the address names, so nothing on
68+the page hints at whether it exists; a missing file, commit or issue in a
69+project you can see keeps that project's sidebar. A profile that does not
70+exist says **No one on g1t goes by that name**, since profiles are public.
6971
7072 ## Browsing without an account
7173
131133 Why: git over SSH needs raw TCP connections on port 22, and g1t runs
132134 entirely on Cloudflare's network. Accepting inbound TCP traffic directly
133135 into Workers is in a beta from Cloudflare that g1t has applied for and is
134−waiting on. SSH keys can already be added under **Settings → SSH keys**,
136+waiting on. SSH keys can already be added under
137+[Settings → SSH keys](https://g1t.sh/settings/keys),
135138 and will be used once SSH is on.
+1−1
5050
5151 ## Link and unlink GitHub
5252
53−In [Settings](https://g1t.sh/settings#github), under **GitHub**:
53+In [Settings → GitHub](https://g1t.sh/settings/github):
5454
5555 - **Link GitHub** sends you to GitHub and links the account you authorize.
5656 One GitHub account links to one g1t account, and the other way round.
+22−1
2020 | Repositories: create, push and clone over HTTP, browse code, commits | Works |
2121 | Issues, comments, labels | Works |
2222 | Site search | Works |
23+| A status page of your own | Works, at `http://localhost:8788` ([below](#the-status-page)) |
2324 | Webhooks, integrations | Run, but scheduled retries do not (see below) |
2425 | Sign in with GitHub, import from GitHub | Off until you register a GitHub App of your own ([below](#sign-in-with-github-and-import-from-github)). Mirrors sync with **Sync now**: GitHub's webhook needs the REST API. |
2526 | g1t agents, plans, reviews by agents | Off |
3334
3435 - Docker with Compose v2 (`docker compose version`).
3536 - About 4 GB of free disk space for the images.
36−- Ports 8787 and 8025 free on your machine.
37+- Ports 8787, 8788 and 8025 free on your machine.
3738
3839 ## Start g1t
3940
100101 | `MAIL_URL` | `http://mailpit:8025` | The Mailpit server g1t sends mail through |
101102 | `REGISTRATION_MODE` | `open` | `open`: anyone can make an account. `invite`: every new account needs an [invite](/guides/authentication/#invites), as on g1t.sh. |
102103 | `INVITES_PER_USER` | `5` | How many invites each person can have out, while `REGISTRATION_MODE` is `invite` |
104+| `WAITLIST_NOTIFY_EMAIL` | (none) | Where a summary of new access requests goes, at most every 15 minutes. Empty sends none; requests still wait for you in the database. |
105+| `STATUS_PORT` | `8788` | The port the status page is published on |
106+| `STATUS_PROBE_REPO` | (none) | A public repository, `workspace/repo`, whose branches the status page lists every minute as a clone would. Empty: git is not checked. |
103107 | `INVITE_STAFF_WORKSPACES` | (none) | Workspace slugs, comma separated, whose owners can make invites without a limit. Set it to your own workspace before you switch to `invite`, so someone can invite the first people. |
104108
109+## The status page
110+
111+The `status` service runs the same status page as
112+[status.g1t.sh](/guides/status/), in a process of its own, so it keeps
113+answering when the site does not. Open
114+[http://localhost:8788](http://localhost:8788).
115+
116+Every minute it loads the site's sign-in page from inside Compose, and,
117+with `STATUS_PROBE_REPO` set, lists that repository's branches. It keeps
118+90 days of history on its own volume, `g1t-status`. Parts an installation
119+of your own does not run (the API, MCP, docs, deployments, the model
120+proxy and billing) are left off its page.
121+
122+The links to **Status** in the site's footer and account menu still point
123+to status.g1t.sh; pointing them at your own status page is not a setting
124+yet.
125+
105126 To deliver email to real inboxes, have Mailpit relay it through your SMTP
106127 server. The settings are in `docker-compose.yml`, under `mailpit`.
107128
+224−0
1+---
2+title: Status and incidents
3+description: Where to see whether g1t is working, what each check measures, how incidents and maintenance are posted, how to subscribe, and the JSON and feeds behind the page.
4+---
5+
6+[status.g1t.sh](https://status.g1t.sh/) says whether each part of g1t is
7+working now, how each has done over the last 90 days, what g1t staff have
8+said about anything that went wrong, and what planned maintenance is
9+coming. It runs as a service of its own, apart from the site, so it stays
10+up when g1t.sh does not.
11+
12+The account menu at the bottom of the sidebar shows the same state as a
13+dot beside **Status**, and so does the footer of g1t.sh's public pages.
14+
15+## What is checked
16+
17+Every minute, each part gets one quick request, the same one a visitor
18+would make, over the public internet:
19+
20+| Part | What the check does |
21+| --- | --- |
22+| Website and sign-in | Loads `g1t.sh/login`, and asks the API about an access token no one holds, which the account service must refuse with `401` |
23+| API | Loads `api.g1t.sh/` |
24+| Git and repositories | Lists the branches of a public repository over HTTPS (`info/refs`), the first step of every clone |
25+| MCP server | Loads `mcp.g1t.sh/` |
26+| Documentation | Loads `docs.g1t.sh/` |
27+| Deployments | Loads `g1t.page/`. Each deployed app is not checked one by one. |
28+| Agents' model proxy | Loads `models.g1t.sh/`. The model providers behind it are not checked. |
29+| Sandboxes | Not checked yet: starting a sandbox costs money and takes seconds. The page says so rather than showing green. |
30+| Billing | Reads billing's price book. Stripe itself is not checked. |
31+
32+A part that answers in over 1.5 seconds counts as slow, and one that
33+fails or takes over 5 seconds counts as down.
34+
35+## What each part shows
36+
37+A part shows the worse of its last check and what an open incident says
38+about it. During planned maintenance it shows **Under maintenance**
39+instead, unless an incident says something worse.
40+
41+| State | Meaning |
42+| --- | --- |
43+| Operational | Its check answered quickly, and no incident says otherwise |
44+| Degraded | Slow to answer, or an incident reports degraded performance |
45+| Partial outage | An incident reports that it is partly down |
46+| Outage | Its check failed, or an incident reports a major outage |
47+| Under maintenance | Planned work on it is under way |
48+| Not monitored | It has no check, and no incident is open on it |
49+
50+The banner at the top sums them up:
51+
52+| Banner | When |
53+| --- | --- |
54+| All systems normal | Every checked part answered quickly, and no incident is open |
55+| Under maintenance | Planned work is under way, and nothing else is wrong |
56+| Degraded performance | Some parts are slow, and none is down |
57+| Partial outage | A part other than the website, API or git is down, a part is partly down, or an incident is open |
58+| Major outage | The website, the API or git is down, or an open incident reports a major outage |
59+
60+## Uptime bars
61+
62+Each part has a bar of 90 days, one square per day in UTC, newest on the
63+right. Hover over a square, tap it, or focus the bar and use the arrow
64+keys to see that day's share of answered checks, failed and slow checks,
65+the average response time, and any incident. On a phone the bar shows
66+the last 30 days.
67+
68+| Square | The day |
69+| --- | --- |
70+| Green | At least 99.9% of checks answered, so one failed check in a day stays green |
71+| Peach | At least 95% answered, over a quarter of answers were slow, or an incident with degraded performance or a partial outage on the part was open |
72+| Red | Less than 95% answered, or an incident with a major outage on the part was open |
73+| Gray | No checks yet |
74+
75+The percentage under each bar is the share of checks that answered over
76+90 days, rounded down, so a single failure never shows as 100%. Checks
77+made while a part is under planned maintenance are not counted.
78+
79+## Incidents
80+
81+When something goes wrong, g1t staff post an incident: a title, the
82+parts it affects and how badly, and updates as it moves from
83+**Investigating** to **Identified**, **Monitoring** and **Resolved**.
84+
85+- Open incidents are shown at the top under **Happening now**, with every
86+ update, newest first.
87+- Each incident has a page of its own at
88+ `https://status.g1t.sh/incidents/<id>`, with its full history. Links in
89+ emails and feeds go there.
90+- Incidents resolved in the last 14 days are listed under **Past
91+ incidents**; [History](https://status.g1t.sh/history) lists every
92+ incident and maintenance of the last 12 months, by month.
93+- After a significant incident, g1t publishes a **postmortem** on the
94+ incident's page: a summary, the impact, a timeline, the root cause, what
95+ went well and badly, and what will change. Incidents with one are marked
96+ **Postmortem** in the lists.
97+
98+### Severity
99+
100+Staff give every incident a severity. It decides how fast the team
101+responds and how often it posts updates; it is not shown on the status
102+page, which shows each part's impact instead.
103+
104+| Severity | Meaning | Public updates |
105+| --- | --- | --- |
106+| SEV1 | Critical: g1t is down or unusable for most people, or data is at risk | At least every 30 minutes; subscribers are emailed |
107+| SEV2 | Major: a core part (sign-in, git, the API, agents) is broken or badly degraded for many people | At least hourly; subscribers are emailed |
108+| SEV3 | Minor: one part is degraded or broken for some people, and there is a way around it | As things change |
109+| SEV4 | Low: little or no customer impact | As things change |
110+
111+### Noticed before anyone reports it
112+
113+When a part fails, or is slow, three checks in a row, g1t staff are
114+alerted and an incident is drafted for them. It appears on the status
115+page once someone confirms it, usually within minutes. The part's own
116+state on the page changes at once either way, because it comes from the
117+checks.
118+
119+If something is broken and the page does not show it, write to
120+[hey@flagon.io](mailto:hey@flagon.io) or see
121+[support](https://g1t.sh/support).
122+
123+## Planned maintenance
124+
125+Work that may interrupt a part is announced ahead of time:
126+
127+1. It is listed under **Upcoming maintenance** with its window (start and
128+ end, shown in your time zone), the parts it affects, and what you may
129+ notice.
130+2. When the window opens, it moves to **Maintenance in progress**, and its
131+ parts show **Under maintenance**.
132+3. When the window ends, it is marked complete on its own, with an update
133+ saying so. Staff can also start it early, finish it early, or cancel it.
134+
135+Each maintenance window has a page at
136+`https://status.g1t.sh/maintenance/<id>`.
137+
138+## Subscribe to updates
139+
140+Get an email when an incident or maintenance is posted or updated:
141+
142+1. Enter your address under **Subscribe to updates** on
143+ [status.g1t.sh](https://status.g1t.sh/), or on
144+ [status.g1t.sh/subscribe](https://status.g1t.sh/subscribe) to choose
145+ which parts you hear about. Leave every part clear to hear about all of
146+ them.
147+2. Open the email from `noreply@g1t.sh` and follow **Confirm
148+ subscription**, then press the button on the page. The link works once,
149+ for 24 hours. Nothing is sent until you confirm.
150+3. To change which parts you hear about, subscribe again with the same
151+ address and confirm again.
152+
153+Every email has an **Unsubscribe** link at the bottom, and mail apps that
154+support one-click unsubscribing show it too. Staff choose per update
155+whether to email: SEV1 and SEV2 updates are emailed, smaller ones may
156+not be. The feeds carry every update.
157+
158+## Feeds
159+
160+Every public incident update and maintenance notice, newest first, the
161+last 50:
162+
163+| Feed | Address |
164+| --- | --- |
165+| Atom (feed readers, Slack's RSS app, and most chat tools) | `https://status.g1t.sh/feed.xml` |
166+| JSON Feed 1.1 | `https://status.g1t.sh/feed.json` |
167+
168+Each item links to the update on its incident's or maintenance's page.
169+
170+## The status as JSON
171+
172+`GET https://status.g1t.sh/status.json` returns the same report, readable
173+from any origin and cached for 30 seconds:
174+
175+```sh
176+curl -s https://status.g1t.sh/status.json
177+```
178+
179+```json
180+{
181+ "checked_at": "2026-10-05T14:03:00.000Z",
182+ "overall": {
183+ "state": "up",
184+ "title": "All systems normal",
185+ "line": "Every part of g1t answered its last check."
186+ },
187+ "components": [
188+ {
189+ "key": "api",
190+ "name": "API",
191+ "address": "api.g1t.sh",
192+ "checks": "The API's front page.",
193+ "state": "up",
194+ "check_state": "up",
195+ "detail": "Answered in 84 ms",
196+ "latency_ms": 84,
197+ "uptime_90d": 99.98
198+ }
199+ ],
200+ "incidents": [],
201+ "maintenance": []
202+}
203+```
204+
205+| Field | What it is |
206+| --- | --- |
207+| `overall.state` | `up`, `degraded`, `down`, `maintenance`, or `unknown` before the first check |
208+| `components[].state` | What the page shows: `up`, `degraded`, `partial`, `down`, `maintenance`, or `unmonitored` |
209+| `components[].check_state` | What the last check alone said: `up`, `degraded`, `down` or `unmonitored` |
210+| `components[].uptime_90d` | Share of checks answered over 90 days, 0 to 100, or `null` |
211+| `incidents[]` | Open incidents, and those resolved in the last 90 days, newest first |
212+| `incidents[].status` | `investigating`, `identified`, `monitoring` or `resolved` |
213+| `incidents[].impact` | `down` when any part has a major outage, otherwise `degraded` |
214+| `incidents[].component_impacts` | Each affected part's `key` and `impact`: `degraded`, `partial_outage` or `major_outage` |
215+| `incidents[].url` | The incident's page |
216+| `incidents[].postmortem_published_at` | When its postmortem was published, or `null` |
217+| `incidents[].updates` | Newest first, each with `id`, `at`, `status` and `text` |
218+| `maintenance[]` | Maintenance scheduled or under way, soonest first, each with `title`, `message`, `components`, `starts_at`, `ends_at`, `state` (`scheduled` or `in_progress`), `url` and `updates` |
219+
220+`https://status.g1t.sh/badge.svg` is a small badge with the banner's
221+words, for a README or a dashboard.
222+
223+The old addresses, `g1t.sh/status` and `g1t.sh/status.json`, redirect
224+here.
+11−4
110110 ## Deployments
111111
112112 A `g1t.page` address ends in its workspace's name: production is at
113−`<project>-<workspace>.g1t.page`. After a transfer, each app that is up is
114−built again from the same commit under the new workspace's name. The old
115−address keeps serving until the new one is live, then redirects to it for
116−90 days. Custom domains move with the project and serve the new build.
113+`<project>-<workspace>.g1t.page`. After a transfer, each of the project's
114+apps (production and every open preview, including one paused by the old
115+workspace's usage limit) is built again from the same commit under the new
116+workspace's name. The old address keeps serving until the new one is live,
117+then redirects to it for 90 days, whatever the old workspace's plan or
118+limit. Custom domains move with the project and serve the new build.
119+
120+From the transfer on, the apps are the new workspace's: its plan and usage
121+limit decide whether they build and serve. If the new workspace cannot
122+build yet (Deployments are off, or it reached its limit), the rebuild waits
123+and is tried again until it can.
117124
118125 ## Billing
119126
+90−45
66 The forge is free: repositories, git, issues, pull requests and review cost
77 nothing, for public and private work. What costs money is compute, the
88 things g1t runs for you: agents, the sandboxes they and your checks run in,
9−deployed apps, and, on the plan, storage and git traffic past what is
10−included. Each is
11−charged at what it costs g1t plus 20%, to the workspace that owns the
12−repository. There is no seat price.
9+deployed apps, and, on the plan, storage and git traffic past the free
10+amounts. Each is charged at what it costs g1t plus 20%, to the workspace
11+that owns the repository. There is no seat price, and on the plan there
12+are no quotas: only your spend limit stops anything.
1313
1414 ## What is free
1515
2020 - Search and Explore.
2121 - The [audit log](/guides/audit-log/), kept 90 days, with export.
2222 - Your own agent through [MCP](/reference/mcp/).
23−- 1 GB of private repository storage, and 10,000
23+- 1 GB of private repository storage, and 50,000
2424 [git operations](#git-operations) a month.
2525
26−None of this is charged. A free workspace is never charged for storage:
27−once its private repositories hold 1 GB, pushes to them are refused at the
28−start of the push, with what to do: make the repository public, delete what
29−you do not need, or start the plan for 10 GB. Public repositories are never
30−charged for storage.
26+None of this is charged, on the plan or not. A free workspace is never
27+charged for storage: once its private repositories hold 1 GB, pushes to
28+them are refused at the start of the push, with what to do: make the
29+repository public, delete what you do not need, or start the plan, where
30+storage past 1 GB is usage and pushes never stop. Public repositories are
31+never charged for storage.
3132
3233 Compute is not free. Agents, checks, workflows, the merge queue,
3334 deployments and semantic search need [the g1t plan](#the-g1t-plan), or a
3839 One plan, **$20 a month per workspace**, however many people and agents
3940 are in it. It is never priced per person.
4041
41−| Each month (UTC) | |
42−| --- | --- |
43−| Included usage | $10, at cost plus 20% |
44−| Members | Unlimited |
45−| Private repository storage | 10 GB |
46−| Git operations | 10,000 |
47−| [Deployments](/guides/deployments/) | 10 apps, 1 million requests, 3 million CPU milliseconds, 3 custom domains and 200 build minutes |
42+- **$10 of usage each month** at cost plus 20%, used first.
43+- **Everyone in the workspace** at one price, never per person.
44+- **Unlimited** projects, previews and repositories.
45+- Agents, checks, workflows, the merge queue,
46+ [deployments](/guides/deployments/) and semantic search.
47+- Usage past $10 is charged at cost plus 20%, **up to your
48+ [spend limit](#limits)**.
49+
50+There are **no quotas** on the plan: no count of projects, previews,
51+build minutes, requests, custom domains, git operations or gigabytes
52+stops a workspace. Everything that costs g1t money is metered from the
53+first unit (past the free 1 GB of private storage and 50,000 git
54+operations that every workspace has), and only your spend limit stops
55+work.
4856
4957 **Included usage** pays for the month's usage first, at the same prices as
50−everything else: agents, sandbox time, builds, storage, git operations,
51−search embeddings and security scans. Past it, usage is charged at cost
52−plus 20%. It starts again on the 1st of each month (UTC). **Unused
53−included usage does not roll over.** Billing shows how much of it this
54−month's usage has drawn.
58+everything else: agents, sandbox time, builds, app traffic, custom domains,
59+storage, git operations, search embeddings and security scans. Past it,
60+usage is charged at cost plus 20%. It starts again on the 1st of each
61+month (UTC). **Unused included usage does not roll over.** Billing shows
62+how much of it this month's usage has drawn.
63+
64+### What it costs
65+
66+Every price is what g1t pays plus 20%. The live figures are on
67+[g1t.sh/pricing](https://g1t.sh/pricing).
5568
69+| What | Unit | Costs g1t | You pay |
70+| --- | --- | --- | --- |
71+| Models | A run | What the provider charged | Cost + 20% |
72+| Sandbox time (agents, checks, workflows, the merge queue) | Second | About $0.000016 | About $0.000019 |
73+| Deploy builds | Second | About $0.000016 | About $0.000019 ($0.0012 a minute) |
74+| App requests | Million | $0.30 | $0.36 |
75+| App CPU time | Million CPU milliseconds | $0.02 | $0.024 |
76+| Custom domains | Domain-month | $0.10 | $0.12 |
77+| Private storage, past the free 1 GB | GB-month | $0.50 | $0.60 |
78+| Git operations, past the free 50,000 a month | 1,000 | $0.15 | $0.18 |
79+| Search embeddings | Million tokens | $0.067 | $0.0804 |
80+| Security scans | Million CPU ms / million rows | $0.02 / $1.00 | $0.024 / $1.20 |
81+| Projects, previews, apps and repositories | | Next to nothing | Not charged |
82+
83+Sandbox and build seconds follow what Cloudflare bills g1t, so the
84+figures above move a little; the pricing page always has today's.
85+
5686 **The other $10** pays for running g1t, the free forge everyone uses, and
5787 the people building it.
5888
70100
71101 Back on Billing, the card says **On the g1t plan** (**first month** in
72102 the first billing cycle), with a meter for the month's included usage and
73−one for deployments. **Manage on Stripe** opens the card, invoices and
103+**This month's usage**: what each kind of usage has come to so far, in
104+dollars and in what was used (*Agents & sandboxes*, *Builds*, *Requests &
105+CPU*, *Custom domains*, *Git operations & storage*, *Search & security
106+scans*). App traffic, custom domains, storage and git operations are
107+counted through the month and charged when it closes. **Manage on Stripe** opens the card, invoices and
74108 billing details. **End at the end of the period** ends the plan then, with
75109 nothing more charged after; **Keep the plan** takes that back until then.
76110 If a renewal payment fails, the card says **Payment failed**, with
155189 | Acceptance checks | [Sandbox time](#sandbox-time) |
156190 | The [merge queue](/guides/merge-queue/) | [Sandbox time](#sandbox-time) |
157191 | [Workflow](/guides/actions/) jobs | [Sandbox time](#sandbox-time) |
158−| [Deployments](/guides/deployments/) | Past what the plan includes |
159−| [Private repository storage](#storage-search-embeddings-and-scans) | Past 10 GB, on the plan only |
160−| [Git operations](#git-operations) | Past 10,000 a month, on the plan only |
192+| [Deployments](/guides/deployments/) | Builds, requests, CPU time and custom domains, from the first. Projects and previews are not charged. |
193+| [Private repository storage](#storage-search-embeddings-and-scans) | Past the free 1 GB, on the plan only |
194+| [Git operations](#git-operations) | Past the free 50,000 a month, on the plan only |
161195 | [Search embeddings](#storage-search-embeddings-and-scans) | For private text |
162196 | [Security scans](#storage-search-embeddings-and-scans) | Yes |
163197 | Repositories, issues, pull requests, review, search, the API and MCP | No |
185219 | Models | What the provider charged for the run |
186220 | Sandbox time | Per second: memory, disk and the Durable Object behind each container, plus CPU |
187221 | Deploy builds | Per second |
188−| App requests, CPU and apps | Per million requests, per million CPU milliseconds, per app-month |
189−| Custom domains past the plan | Per domain-month |
190−| Private storage | Per GB-month |
191−| Git operations | Per 1,000 |
222+| App requests and CPU | Per million requests, per million CPU milliseconds |
223+| Custom domains | Per domain-month |
224+| Private storage past the free 1 GB | Per GB-month |
225+| Git operations past the free 50,000 a month | Per 1,000 |
192226 | Search embeddings | Per million tokens |
193227 | Security scans | Per million CPU milliseconds and per million rows written |
194228
226260 Each sandbox is one line on the [statement](#the-statement), such as
227261 *Checks on acme/api#12: 3m 12s of sandbox time*. Deploy builds are not
228262 counted here: [Deployments](/guides/deployments/) charges them by the
229−second, after the plan's 200 build minutes.
263+second, from the first, under **Builds**.
230264
231265 ## Git operations
232266
236270
237271 | | Each month (UTC) |
238272 | --- | --- |
239−| Included, on every workspace | 10,000 |
240−| On the plan, past 10,000 | $0.18 per 1,000 (cost plus 20%) |
273+| Free, on every workspace | 50,000 |
274+| On the plan, past 50,000 | $0.18 per 1,000 (cost plus 20%). Never slowed or refused. |
241275 | Free workspace, past 50,000 | Not charged. Slowed to 60 an hour until the month turns. |
242276
243−10,000 is about twenty times what an active workspace uses; an agent run
244−takes two to four. A free workspace is never charged for git operations.
245−Past 50,000 in a month, its git requests past 60 in an hour are answered
246−`429` with when to try again.
277+50,000 is far more than an active workspace uses; an agent run takes two
278+to four. A workspace on the plan pushes and clones on past it and pays for
279+what it uses, up to its spend limit. A free workspace is never charged for
280+git operations: past 50,000 in a month, its git requests past 60 in an
281+hour are answered `429` with when to try again.
247282
248−Counting starts on 2026-10-14. **Billing** shows this month's count
249−against what is included.
283+Counting starts on 2026-10-14. **Billing** shows this month's count under
284+**Git operations & storage**.
250285
251286 ## Storage, search embeddings and scans
252287
256291
257292 | | What it costs g1t | What is counted |
258293 | --- | --- | --- |
259−| Private repository storage | $0.50 a GB-month (Cloudflare Artifacts) | On the plan, each day, what the workspace's private repositories hold past 10 GB. A month's GB-months are those days added up, divided by 30. |
294+| Private repository storage | $0.50 a GB-month (Cloudflare Artifacts) | On the plan, each day, what the workspace's private repositories hold past the free 1 GB. A month's GB-months are those days added up, divided by 30. |
260295 | Search embeddings | $0.067 per million tokens (Workers AI) | The text of private repositories, issues and pull requests put in the search index. Public text and searches are not charged. |
261296 | Security scans | $0.02 per million CPU milliseconds and $1.00 per million rows written (Workers and D1) | The CPU each history scan and dependency check takes and the rows it writes. |
262297
264299 the packs pushed through g1t's git endpoints to each repository and its
265300 pull requests' working copies, so what is charged is never more than what
266301 is stored. Public repositories are never charged, and neither is a free
267−workspace: its pushes to private repositories stop at 1 GB instead.
302+workspace: its pushes to private repositories stop at 1 GB instead. On the
303+plan they never stop.
268304
269305 ## Limits
270306
542578 | Agent runs | Runs on g1t's models: the model's cost plus the margin. |
543579 | Runs on your own model provider | Older months only: the flat fee runs on your own provider used to carry. |
544580 | Sandbox time | Each sandbox's time. |
545− | Deployments | Builds and apps past what the plan includes. |
546− | Private storage | Storage past what is included, once a month. |
547− | Git operations | Operations past what is included, once a month. |
581+ | Deployments | Builds as they finish; each month's requests, CPU time and custom domains when it closes. |
582+ | Private storage | Storage past the free 1 GB, once a month. |
583+ | Git operations | Operations past the free 50,000, once a month. |
548584 | Search embeddings | Private text put in the search index, once a month. |
549585 | Security scans | History scans and dependency checks, once a month. |
550586 | Payments | Card payments and invoices paid. |
597633 `maxConcurrentAgents`, `maxRunMinutes`, `runCapMicros`, `issueCapMicros`,
598634 `ceilingMicros`, `exposureMicros`, `heldMicros`, `prepaidMicros`,
599635 `includedMicros`, `includedUsedMicros`, `paused` (why compute is paused,
600−or none), `spike`, `alerts`, `gitOperations` and `gitOperationsIncluded`.
636+or none), `spike`, `alerts`, `gitOperations` and `gitOperationsIncluded`
637+(the git operations free for every workspace), `freePrivateStorageBytes`
638+(the private storage free for every workspace) and `buildSecondsUsed`.
639+None of these is a quota on the plan.
640+
641+**`usage_meters`** (`workspace`, `viewer`; members only) returns this
642+month's usage in six lines, `agents`, `builds`, `requests`, `domains`,
643+`git_storage` and `search_scans`, each with `micros` (at cost plus 20%,
644+before included usage or a pool paid for it) and a `quantity` such as
645+*42 build minutes*. It is what **This month's usage** on Billing shows.
601646
602647 **`reserve`** holds the work's estimated cost before it starts, so starts
603648 at the same moment cannot overshoot together. `kind` is `agent`, `check`,
+9−3
6565 that changes gets a new address and nothing shows the old one.
6666
6767 You can upload a picture of yourself the same way, under
68−[Settings → Picture](https://g1t.sh/settings#picture).
68+[Settings → Profile](https://g1t.sh/settings/profile).
6969
7070 ## Rename a workspace
7171
214214 an account, it costs nothing, and they join when they accept. The page
215215 never says which it was.
216216
217+The email names you and the workspace and links to the invite's page.
218+Someone new signs up right there, with the invited address filled in and
219+already confirmed; someone with an account signs in. Either way they land
220+in the workspace as a member, with a one-time welcome. See
221+[using an invite](/guides/authentication/#using-an-invite).
222+
217223 Pending invites are listed under the members, with a link to copy and
218224 **Revoke**. An owner can also remove a member there. Through the API, use
219225 [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/)
276282 | Can reach | Every workspace you belong to | That workspace only |
277283 | Can do | Everything you can | Admin on the workspace's repositories; it cannot manage people, tokens or workspaces |
278284 | When its creator leaves | Stops working | Keeps working |
279−| Created by | You, in [Settings](https://g1t.sh/settings) | An owner, under the workspace's **Settings → Access tokens** |
285+| Created by | You, in [Settings → Access tokens](https://g1t.sh/settings/tokens) | An owner, under the workspace's **Settings → Access tokens** |
280286
281287 They are the same kind of token and are sent the same way; see
282288 [access tokens](/guides/authentication/#access-tokens). With git, any
302308 repository and sort order. Add `?tab=pulls&state=merged` and the like to
303309 link to a filtered list.
304310
305−**Edit it** under **Profile** in [Settings](https://g1t.sh/settings). Every
311+**Edit it** in [Settings → Profile](https://g1t.sh/settings/profile). Every
306312 field is optional. The bio takes up to 160 characters and is also what a
307313 link to your profile says. The website must be an `https://` address;
308314 `example.com` is saved as `https://example.com`. Your email address is
+1−1
3030 2. **Create an access token**
3131
3232 Git and the API authenticate with a token. Create one in
33− [Settings](https://g1t.sh/settings) and copy it: it is shown once.
33+ [Settings → Access tokens](https://g1t.sh/settings/tokens) and copy it: it is shown once.
3434
3535 ```sh
3636 export G1T_TOKEN=g1t_…
+1−1
4040 ## Authentication
4141
4242 A token is needed to change anything, and to see what is private. Send an
43−[access token](https://g1t.sh/settings) as a bearer token:
43+[access token](https://g1t.sh/settings/tokens) as a bearer token:
4444
4545 ```sh
4646 curl https://api.g1t.sh/user \
+3−0
11 import { defineRouteMiddleware } from '@astrojs/starlight/route-data';
2+import { ogVersion } from '@g1t/contracts/og';
23
34 import { tabFor } from './tabs';
45
5657 card.searchParams.set('title', title);
5758 if (section) card.searchParams.set('section', section);
5859 if (description) card.searchParams.set('description', description);
60+ // The render version, so a new card design is a new address for every cache.
61+ card.searchParams.set('v', ogVersion());
5962 const image = card.toString();
6063 const alt = `${title} · g1t docs`;
6164 const meta = (key: 'property' | 'name', name: string, content: string) => ({
+55−0
1+-- The status page's own record: each part's last check, a day-by-day
2+-- tally for the 90-day bars, and incidents posted from sudo.
3+
4+-- Each part at its last check.
5+CREATE TABLE current (
6+ component TEXT PRIMARY KEY,
7+ state TEXT NOT NULL,
8+ detail TEXT NOT NULL,
9+ latency_ms INTEGER,
10+ checked_at TEXT NOT NULL
11+);
12+
13+-- One row per part per UTC day: how many checks, and how they went.
14+-- Rows older than 90 days are deleted as checks run.
15+CREATE TABLE daily (
16+ component TEXT NOT NULL,
17+ day TEXT NOT NULL,
18+ checks INTEGER NOT NULL DEFAULT 0,
19+ up INTEGER NOT NULL DEFAULT 0,
20+ degraded INTEGER NOT NULL DEFAULT 0,
21+ down INTEGER NOT NULL DEFAULT 0,
22+ latency_total INTEGER NOT NULL DEFAULT 0,
23+ latency_count INTEGER NOT NULL DEFAULT 0,
24+ PRIMARY KEY (component, day)
25+);
26+CREATE INDEX daily_day ON daily (day);
27+
28+-- When the parts were last checked, as a whole.
29+CREATE TABLE meta (
30+ key TEXT PRIMARY KEY,
31+ value TEXT NOT NULL
32+);
33+
34+CREATE TABLE incidents (
35+ id TEXT PRIMARY KEY,
36+ title TEXT NOT NULL,
37+ impact TEXT NOT NULL CHECK (impact IN ('degraded', 'down')),
38+ status TEXT NOT NULL CHECK (status IN ('investigating', 'identified', 'monitoring', 'resolved')),
39+ -- A JSON array of component keys.
40+ components TEXT NOT NULL,
41+ started_at TEXT NOT NULL,
42+ resolved_at TEXT,
43+ created_by TEXT NOT NULL
44+);
45+CREATE INDEX incidents_started ON incidents (started_at);
46+
47+CREATE TABLE incident_updates (
48+ id TEXT PRIMARY KEY,
49+ incident_id TEXT NOT NULL REFERENCES incidents (id),
50+ status TEXT NOT NULL,
51+ message TEXT NOT NULL,
52+ at TEXT NOT NULL,
53+ by TEXT NOT NULL
54+);
55+CREATE INDEX incident_updates_incident ON incident_updates (incident_id, at);
+163−0
1+-- Incident management: severity, a lifecycle with timestamps, each part's
2+-- impact, roles, an internal timeline beside the public updates,
3+-- follow-ups, postmortems, scheduled maintenance, email subscribers,
4+-- detection streaks, and an audit log of every staff change.
5+--
6+-- Idempotent: every table and index is made only if missing, and the
7+-- incidents posted before this (the `incidents` and `incident_updates`
8+-- tables of 0001) are copied in with INSERT OR IGNORE, so running it
9+-- again changes nothing. The 0001 tables are left in place, unused, and
10+-- can be dropped by a later migration.
11+
12+CREATE TABLE IF NOT EXISTS incident (
13+ id TEXT PRIMARY KEY,
14+ title TEXT NOT NULL,
15+ severity TEXT NOT NULL CHECK (severity IN ('sev1', 'sev2', 'sev3', 'sev4')),
16+ status TEXT NOT NULL CHECK (status IN ('investigating', 'identified', 'monitoring', 'resolved')),
17+ -- draft: not on the status page yet (detected, or saved); dismissed: a
18+ -- draft closed as a false alarm, never shown.
19+ visibility TEXT NOT NULL CHECK (visibility IN ('draft', 'public', 'dismissed')),
20+ source TEXT NOT NULL CHECK (source IN ('declared', 'detected')),
21+ -- When the impact began; declared_at is when staff (or the checks) said so.
22+ started_at TEXT NOT NULL,
23+ declared_at TEXT NOT NULL,
24+ acknowledged_at TEXT,
25+ mitigated_at TEXT,
26+ resolved_at TEXT,
27+ published_at TEXT,
28+ commander TEXT,
29+ communications TEXT,
30+ created_by TEXT NOT NULL
31+);
32+CREATE INDEX IF NOT EXISTS incident_started ON incident (started_at);
33+CREATE INDEX IF NOT EXISTS incident_open ON incident (resolved_at, visibility);
34+
35+-- What an incident does to each part, while it is open.
36+CREATE TABLE IF NOT EXISTS incident_component (
37+ incident_id TEXT NOT NULL,
38+ component TEXT NOT NULL,
39+ impact TEXT NOT NULL CHECK (impact IN ('operational', 'degraded', 'partial_outage', 'major_outage')),
40+ PRIMARY KEY (incident_id, component)
41+);
42+
43+-- Everything that happened, in order: public updates (public = 1, what
44+-- the status page shows) and, for staff only, notes and every change.
45+CREATE TABLE IF NOT EXISTS incident_timeline (
46+ id TEXT PRIMARY KEY,
47+ incident_id TEXT NOT NULL,
48+ at TEXT NOT NULL,
49+ by TEXT NOT NULL,
50+ kind TEXT NOT NULL,
51+ public INTEGER NOT NULL DEFAULT 0,
52+ status TEXT,
53+ text TEXT NOT NULL,
54+ -- Subscribers emailed about it; null when none were.
55+ notified INTEGER
56+);
57+CREATE INDEX IF NOT EXISTS incident_timeline_incident ON incident_timeline (incident_id, at);
58+CREATE INDEX IF NOT EXISTS incident_timeline_public ON incident_timeline (public, at);
59+
60+CREATE TABLE IF NOT EXISTS incident_followup (
61+ id TEXT PRIMARY KEY,
62+ incident_id TEXT NOT NULL,
63+ title TEXT NOT NULL,
64+ owner TEXT,
65+ done_at TEXT,
66+ done_by TEXT,
67+ created_at TEXT NOT NULL,
68+ created_by TEXT NOT NULL
69+);
70+CREATE INDEX IF NOT EXISTS incident_followup_incident ON incident_followup (incident_id, created_at);
71+
72+CREATE TABLE IF NOT EXISTS postmortem (
73+ incident_id TEXT PRIMARY KEY,
74+ summary TEXT NOT NULL DEFAULT '',
75+ impact TEXT NOT NULL DEFAULT '',
76+ timeline TEXT NOT NULL DEFAULT '',
77+ root_cause TEXT NOT NULL DEFAULT '',
78+ went_well TEXT NOT NULL DEFAULT '',
79+ went_badly TEXT NOT NULL DEFAULT '',
80+ action_items TEXT NOT NULL DEFAULT '',
81+ updated_at TEXT NOT NULL,
82+ updated_by TEXT NOT NULL,
83+ published_at TEXT,
84+ published_by TEXT
85+);
86+
87+CREATE TABLE IF NOT EXISTS maintenance (
88+ id TEXT PRIMARY KEY,
89+ title TEXT NOT NULL,
90+ message TEXT NOT NULL,
91+ -- A JSON array of component keys.
92+ components TEXT NOT NULL,
93+ starts_at TEXT NOT NULL,
94+ ends_at TEXT NOT NULL,
95+ state TEXT NOT NULL CHECK (state IN ('scheduled', 'in_progress', 'completed', 'cancelled')),
96+ -- Whether subscribers hear about it when it starts and ends.
97+ notify INTEGER NOT NULL DEFAULT 0,
98+ created_at TEXT NOT NULL,
99+ created_by TEXT NOT NULL
100+);
101+CREATE INDEX IF NOT EXISTS maintenance_window ON maintenance (state, starts_at);
102+
103+CREATE TABLE IF NOT EXISTS maintenance_update (
104+ id TEXT PRIMARY KEY,
105+ maintenance_id TEXT NOT NULL,
106+ at TEXT NOT NULL,
107+ by TEXT NOT NULL,
108+ text TEXT NOT NULL,
109+ notified INTEGER
110+);
111+CREATE INDEX IF NOT EXISTS maintenance_update_maintenance ON maintenance_update (maintenance_id, at);
112+
113+-- Email subscribers. Only hashes of tokens are kept: confirm_hash is the
114+-- SHA-256 of the confirmation link's token; unsubscribe links are signed
115+-- with STATUS_SECRET and not stored. `components` is a JSON array of keys,
116+-- or null for everything; `pending_components` is what the unconfirmed
117+-- request asked for, applied on confirming.
118+CREATE TABLE IF NOT EXISTS subscriber (
119+ id TEXT PRIMARY KEY,
120+ email TEXT NOT NULL UNIQUE,
121+ components TEXT,
122+ pending_components TEXT,
123+ confirm_hash TEXT,
124+ confirm_expires_at TEXT,
125+ confirm_sent_at TEXT,
126+ confirmed_at TEXT,
127+ created_at TEXT NOT NULL
128+);
129+CREATE INDEX IF NOT EXISTS subscriber_confirm ON subscriber (confirm_hash);
130+
131+-- A part's current run of failed or slow checks, for detection.
132+CREATE TABLE IF NOT EXISTS streak (
133+ component TEXT PRIMARY KEY,
134+ state TEXT NOT NULL,
135+ count INTEGER NOT NULL,
136+ since TEXT NOT NULL,
137+ alerted INTEGER NOT NULL DEFAULT 0
138+);
139+
140+-- Every staff change, with who made it; sudo's audit log reads it.
141+CREATE TABLE IF NOT EXISTS audit (
142+ id TEXT PRIMARY KEY,
143+ at TEXT NOT NULL,
144+ by TEXT NOT NULL,
145+ action TEXT NOT NULL,
146+ target TEXT NOT NULL,
147+ detail TEXT NOT NULL
148+);
149+CREATE INDEX IF NOT EXISTS audit_at ON audit (at);
150+
151+-- The incidents posted before this migration.
152+INSERT OR IGNORE INTO incident
153+ (id, title, severity, status, visibility, source, started_at, declared_at, acknowledged_at, mitigated_at, resolved_at, published_at, created_by)
154+SELECT id, title, CASE impact WHEN 'down' THEN 'sev2' ELSE 'sev3' END, status, 'public', 'declared',
155+ started_at, started_at, started_at, resolved_at, resolved_at, started_at, created_by
156+FROM incidents;
157+
158+INSERT OR IGNORE INTO incident_component (incident_id, component, impact)
159+SELECT i.id, j.value, CASE i.impact WHEN 'down' THEN 'major_outage' ELSE 'partial_outage' END
160+FROM incidents i, json_each(i.components) j;
161+
162+INSERT OR IGNORE INTO incident_timeline (id, incident_id, at, by, kind, public, status, text)
163+SELECT id, incident_id, at, by, 'update', 1, status, message FROM incident_updates;
+17−0
1+{
2+ "name": "@g1t/status",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "license": "MIT",
7+ "scripts": {
8+ "test": "node --experimental-transform-types --no-warnings=ExperimentalWarning --test src/*.test.ts",
9+ "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json",
10+ "dev": "wrangler dev --test-scheduled",
11+ "deploy": "wrangler deploy"
12+ },
13+ "dependencies": {
14+ "@g1t/contracts": "*",
15+ "@g1t/theme": "*"
16+ }
17+}
+179−0
1+/**
2+ * The parts of g1t the page lists, and how each is checked. Every address
3+ * comes from the Worker's vars, so an installation of its own names its own
4+ * hosts; a part whose address is empty is not part of that installation
5+ * and is left off the page.
6+ *
7+ * Checks go over the public internet, as people reach g1t, so they measure
8+ * what people see. Billing has no public address of its own, so it is the
9+ * one part checked through a service binding, when there is one.
10+ */
11+
12+/** The Worker's vars that say where things are. */
13+export type Targets = {
14+ /** The site: `https://g1t.sh`. */
15+ SITE_URL: string;
16+ /** The API: `https://api.g1t.sh`. */
17+ API_URL: string;
18+ MCP_URL: string;
19+ DOCS_URL: string;
20+ /** Where deployed apps live: `https://g1t.page`. */
21+ PAGES_URL: string;
22+ MODELS_URL: string;
23+ /** A public repository that always exists, `owner/name`, to fetch refs of. */
24+ PROBE_REPO: string;
25+ /** The site as browsers reach it, when the checks reach it another way. */
26+ PUBLIC_SITE_URL?: string;
27+};
28+
29+/** One request a check makes, and the answer that means the part works. */
30+export type Step = {
31+ url: string;
32+ headers?: Record<string, string>;
33+ /** The status that means it works; any 2xx when absent. */
34+ expect?: number;
35+};
36+
37+export type Check =
38+ | { kind: "http"; steps: Step[] }
39+ /** Billing, through its binding: reading its price book. */
40+ | { kind: "billing" }
41+ /** No check yet: the page says so rather than showing green. */
42+ | { kind: "none" };
43+
44+export type ComponentInfo = {
45+ key: string;
46+ name: string;
47+ address: string;
48+ checks: string;
49+ /** Whether g1t as a whole is down when this is. */
50+ core: boolean;
51+ check: Check;
52+};
53+
54+/** A token no one holds: the API asks identity about it and refuses it. */
55+export const NO_TOKEN = `g1t_${"0".repeat(40)}`;
56+
57+function host(url: string): string {
58+ try {
59+ return new URL(url).host;
60+ } catch {
61+ return url;
62+ }
63+}
64+
65+const trim = (url: string | undefined) => (url ?? "").trim().replace(/\/+$/, "");
66+
67+/** The parts, in the order the page lists them. */
68+export function components(vars: Partial<Targets>, billing = true): ComponentInfo[] {
69+ const site = trim(vars.SITE_URL);
70+ const api = trim(vars.API_URL);
71+ const mcp = trim(vars.MCP_URL);
72+ const docs = trim(vars.DOCS_URL);
73+ const pages = trim(vars.PAGES_URL);
74+ const models = trim(vars.MODELS_URL);
75+ const repo = (vars.PROBE_REPO ?? "").trim();
76+ // What the page shows as the site's address: where people reach it.
77+ const shown = host(trim(vars.PUBLIC_SITE_URL) || site);
78+ const list: (ComponentInfo | null)[] = [
79+ site
80+ ? {
81+ key: "site",
82+ name: "Website and sign-in",
83+ address: shown,
84+ checks: api
85+ ? "The sign-in page loading, and the account service answering an access token through the API."
86+ : "The sign-in page loading.",
87+ core: true,
88+ check: {
89+ kind: "http",
90+ steps: [
91+ { url: `${site}/login` },
92+ ...(api ? [{ url: `${api}/v1/user`, headers: { authorization: `Bearer ${NO_TOKEN}` }, expect: 401 }] : []),
93+ ],
94+ },
95+ }
96+ : null,
97+ api
98+ ? {
99+ key: "api",
100+ name: "API",
101+ address: host(api),
102+ checks: "The API's front page.",
103+ core: true,
104+ check: { kind: "http", steps: [{ url: `${api}/` }] },
105+ }
106+ : null,
107+ site && repo
108+ ? {
109+ key: "git",
110+ name: "Git and repositories",
111+ address: `${shown}/<owner>/<repo>.git`,
112+ checks: "Listing the branches of a public repository over HTTPS, the first step of every clone.",
113+ core: true,
114+ check: { kind: "http", steps: [{ url: `${site}/${repo}.git/info/refs?service=git-upload-pack` }] },
115+ }
116+ : null,
117+ mcp
118+ ? {
119+ key: "mcp",
120+ name: "MCP server",
121+ address: host(mcp),
122+ checks: "The MCP server's description.",
123+ core: false,
124+ check: { kind: "http", steps: [{ url: `${mcp}/`, headers: { accept: "application/json" } }] },
125+ }
126+ : null,
127+ docs
128+ ? {
129+ key: "docs",
130+ name: "Documentation",
131+ address: host(docs),
132+ checks: "The documentation's front page.",
133+ core: false,
134+ check: { kind: "http", steps: [{ url: `${docs}/` }] },
135+ }
136+ : null,
137+ pages
138+ ? {
139+ key: "deployments",
140+ name: "Deployments",
141+ address: `*.${host(pages)}`,
142+ checks: `${host(pages)} answering. Each deployed app is not checked one by one.`,
143+ core: false,
144+ check: { kind: "http", steps: [{ url: `${pages}/` }] },
145+ }
146+ : null,
147+ models
148+ ? {
149+ key: "agents",
150+ name: "Agents' model proxy",
151+ address: host(models),
152+ checks: "The proxy agents reach their model through. The model providers behind it are not checked.",
153+ core: false,
154+ check: { kind: "http", steps: [{ url: `${models}/` }] },
155+ }
156+ : null,
157+ models
158+ ? {
159+ key: "sandboxes",
160+ name: "Sandboxes",
161+ address: "Agents, checks, workflows and builds",
162+ checks: "Starting a sandbox costs money and takes seconds, so it is not checked from here yet.",
163+ core: false,
164+ check: { kind: "none" },
165+ }
166+ : null,
167+ billing && site
168+ ? {
169+ key: "billing",
170+ name: "Billing",
171+ address: `${shown}/<workspace>/-/billing`,
172+ checks: "The billing service reading its price book. Stripe itself is not checked.",
173+ core: false,
174+ check: { kind: "billing" },
175+ }
176+ : null,
177+ ];
178+ return list.filter((c): c is ComponentInfo => c != null);
179+}
+71−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { DETECT_AFTER, type Streak, detect, detectedImpact, draftTitle } from "./detect.ts";
5+
6+const at = (minute: number) => new Date(Date.UTC(2026, 9, 5, 12, minute));
7+
8+/** Runs rounds of checks through detection, carrying the streaks along. */
9+function run(rounds: Record<string, "up" | "degraded" | "down">[], open: { id: string; components: string[] }[] = [], maintenance = new Set<string>()) {
10+ let streaks = new Map<string, Streak>();
11+ const results = rounds.map((round, minute) => {
12+ const found = detect(streaks, Object.entries(round).map(([component, state]) => ({ component, state })), open, maintenance, at(minute));
13+ streaks = new Map(found.streaks.map((s) => [s.component, s]));
14+ return found;
15+ });
16+ return results;
17+}
18+
19+test(`a draft after ${DETECT_AFTER} failed checks in a row, once`, () => {
20+ assert.equal(DETECT_AFTER, 3);
21+ const rounds = run([{ api: "down" }, { api: "down" }, { api: "down" }, { api: "down" }]);
22+ assert.deepEqual(rounds.map((r) => r.draft.length), [0, 0, 1, 0]);
23+ assert.deepEqual(rounds[2]!.draft[0], { key: "api", state: "down", since: at(0).toISOString() });
24+});
25+
26+test("a blip is not an incident: a good check resets the run", () => {
27+ const rounds = run([{ api: "down" }, { api: "down" }, { api: "up" }, { api: "down" }, { api: "down" }]);
28+ assert.ok(rounds.every((r) => r.draft.length === 0));
29+ assert.equal(rounds[4]!.streaks[0]!.count, 2);
30+});
31+
32+test("slow then failing is one run, at its worst", () => {
33+ const rounds = run([{ docs: "degraded" }, { docs: "down" }, { docs: "degraded" }]);
34+ assert.deepEqual(rounds[2]!.draft, [{ key: "docs", state: "down", since: at(0).toISOString() }]);
35+});
36+
37+test("parts failing together share one draft", () => {
38+ const rounds = run([{ api: "down", git: "down" }, { api: "down", git: "down" }, { api: "down", git: "down" }]);
39+ assert.deepEqual(rounds[2]!.draft.map((d) => d.key), ["api", "git"]);
40+});
41+
42+test("with an incident already open on the part: a line on it, no draft; recovery is noted", () => {
43+ const open = [{ id: "inc1", components: ["api"] }];
44+ const rounds = run([{ api: "down" }, { api: "down" }, { api: "down" }, { api: "up" }], open);
45+ assert.equal(rounds[2]!.draft.length, 0);
46+ assert.deepEqual(rounds[2]!.failing, [{ incident: "inc1", key: "api", state: "down", since: at(0).toISOString() }]);
47+ assert.deepEqual(rounds[3]!.recovered, [{ incident: "inc1", key: "api", since: at(0).toISOString(), checks: 3 }]);
48+ assert.equal(rounds[3]!.streaks.length, 0);
49+});
50+
51+test("a short blip that never crossed the line is not noted as a recovery", () => {
52+ const rounds = run([{ api: "down" }, { api: "up" }], [{ id: "inc1", components: ["api"] }]);
53+ assert.equal(rounds[1]!.recovered.length, 0);
54+});
55+
56+test("parts under maintenance and unmonitored parts are left alone", () => {
57+ const rounds = run([{ api: "down" }, { api: "down" }, { api: "down" }], [], new Set(["api"]));
58+ assert.ok(rounds.every((r) => r.draft.length === 0 && r.streaks.length === 0));
59+ const none = detect(new Map(), [{ component: "sandboxes", state: "unmonitored" }], [], new Set(), at(0));
60+ assert.equal(none.streaks.length, 0);
61+});
62+
63+test("the draft says what failed", () => {
64+ assert.equal(detectedImpact("down"), "major_outage");
65+ assert.equal(detectedImpact("degraded"), "degraded");
66+ assert.equal(draftTitle([{ name: "API", state: "down" }, { name: "Git", state: "down" }]), "Detected: API and Git not answering");
67+ assert.equal(
68+ draftTitle([{ name: "API", state: "down" }, { name: "Git", state: "down" }, { name: "MCP", state: "down" }, { name: "Docs", state: "degraded" }]),
69+ "Detected: API, Git and MCP not answering; Docs slow",
70+ );
71+});
+81−0
1+/**
2+ * Noticing trouble before anyone reports it: when a part fails (or is
3+ * slow) for DETECT_AFTER checks in a row, a draft incident is made for
4+ * staff in sudo, not shown on the status page until someone publishes it.
5+ * When a part that had crossed that line answers again, open incidents on
6+ * it get a note. No Workers imports, so it is tested under Node.
7+ */
8+import type { ComponentImpact, StatusComponentState } from "@g1t/contracts";
9+
10+/** Checks in a row, a minute apart, before a draft is made. */
11+export const DETECT_AFTER = 3;
12+
13+export type Streak = {
14+ component: string;
15+ /** The worst seen in this run of failures. */
16+ state: "degraded" | "down";
17+ count: number;
18+ /** When the run began. */
19+ since: string;
20+ /** Whether it has crossed the line, and been raised. */
21+ alerted: boolean;
22+};
23+
24+/** An open incident (draft or public), with the parts it affects. */
25+export type OpenRef = { id: string; components: string[] };
26+
27+export type Detection = {
28+ /** Every failing part's run after this round; parts not here have none. */
29+ streaks: Streak[];
30+ /** Parts that crossed the line with no open incident on them: one draft for all. */
31+ draft: { key: string; state: "degraded" | "down"; since: string }[];
32+ /** Parts that crossed the line while an incident on them was open. */
33+ failing: { incident: string; key: string; state: "degraded" | "down"; since: string }[];
34+ /** Parts answering again after crossing the line. */
35+ recovered: { incident: string; key: string; since: string; checks: number }[];
36+};
37+
38+export function detect(
39+ previous: Map<string, Streak>,
40+ observations: { component: string; state: StatusComponentState }[],
41+ open: OpenRef[],
42+ maintenance: Set<string>,
43+ at: Date,
44+ threshold = DETECT_AFTER,
45+): Detection {
46+ const out: Detection = { streaks: [], draft: [], failing: [], recovered: [] };
47+ const covering = (key: string) => open.filter((i) => i.components.includes(key));
48+ for (const { component: key, state } of observations) {
49+ const prev = previous.get(key);
50+ if (state === "unmonitored" || maintenance.has(key)) continue;
51+ if (state !== "degraded" && state !== "down") {
52+ if (prev?.alerted) for (const i of covering(key)) out.recovered.push({ incident: i.id, key, since: prev.since, checks: prev.count });
53+ continue;
54+ }
55+ const streak: Streak = prev
56+ ? { ...prev, count: prev.count + 1, state: prev.state === "down" || state === "down" ? "down" : "degraded" }
57+ : { component: key, state, count: 1, since: at.toISOString(), alerted: false };
58+ if (!streak.alerted && streak.count >= threshold) {
59+ streak.alerted = true;
60+ const incidents = covering(key);
61+ if (incidents.length) for (const i of incidents) out.failing.push({ incident: i.id, key, state: streak.state, since: streak.since });
62+ else out.draft.push({ key, state: streak.state, since: streak.since });
63+ }
64+ out.streaks.push(streak);
65+ }
66+ return out;
67+}
68+
69+/** What a detected failure does to its part, for the draft. */
70+export function detectedImpact(state: "degraded" | "down"): ComponentImpact {
71+ return state === "down" ? "major_outage" : "degraded";
72+}
73+
74+/** The draft's title: "Detected: API and Git not answering". */
75+export function draftTitle(parts: { name: string; state: "degraded" | "down" }[]): string {
76+ const list = (names: string[]) => (names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
77+ const down = parts.filter((p) => p.state === "down").map((p) => p.name);
78+ const slow = parts.filter((p) => p.state === "degraded").map((p) => p.name);
79+ const said = [down.length ? `${list(down)} not answering` : "", slow.length ? `${list(slow)} slow` : ""].filter(Boolean).join("; ");
80+ return `Detected: ${said}`.slice(0, 120);
81+}
+110−0
1+/**
2+ * Email from the status page: subscription confirmations, incident and
3+ * maintenance updates to subscribers, and the staff alert when a draft is
4+ * detected. Sending goes through a `Sender`, so the page works the same
5+ * with no email at all (an installation without it, or tests): the feeds
6+ * still carry every update. On g1t.sh the sender is Cloudflare Email
7+ * Sending, the `send_email` binding identity uses too. No Workers imports.
8+ */
9+
10+export type Mail = { to: string; subject: string; text: string; html: string; headers?: Record<string, string> };
11+
12+export interface Sender {
13+ send(mail: Mail): Promise<void>;
14+}
15+
16+/** The `send_email` binding, as Cloudflare Email Sending gives it. */
17+export type EmailBinding = {
18+ send(message: { to: string; from: string; subject: string; text: string; html: string; headers?: Record<string, string> }): Promise<unknown>;
19+};
20+
21+export const DEFAULT_FROM = "g1t status <noreply@g1t.sh>";
22+
23+/** A sender over the binding, or null when there is none. */
24+export function bindingSender(binding: EmailBinding | undefined, from = DEFAULT_FROM): Sender | null {
25+ if (!binding || typeof binding.send !== "function") return null;
26+ return { send: async ({ to, subject, text, html, headers }) => void (await binding.send({ to, from, subject, text, html, ...(headers ? { headers } : {}) })) };
27+}
28+
29+/** Collects mail instead of sending it: for tests and previews. */
30+export function memorySender(): Sender & { sent: Mail[] } {
31+ const sent: Mail[] = [];
32+ return { sent, send: async (mail) => void sent.push(mail) };
33+}
34+
35+function escape(text: string): string {
36+ return text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
37+}
38+
39+export type Letter = {
40+ /** A line over the body, such as "Identified · Pushes failing". */
41+ heading: string;
42+ paragraphs: string[];
43+ action?: { label: string; url: string };
44+ footer: string[];
45+ /** Where the reader leaves, as a link in the footer. */
46+ unsubscribe?: string;
47+};
48+
49+/** The plain text and HTML of a letter; everything is escaped. */
50+export function render(letter: Letter): { text: string; html: string } {
51+ const text: string[] = [letter.heading, ""];
52+ let html = `<div style="font-family:system-ui,sans-serif;max-width:520px;margin:0 auto;padding:32px 16px;color:#16150f">`;
53+ html += `<p style="margin:0 0 6px;font-size:13px;color:#6e6a5e">g1t status</p>`;
54+ html += `<h1 style="margin:0 0 16px;font-size:19px;line-height:1.35">${escape(letter.heading)}</h1>`;
55+ for (const p of letter.paragraphs) {
56+ text.push(p, "");
57+ html += `<p style="font-size:15px;line-height:1.6;white-space:pre-line">${escape(p)}</p>`;
58+ }
59+ if (letter.action) {
60+ text.push(`${letter.action.label}: ${letter.action.url}`, "");
61+ html += `<p style="margin:24px 0"><a href="${escape(letter.action.url)}" style="background:#16150f;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-size:15px">${escape(letter.action.label)}</a></p>`;
62+ }
63+ for (const line of letter.footer) {
64+ text.push(line);
65+ html += `<p style="font-size:13px;line-height:1.6;color:#6e6a5e;margin:4px 0">${escape(line)}</p>`;
66+ }
67+ if (letter.unsubscribe) {
68+ text.push(`Unsubscribe: ${letter.unsubscribe}`);
69+ html += `<p style="font-size:13px;line-height:1.6;color:#6e6a5e;margin:4px 0"><a href="${escape(letter.unsubscribe)}" style="color:#6e6a5e">Unsubscribe</a></p>`;
70+ }
71+ html += "</div>";
72+ return { text: `${text.join("\n").trim()}\n`, html };
73+}
74+
75+/** The headers that let mail apps offer one-click unsubscribing (RFC 8058). */
76+export function unsubscribeHeaders(url: string): Record<string, string> {
77+ return { "List-Unsubscribe": `<${url}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" };
78+}
79+
80+export function confirmLetter(link: string, parts: string[] | null): Letter {
81+ return {
82+ heading: "Confirm your subscription to g1t status",
83+ paragraphs: [
84+ `Someone, hopefully you, asked to get an email when g1t posts an incident or planned maintenance${parts ? ` affecting ${parts.join(", ")}` : ""}. Confirm to start.`,
85+ ],
86+ action: { label: "Confirm subscription", url: link },
87+ footer: ["The link works for 24 hours. If you did not ask for this, ignore this email and nothing happens."],
88+ };
89+}
90+
91+export function updateLetter(input: { heading: string; text: string; url: string; affects: string[]; unsubscribe: string }): Letter {
92+ return {
93+ heading: input.heading,
94+ paragraphs: [input.text, ...(input.affects.length ? [`Affects: ${input.affects.join(", ")}.`] : [])],
95+ action: { label: "See it on the status page", url: input.url },
96+ footer: ["You get these because you subscribed at status.g1t.sh."],
97+ unsubscribe: input.unsubscribe,
98+ };
99+}
100+
101+export function alertLetter(input: { title: string; parts: string[]; since: string; link: string }): Letter {
102+ return {
103+ heading: input.title,
104+ paragraphs: [
105+ `The checks have failed for ${input.parts.join(", ")} since ${input.since}, three times in a row. A draft incident is waiting in sudo. It is not on the status page until someone publishes it.`,
106+ ],
107+ action: { label: "Open it in sudo", url: input.link },
108+ footer: ["Sent by status.g1t.sh to the staff alert address (STATUS_ALERT_EMAIL)."],
109+ };
110+}
+86−0
1+/**
2+ * The status page's feeds: every public incident update and maintenance
3+ * notice, newest first, as Atom (`/feed.xml`) and JSON Feed
4+ * (`/feed.json`). No Workers imports, so it is tested under Node.
5+ */
6+import type { StatusIncident, StatusMaintenance } from "@g1t/contracts";
7+
8+import { INCIDENT_STATUS } from "./model.ts";
9+
10+export type FeedItem = {
11+ /** Unique and stable: the page's address with the update's anchor. */
12+ id: string;
13+ url: string;
14+ title: string;
15+ text: string;
16+ at: string;
17+};
18+
19+/** How many items a feed carries. */
20+export const FEED_SIZE = 50;
21+
22+/** Feed items from incidents and maintenance, newest first. */
23+export function feedItems(incidents: StatusIncident[], maintenance: StatusMaintenance[], size = FEED_SIZE): FeedItem[] {
24+ const items: FeedItem[] = [];
25+ for (const incident of incidents) {
26+ for (const update of incident.updates) {
27+ items.push({
28+ id: `${incident.url}#update-${update.id}`,
29+ url: `${incident.url}#update-${update.id}`,
30+ title: `${INCIDENT_STATUS[update.status]}: ${incident.title}`,
31+ text: update.text,
32+ at: update.at,
33+ });
34+ }
35+ }
36+ for (const m of maintenance) {
37+ for (const update of m.updates) {
38+ items.push({ id: `${m.url}#update-${update.id}`, url: `${m.url}#update-${update.id}`, title: `Maintenance: ${m.title}`, text: update.text, at: update.at });
39+ }
40+ }
41+ return items.sort((a, b) => b.at.localeCompare(a.at)).slice(0, size);
42+}
43+
44+function xml(text: string): string {
45+ return text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]!);
46+}
47+
48+export type FeedOptions = { origin: string; title: string; updated: string };
49+
50+export function atom(items: FeedItem[], { origin, title, updated }: FeedOptions): string {
51+ const entries = items
52+ .map(
53+ (item) => `<entry>
54+<id>${xml(item.id)}</id>
55+<title>${xml(item.title)}</title>
56+<link rel="alternate" type="text/html" href="${xml(item.url)}"/>
57+<updated>${xml(item.at)}</updated>
58+<published>${xml(item.at)}</published>
59+<content type="text">${xml(item.text)}</content>
60+</entry>`,
61+ )
62+ .join("\n");
63+ return `<?xml version="1.0" encoding="utf-8"?>
64+<feed xmlns="http://www.w3.org/2005/Atom">
65+<id>${xml(`${origin}/`)}</id>
66+<title>${xml(title)}</title>
67+<link rel="self" type="application/atom+xml" href="${xml(`${origin}/feed.xml`)}"/>
68+<link rel="alternate" type="text/html" href="${xml(`${origin}/`)}"/>
69+<updated>${xml(items[0]?.at ?? updated)}</updated>
70+<author><name>g1t</name></author>
71+${entries}
72+</feed>
73+`;
74+}
75+
76+/** JSON Feed 1.1. */
77+export function jsonFeed(items: FeedItem[], { origin, title }: FeedOptions): object {
78+ return {
79+ version: "https://jsonfeed.org/version/1.1",
80+ title,
81+ home_page_url: `${origin}/`,
82+ feed_url: `${origin}/feed.json`,
83+ language: "en",
84+ items: items.map((item) => ({ id: item.id, url: item.url, title: item.title, content_text: item.text, date_published: item.at })),
85+ };
86+}
+206−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ type IncidentFacts,
6+ applyChange,
7+ applyRoles,
8+ checkChange,
9+ checkDeclare,
10+ checkMaintenance,
11+ checkMaintenanceChange,
12+ checkPostmortem,
13+ duration,
14+ durations,
15+ notifyByDefault,
16+ postmortemReady,
17+ shortId,
18+} from "./incidents.ts";
19+import { postmortemDraft, timelineText } from "./postmortem.ts";
20+
21+const NOW = new Date("2026-10-05T12:00:00Z");
22+const KNOWN = ["git", "api", "docs"];
23+const base = {
24+ title: " Pushes failing ",
25+ severity: "sev2",
26+ message: "We are looking into it.\r\n\r\n\r\nMore soon.",
27+ components: [
28+ { key: "git", impact: "major_outage" },
29+ { key: "api", impact: "operational" },
30+ ],
31+ commander: " Ana@G1T.sh ",
32+ notify: true,
33+ by: "staff@g1t.sh",
34+};
35+
36+test("declaring: cleaned and checked; operational parts are left out", () => {
37+ const checked = checkDeclare(base, KNOWN, NOW);
38+ assert.ok(checked.ok);
39+ assert.equal(checked.value.title, "Pushes failing");
40+ assert.equal(checked.value.status, "investigating");
41+ assert.equal(checked.value.message, "We are looking into it.\n\nMore soon.");
42+ assert.deepEqual(checked.value.components, [{ key: "git", impact: "major_outage" }]);
43+ assert.equal(checked.value.commander, "ana@g1t.sh");
44+ assert.equal(checked.value.started_at, null);
45+ assert.equal(checked.value.notify, true);
46+});
47+
48+test("declaring is refused without what it needs", () => {
49+ assert.equal(checkDeclare({ ...base, title: "" }, KNOWN, NOW).ok, false);
50+ assert.equal(checkDeclare({ ...base, severity: "sev9" }, KNOWN, NOW).ok, false);
51+ assert.equal(checkDeclare({ ...base, status: "resolved" }, KNOWN, NOW).ok, false);
52+ assert.equal(checkDeclare({ ...base, components: [{ key: "git", impact: "operational" }] }, KNOWN, NOW).ok, false);
53+ assert.equal(checkDeclare({ ...base, components: [{ key: "nope", impact: "degraded" }] }, KNOWN, NOW).ok, false);
54+ assert.equal(checkDeclare({ ...base, components: [{ key: "git", impact: "meh" }] }, KNOWN, NOW).ok, false);
55+ assert.equal(checkDeclare({ ...base, commander: "not an email" }, KNOWN, NOW).ok, false);
56+ assert.equal(checkDeclare({ ...base, message: " " }, KNOWN, NOW).ok, false);
57+ assert.equal(checkDeclare({ ...base, started_at: "2026-10-06T00:00:00Z" }, KNOWN, NOW).ok, false);
58+ const backdated = checkDeclare({ ...base, started_at: "2026-10-05T11:00:00Z" }, KNOWN, NOW);
59+ assert.ok(backdated.ok && backdated.value.started_at === "2026-10-05T11:00:00.000Z");
60+});
61+
62+test("severities: SEV1 and SEV2 email subscribers by default", () => {
63+ assert.deepEqual(
64+ (["sev1", "sev2", "sev3", "sev4"] as const).map(notifyByDefault),
65+ [true, true, false, false],
66+ );
67+});
68+
69+test("an update needs words when it is public; a note can be just a change", () => {
70+ assert.equal(checkChange({ public: true, message: " ", by: "x" }, KNOWN).ok, false);
71+ const note = checkChange({ public: false, message: "", severity: "sev1", by: "x" }, KNOWN);
72+ assert.ok(note.ok && note.value.severity === "sev1" && note.value.notify === false);
73+ assert.equal(checkChange({ public: true, message: "ok", status: "panicking", by: "x" }, KNOWN).ok, false);
74+ assert.equal(checkChange({ public: true, message: "ok", impacts: [{ key: "zzz", impact: "degraded" }], by: "x" }, KNOWN).ok, false);
75+ const notify = checkChange({ public: false, message: "internal", notify: true, by: "x" }, KNOWN);
76+ assert.ok(notify.ok && notify.value.notify === false, "a note is never emailed");
77+});
78+
79+const open: IncidentFacts = {
80+ status: "investigating",
81+ severity: "sev2",
82+ visibility: "public",
83+ components: [{ key: "git", impact: "major_outage" }],
84+ started_at: "2026-10-05T11:30:00.000Z",
85+ acknowledged_at: "2026-10-05T11:35:00.000Z",
86+ mitigated_at: null,
87+ resolved_at: null,
88+ commander: null,
89+ communications: null,
90+};
91+const names = new Map([["git", "Git and repositories"]]);
92+
93+function change(input: Record<string, unknown>) {
94+ const checked = checkChange({ by: "staff@g1t.sh", ...input }, KNOWN);
95+ assert.ok(checked.ok, checked.ok ? "" : checked.error);
96+ return checked.value;
97+}
98+
99+test("lifecycle: identified, then monitoring marks it mitigated, then resolved", () => {
100+ const a = applyChange(open, change({ public: true, status: "identified", message: "A bad deploy." }), NOW, names);
101+ assert.ok(a.ok);
102+ assert.equal(a.value.next.status, "identified");
103+ assert.equal(a.value.next.mitigated_at, null);
104+ assert.deepEqual(a.value.entries.map((e) => [e.kind, e.public]), [["status", false], ["update", true]]);
105+ assert.equal(a.value.entries[1]!.status, "identified");
106+ assert.equal(a.value.entries[0]!.text, "Investigating → Identified.");
107+
108+ const b = applyChange(a.value.next, change({ public: true, status: "monitoring", message: "Rolled back." }), new Date("2026-10-05T12:10:00Z"));
109+ assert.ok(b.ok && b.value.next.mitigated_at === "2026-10-05T12:10:00.000Z");
110+
111+ const c = applyChange(b.value.next, change({ public: true, status: "resolved", message: "Fixed." }), new Date("2026-10-05T12:40:00Z"));
112+ assert.ok(c.ok);
113+ assert.equal(c.value.next.resolved_at, "2026-10-05T12:40:00.000Z");
114+ assert.equal(c.value.next.mitigated_at, "2026-10-05T12:10:00.000Z", "mitigated keeps its first time");
115+ assert.deepEqual(durations(c.value.next), { to_acknowledge: 300, to_mitigate: 2400, to_resolve: 4200 });
116+});
117+
118+test("lifecycle: resolving straight away marks it mitigated too; reopening clears resolved", () => {
119+ const done = applyChange(open, change({ public: true, status: "resolved", message: "It was a blip." }), NOW);
120+ assert.ok(done.ok && done.value.next.mitigated_at === NOW.toISOString() && done.value.next.resolved_at === NOW.toISOString());
121+ const again = applyChange(done.value.next, change({ public: true, status: "investigating", message: "It is back." }), NOW);
122+ assert.ok(again.ok);
123+ assert.equal(again.value.next.resolved_at, null);
124+ assert.match(again.value.entries[0]!.text, /^Reopened/);
125+});
126+
127+test("lifecycle: a published incident's status changes only with public words", () => {
128+ const quiet = applyChange(open, change({ public: false, status: "identified", message: "Found it." }), NOW);
129+ assert.equal(quiet.ok, false);
130+ const note = applyChange(open, change({ public: false, message: "Paging the storage team." }), NOW);
131+ assert.ok(note.ok && note.value.entries.length === 1 && note.value.entries[0]!.kind === "note" && !note.value.entries[0]!.public);
132+ assert.equal(applyChange(open, change({ public: false, message: "" }), NOW).ok, false, "nothing to post");
133+});
134+
135+test("lifecycle: severity and impact changes are written on the timeline", () => {
136+ const r = applyChange(open, change({ public: false, message: "", severity: "sev1", impacts: [{ key: "git", impact: "partial_outage" }, { key: "api", impact: "degraded" }] }), NOW, names);
137+ assert.ok(r.ok);
138+ assert.equal(r.value.next.severity, "sev1");
139+ assert.deepEqual(r.value.next.components, [{ key: "git", impact: "partial_outage" }, { key: "api", impact: "degraded" }]);
140+ assert.deepEqual(r.value.entries.map((e) => e.text), ["Severity SEV2 → SEV1.", "Git and repositories: Major outage → Partial outage.", "api: Operational → Degraded performance."]);
141+});
142+
143+test("lifecycle: drafts take notes but nothing public; dismissed takes nothing; the first touch acknowledges", () => {
144+ const draft: IncidentFacts = { ...open, visibility: "draft", acknowledged_at: null };
145+ assert.equal(applyChange(draft, change({ public: true, message: "Hi" }), NOW).ok, false);
146+ const note = applyChange(draft, change({ public: false, message: "Looking.", status: "identified" }), NOW);
147+ assert.ok(note.ok);
148+ assert.equal(note.value.next.acknowledged_at, NOW.toISOString());
149+ assert.equal(note.value.entries[0]!.kind, "acknowledged");
150+ assert.equal(note.value.next.status, "identified", "a draft's status can move without words");
151+ assert.equal(applyChange({ ...open, visibility: "dismissed" }, change({ public: false, message: "x" }), NOW).ok, false);
152+});
153+
154+test("roles: each change is a line, and nobody is said plainly", () => {
155+ const r = applyRoles({ ...open, commander: "ana@g1t.sh" }, { commander: null, communications: "bo@g1t.sh", by: "x" }, NOW);
156+ assert.deepEqual(r.entries.map((e) => e.text), ["Incident commander: nobody.", "Communications: bo@g1t.sh."]);
157+ assert.equal(applyRoles(open, { commander: null, communications: null, by: "x" }, NOW).entries.length, 0);
158+});
159+
160+test("durations read plainly", () => {
161+ assert.equal(duration(null), "—");
162+ assert.equal(duration(42), "42s");
163+ assert.equal(duration(720), "12m");
164+ assert.equal(duration(7500), "2h 05m");
165+ assert.equal(duration(3 * 86400 + 4 * 3600), "3d 4h");
166+});
167+
168+test("maintenance: a window in the future, under 72 hours, on known parts", () => {
169+ const m = { title: "Database upgrade", message: "Pushes pause for a few minutes.", components: ["git"], starts_at: "2026-10-06T02:00Z", ends_at: "2026-10-06T03:00Z", by: "x" };
170+ const checked = checkMaintenance(m, KNOWN, NOW);
171+ assert.ok(checked.ok && checked.value.ends_at === "2026-10-06T03:00:00.000Z");
172+ assert.equal(checkMaintenance({ ...m, ends_at: "2026-10-06T01:00Z" }, KNOWN, NOW).ok, false);
173+ assert.equal(checkMaintenance({ ...m, starts_at: "2026-10-04T01:00Z", ends_at: "2026-10-04T02:00Z" }, KNOWN, NOW).ok, false);
174+ assert.equal(checkMaintenance({ ...m, ends_at: "2026-10-10T03:00Z" }, KNOWN, NOW).ok, false);
175+ assert.equal(checkMaintenance({ ...m, components: [] }, KNOWN, NOW).ok, false);
176+ assert.equal(checkMaintenanceChange({ action: "update", message: "", by: "x" }).ok, false);
177+ assert.ok(checkMaintenanceChange({ action: "cancel", message: "", by: "x" }).ok);
178+});
179+
180+test("postmortems: published only with a summary and a cause; the draft is filled from the incident", () => {
181+ const pm = checkPostmortem({ summary: "A deploy broke pushes.", impact: "", timeline: "", root_cause: "", went_well: "", went_badly: "", action_items: "", by: "x" });
182+ assert.ok(pm.ok);
183+ assert.match(postmortemReady(pm.value) ?? "", /caused/);
184+ assert.equal(postmortemReady({ ...pm.value, root_cause: "A missing index." }), null);
185+
186+ const timeline = [
187+ { id: "1", at: "2026-10-05T11:35:00Z", by: "x", kind: "declared" as const, public: false, status: null, text: "Declared SEV2.", notified: null },
188+ { id: "2", at: "2026-10-05T11:36:00Z", by: "x", kind: "acknowledged" as const, public: false, status: null, text: "Acknowledged.", notified: null },
189+ { id: "3", at: "2026-10-05T11:40:00Z", by: "x", kind: "update" as const, public: true, status: "investigating" as const, text: "Pushes fail.\nLooking.", notified: 3 },
190+ ];
191+ assert.equal(timelineText(timeline), "- 5 Oct 11:35 UTC: Declared SEV2.\n- 5 Oct 11:40 UTC: Status page: Pushes fail. Looking.");
192+ const draft = postmortemDraft(
193+ { started_at: "2026-10-05T11:30:00Z", resolved_at: "2026-10-05T12:40:00Z", components: [{ key: "git", impact: "major_outage" }], durations: { to_acknowledge: 300, to_mitigate: null, to_resolve: 4200 } },
194+ timeline,
195+ [{ id: "f", title: "Alert on push errors", owner: "ana@g1t.sh", done_at: null, created_at: "", created_by: "" }],
196+ names,
197+ );
198+ assert.equal(draft.impact, "Git and repositories (major outage) affected for 1h 10m, from 5 Oct 11:30 UTC to 5 Oct 12:40 UTC.");
199+ assert.equal(draft.action_items, "- Alert on push errors");
200+});
201+
202+test("short ids are ten unambiguous characters", () => {
203+ const id = shortId();
204+ assert.match(id, /^[a-km-np-z2-9]{10}$/);
205+ assert.notEqual(shortId(), id);
206+});
+410−0
1+/**
2+ * Incidents and maintenance as staff run them: checking what sudo sends,
3+ * and the lifecycle rules (which changes are allowed, which timestamps
4+ * they set, and what each writes on the timeline). No Workers imports,
5+ * so it is tested under Node.
6+ */
7+import {
8+ INCIDENT_SEVERITIES,
9+ type ComponentImpact,
10+ type DeclareIncident,
11+ type IncidentChange,
12+ type IncidentDurations,
13+ type IncidentSeverity,
14+ type IncidentStatus,
15+ type IncidentVisibility,
16+ type ImpactInput,
17+ type MaintenanceChange,
18+ type NewMaintenance,
19+ type PostmortemFields,
20+ type PublishIncident,
21+ type RolesChange,
22+ type TimelineKind,
23+} from "@g1t/contracts/status";
24+
25+import { IMPACT_WORD, INCIDENT_STATUS } from "./model.ts";
26+
27+export const STATUSES: IncidentStatus[] = ["investigating", "identified", "monitoring", "resolved"];
28+export const IMPACTS: ComponentImpact[] = ["operational", "degraded", "partial_outage", "major_outage"];
29+
30+export const SEVERITIES = INCIDENT_SEVERITIES;
31+
32+export const SEVERITY_LABEL = Object.fromEntries(SEVERITIES.map((s) => [s.value, s.label])) as Record<IncidentSeverity, string>;
33+
34+/** Whether subscribers are emailed by default at a severity. */
35+export function notifyByDefault(severity: IncidentSeverity): boolean {
36+ return SEVERITIES.find((s) => s.value === severity)?.notify ?? false;
37+}
38+
39+export const MAX_TITLE = 120;
40+export const MAX_MESSAGE = 4000;
41+export const MAX_SECTION = 20000;
42+/** How long maintenance may be scheduled ahead, and how long a window may be. */
43+export const MAX_AHEAD_DAYS = 180;
44+export const MAX_WINDOW_HOURS = 72;
45+
46+export type Checked<T> = { ok: true; value: T } | { ok: false; error: string };
47+
48+const fail = (error: string): { ok: false; error: string } => ({ ok: false, error });
49+
50+function clean(text: unknown): string {
51+ return typeof text === "string" ? text.replace(/\s+/g, " ").trim() : "";
52+}
53+
54+/** A message keeps its paragraphs; runs of spaces within a line are folded. */
55+export function cleanMessage(text: unknown): string {
56+ if (typeof text !== "string") return "";
57+ return text
58+ .replace(/\r\n?/g, "\n")
59+ .split("\n")
60+ .map((line) => line.replace(/[ \t]+/g, " ").trimEnd())
61+ .join("\n")
62+ .replace(/\n{3,}/g, "\n\n")
63+ .trim();
64+}
65+
66+const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,}$/i;
67+
68+/** A role's holder: a staff email, lowercased, or null for nobody. */
69+function role(raw: unknown, name: string): Checked<string | null> {
70+ const value = clean(raw).toLowerCase();
71+ if (!value) return { ok: true, value: null };
72+ if (!EMAIL.test(value)) return fail(`The ${name} is an email address.`);
73+ return { ok: true, value };
74+}
75+
76+function startTime(raw: unknown, now: Date): Checked<string | null> {
77+ if (raw == null || raw === "") return { ok: true, value: null };
78+ const at = Date.parse(String(raw));
79+ if (Number.isNaN(at)) return fail("The start time is not a date.");
80+ if (at > now.getTime() + 60_000) return fail("The start time is in the future.");
81+ if (at < now.getTime() - 90 * 24 * 60 * 60 * 1000) return fail("The start time is over 90 days ago.");
82+ return { ok: true, value: new Date(at).toISOString() };
83+}
84+
85+function title(raw: unknown): Checked<string> {
86+ const value = clean(raw);
87+ if (!value) return fail("Give it a title.");
88+ if (value.length > MAX_TITLE) return fail(`Keep the title under ${MAX_TITLE} characters.`);
89+ return { ok: true, value };
90+}
91+
92+function by(raw: unknown): Checked<string> {
93+ const value = clean(raw);
94+ return value ? { ok: true, value } : fail("Who is making the change is missing.");
95+}
96+
97+/** Parts and their impact: known parts, each once, the last one given winning. */
98+export function checkImpacts(raw: unknown, known: string[]): Checked<ImpactInput[]> {
99+ if (!Array.isArray(raw)) return { ok: true, value: [] };
100+ const byKey = new Map<string, ComponentImpact>();
101+ for (const item of raw) {
102+ const key = String((item as ImpactInput)?.key ?? "");
103+ const impact = (item as ImpactInput)?.impact;
104+ if (!known.includes(key)) return fail(`Unknown part: ${key || "(none)"}.`);
105+ if (!IMPACTS.includes(impact)) return fail(`Choose an impact for ${key}.`);
106+ byKey.set(key, impact);
107+ }
108+ return { ok: true, value: [...byKey].map(([key, impact]) => ({ key, impact })) };
109+}
110+
111+export function checkDeclare(input: unknown, known: string[], now = new Date()): Checked<DeclareIncident & { started_at: string | null }> {
112+ const raw = (input ?? {}) as Record<string, unknown>;
113+ const t = title(raw.title);
114+ if (!t.ok) return t;
115+ const severity = raw.severity as IncidentSeverity;
116+ if (!SEVERITY_LABEL[severity]) return fail("Choose a severity.");
117+ const status = (raw.status ?? "investigating") as IncidentStatus;
118+ if (!STATUSES.includes(status) || status === "resolved") return fail("A new incident is investigating, identified or monitoring.");
119+ const impacts = checkImpacts(raw.components, known);
120+ if (!impacts.ok) return impacts;
121+ const affected = impacts.value.filter((c) => c.impact !== "operational");
122+ if (affected.length === 0) return fail("Choose at least one part it affects, and how.");
123+ const message = cleanMessage(raw.message);
124+ if (!message) return fail("Write the first update: what people notice, in a sentence or two.");
125+ if (message.length > MAX_MESSAGE) return fail(`Keep the update under ${MAX_MESSAGE} characters.`);
126+ const commander = role(raw.commander, "incident commander");
127+ if (!commander.ok) return commander;
128+ const communications = role(raw.communications, "communications lead");
129+ if (!communications.ok) return communications;
130+ const who = by(raw.by);
131+ if (!who.ok) return who;
132+ const started = startTime(raw.started_at, now);
133+ if (!started.ok) return started;
134+ return {
135+ ok: true,
136+ value: {
137+ title: t.value,
138+ severity,
139+ status,
140+ components: affected,
141+ message,
142+ started_at: started.value,
143+ commander: commander.value,
144+ communications: communications.value,
145+ notify: raw.notify === true,
146+ by: who.value,
147+ },
148+ };
149+}
150+
151+export function checkChange(input: unknown, known: string[]): Checked<IncidentChange> {
152+ const raw = (input ?? {}) as Record<string, unknown>;
153+ const message = cleanMessage(raw.message);
154+ if (message.length > MAX_MESSAGE) return fail(`Keep the update under ${MAX_MESSAGE} characters.`);
155+ const status = raw.status == null || raw.status === "" ? null : (raw.status as IncidentStatus);
156+ if (status && !STATUSES.includes(status)) return fail("Choose a status.");
157+ const severity = raw.severity == null || raw.severity === "" ? null : (raw.severity as IncidentSeverity);
158+ if (severity && !SEVERITY_LABEL[severity]) return fail("Choose a severity.");
159+ const impacts = raw.impacts == null ? { ok: true as const, value: null } : checkImpacts(raw.impacts, known);
160+ if (!impacts.ok) return impacts;
161+ const who = by(raw.by);
162+ if (!who.ok) return who;
163+ const isPublic = raw.public === true;
164+ if (isPublic && !message) return fail("Write the update the status page will show.");
165+ return {
166+ ok: true,
167+ value: { public: isPublic, message, status, severity, impacts: impacts.value, notify: isPublic && raw.notify === true, by: who.value },
168+ };
169+}
170+
171+export function checkRoles(input: unknown): Checked<RolesChange> {
172+ const raw = (input ?? {}) as Record<string, unknown>;
173+ const commander = role(raw.commander, "incident commander");
174+ if (!commander.ok) return commander;
175+ const communications = role(raw.communications, "communications lead");
176+ if (!communications.ok) return communications;
177+ const who = by(raw.by);
178+ if (!who.ok) return who;
179+ return { ok: true, value: { commander: commander.value, communications: communications.value, by: who.value } };
180+}
181+
182+export function checkPublish(input: unknown): Checked<PublishIncident> {
183+ const raw = (input ?? {}) as Record<string, unknown>;
184+ let name: string | null = null;
185+ if (raw.title != null && raw.title !== "") {
186+ const t = title(raw.title);
187+ if (!t.ok) return t;
188+ name = t.value;
189+ }
190+ const message = cleanMessage(raw.message);
191+ if (!message) return fail("Write the first update the status page will show.");
192+ if (message.length > MAX_MESSAGE) return fail(`Keep the update under ${MAX_MESSAGE} characters.`);
193+ const who = by(raw.by);
194+ if (!who.ok) return who;
195+ return { ok: true, value: { title: name, message, notify: raw.notify === true, by: who.value } };
196+}
197+
198+export function checkFollowUp(input: unknown): Checked<{ title: string; owner: string | null; by: string }> {
199+ const raw = (input ?? {}) as Record<string, unknown>;
200+ const name = clean(raw.title);
201+ if (!name) return fail("Say what needs doing.");
202+ if (name.length > 200) return fail("Keep a follow-up under 200 characters.");
203+ const owner = role(raw.owner, "owner");
204+ if (!owner.ok) return owner;
205+ const who = by(raw.by);
206+ if (!who.ok) return who;
207+ return { ok: true, value: { title: name, owner: owner.value, by: who.value } };
208+}
209+
210+export const POSTMORTEM_FIELDS: (keyof PostmortemFields)[] = ["summary", "impact", "timeline", "root_cause", "went_well", "went_badly", "action_items"];
211+
212+export function checkPostmortem(input: unknown): Checked<PostmortemFields & { by: string }> {
213+ const raw = (input ?? {}) as Record<string, unknown>;
214+ const fields = {} as PostmortemFields;
215+ for (const key of POSTMORTEM_FIELDS) {
216+ const value = cleanMessage(raw[key]);
217+ if (value.length > MAX_SECTION) return fail(`Keep each section under ${MAX_SECTION} characters.`);
218+ fields[key] = value;
219+ }
220+ const who = by(raw.by);
221+ if (!who.ok) return who;
222+ return { ok: true, value: { ...fields, by: who.value } };
223+}
224+
225+/** A postmortem can be published once it says what happened and why. */
226+export function postmortemReady(p: PostmortemFields): string | null {
227+ if (!p.summary) return "Write a summary before publishing.";
228+ if (!p.root_cause) return "Say what caused it before publishing.";
229+ return null;
230+}
231+
232+export function checkMaintenance(input: unknown, known: string[], now = new Date()): Checked<NewMaintenance> {
233+ const raw = (input ?? {}) as Record<string, unknown>;
234+ const t = title(raw.title);
235+ if (!t.ok) return t;
236+ const message = cleanMessage(raw.message);
237+ if (!message) return fail("Say what will happen and what people will notice.");
238+ if (message.length > MAX_MESSAGE) return fail(`Keep the message under ${MAX_MESSAGE} characters.`);
239+ const components = Array.isArray(raw.components) ? [...new Set(raw.components.map(String))] : [];
240+ const unknown = components.filter((c) => !known.includes(c));
241+ if (unknown.length) return fail(`Unknown parts: ${unknown.join(", ")}.`);
242+ if (components.length === 0) return fail("Choose at least one part the work affects.");
243+ const starts = Date.parse(String(raw.starts_at ?? ""));
244+ const ends = Date.parse(String(raw.ends_at ?? ""));
245+ if (Number.isNaN(starts) || Number.isNaN(ends)) return fail("Give the window's start and end.");
246+ if (ends <= starts) return fail("The window ends after it starts.");
247+ if (ends <= now.getTime()) return fail("The window is already over.");
248+ if (starts > now.getTime() + MAX_AHEAD_DAYS * 86_400_000) return fail(`Schedule maintenance at most ${MAX_AHEAD_DAYS} days ahead.`);
249+ if (ends - starts > MAX_WINDOW_HOURS * 3_600_000) return fail(`Keep a window under ${MAX_WINDOW_HOURS} hours.`);
250+ const who = by(raw.by);
251+ if (!who.ok) return who;
252+ return {
253+ ok: true,
254+ value: {
255+ title: t.value,
256+ message,
257+ components,
258+ starts_at: new Date(starts).toISOString(),
259+ ends_at: new Date(ends).toISOString(),
260+ notify: raw.notify === true,
261+ by: who.value,
262+ },
263+ };
264+}
265+
266+export function checkMaintenanceChange(input: unknown): Checked<MaintenanceChange> {
267+ const raw = (input ?? {}) as Record<string, unknown>;
268+ const action = raw.action as MaintenanceChange["action"];
269+ if (!["update", "start", "complete", "cancel"].includes(action)) return fail("Choose what to do.");
270+ const message = cleanMessage(raw.message);
271+ if (action === "update" && !message) return fail("Write the update.");
272+ if (message.length > MAX_MESSAGE) return fail(`Keep the message under ${MAX_MESSAGE} characters.`);
273+ const who = by(raw.by);
274+ if (!who.ok) return who;
275+ return { ok: true, value: { action, message, notify: raw.notify === true, by: who.value } };
276+}
277+
278+// --- Lifecycle -------------------------------------------------------------------
279+
280+/** What the lifecycle reads and changes on an incident. */
281+export type IncidentFacts = {
282+ status: IncidentStatus;
283+ severity: IncidentSeverity;
284+ visibility: IncidentVisibility;
285+ components: ImpactInput[];
286+ started_at: string;
287+ acknowledged_at: string | null;
288+ mitigated_at: string | null;
289+ resolved_at: string | null;
290+ commander: string | null;
291+ communications: string | null;
292+};
293+
294+/** A line the change writes on the timeline. */
295+export type Entry = { kind: TimelineKind; public: boolean; status: IncidentStatus | null; text: string };
296+
297+const ORDER: Record<IncidentStatus, number> = { investigating: 0, identified: 1, monitoring: 2, resolved: 3 };
298+
299+/**
300+ * Applies an update, a note, or changes. The rules:
301+ *
302+ * - A dismissed incident is closed for good.
303+ * - A draft takes notes and changes, but nothing public until it is published.
304+ * - On a published incident, a status change is said publicly: the status page
305+ * shows each status with words.
306+ * - Reaching monitoring (or resolved) marks it mitigated; resolved marks it
307+ * resolved; leaving resolved reopens it and clears that.
308+ * - The first change by a person acknowledges a detected incident.
309+ */
310+export function applyChange(
311+ facts: IncidentFacts,
312+ change: IncidentChange,
313+ now: Date,
314+ names: Map<string, string> = new Map(),
315+): Checked<{ next: IncidentFacts; entries: Entry[] }> {
316+ if (facts.visibility === "dismissed") return fail("This draft was dismissed; declare a new incident instead.");
317+ if (change.public && facts.visibility === "draft") return fail("Publish the draft before posting public updates.");
318+ const at = now.toISOString();
319+ const next: IncidentFacts = { ...facts, components: facts.components.map((c) => ({ ...c })) };
320+ const entries: Entry[] = [];
321+
322+ const status = change.status && change.status !== facts.status ? change.status : null;
323+ if (status && facts.visibility === "public" && !change.public) {
324+ return fail("Changing the status of a published incident is a public update: say what changed for the status page.");
325+ }
326+ if (!status && !change.severity && !change.impacts?.length && !change.message) return fail("Nothing to post: write an update or change something.");
327+
328+ if (!next.acknowledged_at) {
329+ next.acknowledged_at = at;
330+ entries.push({ kind: "acknowledged", public: false, status: null, text: "Acknowledged." });
331+ }
332+ if (change.severity && change.severity !== facts.severity) {
333+ entries.push({
334+ kind: "severity",
335+ public: false,
336+ status: null,
337+ text: `Severity ${SEVERITY_LABEL[facts.severity]} → ${SEVERITY_LABEL[change.severity]}.`,
338+ });
339+ next.severity = change.severity;
340+ }
341+ for (const { key, impact } of change.impacts ?? []) {
342+ const index = next.components.findIndex((c) => c.key === key);
343+ const before = index >= 0 ? next.components[index]!.impact : "operational";
344+ if (before === impact) continue;
345+ if (index >= 0) next.components[index]!.impact = impact;
346+ else next.components.push({ key, impact });
347+ entries.push({ kind: "impact", public: false, status: null, text: `${names.get(key) ?? key}: ${IMPACT_WORD[before]} → ${IMPACT_WORD[impact]}.` });
348+ }
349+ if (status) {
350+ const reopening = facts.status === "resolved";
351+ entries.push({
352+ kind: "status",
353+ public: false,
354+ status,
355+ text: reopening ? `Reopened: ${INCIDENT_STATUS[status]}.` : `${INCIDENT_STATUS[facts.status]} → ${INCIDENT_STATUS[status]}.`,
356+ });
357+ next.status = status;
358+ if (ORDER[status] >= ORDER.monitoring && !next.mitigated_at) next.mitigated_at = at;
359+ if (status === "resolved") next.resolved_at = at;
360+ if (reopening) next.resolved_at = null;
361+ }
362+ if (change.message) {
363+ entries.push({ kind: change.public ? "update" : "note", public: change.public, status: change.public ? next.status : null, text: change.message });
364+ }
365+ return { ok: true, value: { next, entries } };
366+}
367+
368+/** A change of roles, and the lines it writes. */
369+export function applyRoles(facts: IncidentFacts, change: RolesChange, now: Date): { next: IncidentFacts; entries: Entry[] } {
370+ const next = { ...facts };
371+ const entries: Entry[] = [];
372+ if (!next.acknowledged_at) {
373+ next.acknowledged_at = now.toISOString();
374+ entries.push({ kind: "acknowledged", public: false, status: null, text: "Acknowledged." });
375+ }
376+ const say = (who: string | null) => who ?? "nobody";
377+ if (change.commander !== facts.commander) {
378+ entries.push({ kind: "role", public: false, status: null, text: `Incident commander: ${say(change.commander)}.` });
379+ next.commander = change.commander;
380+ }
381+ if (change.communications !== facts.communications) {
382+ entries.push({ kind: "role", public: false, status: null, text: `Communications: ${say(change.communications)}.` });
383+ next.communications = change.communications;
384+ }
385+ return { next, entries };
386+}
387+
388+/** Seconds from the impact starting to each milestone. */
389+export function durations(i: Pick<IncidentFacts, "started_at" | "acknowledged_at" | "mitigated_at" | "resolved_at">): IncidentDurations {
390+ const start = Date.parse(i.started_at);
391+ const since = (at: string | null) => (at ? Math.max(0, Math.round((Date.parse(at) - start) / 1000)) : null);
392+ return { to_acknowledge: since(i.acknowledged_at), to_mitigate: since(i.mitigated_at), to_resolve: since(i.resolved_at) };
393+}
394+
395+/** "45s", "12m", "2h 05m", "3d 4h". */
396+export function duration(seconds: number | null): string {
397+ if (seconds == null) return "—";
398+ if (seconds < 60) return `${seconds}s`;
399+ const minutes = Math.floor(seconds / 60);
400+ if (minutes < 60) return `${minutes}m`;
401+ const hours = Math.floor(minutes / 60);
402+ if (hours < 48) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
403+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
404+}
405+
406+/** A short random id for a permalink: ten lowercase letters and digits. */
407+export function shortId(random: (n: number) => Uint8Array = (n) => crypto.getRandomValues(new Uint8Array(n))): string {
408+ const alphabet = "abcdefghijkmnpqrstuvwxyz23456789";
409+ return [...random(10)].map((b) => alphabet[b % alphabet.length]).join("");
410+}
+810−0
1+/**
2+ * status.g1t.sh: whether each part of g1t is working, how it has done over
3+ * 90 days, and what staff have said about incidents and maintenance.
4+ *
5+ * A Worker of its own, apart from the site, so it stays up when g1t does
6+ * not. Every minute a cron checks each part over the public internet, as
7+ * people reach it, and keeps the result in D1. The same run moves planned
8+ * maintenance along, and drafts an incident for staff when a part keeps
9+ * failing (detect.ts). Pages are drawn from what is kept, never by
10+ * checking on the spot, and kept at the edge for 30 seconds.
11+ *
12+ * Staff run incidents from sudo, through the `StatusAdmin` entrypoint,
13+ * which only a service binding reaches. Every change there is audited.
14+ *
15+ * GET / the page
16+ * GET /status.json the same as JSON (snake_case, CORS open)
17+ * GET /badge.svg a small badge
18+ * GET /incidents/<id> an incident's updates and postmortem
19+ * GET /maintenance/<id> a maintenance window's updates
20+ * GET /history the last 12 months, by month
21+ * GET /feed.xml, /feed.json every public update, newest first
22+ * GET /subscribe subscribing by email
23+ * POST /subscribe asks for a subscription: a confirmation email
24+ * GET|POST /subscribe/confirm?token= confirms (GET shows a button: link scanners must not confirm)
25+ * GET|POST /unsubscribe?token= leaves (POST also takes RFC 8058 one-click)
26+ */
27+import { WorkerEntrypoint } from "cloudflare:workers";
28+import {
29+ type AdminIncident,
30+ type AdminIncidentDetail,
31+ type AdminMaintenance,
32+ type DeclareIncident,
33+ type FollowUp,
34+ type IncidentChange,
35+ type MaintenanceChange,
36+ type NewMaintenance,
37+ type Postmortem,
38+ type PostmortemFields,
39+ type PublishIncident,
40+ type Result,
41+ type RolesChange,
42+ type StatusAdminApi,
43+ type StatusAuditEntry,
44+ type StatusBoard,
45+ billingClient,
46+ fail,
47+ ok,
48+} from "@g1t/contracts";
49+import bricolage from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2";
50+import hanken from "@g1t/theme/fonts/hanken-grotesk-latin.woff2";
51+import plexMono from "@g1t/theme/fonts/ibm-plex-mono-latin-400.woff2";
52+
53+import { type Targets, components } from "./components.ts";
54+import { DETECT_AFTER, detect, detectedImpact, draftTitle } from "./detect.ts";
55+import { type EmailBinding, type Sender, alertLetter, bindingSender, confirmLetter, render as renderMail, unsubscribeHeaders, updateLetter } from "./email.ts";
56+import { atom, feedItems, jsonFeed } from "./feed.ts";
57+import {
58+ type Entry,
59+ applyChange,
60+ applyRoles,
61+ checkChange,
62+ checkDeclare,
63+ checkFollowUp,
64+ checkMaintenance,
65+ checkMaintenanceChange,
66+ checkPostmortem,
67+ checkPublish,
68+ checkRoles,
69+ postmortemReady,
70+ SEVERITY_LABEL,
71+ shortId,
72+} from "./incidents.ts";
73+import { INCIDENT_STATUS, type PageModel, buildPage, classify, underMaintenance } from "./model.ts";
74+import { stamp } from "./postmortem.ts";
75+import { runCheck } from "./probe.ts";
76+import {
77+ FAVICON,
78+ SCRIPT,
79+ type PageOptions,
80+ renderBadge,
81+ renderHistory,
82+ renderIncident,
83+ renderMaintenance,
84+ renderMessage,
85+ renderPage,
86+ renderSubscribe,
87+} from "./render.ts";
88+import {
89+ type Observation,
90+ addFollowUp,
91+ addSystemLines,
92+ auditLog,
93+ board,
94+ confirmSubscription,
95+ createIncident,
96+ dueMaintenance,
97+ facts,
98+ incidentDetail,
99+ load,
100+ loadHistory,
101+ loadPublicIncident,
102+ loadPublicMaintenance,
103+ loadStreaks,
104+ maintenanceById,
105+ maintenanceUrl,
106+ maintenanceUpdate,
107+ openCount,
108+ openRefs,
109+ publishPostmortem,
110+ recipients,
111+ record,
112+ requestSubscription,
113+ saveIncident,
114+ savePostmortem,
115+ saveStreaks,
116+ scheduleMaintenance,
117+ setFollowUp,
118+ unsubscribe,
119+} from "./store.ts";
120+import { CONFIRM_TTL_MS, RESEND_AFTER_MS, chosenParts, hashToken, newToken, normalizeEmail, readUnsubscribeToken, unsubscribeToken } from "./subscribers.ts";
121+
122+export interface Env extends Partial<Targets> {
123+ DB: D1Database;
124+ /** Billing, for reading its price book. Optional: without it, billing is not listed. */
125+ BILLING?: Fetcher;
126+ /** Where help is. */
127+ SUPPORT_URL?: string;
128+ /**
129+ * The site's address as people's browsers reach it, for the page's links,
130+ * when the checks reach it by another (self-hosted: `http://g1t:8787`
131+ * inside Compose). SITE_URL when empty.
132+ */
133+ PUBLIC_SITE_URL?: string;
134+ /** The page's share card; empty for none. */
135+ OG_IMAGE?: string;
136+ /** This page's own address, for links in email and feeds made outside a request. */
137+ STATUS_URL?: string;
138+ /** Where sudo is, for the staff alert's link. */
139+ SUDO_URL?: string;
140+ /** Who hears about detected drafts. Empty sends none. */
141+ STATUS_ALERT_EMAIL?: string;
142+ /** The From of every email. */
143+ STATUS_FROM?: string;
144+ /** Signs unsubscribe links (a secret). Without it, email subscriptions are off; the feeds still work. */
145+ STATUS_SECRET?: string;
146+ /** Cloudflare Email Sending (`send_email`). Without it, nothing is emailed. */
147+ EMAIL?: EmailBinding;
148+}
149+
150+/** How long the edge keeps a page or the JSON. */
151+const CACHE_SECONDS = 30;
152+/** Older than this, a visit asks for a round of checks too (a missed cron, or `wrangler dev`). */
153+const BEHIND_MS = 3 * 60 * 1000;
154+/** How many subscribers one update emails at most, within a Worker's limits. */
155+const MAX_RECIPIENTS = 900;
156+
157+function parts(env: Env) {
158+ return components(env, env.BILLING != null);
159+}
160+
161+function names(env: Env): Map<string, string> {
162+ return new Map(parts(env).map((p) => [p.key, p.name]));
163+}
164+
165+/** This page's address: the request's own, or STATUS_URL outside a request. */
166+function originOf(env: Env, url?: URL): string {
167+ return (url?.origin ?? env.STATUS_URL ?? "https://status.g1t.sh").replace(/\/+$/, "");
168+}
169+
170+function sender(env: Env): Sender | null {
171+ return bindingSender(env.EMAIL, env.STATUS_FROM || undefined);
172+}
173+
174+/** Whether subscribers can sign up: a way to send, and a secret to sign their links. */
175+function emailOn(env: Env): boolean {
176+ return sender(env) != null && !!env.STATUS_SECRET;
177+}
178+
179+/** One round of checks, kept. Parts under maintenance are checked but not tallied. */
180+export async function checkAll(env: Env, now = new Date()): Promise<Observation[]> {
181+ const billing = env.BILLING;
182+ const list = parts(env);
183+ const observations = await Promise.all(
184+ list.map(async (info): Promise<Observation> => {
185+ const result = await runCheck(info.check, {
186+ fetch: (url, init) => fetch(url, init),
187+ billing: billing ? () => billingClient(billing).prices() : null,
188+ });
189+ const { state, detail } = classify(result);
190+ return { component: info.key, state, detail, latency_ms: result ? Math.round(result.ms) : null };
191+ }),
192+ );
193+ const { maintenance } = await load(env.DB, now, originOf(env));
194+ await record(env.DB, observations, now, underMaintenance(maintenance, now));
195+ return observations;
196+}
197+
198+// --- Email ---------------------------------------------------------------------------
199+
200+/**
201+ * Emails confirmed subscribers who want news about `about`, in the
202+ * background. Returns how many it will email, or null when email is off.
203+ */
204+async function notify(
205+ env: Env,
206+ ctx: { waitUntil(p: Promise<unknown>): void },
207+ about: string[],
208+ mail: { heading: string; text: string; url: string },
209+): Promise<number | null> {
210+ const send = sender(env);
211+ const secret = env.STATUS_SECRET;
212+ if (!send || !secret) return null;
213+ const list = (await recipients(env.DB, about)).slice(0, MAX_RECIPIENTS);
214+ const origin = originOf(env);
215+ const affects = about.map((k) => names(env).get(k) ?? k);
216+ ctx.waitUntil(
217+ (async () => {
218+ let failed = 0;
219+ for (let i = 0; i < list.length; i += 6) {
220+ await Promise.all(
221+ list.slice(i, i + 6).map(async (r) => {
222+ const link = `${origin}/unsubscribe?token=${encodeURIComponent(await unsubscribeToken(secret, r.id))}`;
223+ const { text, html } = renderMail(updateLetter({ heading: mail.heading, text: mail.text, url: mail.url, affects, unsubscribe: link }));
224+ await send.send({ to: r.email, subject: mail.heading, text, html, headers: unsubscribeHeaders(link) }).catch(() => void (failed += 1));
225+ }),
226+ );
227+ }
228+ console.log(JSON.stringify({ event: "status.notified", sent: list.length - failed, failed }));
229+ })(),
230+ );
231+ return list.length;
232+}
233+
234+// --- The cron: detection and maintenance --------------------------------------------------
235+
236+async function afterChecks(env: Env, ctx: { waitUntil(p: Promise<unknown>): void }, observations: Observation[], now: Date): Promise<void> {
237+ const origin = originOf(env);
238+ const named = names(env);
239+ // Maintenance whose window opened or closed.
240+ for (const m of await dueMaintenance(env.DB, now, origin)) {
241+ const ended = Date.parse(m.ends_at) <= now.getTime();
242+ const text = ended ? "The maintenance is complete." : "The maintenance has begun.";
243+ const notified = m.notify ? await notify(env, ctx, m.components, { heading: `${ended ? "Completed" : "In progress"}: ${m.title}`, text, url: m.url }) : null;
244+ await maintenanceUpdate(env.DB, m.id, ended ? "completed" : "in_progress", text, "status", notified, now, {
245+ action: ended ? "maintenance_completed" : "maintenance_started",
246+ detail: `${m.title} (on schedule)`,
247+ });
248+ }
249+ // Detection.
250+ const [streaks, open, page] = await Promise.all([loadStreaks(env.DB), openRefs(env.DB), load(env.DB, now, origin)]);
251+ const found = detect(streaks, observations, open, underMaintenance(page.maintenance, now), now);
252+ await saveStreaks(env.DB, found.streaks);
253+ const lines = [
254+ ...found.failing.map((f) => ({
255+ incident: f.incident,
256+ kind: "failing" as const,
257+ text: `${named.get(f.key) ?? f.key}: checks ${f.state === "down" ? "failing" : "slow"} ${DETECT_AFTER} times in a row since ${stamp(f.since)}.`,
258+ })),
259+ ...found.recovered.map((r) => ({
260+ incident: r.incident,
261+ kind: "recovered" as const,
262+ text: `${named.get(r.key) ?? r.key}: answering again, after ${r.checks} failed or slow checks since ${stamp(r.since)}.`,
263+ })),
264+ ];
265+ await addSystemLines(env.DB, lines, now);
266+ if (found.draft.length) {
267+ const core = new Set(parts(env).filter((p) => p.core).map((p) => p.key));
268+ const title = draftTitle(found.draft.map((d) => ({ name: named.get(d.key) ?? d.key, state: d.state })));
269+ const since = found.draft.map((d) => d.since).sort()[0]!;
270+ const id = await createIncident(
271+ env.DB,
272+ {
273+ title,
274+ severity: found.draft.some((d) => d.state === "down" && core.has(d.key)) ? "sev2" : "sev3",
275+ status: "investigating",
276+ visibility: "draft",
277+ source: "detected",
278+ components: found.draft.map((d) => ({ key: d.key, impact: detectedImpact(d.state) })),
279+ started_at: since,
280+ acknowledged_at: null,
281+ commander: null,
282+ communications: null,
283+ by: "status",
284+ },
285+ [
286+ {
287+ kind: "detected",
288+ public: false,
289+ status: null,
290+ text: `The checks failed ${DETECT_AFTER} times in a row: ${found.draft.map((d) => `${named.get(d.key) ?? d.key} (${d.state === "down" ? "not answering" : "slow"})`).join(", ")}. Not on the status page until it is published.`,
291+ },
292+ ],
293+ now,
294+ { action: "incident_detected", detail: title },
295+ );
296+ console.warn(JSON.stringify({ event: "status.detected", id, parts: found.draft.map((d) => d.key) }));
297+ const to = (env.STATUS_ALERT_EMAIL ?? "").trim();
298+ const send = sender(env);
299+ if (to && send) {
300+ const link = `${(env.SUDO_URL || "https://sudo.g1t.sh").replace(/\/+$/, "")}/incidents/${id}`;
301+ const { text, html } = renderMail(alertLetter({ title, parts: found.draft.map((d) => named.get(d.key) ?? d.key), since: stamp(since), link }));
302+ ctx.waitUntil(send.send({ to, subject: `[g1t status] ${title}`, text, html }).catch((e) => console.error(JSON.stringify({ event: "status.alert_failed", error: String(e) }))));
303+ }
304+ }
305+}
306+
307+// --- Pages -------------------------------------------------------------------------------
308+
309+async function model(env: Env, now: Date, origin: string): Promise<PageModel> {
310+ const stored = await load(env.DB, now, origin);
311+ return buildPage({
312+ parts: parts(env),
313+ current: stored.current,
314+ checkedAt: stored.checkedAt,
315+ days: stored.days,
316+ incidents: stored.incidents,
317+ maintenance: stored.maintenance,
318+ now,
319+ });
320+}
321+
322+/** When this isolate last asked for a catch-up round, so a busy page asks once. */
323+let caughtUpAt = 0;
324+
325+function catchUp(env: Env, ctx: ExecutionContext, page: PageModel, now: Date) {
326+ const checked = page.report.checked_at ? Date.parse(page.report.checked_at) : 0;
327+ if (now.getTime() - checked < BEHIND_MS || now.getTime() - caughtUpAt < BEHIND_MS) return;
328+ caughtUpAt = now.getTime();
329+ ctx.waitUntil(checkAll(env, now).catch((error) => console.error(JSON.stringify({ event: "status.catch_up_failed", error: String(error) }))));
330+}
331+
332+const PAGE_POLICY = [
333+ "default-src 'none'",
334+ "script-src 'self'",
335+ "style-src 'unsafe-inline'",
336+ "font-src 'self'",
337+ "img-src 'self' data:",
338+ "base-uri 'none'",
339+ "form-action 'self'",
340+ "frame-ancestors 'none'",
341+].join("; ");
342+
343+const COMMON = {
344+ "x-content-type-options": "nosniff",
345+ "referrer-policy": "strict-origin-when-cross-origin",
346+};
347+
348+function edgeCache(): Cache | null {
349+ return (globalThis as unknown as { caches?: { default?: Cache } }).caches?.default ?? null;
350+}
351+
352+/** A response from the edge cache, or made and kept there. */
353+async function cached(request: Request, ctx: ExecutionContext, make: () => Promise<Response>): Promise<Response> {
354+ const cache = edgeCache();
355+ const url = new URL(request.url);
356+ const key = new Request(url.origin + url.pathname, { method: "GET" });
357+ if (cache) {
358+ const hit = await cache.match(key).catch(() => undefined);
359+ if (hit) return hit;
360+ }
361+ const response = await make();
362+ if (cache && response.ok) ctx.waitUntil(cache.put(key, response.clone()).catch(() => undefined));
363+ return response;
364+}
365+
366+const FONTS: Record<string, ArrayBuffer> = {
367+ "/fonts/hanken-grotesk.woff2": hanken,
368+ "/fonts/bricolage-grotesque.woff2": bricolage,
369+ "/fonts/ibm-plex-mono.woff2": plexMono,
370+};
371+
372+function html(body: string, cacheControl: string, status = 200): Response {
373+ return new Response(body, {
374+ status,
375+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": cacheControl, "content-security-policy": PAGE_POLICY, ...COMMON },
376+ });
377+}
378+
379+async function form(request: Request): Promise<FormData> {
380+ try {
381+ return await request.formData();
382+ } catch {
383+ return new FormData();
384+ }
385+}
386+
387+/** Subscribing, confirming and leaving: the page's only writes. */
388+async function subscriptions(request: Request, env: Env, ctx: ExecutionContext, url: URL, options: PageOptions): Promise<Response | null> {
389+ const path = url.pathname;
390+ const noStore = "no-store";
391+ const message = (title: string, text: string, status = 200, f?: { action: string; fields: Record<string, string>; button: string }) =>
392+ html(renderMessage({ ...options, selfUrl: `${url.origin}${path}` }, { title, text, form: f }), noStore, status);
393+ const post = request.method === "POST";
394+
395+ if (path === "/subscribe" && post) {
396+ if (!emailOn(env)) return message("Email updates are not available", "Follow the Atom or JSON feed instead.", 503);
397+ const data = await form(request);
398+ if (String(data.get("website") ?? "")) return message("Check your inbox", "If the address is right, a confirmation link is on its way.");
399+ const email = normalizeEmail(data.get("email"));
400+ if (!email) return message("That is not an email address", "Go back and check it.", 400);
401+ const chosen = chosenParts(data.getAll("components").map(String), parts(env).map((p) => p.key));
402+ const token = newToken();
403+ const { send } = await requestSubscription(env.DB, email, chosen, await hashToken(token), new Date(), CONFIRM_TTL_MS, RESEND_AFTER_MS);
404+ if (send) {
405+ const link = `${url.origin}/subscribe/confirm?token=${encodeURIComponent(token)}`;
406+ const { text, html: body } = renderMail(confirmLetter(link, chosen ? chosen.map((k) => names(env).get(k) ?? k) : null));
407+ 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) }))));
408+ }
409+ return message("Check your inbox", "If the address is right, a confirmation link is on its way. It works for 24 hours.");
410+ }
411+ if (path === "/subscribe/confirm") {
412+ const token = url.searchParams.get("token") ?? (post ? String((await form(request)).get("token") ?? "") : "");
413+ if (!token) return message("That link is incomplete", "Copy the whole link from the email.", 400);
414+ 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" });
415+ const done = await confirmSubscription(env.DB, await hashToken(token), new Date());
416+ if (!done) return message("That link has expired", "Confirmation links work for 24 hours and once. Subscribe again for a new one.", 410);
417+ 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.`);
418+ }
419+ if (path === "/unsubscribe") {
420+ const token = url.searchParams.get("token") ?? (post ? String((await form(request)).get("token") ?? "") : "");
421+ const id = env.STATUS_SECRET && token ? await readUnsubscribeToken(env.STATUS_SECRET, token) : null;
422+ if (!id) return message("That link is not valid", "Use the unsubscribe link at the bottom of any email from g1t status.", 400);
423+ if (!post) return message("Unsubscribe", "Stop getting emails from g1t status?", 200, { action: "/unsubscribe", fields: { token }, button: "Unsubscribe" });
424+ await unsubscribe(env.DB, id);
425+ return message("You are unsubscribed", "You will not get any more emails from g1t status. You can subscribe again at any time.");
426+ }
427+ return null;
428+}
429+
430+async function handle(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
431+ const url = new URL(request.url);
432+ const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : url.pathname;
433+ if (request.method === "OPTIONS" && (path === "/status.json" || path === "/feed.json")) {
434+ return new Response(null, {
435+ status: 204,
436+ headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, HEAD", "access-control-max-age": "86400" },
437+ });
438+ }
439+ const now = new Date();
440+ const origin = originOf(env, url);
441+ const site = env.PUBLIC_SITE_URL || env.SITE_URL || url.origin;
442+ const options: PageOptions = {
443+ siteUrl: site,
444+ supportUrl: env.SUPPORT_URL || `${site}/support`,
445+ ogImage: env.OG_IMAGE ?? "",
446+ selfUrl: `${url.origin}${path === "/" ? "/" : path}`,
447+ now,
448+ email: emailOn(env),
449+ };
450+
451+ if (request.method === "POST") {
452+ const answer = await subscriptions(request, env, ctx, url, options);
453+ return answer ?? new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD", ...COMMON } });
454+ }
455+ if (request.method !== "GET" && request.method !== "HEAD") {
456+ return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD, POST", ...COMMON } });
457+ }
458+ const fresh = (cacheSeconds = CACHE_SECONDS) => `public, max-age=${cacheSeconds}`;
459+ const notFound = () => html(renderMessage(options, { title: "Not found", text: "There is nothing at this address." }), fresh(), 404);
460+
461+ switch (path) {
462+ case "/":
463+ return cached(request, ctx, async () => {
464+ const page = await model(env, now, origin);
465+ catchUp(env, ctx, page, now);
466+ return html(renderPage(page, options), fresh());
467+ });
468+ case "/status.json":
469+ return cached(request, ctx, async () => {
470+ const page = await model(env, now, origin);
471+ catchUp(env, ctx, page, now);
472+ return Response.json(page.report, { headers: { "cache-control": fresh(), "access-control-allow-origin": "*", ...COMMON } });
473+ });
474+ case "/badge.svg":
475+ return cached(request, ctx, async () => {
476+ const page = await model(env, now, origin);
477+ return new Response(renderBadge(page.report.overall.state, page.report.overall.title), {
478+ headers: { "content-type": "image/svg+xml", "cache-control": fresh(), "access-control-allow-origin": "*", ...COMMON },
479+ });
480+ });
481+ case "/history":
482+ return cached(request, ctx, async () => {
483+ const since = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1));
484+ const { incidents, maintenance } = await loadHistory(env.DB, since, origin);
485+ return html(renderHistory(incidents, maintenance, options), fresh());
486+ });
487+ case "/feed.xml":
488+ case "/feed.json":
489+ return cached(request, ctx, async () => {
490+ const { incidents, maintenance } = await loadHistory(env.DB, new Date(now.getTime() - 365 * 86_400_000), origin);
491+ const items = feedItems(incidents, maintenance);
492+ const feed = { origin, title: "g1t status", updated: now.toISOString() };
493+ const headers = { "cache-control": fresh(60), "access-control-allow-origin": "*", ...COMMON };
494+ return path === "/feed.xml"
495+ ? new Response(atom(items, feed), { headers: { "content-type": "application/atom+xml; charset=utf-8", ...headers } })
496+ : Response.json(jsonFeed(items, feed), { headers: { "content-type": "application/feed+json; charset=utf-8", ...headers } });
497+ });
498+ case "/subscribe":
499+ return html(renderSubscribe(options, parts(env).map(({ key, name }) => ({ key, name }))), fresh(300));
500+ case "/subscribe/confirm":
501+ case "/unsubscribe":
502+ return (await subscriptions(request, env, ctx, url, options))!;
503+ case "/status.js":
504+ return new Response(SCRIPT, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": fresh(3600), ...COMMON } });
505+ case "/favicon.svg":
506+ case "/favicon.ico":
507+ return new Response(FAVICON, { headers: { "content-type": "image/svg+xml", "cache-control": fresh(86400), ...COMMON } });
508+ case "/robots.txt":
509+ return new Response("User-agent: *\nAllow: /\nDisallow: /subscribe/confirm\nDisallow: /unsubscribe\n", {
510+ headers: { "content-type": "text/plain", "cache-control": fresh(86400) },
511+ });
512+ }
513+ const incident = /^\/incidents\/([a-z0-9-]{1,64})$/.exec(path);
514+ if (incident) {
515+ return cached(request, ctx, async () => {
516+ const found = await loadPublicIncident(env.DB, incident[1]!, origin);
517+ return found ? html(renderIncident(found.incident, found.postmortem, names(env), options), fresh()) : notFound();
518+ });
519+ }
520+ const maintenance = /^\/maintenance\/([a-z0-9-]{1,64})$/.exec(path);
521+ if (maintenance) {
522+ return cached(request, ctx, async () => {
523+ const found = await loadPublicMaintenance(env.DB, maintenance[1]!, origin);
524+ return found ? html(renderMaintenance(found, names(env), options), fresh()) : notFound();
525+ });
526+ }
527+ const font = FONTS[path];
528+ if (font) {
529+ return new Response(font, { headers: { "content-type": "font/woff2", "cache-control": "public, max-age=31536000, immutable", ...COMMON } });
530+ }
531+ return notFound();
532+}
533+
534+export default {
535+ async fetch(request, env, ctx) {
536+ try {
537+ return await handle(request, env, ctx);
538+ } catch (error) {
539+ console.error(JSON.stringify({ event: "status.failed", path: new URL(request.url).pathname, error: String(error) }));
540+ return new Response("The status page could not be drawn. Try again in a minute.", {
541+ status: 503,
542+ headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "60", ...COMMON },
543+ });
544+ }
545+ },
546+ async scheduled(_controller, env, ctx) {
547+ const now = new Date();
548+ ctx.waitUntil(
549+ checkAll(env, now)
550+ .then(async (observations) => {
551+ const failing = observations.filter((o) => o.state === "down" || o.state === "degraded");
552+ if (failing.length) console.warn(JSON.stringify({ event: "status.trouble", parts: failing }));
553+ await afterChecks(env, ctx, observations, now);
554+ })
555+ .catch((error) => console.error(JSON.stringify({ event: "status.cron_failed", error: String(error) }))),
556+ );
557+ },
558+} satisfies ExportedHandler<Env>;
559+
560+// --- Staff ---------------------------------------------------------------------------------
561+
562+/**
563+ * Staff only: running incidents and maintenance. Reached only through a
564+ * service binding (sudo's `STATUS`); status.g1t.sh's own address cannot.
565+ */
566+export class StatusAdmin extends WorkerEntrypoint<Env> implements StatusAdminApi {
567+ private known() {
568+ return parts(this.env).map((p) => p.key);
569+ }
570+
571+ private origin() {
572+ return originOf(this.env);
573+ }
574+
575+ private async detail(id: string): Promise<AdminIncidentDetail | null> {
576+ return incidentDetail(this.env.DB, String(id), this.origin(), names(this.env));
577+ }
578+
579+ private async summary(id: string): Promise<AdminIncident> {
580+ const { timeline: _t, followups: _f, postmortem: _p, postmortem_draft: _d, url: _u, ...incident } = (await this.detail(id))!;
581+ return incident;
582+ }
583+
584+ /** Emails a public update to subscribers when asked; the count to keep with it. */
585+ private async announce(incident: { id: string; title: string; components: { key: string; impact: string }[] }, status: keyof typeof INCIDENT_STATUS, text: string, wanted: boolean) {
586+ if (!wanted) return null;
587+ const about = incident.components.filter((c) => c.impact !== "operational").map((c) => c.key);
588+ return notify(this.env, this.ctx, about, {
589+ heading: `${INCIDENT_STATUS[status]}: ${incident.title}`,
590+ text,
591+ url: `${this.origin()}/incidents/${incident.id}`,
592+ });
593+ }
594+
595+ async components(): Promise<{ key: string; name: string }[]> {
596+ return parts(this.env).map(({ key, name }) => ({ key, name }));
597+ }
598+
599+ async board(): Promise<StatusBoard> {
600+ return { ...(await board(this.env.DB, new Date(), this.origin())), email: emailOn(this.env) };
601+ }
602+
603+ async incident(id: string): Promise<AdminIncidentDetail | null> {
604+ return this.detail(id);
605+ }
606+
607+ async openCount(): Promise<number> {
608+ return openCount(this.env.DB);
609+ }
610+
611+ async declare(input: DeclareIncident): Promise<Result<AdminIncident>> {
612+ const checked = checkDeclare(input, this.known());
613+ if (!checked.ok) return fail("invalid", checked.error);
614+ const v = checked.value;
615+ const now = new Date();
616+ const entries: (Entry & { notified?: number | null })[] = [
617+ { kind: "declared", public: false, status: null, text: `Declared ${SEVERITY_LABEL[v.severity]}.` },
618+ ];
619+ if (v.commander) entries.push({ kind: "role", public: false, status: null, text: `Incident commander: ${v.commander}.` });
620+ if (v.communications) entries.push({ kind: "role", public: false, status: null, text: `Communications: ${v.communications}.` });
621+ const id = shortId();
622+ const status = v.status ?? "investigating";
623+ const notified = await this.announce({ id, title: v.title, components: v.components }, status, v.message, v.notify);
624+ entries.push({ kind: "update", public: true, status, text: v.message, notified });
625+ await createIncident(
626+ this.env.DB,
627+ {
628+ title: v.title,
629+ severity: v.severity,
630+ status,
631+ visibility: "public",
632+ source: "declared",
633+ components: v.components,
634+ started_at: v.started_at ?? now.toISOString(),
635+ acknowledged_at: now.toISOString(),
636+ commander: v.commander ?? null,
637+ communications: v.communications ?? null,
638+ by: v.by,
639+ },
640+ entries,
641+ now,
642+ { action: "incident_declared", detail: `${SEVERITY_LABEL[v.severity]}: ${v.title}` },
643+ id,
644+ );
645+ console.log(JSON.stringify({ event: "status.incident_declared", id, by: v.by }));
646+ return ok(await this.summary(id));
647+ }
648+
649+ async update(id: string, change: IncidentChange): Promise<Result<AdminIncident>> {
650+ const checked = checkChange(change, this.known());
651+ if (!checked.ok) return fail("invalid", checked.error);
652+ const existing = await this.detail(id);
653+ if (!existing) return fail("not_found", "No such incident.");
654+ const now = new Date();
655+ const applied = applyChange(facts(existing), checked.value, now, names(this.env));
656+ if (!applied.ok) return fail("invalid", applied.error);
657+ const { next, entries } = applied.value;
658+ const update = entries.find((e) => e.kind === "update");
659+ const notified = update
660+ ? await this.announce({ id: existing.id, title: existing.title, components: next.components }, next.status, update.text, checked.value.notify === true)
661+ : null;
662+ const lines = entries.map((e) => (e === update ? { ...e, notified } : e));
663+ const action = next.status === "resolved" && existing.status !== "resolved" ? "incident_resolved" : update ? "incident_update" : "incident_note";
664+ await saveIncident(this.env.DB, existing.id, next, lines, now, checked.value.by, {
665+ action,
666+ detail: entries.map((e) => (e.kind === "update" || e.kind === "note" ? `${e.kind === "update" ? "Public" : "Note"}: ${e.text}` : e.text)).join(" ").slice(0, 500),
667+ });
668+ return ok(await this.summary(existing.id));
669+ }
670+
671+ async roles(id: string, change: RolesChange): Promise<Result<AdminIncident>> {
672+ const checked = checkRoles(change);
673+ if (!checked.ok) return fail("invalid", checked.error);
674+ const existing = await this.detail(id);
675+ if (!existing) return fail("not_found", "No such incident.");
676+ const now = new Date();
677+ const { next, entries } = applyRoles(facts(existing), checked.value, now);
678+ if (!entries.length) return ok(await this.summary(existing.id));
679+ await saveIncident(this.env.DB, existing.id, next, entries, now, checked.value.by, { action: "incident_roles", detail: entries.map((e) => e.text).join(" ") });
680+ return ok(await this.summary(existing.id));
681+ }
682+
683+ async publish(id: string, input: PublishIncident): Promise<Result<AdminIncident>> {
684+ const checked = checkPublish(input);
685+ if (!checked.ok) return fail("invalid", checked.error);
686+ const existing = await this.detail(id);
687+ if (!existing) return fail("not_found", "No such incident.");
688+ if (existing.visibility !== "draft") return fail("conflict", "Only a draft can be published.");
689+ const now = new Date();
690+ const at = now.toISOString();
691+ const title = checked.value.title ?? existing.title;
692+ const next = { ...facts(existing), visibility: "public" as const, acknowledged_at: existing.acknowledged_at ?? at, title, published_at: at };
693+ const notified = await this.announce({ id: existing.id, title, components: existing.components }, existing.status, checked.value.message, checked.value.notify);
694+ await saveIncident(
695+ this.env.DB,
696+ existing.id,
697+ next,
698+ [
699+ ...(existing.acknowledged_at ? [] : [{ kind: "acknowledged" as const, public: false, status: null, text: "Acknowledged." }]),
700+ { kind: "published", public: false, status: null, text: title !== existing.title ? `Published as “${title}”.` : "Published to the status page." },
701+ { kind: "update", public: true, status: existing.status, text: checked.value.message, notified },
702+ ],
703+ now,
704+ checked.value.by,
705+ { action: "incident_published", detail: title },
706+ );
707+ return ok(await this.summary(existing.id));
708+ }
709+
710+ async dismiss(id: string, input: { reason: string; by: string }): Promise<Result<AdminIncident>> {
711+ const existing = await this.detail(id);
712+ if (!existing) return fail("not_found", "No such incident.");
713+ if (existing.visibility !== "draft") return fail("conflict", "Only a draft can be dismissed; resolve a published incident instead.");
714+ const by = String(input?.by ?? "").trim();
715+ if (!by) return fail("invalid", "Who is making the change is missing.");
716+ const reason = String(input?.reason ?? "").trim().slice(0, 500) || "No reason given.";
717+ const now = new Date();
718+ const at = now.toISOString();
719+ const next = { ...facts(existing), visibility: "dismissed" as const, status: "resolved" as const, acknowledged_at: existing.acknowledged_at ?? at, resolved_at: at };
720+ await saveIncident(this.env.DB, existing.id, next, [{ kind: "dismissed", public: false, status: null, text: `Dismissed: ${reason}` }], now, by, {
721+ action: "incident_dismissed",
722+ detail: `${existing.title}: ${reason}`,
723+ });
724+ return ok(await this.summary(existing.id));
725+ }
726+
727+ async addFollowUp(id: string, input: { title: string; owner: string | null; by: string }): Promise<Result<FollowUp>> {
728+ const checked = checkFollowUp(input);
729+ if (!checked.ok) return fail("invalid", checked.error);
730+ if (!(await this.detail(id))) return fail("not_found", "No such incident.");
731+ return ok(await addFollowUp(this.env.DB, String(id), checked.value, new Date()));
732+ }
733+
734+ async setFollowUp(id: string, followUp: string, input: { done: boolean; by: string }): Promise<Result<FollowUp>> {
735+ const by = String(input?.by ?? "").trim();
736+ if (!by) return fail("invalid", "Who is making the change is missing.");
737+ const done = await setFollowUp(this.env.DB, String(id), String(followUp), input.done === true, by, new Date());
738+ return done ? ok(done) : fail("not_found", "No such follow-up.");
739+ }
740+
741+ async savePostmortem(id: string, input: PostmortemFields & { by: string }): Promise<Result<Postmortem>> {
742+ const checked = checkPostmortem(input);
743+ if (!checked.ok) return fail("invalid", checked.error);
744+ const existing = await this.detail(id);
745+ if (!existing) return fail("not_found", "No such incident.");
746+ if (existing.visibility !== "public") return fail("conflict", "Only a published incident has a postmortem.");
747+ const { by, ...fields } = checked.value;
748+ await savePostmortem(this.env.DB, existing.id, fields, by, new Date());
749+ return ok((await this.detail(existing.id))!.postmortem!);
750+ }
751+
752+ async publishPostmortem(id: string, input: { publish: boolean; by: string }): Promise<Result<Postmortem>> {
753+ const by = String(input?.by ?? "").trim();
754+ if (!by) return fail("invalid", "Who is making the change is missing.");
755+ const existing = await this.detail(id);
756+ if (!existing) return fail("not_found", "No such incident.");
757+ if (!existing.postmortem) return fail("conflict", "Save the postmortem before publishing it.");
758+ if (input.publish) {
759+ if (!existing.resolved_at) return fail("conflict", "Resolve the incident before publishing its postmortem.");
760+ const missing = postmortemReady(existing.postmortem);
761+ if (missing) return fail("invalid", missing);
762+ }
763+ await publishPostmortem(this.env.DB, existing.id, input.publish === true, by, new Date());
764+ return ok((await this.detail(existing.id))!.postmortem!);
765+ }
766+
767+ async scheduleMaintenance(input: NewMaintenance): Promise<Result<AdminMaintenance>> {
768+ const checked = checkMaintenance(input, this.known());
769+ if (!checked.ok) return fail("invalid", checked.error);
770+ const v = checked.value;
771+ const now = new Date();
772+ const id = shortId();
773+ const notified = v.notify
774+ ? await notify(this.env, this.ctx, v.components, {
775+ heading: `Planned maintenance: ${v.title}`,
776+ text: `${v.message}\n\nWhen: ${stamp(v.starts_at)} to ${stamp(v.ends_at)}.`,
777+ url: maintenanceUrl(this.origin(), id),
778+ })
779+ : null;
780+ const made = await scheduleMaintenance(this.env.DB, v, notified, now, id);
781+ return ok((await maintenanceById(this.env.DB, made, this.origin()))!);
782+ }
783+
784+ async changeMaintenance(id: string, change: MaintenanceChange): Promise<Result<AdminMaintenance>> {
785+ const checked = checkMaintenanceChange(change);
786+ if (!checked.ok) return fail("invalid", checked.error);
787+ const existing = await maintenanceById(this.env.DB, String(id), this.origin());
788+ if (!existing) return fail("not_found", "No such maintenance.");
789+ const v = checked.value;
790+ if (existing.state === "completed" || existing.state === "cancelled") return fail("conflict", `This maintenance is ${existing.state}.`);
791+ if (v.action === "start" && existing.state !== "scheduled") return fail("conflict", "It has already started.");
792+ const state = v.action === "start" ? "in_progress" : v.action === "complete" ? "completed" : v.action === "cancel" ? "cancelled" : null;
793+ const text =
794+ v.message ||
795+ (v.action === "start" ? "The maintenance has begun." : v.action === "complete" ? "The maintenance is complete." : "This maintenance is cancelled.");
796+ const word = { update: "Update", start: "In progress", complete: "Completed", cancel: "Cancelled" }[v.action];
797+ const notified = v.notify ? await notify(this.env, this.ctx, existing.components, { heading: `${word}: ${existing.title}`, text, url: existing.url }) : null;
798+ await maintenanceUpdate(this.env.DB, existing.id, state, text, v.by, notified, new Date(), {
799+ action: `maintenance_${v.action === "update" ? "update" : v.action === "start" ? "started" : v.action === "complete" ? "completed" : "cancelled"}`,
800+ detail: `${existing.title}: ${text}`,
801+ });
802+ return ok((await maintenanceById(this.env.DB, existing.id, this.origin()))!);
803+ }
804+
805+ async audit(filter?: { before?: string | null }): Promise<StatusAuditEntry[]> {
806+ const before = filter?.before && !Number.isNaN(Date.parse(filter.before)) ? filter.before : null;
807+ return auditLog(this.env.DB, before);
808+ }
809+}
810+
+255−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { components } from "./components.ts";
5+import {
6+ type DayRow,
7+ barFor,
8+ buildPage,
9+ classify,
10+ dayState,
11+ effectiveState,
12+ lastDays,
13+ maintenanceState,
14+ openOn,
15+ percent,
16+ recentIncidents,
17+ summarize,
18+ uptimeOf,
19+ uptimeOver,
20+} from "./model.ts";
21+
22+const VARS = {
23+ SITE_URL: "https://g1t.sh",
24+ API_URL: "https://api.g1t.sh",
25+ MCP_URL: "https://mcp.g1t.sh",
26+ DOCS_URL: "https://docs.g1t.sh",
27+ PAGES_URL: "https://g1t.page",
28+ MODELS_URL: "https://models.g1t.sh",
29+ PROBE_REPO: "flagon-io/g1t",
30+};
31+
32+const row = (component: string, day: string, up: number, degraded = 0, down = 0): DayRow => ({
33+ component,
34+ day,
35+ checks: up + degraded + down,
36+ up,
37+ degraded,
38+ down,
39+ latency_total: (up + degraded) * 100,
40+ latency_count: up + degraded,
41+});
42+
43+test("classify: fast is up, slow is degraded, failed is down, none is unmonitored", () => {
44+ assert.equal(classify({ ok: true, ms: 84.4 }).state, "up");
45+ assert.equal(classify({ ok: true, ms: 84.4 }).detail, "Answered in 84 ms");
46+ assert.equal(classify({ ok: true, ms: 2000 }).state, "degraded");
47+ assert.deepEqual(classify({ ok: false, ms: 5000, error: "timed out" }), { state: "down", detail: "Failed: timed out" });
48+ assert.equal(classify(null).state, "unmonitored");
49+});
50+
51+test("uptime rounds down, so one failure never reads as 100%", () => {
52+ assert.equal(uptimeOf(1439, 1440), 99.93);
53+ assert.equal(uptimeOf(99999, 100000), 99.99);
54+ assert.equal(uptimeOf(10, 10), 100);
55+ assert.equal(uptimeOf(0, 0), null);
56+ assert.equal(percent(100), "100%");
57+ assert.equal(percent(99.5), "99.50%");
58+ assert.equal(percent(null), "No data");
59+});
60+
61+test("a day's colour: a blip stays green, a bad hour is degraded, worse is down", () => {
62+ assert.equal(dayState(null), "none");
63+ assert.equal(dayState({ checks: 1440, up: 1439, degraded: 0, down: 1 }), "up");
64+ assert.equal(dayState({ checks: 1440, up: 1380, degraded: 0, down: 60 }), "degraded");
65+ assert.equal(dayState({ checks: 1440, up: 1000, degraded: 0, down: 440 }), "down");
66+ // Answering, but slow much of the day.
67+ assert.equal(dayState({ checks: 100, up: 60, degraded: 40, down: 0 }), "degraded");
68+ // An incident colours the day at least as badly as its impact.
69+ assert.equal(dayState({ checks: 1440, up: 1440, degraded: 0, down: 0 }, "down"), "down");
70+ assert.equal(dayState(null, "degraded"), "degraded");
71+});
72+
73+test("lastDays ends today, oldest first", () => {
74+ const days = lastDays(new Date("2026-10-05T12:00:00Z"));
75+ assert.equal(days.length, 90);
76+ assert.equal(days.at(-1), "2026-10-05");
77+ assert.equal(days[0], "2026-07-08");
78+});
79+
80+test("an incident is on every day it was open", () => {
81+ const incident = { started_at: "2026-10-03T23:30:00Z", resolved_at: "2026-10-04T00:10:00Z" };
82+ assert.equal(openOn(incident, "2026-10-02"), false);
83+ assert.equal(openOn(incident, "2026-10-03"), true);
84+ assert.equal(openOn(incident, "2026-10-04"), true);
85+ assert.equal(openOn(incident, "2026-10-05"), false);
86+ assert.equal(openOn({ started_at: "2026-10-01T00:00:00Z", resolved_at: null }, "2026-10-05"), true);
87+});
88+
89+test("bars and 90-day uptime come from the daily tally", () => {
90+ const days = ["2026-10-03", "2026-10-04", "2026-10-05"];
91+ const rows = [row("api", "2026-10-04", 1439, 0, 1), row("api", "2026-10-05", 600), row("git", "2026-10-05", 10)];
92+ const bar = barFor("api", rows, [], days);
93+ assert.deepEqual(
94+ bar.map((d) => d.state),
95+ ["none", "up", "up"],
96+ );
97+ assert.equal(bar[1]!.failed, 1);
98+ assert.equal(bar[1]!.avg_ms, 100);
99+ assert.equal(uptimeOver("api", rows), uptimeOf(2039, 2040));
100+ assert.equal(uptimeOver("docs", rows), null);
101+});
102+
103+test("a bar marks the days an incident on its part was open", () => {
104+ const incident = {
105+ id: "1",
106+ title: "Clones failing",
107+ impact: "down" as const,
108+ status: "resolved" as const,
109+ components: ["git"],
110+ component_impacts: [{ key: "git", impact: "major_outage" as const }],
111+ started_at: "2026-10-04T10:00:00Z",
112+ resolved_at: "2026-10-04T11:00:00Z",
113+ url: "https://status.g1t.sh/incidents/1",
114+ postmortem_published_at: null,
115+ updates: [],
116+ };
117+ const days = ["2026-10-04", "2026-10-05"];
118+ const git = barFor("git", [row("git", "2026-10-04", 1440)], [incident], days);
119+ assert.equal(git[0]!.state, "down");
120+ assert.deepEqual(git[0]!.incidents, ["Clones failing"]);
121+ assert.equal(barFor("api", [row("api", "2026-10-04", 1440)], [incident], days)[0]!.state, "up");
122+});
123+
124+test("summarize: core down is a major outage, others partial, slow is degraded performance", () => {
125+ const core = new Set(["site", "api"]);
126+ const up = { key: "api", name: "API", state: "up" as const };
127+ assert.equal(summarize([up], core).title, "All systems normal");
128+ assert.equal(summarize([{ ...up, state: "down" }], core).title, "Major outage");
129+ assert.equal(summarize([up, { key: "docs", name: "Documentation", state: "down" }], core).title, "Partial outage");
130+ const slow = summarize([up, { key: "docs", name: "Documentation", state: "degraded" }], core);
131+ assert.equal(slow.title, "Degraded performance");
132+ assert.match(slow.line, /Documentation/);
133+ assert.equal(summarize([{ key: "sandboxes", name: "Sandboxes", state: "unmonitored" }], core).state, "unknown");
134+ const incident = summarize([up], core, [{ title: "Pushes failing", impact: "degraded", status: "identified" }]);
135+ assert.equal(incident.title, "Partial outage");
136+ assert.equal(incident.line, "Identified: Pushes failing.");
137+});
138+
139+test("recent incidents: open ones and those resolved in 90 days, newest first", () => {
140+ const now = new Date("2026-10-05T00:00:00Z");
141+ const list = recentIncidents(
142+ [
143+ { id: "old", started_at: "2026-01-01T00:00:00Z", resolved_at: "2026-01-01T01:00:00Z" },
144+ { id: "open", started_at: "2026-01-02T00:00:00Z", resolved_at: null },
145+ { id: "new", started_at: "2026-10-01T00:00:00Z", resolved_at: "2026-10-01T01:00:00Z" },
146+ ],
147+ now,
148+ );
149+ assert.deepEqual(
150+ list.map((i) => i.id),
151+ ["new", "open"],
152+ );
153+});
154+
155+test("buildPage: parts with no check are unmonitored and have no uptime; staleness is noticed", () => {
156+ const now = new Date("2026-10-05T12:00:00Z");
157+ const parts = components(VARS, true);
158+ const current = new Map(parts.map((p) => [p.key, { state: "up" as const, detail: "Answered in 80 ms", latency_ms: 80 }]));
159+ const page = buildPage({
160+ parts,
161+ current,
162+ checkedAt: "2026-10-05T11:59:30Z",
163+ days: [row("api", "2026-10-05", 700)],
164+ incidents: [],
165+ now,
166+ });
167+ const sandboxes = page.report.components.find((c) => c.key === "sandboxes")!;
168+ assert.equal(sandboxes.state, "unmonitored");
169+ assert.equal(sandboxes.uptime_90d, null);
170+ assert.equal(page.report.components.find((c) => c.key === "api")!.uptime_90d, 100);
171+ assert.equal(page.report.overall.title, "All systems normal");
172+ assert.equal(page.stale, false);
173+ assert.equal(page.bars.api!.length, 90);
174+ assert.equal(buildPage({ parts, current, checkedAt: "2026-10-05T11:00:00Z", days: [], incidents: [], now }).stale, true);
175+});
176+
177+test("components: an installation without a host leaves that part off", () => {
178+ const keys = components({ SITE_URL: "http://localhost:8787", API_URL: "http://localhost:8787/api", PROBE_REPO: "me/demo" }, false).map(
179+ (c) => c.key,
180+ );
181+ assert.deepEqual(keys, ["site", "api", "git"]);
182+ const bare = components({ SITE_URL: "http://g1t:8787" }, false);
183+ assert.deepEqual(
184+ bare.map((c) => c.key),
185+ ["site"],
186+ );
187+ assert.equal(bare[0]!.check.kind === "http" && bare[0]!.check.steps.length, 1);
188+ const all = components(VARS, true);
189+ assert.deepEqual(
190+ all.map((c) => c.key),
191+ ["site", "api", "git", "mcp", "docs", "deployments", "agents", "sandboxes", "billing"],
192+ );
193+ const git = all.find((c) => c.key === "git")!;
194+ assert.deepEqual(git.check, { kind: "http", steps: [{ url: "https://g1t.sh/flagon-io/g1t.git/info/refs?service=git-upload-pack" }] });
195+});
196+
197+test("a part's state: the worse of its check and open incidents; maintenance unless an incident says worse", () => {
198+ assert.equal(effectiveState("up", [], false), "up");
199+ assert.equal(effectiveState("up", ["partial_outage"], false), "partial");
200+ assert.equal(effectiveState("down", ["degraded"], false), "down", "a failing check beats a milder report");
201+ assert.equal(effectiveState("degraded", ["major_outage", "degraded"], false), "down", "the worst impact wins");
202+ assert.equal(effectiveState("up", ["operational"], false), "up");
203+ assert.equal(effectiveState("unmonitored", ["degraded"], false), "degraded", "an incident speaks for a part with no check");
204+ assert.equal(effectiveState("down", [], true), "maintenance", "failing checks in a window are expected");
205+ assert.equal(effectiveState("up", ["major_outage"], true), "down", "an incident during maintenance still shows");
206+});
207+
208+test("maintenance moves with its window, and cancelled or completed stays put", () => {
209+ const m = { state: "scheduled" as const, starts_at: "2026-10-05T12:00:00Z", ends_at: "2026-10-05T13:00:00Z" };
210+ assert.equal(maintenanceState(m, new Date("2026-10-05T11:59:00Z")), "scheduled");
211+ assert.equal(maintenanceState(m, new Date("2026-10-05T12:00:00Z")), "in_progress");
212+ assert.equal(maintenanceState(m, new Date("2026-10-05T13:00:00Z")), "completed");
213+ assert.equal(maintenanceState({ ...m, state: "in_progress" }, new Date("2026-10-05T11:00:00Z")), "in_progress", "started early by hand");
214+ assert.equal(maintenanceState({ ...m, state: "cancelled" }, new Date("2026-10-05T12:30:00Z")), "cancelled");
215+ assert.equal(maintenanceState({ ...m, state: "completed" }, new Date("2026-10-05T12:30:00Z")), "completed", "finished early by hand");
216+});
217+
218+test("buildPage: incident impacts and maintenance reach the parts and the banner", () => {
219+ const now = new Date("2026-10-05T12:30:00Z");
220+ const parts = components(VARS, false);
221+ const current = new Map(parts.map((p) => [p.key, { state: "up" as const, detail: "Answered in 80 ms", latency_ms: 80 }]));
222+ current.set("docs", { state: "down", detail: "Failed: HTTP 502", latency_ms: null });
223+ const maintenance = [
224+ { id: "m1", title: "Docs move", message: "Moving docs.", components: ["docs"], starts_at: "2026-10-05T12:00:00Z", ends_at: "2026-10-05T13:00:00Z", state: "scheduled" as const, url: "", updates: [] },
225+ { id: "m2", title: "Later", message: "Later.", components: ["api"], starts_at: "2026-10-06T12:00:00Z", ends_at: "2026-10-06T13:00:00Z", state: "scheduled" as const, url: "", updates: [] },
226+ { id: "m3", title: "Called off", message: "No.", components: ["api"], starts_at: "2026-10-05T12:00:00Z", ends_at: "2026-10-05T13:00:00Z", state: "cancelled" as const, url: "", updates: [] },
227+ ];
228+ const quiet = buildPage({ parts, current, checkedAt: "2026-10-05T12:29:30Z", days: [], incidents: [], maintenance, now });
229+ const docs = quiet.report.components.find((c) => c.key === "docs")!;
230+ assert.equal(docs.state, "maintenance");
231+ assert.equal(docs.check_state, "down");
232+ assert.equal(quiet.report.overall.state, "maintenance");
233+ assert.equal(quiet.report.overall.title, "Under maintenance");
234+ assert.deepEqual(quiet.report.maintenance.map((m) => [m.id, m.state]), [["m1", "in_progress"], ["m2", "scheduled"]]);
235+
236+ const incident = {
237+ id: "i1",
238+ title: "API errors",
239+ impact: "degraded" as const,
240+ status: "identified" as const,
241+ components: ["api"],
242+ component_impacts: [{ key: "api", impact: "partial_outage" as const }],
243+ started_at: "2026-10-05T12:10:00Z",
244+ resolved_at: null,
245+ url: "",
246+ postmortem_published_at: null,
247+ updates: [],
248+ };
249+ const busy = buildPage({ parts, current, checkedAt: "2026-10-05T12:29:30Z", days: [], incidents: [incident], maintenance, now });
250+ assert.equal(busy.report.components.find((c) => c.key === "api")!.state, "partial");
251+ assert.equal(busy.report.overall.title, "Partial outage");
252+ assert.equal(busy.report.overall.line, "Identified: API errors.");
253+ const resolved = buildPage({ parts, current, checkedAt: "2026-10-05T12:29:30Z", days: [], incidents: [{ ...incident, resolved_at: "2026-10-05T12:20:00Z" }], now });
254+ assert.equal(resolved.report.components.find((c) => c.key === "api")!.state, "up", "a resolved incident no longer colours its part");
255+});
+369−0
1+/**
2+ * What a check's result means, how the parts add up to one line for the
3+ * whole, and the 90 days of history behind each uptime bar. No Workers
4+ * imports, so it is tested under Node.
5+ */
6+import type {
7+ ComponentImpact,
8+ IncidentImpact,
9+ IncidentStatus,
10+ MaintenanceState,
11+ StatusComponent,
12+ StatusComponentState,
13+ StatusIncident,
14+ StatusMaintenance,
15+ StatusOverall,
16+ StatusOverallState,
17+ StatusReport,
18+} from "@g1t/contracts";
19+
20+import type { ComponentInfo } from "./components.ts";
21+import type { ProbeResult } from "./probe.ts";
22+
23+/** Slower than this, a part that answered counts as degraded. */
24+export const SLOW_MS = 1500;
25+/** How many days of history the page shows and keeps. */
26+export const HISTORY_DAYS = 90;
27+/** Checks older than this mean the checker has stopped: the page says so. */
28+export const STALE_MS = 10 * 60 * 1000;
29+
30+/** What a check's result means for its part. `null` is a part with no check. */
31+export function classify(result: ProbeResult | null, slowMs = SLOW_MS): { state: StatusComponentState; detail: string } {
32+ if (!result) return { state: "unmonitored", detail: "Not monitored yet" };
33+ if (!result.ok) return { state: "down", detail: `Failed: ${result.error ?? "no answer"}` };
34+ const ms = Math.round(result.ms);
35+ if (ms > slowMs) return { state: "degraded", detail: `Slow: answered in ${ms} ms` };
36+ return { state: "up", detail: `Answered in ${ms} ms` };
37+}
38+
39+/** A part's state at its last check, as stored. */
40+export type Current = { state: StatusComponentState; detail: string; latency_ms: number | null };
41+
42+/** One part's checks on one UTC day, as stored. */
43+export type DayRow = {
44+ component: string;
45+ /** `YYYY-MM-DD`, UTC. */
46+ day: string;
47+ checks: number;
48+ up: number;
49+ degraded: number;
50+ down: number;
51+ latency_total: number;
52+ latency_count: number;
53+};
54+
55+/** One day's square on a bar. */
56+export type DayBar = {
57+ day: string;
58+ state: "up" | "degraded" | "down" | "none";
59+ /** Share of checks that answered, 0 to 100; null with none. */
60+ uptime: number | null;
61+ checks: number;
62+ failed: number;
63+ slow: number;
64+ avg_ms: number | null;
65+ /** Incidents on this part that were open some time that day. */
66+ incidents: string[];
67+};
68+
69+const DAY_MS = 24 * 60 * 60 * 1000;
70+
71+/** `YYYY-MM-DD` of a time, in UTC. */
72+export function dayOf(at: Date | number): string {
73+ return new Date(at).toISOString().slice(0, 10);
74+}
75+
76+/** The last `count` days, oldest first, ending today. */
77+export function lastDays(now: Date, count = HISTORY_DAYS): string[] {
78+ const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
79+ return Array.from({ length: count }, (_, i) => dayOf(today - (count - 1 - i) * DAY_MS));
80+}
81+
82+/**
83+ * Share of checks answered, rounded down to hundredths so a single failure
84+ * never shows as 100%.
85+ */
86+export function uptimeOf(answered: number, checks: number): number | null {
87+ if (checks <= 0) return null;
88+ return Math.floor((answered / checks) * 10000) / 100;
89+}
90+
91+/** Words for an uptime: "100%", "99.93%". */
92+export function percent(uptime: number | null): string {
93+ if (uptime == null) return "No data";
94+ return uptime >= 100 ? "100%" : `${uptime.toFixed(2)}%`;
95+}
96+
97+const LEVEL = { none: 0, up: 1, degraded: 2, down: 3 } as const;
98+
99+/**
100+ * A day's colour: answered at least 99.9% of the time is up (one blip in a
101+ * day of minutely checks), unless over a quarter of answers were slow; at
102+ * least 95% is degraded; less is down. An incident on the part that day
103+ * colours it at least as badly as its impact.
104+ */
105+export function dayState(row: Pick<DayRow, "checks" | "up" | "degraded" | "down"> | null, incidentImpact: IncidentImpact | null = null): DayBar["state"] {
106+ let state: DayBar["state"] = "none";
107+ if (row && row.checks > 0) {
108+ const uptime = ((row.up + row.degraded) / row.checks) * 100;
109+ if (uptime >= 99.9) state = row.degraded / row.checks > 0.25 ? "degraded" : "up";
110+ else if (uptime >= 95) state = "degraded";
111+ else state = "down";
112+ }
113+ if (incidentImpact && LEVEL[incidentImpact] > LEVEL[state]) state = incidentImpact;
114+ return state;
115+}
116+
117+/** Whether an incident was open at some time during `day`. */
118+export function openOn(incident: Pick<StatusIncident, "started_at" | "resolved_at">, day: string): boolean {
119+ const start = Date.parse(`${day}T00:00:00Z`);
120+ const end = start + DAY_MS;
121+ const began = Date.parse(incident.started_at);
122+ const ended = incident.resolved_at ? Date.parse(incident.resolved_at) : Number.POSITIVE_INFINITY;
123+ return began < end && ended >= start;
124+}
125+
126+/** A part's bar: one square per day, oldest first. */
127+export function barFor(
128+ key: string,
129+ rows: DayRow[],
130+ incidents: Pick<StatusIncident, "title" | "started_at" | "resolved_at" | "component_impacts">[],
131+ days: string[],
132+): DayBar[] {
133+ const byDay = new Map(rows.filter((r) => r.component === key).map((r) => [r.day, r]));
134+ const mine = incidents
135+ .map((i) => ({ incident: i, impact: i.component_impacts.find((c) => c.key === key)?.impact ?? null }))
136+ .filter((m) => m.impact != null && m.impact !== "operational");
137+ return days.map((day) => {
138+ const row = byDay.get(day) ?? null;
139+ const during = mine.filter((m) => openOn(m.incident, day));
140+ const impact = during.some((m) => m.impact === "major_outage") ? "down" : during.length ? "degraded" : null;
141+ return {
142+ day,
143+ state: dayState(row, impact),
144+ uptime: row ? uptimeOf(row.up + row.degraded, row.checks) : null,
145+ checks: row?.checks ?? 0,
146+ failed: row?.down ?? 0,
147+ slow: row?.degraded ?? 0,
148+ avg_ms: row && row.latency_count > 0 ? Math.round(row.latency_total / row.latency_count) : null,
149+ incidents: during.map((m) => m.incident.title),
150+ };
151+ });
152+}
153+
154+/** A part's uptime over the days given. */
155+export function uptimeOver(key: string, rows: DayRow[]): number | null {
156+ let checks = 0;
157+ let answered = 0;
158+ for (const row of rows) {
159+ if (row.component !== key) continue;
160+ checks += row.checks;
161+ answered += row.up + row.degraded;
162+ }
163+ return uptimeOf(answered, checks);
164+}
165+
166+
167+const RANK: Record<StatusOverallState, number> = { unknown: 0, up: 1, maintenance: 1.5, degraded: 2, down: 3 };
168+
169+function worse(a: StatusOverallState, b: StatusOverallState): StatusOverallState {
170+ return RANK[b] > RANK[a] ? b : a;
171+}
172+
173+/** Words for an incident's status. */
174+export const INCIDENT_STATUS: Record<IncidentStatus, string> = {
175+ investigating: "Investigating",
176+ identified: "Identified",
177+ monitoring: "Monitoring",
178+ resolved: "Resolved",
179+};
180+
181+/** Words for what an incident does to a part. */
182+export const IMPACT_WORD: Record<ComponentImpact, string> = {
183+ operational: "Operational",
184+ degraded: "Degraded performance",
185+ partial_outage: "Partial outage",
186+ major_outage: "Major outage",
187+};
188+
189+/** A part's state for an impact on it. */
190+export const IMPACT_STATE: Record<ComponentImpact, StatusComponentState> = {
191+ operational: "up",
192+ degraded: "degraded",
193+ partial_outage: "partial",
194+ major_outage: "down",
195+};
196+
197+/** How bad each state is: the worse of a check and an incident wins. */
198+const STATE_RANK: Record<StatusComponentState, number> = { unmonitored: 0, up: 1, maintenance: 1, degraded: 2, partial: 3, down: 4 };
199+
200+/** An incident's impact as a whole: `down` when any part has a major outage. */
201+export function overallImpact(impacts: { impact: ComponentImpact }[]): IncidentImpact {
202+ return impacts.some((i) => i.impact === "major_outage") ? "down" : "degraded";
203+}
204+
205+/**
206+ * What the page shows for a part: the worse of its last check and what
207+ * open incidents say about it. During maintenance it is "Under
208+ * maintenance", unless an incident says worse, since checks failing in a
209+ * window are expected.
210+ */
211+export function effectiveState(check: StatusComponentState, impacts: ComponentImpact[], underMaintenance: boolean): StatusComponentState {
212+ let reported: StatusComponentState | null = null;
213+ for (const impact of impacts) {
214+ const state = IMPACT_STATE[impact];
215+ if (reported == null || STATE_RANK[state] > STATE_RANK[reported]) reported = state;
216+ }
217+ if (underMaintenance && (reported == null || reported === "up")) return "maintenance";
218+ if (reported == null) return check;
219+ if (check === "unmonitored" || check === "maintenance") return reported;
220+ return STATE_RANK[reported] > STATE_RANK[check] ? reported : check;
221+}
222+
223+/**
224+ * Where maintenance stands at `now`. Cancelled and completed are final;
225+ * otherwise the window decides, so the page is right even if the
226+ * minutely job that moves it along is late.
227+ */
228+export function maintenanceState(m: Pick<StatusMaintenance, "state" | "starts_at" | "ends_at">, now: Date): MaintenanceState {
229+ if (m.state === "cancelled" || m.state === "completed") return m.state;
230+ const t = now.getTime();
231+ if (t >= Date.parse(m.ends_at)) return "completed";
232+ if (t >= Date.parse(m.starts_at) || m.state === "in_progress") return "in_progress";
233+ return "scheduled";
234+}
235+
236+/** The parts under maintenance at `now`. */
237+export function underMaintenance(
238+ list: Pick<StatusMaintenance, "state" | "starts_at" | "ends_at" | "components">[],
239+ now: Date,
240+): Set<string> {
241+ const keys = new Set<string>();
242+ for (const m of list) if (maintenanceState(m, now) === "in_progress") for (const k of m.components) keys.add(k);
243+ return keys;
244+}
245+
246+/**
247+ * The whole, from its parts and its open incidents: a major outage when a
248+ * core part is down or an incident says so; a partial outage when any part
249+ * is down or partly down, or an incident is open; degraded performance
250+ * when parts are only slow; under maintenance when work is under way and
251+ * nothing else is wrong. Parts with no check never make it look better or
252+ * worse.
253+ */
254+export function summarize(
255+ parts: Pick<StatusComponent, "key" | "name" | "state">[],
256+ core: Set<string>,
257+ open: Pick<StatusIncident, "title" | "impact" | "status">[] = [],
258+ maintenance: Pick<StatusMaintenance, "title">[] = [],
259+): StatusOverall {
260+ const monitored = parts.filter((c) => c.state !== "unmonitored");
261+ if (monitored.length === 0 && open.length === 0) {
262+ return { state: "unknown", title: "Status unavailable", line: "No checks have run yet." };
263+ }
264+ let state: StatusOverallState = monitored.length > 0 ? "up" : "unknown";
265+ for (const c of monitored) {
266+ if (c.state === "down") state = worse(state, core.has(c.key) ? "down" : "degraded");
267+ else if (c.state === "partial" || c.state === "degraded") state = worse(state, "degraded");
268+ else if (c.state === "maintenance") state = worse(state, "maintenance");
269+ }
270+ for (const incident of open) state = worse(state, incident.impact);
271+
272+ const down = monitored.filter((c) => c.state === "down" || c.state === "partial").map((c) => c.name);
273+ const slow = monitored.filter((c) => c.state === "degraded").map((c) => c.name);
274+ const lead = open[0];
275+ const incidentLine = lead ? `${INCIDENT_STATUS[lead.status]}: ${lead.title}.` : null;
276+ const named = (names: string[]) => names.join(", ");
277+
278+ if (state === "up") return { state, title: "All systems normal", line: "Every part of g1t answered its last check." };
279+ if (state === "maintenance") {
280+ const work = maintenance[0];
281+ return { state, title: "Under maintenance", line: work ? `Planned work: ${work.title}.` : "Planned work is under way." };
282+ }
283+ if (state === "down") {
284+ return {
285+ state,
286+ title: "Major outage",
287+ line: incidentLine ?? (down.length ? `Not answering: ${named(down)}.` : "g1t is not working right now."),
288+ };
289+ }
290+ if (state === "degraded") {
291+ if (down.length || open.length) {
292+ return {
293+ state,
294+ title: "Partial outage",
295+ line: incidentLine ?? `Not answering: ${named(down)}${slow.length ? `. Slow: ${named(slow)}` : ""}.`,
296+ };
297+ }
298+ return { state, title: "Degraded performance", line: `Slow to answer: ${named(slow)}.` };
299+ }
300+ return { state, title: "Status unavailable", line: "No checks have run yet." };
301+}
302+
303+/** Open incidents, and those resolved within `days` of `now`, newest first. */
304+export function recentIncidents<T extends Pick<StatusIncident, "started_at" | "resolved_at">>(
305+ incidents: T[],
306+ now: Date,
307+ days = HISTORY_DAYS,
308+): T[] {
309+ const since = now.getTime() - days * DAY_MS;
310+ return incidents
311+ .filter((i) => i.resolved_at == null || Date.parse(i.resolved_at) >= since)
312+ .sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at));
313+}
314+
315+/** Everything the page and the JSON show. */
316+export type PageModel = {
317+ report: StatusReport;
318+ bars: Record<string, DayBar[]>;
319+ /** The checks have not run for a while: the checker itself is in trouble. */
320+ stale: boolean;
321+};
322+
323+/** The report, from what is stored. */
324+export function buildPage(input: {
325+ parts: ComponentInfo[];
326+ current: Map<string, Current>;
327+ checkedAt: string | null;
328+ days: DayRow[];
329+ incidents: StatusIncident[];
330+ maintenance?: StatusMaintenance[];
331+ now: Date;
332+}): PageModel {
333+ const { parts, current, checkedAt, days, now } = input;
334+ const window = lastDays(now);
335+ const since = window[0]!;
336+ const rows = days.filter((r) => r.day >= since);
337+ const incidents = recentIncidents(input.incidents, now);
338+ const open = incidents.filter((i) => i.resolved_at == null);
339+ // Upcoming and under way, soonest first, each with its state as of now.
340+ const maintenance = (input.maintenance ?? [])
341+ .map((m) => ({ ...m, state: maintenanceState(m, now) }))
342+ .filter((m) => m.state === "scheduled" || m.state === "in_progress")
343+ .sort((a, b) => a.starts_at.localeCompare(b.starts_at));
344+ const working = underMaintenance(maintenance, now);
345+ const components: StatusComponent[] = parts.map((info) => {
346+ const seen = info.check.kind === "none" ? null : current.get(info.key);
347+ const check: StatusComponentState = seen?.state ?? "unmonitored";
348+ const impacts = open.flatMap((i) => i.component_impacts.filter((c) => c.key === info.key).map((c) => c.impact));
349+ return {
350+ key: info.key,
351+ name: info.name,
352+ address: info.address,
353+ checks: info.checks,
354+ state: effectiveState(check, impacts, working.has(info.key)),
355+ check_state: check,
356+ detail: seen?.detail ?? (info.check.kind === "none" ? "Not monitored yet" : "Not checked yet"),
357+ latency_ms: seen?.latency_ms ?? null,
358+ uptime_90d: info.check.kind === "none" ? null : uptimeOver(info.key, rows),
359+ };
360+ });
361+ const core = new Set(parts.filter((p) => p.core).map((p) => p.key));
362+ const bars = Object.fromEntries(parts.map((p) => [p.key, barFor(p.key, rows, incidents, window)]));
363+ const inProgress = maintenance.filter((m) => m.state === "in_progress");
364+ return {
365+ report: { checked_at: checkedAt, overall: summarize(components, core, open, inProgress), components, incidents, maintenance },
366+ bars,
367+ stale: checkedAt == null || now.getTime() - Date.parse(checkedAt) > STALE_MS,
368+ };
369+}
+5−0
1+/** Font files, bundled as Data modules (see `rules` in wrangler.jsonc). */
2+declare module "*.woff2" {
3+ const data: ArrayBuffer;
4+ export default data;
5+}
+52−0
1+/**
2+ * A postmortem's starting point: the timeline written out from the
3+ * incident's own, the impact from its parts and times, and the follow-ups
4+ * as action items. Staff edit it before publishing. No Workers imports.
5+ */
6+import type { AdminIncident, FollowUp, PostmortemFields, TimelineEntry } from "@g1t/contracts";
7+
8+import { duration } from "./incidents.ts";
9+import { IMPACT_WORD } from "./model.ts";
10+
11+const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
12+
13+/** "5 Oct 14:03 UTC". */
14+export function stamp(at: string): string {
15+ const d = new Date(at);
16+ const hh = String(d.getUTCHours()).padStart(2, "0");
17+ const mm = String(d.getUTCMinutes()).padStart(2, "0");
18+ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${hh}:${mm} UTC`;
19+}
20+
21+/** Lines not worth a reader's time: acknowledgements, follow-up ticks, the postmortem itself. */
22+const SKIP = new Set(["acknowledged", "followup", "postmortem"]);
23+
24+/** The internal timeline as a list, one line per entry, public updates marked. */
25+export function timelineText(entries: TimelineEntry[]): string {
26+ return entries
27+ .filter((e) => !SKIP.has(e.kind))
28+ .map((e) => `- ${stamp(e.at)}: ${e.kind === "update" ? "Status page: " : ""}${e.text.replace(/\s*\n+\s*/g, " ")}`)
29+ .join("\n");
30+}
31+
32+export function postmortemDraft(
33+ incident: Pick<AdminIncident, "started_at" | "resolved_at" | "components" | "durations">,
34+ timeline: TimelineEntry[],
35+ followups: FollowUp[],
36+ names: Map<string, string>,
37+): PostmortemFields {
38+ const parts = incident.components
39+ .filter((c) => c.impact !== "operational")
40+ .map((c) => `${names.get(c.key) ?? c.key} (${IMPACT_WORD[c.impact].toLowerCase()})`);
41+ const lasted = incident.durations.to_resolve != null ? ` for ${duration(incident.durations.to_resolve)}` : "";
42+ const window = `from ${stamp(incident.started_at)}${incident.resolved_at ? ` to ${stamp(incident.resolved_at)}` : ""}`;
43+ return {
44+ summary: "",
45+ impact: `${parts.join(", ") || "No part"} affected${lasted}, ${window}.`,
46+ timeline: timelineText(timeline),
47+ root_cause: "",
48+ went_well: "",
49+ went_badly: "",
50+ action_items: followups.map((f) => `- ${f.title}`).join("\n"),
51+ };
52+}
+40−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { combine, runCheck, step } from "./probe.ts";
5+
6+const answer = (status: number) => async () => new Response("x", { status });
7+
8+test("a step works on 2xx, or on exactly the status it expects", async () => {
9+ assert.equal((await step(answer(200), { url: "https://a/" })).ok, true);
10+ const refused = await step(answer(502), { url: "https://a/" });
11+ assert.deepEqual([refused.ok, refused.error], [false, "HTTP 502"]);
12+ assert.equal((await step(answer(401), { url: "https://a/", expect: 401 })).ok, true);
13+ assert.equal((await step(answer(200), { url: "https://a/", expect: 401 })).ok, false);
14+});
15+
16+test("a step that cannot connect, or takes too long, fails with why", async () => {
17+ const broken = await step(async () => Promise.reject(new TypeError("boom")), { url: "https://a/" });
18+ assert.equal(broken.error, "could not connect");
19+ const slow = await step(
20+ (_url, init) => new Promise((_, reject) => init.signal?.addEventListener("abort", () => reject(new Error("aborted")))),
21+ { url: "https://a/" },
22+ 20,
23+ );
24+ assert.equal(slow.error, "timed out");
25+});
26+
27+test("a check's steps: all must work, and it takes as long as the slowest", () => {
28+ assert.deepEqual(combine([{ ok: true, ms: 10 }, { ok: true, ms: 30 }]), { ok: true, ms: 30 });
29+ assert.deepEqual(combine([{ ok: true, ms: 10 }, { ok: false, ms: 5, error: "HTTP 500" }]), { ok: false, ms: 10, error: "HTTP 500" });
30+});
31+
32+test("runCheck: billing without a binding, and a part with no check, are null", async () => {
33+ const probers = { fetch: answer(200), billing: null };
34+ assert.equal(await runCheck({ kind: "billing" }, probers), null);
35+ assert.equal(await runCheck({ kind: "none" }, probers), null);
36+ assert.equal((await runCheck({ kind: "billing" }, { ...probers, billing: async () => ({ version: 1 }) }))!.ok, true);
37+ assert.equal((await runCheck({ kind: "billing" }, { ...probers, billing: async () => null }))!.error, "no price book");
38+ const mixed = await runCheck({ kind: "http", steps: [{ url: "https://a/" }, { url: "https://b/", expect: 401 }] }, probers);
39+ assert.equal(mixed!.ok, false);
40+});
+94−0
1+/**
2+ * Running a part's check: each request it makes, all at once, with a short
3+ * timeout. The answer's body is never read.
4+ */
5+import type { Check, Step } from "./components.ts";
6+
7+/** A check's raw outcome. */
8+export type ProbeResult = {
9+ ok: boolean;
10+ /** How long it took, in milliseconds: the slowest of its requests. */
11+ ms: number;
12+ /** Why it failed, in a few words: "timed out", "HTTP 502". */
13+ error?: string;
14+};
15+
16+/** No request waits longer than this. */
17+export const TIMEOUT_MS = 5000;
18+
19+export const USER_AGENT = "g1t-status (+https://status.g1t.sh)";
20+
21+type Fetch = (url: string, init: RequestInit) => Promise<Response>;
22+
23+class Timeout extends Error {}
24+
25+/** Runs `work`, timing it, and failing it after `timeoutMs`. */
26+export async function timed(
27+ work: (signal: AbortSignal) => Promise<true | string>,
28+ timeoutMs = TIMEOUT_MS,
29+ now: () => number = Date.now,
30+): Promise<ProbeResult> {
31+ const started = now();
32+ const controller = new AbortController();
33+ let timer: ReturnType<typeof setTimeout> | undefined;
34+ const deadline = new Promise<never>((_, reject) => {
35+ timer = setTimeout(() => {
36+ controller.abort();
37+ reject(new Timeout());
38+ }, timeoutMs);
39+ });
40+ try {
41+ const outcome = await Promise.race([work(controller.signal), deadline]);
42+ const ms = now() - started;
43+ return outcome === true ? { ok: true, ms } : { ok: false, ms, error: outcome };
44+ } catch (error) {
45+ const ms = now() - started;
46+ return { ok: false, ms, error: error instanceof Timeout ? "timed out" : "could not connect" };
47+ } finally {
48+ clearTimeout(timer);
49+ }
50+}
51+
52+/** One request, answered with the status that means it works. */
53+export function step(fetcher: Fetch, { url, headers = {}, expect }: Step, timeoutMs = TIMEOUT_MS): Promise<ProbeResult> {
54+ return timed(async (signal) => {
55+ const response = await fetcher(url, {
56+ signal,
57+ redirect: "manual",
58+ headers: { "user-agent": USER_AGENT, "cache-control": "no-cache", ...headers },
59+ });
60+ await response.body?.cancel().catch(() => undefined);
61+ const good = expect == null ? response.ok : response.status === expect;
62+ return good || `HTTP ${response.status}`;
63+ }, timeoutMs);
64+}
65+
66+/** A check's requests together: it works when every one does, and takes as long as the slowest. */
67+export function combine(results: ProbeResult[]): ProbeResult {
68+ const ms = Math.max(0, ...results.map((r) => r.ms));
69+ const failed = results.find((r) => !r.ok);
70+ return failed ? { ok: false, ms, error: failed.error ?? "no answer" } : { ok: true, ms };
71+}
72+
73+/** What runs a check. `billing` is null when there is no binding to it. */
74+export type Probers = {
75+ fetch: Fetch;
76+ billing: (() => Promise<unknown>) | null;
77+ timeoutMs?: number;
78+};
79+
80+/** Runs one part's check. Null for a part with no check. */
81+export async function runCheck(check: Check, probers: Probers): Promise<ProbeResult | null> {
82+ const timeoutMs = probers.timeoutMs ?? TIMEOUT_MS;
83+ switch (check.kind) {
84+ case "http":
85+ return combine(await Promise.all(check.steps.map((s) => step(probers.fetch, s, timeoutMs))));
86+ case "billing": {
87+ const billing = probers.billing;
88+ if (!billing) return null;
89+ return timed(async () => ((await billing()) ? true : "no price book"), timeoutMs);
90+ }
91+ case "none":
92+ return null;
93+ }
94+}
+174−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { StatusIncident, StatusMaintenance } from "@g1t/contracts";
5+
6+import { components } from "./components.ts";
7+import { type DayRow, buildPage } from "./model.ts";
8+import { byMonth, dayDetail, escape, prose, renderBadge, renderHistory, renderIncident, renderMaintenance, renderMessage, renderPage, renderSubscribe } from "./render.ts";
9+
10+const NOW = new Date("2026-10-05T12:00:00Z");
11+const OPTIONS = { siteUrl: "https://g1t.sh", supportUrl: "https://g1t.sh/support", ogImage: "", selfUrl: "https://status.g1t.sh/", now: NOW, email: true };
12+const parts = components(
13+ {
14+ SITE_URL: "https://g1t.sh",
15+ API_URL: "https://api.g1t.sh",
16+ DOCS_URL: "https://docs.g1t.sh",
17+ MODELS_URL: "https://models.g1t.sh",
18+ PROBE_REPO: "flagon-io/g1t",
19+ },
20+ false,
21+);
22+
23+function page(states: Record<string, "up" | "degraded" | "down">, incidents: StatusIncident[] = [], maintenance: StatusMaintenance[] = []) {
24+ const current = new Map(
25+ Object.entries(states).map(([k, state]) => [k, { state, detail: state === "down" ? "Failed: HTTP 502" : "Answered in 80 ms", latency_ms: 80 }]),
26+ );
27+ const days: DayRow[] = [
28+ { component: "api", day: "2026-10-05", checks: 720, up: 719, degraded: 0, down: 1, latency_total: 71900, latency_count: 719 },
29+ ];
30+ return buildPage({ parts, current, checkedAt: "2026-10-05T11:59:40Z", days, incidents, maintenance, now: NOW });
31+}
32+
33+const INCIDENT: StatusIncident = {
34+ id: "abc",
35+ title: "Sign-in <failing>",
36+ impact: "down",
37+ status: "identified",
38+ components: ["site"],
39+ component_impacts: [{ key: "site", impact: "major_outage" }],
40+ started_at: "2026-10-05T11:40:00Z",
41+ resolved_at: null,
42+ url: "https://status.g1t.sh/incidents/abc",
43+ postmortem_published_at: null,
44+ updates: [{ id: "u1", at: "2026-10-05T11:45:00Z", status: "identified", text: "A bad deploy.\n\nRolling back." }],
45+};
46+
47+const MAINT: StatusMaintenance = {
48+ id: "m1",
49+ title: "Database upgrade",
50+ message: "Pushes pause for up to five minutes.",
51+ components: ["git"],
52+ starts_at: "2026-10-06T02:00:00Z",
53+ ends_at: "2026-10-06T03:00:00Z",
54+ state: "scheduled",
55+ url: "https://status.g1t.sh/maintenance/m1",
56+ updates: [{ id: "mu1", at: "2026-10-05T10:00:00Z", text: "Pushes pause for up to five minutes." }],
57+};
58+
59+test("escape covers HTML's specials", () => {
60+ assert.equal(escape(`<a href="x">'&'</a>`), "&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;");
61+});
62+
63+test("the page: banner, every part with its bar, and no incidents", () => {
64+ const html = renderPage(page({ site: "up", api: "up", git: "up", docs: "up", agents: "up" }), OPTIONS);
65+ assert.match(html, /<h1>All systems normal<\/h1>/);
66+ for (const name of ["Website and sign-in", "API", "Git and repositories", "Documentation", "Sandboxes"]) assert.ok(html.includes(name), name);
67+ assert.equal((html.match(/<ol class="bar"/g) ?? []).length, 5, "Sandboxes has no bar");
68+ assert.ok(html.includes("No incidents in the last 14 days"));
69+ assert.ok(html.includes('action="/subscribe"'), "a subscribe form");
70+ assert.ok(html.includes('href="/feed.xml"'));
71+ assert.match(html, /99\.86% uptime/);
72+ assert.match(html, /aria-label="g1t"/);
73+ assert.doesNotMatch(html, /Happening now/);
74+});
75+
76+test("an outage and an open incident are said at the top, escaped", () => {
77+ const html = renderPage(
78+ page({ site: "down", api: "up", git: "up", docs: "up", agents: "up" }, [
79+ INCIDENT,
80+ ]),
81+ OPTIONS,
82+ );
83+ assert.match(html, /<h1>Major outage<\/h1>/);
84+ assert.match(html, /Happening now/);
85+ assert.ok(html.includes("Sign-in &lt;failing&gt;"));
86+ assert.ok(!html.includes("<failing>"));
87+ assert.match(html, /<p>A bad deploy\.<\/p><p>Rolling back\.<\/p>/);
88+ assert.match(html, /Identified · Major outage/);
89+ assert.ok(html.includes('href="/incidents/abc"'), "the title links to its page");
90+});
91+
92+test("a stale page says the checks stopped", () => {
93+ const model = page({ api: "up" });
94+ const html = renderPage({ ...model, stale: true }, OPTIONS);
95+ assert.match(html, /have not run since/);
96+});
97+
98+test("a day's detail names what happened", () => {
99+ assert.equal(
100+ dayDetail({ day: "2026-10-05", state: "up", uptime: 99.86, checks: 720, failed: 1, slow: 0, avg_ms: 100, incidents: ["Pushes failing"] }),
101+ "5 Oct 2026 · 99.86% answered · 1 failed check of 720 · average 100 ms · Incident: Pushes failing",
102+ );
103+ assert.equal(
104+ dayDetail({ day: "2026-10-01", state: "none", uptime: null, checks: 0, failed: 0, slow: 0, avg_ms: null, incidents: [] }),
105+ "1 Oct 2026 · No data",
106+ );
107+});
108+
109+test("the badge is an SVG coloured by state", () => {
110+ const svg = renderBadge("degraded", "Partial outage");
111+ assert.match(svg, /^<svg/);
112+ assert.match(svg, /#ffbd8c/);
113+ assert.match(svg, /partial outage/);
114+});
115+
116+test("maintenance: upcoming, then in progress with its parts under maintenance", () => {
117+ const ahead = renderPage(page({ site: "up", api: "up", git: "up" }, [], [MAINT]), OPTIONS);
118+ assert.ok(ahead.includes("Upcoming maintenance") && ahead.includes("Database upgrade") && !ahead.includes("Maintenance in progress"));
119+ const during = { ...OPTIONS, now: new Date("2026-10-06T02:30:00Z") };
120+ const model = buildPage({
121+ parts,
122+ current: new Map([["git", { state: "down" as const, detail: "Failed: HTTP 503", latency_ms: null }], ["site", { state: "up" as const, detail: "ok", latency_ms: 1 }]]),
123+ checkedAt: "2026-10-06T02:29:40Z",
124+ days: [],
125+ incidents: [],
126+ maintenance: [MAINT],
127+ now: during.now,
128+ });
129+ const html = renderPage(model, during);
130+ assert.ok(html.includes("Maintenance in progress"));
131+ assert.ok(html.includes("<h1>Under maintenance</h1>"));
132+ assert.ok(html.includes('class="state s-maintenance"'));
133+});
134+
135+test("an incident's page: every update, its parts' impact, and the postmortem once published", () => {
136+ const names = new Map([["site", "Website and sign-in"]]);
137+ const resolved = { ...INCIDENT, status: "resolved" as const, resolved_at: "2026-10-05T12:40:00Z", postmortem_published_at: "2026-10-06T09:00:00Z" };
138+ const pm = { summary: "A deploy broke sign-in.", impact: "", timeline: "- 11:40 declared\n- 12:40 resolved", root_cause: "A <bad> cookie.", went_well: "", went_badly: "", action_items: "- Canary deploys", published_at: "2026-10-06T09:00:00Z" };
139+ const html = renderIncident(resolved, pm, names, { ...OPTIONS, selfUrl: resolved.url });
140+ assert.ok(html.includes('id="update-u1"'));
141+ assert.ok(html.includes("Website and sign-in"));
142+ assert.ok(html.includes("Major outage"));
143+ assert.ok(html.includes('id="postmortem"'));
144+ assert.ok(html.includes("<h3>Root cause</h3>") && html.includes("A &lt;bad&gt; cookie."));
145+ assert.ok(!html.includes("<h3>Impact</h3>"), "empty sections are left out");
146+ assert.ok(html.includes("<ul><li>Canary deploys</li></ul>"));
147+ assert.ok(html.includes("lasted 1h 00m"));
148+ const draft = renderIncident(resolved, null, names, OPTIONS);
149+ assert.ok(!draft.includes('id="postmortem"') && draft.includes("If a postmortem is written"));
150+});
151+
152+test("prose: paragraphs and lists, escaped", () => {
153+ assert.equal(prose("One\ntwo\n\n- a\n- <b>"),"<p>One<br>two</p><ul><li>a</li><li>&lt;b&gt;</li></ul>");
154+});
155+
156+test("history: months newest first, empty ones said so", () => {
157+ const old = { ...INCIDENT, id: "old", started_at: "2026-08-10T00:00:00Z", resolved_at: "2026-08-10T01:00:00Z" };
158+ const groups = byMonth([INCIDENT, old], [MAINT]);
159+ assert.deepEqual(groups.map((g) => g.month), ["2026-10", "2026-08"]);
160+ const html = renderHistory([INCIDENT, old], [MAINT], OPTIONS, 3);
161+ assert.ok(html.includes("October 2026") && html.includes("September 2026") && html.includes("August 2026"));
162+ assert.ok(html.includes("No incidents or maintenance."));
163+ assert.ok(html.indexOf("October 2026") < html.indexOf("August 2026"));
164+ assert.ok(html.includes('href="/maintenance/m1"'));
165+});
166+
167+test("subscribe pages: the form, its parts, and a confirm button rather than a link that acts", () => {
168+ const html = renderSubscribe(OPTIONS, [{ key: "git", name: "Git" }]);
169+ assert.ok(html.includes('name="components" value="git"') && html.includes('name="website"'));
170+ assert.ok(renderSubscribe({ ...OPTIONS, email: false }, []).includes("not available"));
171+ const confirm = renderMessage(OPTIONS, { title: "Confirm", text: "One more step", form: { action: "/subscribe/confirm", fields: { token: 'a"b' }, button: "Confirm subscription" } });
172+ assert.ok(confirm.includes('method="post" action="/subscribe/confirm"') && confirm.includes('value="a&quot;b"'));
173+ assert.ok(renderMaintenance(MAINT, new Map([["git", "Git"]]), OPTIONS).includes("Affects Git"));
174+});
+664−0
1+/**
2+ * The pages at status.g1t.sh, drawn on the server as plain HTML: no
3+ * framework, a small stylesheet inline, and a few lines of script (served
4+ * at /status.js) that only add hover and keyboard detail to the bars and
5+ * say times in the reader's own words. Every page works without it:
6+ * subscribing, confirming and leaving are plain forms. No Workers
7+ * imports, so it is tested under Node.
8+ *
9+ * / the status now, maintenance, recent incidents, subscribing
10+ * /incidents/<id> one incident's every public update, and its postmortem
11+ * /maintenance/<id> one maintenance window and its updates
12+ * /history past incidents and maintenance, by month
13+ * /subscribe subscribing by email, to every part or some
14+ */
15+import type { PostmortemFields, StatusComponent, StatusIncident, StatusMaintenance, StatusOverallState } from "@g1t/contracts";
16+
17+import { type DayBar, HISTORY_DAYS, IMPACT_WORD, INCIDENT_STATUS, type PageModel, maintenanceState, percent } from "./model.ts";
18+
19+export type PageOptions = {
20+ /** The site the page is about: `https://g1t.sh`. */
21+ siteUrl: string;
22+ /** Where help is: `https://g1t.sh/support`. */
23+ supportUrl: string;
24+ /** The share card, or empty for none. */
25+ ogImage: string;
26+ /** This page's own address, for its canonical link. */
27+ selfUrl: string;
28+ now: Date;
29+ /** Whether email subscriptions can be offered (a sender and a secret). */
30+ email?: boolean;
31+};
32+
33+export function escape(text: string): string {
34+ return text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
35+}
36+
37+const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
38+const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
39+
40+/** "5 Oct 2026". */
41+export function dayLabel(day: string): string {
42+ const [y, m, d] = day.split("-").map(Number);
43+ return `${d} ${MONTHS[(m ?? 1) - 1]} ${y}`;
44+}
45+
46+/** "5 Oct 2026, 14:03 UTC": the script says it in the reader's time. */
47+export function timeLabel(at: string): string {
48+ const date = new Date(at);
49+ const hh = String(date.getUTCHours()).padStart(2, "0");
50+ const mm = String(date.getUTCMinutes()).padStart(2, "0");
51+ return `${dayLabel(date.toISOString().slice(0, 10))}, ${hh}:${mm} UTC`;
52+}
53+
54+function time(at: string, relative = false): string {
55+ return `<time datetime="${escape(at)}"${relative ? " data-relative" : ""}>${escape(timeLabel(at))}</time>`;
56+}
57+
58+/** "1h 05m", "3d 4h". */
59+export function lasted(fromIso: string, toIso: string): string {
60+ const minutes = Math.max(0, Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 60000));
61+ if (minutes < 60) return `${minutes} min`;
62+ const hours = Math.floor(minutes / 60);
63+ if (hours < 48) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
64+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
65+}
66+
67+/** What a day's square says on hover, and to a screen reader. */
68+export function dayDetail(bar: DayBar): string {
69+ const parts = [dayLabel(bar.day)];
70+ if (bar.checks === 0) parts.push("No data");
71+ else {
72+ parts.push(`${percent(bar.uptime)} answered`);
73+ if (bar.failed > 0) parts.push(`${bar.failed} failed check${bar.failed === 1 ? "" : "s"} of ${bar.checks}`);
74+ if (bar.slow > 0) parts.push(`${bar.slow} slow`);
75+ if (bar.avg_ms != null) parts.push(`average ${bar.avg_ms} ms`);
76+ }
77+ for (const title of bar.incidents) parts.push(`Incident: ${title}`);
78+ return parts.join(" · ");
79+}
80+
81+export const STATE_WORD: Record<StatusComponent["state"], string> = {
82+ up: "Operational",
83+ degraded: "Degraded",
84+ partial: "Partial outage",
85+ down: "Outage",
86+ maintenance: "Under maintenance",
87+ unmonitored: "Not monitored",
88+};
89+
90+/** The pixel G1T: 5×7 capitals, the 1 in lavender, as in the site's logo. */
91+const G = [".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."];
92+const ONE = ["..#..", ".##..", "#.#..", "..#..", "..#..", "..#..", "#####"];
93+const T = ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."];
94+
95+function pixels(rows: string[], dx: number, cls: string): string {
96+ return rows
97+ .flatMap((row, y) => [...row].flatMap((c, x) => (c === "#" ? [`<rect x="${(dx + x) * 10 + 1}" y="${y * 10 + 1}" width="8" height="8"${cls}/>`] : [])))
98+ .join("");
99+}
100+
101+export const LOGO = `<svg class="logo" viewBox="1 1 148 68" role="img" aria-label="g1t">${pixels(G, 0, "")}${pixels(ONE, 6, ' class="one"')}${pixels(T, 10, "")}</svg>`;
102+
103+/** The favicon: the pixel 1. */
104+export const FAVICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges"><style>rect{fill:#6b56e8}@media (prefers-color-scheme: dark){rect{fill:#b6a8ff}}</style>${ONE.flatMap(
105+ (row, y) => [...row].flatMap((c, x) => (c === "#" ? [`<rect x="${x * 2 + 3}" y="${y * 2 + 1}" width="2" height="2"/>`] : [])),
106+).join("")}</svg>`;
107+
108+function bar(component: StatusComponent, days: DayBar[]): string {
109+ if (component.check_state === "unmonitored" && component.uptime_90d == null) {
110+ return `<p class="unchecked">${escape(component.checks)}</p>`;
111+ }
112+ const cells = days
113+ .map((d) => {
114+ const detail = dayDetail(d);
115+ return `<li class="${d.state}" title="${escape(detail)}" data-detail="${escape(detail)}"></li>`;
116+ })
117+ .join("");
118+ const label = `${component.name}: ${percent(component.uptime_90d)} of checks answered over ${HISTORY_DAYS} days. Use the arrow keys for each day.`;
119+ return `<div class="history">
120+<ol class="bar" tabindex="0" aria-label="${escape(label)}">${cells}</ol>
121+<div class="legend"><span><span class="long">${HISTORY_DAYS}</span><span class="short">30</span> days ago</span><span class="uptime">${escape(percent(component.uptime_90d))} uptime</span><span>Today</span></div>
122+<p class="detail" aria-live="polite"></p>
123+</div>`;
124+}
125+
126+function componentRow(component: StatusComponent, days: DayBar[]): string {
127+ const seen = component.check_state !== "unmonitored" ? `<p class="seen">${escape(component.detail)}</p>` : "";
128+ return `<li class="component">
129+<div class="row">
130+<div class="name"><h3>${escape(component.name)}</h3><span class="address">${escape(component.address)}</span></div>
131+<div class="state s-${component.state}"><span class="dot ${component.state}" aria-hidden="true"></span>${STATE_WORD[component.state]}</div>
132+</div>
133+${seen}
134+${bar(component, days)}
135+</li>`;
136+}
137+
138+/**
139+ * Text as HTML: blank lines between paragraphs, and a block whose lines
140+ * all start with "- " as a list. Everything is escaped.
141+ */
142+export function prose(text: string): string {
143+ return text
144+ .split(/\n{2,}/)
145+ .map((block) => {
146+ const lines = block.split("\n").filter((l) => l.trim() !== "");
147+ if (lines.length && lines.every((l) => /^\s*[-*] /.test(l))) {
148+ return `<ul>${lines.map((l) => `<li>${escape(l.replace(/^\s*[-*] /, ""))}</li>`).join("")}</ul>`;
149+ }
150+ return `<p>${escape(block).replace(/\n/g, "<br>")}</p>`;
151+ })
152+ .join("");
153+}
154+
155+function impactLine(incident: StatusIncident, names: Map<string, string>): string {
156+ return incident.component_impacts
157+ .map((c) => `<span class="affects">${escape(names.get(c.key) ?? c.key)} <span class="impact i-${c.impact}">${escape(IMPACT_WORD[c.impact])}</span></span>`)
158+ .join("");
159+}
160+
161+function pill(incident: StatusIncident): string {
162+ const open = incident.resolved_at == null;
163+ const impact = incident.impact === "down" ? "Major outage" : "Partial outage";
164+ return `<span class="pill ${open ? incident.impact : "done"}">${open ? `${escape(INCIDENT_STATUS[incident.status])} · ${impact}` : "Resolved"}</span>`;
165+}
166+
167+function updatesList(incident: StatusIncident): string {
168+ return `<ol class="updates">${incident.updates
169+ .map(
170+ (u) =>
171+ `<li id="update-${escape(u.id)}"><p class="when"><strong>${escape(INCIDENT_STATUS[u.status])}</strong> ${time(u.at)}</p><div class="text">${prose(u.text)}</div></li>`,
172+ )
173+ .join("")}</ol>`;
174+}
175+
176+/** An incident on the front page: its title links to its own page. */
177+function incidentBlock(incident: StatusIncident, names: Map<string, string>): string {
178+ const open = incident.resolved_at == null;
179+ return `<article class="incident ${open ? `open ${incident.impact}` : "resolved"}">
180+<header>
181+<h3><a href="/incidents/${escape(incident.id)}">${escape(incident.title)}</a></h3>
182+${pill(incident)}
183+</header>
184+<p class="meta">Started ${time(incident.started_at)}${incident.resolved_at ? ` · resolved ${time(incident.resolved_at)}` : ""}</p>
185+<div class="impacts">${impactLine(incident, names)}</div>
186+${updatesList(incident)}
187+</article>`;
188+}
189+
190+/** A line for a past incident: title, when, how long, and its postmortem. */
191+function incidentLine(incident: StatusIncident): string {
192+ const how = incident.resolved_at ? `lasted ${lasted(incident.started_at, incident.resolved_at)}` : escape(INCIDENT_STATUS[incident.status]);
193+ const latest = incident.updates[0];
194+ return `<li class="line">
195+<div class="line-head"><a href="/incidents/${escape(incident.id)}">${escape(incident.title)}</a>${incident.postmortem_published_at ? `<a class="tag" href="/incidents/${escape(incident.id)}#postmortem">Postmortem</a>` : ""}</div>
196+<p class="meta">${time(incident.started_at)} · ${how}</p>
197+${latest ? `<p class="last">${escape(latest.text.split("\n")[0]!.slice(0, 220))}</p>` : ""}
198+</li>`;
199+}
200+
201+function maintenanceWindow(m: StatusMaintenance): string {
202+ return `${time(m.starts_at)} to ${time(m.ends_at)} · ${lasted(m.starts_at, m.ends_at)}`;
203+}
204+
205+const MAINTENANCE_WORD: Record<StatusMaintenance["state"], string> = {
206+ scheduled: "Scheduled",
207+ in_progress: "In progress",
208+ completed: "Completed",
209+ cancelled: "Cancelled",
210+};
211+
212+function maintenanceBlock(m: StatusMaintenance, names: Map<string, string>, now: Date): string {
213+ const state = maintenanceState(m, now);
214+ const latest = m.updates[0];
215+ return `<article class="incident maint ${state}">
216+<header>
217+<h3><a href="/maintenance/${escape(m.id)}">${escape(m.title)}</a></h3>
218+<span class="pill maint">${MAINTENANCE_WORD[state]}</span>
219+</header>
220+<p class="meta">${maintenanceWindow(m)}</p>
221+<p class="meta">Affects ${escape(m.components.map((k) => names.get(k) ?? k).join(", "))}</p>
222+<div class="text">${prose(m.message)}</div>
223+${latest && latest.text !== m.message ? `<p class="when latest"><strong>Latest</strong> ${time(latest.at)} · ${escape(latest.text.split("\n")[0]!)}</p>` : ""}
224+</article>`;
225+}
226+
227+const BANNER_CLASS: Record<StatusOverallState, string> = { up: "up", degraded: "degraded", down: "down", maintenance: "maintenance", unknown: "unknown" };
228+
229+type Layout = { title: string; description: string; body: string; live?: boolean; feeds?: boolean };
230+
231+function layout(options: PageOptions, page: Layout): string {
232+ const site = options.siteUrl.replace(/\/+$/, "");
233+ const siteHost = site.replace(/^https?:\/\//, "");
234+ return `<!doctype html>
235+<html lang="en">
236+<head>
237+<meta charset="utf-8">
238+<meta name="viewport" content="width=device-width, initial-scale=1">
239+<title>${escape(page.title)}</title>
240+<meta name="description" content="${escape(page.description)}">
241+<meta name="theme-color" content="#0f0f11">
242+<meta name="color-scheme" content="dark">
243+<link rel="icon" href="/favicon.svg" type="image/svg+xml">
244+<link rel="canonical" href="${escape(options.selfUrl)}">
245+<link rel="alternate" type="application/json" href="/status.json">
246+<link rel="alternate" type="application/atom+xml" title="g1t status" href="/feed.xml">
247+<link rel="alternate" type="application/feed+json" title="g1t status" href="/feed.json">
248+<link rel="preload" href="/fonts/hanken-grotesk.woff2" as="font" type="font/woff2" crossorigin>
249+<link rel="preload" href="/fonts/bricolage-grotesque.woff2" as="font" type="font/woff2" crossorigin>
250+<meta property="og:site_name" content="g1t">
251+<meta property="og:type" content="website">
252+<meta property="og:url" content="${escape(options.selfUrl)}">
253+<meta property="og:title" content="${escape(page.title)}">
254+<meta property="og:description" content="${escape(page.description)}">
255+${options.ogImage ? `<meta property="og:image" content="${escape(options.ogImage)}"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="${escape(options.ogImage)}">` : ""}
256+<style>${STYLE}</style>
257+<script src="/status.js" defer></script>
258+</head>
259+<body${page.live ? " data-live" : ""}>
260+<div class="page">
261+<header class="top">
262+<a class="brand" href="/" aria-label="g1t status">${LOGO}<span>Status</span></a>
263+<a class="out" href="${escape(site)}/">${escape(siteHost)} <span aria-hidden="true">→</span></a>
264+</header>
265+<main>
266+${page.body}
267+</main>
268+<footer class="foot">
269+<a href="/history">History</a><span aria-hidden="true">·</span><a href="/subscribe">Subscribe</a><span aria-hidden="true">·</span><a href="/feed.xml">Atom feed</a><span aria-hidden="true">·</span><a href="/feed.json">JSON feed</a><span aria-hidden="true">·</span><a href="/status.json">status.json</a><span aria-hidden="true">·</span><a href="${escape(options.supportUrl)}">Support</a><span aria-hidden="true">·</span><a href="${escape(site)}/">${escape(siteHost)}</a>
270+</footer>
271+</div>
272+</body>
273+</html>`;
274+}
275+
276+const FEEDS = `the <a href="/feed.xml">Atom feed</a> or the <a href="/feed.json">JSON feed</a>`;
277+
278+function subscribeForm(options: PageOptions, compact: boolean): string {
279+ if (!options.email) return `<p class="muted">Email updates are not available here. Follow ${FEEDS} instead.</p>`;
280+ return `<form class="subscribe" method="post" action="/subscribe">
281+<label class="sr" for="sub-email">Email address</label>
282+<input id="sub-email" type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com">
283+<input class="trap" type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
284+<button type="submit">Subscribe</button>
285+</form>
286+<p class="feeds">Get an email when an incident or planned maintenance is posted${compact ? `, or <a href="/subscribe">choose which parts</a>` : ""}. Or follow ${FEEDS}.</p>`;
287+}
288+
289+/** The front page. */
290+export function renderPage(model: PageModel, options: PageOptions): string {
291+ const { report, bars, stale } = model;
292+ const { overall, components, incidents, maintenance, checked_at } = report;
293+ const names = new Map(components.map((c) => [c.key, c.name]));
294+ const open = incidents.filter((i) => i.resolved_at == null);
295+ const recentSince = options.now.getTime() - 14 * 24 * 60 * 60 * 1000;
296+ const past = incidents.filter((i) => i.resolved_at != null && Date.parse(i.resolved_at) >= recentSince);
297+ const inProgress = maintenance.filter((m) => maintenanceState(m, options.now) === "in_progress");
298+ const upcoming = maintenance.filter((m) => maintenanceState(m, options.now) === "scheduled");
299+ const description = `${overall.title}. Live status of g1t: the website, API, git, MCP, docs, deployments, agents and billing, with 90 days of history.`;
300+ const checkedLine = checked_at
301+ ? stale
302+ ? `The checks have not run since ${time(checked_at, true)}, so what follows may be out of date.`
303+ : `Checked ${time(checked_at, true)}, and every minute.`
304+ : "Waiting for the first checks.";
305+ const body = `<section class="banner ${BANNER_CLASS[overall.state]}" aria-live="polite">
306+<span class="dot big ${overall.state}" aria-hidden="true"></span>
307+<div>
308+<h1>${escape(overall.title)}</h1>
309+<p>${overall.state === "up" ? "" : `${escape(overall.line)} `}${checkedLine}</p>
310+</div>
311+</section>
312+${open.length ? `<section class="block"><h2>Happening now</h2>${open.map((i) => incidentBlock(i, names)).join("")}</section>` : ""}
313+${inProgress.length ? `<section class="block"><h2>Maintenance in progress</h2>${inProgress.map((m) => maintenanceBlock(m, names, options.now)).join("")}</section>` : ""}
314+${upcoming.length ? `<section class="block"><h2>Upcoming maintenance</h2>${upcoming.map((m) => maintenanceBlock(m, names, options.now)).join("")}</section>` : ""}
315+<section class="block">
316+<h2>Components</h2>
317+<ul class="components">${components.map((c) => componentRow(c, bars[c.key] ?? [])).join("")}</ul>
318+</section>
319+<section class="block">
320+<div class="block-head"><h2>Past incidents</h2><a href="/history">Full history <span aria-hidden="true">→</span></a></div>
321+${past.length ? `<ul class="lines">${past.map(incidentLine).join("")}</ul>` : `<p class="empty">No incidents in the last 14 days.</p>`}
322+</section>
323+<section class="block" id="subscribe">
324+<h2>Subscribe to updates</h2>
325+<div class="card">${subscribeForm(options, true)}</div>
326+</section>
327+<section class="block about">
328+<h2>About these checks</h2>
329+<p>Each part is checked every minute with one quick request, the same a visitor's would make, from outside g1t. A part that answers in over 1.5 seconds counts as slow; one that fails, or takes over 5 seconds, as down. A day shows green when at least 99.9% of its checks answered; checks during planned maintenance are not counted. This page runs apart from g1t itself, so it stays up when g1t does not.</p>
330+<dl>${components.map((c) => `<dt>${escape(c.name)}</dt><dd>${escape(c.checks)}</dd>`).join("")}</dl>
331+</section>`;
332+ return layout(options, { title: `${overall.title} · g1t status`, description, body, live: true });
333+}
334+
335+const POSTMORTEM_SECTIONS: { key: keyof PostmortemFields; title: string }[] = [
336+ { key: "summary", title: "Summary" },
337+ { key: "impact", title: "Impact" },
338+ { key: "timeline", title: "Timeline" },
339+ { key: "root_cause", title: "Root cause" },
340+ { key: "went_well", title: "What went well" },
341+ { key: "went_badly", title: "What went badly" },
342+ { key: "action_items", title: "Action items" },
343+];
344+
345+/** A postmortem's sections, those with something in them. */
346+export function postmortemHtml(p: PostmortemFields): string {
347+ return POSTMORTEM_SECTIONS.filter((s) => p[s.key].trim())
348+ .map((s) => `<section class="pm-section"><h3>${s.title}</h3><div class="text">${prose(p[s.key])}</div></section>`)
349+ .join("");
350+}
351+
352+/** One incident's page: every public update, oldest last, and its postmortem once published. */
353+export function renderIncident(
354+ incident: StatusIncident,
355+ postmortem: (PostmortemFields & { published_at: string | null }) | null,
356+ names: Map<string, string>,
357+ options: PageOptions,
358+): string {
359+ const open = incident.resolved_at == null;
360+ const body = `<p class="crumbs"><a href="/">Status</a> <span aria-hidden="true">/</span> <a href="/history">History</a></p>
361+<article class="incident-page">
362+<header class="page-head">
363+<h1>${escape(incident.title)}</h1>
364+${pill(incident)}
365+</header>
366+<p class="meta">Started ${time(incident.started_at)}${incident.resolved_at ? ` · resolved ${time(incident.resolved_at)} · lasted ${lasted(incident.started_at, incident.resolved_at)}` : ""}</p>
367+<div class="impacts">${impactLine(incident, names)}</div>
368+${postmortem?.published_at ? `<p class="note">A postmortem was published ${time(postmortem.published_at)}. <a href="#postmortem">Read it</a>.</p>` : ""}
369+<section class="block"><h2>Updates</h2><div class="card">${updatesList(incident)}</div></section>
370+${
371+ postmortem?.published_at
372+ ? `<section class="block" id="postmortem"><h2>Postmortem</h2><div class="card postmortem">${postmortemHtml(postmortem)}</div></section>`
373+ : !open
374+ ? `<p class="muted">If a postmortem is written for this incident, it will appear on this page.</p>`
375+ : ""
376+}
377+</article>`;
378+ return layout(options, {
379+ title: `${incident.title} · g1t status`,
380+ description: `${open ? INCIDENT_STATUS[incident.status] : "Resolved"}: ${incident.title}. ${incident.updates[0]?.text.slice(0, 140) ?? ""}`,
381+ body,
382+ });
383+}
384+
385+export function renderMaintenance(m: StatusMaintenance, names: Map<string, string>, options: PageOptions): string {
386+ const state = maintenanceState(m, options.now);
387+ const body = `<p class="crumbs"><a href="/">Status</a> <span aria-hidden="true">/</span> <a href="/history">History</a></p>
388+<header class="page-head"><h1>${escape(m.title)}</h1><span class="pill maint">${MAINTENANCE_WORD[state]}</span></header>
389+<p class="meta">${maintenanceWindow(m)}</p>
390+<p class="meta">Affects ${escape(m.components.map((k) => names.get(k) ?? k).join(", "))}</p>
391+<section class="block"><h2>Updates</h2><div class="card"><ol class="updates">${m.updates
392+ .map((u) => `<li id="update-${escape(u.id)}"><p class="when">${time(u.at)}</p><div class="text">${prose(u.text)}</div></li>`)
393+ .join("")}</ol></div></section>`;
394+ return layout(options, { title: `${m.title} · g1t status`, description: `Planned maintenance: ${m.title}.`, body });
395+}
396+
397+/** Months, newest first, each with its incidents and maintenance. */
398+export function byMonth(incidents: StatusIncident[], maintenance: StatusMaintenance[]): { month: string; incidents: StatusIncident[]; maintenance: StatusMaintenance[] }[] {
399+ const months = new Map<string, { incidents: StatusIncident[]; maintenance: StatusMaintenance[] }>();
400+ const slot = (month: string) => months.get(month) ?? months.set(month, { incidents: [], maintenance: [] }).get(month)!;
401+ for (const i of incidents) slot(i.started_at.slice(0, 7)).incidents.push(i);
402+ for (const m of maintenance) slot(m.starts_at.slice(0, 7)).maintenance.push(m);
403+ return [...months]
404+ .sort((a, b) => b[0].localeCompare(a[0]))
405+ .map(([month, v]) => ({
406+ month,
407+ incidents: v.incidents.sort((a, b) => b.started_at.localeCompare(a.started_at)),
408+ maintenance: v.maintenance.sort((a, b) => b.starts_at.localeCompare(a.starts_at)),
409+ }));
410+}
411+
412+export function renderHistory(incidents: StatusIncident[], maintenance: StatusMaintenance[], options: PageOptions, months = 12): string {
413+ const groups = byMonth(incidents, maintenance);
414+ const now = options.now;
415+ const list: string[] = [];
416+ for (let i = 0; i < months; i++) {
417+ const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1));
418+ const key = d.toISOString().slice(0, 7);
419+ const group = groups.find((g) => g.month === key);
420+ const items = [
421+ ...(group?.incidents.map(incidentLine) ?? []),
422+ ...(group?.maintenance.map(
423+ (m) =>
424+ `<li class="line"><div class="line-head"><a href="/maintenance/${escape(m.id)}">${escape(m.title)}</a><span class="tag">Maintenance · ${MAINTENANCE_WORD[maintenanceState(m, now)]}</span></div><p class="meta">${maintenanceWindow(m)}</p></li>`,
425+ ) ?? []),
426+ ];
427+ list.push(`<section class="month"><h2>${MONTH_NAMES[d.getUTCMonth()]} ${d.getUTCFullYear()}</h2>${items.length ? `<ul class="lines">${items.join("")}</ul>` : `<p class="none">No incidents or maintenance.</p>`}</section>`);
428+ }
429+ const body = `<p class="crumbs"><a href="/">Status</a></p>
430+<header class="page-head"><h1>History</h1></header>
431+<p class="muted">Every incident and planned maintenance of the last ${months} months, newest first.</p>
432+${list.join("")}`;
433+ return layout(options, { title: "History · g1t status", description: "Past incidents and maintenance on g1t, by month.", body });
434+}
435+
436+export function renderSubscribe(options: PageOptions, parts: { key: string; name: string }[]): string {
437+ const form = options.email
438+ ? `<form class="stack" method="post" action="/subscribe">
439+<label for="email">Email address</label>
440+<input id="email" type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com">
441+<input class="trap" type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
442+<fieldset>
443+<legend>Which parts</legend>
444+<p class="muted">Leave every box clear to hear about everything.</p>
445+<div class="checks">${parts.map((p) => `<label class="check"><input type="checkbox" name="components" value="${escape(p.key)}"> ${escape(p.name)}</label>`).join("")}</div>
446+</fieldset>
447+<button type="submit">Subscribe</button>
448+<p class="muted">We email you a link to confirm first. Every email has a link to unsubscribe. Your address is used for nothing else.</p>
449+</form>`
450+ : `<p class="muted">Email updates are not available on this installation.</p>`;
451+ const body = `<p class="crumbs"><a href="/">Status</a></p>
452+<header class="page-head"><h1>Subscribe to updates</h1></header>
453+<div class="card">${form}</div>
454+<section class="block"><h2>Feeds</h2><div class="card"><p class="muted">Every public update and maintenance notice, newest first: the <a href="/feed.xml">Atom feed</a> for feed readers and chat apps, the <a href="/feed.json">JSON feed</a>, and <a href="/status.json">status.json</a> for the state right now.</p></div></section>`;
455+ return layout(options, { title: "Subscribe · g1t status", description: "Get an email when g1t posts an incident or planned maintenance.", body });
456+}
457+
458+/** A short page: a heading, a line, and perhaps a form with one button. */
459+export function renderMessage(
460+ options: PageOptions,
461+ input: { title: string; text: string; form?: { action: string; fields: Record<string, string>; button: string } },
462+): string {
463+ const form = input.form
464+ ? `<form method="post" action="${escape(input.form.action)}">${Object.entries(input.form.fields)
465+ .map(([k, v]) => `<input type="hidden" name="${escape(k)}" value="${escape(v)}">`)
466+ .join("")}<button type="submit">${escape(input.form.button)}</button></form>`
467+ : `<p><a href="/">Back to the status page</a></p>`;
468+ const body = `<p class="crumbs"><a href="/">Status</a></p>
469+<section class="card message"><h1>${escape(input.title)}</h1><p>${escape(input.text)}</p>${form}</section>`;
470+ return layout(options, { title: `${input.title} · g1t status`, description: input.text, body });
471+}
472+
473+/** A small badge: "g1t | all systems normal", coloured by state. */
474+export function renderBadge(state: StatusOverallState, title: string): string {
475+ const color = { up: "#86efc4", degraded: "#ffbd8c", down: "#ff8394", maintenance: "#8ec5ff", unknown: "#86868e" }[state];
476+ const label = "g1t";
477+ const value = title.toLowerCase();
478+ // Widths from an average glyph of 6.5px at 11px: close enough to centre.
479+ const left = Math.round(label.length * 6.5 + 14);
480+ const right = Math.round(value.length * 6.2 + 14);
481+ const width = left + right;
482+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${escape(`${label}: ${value}`)}">
483+<title>${escape(`${label}: ${value}`)}</title>
484+<clipPath id="r"><rect width="${width}" height="20" rx="4"/></clipPath>
485+<g clip-path="url(#r)"><rect width="${left}" height="20" fill="#1e1e21"/><rect x="${left}" width="${right}" height="20" fill="${color}"/></g>
486+<g font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle">
487+<text x="${left / 2}" y="14" fill="#ededef">${escape(label)}</text>
488+<text x="${left + right / 2}" y="14" fill="#0f0f11">${escape(value)}</text>
489+</g></svg>`;
490+}
491+
492+/** The page's script: day detail on hover, tap and arrow keys; times in the reader's words. */
493+export const SCRIPT = `(() => {
494+ const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
495+ const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
496+ function ago(at) {
497+ const s = (Date.parse(at) - Date.now()) / 1000;
498+ const a = Math.abs(s);
499+ if (a < 45) return rtf.format(Math.round(s), "second");
500+ if (a < 2700) return rtf.format(Math.round(s / 60), "minute");
501+ if (a < 79200) return rtf.format(Math.round(s / 3600), "hour");
502+ return rtf.format(Math.round(s / 86400), "day");
503+ }
504+ function times() {
505+ for (const t of document.querySelectorAll("time[datetime]")) {
506+ const at = t.getAttribute("datetime");
507+ t.title = fmt.format(new Date(at));
508+ t.textContent = t.hasAttribute("data-relative") ? ago(at) : fmt.format(new Date(at));
509+ }
510+ }
511+ times();
512+ setInterval(times, 30000);
513+ for (const bar of document.querySelectorAll(".bar")) {
514+ const detail = bar.parentElement.querySelector(".detail");
515+ const days = [...bar.children];
516+ let at = -1;
517+ function show(i) {
518+ days.forEach((d, j) => d.classList.toggle("on", j === i));
519+ at = i;
520+ detail.textContent = i >= 0 ? days[i].dataset.detail : "";
521+ }
522+ bar.addEventListener("pointerover", (e) => { const i = days.indexOf(e.target); if (i >= 0) show(i); });
523+ bar.addEventListener("click", (e) => { const i = days.indexOf(e.target); if (i >= 0) show(i); });
524+ bar.addEventListener("pointerleave", (e) => { if (e.pointerType === "mouse" && document.activeElement !== bar) show(-1); });
525+ bar.addEventListener("blur", () => show(-1));
526+ bar.addEventListener("keydown", (e) => {
527+ const visible = days.filter((d) => d.offsetParent !== null);
528+ const first = days.indexOf(visible[0]);
529+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
530+ e.preventDefault();
531+ const next = at < 0 ? days.length - 1 : at + (e.key === "ArrowLeft" ? -1 : 1);
532+ show(Math.min(days.length - 1, Math.max(first, next)));
533+ } else if (e.key === "Home") { e.preventDefault(); show(first); }
534+ else if (e.key === "End") { e.preventDefault(); show(days.length - 1); }
535+ });
536+ bar.addEventListener("focus", () => { if (at < 0) show(days.length - 1); });
537+ }
538+ // Fresh numbers without a reload, while the front page is in view and no one is typing.
539+ if (document.body.hasAttribute("data-live")) {
540+ setInterval(() => {
541+ const busy = document.querySelector(".bar:focus") || (document.activeElement && document.activeElement.tagName === "INPUT");
542+ if (document.visibilityState === "visible" && !busy) location.reload();
543+ }, 120000);
544+ }
545+})();
546+`;
547+
548+const STYLE = `
549+@font-face{font-family:"Hanken Grotesk";font-weight:400 700;font-display:swap;src:url(/fonts/hanken-grotesk.woff2) format("woff2")}
550+@font-face{font-family:"Bricolage Grotesque";font-weight:500 700;font-display:swap;src:url(/fonts/bricolage-grotesque.woff2) format("woff2")}
551+@font-face{font-family:"IBM Plex Mono";font-weight:400;font-display:swap;src:url(/fonts/ibm-plex-mono.woff2) format("woff2")}
552+:root{--bg:#0f0f11;--surface:#161618;--raised:#1e1e21;--line:#28282c;--line-strong:#38383e;--fg:#ededef;--soft:#dcdce0;--muted:#a0a0a8;--faint:#86868e;
553+--up:#86efc4;--warn:#ffbd8c;--down:#ff8394;--lav:#b6a8ff;--info:#8ec5ff;
554+--sans:"Hanken Grotesk",ui-sans-serif,system-ui,sans-serif;--display:"Bricolage Grotesque",ui-sans-serif,system-ui,sans-serif;--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color-scheme:dark}
555+*{box-sizing:border-box}
556+html{-webkit-text-size-adjust:100%}
557+body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 var(--sans);-webkit-font-smoothing:antialiased}
558+a{color:inherit}
559+a:focus-visible,.bar:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--lav);outline-offset:3px;border-radius:4px}
560+.page{max-width:760px;margin:0 auto;padding:0 16px 48px}
561+.top{display:flex;align-items:center;justify-content:space-between;gap:16px;height:72px;border-bottom:1px solid var(--line)}
562+.brand{display:flex;align-items:center;gap:12px;text-decoration:none}
563+.brand span{font:600 20px/1 var(--display);letter-spacing:-.01em;color:var(--muted);padding-left:12px;border-left:1px solid var(--line-strong)}
564+.logo{height:22px;width:auto;display:block}.logo rect{fill:var(--fg)}.logo rect.one{fill:var(--lav)}
565+.out{font-size:13px;color:var(--muted);text-decoration:none}.out:hover{color:var(--fg)}
566+.banner{display:flex;gap:14px;align-items:flex-start;margin-top:32px;padding:20px 22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
567+.banner.up{border-color:rgba(134,239,196,.28);background:linear-gradient(0deg,rgba(134,239,196,.04),rgba(134,239,196,.04)),var(--surface)}
568+.banner.degraded{border-color:rgba(255,189,140,.4);background:linear-gradient(0deg,rgba(255,189,140,.06),rgba(255,189,140,.06)),var(--surface)}
569+.banner.down{border-color:rgba(255,131,148,.45);background:linear-gradient(0deg,rgba(255,131,148,.07),rgba(255,131,148,.07)),var(--surface)}
570+.banner.maintenance{border-color:rgba(142,197,255,.4);background:linear-gradient(0deg,rgba(142,197,255,.06),rgba(142,197,255,.06)),var(--surface)}
571+.banner h1{margin:0;font:600 26px/1.2 var(--display);letter-spacing:-.02em}
572+.banner p{margin:6px 0 0;color:var(--muted);font-size:14px}
573+.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--faint);flex:none}
574+.dot.big{width:12px;height:12px;margin-top:9px;box-shadow:0 0 0 4px rgba(134,239,196,.12)}
575+.dot.up{background:var(--up)}.dot.degraded,.dot.partial{background:var(--warn)}.dot.down{background:var(--down)}.dot.maintenance{background:var(--info)}.dot.unmonitored,.dot.unknown{background:var(--faint)}
576+.dot.big.degraded{box-shadow:0 0 0 4px rgba(255,189,140,.14)}.dot.big.down{box-shadow:0 0 0 4px rgba(255,131,148,.16)}.dot.big.maintenance{box-shadow:0 0 0 4px rgba(142,197,255,.14)}.dot.big.unknown{box-shadow:none}
577+.block{margin-top:40px}
578+.block h2,.month h2{margin:0 0 12px;font:600 13px/1.4 var(--sans);color:var(--muted);letter-spacing:.02em}
579+.block-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
580+.block-head a{font-size:13px;color:var(--muted);text-decoration:none}.block-head a:hover{color:var(--fg)}
581+.components{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
582+.component{padding:16px 18px 14px}
583+.component+.component{border-top:1px solid var(--line)}
584+.row{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
585+.name{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px;min-width:0}
586+.name h3{margin:0;font-size:15px;font-weight:600}
587+.address{font:12px/1.4 var(--mono);color:var(--faint);overflow-wrap:anywhere}
588+.state{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:500;white-space:nowrap}
589+.s-up{color:var(--up)}.s-degraded,.s-partial{color:var(--warn)}.s-down{color:var(--down)}.s-maintenance{color:var(--info)}.s-unmonitored{color:var(--faint)}
590+.seen{margin:2px 0 0;font-size:12px;color:var(--faint)}
591+.unchecked{margin:8px 0 2px;font-size:13px;color:var(--faint)}
592+.history{margin-top:12px}
593+.bar{list-style:none;margin:0;padding:0;display:flex;gap:2px;height:30px}
594+.bar li{flex:1 1 0;min-width:0;border-radius:2px;background:var(--line);transition:opacity .12s}
595+.bar li.up{background:var(--up);opacity:.85}.bar li.degraded{background:var(--warn)}.bar li.down{background:var(--down)}
596+.bar:hover li,.bar:focus li{opacity:.55}.bar li.on,.bar:hover li:hover{opacity:1;transform:scaleY(1.08)}
597+.legend{display:flex;justify-content:space-between;margin-top:6px;font-size:12px;color:var(--faint)}
598+.legend .uptime{color:var(--muted)}
599+.legend .short{display:none}
600+.detail{margin:4px 0 0;min-height:18px;font-size:12px;color:var(--soft)}
601+.incident,.card{border:1px solid var(--line);border-radius:14px;background:var(--surface);padding:16px 18px;margin-bottom:12px}
602+.incident.open.degraded{border-color:rgba(255,189,140,.4)}.incident.open.down{border-color:rgba(255,131,148,.45)}.incident.maint{border-color:rgba(142,197,255,.35)}
603+.incident header,.page-head{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:6px 12px}
604+.incident h3{margin:0;font-size:15px;font-weight:600}
605+.incident h3 a,.line-head a{text-decoration:none}.incident h3 a:hover,.line-head a:hover{text-decoration:underline;text-underline-offset:3px}
606+.page-head{margin-top:20px}
607+.page-head h1{margin:0;font:600 24px/1.25 var(--display);letter-spacing:-.02em;overflow-wrap:anywhere}
608+.crumbs{margin:24px 0 0;font-size:13px;color:var(--faint)}.crumbs a{color:var(--muted);text-decoration:none}.crumbs a:hover{color:var(--fg)}
609+.pill{font-size:12px;font-weight:500;padding:1px 8px;border-radius:999px;border:1px solid var(--line-strong);color:var(--muted);white-space:nowrap}
610+.pill.degraded{color:var(--warn);border-color:rgba(255,189,140,.4)}.pill.down{color:var(--down);border-color:rgba(255,131,148,.45)}.pill.maint{color:var(--info);border-color:rgba(142,197,255,.4)}
611+.meta{margin:4px 0 0;font-size:12px;color:var(--faint)}
612+.impacts{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:6px}
613+.affects{font-size:12px;color:var(--muted)}
614+.impact{margin-left:4px;font-weight:500}.i-degraded,.i-partial_outage{color:var(--warn)}.i-major_outage{color:var(--down)}.i-operational{color:var(--up)}
615+.updates{list-style:none;margin:12px 0 0;padding:0 0 0 14px;border-left:1px solid var(--line-strong)}
616+.card>.updates{margin-top:0}
617+.updates li+li{margin-top:12px}
618+.when{margin:0;font-size:12px;color:var(--faint)}.when.latest{margin-top:8px}.when strong{color:var(--soft);font-weight:600;margin-right:4px}
619+.text p,.text li{margin:4px 0 0;font-size:14px;color:var(--soft)}
620+.text ul{margin:4px 0 0;padding-left:20px}
621+.empty{margin:0;color:var(--muted);font-size:14px;padding:16px 18px;border:1px dashed var(--line-strong);border-radius:14px}
622+.muted{color:var(--muted);font-size:14px}
623+.note{margin:14px 0 0;font-size:14px;color:var(--soft);padding:10px 14px;border:1px solid rgba(182,168,255,.35);border-radius:10px;background:rgba(182,168,255,.06)}
624+.lines{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
625+.line{padding:12px 18px}.line+.line{border-top:1px solid var(--line)}
626+.line-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;font-weight:600;font-size:15px}
627+.tag{font-size:12px;font-weight:500;color:var(--lav);text-decoration:none;white-space:nowrap}
628+.last{margin:4px 0 0;font-size:13px;color:var(--muted)}
629+.month{margin-top:28px}.month:has(.none){margin-top:18px}.month:has(.none) h2{margin-bottom:2px}
630+.postmortem .pm-section+.pm-section{margin-top:18px}
631+.pm-section h3{margin:0;font-size:15px;font-weight:600}
632+form.subscribe{display:flex;gap:8px;flex-wrap:wrap}
633+input[type=email]{flex:1 1 220px;min-width:0;font:inherit;font-size:14px;color:var(--fg);background:var(--bg);border:1px solid var(--line-strong);border-radius:8px;padding:9px 12px}
634+input[type=email]::placeholder{color:var(--faint)}
635+button{font:inherit;font-size:14px;font-weight:600;color:var(--bg);background:var(--lav);border:0;border-radius:8px;padding:9px 16px;cursor:pointer}
636+button:hover{background:#c8bdff}
637+.trap{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}
638+.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
639+.feeds{margin:10px 0 0;font-size:13px;color:var(--faint)}.feeds a,.muted a,.note a{color:var(--soft)}
640+.stack{display:flex;flex-direction:column;gap:10px}.stack label{font-size:13px;color:var(--muted)}
641+.stack button{align-self:flex-start}.stack input[type=email]{flex:none}
642+.page-head+.card,.page-head+.muted+.card{margin-top:16px}
643+.month .none{margin:0;font-size:13px;color:var(--faint)}
644+fieldset{border:0;padding:0;margin:6px 0 0}legend{font-size:13px;color:var(--muted);padding:0}
645+fieldset .muted{margin:2px 0 8px;font-size:13px}
646+.checks{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:6px}
647+.check{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--soft)!important;padding:6px 10px;border:1px solid var(--line);border-radius:8px}
648+.check input{accent-color:var(--lav)}
649+.message{margin-top:24px}.message h1{margin:0;font:600 22px/1.25 var(--display)}.message p{color:var(--muted);font-size:14px}
650+.about p{margin:0;font-size:13px;color:var(--muted);max-width:68ch}
651+.about dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:16px 0 0;font-size:13px}
652+.about dt{color:var(--soft)}.about dd{margin:0;color:var(--faint)}
653+.foot{display:flex;flex-wrap:wrap;gap:8px;margin-top:48px;padding-top:20px;border-top:1px solid var(--line);font-size:13px;color:var(--faint)}
654+.foot a{text-decoration:none;color:var(--muted)}.foot a:hover{color:var(--fg)}
655+@media (max-width:640px){
656+.bar li:nth-child(-n+${HISTORY_DAYS - 30}){display:none}
657+.bar{gap:3px}
658+.legend .long{display:none}.legend .short{display:inline}
659+.banner{padding:16px}.banner h1{font-size:22px}
660+.component,.incident,.card,.line{padding:14px}
661+.about dl{grid-template-columns:1fr;gap:2px}.about dd{margin-bottom:8px}
662+}
663+@media (prefers-reduced-motion:reduce){.bar li{transition:none}}
664+`;
+749−0
1+/**
2+ * The status page's D1 database: each part's last check and daily tally
3+ * (migrations/0001), and incidents, maintenance, postmortems, subscribers,
4+ * detection and the audit log (migrations/0002).
5+ */
6+import type {
7+ AdminIncident,
8+ AdminIncidentDetail,
9+ AdminMaintenance,
10+ ComponentImpact,
11+ FollowUp,
12+ ImpactInput,
13+ IncidentSeverity,
14+ IncidentStatus,
15+ IncidentVisibility,
16+ MaintenanceState,
17+ Postmortem,
18+ PostmortemFields,
19+ StatusAuditEntry,
20+ StatusIncident,
21+ StatusMaintenance,
22+ TimelineEntry,
23+ TimelineKind,
24+} from "@g1t/contracts";
25+
26+import type { Streak } from "./detect.ts";
27+import { type Entry, type IncidentFacts, durations, shortId } from "./incidents.ts";
28+import { type Current, type DayRow, HISTORY_DAYS, dayOf, overallImpact } from "./model.ts";
29+import { postmortemDraft } from "./postmortem.ts";
30+import { parseParts, wants } from "./subscribers.ts";
31+
32+const DAY_MS = 24 * 60 * 60 * 1000;
33+const iso = (ms: number) => new Date(ms).toISOString();
34+
35+/** One part's check, ready to keep. */
36+export type Observation = Current & { component: string };
37+
38+/**
39+ * Keeps one round of checks: each part's state, today's tally, and the
40+ * time. Parts under maintenance keep their last check but are left out of
41+ * the tally: failures in a planned window do not count against uptime.
42+ */
43+export async function record(db: D1Database, observations: Observation[], at: Date, maintenance: Set<string> = new Set()): Promise<void> {
44+ const when = at.toISOString();
45+ const day = dayOf(at);
46+ const oldest = dayOf(at.getTime() - HISTORY_DAYS * DAY_MS);
47+ const statements: D1PreparedStatement[] = [];
48+ for (const o of observations) {
49+ statements.push(
50+ db
51+ .prepare(
52+ `INSERT INTO current (component, state, detail, latency_ms, checked_at) VALUES (?1, ?2, ?3, ?4, ?5)
53+ ON CONFLICT (component) DO UPDATE SET state = ?2, detail = ?3, latency_ms = ?4, checked_at = ?5`,
54+ )
55+ .bind(o.component, o.state, o.detail, o.latency_ms, when),
56+ );
57+ if (o.state === "unmonitored" || maintenance.has(o.component)) continue;
58+ const up = o.state === "up" ? 1 : 0;
59+ const degraded = o.state === "degraded" ? 1 : 0;
60+ const down = o.state === "down" ? 1 : 0;
61+ // A latency only counts when it answered: a timeout is not a speed.
62+ const latency = o.state !== "down" && o.latency_ms != null ? o.latency_ms : 0;
63+ const counted = o.state !== "down" && o.latency_ms != null ? 1 : 0;
64+ statements.push(
65+ db
66+ .prepare(
67+ `INSERT INTO daily (component, day, checks, up, degraded, down, latency_total, latency_count)
68+ VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7)
69+ ON CONFLICT (component, day) DO UPDATE SET
70+ checks = checks + 1, up = up + ?3, degraded = degraded + ?4, down = down + ?5,
71+ latency_total = latency_total + ?6, latency_count = latency_count + ?7`,
72+ )
73+ .bind(o.component, day, up, degraded, down, latency, counted),
74+ );
75+ }
76+ statements.push(
77+ db.prepare(`INSERT INTO meta (key, value) VALUES ('checked_at', ?1) ON CONFLICT (key) DO UPDATE SET value = ?1`).bind(when),
78+ );
79+ statements.push(db.prepare(`DELETE FROM daily WHERE day < ?1`).bind(oldest));
80+ await db.batch(statements);
81+}
82+
83+// --- Rows ------------------------------------------------------------------------
84+
85+type IncidentRow = {
86+ id: string;
87+ title: string;
88+ severity: IncidentSeverity;
89+ status: IncidentStatus;
90+ visibility: IncidentVisibility;
91+ source: "declared" | "detected";
92+ started_at: string;
93+ declared_at: string;
94+ acknowledged_at: string | null;
95+ mitigated_at: string | null;
96+ resolved_at: string | null;
97+ published_at: string | null;
98+ commander: string | null;
99+ communications: string | null;
100+ created_by: string;
101+};
102+type ComponentRow = { incident_id: string; component: string; impact: ComponentImpact };
103+type TimelineRow = {
104+ id: string;
105+ incident_id: string;
106+ at: string;
107+ by: string;
108+ kind: TimelineKind;
109+ public: number;
110+ status: IncidentStatus | null;
111+ text: string;
112+ notified: number | null;
113+};
114+type FollowUpRow = FollowUp & { incident_id: string };
115+type PostmortemRow = PostmortemFields & { incident_id: string; updated_at: string; updated_by: string; published_at: string | null };
116+type MaintenanceRow = {
117+ id: string;
118+ title: string;
119+ message: string;
120+ components: string;
121+ starts_at: string;
122+ ends_at: string;
123+ state: MaintenanceState;
124+ notify: number;
125+ created_at: string;
126+ created_by: string;
127+};
128+type MaintenanceUpdateRow = { id: string; maintenance_id: string; at: string; by: string; text: string; notified: number | null };
129+
130+function keys(json: string): string[] {
131+ try {
132+ const value = JSON.parse(json);
133+ return Array.isArray(value) ? value.map(String) : [];
134+ } catch {
135+ return [];
136+ }
137+}
138+
139+const rows = <T>(result: D1Result | undefined) => (result?.results ?? []) as T[];
140+
141+/** `in (?, ?, ?)` for a list, as bind arguments. */
142+function inList(ids: string[]): string {
143+ return ids.map(() => "?").join(", ") || "NULL";
144+}
145+
146+export function incidentUrl(origin: string, id: string): string {
147+ return `${origin}/incidents/${id}`;
148+}
149+
150+export function maintenanceUrl(origin: string, id: string): string {
151+ return `${origin}/maintenance/${id}`;
152+}
153+
154+function components(id: string, all: ComponentRow[]): ImpactInput[] {
155+ return all.filter((c) => c.incident_id === id).map((c) => ({ key: c.component, impact: c.impact }));
156+}
157+
158+function toAdmin(row: IncidentRow, comps: ComponentRow[], timeline: TimelineRow[], followups: FollowUpRow[], pm: PostmortemRow | null): AdminIncident {
159+ const mine = followups.filter((f) => f.incident_id === row.id);
160+ const lastUpdate = timeline.filter((t) => t.incident_id === row.id && t.public === 1).reduce<string | null>((a, t) => (a && a > t.at ? a : t.at), null);
161+ return {
162+ id: row.id,
163+ title: row.title,
164+ severity: row.severity,
165+ status: row.status,
166+ visibility: row.visibility,
167+ source: row.source,
168+ components: components(row.id, comps),
169+ started_at: row.started_at,
170+ declared_at: row.declared_at,
171+ acknowledged_at: row.acknowledged_at,
172+ mitigated_at: row.mitigated_at,
173+ resolved_at: row.resolved_at,
174+ published_at: row.published_at,
175+ commander: row.commander,
176+ communications: row.communications,
177+ created_by: row.created_by,
178+ postmortem_published_at: pm?.published_at ?? null,
179+ durations: durations(row),
180+ followups_open: mine.filter((f) => !f.done_at).length,
181+ followups_done: mine.filter((f) => f.done_at).length,
182+ last_update_at: lastUpdate,
183+ };
184+}
185+
186+function toPublic(row: IncidentRow, comps: ComponentRow[], timeline: TimelineRow[], origin: string, pmPublished: string | null): StatusIncident {
187+ const impacts = components(row.id, comps).filter((c) => c.impact !== "operational");
188+ return {
189+ id: row.id,
190+ title: row.title,
191+ impact: overallImpact(impacts),
192+ status: row.status,
193+ components: impacts.map((c) => c.key),
194+ component_impacts: impacts,
195+ started_at: row.started_at,
196+ resolved_at: row.resolved_at,
197+ url: incidentUrl(origin, row.id),
198+ postmortem_published_at: pmPublished,
199+ updates: timeline
200+ .filter((t) => t.incident_id === row.id && t.public === 1)
201+ .sort((a, b) => b.at.localeCompare(a.at))
202+ .map((t) => ({ id: t.id, at: t.at, status: t.status ?? row.status, text: t.text })),
203+ };
204+}
205+
206+function toTimeline(t: TimelineRow): TimelineEntry {
207+ return { id: t.id, at: t.at, by: t.by, kind: t.kind, public: t.public === 1, status: t.status, text: t.text, notified: t.notified };
208+}
209+
210+function toMaintenance(row: MaintenanceRow, updates: MaintenanceUpdateRow[], origin: string): AdminMaintenance {
211+ return {
212+ id: row.id,
213+ title: row.title,
214+ message: row.message,
215+ components: keys(row.components),
216+ starts_at: row.starts_at,
217+ ends_at: row.ends_at,
218+ state: row.state,
219+ url: maintenanceUrl(origin, row.id),
220+ updates: updates
221+ .filter((u) => u.maintenance_id === row.id)
222+ .sort((a, b) => b.at.localeCompare(a.at))
223+ .map((u) => ({ id: u.id, at: u.at, text: u.text })),
224+ created_at: row.created_at,
225+ created_by: row.created_by,
226+ };
227+}
228+
229+/** The public view of maintenance: no staff emails. */
230+export function publicMaintenance(m: AdminMaintenance): StatusMaintenance {
231+ const { created_at: _a, created_by: _b, ...rest } = m;
232+ return rest;
233+}
234+
235+// --- Public reads ------------------------------------------------------------------
236+
237+/** Public incidents matching `where`, with their parts and public updates. */
238+async function publicIncidents(db: D1Database, where: string, args: unknown[], origin: string): Promise<StatusIncident[]> {
239+ const list = rows<IncidentRow>(
240+ (await db.prepare(`SELECT * FROM incident WHERE visibility = 'public' AND (${where}) ORDER BY started_at DESC LIMIT 500`).bind(...args).all()) as D1Result,
241+ );
242+ if (!list.length) return [];
243+ const ids = list.map((i) => i.id);
244+ const [comps, timeline, pms] = await db.batch([
245+ db.prepare(`SELECT * FROM incident_component WHERE incident_id IN (${inList(ids)})`).bind(...ids),
246+ db.prepare(`SELECT * FROM incident_timeline WHERE public = 1 AND incident_id IN (${inList(ids)})`).bind(...ids),
247+ db.prepare(`SELECT incident_id, published_at FROM postmortem WHERE published_at IS NOT NULL AND incident_id IN (${inList(ids)})`).bind(...ids),
248+ ]);
249+ const published = new Map(rows<{ incident_id: string; published_at: string }>(pms).map((p) => [p.incident_id, p.published_at]));
250+ return list.map((row) => toPublic(row, rows(comps), rows(timeline), origin, published.get(row.id) ?? null));
251+}
252+
253+async function maintenanceWhere(db: D1Database, where: string, args: unknown[], origin: string): Promise<AdminMaintenance[]> {
254+ const list = rows<MaintenanceRow>((await db.prepare(`SELECT * FROM maintenance WHERE ${where} ORDER BY starts_at DESC LIMIT 500`).bind(...args).all()) as D1Result);
255+ if (!list.length) return [];
256+ const ids = list.map((m) => m.id);
257+ const updates = rows<MaintenanceUpdateRow>(
258+ (await db.prepare(`SELECT * FROM maintenance_update WHERE maintenance_id IN (${inList(ids)})`).bind(...ids).all()) as D1Result,
259+ );
260+ return list.map((m) => toMaintenance(m, updates, origin));
261+}
262+
263+/** Everything the front page reads. */
264+export async function load(db: D1Database, now: Date, origin: string) {
265+ const since = dayOf(now.getTime() - HISTORY_DAYS * DAY_MS);
266+ const [current, meta, days] = await db.batch([
267+ db.prepare(`SELECT component, state, detail, latency_ms FROM current`),
268+ db.prepare(`SELECT value FROM meta WHERE key = 'checked_at'`),
269+ db.prepare(`SELECT * FROM daily WHERE day >= ?1`).bind(since),
270+ ]);
271+ const [incidents, maintenance] = await Promise.all([
272+ publicIncidents(db, `resolved_at IS NULL OR resolved_at >= ?1`, [iso(now.getTime() - HISTORY_DAYS * DAY_MS)], origin),
273+ maintenanceWhere(db, `state IN ('scheduled', 'in_progress')`, [], origin),
274+ ]);
275+ const currentRows = rows<Current & { component: string }>(current);
276+ return {
277+ current: new Map(currentRows.map((row) => [row.component, { state: row.state, detail: row.detail, latency_ms: row.latency_ms }])),
278+ checkedAt: (rows<{ value?: string }>(meta)[0]?.value ?? null) as string | null,
279+ days: rows<DayRow>(days),
280+ incidents,
281+ maintenance: maintenance.map(publicMaintenance),
282+ };
283+}
284+
285+/** One public incident, with its postmortem once published. */
286+export async function loadPublicIncident(db: D1Database, id: string, origin: string) {
287+ const [incident] = await publicIncidents(db, `id = ?1`, [id], origin);
288+ if (!incident) return null;
289+ const pm = (await db.prepare(`SELECT * FROM postmortem WHERE incident_id = ?1 AND published_at IS NOT NULL`).bind(id).first()) as PostmortemRow | null;
290+ return { incident, postmortem: pm };
291+}
292+
293+export async function loadPublicMaintenance(db: D1Database, id: string, origin: string): Promise<StatusMaintenance | null> {
294+ const [m] = await maintenanceWhere(db, `id = ?1`, [id], origin);
295+ return m ? publicMaintenance(m) : null;
296+}
297+
298+/** Incidents and finished maintenance since `since`, for the history page and the feeds. */
299+export async function loadHistory(db: D1Database, since: Date, origin: string) {
300+ const at = since.toISOString();
301+ const [incidents, maintenance] = await Promise.all([
302+ publicIncidents(db, `started_at >= ?1 OR resolved_at IS NULL`, [at], origin),
303+ maintenanceWhere(db, `starts_at >= ?1 OR state IN ('scheduled', 'in_progress')`, [at], origin),
304+ ]);
305+ return { incidents, maintenance: maintenance.map(publicMaintenance) };
306+}
307+
308+// --- Staff reads -------------------------------------------------------------------
309+
310+async function adminIncidents(db: D1Database, where: string, args: unknown[]): Promise<{ list: IncidentRow[]; comps: ComponentRow[]; timeline: TimelineRow[]; followups: FollowUpRow[]; pms: PostmortemRow[] }> {
311+ const list = rows<IncidentRow>((await db.prepare(`SELECT * FROM incident WHERE ${where} ORDER BY started_at DESC LIMIT 500`).bind(...args).all()) as D1Result);
312+ if (!list.length) return { list, comps: [], timeline: [], followups: [], pms: [] };
313+ const ids = list.map((i) => i.id);
314+ const [comps, timeline, followups, pms] = await db.batch([
315+ db.prepare(`SELECT * FROM incident_component WHERE incident_id IN (${inList(ids)})`).bind(...ids),
316+ db.prepare(`SELECT * FROM incident_timeline WHERE incident_id IN (${inList(ids)}) ORDER BY at, rowid`).bind(...ids),
317+ db.prepare(`SELECT * FROM incident_followup WHERE incident_id IN (${inList(ids)}) ORDER BY created_at, rowid`).bind(...ids),
318+ db.prepare(`SELECT * FROM postmortem WHERE incident_id IN (${inList(ids)})`).bind(...ids),
319+ ]);
320+ return { list, comps: rows(comps), timeline: rows(timeline), followups: rows(followups), pms: rows(pms) };
321+}
322+
323+/** Sudo's board: open and draft incidents, the last 180 days, maintenance, and subscribers. */
324+export async function board(db: D1Database, now: Date, origin: string) {
325+ const since = iso(now.getTime() - 180 * DAY_MS);
326+ const [data, maintenance, count] = await Promise.all([
327+ adminIncidents(db, `resolved_at IS NULL OR resolved_at >= ?1`, [since]),
328+ maintenanceWhere(db, `state IN ('scheduled', 'in_progress') OR ends_at >= ?1`, [iso(now.getTime() - HISTORY_DAYS * DAY_MS)], origin),
329+ db.prepare(`SELECT COUNT(*) AS n FROM subscriber WHERE confirmed_at IS NOT NULL`).first<{ n: number }>(),
330+ ]);
331+ return {
332+ incidents: data.list.map((row) => toAdmin(row, data.comps, data.timeline, data.followups, data.pms.find((p) => p.incident_id === row.id) ?? null)),
333+ maintenance,
334+ subscribers: count?.n ?? 0,
335+ };
336+}
337+
338+export async function openCount(db: D1Database): Promise<number> {
339+ const row = await db.prepare(`SELECT COUNT(*) AS n FROM incident WHERE resolved_at IS NULL AND visibility IN ('draft', 'public')`).first<{ n: number }>();
340+ return row?.n ?? 0;
341+}
342+
343+export async function incidentDetail(db: D1Database, id: string, origin: string, names: Map<string, string>): Promise<AdminIncidentDetail | null> {
344+ const data = await adminIncidents(db, `id = ?1`, [id]);
345+ const row = data.list[0];
346+ if (!row) return null;
347+ const pmRow = data.pms[0] ?? null;
348+ const incident = toAdmin(row, data.comps, data.timeline, data.followups, pmRow);
349+ const timeline = data.timeline.map(toTimeline);
350+ const followups: FollowUp[] = data.followups.map(({ incident_id: _, ...f }) => ({
351+ id: f.id,
352+ title: f.title,
353+ owner: f.owner,
354+ done_at: f.done_at,
355+ created_at: f.created_at,
356+ created_by: f.created_by,
357+ }));
358+ const postmortem: Postmortem | null = pmRow
359+ ? {
360+ summary: pmRow.summary,
361+ impact: pmRow.impact,
362+ timeline: pmRow.timeline,
363+ root_cause: pmRow.root_cause,
364+ went_well: pmRow.went_well,
365+ went_badly: pmRow.went_badly,
366+ action_items: pmRow.action_items,
367+ updated_at: pmRow.updated_at,
368+ updated_by: pmRow.updated_by,
369+ published_at: pmRow.published_at,
370+ }
371+ : null;
372+ return {
373+ ...incident,
374+ timeline,
375+ followups,
376+ postmortem,
377+ postmortem_draft: postmortemDraft(incident, timeline, followups, names),
378+ url: incidentUrl(origin, id),
379+ };
380+}
381+
382+export function facts(i: AdminIncident): IncidentFacts {
383+ return {
384+ status: i.status,
385+ severity: i.severity,
386+ visibility: i.visibility,
387+ components: i.components,
388+ started_at: i.started_at,
389+ acknowledged_at: i.acknowledged_at,
390+ mitigated_at: i.mitigated_at,
391+ resolved_at: i.resolved_at,
392+ commander: i.commander,
393+ communications: i.communications,
394+ };
395+}
396+
397+export async function maintenanceById(db: D1Database, id: string, origin: string): Promise<AdminMaintenance | null> {
398+ return (await maintenanceWhere(db, `id = ?1`, [id], origin))[0] ?? null;
399+}
400+
401+// --- Writes ------------------------------------------------------------------------
402+
403+export function auditStatement(db: D1Database, at: string, by: string, action: string, target: string, detail: string): D1PreparedStatement {
404+ return db
405+ .prepare(`INSERT INTO audit (id, at, by, action, target, detail) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`)
406+ .bind(crypto.randomUUID(), at, by, action, target, detail.slice(0, 500));
407+}
408+
409+/** Timeline lines, written a millisecond apart so their order survives. */
410+function timelineStatements(db: D1Database, incident: string, entries: (Entry & { notified?: number | null })[], at: Date, by: string): D1PreparedStatement[] {
411+ return entries.map((e, i) =>
412+ db
413+ .prepare(`INSERT INTO incident_timeline (id, incident_id, at, by, kind, public, status, text, notified) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`)
414+ .bind(shortId(), incident, iso(at.getTime() + i), by, e.kind, e.public ? 1 : 0, e.status, e.text, e.notified ?? null),
415+ );
416+}
417+
418+function componentStatements(db: D1Database, incident: string, list: ImpactInput[]): D1PreparedStatement[] {
419+ return list.map((c) =>
420+ db
421+ .prepare(`INSERT INTO incident_component (incident_id, component, impact) VALUES (?1, ?2, ?3) ON CONFLICT (incident_id, component) DO UPDATE SET impact = ?3`)
422+ .bind(incident, c.key, c.impact),
423+ );
424+}
425+
426+export type NewIncidentRow = {
427+ title: string;
428+ severity: IncidentSeverity;
429+ status: IncidentStatus;
430+ visibility: IncidentVisibility;
431+ source: "declared" | "detected";
432+ components: ImpactInput[];
433+ started_at: string;
434+ acknowledged_at: string | null;
435+ commander: string | null;
436+ communications: string | null;
437+ by: string;
438+};
439+
440+/** A new incident with its first lines. Returns its id. */
441+export async function createIncident(
442+ db: D1Database,
443+ input: NewIncidentRow,
444+ entries: (Entry & { notified?: number | null })[],
445+ now: Date,
446+ audit: { action: string; detail: string } | null,
447+ id = shortId(),
448+): Promise<string> {
449+ const at = now.toISOString();
450+ await db.batch([
451+ db
452+ .prepare(
453+ `INSERT INTO incident (id, title, severity, status, visibility, source, started_at, declared_at, acknowledged_at, published_at, commander, communications, created_by)
454+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)`,
455+ )
456+ .bind(
457+ id,
458+ input.title,
459+ input.severity,
460+ input.status,
461+ input.visibility,
462+ input.source,
463+ input.started_at,
464+ at,
465+ input.acknowledged_at,
466+ input.visibility === "public" ? at : null,
467+ input.commander,
468+ input.communications,
469+ input.by,
470+ ),
471+ ...componentStatements(db, id, input.components),
472+ ...timelineStatements(db, id, entries, now, input.by),
473+ ...(audit ? [auditStatement(db, at, input.by, audit.action, id, audit.detail)] : []),
474+ ]);
475+ return id;
476+}
477+
478+/** Saves an incident's changed facts and the lines that say so. */
479+export async function saveIncident(
480+ db: D1Database,
481+ id: string,
482+ next: IncidentFacts & { title?: string; published_at?: string | null },
483+ entries: (Entry & { notified?: number | null })[],
484+ now: Date,
485+ by: string,
486+ audit: { action: string; detail: string },
487+): Promise<void> {
488+ await db.batch([
489+ db
490+ .prepare(
491+ `UPDATE incident SET status = ?2, severity = ?3, visibility = ?4, acknowledged_at = ?5, mitigated_at = ?6, resolved_at = ?7,
492+ commander = ?8, communications = ?9, title = COALESCE(?10, title), published_at = COALESCE(published_at, ?11) WHERE id = ?1`,
493+ )
494+ .bind(
495+ id,
496+ next.status,
497+ next.severity,
498+ next.visibility,
499+ next.acknowledged_at,
500+ next.mitigated_at,
501+ next.resolved_at,
502+ next.commander,
503+ next.communications,
504+ next.title ?? null,
505+ next.published_at ?? null,
506+ ),
507+ ...componentStatements(db, id, next.components),
508+ ...timelineStatements(db, id, entries, now, by),
509+ auditStatement(db, now.toISOString(), by, audit.action, id, audit.detail),
510+ ]);
511+}
512+
513+/** Lines the checks write on open incidents: failing again, or answering again. */
514+export async function addSystemLines(db: D1Database, lines: { incident: string; kind: TimelineKind; text: string }[], now: Date): Promise<void> {
515+ if (!lines.length) return;
516+ await db.batch(lines.flatMap((l) => timelineStatements(db, l.incident, [{ kind: l.kind, public: false, status: null, text: l.text }], now, "status")));
517+}
518+
519+export async function addFollowUp(db: D1Database, incident: string, input: { title: string; owner: string | null; by: string }, now: Date): Promise<FollowUp> {
520+ const f: FollowUp = { id: shortId(), title: input.title, owner: input.owner, done_at: null, created_at: now.toISOString(), created_by: input.by };
521+ await db.batch([
522+ db
523+ .prepare(`INSERT INTO incident_followup (id, incident_id, title, owner, created_at, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`)
524+ .bind(f.id, incident, f.title, f.owner, f.created_at, f.created_by),
525+ ...timelineStatements(db, incident, [{ kind: "followup", public: false, status: null, text: `Follow-up: ${f.title}${f.owner ? ` (${f.owner})` : ""}.` }], now, input.by),
526+ auditStatement(db, f.created_at, input.by, "incident_followup", incident, `Added follow-up: ${f.title}`),
527+ ]);
528+ return f;
529+}
530+
531+export async function setFollowUp(db: D1Database, incident: string, id: string, done: boolean, by: string, now: Date): Promise<FollowUp | null> {
532+ const row = (await db.prepare(`SELECT * FROM incident_followup WHERE id = ?1 AND incident_id = ?2`).bind(id, incident).first()) as FollowUpRow | null;
533+ if (!row) return null;
534+ const at = now.toISOString();
535+ await db.batch([
536+ db.prepare(`UPDATE incident_followup SET done_at = ?2, done_by = ?3 WHERE id = ?1`).bind(id, done ? at : null, done ? by : null),
537+ ...timelineStatements(db, incident, [{ kind: "followup", public: false, status: null, text: `${done ? "Done" : "Reopened"}: ${row.title}.` }], now, by),
538+ auditStatement(db, at, by, "incident_followup", incident, `${done ? "Done" : "Reopened"}: ${row.title}`),
539+ ]);
540+ return { id, title: row.title, owner: row.owner, done_at: done ? at : null, created_at: row.created_at, created_by: row.created_by };
541+}
542+
543+export async function savePostmortem(db: D1Database, incident: string, fields: PostmortemFields, by: string, now: Date): Promise<void> {
544+ const at = now.toISOString();
545+ await db.batch([
546+ db
547+ .prepare(
548+ `INSERT INTO postmortem (incident_id, summary, impact, timeline, root_cause, went_well, went_badly, action_items, updated_at, updated_by)
549+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
550+ ON CONFLICT (incident_id) DO UPDATE SET summary = ?2, impact = ?3, timeline = ?4, root_cause = ?5, went_well = ?6, went_badly = ?7,
551+ action_items = ?8, updated_at = ?9, updated_by = ?10`,
552+ )
553+ .bind(incident, fields.summary, fields.impact, fields.timeline, fields.root_cause, fields.went_well, fields.went_badly, fields.action_items, at, by),
554+ auditStatement(db, at, by, "postmortem_saved", incident, "Saved the postmortem draft"),
555+ ]);
556+}
557+
558+export async function publishPostmortem(db: D1Database, incident: string, publish: boolean, by: string, now: Date): Promise<void> {
559+ const at = now.toISOString();
560+ await db.batch([
561+ db.prepare(`UPDATE postmortem SET published_at = ?2, published_by = ?3 WHERE incident_id = ?1`).bind(incident, publish ? at : null, publish ? by : null),
562+ ...timelineStatements(db, incident, [{ kind: "postmortem", public: false, status: null, text: publish ? "Postmortem published." : "Postmortem taken down." }], now, by),
563+ auditStatement(db, at, by, publish ? "postmortem_published" : "postmortem_unpublished", incident, publish ? "Published the postmortem" : "Took the postmortem down"),
564+ ]);
565+}
566+
567+// --- Maintenance ---------------------------------------------------------------------
568+
569+export async function scheduleMaintenance(
570+ db: D1Database,
571+ input: { title: string; message: string; components: string[]; starts_at: string; ends_at: string; notify: boolean; by: string },
572+ notified: number | null,
573+ now: Date,
574+ id = shortId(),
575+): Promise<string> {
576+ const at = now.toISOString();
577+ await db.batch([
578+ db
579+ .prepare(
580+ `INSERT INTO maintenance (id, title, message, components, starts_at, ends_at, state, notify, created_at, created_by)
581+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'scheduled', ?7, ?8, ?9)`,
582+ )
583+ .bind(id, input.title, input.message, JSON.stringify(input.components), input.starts_at, input.ends_at, input.notify ? 1 : 0, at, input.by),
584+ db
585+ .prepare(`INSERT INTO maintenance_update (id, maintenance_id, at, by, text, notified) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`)
586+ .bind(shortId(), id, at, input.by, input.message, notified),
587+ auditStatement(db, at, input.by, "maintenance_scheduled", id, `${input.title} (${input.starts_at} to ${input.ends_at})`),
588+ ]);
589+ return id;
590+}
591+
592+/** Moves maintenance along and posts its update. Null `state` keeps it where it is. */
593+export async function maintenanceUpdate(
594+ db: D1Database,
595+ id: string,
596+ state: MaintenanceState | null,
597+ text: string,
598+ by: string,
599+ notified: number | null,
600+ now: Date,
601+ audit: { action: string; detail: string } | null,
602+): Promise<void> {
603+ const at = now.toISOString();
604+ await db.batch([
605+ ...(state ? [db.prepare(`UPDATE maintenance SET state = ?2 WHERE id = ?1`).bind(id, state)] : []),
606+ db
607+ .prepare(`INSERT INTO maintenance_update (id, maintenance_id, at, by, text, notified) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`)
608+ .bind(shortId(), id, at, by, text, notified),
609+ ...(audit ? [auditStatement(db, at, by, audit.action, id, audit.detail)] : []),
610+ ]);
611+}
612+
613+/** Maintenance whose window has started or ended but whose state has not caught up. */
614+export async function dueMaintenance(db: D1Database, now: Date, origin: string): Promise<(AdminMaintenance & { notify: boolean })[]> {
615+ const at = now.toISOString();
616+ const list = rows<MaintenanceRow>(
617+ (await db
618+ .prepare(`SELECT * FROM maintenance WHERE (state = 'scheduled' AND starts_at <= ?1) OR (state = 'in_progress' AND ends_at <= ?1)`)
619+ .bind(at)
620+ .all()) as D1Result,
621+ );
622+ return list.map((row) => ({ ...toMaintenance(row, [], origin), notify: row.notify === 1 }));
623+}
624+
625+// --- Subscribers -----------------------------------------------------------------------
626+
627+type SubscriberRow = {
628+ id: string;
629+ email: string;
630+ components: string | null;
631+ pending_components: string | null;
632+ confirm_hash: string | null;
633+ confirm_expires_at: string | null;
634+ confirm_sent_at: string | null;
635+ confirmed_at: string | null;
636+};
637+
638+/**
639+ * Asks for a subscription (or a change of parts): a new confirmation,
640+ * unless one went out to this address in the last `resendAfterMs`. The
641+ * caller answers the same either way, so the form never tells whether an
642+ * address is subscribed.
643+ */
644+export async function requestSubscription(
645+ db: D1Database,
646+ email: string,
647+ parts: string[] | null,
648+ hash: string,
649+ now: Date,
650+ ttlMs: number,
651+ resendAfterMs: number,
652+): Promise<{ send: boolean }> {
653+ const at = now.toISOString();
654+ const existing = (await db.prepare(`SELECT * FROM subscriber WHERE email = ?1`).bind(email).first()) as SubscriberRow | null;
655+ if (existing?.confirm_sent_at && now.getTime() - Date.parse(existing.confirm_sent_at) < resendAfterMs) return { send: false };
656+ const pending = parts ? JSON.stringify(parts) : null;
657+ if (existing) {
658+ await db
659+ .prepare(`UPDATE subscriber SET pending_components = ?2, confirm_hash = ?3, confirm_expires_at = ?4, confirm_sent_at = ?5 WHERE id = ?1`)
660+ .bind(existing.id, pending, hash, iso(now.getTime() + ttlMs), at)
661+ .run();
662+ } else {
663+ await db
664+ .prepare(
665+ `INSERT INTO subscriber (id, email, components, pending_components, confirm_hash, confirm_expires_at, confirm_sent_at, created_at)
666+ VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, ?6)`,
667+ )
668+ .bind(crypto.randomUUID(), email, pending, hash, iso(now.getTime() + ttlMs), at)
669+ .run();
670+ }
671+ return { send: true };
672+}
673+
674+/** Confirms the subscription a token's hash belongs to; null when none or expired. */
675+export async function confirmSubscription(db: D1Database, hash: string, now: Date): Promise<{ email: string; parts: string[] | null } | null> {
676+ const row = (await db.prepare(`SELECT * FROM subscriber WHERE confirm_hash = ?1`).bind(hash).first()) as SubscriberRow | null;
677+ if (!row || !row.confirm_expires_at || Date.parse(row.confirm_expires_at) < now.getTime()) return null;
678+ await db
679+ .prepare(
680+ `UPDATE subscriber SET components = pending_components, pending_components = NULL, confirm_hash = NULL, confirm_expires_at = NULL,
681+ confirmed_at = COALESCE(confirmed_at, ?2) WHERE id = ?1`,
682+ )
683+ .bind(row.id, now.toISOString())
684+ .run();
685+ return { email: row.email, parts: parseParts(row.pending_components) };
686+}
687+
688+export async function unsubscribe(db: D1Database, id: string): Promise<boolean> {
689+ const result = await db.prepare(`DELETE FROM subscriber WHERE id = ?1`).bind(id).run();
690+ return (result.meta?.changes ?? 0) > 0;
691+}
692+
693+/** Confirmed subscribers who want news about these parts. */
694+export async function recipients(db: D1Database, about: string[]): Promise<{ id: string; email: string }[]> {
695+ const all = rows<SubscriberRow>((await db.prepare(`SELECT id, email, components FROM subscriber WHERE confirmed_at IS NOT NULL`).all()) as D1Result);
696+ return all.filter((s) => wants(parseParts(s.components), about)).map((s) => ({ id: s.id, email: s.email }));
697+}
698+
699+// --- Detection ---------------------------------------------------------------------------
700+
701+export async function loadStreaks(db: D1Database): Promise<Map<string, Streak>> {
702+ const list = rows<{ component: string; state: "degraded" | "down"; count: number; since: string; alerted: number }>(
703+ (await db.prepare(`SELECT * FROM streak`).all()) as D1Result,
704+ );
705+ return new Map(list.map((s) => [s.component, { ...s, alerted: s.alerted === 1 }]));
706+}
707+
708+export async function saveStreaks(db: D1Database, streaks: Streak[]): Promise<void> {
709+ const keep = streaks.map((s) => s.component);
710+ await db.batch([
711+ db.prepare(`DELETE FROM streak WHERE component NOT IN (${inList(keep)})`).bind(...keep),
712+ ...streaks.map((s) =>
713+ db
714+ .prepare(
715+ `INSERT INTO streak (component, state, count, since, alerted) VALUES (?1, ?2, ?3, ?4, ?5)
716+ ON CONFLICT (component) DO UPDATE SET state = ?2, count = ?3, since = ?4, alerted = ?5`,
717+ )
718+ .bind(s.component, s.state, s.count, s.since, s.alerted ? 1 : 0),
719+ ),
720+ ]);
721+}
722+
723+/** Open incidents, drafts included, with the parts they affect: what detection checks against. */
724+export async function openRefs(db: D1Database): Promise<{ id: string; components: string[] }[]> {
725+ const list = rows<{ id: string; component: string | null }>(
726+ (await db
727+ .prepare(
728+ `SELECT i.id, c.component FROM incident i LEFT JOIN incident_component c ON c.incident_id = i.id AND c.impact != 'operational'
729+ WHERE i.resolved_at IS NULL AND i.visibility IN ('draft', 'public')`,
730+ )
731+ .all()) as D1Result,
732+ );
733+ const map = new Map<string, string[]>();
734+ for (const r of list) {
735+ const parts = map.get(r.id) ?? [];
736+ if (r.component) parts.push(r.component);
737+ map.set(r.id, parts);
738+ }
739+ return [...map].map(([id, components]) => ({ id, components }));
740+}
741+
742+// --- Audit ---------------------------------------------------------------------------------
743+
744+export async function auditLog(db: D1Database, before: string | null, limit = 100): Promise<StatusAuditEntry[]> {
745+ const statement = before
746+ ? db.prepare(`SELECT * FROM audit WHERE at < ?1 ORDER BY at DESC LIMIT ?2`).bind(before, limit)
747+ : db.prepare(`SELECT * FROM audit ORDER BY at DESC LIMIT ?1`).bind(limit);
748+ return rows<StatusAuditEntry>((await statement.all()) as D1Result);
749+}
+105−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { alertLetter, bindingSender, confirmLetter, memorySender, render, unsubscribeHeaders, updateLetter } from "./email.ts";
5+import { atom, feedItems, jsonFeed } from "./feed.ts";
6+import { chosenParts, hashToken, newToken, normalizeEmail, parseParts, readUnsubscribeToken, unsubscribeToken, wants } from "./subscribers.ts";
7+
8+test("addresses are trimmed, lowercased and checked", () => {
9+ assert.equal(normalizeEmail(" Ana@Example.COM "), "ana@example.com");
10+ assert.equal(normalizeEmail("nope"), null);
11+ assert.equal(normalizeEmail("a@b"), null);
12+ assert.equal(normalizeEmail("a b@example.com"), null);
13+ assert.equal(normalizeEmail(42), null);
14+});
15+
16+test("confirmation tokens: random, and only their hash is kept", async () => {
17+ const a = newToken();
18+ assert.match(a, /^[A-Za-z0-9_-]{43}$/);
19+ assert.notEqual(newToken(), a);
20+ const hash = await hashToken(a);
21+ assert.match(hash, /^[0-9a-f]{64}$/);
22+ assert.equal(await hashToken(a), hash, "the same token hashes the same");
23+ assert.ok(!hash.includes(a));
24+});
25+
26+test("unsubscribe links are signed, not stored, and refuse tampering", async () => {
27+ const token = await unsubscribeToken("secret", "sub-1");
28+ assert.ok(token.startsWith("sub-1."));
29+ assert.equal(await readUnsubscribeToken("secret", token), "sub-1");
30+ assert.equal(await readUnsubscribeToken("other", token), null, "another secret");
31+ assert.equal(await readUnsubscribeToken("secret", token.replace("sub-1", "sub-2")), null, "another subscriber");
32+ assert.equal(await readUnsubscribeToken("secret", `${token}x`), null);
33+ assert.equal(await readUnsubscribeToken("secret", "garbage"), null);
34+});
35+
36+test("per-part subscriptions", () => {
37+ assert.equal(chosenParts([], ["git", "api"]), null, "none chosen is everything");
38+ assert.equal(chosenParts(["git", "api"], ["git", "api"]), null, "all chosen is everything");
39+ assert.deepEqual(chosenParts(["git", "git", "zzz"], ["git", "api"]), ["git"]);
40+ assert.equal(parseParts(null), null);
41+ assert.deepEqual(parseParts('["git"]'), ["git"]);
42+ assert.equal(parseParts("not json"), null);
43+ assert.equal(wants(null, ["api"]), true);
44+ assert.equal(wants(["git"], ["api"]), false);
45+ assert.equal(wants(["git"], ["api", "git"]), true);
46+ assert.equal(wants(["git"], []), true, "news about no part in particular goes to everyone");
47+});
48+
49+test("letters: escaped HTML, plain text, and an unsubscribe link in every update", () => {
50+ const { text, html } = render(updateLetter({ heading: "Identified: <Pushes>", text: "A bad deploy.", url: "https://status.g1t.sh/incidents/a", affects: ["Git"], unsubscribe: "https://status.g1t.sh/unsubscribe?token=x" }));
51+ assert.ok(html.includes("Identified: &lt;Pushes&gt;") && !html.includes("<Pushes>"));
52+ assert.ok(text.includes("Unsubscribe: https://status.g1t.sh/unsubscribe?token=x"));
53+ assert.ok(text.includes("Affects: Git."));
54+ assert.equal(unsubscribeHeaders("https://u")["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click");
55+ assert.ok(render(confirmLetter("https://c", ["Git"])).text.includes("affecting Git"));
56+ assert.ok(render(alertLetter({ title: "Detected: API not answering", parts: ["API"], since: "5 Oct 12:00 UTC", link: "https://sudo.g1t.sh/incidents/x" })).text.includes("https://sudo.g1t.sh/incidents/x"));
57+});
58+
59+test("senders: the binding when there is one, none otherwise", async () => {
60+ assert.equal(bindingSender(undefined), null);
61+ const calls: unknown[] = [];
62+ const sender = bindingSender({ send: async (m) => void calls.push(m) }, "Status <s@g1t.sh>");
63+ await sender!.send({ to: "a@b.co", subject: "Hi", text: "t", html: "h" });
64+ assert.deepEqual(calls, [{ to: "a@b.co", from: "Status <s@g1t.sh>", subject: "Hi", text: "t", html: "h" }]);
65+ const memory = memorySender();
66+ await memory.send({ to: "a@b.co", subject: "Hi", text: "t", html: "h" });
67+ assert.equal(memory.sent.length, 1);
68+});
69+
70+test("feeds: every public update and maintenance notice, newest first, in Atom and JSON Feed", () => {
71+ const incidents = [
72+ {
73+ id: "a",
74+ title: "Pushes & pulls failing",
75+ impact: "down" as const,
76+ status: "resolved" as const,
77+ components: ["git"],
78+ component_impacts: [{ key: "git", impact: "major_outage" as const }],
79+ started_at: "2026-10-05T11:00:00Z",
80+ resolved_at: "2026-10-05T12:00:00Z",
81+ url: "https://status.g1t.sh/incidents/a",
82+ postmortem_published_at: null,
83+ updates: [
84+ { id: "u2", at: "2026-10-05T12:00:00Z", status: "resolved" as const, text: "Fixed." },
85+ { id: "u1", at: "2026-10-05T11:05:00Z", status: "investigating" as const, text: "Looking <now>." },
86+ ],
87+ },
88+ ];
89+ const maintenance = [
90+ { id: "m", title: "Upgrade", message: "m", components: ["git"], starts_at: "2026-10-06T02:00:00Z", ends_at: "2026-10-06T03:00:00Z", state: "scheduled" as const, url: "https://status.g1t.sh/maintenance/m", updates: [{ id: "mu", at: "2026-10-05T11:30:00Z", text: "Scheduled." }] },
91+ ];
92+ const items = feedItems(incidents, maintenance);
93+ assert.deepEqual(items.map((i) => i.title), ["Resolved: Pushes & pulls failing", "Maintenance: Upgrade", "Investigating: Pushes & pulls failing"]);
94+ assert.equal(items[0]!.url, "https://status.g1t.sh/incidents/a#update-u2");
95+ const xml = atom(items, { origin: "https://status.g1t.sh", title: "g1t status", updated: "2026-10-05T13:00:00Z" });
96+ assert.ok(xml.startsWith('<?xml version="1.0" encoding="utf-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom">'));
97+ assert.ok(xml.includes("<title>Resolved: Pushes &amp; pulls failing</title>"));
98+ assert.ok(xml.includes("Looking &lt;now&gt;."));
99+ assert.ok(xml.includes("<updated>2026-10-05T12:00:00Z</updated>"));
100+ assert.equal((xml.match(/<entry>/g) ?? []).length, 3);
101+ const json = jsonFeed(items, { origin: "https://status.g1t.sh", title: "g1t status", updated: "" }) as { version: string; items: { id: string; content_text: string }[] };
102+ assert.equal(json.version, "https://jsonfeed.org/version/1.1");
103+ assert.equal(json.items[2]!.content_text, "Looking <now>.");
104+ assert.equal(feedItems(incidents, maintenance, 1).length, 1);
105+});
+81−0
1+/**
2+ * Email subscribers: double opt-in and one-click leaving. Only hashes are
3+ * kept. A confirmation token is random and stored as its SHA-256; an
4+ * unsubscribe link is the subscriber's id signed with STATUS_SECRET, so it
5+ * is never stored at all and a copy of the database cannot unsubscribe
6+ * anyone. Web Crypto only, so it is tested under Node.
7+ */
8+
9+/** How long a confirmation link works. */
10+export const CONFIRM_TTL_MS = 24 * 60 * 60 * 1000;
11+/** No second confirmation email to one address sooner than this. */
12+export const RESEND_AFTER_MS = 10 * 60 * 1000;
13+
14+const EMAIL = /^[^\s@<>"(),;:]{1,64}@[a-z0-9.-]{1,190}\.[a-z]{2,}$/;
15+
16+/** An address as kept: trimmed and lowercased; null when it is not one. */
17+export function normalizeEmail(raw: unknown): string | null {
18+ const email = typeof raw === "string" ? raw.trim().toLowerCase() : "";
19+ return email.length <= 254 && EMAIL.test(email) ? email : null;
20+}
21+
22+function base64url(bytes: Uint8Array): string {
23+ let text = "";
24+ for (const b of bytes) text += String.fromCharCode(b);
25+ return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
26+}
27+
28+/** A new random token, as it goes in a link. */
29+export function newToken(): string {
30+ return base64url(crypto.getRandomValues(new Uint8Array(32)));
31+}
32+
33+/** What is kept of a token: its SHA-256, hex. */
34+export async function hashToken(token: string): Promise<string> {
35+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
36+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
37+}
38+
39+async function hmac(secret: string, message: string): Promise<string> {
40+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
41+ return base64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message))));
42+}
43+
44+/** The token in a subscriber's unsubscribe link: `<id>.<signature>`. */
45+export async function unsubscribeToken(secret: string, id: string): Promise<string> {
46+ return `${id}.${await hmac(secret, `unsubscribe:${id}`)}`;
47+}
48+
49+/** The subscriber an unsubscribe token is for, or null when it is not genuine. */
50+export async function readUnsubscribeToken(secret: string, token: string): Promise<string | null> {
51+ const dot = token.lastIndexOf(".");
52+ if (dot <= 0) return null;
53+ const id = token.slice(0, dot);
54+ const expected = await unsubscribeToken(secret, id);
55+ if (expected.length !== token.length) return null;
56+ let diff = 0;
57+ for (let i = 0; i < token.length; i++) diff |= expected.charCodeAt(i) ^ token.charCodeAt(i);
58+ return diff === 0 ? id : null;
59+}
60+
61+/** A subscriber's parts: null for everything. */
62+export function parseParts(json: string | null): string[] | null {
63+ if (!json) return null;
64+ try {
65+ const value = JSON.parse(json);
66+ return Array.isArray(value) && value.length ? value.map(String) : null;
67+ } catch {
68+ return null;
69+ }
70+}
71+
72+/** Whether a subscriber wants news about these parts. */
73+export function wants(parts: string[] | null, about: string[]): boolean {
74+ return parts == null || about.length === 0 || about.some((key) => parts.includes(key));
75+}
76+
77+/** The parts chosen in the form, known ones only; null (everything) when none or all are. */
78+export function chosenParts(raw: string[], known: string[]): string[] | null {
79+ const parts = [...new Set(raw.filter((key) => known.includes(key)))];
80+ return parts.length === 0 || parts.length === known.length ? null : parts;
81+}
+15−0
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ // Node runs the tests on these files as they are, which needs the
5+ // extension on relative imports.
6+ "allowImportingTsExtensions": true
7+ },
8+ "include": [
9+ "src/**/*",
10+ "worker-configuration.d.ts"
11+ ],
12+ "exclude": [
13+ "src/**/*.test.ts"
14+ ]
15+}
+61−0
1+{
2+ "$schema": "../../node_modules/wrangler/config-schema.json",
3+ "name": "g1t-status",
4+ "account_id": "1e6f2cffa3f445920836e8ebe446bb58",
5+ "compatibility_date": "2026-09-26",
6+ "main": "./src/index.ts",
7+ "workers_dev": false,
8+ "preview_urls": false,
9+ // The status page. A Worker of its own, bound to nothing it reports on
10+ // but billing, so it stays up when the site does not. A custom domain,
11+ // whose DNS record Cloudflare makes on the first deploy.
12+ "routes": [{ "pattern": "status.g1t.sh", "custom_domain": true }],
13+ // Every minute: check each part and keep the result.
14+ "triggers": { "crons": ["* * * * *"] },
15+ // The fonts, from packages/theme, served from the page's own origin.
16+ "rules": [{ "type": "Data", "globs": ["**/*.woff2"], "fallthrough": false }],
17+ // Each part's last check, 90 days of daily tallies, and incidents.
18+ // Made by `npx wrangler d1 create g1t-status`; put its id here.
19+ "d1_databases": [
20+ {
21+ "binding": "DB",
22+ "database_name": "g1t-status",
23+ "database_id": "dba85721-96dc-401d-9121-a6c587d614b2",
24+ "migrations_dir": "migrations"
25+ }
26+ ],
27+ // Billing has no public address: its price book is read through this.
28+ // Nothing else is bound, so every other check goes over the internet.
29+ "services": [{ "binding": "BILLING", "service": "g1t-billing" }],
30+ // Email to subscribers (confirmations, incident and maintenance
31+ // updates) and the staff alert for detected drafts, through Cloudflare
32+ // Email Sending, as identity sends its own. Subscriptions also need the
33+ // STATUS_SECRET secret, which signs unsubscribe links:
34+ // npx wrangler secret put STATUS_SECRET
35+ // Without either, the page offers the feeds only.
36+ "send_email": [{ "name": "EMAIL", "remote": true }],
37+ "vars": {
38+ // Where each part is. An empty one leaves that part off the page,
39+ // for an installation that does not run it.
40+ "SITE_URL": "https://g1t.sh",
41+ "API_URL": "https://api.g1t.sh",
42+ "MCP_URL": "https://mcp.g1t.sh",
43+ "DOCS_URL": "https://docs.g1t.sh",
44+ "PAGES_URL": "https://g1t.page",
45+ "MODELS_URL": "https://models.g1t.sh",
46+ // A public repository that always exists, whose branches are listed
47+ // as a clone would.
48+ "PROBE_REPO": "flagon-io/g1t",
49+ "SUPPORT_URL": "https://g1t.sh/support",
50+ // This page's own address, for links in email and the feeds.
51+ "STATUS_URL": "https://status.g1t.sh",
52+ // sudo, for the link in the staff alert.
53+ "SUDO_URL": "https://sudo.g1t.sh",
54+ // Who hears when a part fails three checks in a row and a draft
55+ // incident is made. Empty sends none.
56+ "STATUS_ALERT_EMAIL": "hey@flagon.io",
57+ "STATUS_FROM": "g1t status <noreply@g1t.sh>",
58+ "OG_IMAGE": "https://og.g1t.sh/image?path=%2Fstatus&v=2"
59+ },
60+ "observability": { "enabled": true }
61+}
+101−0
1+/**
2+ * The Incidents pages' small parts: chips, a page's back link, the
3+ * timers strip and the component impact picker. Forms only: sudo ships
4+ * no JavaScript.
5+ */
6+import { ArrowLeft } from "lucide-react";
7+import type { ReactNode } from "react";
8+import { Link } from "react-router";
9+
10+import type { AdminIncident, ComponentImpact, IncidentSeverity, MaintenanceState } from "@g1t/contracts";
11+
12+import { Badge, Select } from "~/components/ui";
13+import {
14+ COMPONENT_IMPACTS,
15+ IMPACT_TONE,
16+ MAINTENANCE_TONE,
17+ MAINTENANCE_WORD,
18+ SEVERITY_TONE,
19+ impactLabel,
20+ phase,
21+ severityLabel,
22+} from "~/lib/incidents";
23+
24+export function SeverityBadge({ severity }: { severity: IncidentSeverity }) {
25+ return <Badge tone={SEVERITY_TONE[severity]}>{severityLabel(severity)}</Badge>;
26+}
27+
28+export function PhaseBadge({ incident }: { incident: Pick<AdminIncident, "visibility" | "status" | "postmortem_published_at"> }) {
29+ const { label, tone } = phase(incident);
30+ return <Badge tone={tone}>{label}</Badge>;
31+}
32+
33+export function ImpactBadge({ impact }: { impact: ComponentImpact }) {
34+ return <Badge tone={IMPACT_TONE[impact]}>{impactLabel(impact)}</Badge>;
35+}
36+
37+export function MaintenanceBadge({ state }: { state: MaintenanceState }) {
38+ return <Badge tone={MAINTENANCE_TONE[state]}>{MAINTENANCE_WORD[state]}</Badge>;
39+}
40+
41+export function BackLink({ to, children }: { to: string; children: ReactNode }) {
42+ return (
43+ <Link to={to} className="inline-flex items-center gap-1.5 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
44+ <ArrowLeft size={13} aria-hidden="true" />
45+ {children}
46+ </Link>
47+ );
48+}
49+
50+/** One timer: a label, a figure, and a line under it. */
51+export function Timer({ label, value, hint, tone }: { label: string; value: ReactNode; hint?: ReactNode; tone?: "danger" | "warn" | "mint" }) {
52+ const color = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : tone === "mint" ? "text-accent" : "text-fg";
53+ return (
54+ <div className="min-w-0 px-4 py-3">
55+ <p className="text-xs text-muted">{label}</p>
56+ <p className={`tabular mt-0.5 text-base font-semibold tracking-tight ${color}`}>{value}</p>
57+ {hint && <p className="mt-0.5 truncate text-xs text-faint">{hint}</p>}
58+ </div>
59+ );
60+}
61+
62+/**
63+ * A row per part with a select of impacts. `blank` adds a first choice
64+ * that leaves the part as it is (for updates); without it, every part
65+ * starts operational (for declaring).
66+ */
67+export function ImpactPicker({
68+ components,
69+ current,
70+ blank,
71+}: {
72+ components: { key: string; name: string }[];
73+ current: Map<string, ComponentImpact>;
74+ blank?: boolean;
75+}) {
76+ return (
77+ <div className="divide-y divide-line rounded-md border border-line">
78+ {components.map((c) => {
79+ const now = current.get(c.key) ?? "operational";
80+ return (
81+ <label key={c.key} className="flex items-center gap-3 px-3 py-1.5">
82+ <span className="min-w-0 grow text-sm">
83+ <span className="block truncate">{c.name}</span>
84+ {blank && now !== "operational" && <span className="block text-xs text-faint">now {impactLabel(now).toLowerCase()}</span>}
85+ </span>
86+ <span className="block w-40 shrink-0 sm:w-48">
87+ <Select name={`impact.${c.key}`} defaultValue={blank ? "" : now} aria-label={`${c.name}: impact`} className="py-1 text-xs">
88+ {blank && <option value="">No change</option>}
89+ {COMPONENT_IMPACTS.map((i) => (
90+ <option key={i.value} value={i.value}>
91+ {i.label}
92+ </option>
93+ ))}
94+ </Select>
95+ </span>
96+ </label>
97+ );
98+ })}
99+ </div>
100+ );
101+}
+44−12
3030 Server,
3131 ShieldAlert,
3232 ShieldCheck,
33+ Siren,
3334 Tags,
3435 Ticket,
3536 TrendingUp,
4243 import { Link, useLocation } from "react-router";
4344
4445 import { Logo } from "~/components/logo";
45−import { NAV, type NavGroup, type NavIcon, type NavItem, holdsCurrent, isCurrent } from "~/lib/nav";
46+import { NAV, type NavCounts, type NavGroup, type NavIcon, type NavItem, countFor, holdsCurrent, isCurrent } from "~/lib/nav";
4647
4748 const ICONS: Record<NavIcon, LucideIcon> = {
4849 overview: LayoutDashboard,
5859 costs: Cloud,
5960 agents: Bot,
6061 abuse: ShieldAlert,
62+ incidents: Siren,
6163 inbox: Inbox,
6264 "view-as": Eye,
6365 announcements: Megaphone,
8183 return <Icon size={size} className={className} />;
8284 }
8385
86+/** How many are waiting on a page, such as access requests; nothing at zero. */
87+function CountPill({ count, label }: { count: number; label: string }) {
88+ if (count <= 0) return null;
89+ return (
90+ <span
91+ title={label}
92+ className="shrink-0 rounded-full bg-merged/15 px-1.5 py-px text-[0.6875rem] font-semibold text-merged tabular-nums ring-1 ring-merged/30"
93+ >
94+ {count > 99 ? "99+" : count}
95+ <span className="sr-only"> {label}</span>
96+ </span>
97+ );
98+}
99+
100+const COUNT_LABEL = "waiting";
101+
102+/** What an item's number counts, for screen readers: incidents are open, not waiting. */
103+function countLabel(item: NavItem): string {
104+ return item.count === "incidents" ? "open" : COUNT_LABEL;
105+}
106+
84107 function SoonPill() {
85108 return (
86109 <span className="shrink-0 rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
90113 }
91114
92115 /** A top-level link, with its icon. */
93−function TopLink({ item, pathname }: { item: NavItem; pathname: string }) {
116+function TopLink({ item, pathname, counts }: { item: NavItem; pathname: string; counts: NavCounts }) {
94117 const current = isCurrent(item, pathname);
95118 return (
96119 <Link
103126 >
104127 <NavGlyph icon={item.icon} className={`shrink-0 ${current ? "text-merged" : "text-faint group-hover:text-muted"}`} />
105128 <span className="min-w-0 grow truncate">{item.label}</span>
129+ <CountPill count={countFor([item], counts)} label={countLabel(item)} />
106130 {item.soon && <SoonPill />}
107131 </Link>
108132 );
109133 }
110134
111135 /** A page inside a section: indented, no icon, the current one marked in lavender. */
112−function SubLink({ item, pathname }: { item: NavItem; pathname: string }) {
136+function SubLink({ item, pathname, counts }: { item: NavItem; pathname: string; counts: NavCounts }) {
113137 const current = isCurrent(item, pathname);
114138 return (
115139 <Link
125149 }`}
126150 >
127151 <span className="min-w-0 grow truncate">{item.label}</span>
152+ <CountPill count={countFor([item], counts)} label={countLabel(item)} />
128153 {item.soon && <SoonPill />}
129154 </Link>
130155 );
131156 }
132157
133158 /** A section: one row that folds open to its pages. Open when it holds the current page. */
134−function Section({ group, pathname }: { group: NavGroup & { title: string }; pathname: string }) {
159+function Section({ group, pathname, counts }: { group: NavGroup & { title: string }; pathname: string; counts: NavCounts }) {
135160 const current = holdsCurrent(group, pathname);
161+ const waiting = countFor(group.items, counts);
136162 const allSoon = group.items.every((item) => item.soon);
137163 return (
138164 <details open={current} className="group/section">
148174 <SoonPill />
149175 </span>
150176 )}
177+ {/* Folded, the section carries its pages' counts; open, the pages do. */}
178+ {waiting > 0 && (
179+ <span className="group-open/section:hidden">
180+ <CountPill count={waiting} label={COUNT_LABEL} />
181+ </span>
182+ )}
151183 <ChevronRight size={14} aria-hidden="true" className="shrink-0 text-faint transition-transform duration-150 group-open/section:rotate-90" />
152184 </summary>
153185 <ul className="relative mt-px mb-1 space-y-px before:absolute before:top-1 before:bottom-1 before:left-[1.1875rem] before:w-px before:bg-line">
154186 {group.items.map((item) => (
155187 <li key={item.to}>
156− <SubLink item={item} pathname={pathname} />
188+ <SubLink item={item} pathname={pathname} counts={counts} />
157189 </li>
158190 ))}
159191 </ul>
161193 );
162194 }
163195
164−function NavTree({ pathname }: { pathname: string }) {
196+function NavTree({ pathname, counts }: { pathname: string; counts: NavCounts }) {
165197 return (
166198 <div className="space-y-px">
167199 {NAV.map((group, index) =>
168200 group.title ? (
169− <Section key={group.title} group={group as NavGroup & { title: string }} pathname={pathname} />
201+ <Section key={group.title} group={group as NavGroup & { title: string }} pathname={pathname} counts={counts} />
170202 ) : (
171203 <ul key={index} className={`space-y-px ${index === 0 ? "" : "pt-2"} ${index < NAV.length - 1 ? "pb-2" : ""}`}>
172204 {group.items.map((item) => (
173205 <li key={item.to}>
174− <TopLink item={item} pathname={pathname} />
206+ <TopLink item={item} pathname={pathname} counts={counts} />
175207 </li>
176208 ))}
177209 </ul>
197229 }
198230
199231 /** The sidebar, from `lg` up. */
200−export function Sidebar({ email }: { email: string | null | undefined }) {
232+export function Sidebar({ email, counts = {} }: { email: string | null | undefined; counts?: NavCounts }) {
201233 const { pathname } = useLocation();
202234 return (
203235 <aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-line bg-surface/50 lg:flex">
207239 </Link>
208240 </div>
209241 <nav aria-label="sudo" className="grow overflow-y-auto px-2 pt-2 pb-6">
210− <NavTree pathname={pathname} />
242+ <NavTree pathname={pathname} counts={counts} />
211243 </nav>
212244 <div className="shrink-0">
213245 <SignedIn email={email} />
217249 }
218250
219251 /** The top bar and its menu, below `lg`. */
220−export function MobileBar({ email }: { email: string | null | undefined }) {
252+export function MobileBar({ email, counts = {} }: { email: string | null | undefined; counts?: NavCounts }) {
221253 const { pathname } = useLocation();
222254 return (
223255 <header className="sticky top-0 z-40 border-b border-line bg-surface/95 backdrop-blur lg:hidden">
238270 </summary>
239271 <div className="absolute inset-x-0 top-14 max-h-[calc(100dvh-3.5rem)] overflow-y-auto border-b border-line bg-bg shadow-2xl shadow-black/50">
240272 <nav aria-label="sudo" className="px-2 pt-3 pb-4">
241− <NavTree pathname={pathname} />
273+ <NavTree pathname={pathname} counts={counts} />
242274 </nav>
243275 <SignedIn email={email} />
244276 </div>
+117−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { mergeAudit, accountPath, accountName, statusAuditActions } from "./ledgers.ts";
5+import {
6+ duration,
7+ filterIncidents,
8+ incidentsHref,
9+ maintenanceList,
10+ median,
11+ nextStatus,
12+ parseFilters,
13+ phase,
14+ readImpacts,
15+ staffSuggestions,
16+ tabCounts,
17+ tabOf,
18+ utc,
19+ wantsNotify,
20+} from "./incidents.ts";
21+import { proseBlocks } from "./postmortem.ts";
22+
23+const incident = (over: Record<string, unknown> = {}) =>
24+ ({
25+ title: "Pushes failing",
26+ severity: "sev2",
27+ visibility: "public",
28+ resolved_at: null,
29+ components: [{ key: "git", impact: "major_outage" }],
30+ status: "investigating",
31+ postmortem_published_at: null,
32+ ...over,
33+ }) as Parameters<typeof tabOf>[0] & Parameters<typeof phase>[0] & { title: string; severity: "sev2"; components: { key: string; impact: "major_outage" }[] };
34+
35+test("filters come from the address; unknown values are dropped", () => {
36+ assert.deepEqual(parseFilters(new URLSearchParams("tab=drafts&severity=sev1&component=git&q=%20push%20")), { tab: "drafts", severity: "sev1", component: "git", q: "push" });
37+ assert.deepEqual(parseFilters(new URLSearchParams("tab=nope&severity=sev9&component=%3Cx%3E")), { tab: "open", severity: null, component: null, q: "" });
38+ assert.equal(incidentsHref({ tab: "open" }), "/incidents");
39+ assert.equal(incidentsHref({ tab: "resolved", severity: "sev1", q: "a b" }), "/incidents?tab=resolved&severity=sev1&q=a+b");
40+});
41+
42+test("tabs: open, drafts, and resolved (dismissed drafts included)", () => {
43+ assert.equal(tabOf(incident()), "open");
44+ assert.equal(tabOf(incident({ visibility: "draft" })), "drafts");
45+ assert.equal(tabOf(incident({ resolved_at: "2026-10-05T00:00:00Z" })), "resolved");
46+ assert.equal(tabOf(incident({ visibility: "dismissed", resolved_at: "2026-10-05T00:00:00Z" })), "resolved");
47+ const list = [incident(), incident({ title: "Docs slow", severity: "sev3", components: [{ key: "docs", impact: "degraded" }] }), incident({ visibility: "draft" })];
48+ const all = { tab: "open" as const, severity: null, component: null, q: "" };
49+ assert.equal(filterIncidents(list, all).length, 2);
50+ assert.equal(filterIncidents(list, { ...all, severity: "sev3" }).length, 1);
51+ assert.equal(filterIncidents(list, { ...all, component: "git" }).length, 1);
52+ assert.equal(filterIncidents(list, { ...all, q: "DOCS" })[0]!.title, "Docs slow");
53+ assert.deepEqual(tabCounts(list, [{ state: "scheduled" }, { state: "completed" }]), { open: 2, maintenance: 1, resolved: 0, drafts: 1 });
54+});
55+
56+test("maintenance: live first by start, then finished newest first", () => {
57+ const m = (id: string, state: string, starts_at: string) => ({ id, state, starts_at, components: ["git"] }) as never;
58+ assert.deepEqual(
59+ maintenanceList([m("done-old", "completed", "2026-09-01"), m("later", "scheduled", "2026-10-09"), m("done-new", "cancelled", "2026-10-01"), m("soon", "in_progress", "2026-10-05")], null).map((x: { id: string }) => x.id),
60+ ["soon", "later", "done-new", "done-old"],
61+ );
62+});
63+
64+test("phase chips: draft, dismissed, postmortem, else the status", () => {
65+ assert.deepEqual(phase(incident({ visibility: "draft" })), { label: "Draft", tone: "lavender" });
66+ assert.equal(phase(incident({ visibility: "dismissed" })).label, "Dismissed");
67+ assert.equal(phase(incident({ postmortem_published_at: "x", status: "resolved" })).label, "Postmortem published");
68+ assert.deepEqual(phase(incident({ status: "monitoring" })), { label: "Monitoring", tone: "info" });
69+});
70+
71+test("durations, medians and UTC times", () => {
72+ assert.equal(duration(null), "—");
73+ assert.equal(duration(59.6), "1m");
74+ assert.equal(duration(3 * 3600 + 5 * 60), "3h 05m");
75+ assert.equal(median([300, null, 100, 200]), 200);
76+ assert.equal(median([100, 200]), 150);
77+ assert.equal(median([null]), null);
78+ assert.equal(utc("2026-10-05T14:00"), "2026-10-05T14:00:00.000Z");
79+ assert.equal(utc(""), null);
80+ assert.equal(utc("garbage"), null);
81+});
82+
83+test("forms: impacts per part, notify by severity, next status, staff suggestions", () => {
84+ const form = new FormData();
85+ form.set("impact.git", "partial_outage");
86+ form.set("impact.api", "");
87+ form.set("impact.docs", "bogus");
88+ assert.deepEqual(readImpacts(form, ["git", "api", "docs"]), [{ key: "git", impact: "partial_outage" }]);
89+ assert.equal(wantsNotify("auto", "sev1"), true);
90+ assert.equal(wantsNotify("auto", "sev3"), false);
91+ assert.equal(wantsNotify("yes", "sev4"), true);
92+ assert.equal(wantsNotify("no", "sev1"), false);
93+ assert.equal(nextStatus("investigating"), "identified");
94+ assert.equal(nextStatus("monitoring"), "resolved");
95+ assert.deepEqual(staffSuggestions("syntaqx@gmail.com, @g1t.sh", ["Ana@g1t.sh", null, "syntaqx@gmail.com"]), ["ana@g1t.sh", "syntaqx@gmail.com"]);
96+});
97+
98+test("the status page's audit lines join the Audit log and link to their pages", () => {
99+ const lines = statusAuditActions([
100+ { id: "a1", at: "2026-10-05T12:00:00Z", by: "ana@g1t.sh", action: "incident_declared", target: "abc123", detail: "SEV2: Pushes failing" },
101+ { id: "a2", at: "2026-10-05T10:00:00Z", by: "ana@g1t.sh", action: "maintenance_scheduled", target: "m1", detail: "Upgrade" },
102+ ]);
103+ assert.equal(accountPath(lines[0]!.account), "/incidents/abc123");
104+ assert.equal(accountPath(lines[1]!.account), "/incidents/maintenance/m1");
105+ assert.equal(accountName(lines[0]!.account), "Incident");
106+ const billing = [{ id: "b", account: "ws_acme", action: "terms", detail: "", by: "x", createdAt: "2026-10-05T11:00:00Z" }];
107+ assert.deepEqual(mergeAudit(billing, lines).map((l) => l.id), ["status-a1", "b", "status-a2"]);
108+ assert.equal(mergeAudit(billing, lines, 2).length, 2);
109+});
110+
111+test("the postmortem preview reads text as the status page does", () => {
112+ assert.deepEqual(proseBlocks("One\ntwo\n\n- a\n- b\n\n\n"), [
113+ { kind: "paragraph", text: "One\ntwo" },
114+ { kind: "list", items: ["a", "b"] },
115+ ]);
116+ assert.deepEqual(proseBlocks(""), []);
117+});
+194−0
1+/**
2+ * The Incidents pages' wording and arithmetic: tabs and filters read from
3+ * the address, chips and their tones, durations, and reading the forms.
4+ * No Workers or React imports, so it is tested under Node. The status
5+ * worker checks everything again.
6+ */
7+import {
8+ type AdminIncident,
9+ type AdminMaintenance,
10+ COMPONENT_IMPACTS,
11+ type ComponentImpact,
12+ INCIDENT_SEVERITIES,
13+ INCIDENT_STATUSES,
14+ type ImpactInput,
15+ type IncidentSeverity,
16+ type IncidentStatus,
17+ type MaintenanceState,
18+} from "@g1t/contracts/status";
19+
20+export { COMPONENT_IMPACTS, INCIDENT_SEVERITIES, INCIDENT_STATUSES };
21+
22+export type Tone = "plain" | "lavender" | "mint" | "warn" | "danger" | "info";
23+
24+export const SEVERITY_TONE: Record<IncidentSeverity, Tone> = { sev1: "danger", sev2: "warn", sev3: "info", sev4: "plain" };
25+export const STATUS_TONE: Record<IncidentStatus, Tone> = { investigating: "danger", identified: "warn", monitoring: "info", resolved: "mint" };
26+export const IMPACT_TONE: Record<ComponentImpact, Tone> = { operational: "mint", degraded: "warn", partial_outage: "warn", major_outage: "danger" };
27+export const MAINTENANCE_TONE: Record<MaintenanceState, Tone> = { scheduled: "info", in_progress: "lavender", completed: "mint", cancelled: "plain" };
28+export const MAINTENANCE_WORD: Record<MaintenanceState, string> = {
29+ scheduled: "Scheduled",
30+ in_progress: "In progress",
31+ completed: "Completed",
32+ cancelled: "Cancelled",
33+};
34+
35+export function severityLabel(severity: IncidentSeverity): string {
36+ return INCIDENT_SEVERITIES.find((s) => s.value === severity)?.label ?? severity.toUpperCase();
37+}
38+
39+export function statusLabel(status: IncidentStatus): string {
40+ return INCIDENT_STATUSES.find((s) => s.value === status)?.label ?? status;
41+}
42+
43+export function impactLabel(impact: ComponentImpact): string {
44+ return COMPONENT_IMPACTS.find((i) => i.value === impact)?.label ?? impact;
45+}
46+
47+export function notifyByDefault(severity: IncidentSeverity): boolean {
48+ return INCIDENT_SEVERITIES.find((s) => s.value === severity)?.notify ?? false;
49+}
50+
51+/** A notify choice: `yes`, `no`, or `auto` (the severity's default). */
52+export function wantsNotify(choice: string, severity: IncidentSeverity): boolean {
53+ return choice === "yes" || choice === "on" || (choice === "auto" && notifyByDefault(severity));
54+}
55+
56+/** The status a next update most likely moves to. */
57+export function nextStatus(status: IncidentStatus): IncidentStatus {
58+ return status === "investigating" ? "identified" : status === "identified" ? "monitoring" : "resolved";
59+}
60+
61+// --- Tabs and filters ------------------------------------------------------------------
62+
63+export const TABS = [
64+ { value: "open", label: "Open" },
65+ { value: "maintenance", label: "Scheduled maintenance" },
66+ { value: "resolved", label: "Resolved" },
67+ { value: "drafts", label: "Drafts" },
68+] as const;
69+export type Tab = (typeof TABS)[number]["value"];
70+
71+export type Filters = { tab: Tab; severity: IncidentSeverity | null; component: string | null; q: string };
72+
73+export function parseFilters(params: URLSearchParams): Filters {
74+ const tab = TABS.find((t) => t.value === params.get("tab"))?.value ?? "open";
75+ const severity = INCIDENT_SEVERITIES.find((s) => s.value === params.get("severity"))?.value ?? null;
76+ const component = /^[a-z][a-z0-9-]{0,40}$/.test(params.get("component") ?? "") ? params.get("component") : null;
77+ const q = (params.get("q") ?? "").trim().slice(0, 100);
78+ return { tab, severity, component, q };
79+}
80+
81+export function incidentsHref(filters: Partial<Filters>): string {
82+ const params = new URLSearchParams();
83+ if (filters.tab && filters.tab !== "open") params.set("tab", filters.tab);
84+ if (filters.severity) params.set("severity", filters.severity);
85+ if (filters.component) params.set("component", filters.component);
86+ if (filters.q) params.set("q", filters.q);
87+ const query = params.toString();
88+ return query ? `/incidents?${query}` : "/incidents";
89+}
90+
91+/** Which tab an incident is under. Maintenance has its own. */
92+export function tabOf(incident: Pick<AdminIncident, "visibility" | "resolved_at">): Exclude<Tab, "maintenance"> {
93+ if (incident.visibility === "draft") return "drafts";
94+ if (incident.visibility === "dismissed" || incident.resolved_at) return "resolved";
95+ return "open";
96+}
97+
98+export function filterIncidents<T extends Pick<AdminIncident, "visibility" | "resolved_at" | "severity" | "components" | "title">>(
99+ list: T[],
100+ filters: Filters,
101+): T[] {
102+ const q = filters.q.toLowerCase();
103+ return list.filter(
104+ (i) =>
105+ tabOf(i) === filters.tab &&
106+ (!filters.severity || i.severity === filters.severity) &&
107+ (!filters.component || i.components.some((c) => c.key === filters.component && c.impact !== "operational")) &&
108+ (!q || i.title.toLowerCase().includes(q)),
109+ );
110+}
111+
112+/** Maintenance under the tab: upcoming and under way first, soonest first; then finished, newest first. */
113+export function maintenanceList(list: AdminMaintenance[], component: string | null): AdminMaintenance[] {
114+ const live = (m: AdminMaintenance) => m.state === "scheduled" || m.state === "in_progress";
115+ return list
116+ .filter((m) => !component || m.components.includes(component))
117+ .sort((a, b) => (live(a) !== live(b) ? (live(a) ? -1 : 1) : live(a) ? a.starts_at.localeCompare(b.starts_at) : b.starts_at.localeCompare(a.starts_at)));
118+}
119+
120+export function tabCounts(incidents: Pick<AdminIncident, "visibility" | "resolved_at">[], maintenance: Pick<AdminMaintenance, "state">[]): Record<Tab, number> {
121+ const counts: Record<Tab, number> = { open: 0, maintenance: 0, resolved: 0, drafts: 0 };
122+ for (const i of incidents) counts[tabOf(i)] += 1;
123+ counts.maintenance = maintenance.filter((m) => m.state === "scheduled" || m.state === "in_progress").length;
124+ return counts;
125+}
126+
127+// --- Time ----------------------------------------------------------------------------
128+
129+/** "45s", "12m", "2h 05m", "3d 4h"; a dash for none. */
130+export function duration(seconds: number | null | undefined): string {
131+ if (seconds == null || Number.isNaN(seconds)) return "—";
132+ const s = Math.max(0, Math.round(seconds));
133+ if (s < 60) return `${s}s`;
134+ const minutes = Math.floor(s / 60);
135+ if (minutes < 60) return `${minutes}m`;
136+ const hours = Math.floor(minutes / 60);
137+ if (hours < 48) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
138+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
139+}
140+
141+export function secondsBetween(from: string, to: Date | string): number {
142+ return (new Date(to).getTime() - Date.parse(from)) / 1000;
143+}
144+
145+/** The median of the known values; null with none. */
146+export function median(values: (number | null)[]): number | null {
147+ const known = values.filter((v): v is number => v != null).sort((a, b) => a - b);
148+ if (!known.length) return null;
149+ const mid = Math.floor(known.length / 2);
150+ return known.length % 2 ? known[mid]! : (known[mid - 1]! + known[mid]!) / 2;
151+}
152+
153+/** A `datetime-local` value, read as UTC: sudo ships no script to know the browser's zone. */
154+export function utc(raw: string): string | null {
155+ const value = raw.trim();
156+ if (!value) return null;
157+ const iso = /Z$|[+-]\d\d:\d\d$/.test(value) ? value : `${value.length === 16 ? `${value}:00` : value}Z`;
158+ return Number.isNaN(Date.parse(iso)) ? null : new Date(iso).toISOString();
159+}
160+
161+/** A time as a `datetime-local` value in UTC: `2026-10-05T14:00`. */
162+export function localValue(at: Date | string): string {
163+ return new Date(at).toISOString().slice(0, 16);
164+}
165+
166+// --- Forms ------------------------------------------------------------------------------
167+
168+/** Each part's impact from `impact.<key>` selects; an empty choice leaves the part as it is. */
169+export function readImpacts(form: FormData, keys: string[]): ImpactInput[] {
170+ const out: ImpactInput[] = [];
171+ for (const key of keys) {
172+ const value = form.get(`impact.${key}`);
173+ const impact = COMPONENT_IMPACTS.find((i) => i.value === value)?.value;
174+ if (impact) out.push({ key, impact });
175+ }
176+ return out;
177+}
178+
179+/** Staff addresses for the roles' suggestions: exact addresses in STAFF_EMAILS, and any seen. */
180+export function staffSuggestions(staffEmails: string, seen: (string | null | undefined)[]): string[] {
181+ const listed = staffEmails
182+ .split(",")
183+ .map((e) => e.trim().toLowerCase())
184+ .filter((e) => /^[^@\s]+@[^@\s]+$/.test(e));
185+ return [...new Set([...listed, ...seen.filter((e): e is string => !!e && e.includes("@")).map((e) => e.toLowerCase())])].sort();
186+}
187+
188+/** The chip an incident's phase shows: draft, dismissed, postmortem published, or its status. */
189+export function phase(i: Pick<AdminIncident, "visibility" | "status" | "postmortem_published_at">): { label: string; tone: Tone } {
190+ if (i.visibility === "draft") return { label: "Draft", tone: "lavender" };
191+ if (i.visibility === "dismissed") return { label: "Dismissed", tone: "plain" };
192+ if (i.postmortem_published_at) return { label: "Postmortem published", tone: "mint" };
193+ return { label: statusLabel(i.status), tone: STATUS_TONE[i.status] };
194+}
+38−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { INVITES_DONE, invitesHref, parseGrant, parseMintEmail, parseTab, parseWaitlistStatus } from "./invites.ts";
4+import {
5+ INVITES_DONE,
6+ MAX_BULK,
7+ doneMessage,
8+ invitesHref,
9+ parseGrant,
10+ parseIds,
11+ parseMintEmail,
12+ parseNote,
13+ parseTab,
14+ parseWaitlistStatus,
15+} from "./invites.ts";
516
617 const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null });
718
4152 test("every outcome has words", () => {
4253 for (const key of ["approved", "dismissed", "revoked", "granted"]) assert.ok(INVITES_DONE[key]);
4354 });
55+
56+test("several requests are decided at once, each once", () => {
57+ const ticked = (ids: string[]) => ({ getAll: (name: string) => (name === "ids" ? ids : []) });
58+ assert.deepEqual(parseIds(ticked(["wl_01jb2k", "wl_01jb2m", "wl_01jb2k"])), { ok: true, value: ["wl_01jb2k", "wl_01jb2m"] });
59+ assert.equal(parseIds(ticked([])).ok, false);
60+ assert.equal(parseIds(ticked(["inv_01jb2k", "wl_<script>"])).ok, false);
61+ const many = Array.from({ length: MAX_BULK + 1 }, (_, n) => `wl_${n.toString(36)}`);
62+ assert.equal(parseIds(ticked(many)).ok, false);
63+ assert.equal(parseIds(ticked(many.slice(0, MAX_BULK))).ok, true);
64+});
65+
66+test("an approval's note is optional and short", () => {
67+ assert.deepEqual(parseNote(" Welcome aboard. "), { ok: true, value: "Welcome aboard." });
68+ assert.deepEqual(parseNote(""), { ok: true, value: null });
69+ assert.deepEqual(parseNote(null), { ok: true, value: null });
70+ assert.equal(parseNote("x".repeat(501)).ok, false);
71+});
72+
73+test("the flash says how many were decided", () => {
74+ assert.equal(doneMessage("approved", "3"), "Approved 3 requests. Each invite is on its way.");
75+ assert.equal(doneMessage("dismissed", "2"), "Dismissed 2 requests.");
76+ assert.equal(doneMessage("approved", null), INVITES_DONE.approved);
77+ assert.equal(doneMessage("approved", "1"), INVITES_DONE.approved);
78+ assert.equal(doneMessage("nope", "4"), null);
79+ assert.equal(doneMessage(null, null), null);
80+});
+41−0
7474 revoked: "Invite revoked. It comes back to whoever it was charged to.",
7575 granted: "Granted. It applies at once.",
7676 };
77+
78+/** The flash for `?done=` and, after deciding several at once, `?n=` of them. */
79+export function doneMessage(done: string | null, n: string | null): string | null {
80+ if (!done) return null;
81+ const count = Number(n);
82+ if (Number.isInteger(count) && count > 1) {
83+ if (done === "approved") return `Approved ${count} requests. Each invite is on its way.`;
84+ if (done === "dismissed") return `Dismissed ${count} requests.`;
85+ }
86+ return INVITES_DONE[done] ?? null;
87+}
88+
89+/** The most requests one bulk decision takes: each one is its own call to identity. */
90+export const MAX_BULK = 50;
91+
92+/** The waitlist entries ticked on a bulk form, each once, in order. */
93+export function parseIds(form: { getAll(name: string): unknown[] }): Parsed<string[]> {
94+ const ids = [
95+ ...new Set(
96+ form
97+ .getAll("ids")
98+ .filter((id): id is string => typeof id === "string")
99+ .map((id) => id.trim())
100+ .filter((id) => /^wl_[0-9a-z]{1,40}$/.test(id)),
101+ ),
102+ ];
103+ if (ids.length === 0) return { ok: false, error: "Tick the requests to decide on first." };
104+ if (ids.length > MAX_BULK) return { ok: false, error: `Decide on at most ${MAX_BULK} at a time.` };
105+ return { ok: true, value: ids };
106+}
107+
108+/** The most characters an approval's note keeps; identity holds the same line. */
109+export const MAX_NOTE = 500;
110+
111+/** A note for the invite email: trimmed, or none. */
112+export function parseNote(raw: string | null | undefined): Parsed<string | null> {
113+ const note = (raw ?? "").trim();
114+ if (!note) return { ok: true, value: null };
115+ if (note.length > MAX_NOTE) return { ok: false, error: `Keep the note to ${MAX_NOTE} characters; it is ${note.length}.` };
116+ return { ok: true, value: note };
117+}
+43−0
6767 */
6868 export function accountPath(account: string | null | undefined): string | null {
6969 const id = (account ?? "").trim().toLowerCase();
70+ const status = /^(incident|maintenance):([a-z0-9-]{1,64})$/.exec(id);
71+ if (status) return status[1] === "incident" ? `/incidents/${status[2]}` : `/incidents/maintenance/${status[2]}`;
7072 if (ENTERPRISE.test(id)) return `/enterprises/${encodeURIComponent(id)}`;
7173 if (id.startsWith("ws_") && SLUG.test(id.slice(3))) return `/workspaces/${encodeURIComponent(id.slice(3))}`;
7274 return null;
7577 /** What to call an account: a workspace by its slug, an enterprise by its name when known. */
7678 export function accountName(account: string, names: Map<string, string> = new Map()): string {
7779 if (names.has(account)) return names.get(account) as string;
80+ if (account.startsWith("incident:")) return "Incident";
81+ if (account.startsWith("maintenance:")) return "Maintenance";
7882 if (account.startsWith("ws_")) return account.slice(3);
7983 if (ENTERPRISE.test(account)) return "an enterprise";
8084 return account;
101105 note: "Sales note added",
102106 stripe: "From Stripe",
103107 webhook: "Stripe webhook registered",
108+ // From the status page (apps/status), merged in by the Audit log page.
109+ incident_declared: "Incident declared",
110+ incident_detected: "Incident detected",
111+ incident_update: "Incident update",
112+ incident_note: "Incident note",
113+ incident_resolved: "Incident resolved",
114+ incident_roles: "Incident roles",
115+ incident_published: "Incident published",
116+ incident_dismissed: "Draft dismissed",
117+ incident_followup: "Incident follow-up",
118+ postmortem_saved: "Postmortem saved",
119+ postmortem_published: "Postmortem published",
120+ postmortem_unpublished: "Postmortem taken down",
121+ maintenance_scheduled: "Maintenance scheduled",
122+ maintenance_update: "Maintenance update",
123+ maintenance_started: "Maintenance started",
124+ maintenance_completed: "Maintenance completed",
125+ maintenance_cancelled: "Maintenance cancelled",
104126 };
105127
128+/**
129+ * The status page's audit lines as the Audit log lists billing's: the
130+ * account is `incident:<id>` or `maintenance:<id>`, which link to their
131+ * pages in sudo.
132+ */
133+export function statusAuditActions(entries: { id: string; at: string; by: string; action: string; target: string; detail: string }[]): AdminAction[] {
134+ return entries.map((e) => ({
135+ id: `status-${e.id}`,
136+ account: `${e.action.startsWith("maintenance_") ? "maintenance" : "incident"}:${e.target}`,
137+ action: e.action,
138+ detail: e.detail,
139+ by: e.by,
140+ createdAt: e.at,
141+ }));
142+}
143+
144+/** Billing's lines and the status page's, newest first, one page's worth. */
145+export function mergeAudit(a: AdminAction[], b: AdminAction[], page = AUDIT_PAGE): AdminAction[] {
146+ return [...a, ...b].sort((x, y) => y.createdAt.localeCompare(x.createdAt)).slice(0, page);
147+}
148+
106149 export function actionLabel(action: string): string {
107150 return AUDIT_ACTIONS[action] ?? action.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase());
108151 }
+12−2
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { NAV, holdsCurrent, isCurrent, navItems, soonFor, soonItems } from "./nav.ts";
4+import { NAV, countFor, holdsCurrent, isCurrent, navItems, soonFor, soonItems } from "./nav.ts";
55
66 test("the sidebar: two top-level links, then sections that fold", () => {
77 assert.deepEqual(
4040
4141 test("the built pages are not marked soon", () => {
4242 const built = navItems().filter((item) => !item.soon).map((item) => item.to);
43− assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/requests", "/overages", "/velocity", "/invoices", "/stripe", "/abuse", "/audit"]);
43+ assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/requests", "/overages", "/velocity", "/invoices", "/stripe", "/abuse", "/incidents", "/audit"]);
4444 });
4545
4646 test("every soon page says what it will do, why, and what it will have", () => {
8080 assert.ok(!isCurrent(workspaces, "/workspaces-old"));
8181 assert.ok(isCurrent({ to: "/x", also: ["/y"] }, "/y/z"));
8282 });
83+
84+test("requests waiting show beside Invites, and on Customers while it is folded", () => {
85+ const invites = navItems().find((item) => item.to === "/invites")!;
86+ assert.equal(invites.count, "waitlist");
87+ assert.equal(countFor([invites], { waitlist: 4 }), 4);
88+ assert.equal(countFor([invites], {}), 0);
89+ const customers = NAV.find((group) => group.title === "Customers")!;
90+ assert.equal(countFor(customers.items, { waitlist: 4 }), 4);
91+ assert.equal(countFor(NAV.find((group) => group.title === "Team")!.items, { waitlist: 4 }), 0);
92+});
+21−0
2222 | "costs"
2323 | "agents"
2424 | "abuse"
25+ | "incidents"
2526 | "inbox"
2627 | "view-as"
2728 | "announcements"
6061 also?: string[];
6162 /** Not built yet: the page says what it will be. */
6263 soon?: Roadmap;
64+ /** A number the sidebar shows beside it while it is above zero, such as requests waiting. */
65+ count?: NavCount;
6366 };
6467
68+/** The numbers the sidebar can show, read by root.tsx on every page. */
69+export type NavCount = "waitlist" | "incidents";
70+
71+export type NavCounts = Partial<Record<NavCount, number>>;
72+
73+/** What a page or a folded section shows beside it: its items' counts, added up. */
74+export function countFor(items: Pick<NavItem, "count">[], counts: NavCounts): number {
75+ return items.reduce((sum, item) => sum + (item.count ? (counts[item.count] ?? 0) : 0), 0);
76+}
77+
6578 /**
6679 * A section of the sidebar: a row with an icon that opens to its pages,
6780 * as GitLab and Vercel fold theirs. One with no title is top-level links.
87100 to: "/invites",
88101 icon: "invites",
89102 about: "The waitlist, every invite, more invites for a person or workspace, and who invited whom.",
103+ count: "waitlist",
90104 },
91105 {
92106 label: "People",
247261 icon: "abuse",
248262 about: "Sandboxes stopped for looking like mining, and disputes and declines.",
249263 },
264+ {
265+ label: "Incidents",
266+ to: "/incidents",
267+ icon: "incidents",
268+ about: "Declare and run incidents, schedule maintenance, and publish postmortems on status.g1t.sh.",
269+ count: "incidents",
270+ },
250271 ],
251272 },
252273 {
+31−0
1+/**
2+ * The postmortem editor's sections, and its preview's reading of plain
3+ * text, the same way the status page reads it (apps/status render.ts
4+ * `prose`). No Workers or React imports, so it is tested under Node.
5+ */
6+import type { PostmortemFields } from "@g1t/contracts/status";
7+
8+export const POSTMORTEM_SECTIONS: { key: keyof PostmortemFields; title: string; hint: string; rows: number }[] = [
9+ { key: "summary", title: "Summary", hint: "Two or three sentences: what happened, to whom, for how long. Required to publish.", rows: 4 },
10+ { key: "impact", title: "Impact", hint: "Who noticed, and what they could not do.", rows: 3 },
11+ { key: "timeline", title: "Timeline", hint: "Filled in from the incident's timeline. Times are UTC.", rows: 8 },
12+ { key: "root_cause", title: "Root cause", hint: "Why it happened, not who. Required to publish.", rows: 4 },
13+ { key: "went_well", title: "What went well", hint: "", rows: 3 },
14+ { key: "went_badly", title: "What went badly", hint: "", rows: 3 },
15+ { key: "action_items", title: "Action items", hint: "Filled in from the follow-ups. One per line, starting “- ”.", rows: 4 },
16+];
17+
18+export type Block = { kind: "paragraph"; text: string } | { kind: "list"; items: string[] };
19+
20+/** Blank lines between paragraphs; a block whose lines all start with "- " is a list. */
21+export function proseBlocks(text: string): Block[] {
22+ return text
23+ .split(/\n{2,}/)
24+ .map((block) => block.replace(/^\n+|\n+$/g, ""))
25+ .filter(Boolean)
26+ .map((block): Block => {
27+ const lines = block.split("\n").filter((l) => l.trim() !== "");
28+ if (lines.every((l) => /^\s*[-*] /.test(l))) return { kind: "list", items: lines.map((l) => l.replace(/^\s*[-*] /, "")) };
29+ return { kind: "paragraph", text: block };
30+ });
31+}
+14−1
11 import { env } from "cloudflare:workers";
22
3−import { accountsAdminClient, billingAdminClient, billingClient, eventsClient, identityAdminClient } from "@g1t/contracts";
3+import {
4+ type StatusAdminApi,
5+ accountsAdminClient,
6+ billingAdminClient,
7+ billingClient,
8+ eventsClient,
9+ identityAdminClient,
10+} from "@g1t/contracts";
411
512 /** Staff-only billing, on the billing service. */
613 export const admin = billingAdminClient(env.BILLING);
1926
2027 /** The event log, read-only: sudo lists `abuse.flagged`. */
2128 export const events = eventsClient(env.EVENTS);
29+
30+/** The status page's incidents (apps/status's `StatusAdmin` entrypoint). */
31+export const statusAdmin = env.STATUS as unknown as StatusAdminApi;
32+
33+/** STAFF_EMAILS, for suggesting staff in the incident roles. */
34+export const staffEmails = (): string => env.STAFF_EMAILS ?? "";
+10−3
66 import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url";
77 import { MobileBar, Sidebar } from "./components/shell";
88 import { ButtonLink } from "./components/ui";
9+import type { NavCounts } from "./lib/nav";
10+import { identity, statusAdmin } from "./lib/services.server";
11+import { settle } from "./lib/settle";
912 import { requireStaff } from "./lib/staff";
1013
1114 export const links: Route.LinksFunction = () => [
2124 ];
2225
2326 export async function loader({ context }: Route.LoaderArgs) {
24− return { email: requireStaff(context).email };
27+ const { email } = requireStaff(context);
28+ // The sidebar's counts: a service that does not answer shows none.
29+ const [waitlist, incidents] = await Promise.all([settle(identity.waitlistPending()), settle(statusAdmin.openCount())]);
30+ const counts: NavCounts = { waitlist: waitlist.ok ? waitlist.value : 0, incidents: incidents.ok ? incidents.value : 0 };
31+ return { email, counts };
2532 }
2633
2734 export function Layout({ children }: { children: React.ReactNode }) {
3643 <Links />
3744 </head>
3845 <body className="min-h-screen">
39− <Sidebar email={root?.email} />
40− <MobileBar email={root?.email} />
46+ <Sidebar email={root?.email} counts={root?.counts} />
47+ <MobileBar email={root?.email} counts={root?.counts} />
4148 <div className="lg:pl-60">{children}</div>
4249 {/* No <Scripts />: sudo ships no JavaScript, and its policy allows none. */}
4350 </body>
+6−0
1919 route("invoices", "routes/invoices.tsx"),
2020 route("stripe", "routes/stripe.tsx"),
2121 route("audit", "routes/audit.tsx"),
22+ route("incidents", "routes/incidents.tsx"),
23+ route("incidents/new", "routes/incident-new.tsx"),
24+ route("incidents/maintenance/new", "routes/maintenance-new.tsx"),
25+ route("incidents/maintenance/:id", "routes/maintenance.tsx"),
26+ route("incidents/:id", "routes/incident.tsx"),
27+ route("incidents/:id/postmortem", "routes/incident-postmortem.tsx"),
2228 // What sudo will have: one placeholder page each, from lib/nav.ts.
2329 ...soonItems().map((item) => route(item.to.slice(1), "routes/soon.tsx", { id: `soon${item.to.replace(/\//g, "-")}` })),
2430 // Before sudo was organised around workspaces, everything was an account.
+11−4
1212 accountPath,
1313 actionLabel,
1414 auditHref,
15+ mergeAudit,
1516 olderBefore,
1617 parseAction,
1718 parseBefore,
1819 parseBy,
20+ statusAuditActions,
1921 } from "~/lib/ledgers";
20−import { admin } from "~/lib/services.server";
22+import { admin, statusAdmin } from "~/lib/services.server";
2123 import { settle } from "~/lib/settle";
2224 import { requireStaff } from "~/lib/staff";
2325
3032 const action = parseAction(url.searchParams.get("action"));
3133 const before = parseBefore(url.searchParams.get("before"));
3234 // Enterprises' names, so a line about one reads as its name, not its id.
33− const [result, accounts] = await Promise.all([
35+ // The status page keeps its own log (incidents, maintenance, postmortems), merged in here.
36+ const [result, accounts, status] = await Promise.all([
3437 settle(admin.audit({ by: by ?? undefined, action: action ?? undefined, before: before ?? undefined })),
3538 settle(admin.accounts()),
39+ settle(statusAdmin.audit({ before })),
3640 ]);
3741 const names = Object.fromEntries(
3842 (accounts.ok ? accounts.value : []).filter((row) => row.account.kind === "enterprise").map((row) => [row.account.id, row.account.name]),
3943 );
40− const actions = result.ok ? result.value : [];
44+ const fromStatus = statusAuditActions(status.ok ? status.value : []).filter(
45+ (entry) => (!by || entry.by.toLowerCase() === by) && (!action || entry.action === action),
46+ );
47+ const actions = mergeAudit(result.ok ? result.value : [], fromStatus);
4148 return {
4249 me: staff.email,
4350 by,
6471 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
6572 <PageHeader
6673 title="Audit log"
67− description="Every change made in sudo, and what Stripe told billing, newest first, across every customer. Each names who made it."
74+ description="Every change made in sudo, what Stripe told billing, and every incident and maintenance change on the status page, newest first. Each names who made it."
6875 />
6976
7077 <form method="get" action="/audit" className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center">
+159−0
1+import { redirect } from "react-router";
2+
3+import type { IncidentSeverity, IncidentStatus } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/incident-new";
6+import { BackLink, ImpactPicker } from "~/components/incidents";
7+import { Button, Field, Input, Notice, PageHeader, Section, Select, Textarea } from "~/components/ui";
8+import { text } from "~/lib/forms";
9+import { INCIDENT_SEVERITIES, INCIDENT_STATUSES, readImpacts, staffSuggestions, utc, wantsNotify } from "~/lib/incidents";
10+import { staffEmails, statusAdmin } from "~/lib/services.server";
11+import { settle } from "~/lib/settle";
12+import { requireStaff } from "~/lib/staff";
13+
14+export const meta: Route.MetaFunction = () => [{ title: "Declare an incident · sudo" }, { name: "robots", content: "noindex, nofollow" }];
15+
16+export async function loader({ context }: Route.LoaderArgs) {
17+ const staff = requireStaff(context);
18+ const components = await settle(statusAdmin.components());
19+ return {
20+ me: staff.email,
21+ components: components.ok ? components.value : [],
22+ error: components.ok ? null : components.error,
23+ staff: staffSuggestions(staffEmails(), [staff.email]),
24+ };
25+}
26+
27+type ActionData = { error: string; values: Record<string, string> };
28+
29+export async function action({ request, context }: Route.ActionArgs) {
30+ const staff = requireStaff(context);
31+ const form = await request.formData();
32+ const components = await settle(statusAdmin.components());
33+ const keys = components.ok ? components.value.map((c) => c.key) : [];
34+ const values = Object.fromEntries(["title", "severity", "status", "message", "started_at", "commander", "communications", "notify"].map((k) => [k, text(form, k)]));
35+ const result = await settle(
36+ statusAdmin.declare({
37+ title: text(form, "title"),
38+ severity: text(form, "severity") as IncidentSeverity,
39+ status: text(form, "status") as IncidentStatus,
40+ components: readImpacts(form, keys),
41+ message: String(form.get("message") ?? ""),
42+ started_at: utc(text(form, "started_at")),
43+ commander: text(form, "commander") || null,
44+ communications: text(form, "communications") || null,
45+ notify: wantsNotify(text(form, "notify"), text(form, "severity") as IncidentSeverity),
46+ by: staff.email,
47+ }),
48+ );
49+ if (!result.ok) return { error: result.error, values } satisfies ActionData;
50+ if (!result.value.ok) return { error: result.value.error.message, values } satisfies ActionData;
51+ return redirect(`/incidents/${result.value.value.id}?declared=1`);
52+}
53+
54+export default function DeclareIncident({ loaderData, actionData }: Route.ComponentProps) {
55+ const { me, components, error, staff } = loaderData;
56+ const failed = actionData as ActionData | undefined;
57+ const v = failed?.values ?? {};
58+
59+ return (
60+ <main className="mx-auto max-w-3xl px-4 py-8 sm:py-10">
61+ <BackLink to="/incidents">Incidents</BackLink>
62+ <div className="mt-3">
63+ <PageHeader
64+ title="Declare an incident"
65+ description="It goes on status.g1t.sh at once with its first update, and the parts you mark show their impact there until it is resolved."
66+ />
67+ </div>
68+ <div className="mt-5 space-y-3">
69+ {error && <Notice tone="warn">The status worker did not answer: {error}</Notice>}
70+ {failed && <Notice tone="error">{failed.error}</Notice>}
71+ </div>
72+
73+ <form method="post" className="mt-6 space-y-6">
74+ <Section title="What is happening">
75+ <div className="grid gap-4 sm:grid-cols-2">
76+ <Field label="Title" className="sm:col-span-2" hint="What people notice, not the internals. Shown publicly.">
77+ <Input name="title" required maxLength={120} autoFocus defaultValue={v.title} placeholder="Pushes over HTTPS failing" />
78+ </Field>
79+ <fieldset className="sm:col-span-2">
80+ <legend className="mb-1.5 text-sm font-medium text-muted">Severity</legend>
81+ <div className="grid gap-2 sm:grid-cols-2">
82+ {INCIDENT_SEVERITIES.map((s) => (
83+ <label
84+ key={s.value}
85+ className="flex cursor-pointer gap-2.5 rounded-md border border-line px-3 py-2 has-checked:border-merged/60 has-checked:bg-merged/8"
86+ >
87+ <input type="radio" name="severity" value={s.value} required defaultChecked={(v.severity || "sev3") === s.value} className="mt-1 accent-[#b6a8ff]" />
88+ <span>
89+ <span className="block text-sm font-semibold">{s.label}</span>
90+ <span className="block text-xs text-muted">{s.about}</span>
91+ </span>
92+ </label>
93+ ))}
94+ </div>
95+ </fieldset>
96+ <Field label="Status" hint="Investigating: the cause is not known yet. Identified: a fix is under way. Monitoring: a fix is out.">
97+ <Select name="status" defaultValue={v.status || "investigating"}>
98+ {INCIDENT_STATUSES.filter((s) => s.value !== "resolved").map((s) => (
99+ <option key={s.value} value={s.value}>
100+ {s.label}
101+ </option>
102+ ))}
103+ </Select>
104+ </Field>
105+ <Field label="Impact began (UTC)" hint="Leave empty for now. Up to 90 days back; durations count from here.">
106+ <Input type="datetime-local" name="started_at" defaultValue={v.started_at} />
107+ </Field>
108+ </div>
109+ </Section>
110+
111+ <Section title="Parts affected" description="Mark each affected part. They show this on the status page, or worse if their checks say so.">
112+ <ImpactPicker components={components} current={new Map()} />
113+ </Section>
114+
115+ <Section title="First public update" description="Shown on the status page with the status above.">
116+ <Textarea
117+ name="message"
118+ rows={4}
119+ required
120+ maxLength={4000}
121+ defaultValue={v.message}
122+ aria-label="First public update"
123+ placeholder="We are looking into reports that pushes over HTTPS fail with a 502. Pulls and the website are not affected."
124+ />
125+ <Field label="Email subscribers" className="mt-3 sm:w-80" hint="Subscribers who chose other parts are not emailed.">
126+ <Select name="notify" defaultValue={v.notify || "auto"}>
127+ <option value="auto">As the severity says: yes for SEV1 and SEV2</option>
128+ <option value="yes">Yes</option>
129+ <option value="no">No</option>
130+ </Select>
131+ </Field>
132+ </Section>
133+
134+ <Section title="Roles" description="Staff emails. Change them any time from the incident.">
135+ <div className="grid gap-4 sm:grid-cols-2">
136+ <Field label="Incident commander" hint="Runs the response and decides.">
137+ <Input name="commander" type="email" list="staff" defaultValue={v.commander ?? me} placeholder={me} />
138+ </Field>
139+ <Field label="Communications" hint="Writes the public updates.">
140+ <Input name="communications" type="email" list="staff" defaultValue={v.communications} placeholder="Optional" />
141+ </Field>
142+ </div>
143+ <datalist id="staff">
144+ {staff.map((email) => (
145+ <option key={email} value={email} />
146+ ))}
147+ </datalist>
148+ </Section>
149+
150+ <div className="flex flex-wrap items-center gap-3">
151+ <Button type="submit" variant="lavender">
152+ Declare and publish
153+ </Button>
154+ <p className="text-xs text-faint">Recorded in the audit log with your email.</p>
155+ </div>
156+ </form>
157+ </main>
158+ );
159+}
+194−0
1+import { ExternalLink } from "lucide-react";
2+import { redirect } from "react-router";
3+
4+import type { PostmortemFields } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/incident-postmortem";
7+import { BackLink, SeverityBadge } from "~/components/incidents";
8+import { Badge, Button, Field, Notice, PageHeader, Section, Textarea, When } from "~/components/ui";
9+import { duration } from "~/lib/incidents";
10+import { POSTMORTEM_SECTIONS, proseBlocks } from "~/lib/postmortem";
11+import { statusAdmin } from "~/lib/services.server";
12+import { settle } from "~/lib/settle";
13+import { requireStaff } from "~/lib/staff";
14+
15+export const meta: Route.MetaFunction = ({ loaderData }) => [
16+ { title: `Postmortem: ${loaderData?.incident?.title ?? "incident"} · sudo` },
17+ { name: "robots", content: "noindex, nofollow" },
18+];
19+
20+export async function loader({ params, request, context }: Route.LoaderArgs) {
21+ requireStaff(context);
22+ const incident = await settle(statusAdmin.incident(params.id));
23+ if (incident.ok && !incident.value) throw new Response("No such incident.", { status: 404 });
24+ const done = new URL(request.url).searchParams.get("done");
25+ return {
26+ incident: incident.ok ? incident.value : null,
27+ error: incident.ok ? null : incident.error,
28+ done: done === "saved" ? "Draft saved. The preview shows it." : done === "published" ? "Published on the incident's page." : done === "unpublished" ? "Taken down from the status page." : null,
29+ };
30+}
31+
32+type ActionData = { error: string; values?: PostmortemFields };
33+
34+export async function action({ params, request, context }: Route.ActionArgs) {
35+ const staff = requireStaff(context);
36+ const form = await request.formData();
37+ const intent = String(form.get("intent") ?? "");
38+ const values = Object.fromEntries(POSTMORTEM_SECTIONS.map((s) => [s.key, String(form.get(s.key) ?? "")])) as PostmortemFields;
39+ if (intent === "save" || intent === "save_publish") {
40+ const saved = await settle(statusAdmin.savePostmortem(params.id, { ...values, by: staff.email }));
41+ if (!saved.ok) return { error: saved.error, values } satisfies ActionData;
42+ if (!saved.value.ok) return { error: saved.value.error.message, values } satisfies ActionData;
43+ if (intent === "save") return redirect(`/incidents/${params.id}/postmortem?done=saved#preview`);
44+ }
45+ if (intent === "save_publish" || intent === "publish" || intent === "unpublish") {
46+ const publish = intent !== "unpublish";
47+ const result = await settle(statusAdmin.publishPostmortem(params.id, { publish, by: staff.email }));
48+ if (!result.ok) return { error: result.error } satisfies ActionData;
49+ if (!result.value.ok) return { error: result.value.error.message } satisfies ActionData;
50+ return redirect(`/incidents/${params.id}/postmortem?done=${publish ? "published" : "unpublished"}`);
51+ }
52+ return { error: "Unknown action." } satisfies ActionData;
53+}
54+
55+function Preview({ fields }: { fields: PostmortemFields }) {
56+ const shown = POSTMORTEM_SECTIONS.filter((s) => fields[s.key].trim());
57+ if (!shown.length) return <p className="text-sm text-muted">Nothing written yet.</p>;
58+ return (
59+ <div className="space-y-5">
60+ {shown.map((s) => (
61+ <section key={s.key}>
62+ <h3 className="text-sm font-semibold">{s.title}</h3>
63+ <div className="mt-1 space-y-2 text-sm text-fg-soft">
64+ {proseBlocks(fields[s.key]).map((block, i) =>
65+ block.kind === "list" ? (
66+ <ul key={i} className="list-disc space-y-1 pl-5">
67+ {block.items.map((item, j) => (
68+ <li key={j}>{item}</li>
69+ ))}
70+ </ul>
71+ ) : (
72+ <p key={i} className="whitespace-pre-line">
73+ {block.text}
74+ </p>
75+ ),
76+ )}
77+ </div>
78+ </section>
79+ ))}
80+ </div>
81+ );
82+}
83+
84+export default function PostmortemEditor({ loaderData, actionData }: Route.ComponentProps) {
85+ const { incident, error, done } = loaderData;
86+ const failed = actionData as ActionData | undefined;
87+ if (!incident) {
88+ return (
89+ <main className="mx-auto max-w-6xl px-4 py-8">
90+ <Notice tone="warn">The status worker did not answer: {error}</Notice>
91+ </main>
92+ );
93+ }
94+ const saved = incident.postmortem;
95+ const fields: PostmortemFields = failed?.values ?? saved ?? incident.postmortem_draft;
96+ const published = saved?.published_at != null;
97+ const resolved = incident.resolved_at != null;
98+
99+ return (
100+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
101+ <BackLink to={`/incidents/${incident.id}`}>{incident.title}</BackLink>
102+ <div className="mt-3">
103+ <PageHeader
104+ title="Postmortem"
105+ description={
106+ <>
107+ <SeverityBadge severity={incident.severity} /> <span className="ml-1">{incident.title}</span> · lasted {duration(incident.durations.to_resolve)}
108+ </>
109+ }
110+ actions={
111+ published ? (
112+ <a href={`${incident.url}#postmortem`} className="inline-flex items-center gap-1.5 text-sm text-merged hover:underline">
113+ On the status page <ExternalLink size={13} aria-hidden="true" />
114+ </a>
115+ ) : undefined
116+ }
117+ />
118+ </div>
119+ <div className="mt-4 space-y-3">
120+ {done && <Notice tone="ok">{done}</Notice>}
121+ {failed && <Notice tone="error">{failed.error}</Notice>}
122+ {!resolved && <Notice tone="info">The incident is still open. Draft away; it can be published once it is resolved.</Notice>}
123+ {!saved && (
124+ <Notice tone="info">
125+ Started for you: the impact from its parts and times, the timeline from the incident's own, and the follow-ups as action items. The timeline includes internal notes: edit out anything that should stay inside.
126+ </Notice>
127+ )}
128+ </div>
129+
130+ <div className="mt-6 grid gap-6 lg:grid-cols-2">
131+ <form method="post" className="min-w-0 space-y-4">
132+ <Section title="Write" description="Plain text. A blank line starts a paragraph; lines starting with “- ” become a list.">
133+ <div className="space-y-4">
134+ {POSTMORTEM_SECTIONS.map((s) => (
135+ <Field key={s.key} label={s.title} hint={s.hint}>
136+ <Textarea name={s.key} rows={s.rows} maxLength={20000} defaultValue={fields[s.key]} className="font-[inherit]" />
137+ </Field>
138+ ))}
139+ </div>
140+ </Section>
141+ <div className="flex flex-wrap items-center gap-2">
142+ <Button type="submit" name="intent" value="save" variant="quiet">
143+ Save draft
144+ </Button>
145+ {resolved && !published && (
146+ <Button type="submit" name="intent" value="save_publish" variant="lavender">
147+ Save and publish
148+ </Button>
149+ )}
150+ {saved && (
151+ <span className="text-xs text-faint">
152+ Saved <When at={saved.updated_at} time /> by <span className="font-mono">{saved.updated_by}</span>
153+ </span>
154+ )}
155+ </div>
156+ </form>
157+
158+ <div className="min-w-0 space-y-4 lg:sticky lg:top-6 lg:self-start">
159+ <Section
160+ id="preview"
161+ title="Preview"
162+ description={saved ? "As the status page shows it, from the last save." : "As the status page would show it."}
163+ actions={published ? <Badge tone="mint">Published</Badge> : <Badge>Not published</Badge>}
164+ >
165+ <Preview fields={saved ?? fields} />
166+ </Section>
167+ {saved && (
168+ <form method="post" className="flex flex-wrap items-center gap-3">
169+ {published ? (
170+ <>
171+ <input type="hidden" name="intent" value="unpublish" />
172+ <Button type="submit" variant="danger">
173+ Take down
174+ </Button>
175+ <span className="text-xs text-faint">
176+ Published <When at={saved.published_at} time />
177+ </span>
178+ </>
179+ ) : (
180+ <>
181+ <input type="hidden" name="intent" value="publish" />
182+ <Button type="submit" variant="lavender" disabled={!resolved}>
183+ Publish the saved draft
184+ </Button>
185+ <span className="text-xs text-faint">Needs a summary and a root cause.</span>
186+ </>
187+ )}
188+ </form>
189+ )}
190+ </div>
191+ </div>
192+ </main>
193+ );
194+}
+493−0
1+import { CheckCircle2, Circle, CircleDot, ExternalLink, FileText, Globe, Lock, Radar } from "lucide-react";
2+import { Link, redirect } from "react-router";
3+
4+import type { AdminIncidentDetail, IncidentSeverity, IncidentStatus, TimelineEntry } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/incident";
7+import { BackLink, ImpactBadge, ImpactPicker, PhaseBadge, SeverityBadge, Timer } from "~/components/incidents";
8+import { Badge, Button, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
9+import { text } from "~/lib/forms";
10+import {
11+ INCIDENT_SEVERITIES,
12+ INCIDENT_STATUSES,
13+ duration,
14+ nextStatus,
15+ notifyByDefault,
16+ readImpacts,
17+ secondsBetween,
18+ severityLabel,
19+ staffSuggestions,
20+ statusLabel,
21+} from "~/lib/incidents";
22+import { staffEmails, statusAdmin } from "~/lib/services.server";
23+import { settle } from "~/lib/settle";
24+import { requireStaff } from "~/lib/staff";
25+
26+export const meta: Route.MetaFunction = ({ loaderData }) => [
27+ { title: `${loaderData?.incident?.title ?? "Incident"} · sudo` },
28+ { name: "robots", content: "noindex, nofollow" },
29+];
30+
31+/** What the page says after a change, by the code in `?done=`. */
32+const DONE: Record<string, string> = {
33+ declared: "Declared and published. status.g1t.sh shows it within 30 seconds.",
34+ update: "Update posted. The status page shows it within 30 seconds.",
35+ note: "Note added. Only staff see it.",
36+ resolved: "Resolved. Write the postmortem while it is fresh.",
37+ roles: "Roles saved.",
38+ published: "Published to the status page.",
39+ dismissed: "Dismissed. It never appeared on the status page.",
40+ followup: "Follow-up saved.",
41+};
42+
43+export async function loader({ params, request, context }: Route.LoaderArgs) {
44+ const staff = requireStaff(context);
45+ const [incident, components] = await Promise.all([settle(statusAdmin.incident(params.id)), settle(statusAdmin.components())]);
46+ if (incident.ok && !incident.value) throw new Response("No such incident.", { status: 404 });
47+ const url = new URL(request.url);
48+ const done = url.searchParams.get("declared") ? "declared" : (url.searchParams.get("done") ?? "");
49+ const detail = incident.ok ? incident.value : null;
50+ return {
51+ me: staff.email,
52+ incident: detail,
53+ components: components.ok ? components.value : [],
54+ error: incident.ok ? null : incident.error,
55+ done: DONE[done] ?? null,
56+ staff: staffSuggestions(staffEmails(), [staff.email, detail?.commander, detail?.communications, ...(detail?.followups.map((f) => f.owner) ?? [])]),
57+ now: new Date().toISOString(),
58+ };
59+}
60+
61+type ActionData = { error: string; intent: string };
62+
63+export async function action({ params, request, context }: Route.ActionArgs) {
64+ const staff = requireStaff(context);
65+ const form = await request.formData();
66+ const intent = text(form, "intent");
67+ const id = params.id ?? "";
68+ const back = (done: string, anchor = "") => redirect(`/incidents/${id}?done=${done}${anchor}`);
69+ const failed = (error: string) => ({ error, intent }) satisfies ActionData;
70+ const unwrap = async <T,>(call: Promise<{ ok: true; value: T } | { ok: false; error: { message: string } }>) => {
71+ const result = await settle(call);
72+ if (!result.ok) return { ok: false as const, error: result.error };
73+ return result.value.ok ? { ok: true as const, value: result.value.value } : { ok: false as const, error: result.value.error.message };
74+ };
75+
76+ if (intent === "update" || intent === "resolve") {
77+ const components = await settle(statusAdmin.components());
78+ const keys = components.ok ? components.value.map((c) => c.key) : [];
79+ const isPublic = intent === "resolve" || text(form, "visibility") === "public";
80+ const status = intent === "resolve" ? "resolved" : (text(form, "status") as IncidentStatus) || null;
81+ const r = await unwrap(
82+ statusAdmin.update(id, {
83+ public: isPublic,
84+ message: String(form.get("message") ?? ""),
85+ status,
86+ severity: (text(form, "severity") as IncidentSeverity) || null,
87+ impacts: intent === "resolve" ? [] : readImpacts(form, keys),
88+ notify: isPublic && form.get("notify") === "on",
89+ by: staff.email,
90+ }),
91+ );
92+ if (!r.ok) return failed(r.error);
93+ return back(status === "resolved" ? "resolved" : isPublic ? "update" : "note");
94+ }
95+ if (intent === "roles") {
96+ const r = await unwrap(statusAdmin.roles(id, { commander: text(form, "commander") || null, communications: text(form, "communications") || null, by: staff.email }));
97+ return !r.ok ? failed(r.error) : back("roles", "#roles");
98+ }
99+ if (intent === "publish") {
100+ const r = await unwrap(
101+ statusAdmin.publish(id, { title: text(form, "title") || null, message: String(form.get("message") ?? ""), notify: form.get("notify") === "on", by: staff.email }),
102+ );
103+ return !r.ok ? failed(r.error) : back("published");
104+ }
105+ if (intent === "dismiss") {
106+ const r = await unwrap(statusAdmin.dismiss(id, { reason: text(form, "reason"), by: staff.email }));
107+ return !r.ok ? failed(r.error) : back("dismissed");
108+ }
109+ if (intent === "followup_add") {
110+ const r = await unwrap(statusAdmin.addFollowUp(id, { title: text(form, "title"), owner: text(form, "owner") || null, by: staff.email }));
111+ return !r.ok ? failed(r.error) : back("followup", "#followups");
112+ }
113+ if (intent === "followup_set") {
114+ const r = await unwrap(statusAdmin.setFollowUp(id, text(form, "followup"), { done: text(form, "done") === "1", by: staff.email }));
115+ return !r.ok ? failed(r.error) : back("followup", "#followups");
116+ }
117+ return failed("Unknown action.");
118+}
119+
120+const KIND_ICON = { update: Globe, note: Lock, detected: Radar, followup: CheckCircle2, postmortem: FileText } as const;
121+
122+function EntryLine({ entry }: { entry: TimelineEntry }) {
123+ const who = entry.by === "status" ? "the checks" : entry.by;
124+ const meta = (
125+ <span className="text-xs text-faint">
126+ <When at={entry.at} time /> · <span className="font-mono">{who}</span>
127+ </span>
128+ );
129+ if (entry.kind === "update") {
130+ return (
131+ <li className="rounded-md border border-merged/30 bg-merged/6 px-3.5 py-3">
132+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
133+ <Globe size={14} aria-hidden="true" className="text-merged" />
134+ <span className="text-xs font-semibold text-merged">Public update</span>
135+ {entry.status && <Badge tone="lavender">{statusLabel(entry.status)}</Badge>}
136+ {meta}
137+ {entry.notified != null && <span className="text-xs text-faint">· emailed {entry.notified}</span>}
138+ </div>
139+ <p className="mt-1.5 text-sm whitespace-pre-line text-fg">{entry.text}</p>
140+ </li>
141+ );
142+ }
143+ if (entry.kind === "note") {
144+ return (
145+ <li className="rounded-md border border-line bg-raised/40 px-3.5 py-3">
146+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
147+ <Lock size={13} aria-hidden="true" className="text-faint" />
148+ <span className="text-xs font-medium text-muted">Internal note</span>
149+ {meta}
150+ </div>
151+ <p className="mt-1.5 text-sm whitespace-pre-line text-fg-soft">{entry.text}</p>
152+ </li>
153+ );
154+ }
155+ const Icon = KIND_ICON[entry.kind as keyof typeof KIND_ICON] ?? CircleDot;
156+ const loud = entry.kind === "detected" || entry.kind === "failing";
157+ return (
158+ <li className="flex gap-2.5 px-1 py-1">
159+ <Icon size={13} aria-hidden="true" className={`mt-1 shrink-0 ${loud ? "text-warn" : "text-faint"}`} />
160+ <p className="min-w-0 text-sm text-muted">
161+ <span className={loud ? "text-fg-soft" : ""}>{entry.text}</span> {meta}
162+ </p>
163+ </li>
164+ );
165+}
166+
167+function Header({ incident, now }: { incident: AdminIncidentDetail; now: string }) {
168+ const open = incident.resolved_at == null;
169+ const d = incident.durations;
170+ return (
171+ <>
172+ <div className="mt-3 flex flex-wrap items-center gap-2">
173+ <SeverityBadge severity={incident.severity} />
174+ <PhaseBadge incident={incident} />
175+ {incident.source === "detected" && <Badge tone="warn">Detected by the checks</Badge>}
176+ {incident.visibility !== "public" && <Badge>Not on the status page</Badge>}
177+ </div>
178+ <h1 className="mt-2 text-2xl font-semibold tracking-tight break-words">{incident.title}</h1>
179+ <p className="mt-1 text-sm text-muted">
180+ Impact began <When at={incident.started_at} time /> · declared by <span className="font-mono text-xs">{incident.created_by === "status" ? "the checks" : incident.created_by}</span>
181+ {incident.commander && (
182+ <>
183+ {" "}· IC <span className="font-mono text-xs">{incident.commander}</span>
184+ </>
185+ )}
186+ {incident.communications && (
187+ <>
188+ {" "}· comms <span className="font-mono text-xs">{incident.communications}</span>
189+ </>
190+ )}
191+ </p>
192+ <div className="mt-4 grid grid-cols-2 divide-line rounded-lg border border-line bg-surface sm:grid-cols-4 sm:divide-x [&>*:nth-child(-n+2)]:border-b [&>*:nth-child(-n+2)]:border-line sm:[&>*:nth-child(-n+2)]:border-b-0">
193+ <Timer
194+ label={open ? "Open for" : "Lasted"}
195+ value={duration(open ? secondsBetween(incident.started_at, now) : d.to_resolve)}
196+ tone={open ? "warn" : "mint"}
197+ hint={open ? `${severityLabel(incident.severity)} · ${statusLabel(incident.status)}` : <When at={incident.resolved_at} time />}
198+ />
199+ <Timer label="To acknowledge" value={duration(d.to_acknowledge)} hint={incident.acknowledged_at ? <When at={incident.acknowledged_at} time /> : "Not yet"} />
200+ <Timer label="To mitigate" value={duration(d.to_mitigate)} hint={incident.mitigated_at ? <When at={incident.mitigated_at} time /> : "At monitoring"} />
201+ <Timer label="To resolve" value={duration(d.to_resolve)} hint={incident.resolved_at ? <When at={incident.resolved_at} time /> : "Still open"} />
202+ </div>
203+ </>
204+ );
205+}
206+
207+function UpdateForm({ incident, components, error }: { incident: AdminIncidentDetail; components: { key: string; name: string }[]; error: string | null }) {
208+ const draft = incident.visibility === "draft";
209+ const current = new Map(incident.components.map((c) => [c.key, c.impact]));
210+ return (
211+ <Section id="update" title={draft ? "Add a note" : "Post an update"} description={draft ? "Drafts take notes and changes; publish it to post publicly." : "A public update goes on the status page; a note stays here."}>
212+ <form method="post" action="#update" className="space-y-4">
213+ <input type="hidden" name="intent" value="update" />
214+ {error && <Notice tone="error">{error}</Notice>}
215+ {!draft && (
216+ <fieldset className="flex flex-wrap gap-2" aria-label="Who sees it">
217+ <label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm has-checked:border-merged/60 has-checked:bg-merged/10">
218+ <input type="radio" name="visibility" value="public" defaultChecked className="accent-[#b6a8ff]" />
219+ <Globe size={14} aria-hidden="true" /> Public update
220+ </label>
221+ <label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm has-checked:border-line-strong has-checked:bg-raised">
222+ <input type="radio" name="visibility" value="internal" className="accent-[#b6a8ff]" />
223+ <Lock size={14} aria-hidden="true" /> Internal note
224+ </label>
225+ </fieldset>
226+ )}
227+ <div className="grid gap-3 sm:grid-cols-2">
228+ <Field label="Status" hint={draft ? undefined : "Changing it needs a public update."}>
229+ <Select name="status" defaultValue="">
230+ <option value="">Keep: {statusLabel(incident.status)}</option>
231+ {INCIDENT_STATUSES.filter((s) => s.value !== incident.status).map((s) => (
232+ <option key={s.value} value={s.value}>
233+ {s.label}
234+ {s.value === nextStatus(incident.status) ? " (next)" : ""}
235+ </option>
236+ ))}
237+ </Select>
238+ </Field>
239+ <Field label="Severity">
240+ <Select name="severity" defaultValue="">
241+ <option value="">Keep: {severityLabel(incident.severity)}</option>
242+ {INCIDENT_SEVERITIES.filter((s) => s.value !== incident.severity).map((s) => (
243+ <option key={s.value} value={s.value}>
244+ {s.label}
245+ </option>
246+ ))}
247+ </Select>
248+ </Field>
249+ </div>
250+ <details className="group rounded-md">
251+ <summary className="cursor-pointer text-sm text-muted select-none hover:text-fg">Change the parts' impact</summary>
252+ <div className="mt-2">
253+ <ImpactPicker components={components} current={current} blank />
254+ </div>
255+ </details>
256+ <Field label="Message">
257+ <Textarea name="message" rows={4} maxLength={4000} placeholder={draft ? "What you found, for the team." : "What changed, in a sentence or two, for the people affected."} />
258+ </Field>
259+ <div className="flex flex-wrap items-center justify-between gap-3">
260+ {!draft ? (
261+ <label className="flex items-center gap-2 text-sm">
262+ <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" />
263+ Email subscribers (public updates only)
264+ </label>
265+ ) : (
266+ <span />
267+ )}
268+ <Button type="submit" variant="lavender">
269+ Post
270+ </Button>
271+ </div>
272+ </form>
273+ </Section>
274+ );
275+}
276+
277+export default function Incident({ loaderData, actionData }: Route.ComponentProps) {
278+ const { incident, components, error, done, staff, now } = loaderData;
279+ const failed = actionData as ActionData | undefined;
280+ const errorFor = (intent: string) => (failed?.intent === intent ? failed.error : null);
281+ if (!incident) {
282+ return (
283+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
284+ <BackLink to="/incidents">Incidents</BackLink>
285+ <div className="mt-4">
286+ <Notice tone="warn">The status worker did not answer: {error}</Notice>
287+ </div>
288+ </main>
289+ );
290+ }
291+ const names = new Map(components.map((c) => [c.key, c.name]));
292+ const open = incident.resolved_at == null;
293+ const isPublic = incident.visibility === "public";
294+ const affected = incident.components.filter((c) => c.impact !== "operational");
295+ const timeline = [...incident.timeline].reverse();
296+
297+ return (
298+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
299+ <BackLink to={incident.visibility === "draft" ? "/incidents?tab=drafts" : open ? "/incidents" : "/incidents?tab=resolved"}>Incidents</BackLink>
300+ <Header incident={incident} now={now} />
301+
302+ <div className="mt-4 space-y-3">
303+ {done && <Notice tone="ok">{done}</Notice>}
304+ {failed && !["update", "roles", "followup_add", "followup_set", "resolve", "publish", "dismiss"].includes(failed.intent) && <Notice tone="error">{failed.error}</Notice>}
305+ </div>
306+
307+ {incident.visibility === "draft" && (
308+ <section className="mt-5 rounded-lg border border-warn/35 bg-warn/6 p-4 sm:p-5">
309+ <h2 className="font-semibold">This is a draft</h2>
310+ <p className="mt-1 text-sm text-muted">
311+ {incident.source === "detected"
312+ ? "The checks failed several times in a row and made this. Nobody outside sees it until you publish it. If it was a blip, dismiss it."
313+ : "Nobody outside sees it until you publish it."}
314+ </p>
315+ <div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_18rem]">
316+ <form method="post" className="space-y-3">
317+ <input type="hidden" name="intent" value="publish" />
318+ {errorFor("publish") && <Notice tone="error">{errorFor("publish")}</Notice>}
319+ <Field label="Public title">
320+ <Input name="title" defaultValue={incident.title.replace(/^Detected: /, "")} maxLength={120} required />
321+ </Field>
322+ <Field label="First public update">
323+ <Textarea name="message" rows={3} required maxLength={4000} placeholder="We are looking into reports that…" />
324+ </Field>
325+ <div className="flex flex-wrap items-center justify-between gap-3">
326+ <label className="flex items-center gap-2 text-sm">
327+ <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" />
328+ Email subscribers
329+ </label>
330+ <Button type="submit" variant="lavender">
331+ Publish to the status page
332+ </Button>
333+ </div>
334+ </form>
335+ <form method="post" className="space-y-3 lg:border-l lg:border-warn/20 lg:pl-4">
336+ <input type="hidden" name="intent" value="dismiss" />
337+ {errorFor("dismiss") && <Notice tone="error">{errorFor("dismiss")}</Notice>}
338+ <Field label="Not an incident?">
339+ <Input name="reason" maxLength={500} placeholder="A blip: answered again within minutes" />
340+ </Field>
341+ <Button type="submit" variant="quiet">
342+ Dismiss draft
343+ </Button>
344+ </form>
345+ </div>
346+ </section>
347+ )}
348+
349+ <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
350+ <div className="min-w-0 space-y-6">
351+ {incident.visibility !== "dismissed" && <UpdateForm incident={incident} components={components} error={errorFor("update")} />}
352+ <Section title="Timeline" description="Newest first. Public updates are what the status page shows; everything else is for staff.">
353+ <ol className="space-y-2">
354+ {timeline.map((entry) => (
355+ <EntryLine key={entry.id} entry={entry} />
356+ ))}
357+ </ol>
358+ </Section>
359+ </div>
360+
361+ <aside className="min-w-0 space-y-6">
362+ {open && isPublic && (
363+ <Section id="resolve" title="Resolve" description="Closes it, posts a last public update, and stops colouring the parts.">
364+ <form method="post" action="#resolve" className="space-y-3">
365+ <input type="hidden" name="intent" value="resolve" />
366+ {errorFor("resolve") && <Notice tone="error">{errorFor("resolve")}</Notice>}
367+ <Textarea name="message" rows={3} required maxLength={4000} aria-label="Resolution update" placeholder="Pushes work again. A bad deploy was rolled back; nothing was lost." />
368+ <label className="flex items-center gap-2 text-sm">
369+ <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" />
370+ Email subscribers
371+ </label>
372+ <Button type="submit" variant="lavender" className="w-full">
373+ <CheckCircle2 size={15} aria-hidden="true" /> Resolve incident
374+ </Button>
375+ </form>
376+ </Section>
377+ )}
378+
379+ {!open && isPublic && (
380+ <Section title="Postmortem" description={incident.postmortem_published_at ? "Published on the incident's page." : incident.postmortem ? "Drafted, not published." : "Not started."}>
381+ <div className="flex flex-wrap items-center gap-2">
382+ <Link to={`/incidents/${incident.id}/postmortem`} className="inline-flex items-center gap-1.5 text-sm font-medium text-merged hover:underline">
383+ <FileText size={14} aria-hidden="true" />
384+ {incident.postmortem ? "Edit the postmortem" : "Write the postmortem"}
385+ </Link>
386+ {incident.postmortem_published_at && <Badge tone="mint">Published</Badge>}
387+ </div>
388+ </Section>
389+ )}
390+
391+ <Section title="Parts" description={open ? "What the status page shows for each while it is open." : "What it did to each."}>
392+ {affected.length === 0 ? (
393+ <p className="text-sm text-muted">None marked.</p>
394+ ) : (
395+ <ul className="space-y-2">
396+ {affected.map((c) => (
397+ <li key={c.key} className="flex items-center justify-between gap-2 text-sm">
398+ <span className="min-w-0 truncate">{names.get(c.key) ?? c.key}</span>
399+ <ImpactBadge impact={c.impact} />
400+ </li>
401+ ))}
402+ </ul>
403+ )}
404+ </Section>
405+
406+ <Section id="roles" title="Roles">
407+ <form method="post" action="#roles" className="space-y-3">
408+ <input type="hidden" name="intent" value="roles" />
409+ {errorFor("roles") && <Notice tone="error">{errorFor("roles")}</Notice>}
410+ <Field label="Incident commander">
411+ <Input name="commander" type="email" list="staff" defaultValue={incident.commander ?? ""} placeholder="Nobody" />
412+ </Field>
413+ <Field label="Communications">
414+ <Input name="communications" type="email" list="staff" defaultValue={incident.communications ?? ""} placeholder="Nobody" />
415+ </Field>
416+ <Button type="submit" variant="quiet">
417+ Save roles
418+ </Button>
419+ </form>
420+ </Section>
421+
422+ <Section id="followups" title="Follow-ups" description="What to change so it does not happen again.">
423+ {errorFor("followup_set") && (
424+ <div className="mb-3">
425+ <Notice tone="error">{errorFor("followup_set")}</Notice>
426+ </div>
427+ )}
428+ {incident.followups.length > 0 && (
429+ <ul className="mb-4 space-y-1.5">
430+ {incident.followups.map((f) => (
431+ <li key={f.id}>
432+ <form method="post" action="#followups" className="flex items-start gap-2">
433+ <input type="hidden" name="intent" value="followup_set" />
434+ <input type="hidden" name="followup" value={f.id} />
435+ <input type="hidden" name="done" value={f.done_at ? "0" : "1"} />
436+ <button
437+ type="submit"
438+ aria-label={f.done_at ? `Mark “${f.title}” not done` : `Mark “${f.title}” done`}
439+ className="mt-0.5 shrink-0 rounded text-faint hover:text-merged"
440+ >
441+ {f.done_at ? <CheckCircle2 size={16} className="text-accent" /> : <Circle size={16} />}
442+ </button>
443+ <span className="min-w-0 text-sm">
444+ <span className={f.done_at ? "text-faint line-through" : ""}>{f.title}</span>
445+ {f.owner && <span className="block font-mono text-xs text-faint">{f.owner}</span>}
446+ </span>
447+ </form>
448+ </li>
449+ ))}
450+ </ul>
451+ )}
452+ <form method="post" action="#followups" className="space-y-2">
453+ <input type="hidden" name="intent" value="followup_add" />
454+ {errorFor("followup_add") && <Notice tone="error">{errorFor("followup_add")}</Notice>}
455+ <Input name="title" required maxLength={200} placeholder="Alert on push errors above 1%" aria-label="Follow-up" />
456+ <div className="flex gap-2">
457+ <Input name="owner" type="email" list="staff" placeholder="Owner (optional)" aria-label="Owner" />
458+ <Button type="submit" variant="quiet">
459+ Add
460+ </Button>
461+ </div>
462+ </form>
463+ </Section>
464+
465+ <Section title="Links">
466+ <ul className="space-y-1.5 text-sm">
467+ {isPublic ? (
468+ <li>
469+ <a href={incident.url} className="inline-flex items-center gap-1.5 text-merged hover:underline">
470+ Status page permalink <ExternalLink size={13} aria-hidden="true" />
471+ </a>
472+ <span className="block truncate font-mono text-xs text-faint">{incident.url}</span>
473+ </li>
474+ ) : (
475+ <li className="text-muted">No public page until it is published.</li>
476+ )}
477+ <li>
478+ <Link to="/audit" className="text-muted hover:text-fg hover:underline">
479+ Audit log
480+ </Link>
481+ </li>
482+ </ul>
483+ </Section>
484+ <datalist id="staff">
485+ {staff.map((email) => (
486+ <option key={email} value={email} />
487+ ))}
488+ </datalist>
489+ </aside>
490+ </div>
491+ </main>
492+ );
493+}
+281−0
1+import { CalendarClock, ChevronRight, ExternalLink, Mail, Siren } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { AdminIncident, AdminMaintenance } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/incidents";
7+import { ImpactBadge, MaintenanceBadge, PhaseBadge, SeverityBadge } from "~/components/incidents";
8+import { Button, ButtonLink, EmptyState, Input, Notice, PageHeader, Select, Stat, When } from "~/components/ui";
9+import {
10+ INCIDENT_SEVERITIES,
11+ TABS,
12+ duration,
13+ filterIncidents,
14+ incidentsHref,
15+ maintenanceList,
16+ median,
17+ parseFilters,
18+ secondsBetween,
19+ tabCounts,
20+} from "~/lib/incidents";
21+import { statusAdmin } from "~/lib/services.server";
22+import { settle } from "~/lib/settle";
23+import { requireStaff } from "~/lib/staff";
24+
25+export const meta: Route.MetaFunction = () => [{ title: "Incidents · sudo" }, { name: "robots", content: "noindex, nofollow" }];
26+
27+const STATUS_PAGE = "https://status.g1t.sh/";
28+
29+export async function loader({ request, context }: Route.LoaderArgs) {
30+ requireStaff(context);
31+ const filters = parseFilters(new URL(request.url).searchParams);
32+ const [board, components] = await Promise.all([settle(statusAdmin.board()), settle(statusAdmin.components())]);
33+ return {
34+ filters,
35+ board: board.ok ? board.value : null,
36+ components: components.ok ? components.value : [],
37+ error: board.ok ? null : board.error,
38+ now: new Date().toISOString(),
39+ };
40+}
41+
42+function IncidentRow({ incident, names, now }: { incident: AdminIncident; names: Map<string, string>; now: string }) {
43+ const affected = incident.components.filter((c) => c.impact !== "operational");
44+ const open = incident.resolved_at == null;
45+ return (
46+ <li>
47+ <Link
48+ to={`/incidents/${incident.id}`}
49+ className="group flex flex-col gap-2 px-4 py-3.5 hover:bg-raised/40 sm:flex-row sm:items-start sm:gap-4 sm:px-5"
50+ >
51+ <div className="flex shrink-0 items-center gap-2 sm:w-20 sm:pt-0.5">
52+ <SeverityBadge severity={incident.severity} />
53+ </div>
54+ <div className="min-w-0 grow">
55+ <div className="flex flex-wrap items-center gap-2">
56+ <p className="font-medium group-hover:text-fg">{incident.title}</p>
57+ <PhaseBadge incident={incident} />
58+ {incident.source === "detected" && <span className="text-xs text-faint">Detected by the checks</span>}
59+ </div>
60+ <div className="mt-1.5 flex flex-wrap gap-1.5">
61+ {affected.map((c) => (
62+ <span key={c.key} className="inline-flex items-center gap-1.5 text-xs text-muted">
63+ {names.get(c.key) ?? c.key} <ImpactBadge impact={c.impact} />
64+ </span>
65+ ))}
66+ </div>
67+ <p className="mt-1.5 text-xs text-faint">
68+ Started <When at={incident.started_at} time />
69+ {incident.commander && (
70+ <>
71+ {" "}· IC <span className="font-mono">{incident.commander}</span>
72+ </>
73+ )}
74+ {incident.followups_open + incident.followups_done > 0 && (
75+ <>
76+ {" "}· follow-ups {incident.followups_done}/{incident.followups_open + incident.followups_done}
77+ </>
78+ )}
79+ </p>
80+ </div>
81+ <div className="flex shrink-0 items-center gap-3 text-right sm:block">
82+ <p className={`tabular text-sm font-medium ${open ? "text-warn" : "text-fg-soft"}`}>
83+ {open ? duration(secondsBetween(incident.started_at, now)) : duration(incident.durations.to_resolve)}
84+ </p>
85+ <p className="text-xs text-faint">{open ? "open" : incident.visibility === "dismissed" ? "dismissed" : "to resolve"}</p>
86+ </div>
87+ <ChevronRight size={16} aria-hidden="true" className="hidden shrink-0 self-center text-faint group-hover:text-muted sm:block" />
88+ </Link>
89+ </li>
90+ );
91+}
92+
93+function MaintenanceRow({ m, names }: { m: AdminMaintenance; names: Map<string, string> }) {
94+ return (
95+ <li>
96+ <Link
97+ to={`/incidents/maintenance/${m.id}`}
98+ className="group flex flex-col gap-2 px-4 py-3.5 hover:bg-raised/40 sm:flex-row sm:items-start sm:gap-4 sm:px-5"
99+ >
100+ <CalendarClock size={16} aria-hidden="true" className="hidden shrink-0 text-faint sm:mt-0.5 sm:block" />
101+ <div className="min-w-0 grow">
102+ <div className="flex flex-wrap items-center gap-2">
103+ <p className="font-medium">{m.title}</p>
104+ <MaintenanceBadge state={m.state} />
105+ </div>
106+ <p className="mt-1 text-xs text-muted">{m.components.map((k) => names.get(k) ?? k).join(", ")}</p>
107+ <p className="mt-1 text-xs text-faint">
108+ <When at={m.starts_at} time /> to <When at={m.ends_at} time /> · {duration(secondsBetween(m.starts_at, m.ends_at))}
109+ </p>
110+ </div>
111+ <ChevronRight size={16} aria-hidden="true" className="hidden shrink-0 self-center text-faint group-hover:text-muted sm:block" />
112+ </Link>
113+ </li>
114+ );
115+}
116+
117+export default function Incidents({ loaderData }: Route.ComponentProps) {
118+ const { filters, board, components, error, now } = loaderData;
119+ const names = new Map(components.map((c) => [c.key, c.name]));
120+ const incidents = board?.incidents ?? [];
121+ const maintenance = board?.maintenance ?? [];
122+ const counts = tabCounts(incidents, maintenance);
123+ const shown = filterIncidents(incidents, filters);
124+ const shownMaintenance = maintenanceList(maintenance, filters.component);
125+ const recent = incidents.filter((i) => i.visibility === "public" && i.resolved_at && secondsBetween(i.resolved_at, now) < 90 * 86400);
126+ const filtered = filters.severity != null || filters.component != null || filters.q !== "";
127+
128+ return (
129+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
130+ <PageHeader
131+ title="Incidents"
132+ description="Declare, run and close incidents, schedule maintenance, and publish postmortems. What is public appears on status.g1t.sh within 30 seconds."
133+ actions={
134+ <div className="flex flex-wrap items-center gap-2">
135+ <ButtonLink to="/incidents/new" variant="lavender">
136+ <Siren size={15} aria-hidden="true" />
137+ Declare incident
138+ </ButtonLink>
139+ <ButtonLink to="/incidents/maintenance/new" variant="quiet">
140+ <CalendarClock size={15} aria-hidden="true" />
141+ Schedule maintenance
142+ </ButtonLink>
143+ <a
144+ href={STATUS_PAGE}
145+ className="inline-flex items-center gap-1.5 rounded-md px-2 py-2 text-sm text-muted hover:text-fg"
146+ >
147+ status.g1t.sh <ExternalLink size={13} aria-hidden="true" />
148+ </a>
149+ </div>
150+ }
151+ />
152+
153+ {error && (
154+ <div className="mt-5">
155+ <Notice tone="warn">The status worker did not answer: {error}</Notice>
156+ </div>
157+ )}
158+
159+ <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
160+ <Stat label="Open" value={counts.open} tone={counts.open ? "danger" : "mint"} hint={counts.drafts ? `${counts.drafts} draft${counts.drafts === 1 ? "" : "s"} waiting` : "No drafts waiting"} />
161+ <Stat label="Maintenance ahead" value={counts.maintenance} hint="Scheduled or under way" />
162+ <Stat
163+ label="Median time to resolve"
164+ value={duration(median(recent.map((i) => i.durations.to_resolve)))}
165+ hint={`${recent.length} public incident${recent.length === 1 ? "" : "s"}, 90 days`}
166+ />
167+ <Stat
168+ label="Email subscribers"
169+ value={board?.subscribers ?? "—"}
170+ hint={board && !board.email ? "Email is off: feeds only" : "Confirmed addresses"}
171+ />
172+ </div>
173+
174+ <nav aria-label="Incident lists" className="mt-6 flex gap-1 overflow-x-auto border-b border-line">
175+ {TABS.map((tab) => {
176+ const current = filters.tab === tab.value;
177+ return (
178+ <Link
179+ key={tab.value}
180+ to={incidentsHref({ ...filters, tab: tab.value })}
181+ aria-current={current ? "page" : undefined}
182+ className={`-mb-px flex shrink-0 items-center gap-2 border-b-2 px-3 py-2 text-sm whitespace-nowrap ${
183+ current ? "border-merged font-medium text-fg" : "border-transparent text-muted hover:text-fg"
184+ }`}
185+ >
186+ {tab.label}
187+ <span className={`tabular rounded-full px-1.5 text-xs ${counts[tab.value] && tab.value !== "resolved" ? "bg-merged/15 text-merged" : "text-faint"}`}>
188+ {counts[tab.value]}
189+ </span>
190+ </Link>
191+ );
192+ })}
193+ </nav>
194+
195+ <form method="get" action="/incidents" className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
196+ {filters.tab !== "open" && <input type="hidden" name="tab" value={filters.tab} />}
197+ <Input name="q" type="search" defaultValue={filters.q} placeholder="Search titles" aria-label="Search titles" className="sm:w-64" />
198+ {filters.tab !== "maintenance" && (
199+ <Select name="severity" defaultValue={filters.severity ?? ""} aria-label="Severity" className="sm:w-40">
200+ <option value="">Any severity</option>
201+ {INCIDENT_SEVERITIES.map((s) => (
202+ <option key={s.value} value={s.value}>
203+ {s.label}
204+ </option>
205+ ))}
206+ </Select>
207+ )}
208+ <Select name="component" defaultValue={filters.component ?? ""} aria-label="Part" className="sm:w-56">
209+ <option value="">Any part</option>
210+ {components.map((c) => (
211+ <option key={c.key} value={c.key}>
212+ {c.name}
213+ </option>
214+ ))}
215+ </Select>
216+ <div className="flex items-center gap-2">
217+ <Button type="submit" variant="quiet" className="py-2">
218+ Apply
219+ </Button>
220+ {filtered && (
221+ <Link to={incidentsHref({ tab: filters.tab })} className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
222+ Clear
223+ </Link>
224+ )}
225+ </div>
226+ </form>
227+
228+ <div className="mt-4">
229+ {filters.tab === "maintenance" ? (
230+ shownMaintenance.length === 0 ? (
231+ <EmptyState title="No maintenance scheduled">
232+ <Link to="/incidents/maintenance/new" className="text-merged hover:underline">
233+ Schedule maintenance
234+ </Link>{" "}
235+ to tell people ahead of planned work.
236+ </EmptyState>
237+ ) : (
238+ <ul className="divide-y divide-line rounded-lg border border-line bg-surface">
239+ {shownMaintenance.map((m) => (
240+ <MaintenanceRow key={m.id} m={m} names={names} />
241+ ))}
242+ </ul>
243+ )
244+ ) : shown.length === 0 ? (
245+ <EmptyState
246+ title={
247+ filtered
248+ ? "Nothing matches"
249+ : filters.tab === "open"
250+ ? "Nothing is open"
251+ : filters.tab === "drafts"
252+ ? "No drafts"
253+ : "No resolved incidents in the last 180 days"
254+ }
255+ >
256+ {filtered
257+ ? "Try another severity or part, or clear the filters."
258+ : filters.tab === "open"
259+ ? "The status page shows only its own checks."
260+ : filters.tab === "drafts"
261+ ? "When a part fails three checks in a row, a draft appears here and the alert address gets an email."
262+ : null}
263+ </EmptyState>
264+ ) : (
265+ <ul className="divide-y divide-line rounded-lg border border-line bg-surface">
266+ {shown.map((incident) => (
267+ <IncidentRow key={incident.id} incident={incident} names={names} now={now} />
268+ ))}
269+ </ul>
270+ )}
271+ </div>
272+
273+ {board && !board.email && (
274+ <p className="mt-4 flex items-center gap-2 text-xs text-faint">
275+ <Mail size={13} aria-hidden="true" />
276+ The status worker has no email sender or no STATUS_SECRET, so subscribers cannot sign up; the feeds still carry every update.
277+ </p>
278+ )}
279+ </main>
280+ );
281+}
+198−43
1−import { Check, Search, Ticket, X } from "lucide-react";
1+import { Check, MessageSquareText, Search, Ticket, X } from "lucide-react";
22 import { Link, data, redirect, useLocation } from "react-router";
33
44 import type { Invite, InviteTree, InviteTreeNode, WaitlistEntry } from "@g1t/contracts";
77 import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Select, Textarea, When } from "~/components/ui";
88 import { text } from "~/lib/forms";
99 import {
10− INVITES_DONE,
1110 INVITE_TABS,
1211 type InviteTab,
12+ MAX_BULK,
13+ MAX_NOTE,
1314 TAB_LABEL,
15+ doneMessage,
1416 invitesHref,
1517 parseGrant,
18+ parseIds,
1619 parseMintEmail,
20+ parseNote,
1721 parseTab,
1822 parseWaitlistStatus,
1923 } from "~/lib/invites";
3236 const name = url.searchParams.get("name")?.trim().toLowerCase() ?? "";
3337 const kind = url.searchParams.get("kind") === "workspace" ? "workspace" : "user";
3438 const done = url.searchParams.get("done");
39+ const selectAll = url.searchParams.get("select") === "all";
3540 const [waitlist, invites, tree] = await Promise.all([
3641 tab === "waitlist" ? settle(identity.waitlist(query || null, status === "all" ? null : status)) : null,
3742 tab === "invites" ? settle(identity.invites(query || null)) : null,
4752 invites: invites?.ok ? invites.value : [],
4853 tree: tree?.ok ? tree.value : null,
4954 error: [waitlist, invites, tree].map((result) => (result && !result.ok ? result.error : null)).find(Boolean) ?? null,
50− done: done ? (INVITES_DONE[done] ?? null) : null,
55+ done: doneMessage(done, url.searchParams.get("n")),
56+ selectAll,
5157 };
5258 }
5359
5460 export async function action({ request, context }: Route.ActionArgs) {
5561 const staff = requireStaff(context);
5662 const form = await request.formData();
57− const back = (done: string) => {
63+ const back = (done: string, n?: number) => {
5864 const url = new URL(request.url);
5965 url.searchParams.set("done", done);
66+ url.searchParams.delete("select");
67+ if (n) url.searchParams.set("n", String(n));
68+ else url.searchParams.delete("n");
6069 return redirect(`${url.pathname}${url.search}`);
6170 };
6271 switch (text(form, "intent")) {
6372 case "approve":
6473 case "dismiss": {
6574 const approve = text(form, "intent") === "approve";
66− const result = await identity.decideWaitlist(text(form, "id"), approve, staff.email);
75+ const note = parseNote(text(form, "note"));
76+ if (!note.ok) return data({ error: note.error, id: text(form, "id") }, { status: 422 });
77+ const result = await identity.decideWaitlist(text(form, "id"), approve, staff.email, approve ? note.value : null);
6778 if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
6879 throw back(approve ? "approved" : "dismissed");
6980 }
81+ case "bulk-approve":
82+ case "bulk-dismiss": {
83+ const approve = text(form, "intent") === "bulk-approve";
84+ const ids = parseIds(form);
85+ if (!ids.ok) return data({ error: ids.error, section: "bulk" }, { status: 422 });
86+ const note = parseNote(text(form, "note"));
87+ if (!note.ok) return data({ error: note.error, section: "bulk" }, { status: 422 });
88+ // One at a time: each mints and emails its own invite.
89+ const refused: string[] = [];
90+ let decided = 0;
91+ for (const id of ids.value) {
92+ const result = await identity.decideWaitlist(id, approve, staff.email, approve ? note.value : null);
93+ if (result.ok) decided += 1;
94+ else refused.push(result.error.message);
95+ }
96+ if (refused.length > 0) {
97+ const verb = approve ? "Approved" : "Dismissed";
98+ return data({ error: `${verb} ${decided}; ${refused.length} not: ${refused.join(" ")}`, section: "bulk" }, { status: 422 });
99+ }
100+ throw back(approve ? "approved" : "dismissed", decided);
101+ }
70102 case "revoke": {
71103 const result = await identity.revokeInvite(text(form, "id"), staff.email);
72104 if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
130162 );
131163 }
132164
133−function Waitlist({ entries, query, status, actionData }: { entries: WaitlistEntry[]; query: string; status: string; actionData: ActionData }) {
165+/** A note for the invite email, folded away until wanted. */
166+function NoteField({ form, label }: { form?: string; label: string }) {
167+ return (
168+ <details className="group/note">
169+ <summary className="inline-flex cursor-pointer list-none items-center gap-1.5 text-xs text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden">
170+ <MessageSquareText size={13} />
171+ {label}
172+ </summary>
173+ <div className="mt-2">
174+ <Textarea
175+ name="note"
176+ form={form}
177+ rows={2}
178+ maxLength={MAX_NOTE}
179+ placeholder="Optional. Goes in the invite email as a note from the g1t team."
180+ aria-label="Note for the invite email"
181+ />
182+ </div>
183+ </details>
184+ );
185+}
186+
187+/** Who decided, when, what they wrote, and whether the invite was used. */
188+function Decision({ entry }: { entry: WaitlistEntry }) {
189+ if (!entry.decidedBy) return null;
190+ return (
191+ <div className="space-y-1.5">
192+ <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-faint">
193+ {entry.status === "invited" ? "Approved" : "Dismissed"} by <span className="font-mono text-muted">{entry.decidedBy}</span>
194+ <When at={entry.decidedAt} />
195+ {entry.status === "invited" &&
196+ (entry.joinedAs ? <Badge tone="mint">Joined as @{entry.joinedAs}</Badge> : <Badge>Invite not used yet</Badge>)}
197+ </p>
198+ {entry.note && (
199+ <p className="text-xs text-muted">
200+ Note sent: <span className="whitespace-pre-line text-fg-soft">{entry.note}</span>
201+ </p>
202+ )}
203+ </div>
204+ );
205+}
206+
207+function Waitlist({
208+ entries,
209+ query,
210+ status,
211+ selectAll,
212+ actionData,
213+}: {
214+ entries: WaitlistEntry[];
215+ query: string;
216+ status: string;
217+ selectAll: boolean;
218+ actionData: ActionData;
219+}) {
134220 const { pathname, search } = useLocation();
221+ const waiting = entries.filter((entry) => entry.status === "waiting");
222+ const params = new URLSearchParams(search);
223+ params.set("select", "all");
224+ const selectHref = `${pathname}?${params}`;
225+ params.delete("select");
226+ const clearHref = `${pathname}?${params}`;
227+ const bulkError = errorFor(actionData, { section: "bulk" });
135228 return (
136229 <div className="space-y-4">
137230 <SearchForm
149242 </div>
150243 }
151244 />
245+ {waiting.length > 0 && (
246+ // Ticked rows belong to this form through their `form` attribute:
247+ // sudo runs no script, so choosing several is plain HTML.
248+ <form
249+ id="bulk"
250+ method="post"
251+ action={`${pathname}${search}`}
252+ className="space-y-3 rounded-lg border border-line bg-raised/40 px-4 py-3 sm:px-5"
253+ >
254+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
255+ <p className="text-sm">
256+ <span className="font-medium">{waiting.length} waiting</span>
257+ <span className="text-muted"> · tick some, or </span>
258+ {selectAll ? (
259+ <a href={clearHref} className="text-merged hover:underline">
260+ clear
261+ </a>
262+ ) : (
263+ <a href={selectHref} className="text-merged hover:underline">
264+ tick all {Math.min(waiting.length, MAX_BULK)}
265+ </a>
266+ )}
267+ </p>
268+ <div className="ml-auto flex flex-wrap gap-2">
269+ <Button type="submit" name="intent" value="bulk-dismiss" variant="quiet">
270+ <X size={14} />
271+ Dismiss ticked
272+ </Button>
273+ <Button type="submit" name="intent" value="bulk-approve" variant="lavender">
274+ <Check size={14} />
275+ Approve and invite ticked
276+ </Button>
277+ </div>
278+ </div>
279+ <NoteField form="bulk" label="Add a note to every invite" />
280+ {bulkError && <Notice tone="error">{bulkError}</Notice>}
281+ </form>
282+ )}
152283 {entries.length === 0 ? (
153− <EmptyState title="Nobody waiting">People who ask for access on g1t.sh/register show up here, oldest first.</EmptyState>
284+ <EmptyState title={status === "waiting" ? "Nobody waiting" : "Nothing here"}>
285+ People who ask for access on g1t.sh/register show up here, newest first.
286+ </EmptyState>
154287 ) : (
155288 <ul className="divide-y divide-line rounded-lg border border-line bg-surface">
156− {entries.map((entry) => {
289+ {entries.map((entry, index) => {
157290 const error = errorFor(actionData, { id: entry.id });
291+ const open = entry.status === "waiting";
158292 return (
159− <li key={entry.id} id={entry.id} className="scroll-mt-20 space-y-2 px-4 py-3 sm:px-5">
160− <div className="flex flex-wrap items-center gap-2">
161− <span className="font-mono text-sm break-all">{entry.email}</span>
162− {entry.status === "invited" && <Badge tone="mint">Invited</Badge>}
163− {entry.status === "dismissed" && <Badge>Dismissed</Badge>}
164− <span className="text-xs text-faint">
165− asked <When at={entry.createdAt} />
166− {entry.updatedAt !== entry.createdAt && (
167− <>
168− , again <When at={entry.updatedAt} />
169− </>
293+ <li key={entry.id} id={entry.id} className="scroll-mt-20 px-4 py-3 sm:px-5">
294+ <div className="flex gap-3">
295+ {open && (
296+ <input
297+ type="checkbox"
298+ form="bulk"
299+ name="ids"
300+ value={entry.id}
301+ defaultChecked={selectAll && index < MAX_BULK}
302+ aria-label={`Tick ${entry.email}`}
303+ className="mt-1 size-4 shrink-0 accent-[var(--color-merged)]"
304+ />
305+ )}
306+ <div className="min-w-0 grow space-y-2">
307+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
308+ <span className="font-mono text-sm break-all">{entry.email}</span>
309+ {entry.status === "invited" && <Badge tone="mint">Invited</Badge>}
310+ {entry.status === "dismissed" && <Badge>Dismissed</Badge>}
311+ <span className="text-xs text-faint">
312+ asked <When at={entry.createdAt} />
313+ {entry.updatedAt !== entry.createdAt && (
314+ <>
315+ , again <When at={entry.updatedAt} />
316+ </>
317+ )}
318+ </span>
319+ </div>
320+ {entry.about ? (
321+ <blockquote className="border-l-2 border-line-strong pl-3 text-sm break-words whitespace-pre-line text-fg-soft">{entry.about}</blockquote>
322+ ) : (
323+ <p className="text-xs text-faint italic">Did not say what they will build.</p>
324+ )}
325+ <Decision entry={entry} />
326+ {open && (
327+ <form method="post" action={`${pathname}${search}#${entry.id}`} className="space-y-2">
328+ <input type="hidden" name="id" value={entry.id} />
329+ <div className="flex flex-wrap items-start justify-between gap-2">
330+ <NoteField label="Add a note to the invite" />
331+ <div className="ml-auto flex gap-2">
332+ <Button type="submit" name="intent" value="dismiss" variant="quiet">
333+ <X size={14} />
334+ Dismiss
335+ </Button>
336+ <Button type="submit" name="intent" value="approve" variant="lavender">
337+ <Check size={14} />
338+ Approve and invite
339+ </Button>
340+ </div>
341+ </div>
342+ </form>
170343 )}
171− </span>
172− {entry.status === "waiting" && (
173− <form method="post" action={`${pathname}${search}#${entry.id}`} className="ml-auto flex gap-2">
174− <input type="hidden" name="id" value={entry.id} />
175− <Button type="submit" name="intent" value="dismiss" variant="quiet">
176− <X size={14} />
177− Dismiss
178− </Button>
179− <Button type="submit" name="intent" value="approve" variant="lavender">
180− <Check size={14} />
181− Approve and invite
182− </Button>
183− </form>
184− )}
344+ {error && <Notice tone="error">{error}</Notice>}
345+ </div>
185346 </div>
186− {entry.about && <blockquote className="border-l-2 border-line-strong pl-3 text-sm break-words whitespace-pre-line text-fg-soft">{entry.about}</blockquote>}
187− {entry.decidedBy && (
188− <p className="text-xs text-faint">
189− {entry.status === "invited" ? "Approved" : "Dismissed"} by <span className="font-mono">{entry.decidedBy}</span>{" "}
190− <When at={entry.decidedAt} />
191− </p>
192− )}
193− {error && <Notice tone="error">{error}</Notice>}
194347 </li>
195348 );
196349 })}
427580 }
428581
429582 export default function Invites({ loaderData, actionData }: Route.ComponentProps) {
430− const { tab, query, status, waitlist, invites, tree, name, kind, error, done } = loaderData;
583+ const { tab, query, status, waitlist, invites, tree, name, kind, error, done, selectAll } = loaderData;
431584 return (
432585 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
433586 <PageHeader
453606 {error && <Notice tone="warn">Identity did not answer: {error}</Notice>}
454607 </div>
455608 <div className="mt-5">
456− {tab === "waitlist" && <Waitlist entries={waitlist} query={query} status={status} actionData={actionData} />}
609+ {tab === "waitlist" && (
610+ <Waitlist entries={waitlist} query={query} status={status} selectAll={selectAll} actionData={actionData} />
611+ )}
457612 {tab === "invites" && (
458613 <div className="space-y-4">
459614 <SearchForm tab="invites" query={query} placeholder="A code's start (g1t-k7m2), an email, or a username" />
+108−0
1+import { redirect } from "react-router";
2+
3+import type { Route } from "./+types/maintenance-new";
4+import { BackLink } from "~/components/incidents";
5+import { Button, Field, Input, Notice, PageHeader, Section, Textarea } from "~/components/ui";
6+import { text } from "~/lib/forms";
7+import { localValue, utc } from "~/lib/incidents";
8+import { statusAdmin } from "~/lib/services.server";
9+import { settle } from "~/lib/settle";
10+import { requireStaff } from "~/lib/staff";
11+
12+export const meta: Route.MetaFunction = () => [{ title: "Schedule maintenance · sudo" }, { name: "robots", content: "noindex, nofollow" }];
13+
14+export async function loader({ context }: Route.LoaderArgs) {
15+ requireStaff(context);
16+ const components = await settle(statusAdmin.components());
17+ // A sensible default: tomorrow, 02:00 to 03:00 UTC.
18+ const now = new Date();
19+ const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 2));
20+ return {
21+ components: components.ok ? components.value : [],
22+ error: components.ok ? null : components.error,
23+ start: localValue(start),
24+ end: localValue(new Date(start.getTime() + 3600_000)),
25+ };
26+}
27+
28+type ActionData = { error: string; values: Record<string, string>; chosen: string[] };
29+
30+export async function action({ request, context }: Route.ActionArgs) {
31+ const staff = requireStaff(context);
32+ const form = await request.formData();
33+ const values = Object.fromEntries(["title", "message", "starts_at", "ends_at"].map((k) => [k, text(form, k)]));
34+ const chosen = form.getAll("components").map(String);
35+ const result = await settle(
36+ statusAdmin.scheduleMaintenance({
37+ title: text(form, "title"),
38+ message: String(form.get("message") ?? ""),
39+ components: chosen,
40+ starts_at: utc(text(form, "starts_at")) ?? "",
41+ ends_at: utc(text(form, "ends_at")) ?? "",
42+ notify: form.get("notify") === "on",
43+ by: staff.email,
44+ }),
45+ );
46+ if (!result.ok) return { error: result.error, values, chosen } satisfies ActionData;
47+ if (!result.value.ok) return { error: result.value.error.message, values, chosen } satisfies ActionData;
48+ return redirect(`/incidents/maintenance/${result.value.value.id}?done=scheduled`);
49+}
50+
51+export default function ScheduleMaintenance({ loaderData, actionData }: Route.ComponentProps) {
52+ const { components, error, start, end } = loaderData;
53+ const failed = actionData as ActionData | undefined;
54+ const v = failed?.values ?? {};
55+ return (
56+ <main className="mx-auto max-w-3xl px-4 py-8 sm:py-10">
57+ <BackLink to="/incidents?tab=maintenance">Incidents</BackLink>
58+ <div className="mt-3">
59+ <PageHeader
60+ title="Schedule maintenance"
61+ description="Shown on status.g1t.sh as upcoming maintenance at once; during the window its parts show “Under maintenance” and their checks do not count against uptime. It completes on its own when the window ends."
62+ />
63+ </div>
64+ <div className="mt-5 space-y-3">
65+ {error && <Notice tone="warn">The status worker did not answer: {error}</Notice>}
66+ {failed && <Notice tone="error">{failed.error}</Notice>}
67+ </div>
68+ <form method="post" className="mt-6 space-y-6">
69+ <Section title="What and when">
70+ <div className="grid gap-4 sm:grid-cols-2">
71+ <Field label="Title" className="sm:col-span-2">
72+ <Input name="title" required maxLength={120} autoFocus defaultValue={v.title} placeholder="Database upgrade" />
73+ </Field>
74+ <Field label="Starts (UTC)">
75+ <Input type="datetime-local" name="starts_at" required defaultValue={v.starts_at || start} />
76+ </Field>
77+ <Field label="Ends (UTC)" hint="Up to 72 hours after it starts.">
78+ <Input type="datetime-local" name="ends_at" required defaultValue={v.ends_at || end} />
79+ </Field>
80+ </div>
81+ </Section>
82+ <Section title="Parts affected">
83+ <div className="flex flex-wrap gap-2">
84+ {components.map((c) => (
85+ <label
86+ key={c.key}
87+ className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-line px-2.5 py-1.5 text-sm has-checked:border-merged/60 has-checked:bg-merged/10"
88+ >
89+ <input type="checkbox" name="components" value={c.key} defaultChecked={failed?.chosen.includes(c.key)} className="accent-[#b6a8ff]" />
90+ {c.name}
91+ </label>
92+ ))}
93+ </div>
94+ </Section>
95+ <Section title="Message" description="What will happen and what people will notice. Shown publicly.">
96+ <Textarea name="message" rows={4} required maxLength={4000} aria-label="Message" defaultValue={v.message} placeholder="We are upgrading the database behind git. Pushes may pause for up to five minutes; clones and the website keep working." />
97+ <label className="mt-3 flex items-center gap-2 text-sm">
98+ <input type="checkbox" name="notify" defaultChecked className="accent-[#b6a8ff]" />
99+ Email subscribers now, when it starts, and when it ends
100+ </label>
101+ </Section>
102+ <Button type="submit" variant="lavender">
103+ Schedule
104+ </Button>
105+ </form>
106+ </main>
107+ );
108+}
+164−0
1+import { ExternalLink } from "lucide-react";
2+import { redirect } from "react-router";
3+
4+import type { MaintenanceChange } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/maintenance";
7+import { BackLink, MaintenanceBadge } from "~/components/incidents";
8+import { Button, EmptyState, Field, Notice, PageHeader, Section, Textarea, When } from "~/components/ui";
9+import { text } from "~/lib/forms";
10+import { duration, secondsBetween } from "~/lib/incidents";
11+import { statusAdmin } from "~/lib/services.server";
12+import { settle } from "~/lib/settle";
13+import { requireStaff } from "~/lib/staff";
14+
15+export const meta: Route.MetaFunction = ({ loaderData }) => [
16+ { title: `${loaderData?.maintenance?.title ?? "Maintenance"} · sudo` },
17+ { name: "robots", content: "noindex, nofollow" },
18+];
19+
20+const DONE: Record<string, string> = {
21+ scheduled: "Scheduled. status.g1t.sh lists it as upcoming within 30 seconds.",
22+ update: "Update posted.",
23+ start: "Started early.",
24+ complete: "Marked complete.",
25+ cancel: "Cancelled.",
26+};
27+
28+export async function loader({ params, request, context }: Route.LoaderArgs) {
29+ requireStaff(context);
30+ const [board, components] = await Promise.all([settle(statusAdmin.board()), settle(statusAdmin.components())]);
31+ const maintenance = board.ok ? (board.value.maintenance.find((m) => m.id === params.id) ?? null) : null;
32+ if (board.ok && !maintenance) throw new Response("No such maintenance, or it ended over 90 days ago.", { status: 404 });
33+ return {
34+ maintenance,
35+ names: components.ok ? components.value : [],
36+ error: board.ok ? null : board.error,
37+ done: DONE[new URL(request.url).searchParams.get("done") ?? ""] ?? null,
38+ };
39+}
40+
41+export async function action({ params, request, context }: Route.ActionArgs) {
42+ const staff = requireStaff(context);
43+ const form = await request.formData();
44+ const act = text(form, "intent") as MaintenanceChange["action"];
45+ const result = await settle(
46+ statusAdmin.changeMaintenance(params.id, { action: act, message: String(form.get("message") ?? ""), notify: form.get("notify") === "on", by: staff.email }),
47+ );
48+ if (!result.ok) return { error: result.error };
49+ if (!result.value.ok) return { error: result.value.error.message };
50+ return redirect(`/incidents/maintenance/${params.id}?done=${act}`);
51+}
52+
53+export default function Maintenance({ loaderData, actionData }: Route.ComponentProps) {
54+ const { maintenance: m, names, error, done } = loaderData;
55+ const failed = actionData as { error: string } | undefined;
56+ if (!m) {
57+ return (
58+ <main className="mx-auto max-w-4xl px-4 py-8">
59+ <Notice tone="warn">The status worker did not answer: {error}</Notice>
60+ </main>
61+ );
62+ }
63+ const name = new Map(names.map((c) => [c.key, c.name]));
64+ const live = m.state === "scheduled" || m.state === "in_progress";
65+ return (
66+ <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
67+ <BackLink to="/incidents?tab=maintenance">Incidents</BackLink>
68+ <div className="mt-3">
69+ <PageHeader
70+ title={m.title}
71+ description={
72+ <>
73+ <MaintenanceBadge state={m.state} /> <When at={m.starts_at} time /> to <When at={m.ends_at} time /> · {duration(secondsBetween(m.starts_at, m.ends_at))} ·{" "}
74+ {m.components.map((k) => name.get(k) ?? k).join(", ")}
75+ </>
76+ }
77+ actions={
78+ <a href={m.url} className="inline-flex items-center gap-1.5 text-sm text-merged hover:underline">
79+ On the status page <ExternalLink size={13} aria-hidden="true" />
80+ </a>
81+ }
82+ />
83+ </div>
84+ <div className="mt-4 space-y-3">
85+ {done && <Notice tone="ok">{done}</Notice>}
86+ {failed && <Notice tone="error">{failed.error}</Notice>}
87+ </div>
88+ <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]">
89+ <div className="min-w-0 space-y-6">
90+ {live && (
91+ <Section title="Post an update" description="Shown on its page and the status page.">
92+ <form method="post" className="space-y-3">
93+ <Textarea name="message" rows={3} maxLength={4000} aria-label="Update" placeholder="Running a little long: about 20 more minutes." />
94+ <div className="flex flex-wrap items-center justify-between gap-3">
95+ <label className="flex items-center gap-2 text-sm">
96+ <input type="checkbox" name="notify" className="accent-[#b6a8ff]" />
97+ Email subscribers
98+ </label>
99+ <Button type="submit" name="intent" value="update" variant="lavender">
100+ Post update
101+ </Button>
102+ </div>
103+ </form>
104+ </Section>
105+ )}
106+ <Section title="Updates" description="Newest first, as the status page shows them.">
107+ {m.updates.length === 0 ? (
108+ <EmptyState title="No updates" />
109+ ) : (
110+ <ol className="space-y-3">
111+ {m.updates.map((u) => (
112+ <li key={u.id} className="rounded-md border border-line bg-raised/30 px-3.5 py-3">
113+ <p className="text-xs text-faint">
114+ <When at={u.at} time />
115+ </p>
116+ <p className="mt-1 text-sm whitespace-pre-line text-fg-soft">{u.text}</p>
117+ </li>
118+ ))}
119+ </ol>
120+ )}
121+ </Section>
122+ </div>
123+ <aside className="min-w-0 space-y-6">
124+ {live ? (
125+ <Section title="Move it along" description="It starts and completes on its own with its window; do it by hand when the work runs early or late.">
126+ <form method="post" className="space-y-3">
127+ <Field label="Message (optional)">
128+ <Textarea name="message" rows={2} maxLength={4000} placeholder="Said for you when empty." />
129+ </Field>
130+ <label className="flex items-center gap-2 text-sm">
131+ <input type="checkbox" name="notify" defaultChecked className="accent-[#b6a8ff]" />
132+ Email subscribers
133+ </label>
134+ <div className="flex flex-wrap gap-2">
135+ {m.state === "scheduled" && (
136+ <Button type="submit" name="intent" value="start" variant="quiet">
137+ Start now
138+ </Button>
139+ )}
140+ <Button type="submit" name="intent" value="complete" variant="quiet">
141+ Complete
142+ </Button>
143+ <Button type="submit" name="intent" value="cancel" variant="danger">
144+ Cancel
145+ </Button>
146+ </div>
147+ </form>
148+ </Section>
149+ ) : (
150+ <Section title="Finished">
151+ <p className="text-sm text-muted">This maintenance is {m.state === "completed" ? "complete" : "cancelled"}. It stays in the status page's history.</p>
152+ </Section>
153+ )}
154+ <Section title="Scheduled by">
155+ <p className="font-mono text-xs text-fg-soft">{m.created_by}</p>
156+ <p className="mt-1 text-xs text-faint">
157+ <When at={m.created_at} time />
158+ </p>
159+ </Section>
160+ </aside>
161+ </div>
162+ </main>
163+ );
164+}
+3−3
529529 ["Held by reservations", usd(e.heldMicros ?? 0)],
530530 ["Prepaid", usd(e.prepaidMicros ?? 0)],
531531 ["Included usage", e.includedMicros ? `${usd(e.includedUsedMicros ?? 0)} of ${usd(e.includedMicros)}` : "—"],
532− ["Private storage", `${gigabytes(e.privateStorageBytes)} of ${gigabytes(e.freePrivateStorageBytes)}`],
532+ ["Private storage", `${gigabytes(e.privateStorageBytes)} (${gigabytes(e.freePrivateStorageBytes)} free)`],
533533 ["Open-source pool paid", usd(e.ossPaidMicros)],
534− ["Build minutes", `${Math.ceil(e.buildSecondsUsed / 60)} of ${Math.floor(e.buildSecondsIncluded / 60)}`],
534+ ["Build minutes", `${Math.ceil((e.buildSecondsUsed ?? 0) / 60)}, all metered`],
535535 [
536536 "Git operations",
537537 e.gitOperations != null
538− ? `${e.gitOperations.toLocaleString("en-US")}${e.gitOperationsIncluded ? ` of ${e.gitOperationsIncluded.toLocaleString("en-US")}` : ""}`
538+ ? `${e.gitOperations.toLocaleString("en-US")}${e.gitOperationsIncluded ? ` (${e.gitOperationsIncluded.toLocaleString("en-US")} free)` : ""}`
539539 : "—",
540540 ],
541541 ["Audit log", `${e.auditRetentionDays} days`],
+2−0
66 BILLING: ServiceBinding;
77 IDENTITY: ServiceBinding;
88 EVENTS: ServiceBinding;
9+ /** apps/status's `StatusAdmin` entrypoint: see `StatusAdminApi`. */
10+ STATUS: Fetcher;
911 ASSETS: Fetcher;
1012 ACCESS_TEAM_DOMAIN: string;
1113 ACCESS_AUD: string;
+4−1
1919 "services": [
2020 { "binding": "BILLING", "service": "g1t-billing" },
2121 { "binding": "IDENTITY", "service": "g1t-identity" },
22− { "binding": "EVENTS", "service": "g1t-events" }
22+ { "binding": "EVENTS", "service": "g1t-events" },
23+ // The status page's staff-only entrypoint: posting incidents to
24+ // status.g1t.sh (apps/status). Only bindings reach it.
25+ { "binding": "STATUS", "service": "g1t-status", "entrypoint": "StatusAdmin" }
2326 ],
2427 "vars": {
2528 // The Zero Trust team domain, such as `g1t.cloudflareaccess.com`.
+24−0
1+import { Form } from "react-router";
2+
3+import { Button } from "./ui";
4+
5+/** One row's way out of a list in your settings: a key, a token, an application. */
6+export function DeleteButton({
7+ intent,
8+ id,
9+ label = "Delete",
10+}: {
11+ intent: string;
12+ id: string;
13+ label?: string;
14+}) {
15+ return (
16+ <Form method="post" className="ml-auto">
17+ <input type="hidden" name="intent" value={intent} />
18+ <input type="hidden" name="id" value={id} />
19+ <Button variant="quiet" type="submit">
20+ {label}
21+ </Button>
22+ </Form>
23+ );
24+}
+68−31
88 import type { ReactNode } from "react";
99 import { Form } from "react-router";
1010
11−import type { DeployUsage, Entitlements, FeatureState, Limit, LimitRequest, UsageAlert } from "@g1t/contracts";
12−import { DEPLOYMENTS_ALLOWANCE } from "@g1t/contracts";
11+import type { Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, UsageAlert } from "@g1t/contracts";
1312
1413 import {
1514 CAPS,
1817 alertText,
1918 alertTone,
2019 dollars,
20+ gigabytes,
2121 requestStatus,
2222 share,
23+ shownMeters,
2324 spendRange,
2425 wholeDollars,
2526 } from "../lib/billing";
9899 }
99100
100101 /** One meter: how much of something is used. */
101−export function Meter({ used, of, label, detail }: { used: number; of: number | null; label: string; detail?: ReactNode }) {
102+export function Meter({
103+ used,
104+ of,
105+ label,
106+ detail,
107+ calm = false,
108+}: {
109+ used: number;
110+ of: number | null;
111+ label: string;
112+ detail?: ReactNode;
113+ /** Reaching the end is ordinary, not a warning: the plan's included usage, past which usage simply goes on demand. */
114+ calm?: boolean;
115+}) {
102116 const part = share(used, of);
103− const bar = of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-accent";
117+ const bar = calm ? "bg-accent" : of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-accent";
104118 return (
105119 <div className="mt-4">
106120 <div className="flex flex-wrap justify-between gap-x-4 text-sm">
229243 enabled,
230244 live,
231245 busy,
232− deployUsage,
246+ meters,
233247 error,
234248 }: {
235249 state: FeatureState | null;
239253 enabled: boolean;
240254 live: boolean;
241255 busy: boolean;
242− deployUsage: DeployUsage | null;
256+ /** This month's usage by meter, from billing's `usage_meters`. */
257+ meters: MeterUsage[] | null;
243258 error?: string;
244259 }) {
245260 const plan = state?.plan;
297312 label="Included usage this month"
298313 used={entitlements.includedUsedMicros ?? 0}
299314 of={entitlements.includedMicros}
300− detail="Used first, at cost plus 20%. It starts again on the 1st; what is unused does not carry over."
315+ calm
316+ detail="Used first, at cost plus 20%. Past it, usage goes on at the same prices. It starts again on the 1st; what is unused does not carry over."
301317 />
302318 ) : null}
303− {on && deployUsage && <DeployMeter usage={deployUsage} entitlements={entitlements} />}
319+ {meters && <MonthUsage meters={meters} on={on} comped={status.kind === "comped"} entitlements={entitlements} />}
304320
305321 {status.kind === "comped" || status.kind === "enterprise" ? (
306322 <p className="mt-4 text-sm text-muted">
352368 );
353369 }
354370
355−/** This month's deployments against what the plan includes. */
356−function DeployMeter({ usage, entitlements }: { usage: DeployUsage; entitlements: Entitlements | null }) {
357− const a = DEPLOYMENTS_ALLOWANCE;
358− const buildMinutes = (entitlements?.buildSecondsIncluded ?? a.buildSeconds) / 60;
359− const rows: [string, number, number][] = [
360− ["Apps up at once (most this month)", usage.peakApps, a.apps],
361− ["Build minutes", Math.ceil(usage.buildSeconds / 60), buildMinutes],
362− ["Requests", usage.requests, a.requests],
363− ["CPU milliseconds", usage.cpuMs, a.cpuMs],
364− ];
371+/**
372+ * This month's usage, one line per meter, in dollars at cost plus 20% and
373+ * in what was used. No quotas: every line is metered from the first unit.
374+ */
375+function MonthUsage({
376+ meters,
377+ on,
378+ comped,
379+ entitlements,
380+}: {
381+ meters: MeterUsage[];
382+ on: boolean;
383+ comped: boolean;
384+ entitlements: Entitlements | null;
385+}) {
386+ const freeStorage = gigabytes(entitlements?.freePrivateStorageBytes ?? 1_000_000_000);
387+ const freeGit = (entitlements?.gitOperationsIncluded ?? 50_000).toLocaleString("en-US");
388+ const rows = shownMeters(meters, on);
389+ const total = rows.reduce((sum, row) => sum + row.micros, 0);
365390 return (
366391 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
367− <p className="text-xs font-medium text-muted">Deployments this month ({usage.month})</p>
368− <ul className="mt-2 space-y-2.5">
369− {rows.map(([label, used, included]) => (
370− <li key={label} className="text-sm">
371− <div className="flex justify-between gap-4">
372− <span className="text-muted">{label}</span>
373− <span className={`tabular-nums ${used > included ? "text-warn" : ""}`}>
374− {used.toLocaleString("en-US")} <span className="text-faint">of {included.toLocaleString("en-US")}</span>
375− </span>
376− </div>
377− <div className="mt-1 h-1 overflow-hidden rounded-full bg-line">
378− <div className={`h-full rounded-full ${used > included ? "bg-warn" : "bg-accent"}`} style={{ width: `${share(used, included) * 100}%` }} />
379− </div>
392+ <div className="flex items-baseline justify-between gap-4">
393+ <p className="text-xs font-medium text-muted">This month's usage</p>
394+ <p className="text-xs text-faint">At cost plus 20%</p>
395+ </div>
396+ <ul className="mt-2 divide-y divide-line/60">
397+ {rows.map((row) => (
398+ <li key={row.key} className="flex items-baseline justify-between gap-4 py-2 text-sm">
399+ <span className="min-w-0">
400+ <span>{row.label}</span>
401+ {(row.quantity || row.micros === 0) && (
402+ <span className="block truncate text-xs text-faint">{row.quantity ?? "None yet"}</span>
403+ )}
404+ </span>
405+ <span className={`shrink-0 tabular-nums ${row.micros > 0 ? "" : "text-faint"}`}>{dollars(row.micros)}</span>
380406 </li>
381407 ))}
382408 </ul>
409+ <div className="mt-1 flex items-baseline justify-between gap-4 border-t border-line pt-2.5 text-sm">
410+ <span className="font-medium">Total</span>
411+ <span className="font-medium tabular-nums">{dollars(total)}</span>
412+ </div>
413+ <p className="mt-2 text-xs text-faint">
414+ {comped
415+ ? "What this workspace's usage would cost. g1t covers it."
416+ : on
417+ ? `Drawn from the included usage first, then charged up to your spend limit. Projects, previews and repositories are never charged, and the first ${freeStorage} of private storage and ${freeGit} git operations a month are free. App traffic, custom domains, storage and git operations are counted through the month and charged when it closes.`
418+ : `The forge is free: ${freeStorage} of private storage and ${freeGit} git operations a month. Agents run from the trial or the open-source pool.`}
419+ </p>
383420 </div>
384421 );
385422 }
+2−2
1717 <CopyLine text={`https://g1t.sh/${path}.git`} />
1818 <p className="mt-2 text-xs text-muted">
1919 To push, use your username and an{" "}
20− <Link to="/settings" className="text-fg underline underline-offset-4">
20+ <Link to="/settings/tokens" className="text-fg underline underline-offset-4">
2121 access token
2222 </Link>{" "}
2323 as the password.
2626 <TabsContent value="ssh">
2727 <p className="rounded-lg border border-dashed border-line p-3 text-xs text-muted">
2828 Git over SSH is waiting on inbound TCP on Cloudflare, which g1t has applied for. Use HTTPS for now: it
29− clones, fetches and pushes the same. Keys you add under Settings will work as soon as SSH is on.
29+ clones, fetches and pushes the same. Keys you add under Settings → SSH keys will work as soon as SSH is on.
3030 </p>
3131 </TabsContent>
3232 <TabsContent value="agent">
+6−17
9494 const full = data.emails.length >= data.limit;
9595 return (
9696 <section id="emails" className="scroll-mt-20">
97− <h2 className="font-medium">Emails</h2>
98− <p className="mt-1 text-sm text-muted">
99− Your primary address gets account mail and password resets. Any confirmed address signs you in and can
100− reset your password, and commits that carry it are shown as yours.
101− </p>
102−
10397 {actionData?.reauth && (
104− <div className="mt-4">
98+ <div className="mb-4">
10599 <ConfirmItIsYou pending={actionData.reauth} hasPassword={hasPassword} />
106100 </div>
107101 )}
108102
109− <ul className="mt-4 divide-y divide-line rounded-md border border-line">
103+ <ul className="divide-y divide-line rounded-md border border-line">
110104 {data.emails.map((email) => {
111105 const can = addressActions(email, data.emails);
112106 return (
162156 )}
163157
164158 <div className="mt-8">
165− <h3 className="text-sm font-medium">Backup address</h3>
159+ <h2 className="font-medium">Backup address</h2>
166160 <p className="mt-1 text-sm text-muted">
167161 Security notices, such as a new address or a changed password, go to your primary and to this address.
168162 </p>
194188 </div>
195189
196190 <div className="mt-8">
197− <h3 className="text-sm font-medium">Privacy</h3>
191+ <h2 className="font-medium">Privacy</h2>
198192 <Form method="post" className="mt-3 space-y-3">
199193 <input type="hidden" name="intent" value="email-privacy" />
200194 <SwitchCard name="private" defaultChecked={data.privateEmail} title="Keep my email address private">
223217 export function SecurityLogSection({ log }: { log: SecurityEvent[] }) {
224218 return (
225219 <section id="security-log" className="scroll-mt-20">
226− <h2 className="font-medium">Security log</h2>
227− <p className="mt-1 text-sm text-muted">
228− Changes to your addresses and password, by you or by g1t staff. If you do not recognise one, reset your
229− password.
230− </p>
231220 {log.length === 0 ? (
232− <p className="mt-4 text-sm text-faint">Nothing yet.</p>
221+ <p className="text-sm text-faint">Nothing yet.</p>
233222 ) : (
234− <ul className="mt-4 divide-y divide-line rounded-md border border-line">
223+ <ul className="divide-y divide-line rounded-md border border-line">
235224 {log.map((event, at) => (
236225 <li key={`${event.createdAt}:${at}`} className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-center sm:gap-4">
237226 <p className="min-w-0 grow truncate text-sm">
+60−51
11 /**
22 * The site's footer: a few links worth having, who makes g1t, and a row of
33 * the trust pages with the live status dot, the account, and the legal
4− * line. Marketing and signed-out pages show it whole; the app's account
5− * menu shows the slim row (`LegalRow`).
4+ * line. Marketing and signed-out pages show it; the app's account menu
5+ * has its own rows (components/shell.tsx).
66 */
7−import { useEffect } from "react";
8−import { Link, useFetcher } from "react-router";
7+import { useEffect, useState } from "react";
8+import { Link } from "react-router";
99
1010 import type { User } from "@g1t/contracts";
1111
1212 import { Mark } from "./logo";
1313 import { COMPANY, MAKER_PRODUCTS, copyright, listed } from "../lib/legal";
14−import { type OverallState, type StatusReport, dotClass } from "../lib/status";
14+import { type OverallState, STATUS_JSON_URL, STATUS_URL, STATUS_WORDS, type StatusReport, dotClass } from "../lib/status";
15+
16+/** How long one fetched report is reused across the pages of one visit. */
17+const STATUS_REUSE_MS = 60_000;
18+let lastStatus: { report: StatusReport; at: number } | null = null;
19+let pendingStatus: Promise<StatusReport | null> | null = null;
20+
21+function fetchStatus(): Promise<StatusReport | null> {
22+ if (lastStatus && Date.now() - lastStatus.at < STATUS_REUSE_MS) return Promise.resolve(lastStatus.report);
23+ pendingStatus ??= fetch(STATUS_JSON_URL, { headers: { accept: "application/json" } })
24+ .then((response) => (response.ok ? (response.json() as Promise<StatusReport>) : null))
25+ .then((report) => {
26+ if (report?.overall) lastStatus = { report, at: Date.now() };
27+ return report?.overall ? report : null;
28+ })
29+ // The status page being unreachable only means no dot.
30+ .catch(() => null)
31+ .finally(() => {
32+ pendingStatus = null;
33+ });
34+ return pendingStatus;
35+}
1536
1637 /**
17− * The live status, fetched from /status.json once the page is up, so no
18− * page waits on the checks. Null until it arrives, or if it cannot.
38+ * The live status, from status.g1t.sh once the page is up (or `when` turns
39+ * true), so no page waits on it. Kept a minute across pages. Null until it
40+ * arrives, or if it cannot.
1941 */
20−export function useSiteStatus(): StatusReport | null {
21− const fetcher = useFetcher<StatusReport>({ key: "site-status" });
42+export function useSiteStatus(when = true): StatusReport | null {
43+ const [report, setReport] = useState<StatusReport | null>(() => lastStatus?.report ?? null);
2244 useEffect(() => {
23− if (fetcher.state === "idle" && fetcher.data == null) fetcher.load("/status.json");
24− // Once per page view is enough: the report itself changes once a minute.
25− }, []);
26− return fetcher.data ?? null;
45+ if (!when) return;
46+ let live = true;
47+ fetchStatus().then((fresh) => {
48+ if (live && fresh) setReport(fresh);
49+ });
50+ return () => {
51+ live = false;
52+ };
53+ }, [when]);
54+ return report;
2755 }
2856
29−const STATUS_WORDS: Record<OverallState, string> = {
30− up: "All systems working",
31− degraded: "Some systems having trouble",
32− down: "Major outage",
33− unknown: "Status unknown",
34−};
35−
3657 /** The coloured dot, with words for screen readers. */
3758 export function StatusDot({ state, className = "" }: { state: OverallState | null; className?: string }) {
3859 return (
7394 ["Flagon, Inc.", COMPANY.url],
7495 ["Support", "/support"],
7596 ["Security", "/security"],
76− ["Status", "/status"],
97+ ["Status", STATUS_URL],
7798 ],
7899 },
79100 {
155176 * Status, the trust pages, the account and the legal line, separated by
156177 * dots that wrap away cleanly on a phone.
157178 */
158−export function LegalRow({ user, compact = false }: { user: User | null | undefined; compact?: boolean }) {
179+export function LegalRow({ user }: { user: User | null | undefined }) {
159180 const status = useSiteStatus();
160181 const links = [
161− <Link key="status" to="/status" className={`inline-flex items-center gap-1.5 ${ROW_LINK}`}>
182+ <a key="status" href={STATUS_URL} className={`inline-flex items-center gap-1.5 ${ROW_LINK}`}>
162183 <StatusDot state={status?.overall.state ?? null} />
163184 Status
164− </Link>,
185+ </a>,
165186 ...ROW_LINKS.map(([label, to]) => (
166187 <Link key={to} to={to} className={ROW_LINK}>
167188 {label}
168189 </Link>
169190 )),
170191 ];
171− const account = compact
172− ? []
173− : [
174− user ? (
175− <Link key="me" to={`/u/${user.username}`} className={`font-medium text-fg/90 ${ROW_LINK}`}>
176− {user.username}
177− </Link>
178− ) : (
179− <Link key="login" to="/login" className={ROW_LINK}>
180− Sign in
181− </Link>
182− ),
183− ];
192+ const account = [
193+ user ? (
194+ <Link key="me" to={`/u/${user.username}`} className={`font-medium text-fg/90 ${ROW_LINK}`}>
195+ {user.username}
196+ </Link>
197+ ) : (
198+ <Link key="login" to="/login" className={ROW_LINK}>
199+ Sign in
200+ </Link>
201+ ),
202+ ];
184203 const legal = [
185204 ...account,
186205 <span key="copyright" className="text-faint">
187206 {copyright()}
188207 </span>,
189208 ];
190− const gap = compact ? "gap-x-2" : "gap-x-3";
209+ const gap = "gap-x-3";
191210 const dot = (
192211 <span aria-hidden className="text-line-strong">
193212 ·
203222 </li>
204223 ));
205224 return (
206− <div
207− className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap} text-muted ${compact ? "text-[0.6875rem]" : "text-[0.8125rem]"}`}
208− >
209− {compact ? (
210− // The account menu is narrow: the links in two short runs.
211− <>
212− <ul className={`flex items-center ${gap}`}>{run(links.slice(0, 3))}</ul>
213− <ul className={`flex items-center ${gap}`}>{run(links.slice(3))}</ul>
214− </>
215− ) : (
216− <ul className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap}`}>{run(links)}</ul>
217− )}
225+ <div className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap} text-[0.8125rem] text-muted`}>
226+ <ul className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap}`}>{run(links)}</ul>
218227 {/* Between the two runs only while they share a line. */}
219− {!compact && <span className="hidden md:inline">{dot}</span>}
228+ <span className="hidden md:inline">{dot}</span>
220229 <ul className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap}`}>{run(legal)}</ul>
221230 </div>
222231 );
+18−0
1+/**
2+ * The fields a bot fills in and a person never sees, on the forms anyone
3+ * can send: signing up and asking for access. `looksAutomated` in
4+ * lib/invites.ts reads them.
5+ */
6+export function Honeypot({ started }: { started: number }) {
7+ return (
8+ <>
9+ <input type="hidden" name="started" value={started} />
10+ <div aria-hidden="true" className="absolute left-[-9999px] h-px w-px overflow-hidden">
11+ <label>
12+ Website
13+ <input type="text" name="website" tabIndex={-1} autoComplete="off" />
14+ </label>
15+ </div>
16+ </>
17+ );
18+}
+3−5
4444 }
4545
4646 /**
47− * Account settings → Invites: what is left, making one (for anyone with the
47+ * Settings → Invites (`/settings/invites`): what is left, making one (for anyone with the
4848 * link, or for one address), copying links, and revoking.
4949 */
5050 export function InvitesSection({
6161 if (!overview) {
6262 return (
6363 <section id="invites" className="scroll-mt-20">
64− <h2 className="font-medium">Invites</h2>
65− <p className="mt-1 text-sm text-muted">Confirm your email address to invite people to g1t.</p>
64+ <p className="text-sm text-muted">Confirm your email address to invite people to g1t.</p>
6665 </section>
6766 );
6867 }
7069 const out = allowance.remaining === 0 && workspaces.every((workspace) => workspace.allowance.remaining === 0);
7170 return (
7271 <section id="invites" className="scroll-mt-20">
73− <h2 className="font-medium">Invites</h2>
74− <p className="mt-1 text-sm text-muted">
72+ <p className="text-sm text-muted">
7573 {overview.mode === "invite"
7674 ? "g1t is invite-only for now. Each invite lets one person make an account. "
7775 : "Anyone can make an account, but an invite still says who sent it. "}
+19−0
6464 </span>
6565 );
6666 }
67+
68+const FOUR = ["...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."];
69+const ZERO = [".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."];
70+/** 4 at 0, the 0 at 6, 4 at 12: the logo's cells and spacing. */
71+const FOUR_O_FOUR = [...pixels(FOUR, 0, false), ...pixels(ZERO, 6, true), ...pixels(FOUR, 12, false)];
72+
73+/**
74+ * 404 in the logo's pixels, the 0 in lavender where the logo has its 1.
75+ * Sized by its font size, like the wordmark.
76+ */
77+export function Pixel404({ className = "text-[4rem]" }: { className?: string }) {
78+ return (
79+ <span className={`inline-flex leading-none text-fg ${className}`}>
80+ <svg viewBox="1 1 168 68" role="img" aria-label="404" className="h-[0.85em] w-auto shrink-0" shapeRendering="crispEdges">
81+ <Pixels of={FOUR_O_FOUR} />
82+ </svg>
83+ </span>
84+ );
85+}
+0−33
1818 import { type ReactNode, useEffect, useState } from "react";
1919 import { Link, useRouteLoaderData } from "react-router";
2020
21−import { CHANGELOG, changelogHref } from "../lib/changelog";
2221 import { trialClosed } from "../lib/trial";
2322 import { type Need, formatSpan, greetingFor, rankNeeds } from "../lib/mission";
2423 import { AgentSetup } from "./agent-setup";
377376 );
378377 }
379378
380−function WhatsNew({ workspace }: { workspace: string | null }) {
381− return (
382− <div className="rounded-xl border border-line bg-surface p-5">
383− <h2 className="flex items-center gap-1.5 text-sm font-semibold">
384− <Sparkles size={14} className="text-accent" />
385− What's new on g1t
386− </h2>
387− <ul className="mt-3 space-y-3">
388− {CHANGELOG.slice(0, 5).map((entry) => {
389− const href = changelogHref(entry, workspace);
390− const title = <span className="text-sm font-medium">{entry.title}</span>;
391− return (
392− <li key={entry.title}>
393− {href ? (
394− <Link to={href} className="hover:text-accent">
395− {title}
396− </Link>
397− ) : (
398− title
399− )}
400− <p className="mt-0.5 text-xs leading-5 text-muted">{entry.about}</p>
401− </li>
402− );
403− })}
404− </ul>
405− <a href="https://docs.g1t.sh/" className="mt-4 inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
406− Read the docs <ArrowRight size={12} />
407− </a>
408− </div>
409− );
410−}
411379
412380 // --- The page -------------------------------------------------------------------
413381
695663 />
696664 )}
697665 {shell?.workspace && <UsageCard shell={shell} weekCost={pulse.cost} />}
698− <WhatsNew workspace={workspace} />
699666 <div className="rounded-xl border border-line bg-surface p-5">
700667 <h2 className="text-sm font-semibold">Connect your own agent</h2>
701668 <p className="mt-1.5 text-xs leading-5 text-muted">
+57−61
1−import { EyeOff, KeyRound, UserX } from "lucide-react";
21 import { Form, Link, useLocation, useRouteLoaderData } from "react-router";
32
4−import type { User } from "@g1t/contracts";
3+import type { Membership, User } from "@g1t/contracts";
54
65 import { withNext } from "../lib/next";
76 import { useSignUpCopy } from "../lib/registration";
87 import { missingKind, notFoundCopy } from "../lib/not-found";
9−import { Avatar, ButtonLink } from "./ui";
8+import { Pixel404 } from "./logo";
9+import { ButtonLink } from "./ui";
1010
1111 /**
1212 * The page for anything someone cannot see. It reads only the kind of thing
1313 * the address names and who is signed in, so a private project and a
14− * missing one give the same page, word for word.
14+ * missing one give the same page, word for word, and the same 404.
1515 */
1616 export function NotFound({ data }: { data?: unknown }) {
1717 const { pathname, search } = useLocation();
18− const user = (useRouteLoaderData("root") as { user?: User | null } | undefined)?.user ?? null;
18+ const root = useRouteLoaderData("root") as
19+ | { user?: User | null; shell?: { workspace?: Membership | null; repo?: { namespace: string; name: string } | null } }
20+ | undefined;
21+ const user = root?.user ?? null;
22+ const workspace = user ? (root?.shell?.workspace ?? null) : null;
23+ // Something missing inside a project the viewer can see: back to it.
24+ const project = root?.shell?.repo ?? null;
25+ const inProject =
26+ project != null && pathname.toLowerCase().startsWith(`/${project.namespace}/${project.name}/`.toLowerCase());
1927 const kind = missingKind(data, pathname);
2028 const copy = notFoundCopy(kind, user?.username);
2129 const here = pathname + search;
2230 const signUp = useSignUpCopy();
23− const Icon = kind === "person" ? UserX : copy.signIn ? KeyRound : EyeOff;
2431 return (
25− <main className="mx-auto flex max-w-lg flex-col items-center px-4 py-24 text-center sm:py-32">
26− <span className="flex size-11 items-center justify-center rounded-xl bg-surface text-muted ring-1 ring-line">
27− <Icon size={20} />
28− </span>
29− <h1 className="mt-6 text-balance text-2xl font-semibold tracking-tight">{copy.title}</h1>
30− {copy.body && <p className="mt-3 text-muted">{copy.body}</p>}
32+ <main className="mx-auto flex max-w-lg flex-col items-center px-4 py-20 text-center sm:py-28">
33+ <Pixel404 className="text-[3.5rem] sm:text-[4.5rem]" />
34+ <h1 className="mt-10 text-balance text-2xl font-semibold tracking-tight">{copy.title}</h1>
35+ <p className="mt-2 text-balance text-muted">{copy.body}</p>
3136
37+ <div className="mt-8 flex flex-wrap justify-center gap-2">
38+ {copy.signIn ? (
39+ <ButtonLink to={withNext("/login", here)}>Sign in</ButtonLink>
40+ ) : kind === "person" ? (
41+ <ButtonLink to="/search?type=people">Search people</ButtonLink>
42+ ) : inProject ? (
43+ <ButtonLink to={`/${project.namespace}/${project.name}`}>Go to {project.name}</ButtonLink>
44+ ) : workspace ? (
45+ <ButtonLink to={`/${workspace.slug}`}>Go to {workspace.name?.trim() || workspace.slug}</ButtonLink>
46+ ) : user ? (
47+ <ButtonLink to="/">Go to Mission control</ButtonLink>
48+ ) : null}
49+ <ButtonLink to="/explore" variant="quiet">
50+ {user ? "Explore" : "Explore public projects"}
51+ </ButtonLink>
52+ </div>
53+
54+ <p className="mt-6 hidden text-sm text-faint sm:block">
55+ Looking for something? Press{" "}
56+ <kbd className="rounded bg-raised px-1.5 py-0.5 font-mono text-[0.6875rem] text-muted ring-1 ring-line">⌘K</kbd>{" "}
57+ to search.
58+ </p>
59+
3260 {copy.signIn && (
33− <>
34− <div className="mt-8 flex flex-wrap justify-center gap-2">
35− <ButtonLink to={withNext("/login", here)}>Sign in</ButtonLink>
36− <ButtonLink to="/explore" variant="quiet">
37− Explore public projects
38− </ButtonLink>
39− </div>
40− <p className="mt-5 text-sm text-muted">
41− New to g1t?{" "}
42− <Link to={withNext("/register", here)} className="font-medium text-fg underline-offset-4 hover:underline">
43− {signUp.primary}
44− </Link>
45− </p>
46− </>
61+ <p className="mt-3 text-xs text-faint">
62+ New to g1t?{" "}
63+ <Link to={withNext("/register", here)} className="text-muted underline-offset-4 hover:text-fg hover:underline">
64+ {signUp.primary}
65+ </Link>
66+ </p>
4767 )}
4868
4969 {copy.signedInAs && (
50− <>
51− <div className="mt-6 flex w-full flex-col items-center gap-3 rounded-lg bg-surface px-4 py-4 text-sm ring-1 ring-line">
52− <p className="flex items-center gap-2 text-muted">
53− <Avatar name={copy.signedInAs} image={user?.avatar} size={20} />
54− Signed in as <span className="font-mono font-medium text-fg">@{copy.signedInAs}</span>
55− </p>
56− {/* Signing out, then in again, comes back here. */}
57− <Form method="post" action={`/logout?next=${encodeURIComponent(withNext("/login", here))}`}>
58− <span className="text-faint">Not you? </span>
59− <button type="submit" className="font-medium text-fg underline-offset-4 hover:underline">
60− Switch account
61− </button>
62− </Form>
63− </div>
64− {copy.askOwner && (
65− <p className="mt-4 text-sm text-muted">
66− If you should have access, ask an owner of the workspace to add you.
67− </p>
68− )}
69− <div className="mt-8 flex flex-wrap justify-center gap-2">
70− <ButtonLink to="/">Back to Mission control</ButtonLink>
71− <ButtonLink to="/explore" variant="quiet">
72− Explore
73− </ButtonLink>
74− </div>
75− </>
76− )}
77−
78− {kind === "person" && (
79− <div className="mt-8 flex flex-wrap justify-center gap-2">
80− <ButtonLink to="/search?type=people">Search people</ButtonLink>
81− <ButtonLink to="/explore" variant="quiet">
82− Explore
83− </ButtonLink>
84− </div>
70+ // Signing out, then in again, comes back here.
71+ <Form
72+ method="post"
73+ action={`/logout?next=${encodeURIComponent(withNext("/login", here))}`}
74+ className="mt-3 text-xs text-faint"
75+ >
76+ Signed in as <span className="font-mono text-muted">@{copy.signedInAs}</span> ·{" "}
77+ <button type="submit" className="text-muted underline-offset-4 hover:text-fg hover:underline">
78+ Switch account
79+ </button>
80+ </Form>
8581 )}
8682 </main>
8783 );
+1−1
3030 return (
3131 <section id="profile" className="scroll-mt-20">
3232 <div className="flex flex-wrap items-baseline justify-between gap-2">
33− <h2 className="font-medium">Profile</h2>
33+ <h2 className="font-medium">Public profile</h2>
3434 <Link to={`/u/${username}`} className="text-sm text-muted underline-offset-4 hover:text-fg hover:underline">
3535 View your profile
3636 </Link>
+255−67
1111 ChevronLeft,
1212 ChevronRight,
1313 ChevronsUpDown,
14− Ellipsis,
14+ CircleUserRound,
15+ Mail,
16+ Ticket,
1517 CircleDot,
1618 Code2,
1719 Compass,
4749 X,
4850 } from "lucide-react";
4951 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
50−import { Form, Link, NavLink, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
52+import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
5153
5254 import type { Abilities, Membership, Spike, User } from "@g1t/contracts";
5355
5456 import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./command-palette";
55−import { LegalRow } from "./footer";
56−import { Logo, Mark } from "./logo";
57+import { StatusDot, useSiteStatus } from "./footer";
58+import { Logo } from "./logo";
5759 import { Avatar, notACredential } from "./ui";
5860 import {
5961 DropdownMenu,
6062 DropdownMenuContent,
63+ DropdownMenuGroup,
6164 DropdownMenuItem,
6265 DropdownMenuLabel,
6366 DropdownMenuSeparator,
6669 import { type RoadmapItem, roadmapIn } from "../lib/roadmap";
6770 import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access";
6871 import { VISITOR_LINKS, projectPages } from "../lib/chrome";
72+import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings";
73+import { GithubMark } from "./github";
6974 import { withNext } from "../lib/next";
7075 import { useSignUpCopy } from "../lib/registration";
76+import { STATUS_URL, statusTitle } from "../lib/status";
77+import type { AccountMenuData } from "../routes/settings-menu-json";
7178
7279 /**
7380 * What the sidebar needs, worked out by the root loader. For a visitor who
316323 );
317324 }
318325
326+/**
327+ * The fine print, as one quiet row at the foot of the account menu and the
328+ * visitor's panel: each link a full-height target, left-aligned. In the
329+ * menu each is a menu item, so the arrow keys reach it.
330+ */
331+const LEGAL_LINKS: [string, string][] = [
332+ ["Policies", "/policies"],
333+ ["Privacy", "/policies/privacy"],
334+ ["Security", "/security"],
335+];
336+const LEGAL_LINK = "flex h-7 items-center rounded px-1.5 text-xs text-faint outline-none transition-colors";
337+
338+function MenuLegalRow() {
339+ return (
340+ <div role="group" aria-label="Legal" className="flex items-center gap-0.5 px-0.5 pt-0.5">
341+ {LEGAL_LINKS.map(([label, to], index) => (
342+ <span key={to} className="flex items-center gap-0.5">
343+ {index > 0 && (
344+ <span aria-hidden="true" className="text-[0.625rem] text-line-strong">
345+ ·
346+ </span>
347+ )}
348+ <DropdownMenuItem asChild className={`${LEGAL_LINK} py-0 data-highlighted:bg-line data-highlighted:text-fg`}>
349+ <Link to={to}>{label}</Link>
350+ </DropdownMenuItem>
351+ </span>
352+ ))}
353+ </div>
354+ );
355+}
356+
357+/** Status's row: the live dot and two or three words, fetched when the menu opens. */
358+function StatusSummary({ open }: { open: boolean }) {
359+ const status = useSiteStatus(open);
360+ const words = statusTitle(status);
361+ return (
362+ <span className="ml-auto flex min-w-0 items-center gap-1.5 pl-2 text-xs text-faint">
363+ {/* The words say it; the dot's own words for screen readers would repeat them. */}
364+ <span aria-hidden="true" className="flex">
365+ <StatusDot state={status?.overall.state ?? null} />
366+ </span>
367+ <span className="truncate">{words ?? "Checking…"}</span>
368+ </span>
369+ );
370+}
371+
372+/** A menu row: an icon, words, and whatever sits at its end. */
373+const MENU_ROW = "h-9 gap-2.5 px-2.5 text-[0.8125rem]";
374+
319375 function AccountMenu({ user }: { user: User }) {
320376 const submit = useSubmit();
377+ const [open, setOpen] = useState(false);
378+ // Name, primary address and invites left: asked for once, when first opened.
379+ const details = useFetcher<AccountMenuData | null>({ key: "account-menu" });
380+ useEffect(() => {
381+ if (open && details.state === "idle" && details.data === undefined) details.load("/settings/menu.json");
382+ }, [open, details]);
383+ const me = details.data ?? null;
384+ const profile = `/u/${user.username}`;
321385 return (
322− <DropdownMenu>
323− <DropdownMenuTrigger className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised">
386+ <DropdownMenu open={open} onOpenChange={setOpen}>
387+ <DropdownMenuTrigger
388+ aria-label={`Account menu for ${user.username}`}
389+ className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised focus-visible:ring-2 focus-visible:ring-merged/60 data-[state=open]:bg-raised"
390+ >
324391 <Avatar name={user.username} image={user.avatar} size={22} />
325392 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
326− <Ellipsis size={15} className="shrink-0 text-faint" />
393+ <ChevronsUpDown size={14} className="shrink-0 text-faint" />
327394 </DropdownMenuTrigger>
328− <DropdownMenuContent align="start" side="top" className="w-64">
329− <DropdownMenuLabel>
330− Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
331− </DropdownMenuLabel>
332− <DropdownMenuSeparator />
333− <DropdownMenuItem asChild>
334− <Link to="/settings">
335− <Settings />
336− Your settings
395+ <DropdownMenuContent align="start" side="top" collisionPadding={8} className="w-[17.5rem] p-1.5">
396+ {/* Who is signed in, and a way to their profile. */}
397+ <DropdownMenuItem asChild className="gap-3 px-2 py-2">
398+ <Link to={profile} aria-label={`${me?.name ?? user.username} (@${user.username}), your profile`}>
399+ <Avatar name={user.username} image={user.avatar} size={36} />
400+ <span className="flex min-w-0 flex-col leading-tight">
401+ <span className="truncate text-sm font-medium text-fg">{me?.name ?? user.username}</span>
402+ <span className="truncate font-mono text-xs text-muted">@{user.username}</span>
403+ {me?.email && <span className="mt-0.5 truncate text-xs text-faint">{me.email}</span>}
404+ </span>
337405 </Link>
338406 </DropdownMenuItem>
339− <DropdownMenuItem asChild>
340− <a href="https://docs.g1t.sh/">
341− <BookOpen />
342− Documentation
343− </a>
344− </DropdownMenuItem>
345− <DropdownMenuSeparator />
407+ <DropdownMenuSeparator className="my-1.5" />
408+ <DropdownMenuGroup>
409+ <DropdownMenuItem asChild className={MENU_ROW}>
410+ <Link to={profile}>
411+ <CircleUserRound />
412+ Your profile
413+ </Link>
414+ </DropdownMenuItem>
415+ <DropdownMenuItem asChild className={MENU_ROW}>
416+ <Link to={FIRST_SETTINGS_PAGE}>
417+ <Settings />
418+ Your settings
419+ </Link>
420+ </DropdownMenuItem>
421+ <DropdownMenuItem asChild className={MENU_ROW}>
422+ <Link to="/settings/invites">
423+ <Ticket />
424+ Invites
425+ {me?.invites_left != null && (
426+ <span className="ml-auto rounded-full bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
427+ {me.invites_left} left
428+ </span>
429+ )}
430+ </Link>
431+ </DropdownMenuItem>
432+ </DropdownMenuGroup>
433+ <DropdownMenuSeparator className="my-1.5" />
434+ <DropdownMenuGroup>
435+ <DropdownMenuItem asChild className={MENU_ROW}>
436+ <a href="https://docs.g1t.sh/">
437+ <BookOpen />
438+ Documentation
439+ </a>
440+ </DropdownMenuItem>
441+ <DropdownMenuItem asChild className={MENU_ROW}>
442+ <Link to="/support">
443+ <LifeBuoy />
444+ Support
445+ </Link>
446+ </DropdownMenuItem>
447+ <DropdownMenuItem asChild className={MENU_ROW}>
448+ <a href={STATUS_URL}>
449+ <Activity />
450+ Status
451+ <StatusSummary open={open} />
452+ </a>
453+ </DropdownMenuItem>
454+ </DropdownMenuGroup>
455+ <DropdownMenuSeparator className="my-1.5" />
346456 {/* Submitted from here: the menu closes on select, and a button
347457 that has left the page cannot submit a form. */}
348− <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
458+ <DropdownMenuItem className={MENU_ROW} onSelect={() => submit(null, { method: "post", action: "/logout" })}>
349459 <LogOut />
350460 Sign out
351461 </DropdownMenuItem>
352− {/* The site footer's row, slim: the app has no footer of its own.
353− Rendered only while the menu is open, so status is fetched then. */}
354− <DropdownMenuSeparator />
355− <div className="px-1.5 pt-1 pb-1.5">
356− <LegalRow user={user} compact />
357− </div>
462+ <DropdownMenuSeparator className="my-1.5" />
463+ <MenuLegalRow />
358464 </DropdownMenuContent>
359465 </DropdownMenu>
360466 );
849955 );
850956 }
851957
852−/** Your own settings, as the sidebar shows them on the settings page. */
853−function AccountSettingsMenu() {
854− const { hash } = useLocation();
855− const item = (id: string, icon: ReactNode, label: string) => (
856− <Link
857− to={`/settings#${id}`}
858− className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
859− hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
860− }`}
861− >
862− <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
863− {label}
864− </Link>
958+/** Your own settings, drilled into from Your settings: one page each. */
959+function AccountSettingsMenu({ username }: { username: string }) {
960+ const link = (page: AccountSettingsPage, icon: ReactNode) => (
961+ <SidebarLink to={`/settings/${page}`} icon={icon}>
962+ {ACCOUNT_SETTINGS[page].title}
963+ </SidebarLink>
865964 );
866965 return (
867966 <nav aria-label="Your settings" className={PANEL}>
868− <BackRow to="/" label="Your settings" />
967+ <BackRow to="/" label="Your settings" context={username} />
869968 <div className="mt-2 space-y-px">
870− {item("ssh-keys", <Fingerprint size={15} />, "SSH keys")}
871− {item("tokens", <KeyRound size={15} />, "Access tokens")}
872− {item("applications", <Plug size={15} />, "Connected applications")}
969+ {link("profile", <CircleUserRound size={15} />)}
970+ {link("emails", <Mail size={15} />)}
971+ {link("invites", <Ticket size={15} />)}
972+ </div>
973+ <Rule />
974+ <div className="space-y-px">
975+ {link("keys", <Fingerprint size={15} />)}
976+ {link("tokens", <KeyRound size={15} />)}
873977 </div>
978+ <Rule />
979+ <div className="space-y-px">
980+ {link("github", <GithubMark className="size-[15px]" />)}
981+ {link("applications", <Plug size={15} />)}
982+ </div>
983+ <Rule />
984+ <div className="space-y-px">
985+ {link("security-log", <History size={15} />)}
986+ </div>
874987 </nav>
875988 );
876989 }
879992 * Signing in and signing up, in place of the account for a visitor. Signing
880993 * in brings them back to the page they are on.
881994 */
995+/** Help, status and the fine print under a visitor's sign-in buttons. */
996+function VisitorLinks() {
997+ const status = useSiteStatus();
998+ const quiet = `${LEGAL_LINK} hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-merged/60`;
999+ const dot = (
1000+ <span aria-hidden="true" className="text-[0.625rem] text-line-strong">
1001+ ·
1002+ </span>
1003+ );
1004+ return (
1005+ // Two short rows, so a narrow sidebar never starts a line with a dot.
1006+ <nav aria-label="About g1t" className="pt-0.5">
1007+ <div className="flex items-center gap-0.5">
1008+ <a href={STATUS_URL} className={`${quiet} gap-1.5`}>
1009+ <StatusDot state={status?.overall.state ?? null} />
1010+ Status
1011+ </a>
1012+ {dot}
1013+ <Link to="/support" className={quiet}>
1014+ Support
1015+ </Link>
1016+ </div>
1017+ <div className="flex items-center gap-0.5">
1018+ {LEGAL_LINKS.map(([label, to], index) => (
1019+ <span key={to} className="flex items-center gap-0.5">
1020+ {index > 0 && dot}
1021+ <Link to={to} className={quiet}>
1022+ {label}
1023+ </Link>
1024+ </span>
1025+ ))}
1026+ </div>
1027+ </nav>
1028+ );
1029+}
1030+
8821031 function VisitorPanel() {
8831032 const { pathname, search } = useLocation();
8841033 const signUp = useSignUpCopy();
9001049 {signUp.primary}
9011050 </Link>
9021051 </div>
903− <nav aria-label="About g1t" className="flex flex-wrap justify-center gap-x-2 gap-y-0.5 px-1 pt-1 text-[0.6875rem] text-faint">
904− <Link to="/status" className="hover:text-fg">Status</Link>
905− <Link to="/support" className="hover:text-fg">Support</Link>
906− <Link to="/policies" className="hover:text-fg">Policies</Link>
907− <Link to="/policies/privacy" className="hover:text-fg">Privacy</Link>
908− <Link to="/security" className="hover:text-fg">Security</Link>
909− </nav>
1052+ <VisitorLinks />
9101053 </div>
9111054 );
9121055 }
9141057 function Sidebar({
9151058 user,
9161059 shell,
1060+ missing = false,
9171061 onFind,
9181062 onClose,
9191063 }: {
9201064 user: User | null;
9211065 shell: ShellData;
1066+ /** The page is a 404: the address names nothing the viewer can see. */
1067+ missing?: boolean;
9221068 onFind: () => void;
9231069 /** In the sheet on a small screen: closing it, at the end of the top row. */
9241070 onClose?: () => void;
9291075 // Where the sidebar is drilled to follows the page being gone to, so it
9301076 // moves as the link is followed, not once the page arrives.
9311077 const target = going ?? pathname;
932− const inSettings = ws != null && SETTINGS_PAGE.exec(target)?.[1]?.toLowerCase() === ws.slug;
933− const inAccount = target === "/settings";
1078+ // On a 404, the address is not a place: no list is drilled into from it,
1079+ // except the project's own when the project is real and visible and only
1080+ // something inside it is missing (a file, a commit, an issue).
1081+ const lost = missing && going == null;
1082+ const inSettings = !lost && ws != null && SETTINGS_PAGE.exec(target)?.[1]?.toLowerCase() === ws.slug;
1083+ const inAccount = !lost && target === "/settings" || target.startsWith("/settings/");
9341084 const active = shell.repo;
9351085 // In a repository, or on the way into one, its own list.
9361086 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
9371087 const reserved = new Set(["settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth", "policies", "security", "support", "status", "invite", ".well-known"]);
9381088 // An invitation to a repository is answered before its menu means anything.
9391089 const inRepo =
940− repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-" && !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target);
1090+ repoPath != null &&
1091+ !reserved.has(repoPath[1]) &&
1092+ repoPath[2] !== "-" &&
1093+ !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target) &&
1094+ (!lost || sameRepo(active, { namespace: repoPath[1]!, name: repoPath[2]! }));
9411095 // A project's settings, one level further in.
9421096 const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target);
9431097 // The repository the menu is for: the one loaded if it is the one being
9631117 // The way from the main list to the one shown.
9641118 const trail: Level[] = [{ key: "main", node: <MainMenu user={user} shell={shell} /> }];
9651119 if (user && inAccount) {
966− trail.push({ key: "account", node: <AccountSettingsMenu /> });
1120+ trail.push({ key: "account", node: <AccountSettingsMenu username={user.username} /> });
9671121 } else if (ws && inSettings) {
9681122 trail.push({ key: `settings:${ws.slug}`, node: <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} /> });
9691123 } else if (menuRepo) {
9941148 <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
9951149 {user ? (
9961150 <>
997− <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
998− <Mark className="size-6" />
1151+ <Link to="/" aria-label="g1t home" className="flex h-9 shrink-0 items-center rounded-md px-2 transition-colors hover:bg-raised">
1152+ <Logo className="text-[1.25rem]" />
9991153 </Link>
10001154 <span className="shrink-0 text-line-strong" aria-hidden="true">
10011155 /
10031157 <WorkspaceSwitcher user={user} shell={shell} />
10041158 </>
10051159 ) : (
1006− <Link to="/" aria-label="g1t home" className="mr-auto flex rounded-md px-1.5 py-1.5 hover:bg-raised">
1007− <Logo />
1160+ <Link to="/" aria-label="g1t home" className="mr-auto flex h-9 items-center rounded-md px-2 transition-colors hover:bg-raised">
1161+ <Logo className="text-[1.25rem]" />
10081162 </Link>
10091163 )}
10101164 {onClose && (
10711225 };
10721226
10731227 /** Where the page is, as a trail of links: workspace / repository / section. */
1074−function Breadcrumbs({ pathname }: { pathname: string }) {
1228+function Breadcrumbs({ pathname, missing, repo }: { pathname: string; missing?: boolean; repo?: ShellData["repo"] }) {
1229+ // A 404 names nothing from the address, unless the project is real and
1230+ // visible and only something inside it is missing.
1231+ const visibleRepo = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(pathname);
1232+ if (missing && !(visibleRepo && sameRepo(repo ?? null, { namespace: visibleRepo[1]!, name: visibleRepo[2]! }))) {
1233+ return <span className="text-sm font-medium">Not found</span>;
1234+ }
10751235 const parts = pathname.split("/").filter(Boolean);
10761236 const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite"];
10771237 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
1238+ // Your settings: Settings / Emails.
1239+ if (parts[0] === "settings") {
1240+ const page = accountSettingsPage(pathname);
1241+ const trail = [{ label: "Settings", to: FIRST_SETTINGS_PAGE }, ...(page ? [{ label: ACCOUNT_SETTINGS[page].title, to: `/settings/${page}` }] : [])];
1242+ return (
1243+ <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
1244+ {trail.map((crumb, index) => (
1245+ <span key={crumb.to + index} className="flex min-w-0 items-center gap-1.5">
1246+ {index > 0 && <span className="text-line-strong">/</span>}
1247+ <Link
1248+ to={crumb.to}
1249+ className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
1250+ index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
1251+ }`}
1252+ >
1253+ {crumb.label}
1254+ </Link>
1255+ </span>
1256+ ))}
1257+ </nav>
1258+ );
1259+ }
10781260 if (reserved.includes(parts[0]!)) {
10791261 const words: Record<string, string> = {
10801262 settings: "Account",
11461328 { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> },
11471329 { label: "New project", to: "/new", icon: <Plus size={15} /> },
11481330 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
1149− { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
1331+ { label: "Your settings", to: FIRST_SETTINGS_PAGE, icon: <Settings size={15} /> },
1332+ ...(Object.keys(ACCOUNT_SETTINGS) as AccountSettingsPage[])
1333+ .filter((page) => page !== "profile")
1334+ .map((page) => ({ label: ACCOUNT_SETTINGS[page].title, hint: "Your settings", to: `/settings/${page}`, icon: <Settings size={15} /> })),
11501335 ];
11511336 const repo = shell.repo;
11521337 if (repo) {
12671452 export function AppShell({
12681453 user,
12691454 shell,
1455+ missing = false,
12701456 banner,
12711457 children,
12721458 }: {
12731459 user: User | null;
12741460 shell: ShellData;
1461+ /** The page is a 404 (see Sidebar). */
1462+ missing?: boolean;
12751463 banner?: ReactNode;
12761464 children: ReactNode;
12771465 }) {
12901478 <div className="min-h-screen">
12911479 <Progress />
12921480 <aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))] lg:block">
1293− <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
1481+ <Sidebar user={user} shell={shell} missing={missing} onFind={() => setPalette(true)} />
12941482 </aside>
12951483 {drawer && (
12961484 <div className="fixed inset-0 z-50 lg:hidden">
13011489 onClick={() => setDrawer(false)}
13021490 />
13031491 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
1304− <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} onClose={() => setDrawer(false)} />
1492+ <Sidebar user={user} shell={shell} missing={missing} onFind={() => setPalette(true)} onClose={() => setDrawer(false)} />
13051493 </aside>
13061494 </div>
13071495 )}
13161504 >
13171505 <Menu size={18} />
13181506 </button>
1319− <Breadcrumbs pathname={pathname} />
1507+ <Breadcrumbs pathname={pathname} missing={missing} repo={shell.repo} />
13201508 <Form action="/search" role="search" className="relative ml-auto hidden w-full max-w-64 md:block">
13211509 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
13221510 <input
+19−0
1+import { PartyPopper } from "lucide-react";
2+import type { ReactNode } from "react";
3+
4+/**
5+ * The one-time "You're in" at the top of the workspace or repository an
6+ * invite led to, on the first view after joining (lib/invites.ts's
7+ * welcome cookie, cleared as it is shown).
8+ */
9+export function WelcomeBanner({ title, children }: { title: string; children: ReactNode }) {
10+ return (
11+ <div role="status" className="flex items-start gap-3 rounded-lg border border-accent/30 bg-accent/5 px-4 py-3">
12+ <PartyPopper size={18} aria-hidden="true" className="mt-0.5 shrink-0 text-accent" />
13+ <div className="min-w-0 text-sm">
14+ <p className="font-medium text-fg">{title}</p>
15+ <p className="mt-0.5 text-muted">{children}</p>
16+ </div>
17+ </div>
18+ );
19+}
+30−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { ACCOUNT_SETTINGS, accountSettingsPage, settingsPathForHash } from "./account-settings.ts";
5+
6+test("every anchor of the old settings page lands on its own page", () => {
7+ const cases: [string, string][] = [
8+ ["#picture", "/settings/profile"],
9+ ["#profile", "/settings/profile"],
10+ ["#emails", "/settings/emails"],
11+ ["#github", "/settings/github"],
12+ ["#invites", "/settings/invites"],
13+ ["#ssh-keys", "/settings/keys"],
14+ ["#tokens", "/settings/tokens"],
15+ ["#applications", "/settings/applications"],
16+ ["#security-log", "/settings/security-log"],
17+ ];
18+ for (const [hash, path] of cases) assert.equal(settingsPathForHash(hash), path, hash);
19+ assert.equal(settingsPathForHash(""), null);
20+ assert.equal(settingsPathForHash("#nothing"), null);
21+});
22+
23+test("a settings path names its page", () => {
24+ for (const page of Object.keys(ACCOUNT_SETTINGS)) {
25+ assert.equal(accountSettingsPage(`/settings/${page}`), page);
26+ }
27+ assert.equal(accountSettingsPage("/settings"), null);
28+ assert.equal(accountSettingsPage("/settings/nope"), null);
29+ assert.equal(accountSettingsPage("/acme/web/settings/domains"), null);
30+});
+78−0
1+/**
2+ * Your own settings: one page for each part, at `/settings/<page>`, so the
3+ * docs, emails and messages can link to exactly the one they mean.
4+ */
5+
6+export type AccountSettingsPage =
7+ | "profile"
8+ | "emails"
9+ | "invites"
10+ | "keys"
11+ | "tokens"
12+ | "github"
13+ | "applications"
14+ | "security-log";
15+
16+/** Each page's name and what it is for, in the sidebar's order. */
17+export const ACCOUNT_SETTINGS: Record<AccountSettingsPage, { title: string; about: string }> = {
18+ profile: { title: "Profile", about: "Your picture, and what everyone sees on your profile." },
19+ emails: {
20+ title: "Emails",
21+ about:
22+ "Your primary address gets account mail and password resets. Any confirmed address signs you in and can reset your password, and commits that carry it are shown as yours.",
23+ },
24+ invites: { title: "Invites", about: "Bring people to g1t, and see which invites were used." },
25+ keys: { title: "SSH keys", about: "Keys that let git on your computers reach g1t as you." },
26+ tokens: {
27+ title: "Access tokens",
28+ about:
29+ "Use a token as the password when git asks for one over HTTPS, and to authenticate agents and the API. A token here acts as you.",
30+ },
31+ github: {
32+ title: "GitHub",
33+ about: "Sign in with your GitHub account, and import or mirror repositories you can reach there.",
34+ },
35+ applications: {
36+ title: "Connected applications",
37+ about:
38+ "Applications you signed in to through your browser, such as an agent connected to the g1t MCP server. Signing one out ends its access at once.",
39+ },
40+ "security-log": {
41+ title: "Security log",
42+ about:
43+ "Changes to your addresses and password, by you or by g1t staff. If you do not recognise one, reset your password.",
44+ },
45+};
46+
47+/** Where the first page is: `/settings` goes there. */
48+export const FIRST_SETTINGS_PAGE = "/settings/profile";
49+
50+/**
51+ * The sections of the old single settings page, by their `#anchor`, and
52+ * the page each now has. Links like `g1t.sh/settings#emails` in old email
53+ * and bookmarks still land in the right place.
54+ */
55+const OLD_ANCHORS: Record<string, AccountSettingsPage> = {
56+ picture: "profile",
57+ profile: "profile",
58+ emails: "emails",
59+ github: "github",
60+ invites: "invites",
61+ "ssh-keys": "keys",
62+ tokens: "tokens",
63+ applications: "applications",
64+ "security-log": "security-log",
65+};
66+
67+/** The page an old `#anchor` of the settings page now lives at, if it was one. */
68+export function settingsPathForHash(hash: string): string | null {
69+ const page = OLD_ANCHORS[hash.replace(/^#/, "")];
70+ return page ? `/settings/${page}` : null;
71+}
72+
73+/** Which settings page a path is, or null when it is none. */
74+export function accountSettingsPage(pathname: string): AccountSettingsPage | null {
75+ const match = /^\/settings\/([^/]+)\/?$/.exec(pathname);
76+ const page = match?.[1];
77+ return page && page in ACCOUNT_SETTINGS ? (page as AccountSettingsPage) : null;
78+}
+33−0
22 import { test } from "node:test";
33
44 import {
5+ gigabytes,
6+ shownMeters,
57 alertText,
68 alertTone,
79 cardCheckResult,
201203 assert.equal(requestStatus({ ...request, status: "approved", decidedMicros: 2_000_000_000 }), "Approved at $2,000");
202204 assert.equal(requestStatus({ ...request, status: "declined" }), "Declined");
203205 });
206+
207+const METERS = [
208+ { key: "agents", label: "Agents & sandboxes", micros: 1_200_000, quantity: "4 runs" },
209+ { key: "builds", label: "Builds", micros: 0, quantity: null },
210+ { key: "requests", label: "Requests & CPU", micros: 0, quantity: null },
211+ { key: "domains", label: "Custom domains", micros: 120_000, quantity: "1 custom domain" },
212+ { key: "git_storage", label: "Git operations & storage", micros: 0, quantity: "1,200 git operations" },
213+ { key: "search_scans", label: "Search & security scans", micros: 3_000, quantity: null },
214+];
215+
216+test("the plan shows every meter, with no quota beside any of them", () => {
217+ const shown = shownMeters(METERS, true);
218+ assert.deepEqual(
219+ shown.map((m) => m.key),
220+ ["agents", "builds", "requests", "domains", "git_storage", "search_scans"],
221+ );
222+ for (const meter of shown) assert.ok(!/ of |apps up/i.test(`${meter.label} ${meter.quantity ?? ""}`));
223+});
224+
225+test("a free workspace sees only what it can use, and anything it used anyway", () => {
226+ assert.deepEqual(
227+ shownMeters(METERS, false).map((m) => m.key),
228+ ["agents", "domains", "git_storage", "search_scans"],
229+ );
230+});
231+
232+test("storage reads in powers of ten", () => {
233+ assert.equal(gigabytes(1_000_000_000), "1 GB");
234+ assert.equal(gigabytes(1_500_000_000), "1.5 GB");
235+ assert.equal(gigabytes(500_000_000), "500 MB");
236+});
+19−1
33 * workspace's alerts. Pure, so it can be tested.
44 */
55
6−import type { Entitlements, FeatureState, Limit, LimitRequest, Usage, UsageAlert } from "@g1t/contracts";
6+import type { Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, Usage, UsageAlert } from "@g1t/contracts";
77
88 /** Millionths of a dollar in one dollar, as `MICROS_PER_DOLLAR`; here so the tests need no build of the contracts. */
99 const MICROS_PER_DOLLAR = 1_000_000;
184184 return { kind: "free", label: "Free" };
185185 }
186186
187+/** Meters only the plan runs: a free workspace never builds, serves apps or adds custom domains. */
188+const PLAN_ONLY_METERS = new Set(["builds", "requests", "domains"]);
189+
190+/**
191+ * The lines of "This month's usage": every meter on the plan, so it reads
192+ * the same each month; without it, only what a free workspace can use,
193+ * plus anything that was used anyway (from before the plan ended).
194+ */
195+export function shownMeters(meters: MeterUsage[], onPlan: boolean): MeterUsage[] {
196+ return meters.filter((meter) => onPlan || !PLAN_ONLY_METERS.has(meter.key) || meter.micros > 0);
197+}
198+
199+/** `1 GB`, `500 MB`: storage as it is priced, in powers of ten. */
200+export function gigabytes(bytes: number): string {
201+ if (bytes >= 1e9) return `${Math.round((bytes / 1e9) * 10) / 10} GB`;
202+ return `${Math.round(bytes / 1e6)} MB`;
203+}
204+
187205 /** A share from 0 to 1 of `used` against `of`, for a meter. */
188206 export function share(used: number, of: number | null | undefined): number {
189207 if (!of || of <= 0) return 0;
+0−114
1−/**
2− * What is new on g1t, newest first, for the corner of mission control that
3− * says so. Short and specific: what changed, and where to see it. Add an
4− * entry in the same change that ships the feature.
5− */
6−export type ChangelogEntry = {
7− /** `YYYY-MM-DD`. */
8− date: string;
9− title: string;
10− about: string;
11− /** Docs, or a page on g1t; `{workspace}` and `{project}` are filled in where known. */
12− href: string;
13−};
14−
15−export const CHANGELOG: ChangelogEntry[] = [
16− {
17− date: "2026-10-05",
18− title: "Invites",
19− about:
20− "g1t is invite-only while we open it up. You have 5 invites: make one for anyone with the link or for one email address, copy it, and revoke it until it is used; an unused one comes back when it expires. Owners can invite an email address straight into a workspace, and it makes the account and joins in one step.",
21− href: "/settings#invites",
22− },
23− {
24− date: "2026-10-05",
25− title: "Status, security and policies",
26− about:
27− "g1t.sh/status checks every public part of g1t each minute and says plainly what it can't check yet; the dot beside Status in the footer and your account menu follows it. Security describes what protects your code and how to report a vulnerability, Support says who to write to, and Policies has the terms, privacy policy, acceptable use, refunds and subprocessors, with every change listed.",
28− href: "/status",
29− },
30− {
31− date: "2026-10-05",
32− title: "One plan, and limits you control",
33− about:
34− "The forge stays free. The g1t plan is $20 a month per workspace, with $10 of usage included and deployments part of it. Billing now has the $5 trial after a card check, your spend limit with how far you can raise it yourself, Raise my limit, Prepay, caps per run and per issue, and Keep going or Stop when spending spikes.",
35− href: "/{workspace}/-/billing",
36− },
37− {
38− date: "2026-10-05",
39− title: "A project overview that shows the way to production",
40− about:
41− "Production shows a screenshot of the live site, taken on each deploy. A checklist counts the steps to production, from first push to a first issue for g1t-agent, each linking to where it is done. Active branches show how far each has moved from main, with its pull request and preview.",
42− href: "https://docs.g1t.sh/guides/projects/#the-overview",
43− },
44− {
45− date: "2026-10-05",
46− title: "Setup for Claude Code, Codex, OpenCode and Cursor",
47− about:
48− "Wherever g1t shows how to connect your coding agent, pick yours: the command or config for it, with a copy button. Every block on the page follows your choice, and it is remembered.",
49− href: "https://docs.g1t.sh/guides/bring-your-own-agent/",
50− },
51− {
52− date: "2026-10-05",
53− title: "Browse public projects in the sidebar",
54− about:
55− "Public projects, Explore, Search and profiles use the sidebar whether you are signed in or not. A page you cannot see says so plainly, the same for private and missing, with a way to sign in or switch account.",
56− href: "https://docs.g1t.sh/guides/git/#browsing-without-an-account",
57− },
58− {
59− date: "2026-10-05",
60− title: "Cost plus 20%, on everything",
61− about:
62− "Sandbox time is charged at cost plus 20% from the first second, with no free minutes. Runs on your own model provider no longer carry a $0.10 fee: they pay only their sandbox time.",
63− href: "/pricing",
64− },
65− {
66− date: "2026-10-05",
67− title: "Catch up in seconds",
68− about:
69− "Catch up with main merges it in at once when the two changed different files. When they overlap, the merge box shows g1t-agent at work, step by step.",
70− href: "https://docs.g1t.sh/guides/pull-requests/#catching-up",
71− },
72− {
73− date: "2026-10-04",
74− title: "Agents and memory",
75− about: "Watch every agent run live, stop or message it, and see what agents learned about each project.",
76− href: "/{workspace}/-/agents",
77− },
78− {
79− date: "2026-10-04",
80− title: "Checks and conflicts",
81− about: "Workflow statuses on every pull request, and conflicts found before you press merge.",
82− href: "https://docs.g1t.sh/guides/pull-requests/",
83− },
84− {
85− date: "2026-10-04",
86− title: "Profiles",
87− about: "Your name, bio and everything you opened, in one place.",
88− href: "/settings",
89− },
90− {
91− date: "2026-10-04",
92− title: "Custom domains",
93− about: "Serve production on your own domain, with its certificate handled for you.",
94− href: "https://docs.g1t.sh/guides/deployments/",
95− },
96− {
97− date: "2026-10-04",
98− title: "Rename a workspace",
99− about: "Old addresses redirect for 90 days, so links and clones keep working.",
100− href: "/{workspace}/-/settings",
101− },
102− {
103− date: "2026-10-04",
104− title: "Workspace names and icons",
105− about: "Give a workspace a display name and an icon of its own.",
106− href: "/{workspace}/-/settings",
107− },
108−];
109−
110−/** An entry's link, with the workspace filled in; null when it needs one and there is none. */
111−export function changelogHref(entry: ChangelogEntry, workspace: string | null): string | null {
112− if (!entry.href.includes("{workspace}")) return entry.href;
113− return workspace ? entry.href.replaceAll("{workspace}", workspace) : null;
114−}
+2−0
4343 // An invite link is the front door: the marketing frame, like /register.
4444 if (path.startsWith("/invite/")) return false;
4545 if (path.startsWith("/policies/")) return false;
46+ // Your settings pages, like /settings: a visitor is sent to sign in.
47+ if (path.startsWith("/settings/")) return false;
4648 return true;
4749 }
4850
+6−0
3434 };
3535 }
3636
37+/** A person's security log, newest first, for its own settings page. */
38+export async function loadSecurityLog(user: User): Promise<SecurityEvent[]> {
39+ const log = await accounts.securityLog(user).catch(() => null);
40+ return log?.ok ? log.value : [];
41+}
42+
3743 /** The proof a change carries: this session, and the password if it was just typed. */
3844 function proof(request: Request, form: FormData): Reauth {
3945 const password = String(form.get("password") ?? "");
+33−0
99 inviteFor,
1010 inviteLink,
1111 inviteState,
12+ landingFor,
1213 looksAutomated,
1314 moreInvitesMailto,
1415 remainingLine,
1516 signUpCopy,
17+ suggestUsername,
18+ welcomeCookie,
19+ clearWelcome,
20+ welcomes,
1621 } from "./invites.ts";
1722
1823 const CODE = "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk";
7378 assert.equal(looksAutomated(form({}), now), false);
7479 assert.equal(looksAutomated(form({ website: " " }), now), false);
7580 });
81+
82+test("a username is suggested from the invited address", () => {
83+ assert.equal(suggestUsername("ada.lovelace@example.com"), "ada-lovelace");
84+ assert.equal(suggestUsername("Margaret_Hamilton+g1t@example.com"), "margaret-hamilton");
85+ assert.equal(suggestUsername("--x--@example.com"), "x");
86+ assert.equal(suggestUsername(`${"a".repeat(38)}.b@example.com`), "a".repeat(38));
87+ assert.equal(suggestUsername("...@example.com"), "");
88+ assert.equal(suggestUsername(null), "");
89+});
90+
91+test("an invite lands in its workspace, else its repository", () => {
92+ assert.equal(landingFor({ workspace: { slug: "Flagon-IO" }, repository: null }), "flagon-io");
93+ assert.equal(landingFor({ workspace: null, repository: { name: "flagon-io/g1t" } }), "flagon-io/g1t");
94+ assert.equal(landingFor({ workspace: null, repository: null }), null);
95+});
96+
97+test("the welcome is for one place, and ends", () => {
98+ const set = welcomeCookie("flagon-io/g1t", true);
99+ assert.match(set, /^g1t_welcome=flagon-io%2Fg1t; Path=\/; Max-Age=300; HttpOnly; SameSite=Lax; Secure$/);
100+ const header = `a=1; ${set.split(";")[0]}; b=2`;
101+ assert.equal(welcomes(header, "flagon-io/g1t"), true);
102+ assert.equal(welcomes(header, "flagon-io"), false);
103+ assert.equal(welcomes("g1t_welcome=flagon-io", "Flagon-IO"), true);
104+ assert.equal(welcomes("g1t_welcome=%E0%A4%A", "flagon-io"), false);
105+ assert.equal(welcomes("g1t_welcome=..%2F..%2Fx", "../../x"), false);
106+ assert.equal(welcomes(null, "flagon-io"), false);
107+ assert.match(clearWelcome(false), /^g1t_welcome=; Path=\/; Max-Age=0; HttpOnly; SameSite=Lax$/);
108+});
+58−0
8888 const started = Number(form.get("started"));
8989 return Number.isFinite(started) && started > 0 && now - started < 1500;
9090 }
91+
92+/**
93+ * A username to offer someone signing up with `email`: its local part, as
94+ * usernames are written (lowercase letters, digits and single hyphens, up
95+ * to 39). Empty when nothing usable is left. Identity checks it is free.
96+ */
97+export function suggestUsername(email: string | null | undefined): string {
98+ const local = (email ?? "").split("@")[0]?.split("+")[0] ?? "";
99+ return local
100+ .toLowerCase()
101+ .replace(/[^a-z0-9]+/g, "-")
102+ .replace(/^-+|-+$/g, "")
103+ .slice(0, 39)
104+ .replace(/-+$/g, "");
105+}
106+
107+type Lands = { workspace: { slug: string } | null; repository: { name: string } | null };
108+
109+/**
110+ * Where using an invite lands: the workspace it joins, the repository it
111+ * gives access to, or nowhere in particular.
112+ */
113+export function landingFor(invite: Lands): string | null {
114+ if (invite.workspace) return invite.workspace.slug.toLowerCase();
115+ if (invite.repository) return invite.repository.name.toLowerCase();
116+ return null;
117+}
118+
119+/** What someone who just joined is welcomed into, for one page view. */
120+export const WELCOME_COOKIE = "g1t_welcome";
121+
122+const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/;
123+
124+/** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */
125+export function welcomeCookie(target: string, secure: boolean): string {
126+ return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
127+}
128+
129+/** The `Set-Cookie` value that ends the welcome, once it has been shown. */
130+export function clearWelcome(secure: boolean): string {
131+ return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
132+}
133+
134+/** Whether the request's cookies welcome someone into `target`. */
135+export function welcomes(cookieHeader: string | null, target: string): boolean {
136+ for (const part of (cookieHeader ?? "").split(";")) {
137+ const [key, ...rest] = part.trim().split("=");
138+ if (key !== WELCOME_COOKIE) continue;
139+ let value: string;
140+ try {
141+ value = decodeURIComponent(rest.join("="));
142+ } catch {
143+ return false;
144+ }
145+ return TARGET.test(value) && value === target.toLowerCase();
146+ }
147+ return false;
148+}
+10−3
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4+import { OG_RENDER_VERSION } from "@g1t/contracts/og";
5+
46 import { DESCRIPTION, cardUrl, excerpt, fingerprint, page } from "./meta.ts";
57
68 type Tag = Record<string, string>;
2123 assert.equal(meta.get("og:title"), "Pricing");
2224 assert.equal(meta.get("og:url"), "https://g1t.sh/pricing");
2325 assert.equal(meta.get("og:site_name"), "g1t");
24− assert.equal(meta.get("og:image"), "https://og.g1t.sh/image?path=%2Fpricing");
26+ assert.equal(meta.get("og:image"), `https://og.g1t.sh/image?path=%2Fpricing&v=${OG_RENDER_VERSION}`);
2527 assert.equal(meta.get("twitter:card"), "summary_large_image");
2628 assert.equal(meta.get("twitter:image"), meta.get("og:image"));
2729 });
3436 const meta = tags(page({ location: { pathname: "/acme/web/commits/" }, matches: [project] }, { title: "Commits · acme/web · g1t" }));
3537 assert.equal(meta.get("description"), "The storefront");
3638 assert.equal(meta.get("og:url"), "https://g1t.sh/acme/web/commits");
37− assert.match(meta.get("og:image") ?? "", /\?path=%2Facme%2Fweb%2Fcommits&v=[0-9a-z]+$/);
39+ assert.match(meta.get("og:image") ?? "", new RegExp(`\\?path=%2Facme%2Fweb%2Fcommits&v=${OG_RENDER_VERSION}\\.[0-9a-z]+$`));
3840 });
3941
4042 test("with nothing particular to say, a page says what g1t is", () => {
4547 test("a card's version changes with what it shows", () => {
4648 assert.equal(fingerprint(["a", "open"]), fingerprint(["a", "open"]));
4749 assert.notEqual(fingerprint(["a", "open"]), fingerprint(["a", "merged"]));
48− assert.equal(cardUrl("/", undefined), "https://og.g1t.sh/image?path=%2F");
50+ assert.equal(cardUrl("/", undefined), `https://og.g1t.sh/image?path=%2F&v=${OG_RENDER_VERSION}`);
51+});
52+
53+test("every card address carries the render version, so a new design is a new address", () => {
54+ assert.match(cardUrl("/"), new RegExp(`[?&]v=${OG_RENDER_VERSION}$`));
55+ assert.match(cardUrl("/acme", ["Acme"]), new RegExp(`[?&]v=${OG_RENDER_VERSION}\\.[0-9a-z]+$`));
4956 });
5057
5158 test("an excerpt is plain text from the start of the Markdown", () => {
+8−2
88 * returns `page(args, …)` rather than a bare title.
99 */
1010 import type { MetaDescriptor } from "react-router";
11+import { ogVersion } from "@g1t/contracts/og";
1112
1213 export const SITE = "https://g1t.sh";
1314 export const OG = "https://og.g1t.sh";
7879 return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
7980 }
8081
81−/** The address of a page's card. */
82+/**
83+ * The address of a page's card. Its `v` always carries the cards' render
84+ * version, so a new card design is a new address for og's cache, browsers
85+ * and every site that has fetched the old card; then, when the page has one,
86+ * a fingerprint of what the card shows.
87+ */
8288 export function cardUrl(pathname: string, version?: unknown): string {
8389 const url = new URL("/image", OG);
8490 url.searchParams.set("path", pathname);
85− if (version !== undefined) url.searchParams.set("v", fingerprint(version));
91+ url.searchParams.set("v", ogVersion(version === undefined ? undefined : fingerprint(version)));
8692 return url.toString();
8793 }
8894
+9−17
2727 for (const [path, kind] of cases) assert.equal(missingKind(null, path), kind, path);
2828 });
2929
30−const KINDS: [MissingKind, string][] = [
31− ["project", "project"],
32− ["issue", "issue"],
33− ["pull", "pull request"],
34− ["workspace", "workspace"],
35− ["page", "page"],
36−];
30+const KINDS: MissingKind[] = ["project", "issue", "pull", "workspace", "page"];
3731
38−test("signed out: sign in to see it, which may be private or missing", () => {
39− for (const [kind, noun] of KINDS) {
32+test("signed out: nothing here, which may be private, and sign in if it is yours", () => {
33+ for (const kind of KINDS) {
4034 const copy = notFoundCopy(kind, null);
41− assert.equal(copy.title, `Sign in to see this ${noun}`);
42− assert.equal(copy.body, "It may be private, or it may not exist.");
35+ assert.equal(copy.title, "Nothing here");
36+ assert.equal(copy.body, "This page doesn't exist, or it's private. Sign in if it's yours.");
4337 assert.equal(copy.signIn, true);
4438 assert.equal(copy.signedInAs, null);
45− assert.equal(copy.askOwner, false);
4639 }
4740 });
4841
4942 test("signed in: it does not exist or you cannot see it, and who you are", () => {
50− for (const [kind, noun] of KINDS) {
43+ for (const kind of KINDS) {
5144 const copy = notFoundCopy(kind, "ada");
52− assert.equal(copy.title, `This ${noun} doesn't exist, or you don't have access to it`);
45+ assert.equal(copy.title, "Nothing here");
46+ assert.equal(copy.body, "This page doesn't exist, or you don't have access to it.");
5347 assert.equal(copy.signIn, false);
5448 assert.equal(copy.signedInAs, "ada");
55− assert.equal(copy.askOwner, true);
5649 }
5750 });
5851
5952 test("a missing person is simply no one, signed in or not", () => {
6053 for (const viewer of [null, "ada"]) {
6154 const copy = notFoundCopy("person", viewer);
62− assert.equal(copy.title, "No one goes by that name");
55+ assert.equal(copy.body, "No one on g1t goes by that name.");
6356 assert.equal(copy.signIn, false);
64− assert.equal(copy.askOwner, false);
6557 }
6658 });
6759
+10−17
5151 signedInAs: string | null;
5252 /** Offer to sign in and come back. */
5353 signIn: boolean;
54− /** Tell them to ask a workspace owner for access. */
55− askOwner: boolean;
5654 };
5755
58−/** What the page says for this kind of thing, to whoever is looking. */
56+/**
57+ * What the page says to whoever is looking. Signed out, it says that the
58+ * page may be private, and to sign in if it is yours; signed in, that it
59+ * does not exist or they cannot reach it. Never which of the two.
60+ */
5961 export function notFoundCopy(kind: MissingKind, username: string | null | undefined): NotFoundCopy {
6062 // People are public: a name either belongs to someone or it does not.
6163 if (kind === "person") {
62− return {
63− title: "No one goes by that name",
64− body: "Check the spelling, or search for them.",
65− signedInAs: null,
66− signIn: false,
67− askOwner: false,
68− };
64+ return { title: "Nothing here", body: "No one on g1t goes by that name.", signedInAs: null, signIn: false };
6965 }
70− const noun = NOUN[kind];
7166 if (!username) {
7267 return {
73− title: `Sign in to see this ${noun}`,
74− body: "It may be private, or it may not exist.",
68+ title: "Nothing here",
69+ body: "This page doesn't exist, or it's private. Sign in if it's yours.",
7570 signedInAs: null,
7671 signIn: true,
77− askOwner: false,
7872 };
7973 }
8074 return {
81− title: `This ${noun} doesn't exist, or you don't have access to it`,
82− body: "",
75+ title: "Nothing here",
76+ body: "This page doesn't exist, or you don't have access to it.",
8377 signedInAs: username,
8478 signIn: false,
85− askOwner: true,
8679 };
8780 }
+1−0
5353 // Someone a repository is shared with can use it without a workspace.
5454 (viewer.grants ?? []).length === 0 &&
5555 !BEFORE_WORKSPACE.includes(pathname) &&
56+ !pathname.startsWith("/settings/") &&
5657 // An invite to a workspace is how someone without one gets one, and an
5758 // invitation to a repository is answered before anything else.
5859 !pathname.startsWith("/invite/") &&
+0−115
1−/**
2− * The checks behind /status and the footer's dot. Each is one cheap call,
3− * the same a visitor's request would make, with a short timeout, all at
4− * once. The report is kept in the data centre's cache for a minute, so
5− * however many people look, each part is checked at most once a minute
6− * per data centre.
7− *
8− * The public hosts (api, mcp, docs, models, g1t.page) are fetched over the
9− * internet, as their users reach them; the rest through service bindings.
10− */
11−import { billing, identity, repos } from "./services.server";
12−import { type ComponentKey, type ProbeResult, type StatusReport, report } from "./status";
13−
14−/** No check waits longer than this. */
15−const TIMEOUT_MS = 3000;
16−/** How long one report is served before the parts are checked again. */
17−const CACHE_SECONDS = 60;
18−/** Where the report is kept: a key in the cache, never a page anyone fetches. */
19−const CACHE_KEY = "https://g1t.sh/__status/report/v1";
20−/** A public repository that has always existed, to look up. */
21−const PROBE_REPO = { namespace: "flagon-io", name: "g1t" };
22−/** A session token that cannot exist: the lookup answers "nobody". */
23−const NO_SESSION = "0".repeat(64);
24−
25−class Timeout extends Error {}
26−
27−async function timed(run: (signal: AbortSignal) => Promise<boolean | string>): Promise<ProbeResult> {
28− const started = Date.now();
29− const controller = new AbortController();
30− let timer: ReturnType<typeof setTimeout> | undefined;
31− const deadline = new Promise<never>((_, reject) => {
32− timer = setTimeout(() => {
33− controller.abort();
34− reject(new Timeout());
35− }, TIMEOUT_MS);
36− });
37− try {
38− const outcome = await Promise.race([run(controller.signal), deadline]);
39− const ms = Date.now() - started;
40− return outcome === true ? { ok: true, ms } : { ok: false, ms, error: typeof outcome === "string" ? outcome : "unexpected answer" };
41− } catch (error) {
42− const ms = Date.now() - started;
43− return { ok: false, ms, error: error instanceof Timeout ? "timed out" : "could not connect" };
44− } finally {
45− clearTimeout(timer);
46− }
47−}
48−
49−/** A public address answering 2xx. The body is not read. */
50−function reachable(url: string, headers: Record<string, string> = {}) {
51− return timed(async (signal) => {
52− const response = await fetch(url, {
53− signal,
54− redirect: "manual",
55− headers: { "user-agent": "g1t-status (+https://g1t.sh/status)", ...headers },
56− });
57− await response.body?.cancel().catch(() => undefined);
58− return response.ok || `HTTP ${response.status}`;
59− });
60−}
61−
62−async function probeAll(): Promise<Partial<Record<ComponentKey, ProbeResult | null>>> {
63− const checks: [ComponentKey, Promise<ProbeResult> | null][] = [
64− // Every page asks identity who is signed in; a token nobody holds
65− // answers "nobody" after a database read.
66− ["site", timed(async () => (await identity.userForSession(NO_SESSION), true))],
67− ["api", reachable("https://api.g1t.sh/")],
68− // A lookup answers, found or not, once the repository service and its
69− // database are up.
70− ["git", timed(async () => (await repos.get(PROBE_REPO, null), true))],
71− ["mcp", reachable("https://mcp.g1t.sh/", { accept: "application/json" })],
72− ["docs", reachable("https://docs.g1t.sh/")],
73− ["deployments", reachable("https://g1t.page/")],
74− ["agents", reachable("https://models.g1t.sh/")],
75− // Not checked yet: see COMPONENTS in status.ts.
76− ["sandboxes", null],
77− ["billing", timed(async () => Boolean(await billing.prices()) || "no price book")],
78− ];
79− const results = await Promise.all(checks.map(async ([key, check]) => [key, check ? await check : null] as const));
80− return Object.fromEntries(results);
81−}
82−
83−function edgeCache(): Cache | null {
84− // The Workers runtime's own cache, which the DOM types do not know.
85− const store = (globalThis as { caches?: { default?: Cache } }).caches;
86− return store?.default ?? null;
87−}
88−
89−/** The current report: from the cache when it is under a minute old, else checked now. */
90−export async function currentStatus(): Promise<StatusReport> {
91− const cache = edgeCache();
92− if (cache) {
93− try {
94− const hit = await cache.match(CACHE_KEY);
95− if (hit) return (await hit.json()) as StatusReport;
96− } catch {
97− // A cache that fails is only slower.
98− }
99− }
100− const fresh = report(await probeAll(), new Date());
101− if (cache) {
102− try {
103− await cache.put(
104− CACHE_KEY,
105− Response.json(fresh, { headers: { "cache-control": `public, max-age=${CACHE_SECONDS}` } }),
106− );
107− } catch {
108− // Checked again next time.
109− }
110− }
111− return fresh;
112−}
113−
114−/** Headers for a response carrying the report: browsers and the edge may keep it a minute. */
115−export const STATUS_HEADERS = { "cache-control": `public, max-age=${CACHE_SECONDS}` };
+8−104
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import {
5− COMPONENTS,
6− type ComponentStatus,
7− type Incident,
8− classify,
9− componentStatus,
10− dotClass,
11− recentIncidents,
12− report,
13− summarize,
14−} from "./status.ts";
4+import { STATUS_JSON_URL, dotClass, statusTitle } from "./status.ts";
155
16−const info = (key: string) => COMPONENTS.find((c) => c.key === key)!;
17−const row = (key: string, state: ComponentStatus["state"]): ComponentStatus => ({
18− ...componentStatus(info(key), null),
19− state,
6+test("the status page lives on its own host", () => {
7+ assert.equal(STATUS_JSON_URL, "https://status.g1t.sh/status.json");
208 });
219
22−test("a quick answer is up, a slow one degraded, a failure down", () => {
23− assert.deepEqual(classify({ ok: true, ms: 84.4 }), { state: "up", detail: "Answered in 84 ms" });
24− assert.equal(classify({ ok: true, ms: 2400 }).state, "degraded");
25− assert.deepEqual(classify({ ok: false, ms: 3000, error: "timed out" }), { state: "down", detail: "Failed: timed out" });
26− assert.equal(classify({ ok: false, ms: 10 }).detail, "Failed: no answer");
27−});
28−
29−test("a part with no check is never shown as working", () => {
30− assert.deepEqual(classify(null), { state: "unmonitored", detail: "Not monitored yet" });
31− const sandboxes = componentStatus(info("sandboxes"), null);
32− assert.equal(sandboxes.state, "unmonitored");
33− assert.equal(sandboxes.latency_ms, null);
34−});
35−
36−test("everything checked and answering is all clear, whatever is unmonitored", () => {
37− const overall = summarize([row("site", "up"), row("api", "up"), row("sandboxes", "unmonitored")]);
38− assert.deepEqual(overall, { state: "up", line: "All monitored systems are working" });
39−});
40−
41−test("a core part down is a major outage", () => {
42− const overall = summarize([row("site", "up"), row("git", "down"), row("docs", "up")]);
43− assert.equal(overall.state, "down");
44− assert.equal(overall.line, "Major outage: Git and repositories");
45−});
46−
47−test("a non-core part down, or anything slow, is degraded", () => {
48− assert.equal(summarize([row("site", "up"), row("docs", "down")]).state, "degraded");
49− assert.equal(summarize([row("site", "degraded"), row("docs", "up")]).state, "degraded");
50− assert.equal(
51− summarize([row("site", "up"), row("docs", "down"), row("billing", "degraded")]).line,
52− "Some systems are having trouble: Documentation, Billing",
53− );
54−});
55−
56−test("down outranks degraded", () => {
57− assert.equal(summarize([row("docs", "degraded"), row("api", "down")]).state, "down");
58−});
59−
60−const incident = (extra: Partial<Incident>): Incident => ({
61− id: "inc_1",
62− title: "Pushes are slow",
63− impact: "degraded",
64− components: ["git"],
65− started_at: "2026-10-05T10:00:00Z",
66− resolved_at: null,
67− updates: [],
68− ...extra,
69−});
70−
71−test("an open incident lowers the line even when checks pass", () => {
72− const overall = summarize([row("site", "up"), row("git", "up")], [incident({})]);
73− assert.deepEqual(overall, { state: "degraded", line: "Investigating: Pushes are slow" });
74− assert.equal(summarize([row("site", "up")], [incident({ impact: "down" })]).state, "down");
75−});
76−
77−test("a resolved incident does not", () => {
78− const overall = summarize([row("site", "up")], [incident({ resolved_at: "2026-10-05T11:00:00Z" })]);
79− assert.equal(overall.state, "up");
80−});
81−
82−test("nothing checked and nothing open is unknown, not green", () => {
83− assert.equal(summarize([row("sandboxes", "unmonitored")]).state, "unknown");
84− assert.equal(dotClass("unknown"), "bg-faint");
85−});
86−
87−test("old resolved incidents fall off the page; open ones never do", () => {
88− const now = new Date("2026-10-05T00:00:00Z");
89− const list = [
90− incident({ id: "old", resolved_at: "2026-06-01T00:00:00Z" }),
91− incident({ id: "recent", resolved_at: "2026-09-20T00:00:00Z" }),
92− incident({ id: "open", started_at: "2026-01-01T00:00:00Z" }),
93− ];
94− assert.deepEqual(
95− recentIncidents(list, now).map((i) => i.id),
96− ["recent", "open"],
97− );
10+test("the short line is the report's title, else words for its state", () => {
11+ assert.equal(statusTitle({ overall: { state: "up", title: "All systems normal", line: "" } }), "All systems normal");
12+ assert.equal(statusTitle({ overall: { state: "down", title: "", line: "" } }), "Major outage");
13+ assert.equal(statusTitle(null), null);
9814 });
9915
100−test("the report covers every part, in order, with the overall line from the same data", () => {
101− const now = new Date("2026-10-05T12:00:00Z");
102− const r = report({ site: { ok: true, ms: 40 }, api: { ok: false, ms: 3000, error: "timed out" } }, now, []);
103− assert.deepEqual(
104− r.components.map((c) => c.key),
105− COMPONENTS.map((c) => c.key),
106− );
107− assert.equal(r.components.find((c) => c.key === "docs")?.state, "unmonitored");
108− assert.equal(r.overall.state, "down");
109− assert.equal(r.checked_at, "2026-10-05T12:00:00.000Z");
110−});
111−
11216 test("each state has its own dot", () => {
11317 assert.equal(dotClass("up"), "bg-accent");
11418 assert.equal(dotClass("degraded"), "bg-warn");
11519 assert.equal(dotClass("down"), "bg-danger");
116− assert.equal(dotClass("unmonitored"), "bg-faint");
20+ assert.equal(dotClass("unknown"), "bg-faint");
11721 assert.equal(dotClass(null), "bg-faint");
11822 });
+23−216
11 /**
2− * What /status shows and the footer's dot is coloured by: each public part
3− * of g1t, whether it answered a quick check, and one line for the whole.
4− *
5− * The checks themselves run on the server (status.server.ts). This module
6− * only says what a check's result means, so it can be tested on its own.
7− * Fields are snake_case: /status.json is public, like the API.
8− */
9−
10−export type ComponentState = "up" | "degraded" | "down" | "unmonitored";
11−export type OverallState = "up" | "degraded" | "down" | "unknown";
12−
13−export type ComponentKey =
14− | "site"
15− | "api"
16− | "git"
17− | "mcp"
18− | "docs"
19− | "deployments"
20− | "agents"
21− | "sandboxes"
22− | "billing";
23−
24−export type ComponentInfo = {
25− key: ComponentKey;
26− name: string;
27− /** Where people meet it. */
28− address: string;
29− /** What the check actually looks at, said plainly on the page. */
30− checks: string;
31− /** Whether g1t as a whole is down when this is. */
32− core: boolean;
33−};
34−
35−/** The public parts of g1t, in the order the page lists them. */
36−export const COMPONENTS: ComponentInfo[] = [
37− {
38− key: "site",
39− name: "Website and sign-in",
40− address: "g1t.sh",
41− checks: "The account service every page asks who is signed in.",
42− core: true,
43− },
44− { key: "api", name: "API", address: "api.g1t.sh", checks: "The API's front page, over the public internet.", core: true },
45− {
46− key: "git",
47− name: "Git and repositories",
48− address: "g1t.sh/<owner>/<repo>.git",
49− checks: "The repository service looking up a public repository. Clones and pushes themselves are not exercised.",
50− core: true,
51− },
52− { key: "mcp", name: "MCP server", address: "mcp.g1t.sh", checks: "The MCP server's description, over the public internet.", core: false },
53− { key: "docs", name: "Documentation", address: "docs.g1t.sh", checks: "The documentation's front page.", core: false },
54− {
55− key: "deployments",
56− name: "Deployments",
57− address: "*.g1t.page",
58− checks: "g1t.page answering. Each deployed app is not checked one by one.",
59− core: false,
60− },
61− {
62− key: "agents",
63− name: "Agents' model proxy",
64− address: "models.g1t.sh",
65− checks: "The proxy g1t's agents reach their model through. The model providers behind it are not checked.",
66− core: false,
67− },
68− {
69− key: "sandboxes",
70− name: "Sandboxes",
71− address: "Agents, checks, workflows and builds",
72− checks: "Starting a sandbox costs money and takes seconds, so it is not checked from here yet.",
73− core: false,
74− },
75− {
76− key: "billing",
77− name: "Billing",
78− address: "g1t.sh/<workspace>/-/billing",
79− checks: "The billing service reading its price book. Stripe itself is not checked.",
80− core: false,
81− },
82−];
83−
84−/** A check's raw outcome. */
85−export type ProbeResult = {
86− ok: boolean;
87− /** How long it took, in milliseconds. */
88− ms: number;
89− /** Why it failed, in a few words: "timed out", "HTTP 502". */
90− error?: string;
91−};
92−
93−/** Slower than this, a part that answered counts as degraded. */
94−export const SLOW_MS = 1500;
95−
96−export type ComponentStatus = {
97− key: ComponentKey;
98− name: string;
99− address: string;
100− checks: string;
101− state: ComponentState;
102− /** What was seen, for the page: "Answered in 84 ms", "Timed out". */
103− detail: string;
104− latency_ms: number | null;
105−};
106−
107−/** What a check's result means for its part. `null` is a part with no check. */
108−export function classify(result: ProbeResult | null, slowMs = SLOW_MS): { state: ComponentState; detail: string } {
109− if (!result) return { state: "unmonitored", detail: "Not monitored yet" };
110− if (!result.ok) {
111− const why = result.error ?? "no answer";
112− return { state: "down", detail: `Failed: ${why}` };
113− }
114− const ms = Math.round(result.ms);
115− if (ms > slowMs) return { state: "degraded", detail: `Slow: answered in ${ms} ms` };
116− return { state: "up", detail: `Answered in ${ms} ms` };
117−}
118−
119−/** One component's row, from its check. */
120−export function componentStatus(info: ComponentInfo, result: ProbeResult | null, slowMs = SLOW_MS): ComponentStatus {
121− const { state, detail } = classify(result, slowMs);
122− return {
123− key: info.key,
124− name: info.name,
125− address: info.address,
126− checks: info.checks,
127− state,
128− detail,
129− latency_ms: result ? Math.round(result.ms) : null,
130− };
131−}
132−
133−export type IncidentUpdate = { at: string; text: string };
134−
135−export type Incident = {
136− id: string;
137− title: string;
138− /** How bad it is while open. */
139− impact: "degraded" | "down";
140− components: ComponentKey[];
141− started_at: string;
142− /** Null while it is still going on. */
143− resolved_at: string | null;
144− /** Newest first. */
145− updates: IncidentUpdate[];
146−};
147−
148−/**
149− * Incidents, newest first. Written by hand, in code, for now: add one when
150− * something breaks, with updates as it is fixed, and set `resolved_at` once
151− * it is. The page shows the last 90 days.
2+ * g1t's status lives at status.g1t.sh (apps/status), a Worker of its own
3+ * that stays up when the site does not. The site only links to it, and
4+ * reads its JSON for the dot in the account menu and the footer.
1525 */
153−export const INCIDENTS: Incident[] = [];
154−
155−/** How far back the page lists incidents. */
156−export const INCIDENT_DAYS = 90;
157−
158−export type Overall = { state: OverallState; line: string };
159−
160−const RANK: Record<OverallState, number> = { unknown: 0, up: 1, degraded: 2, down: 3 };
161−
162−function worse(a: OverallState, b: OverallState): OverallState {
163− return RANK[b] > RANK[a] ? b : a;
164−}
6+import type { StatusOverallState, StatusReport } from "@g1t/contracts";
1657
166−/**
167− * One line for the whole: down when a core part is down, degraded when any
168− * part is down or slow or an incident is open, up when every checked part
169− * answered. Parts that are not checked never make it look better or worse.
170− */
171−export function summarize(components: ComponentStatus[], incidents: Incident[] = []): Overall {
172− const core = new Set(COMPONENTS.filter((c) => c.core).map((c) => c.key));
173− const monitored = components.filter((c) => c.state !== "unmonitored");
174− const open = incidents.filter((incident) => incident.resolved_at == null);
175− if (monitored.length === 0 && open.length === 0) {
176− return { state: "unknown", line: "Status is not available right now" };
177− }
178− let state: OverallState = monitored.length > 0 ? "up" : "unknown";
179− for (const c of monitored) {
180− if (c.state === "down") state = worse(state, core.has(c.key) ? "down" : "degraded");
181− else if (c.state === "degraded") state = worse(state, "degraded");
182− }
183− for (const incident of open) state = worse(state, incident.impact);
8+export type { StatusOverallState as OverallState, StatusReport };
1849
185− const failing = monitored.filter((c) => c.state === "down" || c.state === "degraded").map((c) => c.name);
186− if (state === "up") return { state, line: "All monitored systems are working" };
187− if (state === "down") {
188− return { state, line: failing.length ? `Major outage: ${failing.join(", ")}` : "Major outage" };
189− }
190− if (state === "degraded") {
191− if (failing.length) return { state, line: `Some systems are having trouble: ${failing.join(", ")}` };
192− return { state, line: open[0] ? `Investigating: ${open[0].title}` : "Some systems are having trouble" };
193− }
194− return { state, line: "Status is not available right now" };
195−}
10+/** The status page. */
11+export const STATUS_URL = "https://status.g1t.sh";
12+/** Its report, as JSON, readable from any origin. */
13+export const STATUS_JSON_URL = `${STATUS_URL}/status.json`;
19614
197−export type StatusReport = {
198− checked_at: string;
199− overall: Overall;
200− components: ComponentStatus[];
201− incidents: Incident[];
15+/** Two or three words for the whole, when the report has none. */
16+export const STATUS_WORDS: Record<StatusOverallState, string> = {
17+ up: "All systems normal",
18+ degraded: "Partial outage",
19+ down: "Major outage",
20+ maintenance: "Under maintenance",
21+ unknown: "Status unknown",
20222 };
203−
204−/** Incidents still open, or resolved within `days` of `now`. */
205−export function recentIncidents(incidents: Incident[], now: Date, days = INCIDENT_DAYS): Incident[] {
206− const since = now.getTime() - days * 24 * 60 * 60 * 1000;
207− return incidents.filter((incident) => incident.resolved_at == null || Date.parse(incident.resolved_at) >= since);
208−}
20923
210−/** The whole report, from each part's check. */
211−export function report(results: Partial<Record<ComponentKey, ProbeResult | null>>, now: Date, incidents = INCIDENTS): StatusReport {
212− const components = COMPONENTS.map((info) => componentStatus(info, results[info.key] ?? null));
213− const recent = recentIncidents(incidents, now);
214− return { checked_at: now.toISOString(), overall: summarize(components, recent), components, incidents: recent };
24+/** The short line for the whole: the report's own title, else words for its state. */
25+export function statusTitle(report: Pick<StatusReport, "overall"> | null | undefined): string | null {
26+ if (!report?.overall) return null;
27+ return report.overall.title || STATUS_WORDS[report.overall.state] || null;
21528 }
216−
217−/** Words for a part's state. */
218−export const STATE_LABEL: Record<ComponentState, string> = {
219− up: "Operational",
220− degraded: "Degraded",
221− down: "Down",
222− unmonitored: "Not monitored yet",
223−};
22429
22530 /** The dot's colour for a state, as a Tailwind background class. */
226−export function dotClass(state: ComponentState | OverallState | null | undefined): string {
31+export function dotClass(state: string | null | undefined): string {
22732 switch (state) {
22833 case "up":
22934 return "bg-accent";
23136 return "bg-warn";
23237 case "down":
23338 return "bg-danger";
39+ case "maintenance":
40+ return "bg-info";
23441 default:
23542 return "bg-faint";
23643 }
+7−2
2525 type ShouldRevalidateFunctionArgs,
2626 useLocation,
2727 useParams,
28+ useRouteError,
2829 useRouteLoaderData,
2930 useSubmit,
3031 } from "react-router";
373374 </DropdownMenuItem>
374375 <DropdownMenuSeparator />
375376 <DropdownMenuItem asChild>
376− <Link to="/settings">
377+ <Link to="/settings/profile">
377378 <Settings />
378379 Settings
379380 </Link>
415416 const root = useRouteLoaderData<typeof loader>("root");
416417 const user = root?.user;
417418 const { pathname } = useLocation();
419+ // Drawn around the error page too: a 404 keeps the sidebar out of a
420+ // project or workspace the viewer cannot see.
421+ const error = useRouteError();
422+ const missing = isRouteErrorResponse(error) && error.status === 404;
418423 // The billing page shows the full banner itself.
419424 const paused = root?.shell?.compute && root.shell.workspace && !pathname.endsWith("/-/billing") && (
420425 <Suspense fallback={null}>
459464 </head>
460465 <body className="flex min-h-screen flex-col">
461466 {root?.shell && usesAppShell(pathname, user != null) ? (
462− <AppShell user={user ?? null} shell={root.shell} banner={banner}>
467+ <AppShell user={user ?? null} shell={root.shell} missing={missing} banner={banner}>
463468 {children}
464469 </AppShell>
465470 ) : (
+16−2
2121 route("auth/github/username", "routes/auth-github-username.tsx"),
2222 route("integrations/github/install", "routes/github-install.ts"),
2323 route("integrations/github/setup", "routes/github-setup.tsx"),
24− route("settings", "routes/settings.tsx"),
24+ // Your own settings: one page each, so links can name the one they mean.
25+ // `/settings` goes to the first; an old link's `#anchor` to its page.
26+ route("settings", "routes/settings/layout.tsx", [
27+ index("routes/settings/index.tsx"),
28+ route("profile", "routes/settings/profile.tsx"),
29+ route("emails", "routes/settings/emails.tsx"),
30+ route("invites", "routes/settings/invites.tsx"),
31+ route("keys", "routes/settings/keys.tsx"),
32+ route("tokens", "routes/settings/tokens.tsx"),
33+ route("github", "routes/settings/github.tsx"),
34+ route("applications", "routes/settings/applications.tsx"),
35+ route("security-log", "routes/settings/security-log.tsx"),
36+ ]),
37+ // The account menu's header: name, primary email and invites left.
38+ route("settings/menu.json", "routes/settings-menu-json.ts"),
2539 // People, apart from workspaces: `u` is a reserved name.
2640 route("u/:username", "routes/user.tsx"),
2741 route("explore", "routes/explore.tsx", { id: "explore" }),
3549 route("policies/:policy", "routes/policy.tsx"),
3650 route("security", "routes/security.tsx"),
3751 route("support", "routes/support.tsx"),
52+ // Status moved to status.g1t.sh (apps/status); old links go there.
3853 route("status", "routes/status.tsx"),
39− // What /status shows and the footer's dot reads, as JSON.
4054 route("status.json", "routes/status-json.ts"),
4155 route(".well-known/security.txt", "routes/security-txt.ts"),
4256 // A workspace's own pages sit under `-`, which no repository can be named.
+1−1
4040 headers.append("set-cookie", startSession(done.signedIn.sessionToken));
4141 throw redirect(safeNext(done.next), { headers });
4242 case "linked":
43− throw redirect(safeNext(done.next === "/" ? "/settings#github" : done.next), { headers });
43+ throw redirect(safeNext(done.next === "/" ? "/settings/github" : done.next), { headers });
4444 case "needs_link":
4545 headers.append("set-cookie", cookie(PENDING_COOKIE, done.pending, 1800));
4646 throw redirect(`/login?github=link${done.next === "/" ? "" : `&next=${encodeURIComponent(done.next)}`}`, { headers });
+3−3
1515 const url = new URL(request.url);
1616 const viewer = getViewer(context);
1717 const link = url.searchParams.get("link") === "1";
18− if (link && !viewer) throw redirect(`/login?next=${encodeURIComponent("/settings#github")}`);
18+ if (link && !viewer) throw redirect(`/login?next=${encodeURIComponent("/settings/github")}`);
1919 if (!link && viewer) throw redirect(nextPath(request));
2020 const invite = url.searchParams.get("invite")?.trim().slice(0, 100) || null;
2121 const started = await githubSignIn.start({
2222 purpose: link ? "link" : "sign_in",
2323 user: link ? viewer : null,
2424 redirectUri: callbackUrl(request),
25− next: link ? (url.searchParams.has("next") ? nextPath(request) : "/settings#github") : nextPath(request),
25+ next: link ? (url.searchParams.has("next") ? nextPath(request) : "/settings/github") : nextPath(request),
2626 inviteCode: invite,
2727 });
28− if (!started.ok) throw redirect(link ? "/settings#github" : "/login");
28+ if (!started.ok) throw redirect(link ? "/settings/github" : "/login");
2929 throw redirect(started.value.authorizeUrl, {
3030 headers: { "set-cookie": cookie(STATE_COOKIE, started.value.state, 600) },
3131 });
+386−87
1−import { CircleAlert, Ticket } from "lucide-react";
2−import { Form, Link, data, redirect } from "react-router";
1+import { CircleAlert, Lock, Ticket } from "lucide-react";
2+import { Form, Link, data, redirect, useNavigation } from "react-router";
33
4−import type { InvitePreview } from "@g1t/contracts";
4+import type { InvitePreview, User } from "@g1t/contracts";
55
66 import type { Route } from "./+types/invite";
77 import { page } from "../lib/meta";
88 import { Mark } from "../components/logo";
9−import { Avatar, Button, ButtonLink, ErrorText } from "../components/ui";
9+import { ContinueWithGithub, OrDivider } from "../components/github";
10+import { Honeypot } from "../components/honeypot";
11+import { Avatar, Button, ButtonLink, ErrorText, Field, Input } from "../components/ui";
12+import { githubSignInEnabled } from "../lib/github.server";
1013 import { identity } from "../lib/services.server";
11−import { cleanCode } from "../lib/invites";
14+import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites";
1215 import { clientKey } from "../lib/registration.server";
13−import { assertSameOrigin, getViewer, requireUser } from "../lib/session.server";
16+import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server";
17+import { rememberWorkspace } from "../lib/workspace-choice";
1418
1519 export function meta(args: Route.MetaArgs) {
1620 return page(args, {
1923 });
2024 }
2125
26+/**
27+ * Where an invite is used, from first click to landing inside: who sent
28+ * it and what it is for, then signing up on this page (or signing in, for
29+ * an address that has an account), and the workspace or repository it
30+ * gives. Signing in or up elsewhere (GitHub, /login) comes back here with
31+ * `?accept=1`, which finishes the job.
32+ */
2233 export async function loader({ request, context, params }: Route.LoaderArgs) {
2334 const code = cleanCode(params.code);
2435 const viewer = getViewer(context);
25− const checked = await identity.checkInvite(code, clientKey(request));
26− return {
27− code,
28− viewer: viewer ? { username: viewer.username, avatar: viewer.avatar ?? null } : null,
29− invite: checked.ok ? checked.value : null,
30− error: checked.ok ? null : checked.error.message,
31− };
36+ const accepting = new URL(request.url).searchParams.get("accept") === "1";
37+ const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true });
38+ const invite = checked.ok ? checked.value : null;
39+ const lands = invite ? landingFor(invite) : null;
40+
41+ if (viewer && invite) {
42+ // Used by this person already: just made the account with it (through
43+ // GitHub), or an old link opened again.
44+ if (invite.status === "redeemed" && invite.forViewer) throw landIn(request, invite, [], accepting);
45+ // Back from signing in to accept an invite sent to their address.
46+ if (accepting && invite.status === "pending" && invite.forViewer === true && lands && !alreadyIn(viewer, invite)) {
47+ const accepted = await identity.acceptInvite(viewer, code);
48+ if (accepted.ok) throw landIn(request, invite, [], true);
49+ return { ...base(), acceptError: accepted.error.message };
50+ }
51+ }
52+
53+ function base() {
54+ return {
55+ code,
56+ invite,
57+ error: checked.ok ? null : checked.error.message,
58+ viewer: viewer ? { username: viewer.username, avatar: viewer.avatar ?? null } : null,
59+ alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false,
60+ github: false,
61+ suggestion: suggestUsername(invite?.address),
62+ started: Date.now(),
63+ acceptError: null as string | null,
64+ };
65+ }
66+ const signingUp = !viewer && invite?.status === "pending" && invite.kind === "account" && !invite.hasAccount;
67+ return { ...base(), github: signingUp ? await githubSignInEnabled() : false };
68+}
69+
70+/** Whether the viewer is in what the invite gives already. */
71+function alreadyIn(viewer: User, invite: InvitePreview): boolean {
72+ if (invite.workspace) return roleIn(viewer, invite.workspace.slug) !== null;
73+ return false;
74+}
75+
76+/**
77+ * The redirect into what the invite gave, with the session (when one was
78+ * just made), the welcome for that first view, and the workspace made the
79+ * one the sidebar is about.
80+ */
81+function landIn(request: Request, invite: InvitePreview, cookies: string[], welcome: boolean): Response {
82+ const secure = new URL(request.url).protocol === "https:";
83+ const target = landingFor(invite);
84+ const headers = new Headers();
85+ for (const cookie of cookies) headers.append("set-cookie", cookie);
86+ if (target && welcome) headers.append("set-cookie", welcomeCookie(target, secure));
87+ if (invite.workspace) headers.append("set-cookie", rememberWorkspace(invite.workspace.slug, secure));
88+ return redirect(target ? `/${target}` : "/", { headers });
3289 }
3390
3491 export async function action({ request, context, params }: Route.ActionArgs) {
3592 assertSameOrigin(request);
93+ const code = cleanCode(params.code);
94+ const form = await request.formData();
95+ const client = clientKey(request);
96+ const checked = await identity.checkInvite(code, client, { viewer: getViewer(context) });
97+ if (!checked.ok) return data({ error: checked.error.message }, { status: 422 });
98+ const invite = checked.value;
99+
100+ if (form.get("intent") === "register") {
101+ if (looksAutomated(form)) return data({ error: "Something went wrong. Try again." }, { status: 422 });
102+ // An invite for one address makes the account with that address,
103+ // whatever the form sent.
104+ const email = invite.address ?? String(form.get("email") ?? "");
105+ const result = await identity.register(
106+ String(form.get("username") ?? ""),
107+ email,
108+ String(form.get("password") ?? ""),
109+ code,
110+ client,
111+ );
112+ if (!result.ok) return data({ error: result.error.message }, { status: 422 });
113+ throw landIn(request, invite, [startSession(result.value.sessionToken)], true);
114+ }
115+
36116 const user = requireUser(context, request);
37− const result = await identity.acceptInvite(user, cleanCode(params.code));
117+ const result = await identity.acceptInvite(user, code);
38118 if (!result.ok) return data({ error: result.error.message }, { status: 422 });
39− throw redirect(`/${result.value}`);
119+ throw landIn(request, invite, [], true);
40120 }
41121
42−function Sender({ invite }: { invite: InvitePreview }) {
122+/** Who sent it, and the workspace it joins, as faces. */
123+function Faces({ invite }: { invite: InvitePreview }) {
43124 const from = invite.invitedBy;
44125 return (
45126 <div className="flex items-center gap-3">
60141 );
61142 }
62143
63−function headline(invite: InvitePreview): string {
64− const from = invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team";
65− return invite.workspace ? `${from} invited you to ${invite.workspace.name}` : `${from} invited you to g1t`;
144+function senderName(invite: InvitePreview): string {
145+ return invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team";
66146 }
67147
148+/** "Chase Pierce invited you to join Flagon, Inc. on g1t", with the place in bold. */
149+function Headline({ invite }: { invite: InvitePreview }) {
150+ const from = senderName(invite);
151+ if (invite.workspace) {
152+ return (
153+ <>
154+ {from} invited you to join <strong className="font-semibold text-fg">{invite.workspace.name}</strong> on g1t
155+ </>
156+ );
157+ }
158+ if (invite.repository) {
159+ return (
160+ <>
161+ {from} invited you to collaborate on <strong className="font-mono font-semibold text-fg">{invite.repository.name}</strong>
162+ </>
163+ );
164+ }
165+ return <>{from} invited you to g1t</>;
166+}
167+
168+function about(invite: InvitePreview, signedIn: boolean): string {
169+ const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account";
170+ if (invite.workspace) {
171+ return `g1t is where people and agents ship software together. ${
172+ signingUp ? "Make your account below and you join" : "Accepting joins you to"
173+ } ${invite.workspace.name} as a member.`;
174+ }
175+ if (invite.repository) {
176+ return `g1t is where people and agents ship software together. ${
177+ signingUp ? "Make your account below and you get" : "Accepting gives you"
178+ } the ${invite.repository.role} role on ${invite.repository.name}.`;
179+ }
180+ return "g1t is where people and agents ship software together: plan in issues, assign work to agents like teammates, and land it through checks that hold. It is invite-only for now; this invite gets you in.";
181+}
182+
183+/** What accepting is called on its button. */
184+function joinLabel(invite: InvitePreview): string {
185+ if (invite.workspace) return `Join ${invite.workspace.name}`;
186+ if (invite.repository) return `Accept access to ${invite.repository.name}`;
187+ return "Accept invite";
188+}
189+
68190 const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
69191
192+type Loaded = Route.ComponentProps["loaderData"];
193+
194+/** Signing up, on this page: the address the invite was sent to, a username, a password. */
195+function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) {
196+ const invite = loaded.invite!;
197+ const busy = useNavigation().state === "submitting";
198+ const here = `/invite/${loaded.code}`;
199+ const back = `${here}?accept=1`;
200+ const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`;
201+ return (
202+ <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6">
203+ <h2 id="sign-up" className="text-base font-semibold">
204+ Create your account
205+ </h2>
206+ <p className="mt-1 text-sm text-muted">
207+ {invite.workspace
208+ ? `You land in ${invite.workspace.name} as soon as it is made.`
209+ : invite.repository
210+ ? `You land in ${invite.repository.name} as soon as it is made.`
211+ : "It takes a minute."}
212+ </p>
213+ {loaded.github && (
214+ <div className="mt-5">
215+ <ContinueWithGithub href={github} />
216+ <OrDivider />
217+ </div>
218+ )}
219+ <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}>
220+ <input type="hidden" name="intent" value="register" />
221+ <Honeypot started={loaded.started} />
222+ {invite.address ? (
223+ <Field label="Email" hint="Your invite was sent here, so this address is confirmed already.">
224+ <span className="relative block">
225+ <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" />
226+ <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" />
227+ </span>
228+ </Field>
229+ ) : (
230+ <Field label="Email" hint="We send a link to confirm it.">
231+ <Input name="email" type="email" autoComplete="email" required maxLength={254} />
232+ </Field>
233+ )}
234+ <Field label="Username" hint="Lowercase letters, digits and hyphens. It is how you sign in and how others see you.">
235+ <Input
236+ name="username"
237+ autoComplete="username"
238+ required
239+ autoFocus
240+ maxLength={39}
241+ defaultValue={loaded.suggestion}
242+ pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*"
243+ />
244+ </Field>
245+ <Field label="Password" hint="At least 10 characters.">
246+ <Input name="password" type="password" autoComplete="new-password" required minLength={10} />
247+ </Field>
248+ <ErrorText>{error}</ErrorText>
249+ <div className="pt-1 *:w-full">
250+ <Button type="submit" disabled={busy}>
251+ {invite.workspace
252+ ? `Create account and join ${invite.workspace.name}`
253+ : invite.repository
254+ ? "Create account and accept"
255+ : "Create account"}
256+ </Button>
257+ </div>
258+ </Form>
259+ <p className="mt-5 text-center text-sm text-muted">
260+ Already on g1t?{" "}
261+ <Link to={`/login?next=${encodeURIComponent(back)}`} className="text-fg underline underline-offset-4">
262+ Sign in to accept
263+ </Link>
264+ </p>
265+ </section>
266+ );
267+}
268+
269+/** What to do next: join, sign in, sign out, or sign up. */
270+function Next({ loaded, error }: { loaded: Loaded; error: string | null }) {
271+ const invite = loaded.invite!;
272+ const viewer = loaded.viewer;
273+ const here = `/invite/${loaded.code}`;
274+ const back = `${here}?accept=1`;
275+ const joinable = invite.workspace !== null || invite.repository !== null;
276+
277+ if (viewer) {
278+ const signedInAs = (
279+ <p className="flex items-center gap-2 text-sm text-muted">
280+ <Avatar name={viewer.username} image={viewer.avatar} size={20} />
281+ Signed in as <span className="font-mono text-fg">{viewer.username}</span>
282+ </p>
283+ );
284+ const signOut = (label: string, variant: "primary" | "quiet") => (
285+ <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}>
286+ <Button type="submit" variant={variant}>
287+ {label}
288+ </Button>
289+ </Form>
290+ );
291+ if (invite.forViewer === false) {
292+ return (
293+ <div className="space-y-4">
294+ {signedInAs}
295+ <div className="rounded-md border border-warn/40 bg-warn/5 p-4 text-sm" role="status">
296+ <p className="font-medium text-fg">This invite is for {invite.address ?? invite.email}, not this account.</p>
297+ <p className="mt-1 text-muted">
298+ Only an account with that address confirmed can use it. Sign out, then sign in or sign up with it.
299+ </p>
300+ </div>
301+ <ErrorText>{error}</ErrorText>
302+ <div className="flex flex-wrap gap-3">{signOut("Sign out and continue", "primary")}</div>
303+ </div>
304+ );
305+ }
306+ if (loaded.alreadyIn && invite.workspace) {
307+ return (
308+ <div className="space-y-4">
309+ {signedInAs}
310+ <p className="text-sm text-muted">You are in {invite.workspace.name} already.</p>
311+ <ButtonLink to={`/${invite.workspace.slug}`}>Go to {invite.workspace.name}</ButtonLink>
312+ </div>
313+ );
314+ }
315+ if (!joinable) {
316+ return (
317+ <div className="space-y-4">
318+ {signedInAs}
319+ <div className="rounded-md border border-line bg-surface p-4 text-sm">
320+ <p>
321+ You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this invite has
322+ nothing more to give you.
323+ </p>
324+ <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p>
325+ </div>
326+ <div className="flex flex-wrap gap-3">{signOut("Sign out to use it", "quiet")}</div>
327+ </div>
328+ );
329+ }
330+ return (
331+ <Form method="post" className="space-y-4">
332+ <input type="hidden" name="intent" value="accept" />
333+ {signedInAs}
334+ {invite.forViewer === null && (
335+ <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p>
336+ )}
337+ <ErrorText>{error}</ErrorText>
338+ <div className="flex flex-wrap items-center gap-3">
339+ <Button type="submit">{joinLabel(invite)}</Button>
340+ <span className="text-sm text-muted">
341+ Not you?{" "}
342+ <button
343+ type="submit"
344+ formAction={`/logout?next=${encodeURIComponent(here)}`}
345+ className="text-fg underline underline-offset-4"
346+ >
347+ Sign out
348+ </button>
349+ </span>
350+ </div>
351+ </Form>
352+ );
353+ }
354+
355+ // Someone already on g1t: sign in, and the invite is accepted on return.
356+ if (invite.hasAccount || invite.kind === "workspace") {
357+ return (
358+ <div className="space-y-4">
359+ <div className="rounded-md border border-line bg-surface p-4 text-sm">
360+ <p className="font-medium text-fg">{invite.address ?? invite.email ?? "This address"} has a g1t account.</p>
361+ <p className="mt-1 text-muted">
362+ Sign in to it and {joinable ? `you ${invite.workspace ? `join ${invite.workspace.name}` : `get access to ${invite.repository!.name}`} straight away` : "the invite is accepted"}.
363+ </p>
364+ </div>
365+ <ErrorText>{error}</ErrorText>
366+ <ButtonLink to={`/login?next=${encodeURIComponent(back)}`}>Sign in to accept</ButtonLink>
367+ </div>
368+ );
369+ }
370+ return <SignUp loaded={loaded} error={error} />;
371+}
372+
373+/** A code that cannot be used: why, and whom to ask. */
374+function Dead({ loaded }: { loaded: Loaded }) {
375+ const invite = loaded.invite;
376+ const title = !invite
377+ ? "This invite link does not work"
378+ : invite.status === "expired"
379+ ? "This invite has expired"
380+ : invite.status === "revoked"
381+ ? "This invite was withdrawn"
382+ : "This invite has been used";
383+ const from = invite?.invitedBy;
384+ return (
385+ <>
386+ <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight">
387+ <CircleAlert size={22} className="shrink-0 text-warn" />
388+ {title}
389+ </h1>
390+ {invite ? (
391+ <p className="mt-3 text-sm leading-6 text-muted">
392+ It was {invite.workspace ? `an invite to join ${invite.workspace.name}` : invite.repository ? `an invite to ${invite.repository.name}` : "an invite to g1t"}
393+ {invite.status === "expired" && `, and stopped working on ${LONG_DATE.format(new Date(invite.expiresAt))}`}.{" "}
394+ {from ? "Ask the person who sent it for a new one." : "Ask for access and we will send a new one."}
395+ </p>
396+ ) : (
397+ <p className="mt-3 text-sm leading-6 text-muted">{loaded.error}</p>
398+ )}
399+ {from && (
400+ <div className="mt-5 flex items-center gap-3 rounded-lg border border-line bg-surface p-3">
401+ <Avatar name={from.username} image={from.avatar} size={36} />
402+ <p className="min-w-0 text-sm">
403+ Ask <span className="font-medium text-fg">{from.name ?? from.username}</span>{" "}
404+ <Link to={`/u/${from.username}`} className="font-mono text-muted hover:text-fg">
405+ @{from.username}
406+ </Link>{" "}
407+ <span className="text-muted">for a new invite.</span>
408+ </p>
409+ </div>
410+ )}
411+ <div className="mt-8 flex flex-wrap gap-3">
412+ <ButtonLink to="/register#request">Request access</ButtonLink>
413+ <ButtonLink to={loaded.viewer ? "/" : "/login"} variant="quiet">
414+ {loaded.viewer ? "Go to g1t" : "Sign in"}
415+ </ButtonLink>
416+ </div>
417+ </>
418+ );
419+}
420+
70421 export default function Invite({ loaderData, actionData }: Route.ComponentProps) {
71− const { invite, viewer, code, error } = loaderData;
72− const here = `/invite/${code}`;
422+ const { invite } = loaderData;
423+ const error = (actionData && "error" in actionData ? actionData.error : null) ?? loaderData.acceptError;
424+ const usable = invite?.status === "pending";
73425 return (
74− <main className="mx-auto flex max-w-md flex-col px-4 pt-20 pb-10">
426+ <main className="mx-auto flex max-w-md flex-col px-4 pt-16 pb-12 sm:pt-20">
75427 <Mark className="size-9" />
76− {invite ? (
428+ {usable ? (
77429 <>
78430 <div className="mt-8">
79− <Sender invite={invite} />
431+ <Faces invite={invite} />
80432 </div>
81− <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance">{headline(invite)}</h1>
82− <p className="mt-2 text-sm leading-6 text-muted">
83− {invite.workspace
84− ? `g1t is where people and agents ship software together. Accepting ${
85− invite.kind === "account" ? "makes your account and " : ""
86− }joins you to the ${invite.workspace.slug} workspace.`
87− : "g1t is where people and agents ship software together: plan in issues, assign work to agents like teammates, and land it through checks that hold. It is invite-only for now; this invite gets you in."}
88− </p>
433+ <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft">
434+ <Headline invite={invite} />
435+ </h1>
436+ <p className="mt-2 text-sm leading-6 text-muted">{about(invite, loaderData.viewer !== null)}</p>
89437 <dl className="mt-5 space-y-1 text-sm">
90438 {invite.invitedBy && (
91439 <div className="flex gap-2">
92− <dt className="w-20 shrink-0 text-faint">From</dt>
440+ <dt className="w-24 shrink-0 text-faint">From</dt>
93441 <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd>
94442 </div>
95443 )}
96444 {invite.email && (
97445 <div className="flex gap-2">
98− <dt className="w-20 shrink-0 text-faint">For</dt>
99− <dd className="font-mono text-fg-soft">{invite.email}</dd>
446+ <dt className="w-24 shrink-0 text-faint">For</dt>
447+ <dd className="font-mono break-all text-fg-soft">{invite.address ?? invite.email}</dd>
100448 </div>
101449 )}
102450 <div className="flex gap-2">
103− <dt className="w-20 shrink-0 text-faint">Works until</dt>
451+ <dt className="w-24 shrink-0 text-faint">Works until</dt>
104452 <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd>
105453 </div>
106454 </dl>
107−
108455 <div className="mt-8">
109− {viewer ? (
110− invite.workspace ? (
111− <Form method="post" className="space-y-3">
112− <p className="flex items-center gap-2 text-sm text-muted">
113− <Avatar name={viewer.username} image={viewer.avatar} size={20} />
114− Signed in as <span className="font-mono text-fg">{viewer.username}</span>
115− </p>
116− <ErrorText>{actionData?.error}</ErrorText>
117− <div className="*:w-full">
118− <Button type="submit">Join {invite.workspace.slug}</Button>
119− </div>
120− </Form>
121− ) : (
122− <div className="rounded-md border border-line bg-surface p-4 text-sm">
123− <p>
124− You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this
125− invite has nothing more to give you.
126− </p>
127− <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p>
128− </div>
129− )
130− ) : invite.kind === "workspace" ? (
131− <div className="space-y-3">
132− <ButtonLink to={`/login?next=${encodeURIComponent(here)}`}>Sign in to join</ButtonLink>
133− <p className="text-sm text-muted">This invite is for an address that already has a g1t account.</p>
134− </div>
135− ) : (
136− <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
137− <ButtonLink to={`/register?invite=${encodeURIComponent(code)}`}>Accept invite</ButtonLink>
138− <p className="text-sm text-muted">
139− Already on g1t?{" "}
140− <Link to={`/login?next=${encodeURIComponent(here)}`} className="text-fg underline underline-offset-4">
141− Sign in
142− </Link>
143− </p>
144− </div>
145− )}
456+ <Next loaded={loaderData} error={error} />
146457 </div>
147458 </>
148459 ) : (
149− <>
150− <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight">
151− <CircleAlert size={22} className="text-warn" />
152− This invite cannot be used
153− </h1>
154− <p className="mt-3 text-sm leading-6 text-muted">{error}</p>
155− <div className="mt-8 flex flex-wrap gap-3">
156− <ButtonLink to="/register">Request access</ButtonLink>
157− <ButtonLink to={viewer ? "/" : "/login"} variant="quiet">
158− {viewer ? "Go to g1t" : "Sign in"}
159− </ButtonLink>
160− </div>
161− </>
460+ <Dead loaded={loaderData} />
162461 )}
163462 </main>
164463 );
+32−22
11 import { ArrowUpRight } from "lucide-react";
22
3−import { DEPLOYMENTS_ALLOWANCE, type FeaturePlan, type FreeTier, MICROS_PER_DOLLAR, type Price } from "@g1t/contracts";
3+import { type FeaturePlan, type FreeTier, MICROS_PER_DOLLAR, type Price } from "@g1t/contracts";
44
55 import type { Route } from "./+types/pricing";
66 import { wholeDollars } from "../lib/billing";
5151 freePrivateStorageBytes: 1_000_000_000,
5252 auditRetentionDays: 90,
5353 minChargeMicros: 5_000_000,
54− gitOperationsIncluded: 10_000,
55− gitOperationsFreeCap: 50_000,
56− planPrivateStorageBytes: 10_000_000_000,
54+ gitOperationsIncluded: 50_000,
5755 paidStartCeilingMicros: 100_000_000,
5856 overageForgiveCostMicros: 50_000_000,
5957 };
60−
61−const A = DEPLOYMENTS_ALLOWANCE;
62−const DEPLOYMENTS_LINE = `${A.apps} apps, ${A.requests / 1e6} million requests, ${A.cpuMs / 1e6} million CPU milliseconds, ${A.customDomains} custom domains and ${A.buildSeconds / 60} build minutes a month`;
6358
6459 const DEFAULT_PLAN: FeaturePlan = {
6560 feature: "plan",
6661 title: "g1t",
6762 monthlyCents: 2000,
6863 includes: [
69− "$10 of usage each month at cost plus 20%, used first. Unused included usage does not roll over.",
64+ "$10 of usage each month at cost plus 20%, used first",
65+ "Everyone in the workspace at one price, never per person",
66+ "Unlimited projects, previews and repositories",
7067 "Agents, checks, workflows, the merge queue, deployments and semantic search",
71− `Deployments: ${DEPLOYMENTS_LINE}`,
72− "10 GB of private repository storage",
73− "Unlimited members, at one price for the workspace: never per person",
68+ "Usage past $10 is charged at cost plus 20%, up to your spend limit",
7469 ],
75− overage: "Usage past what is included is charged at cost plus 20%.",
70+ overage: "Everything is metered from the first unit at what it costs g1t plus 20%. Unused included usage does not roll over.",
7671 };
7772
7873 /** The ways to reach g1t about an enterprise account. */
114109 plan: "From the plan's included usage, then at cost plus 20%",
115110 },
116111 {
112+ what: "Projects and previews",
113+ free: "Not available: deployments run on g1t's machines",
114+ plan: "Unlimited, and never charged for. An app no one visits costs nothing.",
115+ },
116+ {
117117 what: "Deployments",
118118 free: "Not available",
119− plan: DEPLOYMENTS_LINE.replace(/^./, (c) => c.toUpperCase()) + ", then at cost plus 20%",
119+ plan: "Builds by the second, app requests, CPU time and custom domains, each from the first at cost plus 20%",
120120 },
121121 {
122122 what: "Semantic search",
126126 {
127127 what: "Private storage",
128128 free: `${gigabytes(tier.freePrivateStorageBytes)}, never charged. Past it, pushes to private repositories stop until you make room or start the plan.`,
129− plan: `${gigabytes(tier.planPrivateStorageBytes)}, then at cost plus 20%`,
129+ plan: `${gigabytes(tier.freePrivateStorageBytes)} free, then at cost plus 20%. Pushes never stop.`,
130130 note: "Public repositories are never charged for storage.",
131131 },
132132 {
133133 what: "Git operations",
134− free: `${count(tier.gitOperationsIncluded)} a month, and never charged. Past ${count(tier.gitOperationsFreeCap)} in a month, slowed to 60 an hour until the month turns.`,
135− plan: `${count(tier.gitOperationsIncluded)} a month, then at cost plus 20%`,
134+ free: `${count(tier.gitOperationsIncluded)} a month, never charged. Past it, slowed to 60 an hour until the month turns.`,
135+ plan: `${count(tier.gitOperationsIncluded)} a month free, then at cost plus 20%. Never slowed.`,
136136 note: "Clones, fetches and pushes through g1t.",
137137 },
138138 {
146146 {
147147 what: "Usage past what is included",
148148 free: "Not possible: a free workspace never runs up a bill",
149− plan: "Opt in, up to a spend limit you set",
149+ plan: "Charged at cost plus 20%, up to a spend limit you set. No quotas: only your limit stops anything.",
150150 },
151151 ];
152152 }
202202 </p>
203203 </div>
204204 <div>
205− <p className="font-medium">Included usage resets each month</p>
205+ <p className="font-medium">No quotas</p>
206206 <p className="mt-1 text-muted">
207− Unused included usage does not roll over: it starts again at $10 when the month turns. Past it, usage is
208− charged at cost plus 20%.
207+ No count of projects, previews, builds, requests or git operations ever stops a workspace on the plan. Past
208+ the $10, usage is charged at cost plus 20%, and only your spend limit stops it. Unused included usage does
209+ not roll over.
209210 </p>
210211 </div>
211212 </div>
258259 </div>
259260 <p className="mt-3 text-sm text-muted">
260261 Git operations cost g1t money too: Cloudflare charges g1t $0.15 per 1,000. {count(tier.gitOperationsIncluded)} a
261− month is about twenty times what an active workspace uses; an agent run takes two to four.
262+ month is far more than an active workspace uses; an agent run takes two to four.
262263 </p>
263264
264265 <h2 className="mt-14 text-xl font-semibold tracking-tight">No card, no compute</h2>
410411 <td className="hidden px-4 py-3 tabular-nums sm:table-cell">{book?.modelMarginPercent ?? 20}%</td>
411412 <td className="px-4 py-3 text-muted">Cost + {book?.modelMarginPercent ?? 20}%</td>
412413 </tr>
413− {(book?.prices ?? []).map((price) => (
414+ <tr>
415+ <td className="px-4 py-3">
416+ <p className="font-medium">Projects, previews and apps</p>
417+ <p className="text-xs text-faint">An app costs only the requests and CPU it answers with</p>
418+ </td>
419+ <td className="px-4 py-3 text-muted">Next to nothing</td>
420+ <td className="hidden px-4 py-3 tabular-nums sm:table-cell">—</td>
421+ <td className="px-4 py-3 text-muted">Not charged</td>
422+ </tr>
423+ {(book?.prices ?? []).filter((price) => price.meter !== "app_month").map((price) => (
414424 <tr key={price.meter}>
415425 <td className="px-4 py-3">
416426 <p className="font-medium">{price.title}</p>
+8−16
77 import { page } from "../lib/meta";
88 import { AuthCard } from "../components/auth-card";
99 import { ContinueWithGithub, OrDivider } from "../components/github";
10+import { Honeypot } from "../components/honeypot";
1011 import { githubSignInEnabled } from "../lib/github.server";
1112 import { Avatar, Button, ErrorText, Field, Input } from "../components/ui";
1213 import { identity } from "../lib/services.server";
3132 // one, there is no form to fill in.
3233 const checked = code ? await identity.checkInvite(code, clientKey(request)) : null;
3334 const invite: InvitePreview | null = checked?.ok ? checked.value : null;
35+ // A good invite is used on its own page, which knows whom it is from and
36+ // where it leads; it signs up, joins and lands in one go.
37+ if (invite && code) throw redirect(`/invite/${encodeURIComponent(code)}`);
3438 // Signing up with GitHub carries the invite code and `next` through it.
3539 const params = new URLSearchParams();
3640 if (code) params.set("invite", code);
124128 </span>
125129 </p>
126130 </div>
127− );
128−}
129−
130−/** The fields a bot fills in and a person never sees. */
131−function Honeypot({ started }: { started: number }) {
132− return (
133− <>
134− <input type="hidden" name="started" value={started} />
135− <div aria-hidden="true" className="absolute left-[-9999px] h-px w-px overflow-hidden">
136− <label>
137− Website
138− <input type="text" name="website" tabIndex={-1} autoComplete="off" />
139− </label>
140− </div>
141− </>
142131 );
143132 }
144133
247236 {requested ? (
248237 <p className="mt-3 flex items-start gap-2 rounded-md border border-accent/40 bg-surface p-3 text-sm" role="status">
249238 <CheckCircle2 size={16} className="mt-0.5 shrink-0 text-accent" />
250− <span>You are on the list. When a place opens, your invite comes to that address.</span>
239+ <span>
240+ You are on the list, and your invite comes to that address when a place opens. The first time an address
241+ asks, we email it to confirm.
242+ </span>
251243 </p>
252244 ) : (
253245 <>
+17−4
11 import { Box, Lock } from "lucide-react";
2−import { Link, NavLink, Outlet, useLocation, useRouteLoaderData } from "react-router";
2+import { Link, NavLink, Outlet, data, useLocation, useRouteLoaderData } from "react-router";
33
44 import type { Project } from "@g1t/contracts";
55
88 import { type Tab as PageTab, tabsFor } from "../../lib/project-nav";
99 import { Pill } from "../../components/ui";
1010 import { ArchivedBanner } from "../../components/repo-lifecycle";
11+import { WelcomeBanner } from "../../components/welcome";
12+import { clearWelcome, welcomes } from "../../lib/invites";
1113 import { notFound } from "../../lib/not-found.server";
1214 import { redirectIfRenamed, redirectIfTransferred } from "../../lib/renamed.server";
1315 import { accessFor, repoFor } from "../../lib/access.server";
4345 // The viewer's role on the repository and what it lets them do, for the
4446 // pages under it and the sidebar.
4547 const access = accessFor(viewer, value);
46− return {
48+ // Just given access with an invite: welcomed once (routes/invite.tsx).
49+ const welcome = Boolean(access.role) && welcomes(request.headers.get("cookie"), `${value.namespace}/${value.name}`);
50+ const headers = welcome ? { "Set-Cookie": clearWelcome(new URL(request.url).protocol === "https:") } : undefined;
51+ return data({
52+ welcome,
4753 repo: value,
4854 project,
4955 open: counts.ok ? counts.value : { issues: 0, pulls: 0 },
5056 access,
5157 member: access.insider,
52− };
58+ }, { headers });
5359 }
5460
5561 /** The project the page is in, for the pages under it. */
149155 }
150156
151157 export default function ProjectLayout({ loaderData }: Route.ComponentProps) {
152− const { repo, project, member, access } = loaderData;
158+ const { repo, project, member, access, welcome } = loaderData;
153159 const base = `/${repo.namespace}/${repo.name}`;
154160 const description = project?.description ?? repo.description;
155161 const { pathname } = useLocation();
177183 </div>
178184 </div>
179185 <div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
186+ {welcome && (
187+ <div className="mb-6">
188+ <WelcomeBanner title={`You're in ${repo.namespace}/${repo.name}`}>
189+ You have the {access.role} role on it now. Its code, issues and pull requests are yours to work on.
190+ </WelcomeBanner>
191+ </div>
192+ )}
180193 {repo.archivedAt && (
181194 <div className="mb-6">
182195 <ArchivedBanner base={base} owner={access.can.administer} settings={/^settings(\/|$)/.test(pathname.slice(base.length + 1))} />
+8−3
7171 removing: { label: "Removing", tone: "text-faint border-line" },
7272 };
7373
74+/** A custom domain's monthly price, to the cent: `$0.12`. */
75+function domainPrice(micros: number | undefined): string {
76+ return `$${((micros ?? 120_000) / 1_000_000).toFixed(2)}`;
77+}
78+
7479 function StatusBadge({ status }: { status: DomainStatus }) {
7580 const { label, tone, live } = STATUS[status];
7681 return (
9095 }
9196
9297 export default function DomainSettings({ loaderData, actionData, params }: Route.ComponentProps) {
93− const { settings, domains, target, available, notice, included, used } = loaderData;
98+ const { settings, domains, target, available, notice, monthlyMicros, used } = loaderData;
9499 const busy = useNavigation().state === "submitting";
95100 const base = `/${params.owner}/${params.repo}`;
96101 const [hostname, setHostname] = useState("");
186191 description="Most sites answer at both example.com and www.example.com; one serves the app and the other sends visitors to it, path and query kept."
187192 />
188193 <p className="mt-4 text-xs text-faint">
189− The g1t plan includes {included} custom domains across the workspace ({used} in use). Each one past that is
190− charged by the month at cost plus the margin.
194+ Each custom domain is {domainPrice(monthlyMicros)} a month on the g1t plan, its cost plus 20%, from the included usage
195+ first. As many as you like: {used} in use across the workspace.
191196 </p>
192197 </Form>
193198
+34−0
1+/**
2+ * What the account menu shows under the avatar, fetched when it opens so
3+ * no page waits on it: the name people see, the primary address, and how
4+ * many invites are left when there is a limit. Each part is optional: a
5+ * service that cannot answer leaves its part out.
6+ */
7+import type { Route } from "./+types/settings-menu-json";
8+import { accounts, identity } from "../lib/services.server";
9+import { getViewer } from "../lib/session.server";
10+import { loadInvites } from "../lib/invites.server";
11+
12+export type AccountMenuData = {
13+ name: string | null;
14+ email: string | null;
15+ /** Invites left, or null when there is no limit (or none can be made yet). */
16+ invites_left: number | null;
17+};
18+
19+export async function loader({ context }: Route.LoaderArgs) {
20+ const user = getViewer(context);
21+ if (!user) return Response.json(null, { status: 401, headers: { "cache-control": "private, no-store" } });
22+ const [profile, emails, invites] = await Promise.all([
23+ identity.profile(user.username).catch(() => null),
24+ accounts.listEmails(user).catch(() => null),
25+ loadInvites(user),
26+ ]);
27+ const primary = emails?.ok ? (emails.value.emails.find((e) => e.primary)?.email ?? null) : null;
28+ const data: AccountMenuData = {
29+ name: profile?.name?.trim() || null,
30+ email: primary,
31+ invites_left: invites?.allowance.limit != null ? (invites.allowance.remaining ?? 0) : null,
32+ };
33+ return Response.json(data, { headers: { "cache-control": "private, max-age=60" } });
34+}
+0−346
1−import { identity } from "../lib/services.server";
2−import { Form, Link } from "react-router";
3−
4−import type { Route } from "./+types/settings";
5−import { page } from "../lib/meta";
6−import { AvatarField } from "../components/avatar-field";
7−import { Button, ErrorText, Field, Input, TimeAgo } from "../components/ui";
8−import { readAvatarUpload } from "../lib/avatar-upload";
9−import { assertSameOrigin, requireUser } from "../lib/session.server";
10−import { ProfileSection } from "../components/profile-form";
11−import { InvitesSection } from "../components/invites-section";
12−import { inviteAction, loadInvites } from "../lib/invites.server";
13−import { EmailsSection, SecurityLogSection } from "../components/emails-section";
14−import { EMAIL_INTENTS, emailAction, loadEmails } from "../lib/emails.server";
15−import { GithubMark } from "../components/github";
16−import { githubSignIn } from "../lib/github.server";
17−
18−export function meta(args: Route.MetaArgs) {
19− return page(args, { title: "Settings · g1t" });
20−}
21−
22−export async function loader({ request, context }: Route.LoaderArgs) {
23− const user = requireUser(context, request);
24− const [keys, tokens, applications, profile, github] = await Promise.all([
25− identity.listSshKeys(user),
26− identity.listAccessTokens(user),
27− identity.listOAuthGrants(user),
28− identity.profile(user.username),
29− githubSignIn.account(user).catch(() => null),
30− ]);
31− const [invites, emails] = await Promise.all([loadInvites(user), loadEmails(user)]);
32− return { user, keys, tokens, applications, profile, github, invites, emails, origin: new URL(request.url).origin };
33−}
34−
35−export async function action({ request, context }: Route.ActionArgs) {
36− assertSameOrigin(request);
37− const user = requireUser(context, request);
38− const form = await request.formData();
39− const id = String(form.get("id") ?? "");
40−
41− // Email addresses (lib/emails.server.ts): sensitive changes may first ask
42− // for the password.
43− if ((EMAIL_INTENTS as readonly string[]).includes(String(form.get("intent")))) {
44− return { emailAction: await emailAction(user, form, request) };
45− }
46−
47− switch (form.get("intent")) {
48− // Invites: making and revoking them (lib/invites.server.ts).
49− case "create-invite":
50− case "revoke-invite": {
51− const done = await inviteAction(user, form);
52− return { inviteCreated: done?.inviteCreated, inviteError: done?.inviteError };
53− }
54− // Identity checks every field again, and the website most of all.
55− case "profile": {
56− const text = (name: string) => String(form.get(name) ?? "");
57− const result = await identity.updateProfile(user, {
58− name: text("name"),
59− bio: text("bio"),
60− location: text("location"),
61− website: text("website"),
62− pronouns: text("pronouns"),
63− });
64− return result.ok ? { profileSaved: true } : { profileError: result.error.message };
65− }
66− // The picture: identity checks the image's bytes again.
67− case "avatar": {
68− const upload = await readAvatarUpload(form);
69− if ("error" in upload) return { avatarError: upload.error };
70− const result = await identity.setUserAvatar(user, upload.image);
71− return result.ok ? null : { avatarError: result.error.message };
72− }
73− case "remove-avatar": {
74− const result = await identity.setUserAvatar(user, null);
75− return result.ok ? null : { avatarError: result.error.message };
76− }
77− case "add-key": {
78− const result = await identity.addSshKey(
79− user,
80− String(form.get("title") ?? ""),
81− String(form.get("key") ?? ""),
82− );
83− return result.ok ? null : { keyError: result.error.message };
84− }
85− case "delete-key":
86− await identity.removeSshKey(user, id);
87− return null;
88− case "add-token": {
89− const created = await identity.createAccessToken(
90− user,
91− String(form.get("label") ?? ""),
92− );
93− return { newToken: created.token };
94− }
95− case "delete-token":
96− await identity.removeAccessToken(user, id);
97− return null;
98− case "unlink-github": {
99− const result = await githubSignIn.unlink(user);
100− return result.ok ? null : { githubError: result.error.message };
101− }
102− case "sign-out-application":
103− await identity.revokeOAuthGrant(user, id);
104− return null;
105− }
106− return null;
107−}
108−
109−function DeleteButton({
110− intent,
111− id,
112− label = "Delete",
113−}: {
114− intent: string;
115− id: string;
116− label?: string;
117−}) {
118− return (
119− <Form method="post" className="ml-auto">
120− <input type="hidden" name="intent" value={intent} />
121− <input type="hidden" name="id" value={id} />
122− <Button variant="quiet" type="submit">
123− {label}
124− </Button>
125− </Form>
126− );
127−}
128−
129−export default function Settings({
130− loaderData,
131− actionData,
132−}: Route.ComponentProps) {
133− const { user, keys, tokens, applications, profile, github, invites, emails, origin } = loaderData;
134− return (
135− <main className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
136− <header className="mb-8 border-b border-line pb-6">
137− <h1 className="text-2xl font-semibold tracking-tight">Account</h1>
138− <p className="mt-1.5 text-sm text-muted">
139− How <span className="font-mono text-fg">{user.username}</span> signs in from git, tools and agents.
140− </p>
141− </header>
142− <div className="max-w-2xl space-y-12">
143− <section id="picture" className="scroll-mt-20">
144− <h2 className="font-medium">Picture</h2>
145− <div className="mt-4">
146− <AvatarField
147− name={user.username}
148− image={user.avatar}
149− error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined}
150− about="Shown beside your username in the sidebar and on mission control. Without one, g1t draws your first letter."
151− />
152− </div>
153− </section>
154− <ProfileSection
155− username={user.username}
156− profile={profile}
157− error={actionData && "profileError" in actionData ? actionData.profileError : undefined}
158− saved={Boolean(actionData && "profileSaved" in actionData)}
159− />
160− <EmailsSection
161− data={emails.emails}
162− actionData={actionData && "emailAction" in actionData ? actionData.emailAction : undefined}
163− hasPassword={github?.hasPassword ?? true}
164− />
165− {github?.enabled && (
166− <section id="github" className="scroll-mt-20">
167− <h2 className="font-medium">GitHub</h2>
168− <p className="mt-1 text-sm text-muted">
169− Sign in with your GitHub account, and import or mirror repositories you can reach there.
170− </p>
171− {github.account ? (
172− <div className="mt-4 flex items-center gap-4 rounded-md border border-line px-4 py-3">
173− <GithubMark className="size-5 shrink-0" />
174− <div className="min-w-0">
175− <p className="text-sm">
176− Linked to <span className="font-mono">@{github.account.login}</span>
177− </p>
178− <p className="text-xs text-faint">
179− Since <TimeAgo at={github.account.linkedAt} />
180− {!github.account.authorized && " · access ended: link it again to import repositories"}
181− </p>
182− </div>
183− <div className="ml-auto flex items-center gap-2">
184− {!github.account.authorized && (
185− <a href="/auth/github?link=1" className="text-sm text-muted hover:text-fg">
186− Link again
187− </a>
188− )}
189− <Form method="post">
190− <input type="hidden" name="intent" value="unlink-github" />
191− <Button variant="quiet" type="submit" disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}>
192− Unlink
193− </Button>
194− </Form>
195− </div>
196− </div>
197− ) : (
198− <div className="mt-4">
199− <a
200− href="/auth/github?link=1"
201− className="inline-flex items-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/90 hover:border-line-strong hover:bg-surface hover:text-fg"
202− >
203− <GithubMark /> Link GitHub
204− </a>
205− </div>
206− )}
207− {github.account && !github.hasPassword && (
208− <p className="mt-2 text-xs text-faint">
209− GitHub is the only way you sign in, so it cannot be unlinked yet. Sign out and use Forgot your password to set one.
210− </p>
211− )}
212− <ErrorText>{actionData && "githubError" in actionData ? actionData.githubError : undefined}</ErrorText>
213− </section>
214− )}
215− <InvitesSection
216− overview={invites}
217− origin={origin}
218− created={actionData && "inviteCreated" in actionData ? actionData.inviteCreated : undefined}
219− error={actionData && "inviteError" in actionData ? actionData.inviteError : undefined}
220− />
221− <section id="ssh-keys" className="scroll-mt-20">
222− <h2 className="font-medium">SSH keys</h2>
223− <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
224− {keys.map((key) => (
225− <li key={key.id} className="flex items-center gap-4 px-4 py-3">
226− <div className="min-w-0">
227− <p className="text-sm">{key.title}</p>
228− <p className="truncate font-mono text-xs text-muted">
229− {key.fingerprint}
230− </p>
231− </div>
232− <DeleteButton intent="delete-key" id={key.id} />
233− </li>
234− ))}
235− </ul>
236− <Form method="post" className="mt-4 space-y-3">
237− <input type="hidden" name="intent" value="add-key" />
238− <Field label="Title (optional)">
239− <Input name="title" maxLength={100} />
240− </Field>
241− <Field label="Public key">
242− <Input name="key" placeholder="ssh-ed25519 AAAA…" required />
243− </Field>
244− <ErrorText>{actionData?.keyError}</ErrorText>
245− <Button type="submit">Add SSH key</Button>
246− </Form>
247− </section>
248−
249− <section id="tokens" className="scroll-mt-20">
250− <h2 className="font-medium">Access tokens</h2>
251− <p className="mt-1 text-sm text-muted">
252− Use a token as the password when git asks for one over HTTPS, and to
253− authenticate agents and the API. A token here acts as you.
254− </p>
255− {(user.workspaces ?? []).length > 0 && (
256− <p className="mt-1 text-sm text-muted">
257− For CI and integrations that work for a team, use a workspace's
258− own tokens instead:{" "}
259− {(user.workspaces ?? []).map((membership, i) => (
260− <span key={membership.slug}>
261− {i > 0 && ", "}
262− <Link
263− to={`/${membership.slug}/-/tokens`}
264− className="font-mono text-fg underline underline-offset-4"
265− >
266− {membership.slug}
267− </Link>
268− </span>
269− ))}
270− .
271− </p>
272− )}
273− {actionData?.newToken && (
274− <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4">
275− <p className="text-sm">Copy it now. It will not be shown again.</p>
276− <pre className="mt-2 overflow-x-auto font-mono text-sm text-accent">
277− {actionData.newToken}
278− </pre>
279− </div>
280− )}
281− <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
282− {tokens.map((token) => (
283− <li key={token.id} className="flex items-center gap-4 px-4 py-3">
284− <div className="min-w-0">
285− <p className="truncate text-sm">{token.name}</p>
286− <p className="text-xs text-faint">
287− Created <TimeAgo at={token.createdAt} /> ·{" "}
288− {token.lastUsedAt ? (
289− <>
290− last used <TimeAgo at={token.lastUsedAt} />
291− </>
292− ) : (
293− "never used"
294− )}
295− </p>
296− </div>
297− <DeleteButton intent="delete-token" id={token.id} />
298− </li>
299− ))}
300− </ul>
301− <Form method="post" className="mt-4 flex items-end gap-3">
302− <input type="hidden" name="intent" value="add-token" />
303− <div className="grow">
304− <Field label="Name">
305− <Input name="label" maxLength={100} placeholder="laptop" />
306− </Field>
307− </div>
308− <Button type="submit">Create token</Button>
309− </Form>
310− </section>
311−
312− <section id="applications" className="scroll-mt-20">
313− <h2 className="font-medium">Connected applications</h2>
314− <p className="mt-1 text-sm text-muted">
315− Applications you signed in to through your browser, such as an agent
316− connected to the g1t MCP server. Signing one out ends its access at
317− once.
318− </p>
319− {applications.length === 0 ? (
320− <p className="mt-4 text-sm text-faint">None yet.</p>
321− ) : (
322− <ul className="mt-4 divide-y divide-line rounded-md border border-line">
323− {applications.map((application) => (
324− <li key={application.id} className="flex items-center gap-4 px-4 py-3">
325− <div>
326− <p className="text-sm">{application.clientName}</p>
327− <p className="text-xs text-faint">
328− Connected <TimeAgo at={application.createdAt} /> · last used{" "}
329− <TimeAgo at={application.lastUsedAt} />
330− </p>
331− </div>
332− <DeleteButton
333− intent="sign-out-application"
334− id={application.id}
335− label="Sign out"
336− />
337− </li>
338− ))}
339− </ul>
340− )}
341− </section>
342− <SecurityLogSection log={emails.log} />
343− </div>
344− </main>
345− );
346−}
+51−0
1+import { identity } from "../../lib/services.server";
2+
3+import type { Route } from "./+types/applications";
4+import { page } from "../../lib/meta";
5+import { TimeAgo } from "../../components/ui";
6+import { DeleteButton } from "../../components/account-settings";
7+import { assertSameOrigin, requireUser } from "../../lib/session.server";
8+
9+export function meta(args: Route.MetaArgs) {
10+ return page(args, { title: "Connected applications · Settings · g1t" });
11+}
12+
13+export async function loader({ request, context }: Route.LoaderArgs) {
14+ const user = requireUser(context, request);
15+ return { applications: await identity.listOAuthGrants(user) };
16+}
17+
18+export async function action({ request, context }: Route.ActionArgs) {
19+ assertSameOrigin(request);
20+ const user = requireUser(context, request);
21+ const form = await request.formData();
22+ if (form.get("intent") === "sign-out-application") {
23+ await identity.revokeOAuthGrant(user, String(form.get("id") ?? ""));
24+ }
25+ return null;
26+}
27+
28+export default function ApplicationSettings({ loaderData }: Route.ComponentProps) {
29+ const { applications } = loaderData;
30+ return (
31+ <section id="applications" className="scroll-mt-20">
32+ {applications.length === 0 ? (
33+ <p className="text-sm text-faint">None yet.</p>
34+ ) : (
35+ <ul className="divide-y divide-line rounded-md border border-line">
36+ {applications.map((application) => (
37+ <li key={application.id} className="flex items-center gap-4 px-4 py-3">
38+ <div className="min-w-0">
39+ <p className="truncate text-sm">{application.clientName}</p>
40+ <p className="text-xs text-faint">
41+ Connected <TimeAgo at={application.createdAt} /> · last used <TimeAgo at={application.lastUsedAt} />
42+ </p>
43+ </div>
44+ <DeleteButton intent="sign-out-application" id={application.id} label="Sign out" />
45+ </li>
46+ ))}
47+ </ul>
48+ )}
49+ </section>
50+ );
51+}
+38−0
1+import type { Route } from "./+types/emails";
2+import { page } from "../../lib/meta";
3+import { assertSameOrigin, requireUser } from "../../lib/session.server";
4+import { EmailsSection } from "../../components/emails-section";
5+import { EMAIL_INTENTS, emailAction, loadEmails } from "../../lib/emails.server";
6+import { githubSignIn } from "../../lib/github.server";
7+
8+export function meta(args: Route.MetaArgs) {
9+ return page(args, { title: "Emails · Settings · g1t" });
10+}
11+
12+export async function loader({ request, context }: Route.LoaderArgs) {
13+ const user = requireUser(context, request);
14+ const [emails, github] = await Promise.all([loadEmails(user), githubSignIn.account(user).catch(() => null)]);
15+ // Whether a sensitive change can be confirmed with a password.
16+ return { emails: emails.emails, hasPassword: github?.hasPassword ?? true };
17+}
18+
19+export async function action({ request, context }: Route.ActionArgs) {
20+ assertSameOrigin(request);
21+ const user = requireUser(context, request);
22+ const form = await request.formData();
23+ // Sensitive changes may first ask for the password (lib/emails.server.ts).
24+ if ((EMAIL_INTENTS as readonly string[]).includes(String(form.get("intent")))) {
25+ return { emailAction: await emailAction(user, form, request) };
26+ }
27+ return null;
28+}
29+
30+export default function EmailSettings({ loaderData, actionData }: Route.ComponentProps) {
31+ return (
32+ <EmailsSection
33+ data={loaderData.emails}
34+ actionData={actionData?.emailAction}
35+ hasPassword={loaderData.hasPassword}
36+ />
37+ );
38+}
+79−0
1+import { Form } from "react-router";
2+
3+import type { Route } from "./+types/github";
4+import { page } from "../../lib/meta";
5+import { Button, ErrorText, TimeAgo } from "../../components/ui";
6+import { assertSameOrigin, requireUser } from "../../lib/session.server";
7+import { GithubMark } from "../../components/github";
8+import { githubSignIn } from "../../lib/github.server";
9+
10+export function meta(args: Route.MetaArgs) {
11+ return page(args, { title: "GitHub · Settings · g1t" });
12+}
13+
14+export async function loader({ request, context }: Route.LoaderArgs) {
15+ const user = requireUser(context, request);
16+ return { github: await githubSignIn.account(user).catch(() => null) };
17+}
18+
19+export async function action({ request, context }: Route.ActionArgs) {
20+ assertSameOrigin(request);
21+ const user = requireUser(context, request);
22+ const form = await request.formData();
23+ if (form.get("intent") === "unlink-github") {
24+ const result = await githubSignIn.unlink(user);
25+ return result.ok ? null : { githubError: result.error.message };
26+ }
27+ return null;
28+}
29+
30+export default function GithubSettings({ loaderData, actionData }: Route.ComponentProps) {
31+ const { github } = loaderData;
32+ if (!github?.enabled) {
33+ return <p className="text-sm text-muted">Signing in with GitHub is not set up here.</p>;
34+ }
35+ return (
36+ <section id="github" className="scroll-mt-20">
37+ {github.account ? (
38+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-md border border-line px-4 py-3">
39+ <GithubMark className="size-5 shrink-0" />
40+ <div className="min-w-0">
41+ <p className="text-sm">
42+ Linked to <span className="font-mono">@{github.account.login}</span>
43+ </p>
44+ <p className="text-xs text-faint">
45+ Since <TimeAgo at={github.account.linkedAt} />
46+ {!github.account.authorized && " · access ended: link it again to import repositories"}
47+ </p>
48+ </div>
49+ <div className="ml-auto flex items-center gap-2">
50+ {!github.account.authorized && (
51+ <a href="/auth/github?link=1" className="text-sm text-muted hover:text-fg">
52+ Link again
53+ </a>
54+ )}
55+ <Form method="post">
56+ <input type="hidden" name="intent" value="unlink-github" />
57+ <Button variant="quiet" type="submit" disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}>
58+ Unlink
59+ </Button>
60+ </Form>
61+ </div>
62+ </div>
63+ ) : (
64+ <a
65+ href="/auth/github?link=1"
66+ className="inline-flex items-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/90 hover:border-line-strong hover:bg-surface hover:text-fg"
67+ >
68+ <GithubMark /> Link GitHub
69+ </a>
70+ )}
71+ {github.account && !github.hasPassword && (
72+ <p className="mt-2 text-xs text-faint">
73+ GitHub is the only way you sign in, so it cannot be unlinked yet. Sign out and use Forgot your password to set one.
74+ </p>
75+ )}
76+ <ErrorText>{actionData?.githubError}</ErrorText>
77+ </section>
78+ );
79+}
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.