flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

Commit

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look

- Registration is invite-only: single-use codes, five invites each, a waitlist, staff grants in sudo. - Sign in with GitHub and bring repositories over: import, mirror, or move to g1t, through one GitHub App. - Repository access: Read, Triage, Write, Maintain and Admin; a workspace base permission; outside collaborators. - Repositories can be renamed, archived, made public or private, transferred, and deleted with 30 days to restore. - Accounts hold up to ten emails with a primary and a backup, private noreply commits, and pushes that would expose an address refused on request. - Sign-in is throttled against guessing, and sensitive changes ask you to confirm it is you. - Workspaces can be deleted; syntaqx becomes an ordinary workspace and flagon-io holds g1t. - Deployments say plainly that they are off until turned on, and show what was detected. - The pixel G1T logo, Bricolage Grotesque, Hanken Grotesk and IBM Plex Mono served from g1t itself. - A faster, phone-friendly marketing site, and a sidebar that drills into settings and projects.

syntaqxcommitted Parent679e64dBrowse files
300 files+5155−3440/300 viewed
+40−24
11 # g1t brand files
22
3−The mark is "the fleet": three 1s stepping back in depth, the agents at work
4−behind the one change in front. The front 1 is `#ededef` (`--g1t-fg`), the two
5−behind it are lavender `#b6a8ff` (`--g1t-merged`) at 65% and 35%, on the very
6−dark gray base `#0f0f11` (`--g1t-bg`). On light backgrounds the front 1 is
7−`#161618` and the lavender deepens to `#6b56e8`.
3+The logo is G1T in 5×7 pixel capitals, the 1 in lavender; the icon is the 1.
4+
5+- **Letters:** one-pixel strokes on a 5×7 grid. The G sits at column 0, the 1
6+ at column 6 and the T at column 10, one column closer than the usual
7+ advance, so the T's bar sits above the end of the 1's foot without
8+ touching it. The wordmark is 15 by 7 cells.
9+- **Pixels:** each is its own square, 0.8 of its cell, centred, with square
10+ corners.
11+- **Colour:** G and T are `#ededef` (`--g1t-fg`) and the 1 is lavender
12+ `#b6a8ff` (`--g1t-merged`), on the very dark gray base `#0f0f11`
13+ (`--g1t-bg`). On light backgrounds the letters are `#161618` and the 1 is
14+ `#6b56e8`. Printed in one ink, everything is that ink.
15+- **Icon:** the 1 alone, centred in a square, for favicons, app icons and
16+ the app's sidebar.
17+- **Name:** the product is "g1t" in lowercase in text. Only the artwork is
18+ capitals.
19+
20+Vectors scale the pixels freely, which blurs them at small sizes, so the
21+small files are hand-fitted cuts on whole device pixels: at 16px each cell is
22+2px with no gap, at 32px 4px with a 1px gap, at 48px 5px with a 1px gap. The
23+PNGs are drawn with every pixel edge on a device pixel.
824
925 These are the files to upload to Stripe and similar services. The site serves
1026 its own copies from `apps/web/public` and `apps/docs/public`.
1329 | --- | --- | --- |
1430 | `icon-512.png` | 512×512, dark square | Stripe branding icon (square, at least 128px), Slack, GitHub app avatars |
1531 | `icon-1024.png` | 1024×1024, dark square | App stores and anywhere asking for a large square icon |
16−| `icon-transparent-1024.png` | 1024×1024, transparent | Placing the mark on a dark surface of your own |
32+| `icon-transparent-1024.png` | 1024×1024, transparent | Placing the icon on a dark surface of your own |
1733 | `logo-on-dark.png` | 1056×642, dark background | Stripe branding logo (wide), docs and decks on dark |
1834 | `logo-on-light.png` | 1056×642, white background | Invoices, receipts and anywhere the background is light |
1935 | `og-1200x630.png` | 1200×630 | Social and Open Graph card |
2440
2541 | File | What it is |
2642 | --- | --- |
27−| `svg/mark.svg` | The mark on transparent, for dark backgrounds (also `packages/theme/mark.svg`) |
28−| `svg/mark-on-light.svg` | The mark on transparent, for light backgrounds |
29−| `svg/mark-small.svg` | The 16px cut: pixel-aligned, with a notch between the front 1's flag and the bar behind it |
30−| `svg/icon-square.svg` | The mark on a full-bleed dark square (`icon-512.png`, `icon-1024.png`) |
31−| `svg/icon-rounded.svg` | The mark on a rounded dark tile (`favicon.ico` at 32 and 48, `brand/g1t-mark.*`) |
32−| `svg/icon-padded.svg` | The mark with room for a circle crop (`apple-touch-icon.png`, `icon-192.png`, `icon-512.png` in the apps) |
33−| `svg/favicon.svg` | The 16px cut with no tile, coloured for a light or dark browser (`favicon.svg` in the apps) |
34−| `svg/logo.svg`, `svg/logo-on-light.svg` | The mark and the wordmark, transparent, for dark and light backgrounds |
35−| `svg/logo-dark-bg.svg`, `svg/logo-light-bg.svg` | The same lockups with padding and a background |
36−| `svg/og.svg` | The social card; its tagline uses Inter and JetBrains Mono as live text |
43+| `svg/mark.svg` | The icon, the pixel 1, centred in a transparent square, for dark backgrounds (also `packages/theme/mark.svg`) |
44+| `svg/mark-on-light.svg` | The icon on transparent, for light backgrounds |
45+| `svg/mark-small.svg` | The 16px cut: each cell 2px, no gap |
46+| `svg/icon-square.svg` | The icon on a full-bleed dark square, 1024 box on 100-unit cells (`icon-512.png`, `icon-1024.png`) |
47+| `svg/icon-rounded.svg` | The icon on a rounded dark tile, the 256px cut (`brand/g1t-mark.*`) |
48+| `svg/icon-padded.svg` | The icon with room for a circle crop, the 512px cut (`icon-512.png` in the apps; `apple-touch-icon.png` and `icon-192.png` are cut the same way at their own sizes) |
49+| `svg/favicon.svg` | The 16px cut with no tile, coloured for a light or dark browser (`favicon.svg` in the apps and `packages/theme`) |
50+| `svg/logo.svg`, `svg/logo-on-light.svg` | The wordmark, transparent and cropped to its ink, for dark and light backgrounds |
51+| `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 |
3753
38−The wordmark is "g1t" in Inter ExtraBold at -0.05em tracking, outlined to paths
39−so the files need no font.
54+`favicon.ico` in the apps holds the 16, 32 and 48px cuts of the icon on a
55+rounded dark tile. The pixels are squares, so no file needs a font.
4056
4157 ## On the web
4258
4359 `apps/web/public/brand/` is served at `https://g1t.sh/brand/` for emails and
4460 anything that links to the mark:
4561
46−- `g1t-mark.svg`, `g1t-mark.png` (256px): the mark on a rounded dark tile, readable on any background
47−- `g1t-logo.svg`, `g1t-logo.png`: the lockup for light backgrounds, used in g1t's emails
48−- `g1t-logo-on-dark.svg`, `g1t-logo-on-dark.png`: the lockup for dark backgrounds
62+- `g1t-mark.svg`, `g1t-mark.png` (256px): the icon on a rounded dark tile, readable on any background
63+- `g1t-logo.svg`, `g1t-logo.png` (300×140, shown at 60×28): the wordmark for light backgrounds, used in g1t's emails
64+- `g1t-logo-on-dark.svg`, `g1t-logo-on-dark.png` (300×140): the wordmark for dark backgrounds
4965 - `g1t-og.png`: the brand social card as `services/og` draws it, which that service falls back to if drawing fails
5066
5167 ## Social cards
5268
5369 Every page of g1t.sh and docs.g1t.sh links to its own card at `https://og.g1t.sh`,
54−drawn by `services/og` in the same colours, type and mark: the lockup and the
55−page's address along the top, its subject in the middle, its facts along the
56−bottom, the fleet standing large and faded off the right edge.
70+drawn by `services/og` in the same colours, type and pixels: the wordmark and
71+the page's address along the top, its subject in the middle, its facts along
72+the bottom, the pixel 1 standing large and faded off the right edge.
+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.

+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.f{fill:#161618}.f.s{fill:none;stroke:#161618}.m{fill:#6b56e8}@media (prefers-color-scheme: dark){.f{fill:#ededef}.f.s{fill:none;stroke:#ededef}.m{fill:#b6a8ff}}</style><mask id="cut" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><path d="M10.5 3.5 8.5 5.4" stroke="#000" stroke-width="3.6" stroke-linecap="round"/></mask><g mask="url(#cut)"><rect class="m" x="3" y="5.5" width="2" height="8.5" rx="1" fill-opacity="0.5"/><rect class="m" x="6" y="4" width="2" height="10" rx="1" fill-opacity="0.85"/></g><rect class="f" x="9" y="2" width="3" height="12" rx="1.5"/><path class="f s" d="M10.5 3.5 8.5 5.4" stroke-width="2.2" stroke-linecap="round"/></svg>
1+<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><rect x="7" y="1" width="2" height="2"/><rect x="5" y="3" width="2" height="2"/><rect x="7" y="3" width="2" height="2"/><rect x="3" y="5" width="2" height="2"/><rect x="7" y="5" width="2" height="2"/><rect x="7" y="7" width="2" height="2"/><rect x="7" y="9" width="2" height="2"/><rect x="7" y="11" width="2" height="2"/><rect x="3" y="13" width="2" height="2"/><rect x="5" y="13" width="2" height="2"/><rect x="7" y="13" width="2" height="2"/><rect x="9" y="13" width="2" height="2"/><rect x="11" y="13" width="2" height="2"/></svg>
+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#0f0f11"/><g transform="translate(3.2 3.2) scale(0.8)"><g transform="translate(0.7 0.5)"><rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="#b6a8ff" fill-opacity="0.35"/><rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="#b6a8ff" fill-opacity="0.65"/><rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="#ededef"/><path d="M21.7 7.2 17.6 10.9" fill="none" stroke="#ededef" stroke-width="4.6" stroke-linecap="round"/></g></g></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" shape-rendering="crispEdges"><rect width="512" height="512" fill="#0f0f11"/><rect x="240" y="120" width="32" height="32" fill="#b6a8ff"/><rect x="200" y="160" width="32" height="32" fill="#b6a8ff"/><rect x="240" y="160" width="32" height="32" fill="#b6a8ff"/><rect x="160" y="200" width="32" height="32" fill="#b6a8ff"/><rect x="240" y="200" width="32" height="32" fill="#b6a8ff"/><rect x="240" y="240" width="32" height="32" fill="#b6a8ff"/><rect x="240" y="280" width="32" height="32" fill="#b6a8ff"/><rect x="240" y="320" width="32" height="32" fill="#b6a8ff"/><rect x="160" y="360" width="32" height="32" fill="#b6a8ff"/><rect x="200" y="360" width="32" height="32" fill="#b6a8ff"/><rect x="240" y="360" width="32" height="32" fill="#b6a8ff"/><rect x="280" y="360" width="32" height="32" fill="#b6a8ff"/><rect x="320" y="360" width="32" height="32" fill="#b6a8ff"/></svg>
+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0f0f11"/><g transform="translate(0.7 0.5)"><rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="#b6a8ff" fill-opacity="0.35"/><rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="#b6a8ff" fill-opacity="0.65"/><rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="#ededef"/><path d="M21.7 7.2 17.6 10.9" fill="none" stroke="#ededef" stroke-width="4.6" stroke-linecap="round"/></g></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" shape-rendering="crispEdges"><rect width="256" height="256" rx="56" shape-rendering="geometricPrecision" fill="#0f0f11"/><rect x="118" y="43" width="20" height="20" fill="#b6a8ff"/><rect x="93" y="68" width="20" height="20" fill="#b6a8ff"/><rect x="118" y="68" width="20" height="20" fill="#b6a8ff"/><rect x="68" y="93" width="20" height="20" fill="#b6a8ff"/><rect x="118" y="93" width="20" height="20" fill="#b6a8ff"/><rect x="118" y="118" width="20" height="20" fill="#b6a8ff"/><rect x="118" y="143" width="20" height="20" fill="#b6a8ff"/><rect x="118" y="168" width="20" height="20" fill="#b6a8ff"/><rect x="68" y="193" width="20" height="20" fill="#b6a8ff"/><rect x="93" y="193" width="20" height="20" fill="#b6a8ff"/><rect x="118" y="193" width="20" height="20" fill="#b6a8ff"/><rect x="143" y="193" width="20" height="20" fill="#b6a8ff"/><rect x="168" y="193" width="20" height="20" fill="#b6a8ff"/></svg>
+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#0f0f11"/><g transform="translate(0.7 0.5)"><rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="#b6a8ff" fill-opacity="0.35"/><rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="#b6a8ff" fill-opacity="0.65"/><rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="#ededef"/><path d="M21.7 7.2 17.6 10.9" fill="none" stroke="#ededef" stroke-width="4.6" stroke-linecap="round"/></g></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" shape-rendering="crispEdges"><rect width="1024" height="1024" fill="#0f0f11"/><rect x="472" y="172" width="80" height="80" fill="#b6a8ff"/><rect x="372" y="272" width="80" height="80" fill="#b6a8ff"/><rect x="472" y="272" width="80" height="80" fill="#b6a8ff"/><rect x="272" y="372" width="80" height="80" fill="#b6a8ff"/><rect x="472" y="372" width="80" height="80" fill="#b6a8ff"/><rect x="472" y="472" width="80" height="80" fill="#b6a8ff"/><rect x="472" y="572" width="80" height="80" fill="#b6a8ff"/><rect x="472" y="672" width="80" height="80" fill="#b6a8ff"/><rect x="272" y="772" width="80" height="80" fill="#b6a8ff"/><rect x="372" y="772" width="80" height="80" fill="#b6a8ff"/><rect x="472" y="772" width="80" height="80" fill="#b6a8ff"/><rect x="572" y="772" width="80" height="80" fill="#b6a8ff"/><rect x="672" y="772" width="80" height="80" fill="#b6a8ff"/></svg>
+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352.19 214.04"><rect width="352.19" height="214.04" rx="0" fill="#0f0f11"/><g transform="translate(37.23 43.65) scale(3.3)"><g transform="translate(0.7 0.5)"><rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="#b6a8ff" fill-opacity="0.35"/><rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="#b6a8ff" fill-opacity="0.65"/><rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="#ededef"/><path d="M21.7 7.2 17.6 10.9" fill="none" stroke="#ededef" stroke-width="4.6" stroke-linecap="round"/></g></g><path transform="translate(161.08 132.75)" fill="#ededef" d="M30.42 21.29Q22.8 21.29 17.5 19.26Q12.21 17.24 9.16 13.67Q6.1 10.11 5.22 5.42L20.75 4.1Q21.19 5.66 22.46 6.86Q23.73 8.06 25.71 8.74Q27.69 9.42 30.37 9.42Q35.3 9.42 37.89 6.93Q40.48 4.44 40.48 -0.59V-9.28H39.89Q38.72 -6.54 36.65 -4.57Q34.57 -2.59 31.67 -1.56Q28.76 -0.54 25.1 -0.54Q18.7 -0.54 13.7 -3.64Q8.69 -6.74 5.83 -12.6Q2.98 -18.46 2.98 -26.76Q2.98 -35.3 5.91 -41.36Q8.84 -47.41 13.82 -50.63Q18.8 -53.86 25 -53.86Q28.81 -53.86 31.71 -52.66Q34.62 -51.46 36.77 -49.41Q38.92 -47.36 40.23 -44.73H40.48V-52.93H57.23V-2.1Q57.23 5.91 53.91 11.08Q50.59 16.26 44.56 18.77Q38.53 21.29 30.42 21.29ZM30.32 -13.38Q33.54 -13.38 35.89 -15.01Q38.23 -16.65 39.53 -19.7Q40.82 -22.75 40.82 -26.95Q40.82 -31.15 39.53 -34.23Q38.23 -37.3 35.89 -38.99Q33.54 -40.67 30.32 -40.67Q27.15 -40.67 24.88 -38.99Q22.61 -37.3 21.41 -34.23Q20.21 -31.15 20.21 -26.95Q20.21 -22.71 21.41 -19.65Q22.61 -16.6 24.88 -14.99Q27.15 -13.38 30.32 -13.38ZM94.12 -72.75V0H77.08V-57.37H76.84L60.33 -46.53V-61.57L77.47 -72.75ZM130.48 -52.93V-40.09H95.18V-52.93ZM103.28 -66.5H120.22V-17.04Q120.22 -14.75 121.23 -13.75Q122.23 -12.74 124.81 -12.74Q125.89 -12.74 127.62 -12.84Q129.36 -12.94 130.14 -13.04L131.11 -0.29Q129.11 0.1 126.43 0.22Q123.74 0.34 121.1 0.34Q112.07 0.34 107.68 -3.42Q103.28 -7.18 103.28 -14.79Z"/></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1056 642" shape-rendering="crispEdges"><rect width="1056" height="642" fill="#0f0f11"/><rect x="272" y="185" width="32" height="32" fill="#ededef"/><rect x="312" y="185" width="32" height="32" fill="#ededef"/><rect x="352" y="185" width="32" height="32" fill="#ededef"/><rect x="232" y="225" width="32" height="32" fill="#ededef"/><rect x="392" y="225" width="32" height="32" fill="#ededef"/><rect x="232" y="265" width="32" height="32" fill="#ededef"/><rect x="232" y="305" width="32" height="32" fill="#ededef"/><rect x="312" y="305" width="32" height="32" fill="#ededef"/><rect x="352" y="305" width="32" height="32" fill="#ededef"/><rect x="392" y="305" width="32" height="32" fill="#ededef"/><rect x="232" y="345" width="32" height="32" fill="#ededef"/><rect x="392" y="345" width="32" height="32" fill="#ededef"/><rect x="232" y="385" width="32" height="32" fill="#ededef"/><rect x="392" y="385" width="32" height="32" fill="#ededef"/><rect x="272" y="425" width="32" height="32" fill="#ededef"/><rect x="312" y="425" width="32" height="32" fill="#ededef"/><rect x="352" y="425" width="32" height="32" fill="#ededef"/><rect x="552" y="185" width="32" height="32" fill="#b6a8ff"/><rect x="512" y="225" width="32" height="32" fill="#b6a8ff"/><rect x="552" y="225" width="32" height="32" fill="#b6a8ff"/><rect x="472" y="265" width="32" height="32" fill="#b6a8ff"/><rect x="552" y="265" width="32" height="32" fill="#b6a8ff"/><rect x="552" y="305" width="32" height="32" fill="#b6a8ff"/><rect x="552" y="345" width="32" height="32" fill="#b6a8ff"/><rect x="552" y="385" width="32" height="32" fill="#b6a8ff"/><rect x="472" y="425" width="32" height="32" fill="#b6a8ff"/><rect x="512" y="425" width="32" height="32" fill="#b6a8ff"/><rect x="552" y="425" width="32" height="32" fill="#b6a8ff"/><rect x="592" y="425" width="32" height="32" fill="#b6a8ff"/><rect x="632" y="425" width="32" height="32" fill="#b6a8ff"/><rect x="632" y="185" width="32" height="32" fill="#ededef"/><rect x="672" y="185" width="32" height="32" fill="#ededef"/><rect x="712" y="185" width="32" height="32" fill="#ededef"/><rect x="752" y="185" width="32" height="32" fill="#ededef"/><rect x="792" y="185" width="32" height="32" fill="#ededef"/><rect x="712" y="225" width="32" height="32" fill="#ededef"/><rect x="712" y="265" width="32" height="32" fill="#ededef"/><rect x="712" y="305" width="32" height="32" fill="#ededef"/><rect x="712" y="345" width="32" height="32" fill="#ededef"/><rect x="712" y="385" width="32" height="32" fill="#ededef"/><rect x="712" y="425" width="32" height="32" fill="#ededef"/></svg>
+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352.19 214.04"><rect width="352.19" height="214.04" rx="0" fill="#ffffff"/><g transform="translate(37.23 43.65) scale(3.3)"><g transform="translate(0.7 0.5)"><rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="#6b56e8" fill-opacity="0.4"/><rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="#6b56e8" fill-opacity="0.75"/><rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="#161618"/><path d="M21.7 7.2 17.6 10.9" fill="none" stroke="#161618" stroke-width="4.6" stroke-linecap="round"/></g></g><path transform="translate(161.08 132.75)" fill="#161618" d="M30.42 21.29Q22.8 21.29 17.5 19.26Q12.21 17.24 9.16 13.67Q6.1 10.11 5.22 5.42L20.75 4.1Q21.19 5.66 22.46 6.86Q23.73 8.06 25.71 8.74Q27.69 9.42 30.37 9.42Q35.3 9.42 37.89 6.93Q40.48 4.44 40.48 -0.59V-9.28H39.89Q38.72 -6.54 36.65 -4.57Q34.57 -2.59 31.67 -1.56Q28.76 -0.54 25.1 -0.54Q18.7 -0.54 13.7 -3.64Q8.69 -6.74 5.83 -12.6Q2.98 -18.46 2.98 -26.76Q2.98 -35.3 5.91 -41.36Q8.84 -47.41 13.82 -50.63Q18.8 -53.86 25 -53.86Q28.81 -53.86 31.71 -52.66Q34.62 -51.46 36.77 -49.41Q38.92 -47.36 40.23 -44.73H40.48V-52.93H57.23V-2.1Q57.23 5.91 53.91 11.08Q50.59 16.26 44.56 18.77Q38.53 21.29 30.42 21.29ZM30.32 -13.38Q33.54 -13.38 35.89 -15.01Q38.23 -16.65 39.53 -19.7Q40.82 -22.75 40.82 -26.95Q40.82 -31.15 39.53 -34.23Q38.23 -37.3 35.89 -38.99Q33.54 -40.67 30.32 -40.67Q27.15 -40.67 24.88 -38.99Q22.61 -37.3 21.41 -34.23Q20.21 -31.15 20.21 -26.95Q20.21 -22.71 21.41 -19.65Q22.61 -16.6 24.88 -14.99Q27.15 -13.38 30.32 -13.38ZM94.12 -72.75V0H77.08V-57.37H76.84L60.33 -46.53V-61.57L77.47 -72.75ZM130.48 -52.93V-40.09H95.18V-52.93ZM103.28 -66.5H120.22V-17.04Q120.22 -14.75 121.23 -13.75Q122.23 -12.74 124.81 -12.74Q125.89 -12.74 127.62 -12.84Q129.36 -12.94 130.14 -13.04L131.11 -0.29Q129.11 0.1 126.43 0.22Q123.74 0.34 121.1 0.34Q112.07 0.34 107.68 -3.42Q103.28 -7.18 103.28 -14.79Z"/></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1056 642" shape-rendering="crispEdges"><rect width="1056" height="642" fill="#ffffff"/><rect x="272" y="185" width="32" height="32" fill="#161618"/><rect x="312" y="185" width="32" height="32" fill="#161618"/><rect x="352" y="185" width="32" height="32" fill="#161618"/><rect x="232" y="225" width="32" height="32" fill="#161618"/><rect x="392" y="225" width="32" height="32" fill="#161618"/><rect x="232" y="265" width="32" height="32" fill="#161618"/><rect x="232" y="305" width="32" height="32" fill="#161618"/><rect x="312" y="305" width="32" height="32" fill="#161618"/><rect x="352" y="305" width="32" height="32" fill="#161618"/><rect x="392" y="305" width="32" height="32" fill="#161618"/><rect x="232" y="345" width="32" height="32" fill="#161618"/><rect x="392" y="345" width="32" height="32" fill="#161618"/><rect x="232" y="385" width="32" height="32" fill="#161618"/><rect x="392" y="385" width="32" height="32" fill="#161618"/><rect x="272" y="425" width="32" height="32" fill="#161618"/><rect x="312" y="425" width="32" height="32" fill="#161618"/><rect x="352" y="425" width="32" height="32" fill="#161618"/><rect x="552" y="185" width="32" height="32" fill="#6b56e8"/><rect x="512" y="225" width="32" height="32" fill="#6b56e8"/><rect x="552" y="225" width="32" height="32" fill="#6b56e8"/><rect x="472" y="265" width="32" height="32" fill="#6b56e8"/><rect x="552" y="265" width="32" height="32" fill="#6b56e8"/><rect x="552" y="305" width="32" height="32" fill="#6b56e8"/><rect x="552" y="345" width="32" height="32" fill="#6b56e8"/><rect x="552" y="385" width="32" height="32" fill="#6b56e8"/><rect x="472" y="425" width="32" height="32" fill="#6b56e8"/><rect x="512" y="425" width="32" height="32" fill="#6b56e8"/><rect x="552" y="425" width="32" height="32" fill="#6b56e8"/><rect x="592" y="425" width="32" height="32" fill="#6b56e8"/><rect x="632" y="425" width="32" height="32" fill="#6b56e8"/><rect x="632" y="185" width="32" height="32" fill="#161618"/><rect x="672" y="185" width="32" height="32" fill="#161618"/><rect x="712" y="185" width="32" height="32" fill="#161618"/><rect x="752" y="185" width="32" height="32" fill="#161618"/><rect x="792" y="185" width="32" height="32" fill="#161618"/><rect x="712" y="225" width="32" height="32" fill="#161618"/><rect x="712" y="265" width="32" height="32" fill="#161618"/><rect x="712" y="305" width="32" height="32" fill="#161618"/><rect x="712" y="345" width="32" height="32" fill="#161618"/><rect x="712" y="385" width="32" height="32" fill="#161618"/><rect x="712" y="425" width="32" height="32" fill="#161618"/></svg>
+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 232.19 94.04"><g transform="translate(-22.77 -16.35) scale(3.3)"><g transform="translate(0.7 0.5)"><rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="#6b56e8" fill-opacity="0.4"/><rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="#6b56e8" fill-opacity="0.75"/><rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="#161618"/><path d="M21.7 7.2 17.6 10.9" fill="none" stroke="#161618" stroke-width="4.6" stroke-linecap="round"/></g></g><path transform="translate(101.08 72.75)" fill="#161618" d="M30.42 21.29Q22.8 21.29 17.5 19.26Q12.21 17.24 9.16 13.67Q6.1 10.11 5.22 5.42L20.75 4.1Q21.19 5.66 22.46 6.86Q23.73 8.06 25.71 8.74Q27.69 9.42 30.37 9.42Q35.3 9.42 37.89 6.93Q40.48 4.44 40.48 -0.59V-9.28H39.89Q38.72 -6.54 36.65 -4.57Q34.57 -2.59 31.67 -1.56Q28.76 -0.54 25.1 -0.54Q18.7 -0.54 13.7 -3.64Q8.69 -6.74 5.83 -12.6Q2.98 -18.46 2.98 -26.76Q2.98 -35.3 5.91 -41.36Q8.84 -47.41 13.82 -50.63Q18.8 -53.86 25 -53.86Q28.81 -53.86 31.71 -52.66Q34.62 -51.46 36.77 -49.41Q38.92 -47.36 40.23 -44.73H40.48V-52.93H57.23V-2.1Q57.23 5.91 53.91 11.08Q50.59 16.26 44.56 18.77Q38.53 21.29 30.42 21.29ZM30.32 -13.38Q33.54 -13.38 35.89 -15.01Q38.23 -16.65 39.53 -19.7Q40.82 -22.75 40.82 -26.95Q40.82 -31.15 39.53 -34.23Q38.23 -37.3 35.89 -38.99Q33.54 -40.67 30.32 -40.67Q27.15 -40.67 24.88 -38.99Q22.61 -37.3 21.41 -34.23Q20.21 -31.15 20.21 -26.95Q20.21 -22.71 21.41 -19.65Q22.61 -16.6 24.88 -14.99Q27.15 -13.38 30.32 -13.38ZM94.12 -72.75V0H77.08V-57.37H76.84L60.33 -46.53V-61.57L77.47 -72.75ZM130.48 -52.93V-40.09H95.18V-52.93ZM103.28 -66.5H120.22V-17.04Q120.22 -14.75 121.23 -13.75Q122.23 -12.74 124.81 -12.74Q125.89 -12.74 127.62 -12.84Q129.36 -12.94 130.14 -13.04L131.11 -0.29Q129.11 0.1 126.43 0.22Q123.74 0.34 121.1 0.34Q112.07 0.34 107.68 -3.42Q103.28 -7.18 103.28 -14.79Z"/></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1 148 68"><rect x="11" y="1" width="8" height="8" fill="#161618"/><rect x="21" y="1" width="8" height="8" fill="#161618"/><rect x="31" y="1" width="8" height="8" fill="#161618"/><rect x="1" y="11" width="8" height="8" fill="#161618"/><rect x="41" y="11" width="8" height="8" fill="#161618"/><rect x="1" y="21" width="8" height="8" fill="#161618"/><rect x="1" y="31" width="8" height="8" fill="#161618"/><rect x="21" y="31" width="8" height="8" fill="#161618"/><rect x="31" y="31" width="8" height="8" fill="#161618"/><rect x="41" y="31" width="8" height="8" fill="#161618"/><rect x="1" y="41" width="8" height="8" fill="#161618"/><rect x="41" y="41" width="8" height="8" fill="#161618"/><rect x="1" y="51" width="8" height="8" fill="#161618"/><rect x="41" y="51" width="8" height="8" fill="#161618"/><rect x="11" y="61" width="8" height="8" fill="#161618"/><rect x="21" y="61" width="8" height="8" fill="#161618"/><rect x="31" y="61" width="8" height="8" fill="#161618"/><rect x="81" y="1" width="8" height="8" fill="#6b56e8"/><rect x="71" y="11" width="8" height="8" fill="#6b56e8"/><rect x="81" y="11" width="8" height="8" fill="#6b56e8"/><rect x="61" y="21" width="8" height="8" fill="#6b56e8"/><rect x="81" y="21" width="8" height="8" fill="#6b56e8"/><rect x="81" y="31" width="8" height="8" fill="#6b56e8"/><rect x="81" y="41" width="8" height="8" fill="#6b56e8"/><rect x="81" y="51" width="8" height="8" fill="#6b56e8"/><rect x="61" y="61" width="8" height="8" fill="#6b56e8"/><rect x="71" y="61" width="8" height="8" fill="#6b56e8"/><rect x="81" y="61" width="8" height="8" fill="#6b56e8"/><rect x="91" y="61" width="8" height="8" fill="#6b56e8"/><rect x="101" y="61" width="8" height="8" fill="#6b56e8"/><rect x="101" y="1" width="8" height="8" fill="#161618"/><rect x="111" y="1" width="8" height="8" fill="#161618"/><rect x="121" y="1" width="8" height="8" fill="#161618"/><rect x="131" y="1" width="8" height="8" fill="#161618"/><rect x="141" y="1" width="8" height="8" fill="#161618"/><rect x="121" y="11" width="8" height="8" fill="#161618"/><rect x="121" y="21" width="8" height="8" fill="#161618"/><rect x="121" y="31" width="8" height="8" fill="#161618"/><rect x="121" y="41" width="8" height="8" fill="#161618"/><rect x="121" y="51" width="8" height="8" fill="#161618"/><rect x="121" y="61" width="8" height="8" fill="#161618"/></svg>
+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 232.19 94.04"><g transform="translate(-22.77 -16.35) scale(3.3)"><g transform="translate(0.7 0.5)"><rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="#b6a8ff" fill-opacity="0.35"/><rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="#b6a8ff" fill-opacity="0.65"/><rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="#ededef"/><path d="M21.7 7.2 17.6 10.9" fill="none" stroke="#ededef" stroke-width="4.6" stroke-linecap="round"/></g></g><path transform="translate(101.08 72.75)" fill="#ededef" d="M30.42 21.29Q22.8 21.29 17.5 19.26Q12.21 17.24 9.16 13.67Q6.1 10.11 5.22 5.42L20.75 4.1Q21.19 5.66 22.46 6.86Q23.73 8.06 25.71 8.74Q27.69 9.42 30.37 9.42Q35.3 9.42 37.89 6.93Q40.48 4.44 40.48 -0.59V-9.28H39.89Q38.72 -6.54 36.65 -4.57Q34.57 -2.59 31.67 -1.56Q28.76 -0.54 25.1 -0.54Q18.7 -0.54 13.7 -3.64Q8.69 -6.74 5.83 -12.6Q2.98 -18.46 2.98 -26.76Q2.98 -35.3 5.91 -41.36Q8.84 -47.41 13.82 -50.63Q18.8 -53.86 25 -53.86Q28.81 -53.86 31.71 -52.66Q34.62 -51.46 36.77 -49.41Q38.92 -47.36 40.23 -44.73H40.48V-52.93H57.23V-2.1Q57.23 5.91 53.91 11.08Q50.59 16.26 44.56 18.77Q38.53 21.29 30.42 21.29ZM30.32 -13.38Q33.54 -13.38 35.89 -15.01Q38.23 -16.65 39.53 -19.7Q40.82 -22.75 40.82 -26.95Q40.82 -31.15 39.53 -34.23Q38.23 -37.3 35.89 -38.99Q33.54 -40.67 30.32 -40.67Q27.15 -40.67 24.88 -38.99Q22.61 -37.3 21.41 -34.23Q20.21 -31.15 20.21 -26.95Q20.21 -22.71 21.41 -19.65Q22.61 -16.6 24.88 -14.99Q27.15 -13.38 30.32 -13.38ZM94.12 -72.75V0H77.08V-57.37H76.84L60.33 -46.53V-61.57L77.47 -72.75ZM130.48 -52.93V-40.09H95.18V-52.93ZM103.28 -66.5H120.22V-17.04Q120.22 -14.75 121.23 -13.75Q122.23 -12.74 124.81 -12.74Q125.89 -12.74 127.62 -12.84Q129.36 -12.94 130.14 -13.04L131.11 -0.29Q129.11 0.1 126.43 0.22Q123.74 0.34 121.1 0.34Q112.07 0.34 107.68 -3.42Q103.28 -7.18 103.28 -14.79Z"/></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1 148 68"><rect x="11" y="1" width="8" height="8" fill="#ededef"/><rect x="21" y="1" width="8" height="8" fill="#ededef"/><rect x="31" y="1" width="8" height="8" fill="#ededef"/><rect x="1" y="11" width="8" height="8" fill="#ededef"/><rect x="41" y="11" width="8" height="8" fill="#ededef"/><rect x="1" y="21" width="8" height="8" fill="#ededef"/><rect x="1" y="31" width="8" height="8" fill="#ededef"/><rect x="21" y="31" width="8" height="8" fill="#ededef"/><rect x="31" y="31" width="8" height="8" fill="#ededef"/><rect x="41" y="31" width="8" height="8" fill="#ededef"/><rect x="1" y="41" width="8" height="8" fill="#ededef"/><rect x="41" y="41" width="8" height="8" fill="#ededef"/><rect x="1" y="51" width="8" height="8" fill="#ededef"/><rect x="41" y="51" width="8" height="8" fill="#ededef"/><rect x="11" y="61" width="8" height="8" fill="#ededef"/><rect x="21" y="61" width="8" height="8" fill="#ededef"/><rect x="31" y="61" width="8" height="8" fill="#ededef"/><rect x="81" y="1" width="8" height="8" fill="#b6a8ff"/><rect x="71" y="11" width="8" height="8" fill="#b6a8ff"/><rect x="81" y="11" width="8" height="8" fill="#b6a8ff"/><rect x="61" y="21" width="8" height="8" fill="#b6a8ff"/><rect x="81" y="21" width="8" height="8" fill="#b6a8ff"/><rect x="81" y="31" width="8" height="8" fill="#b6a8ff"/><rect x="81" y="41" width="8" height="8" fill="#b6a8ff"/><rect x="81" y="51" width="8" height="8" fill="#b6a8ff"/><rect x="61" y="61" width="8" height="8" fill="#b6a8ff"/><rect x="71" y="61" width="8" height="8" fill="#b6a8ff"/><rect x="81" y="61" width="8" height="8" fill="#b6a8ff"/><rect x="91" y="61" width="8" height="8" fill="#b6a8ff"/><rect x="101" y="61" width="8" height="8" fill="#b6a8ff"/><rect x="101" y="1" width="8" height="8" fill="#ededef"/><rect x="111" y="1" width="8" height="8" fill="#ededef"/><rect x="121" y="1" width="8" height="8" fill="#ededef"/><rect x="131" y="1" width="8" height="8" fill="#ededef"/><rect x="141" y="1" width="8" height="8" fill="#ededef"/><rect x="121" y="11" width="8" height="8" fill="#ededef"/><rect x="121" y="21" width="8" height="8" fill="#ededef"/><rect x="121" y="31" width="8" height="8" fill="#ededef"/><rect x="121" y="41" width="8" height="8" fill="#ededef"/><rect x="121" y="51" width="8" height="8" fill="#ededef"/><rect x="121" y="61" width="8" height="8" fill="#ededef"/></svg>
+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><g transform="translate(0.7 0.5)"><rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="#6b56e8" fill-opacity="0.4"/><rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="#6b56e8" fill-opacity="0.75"/><rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="#161618"/><path d="M21.7 7.2 17.6 10.9" fill="none" stroke="#161618" stroke-width="4.6" stroke-linecap="round"/></g></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect x="36" y="6" width="8" height="8" fill="#6b56e8"/><rect x="26" y="16" width="8" height="8" fill="#6b56e8"/><rect x="36" y="16" width="8" height="8" fill="#6b56e8"/><rect x="16" y="26" width="8" height="8" fill="#6b56e8"/><rect x="36" y="26" width="8" height="8" fill="#6b56e8"/><rect x="36" y="36" width="8" height="8" fill="#6b56e8"/><rect x="36" y="46" width="8" height="8" fill="#6b56e8"/><rect x="36" y="56" width="8" height="8" fill="#6b56e8"/><rect x="16" y="66" width="8" height="8" fill="#6b56e8"/><rect x="26" y="66" width="8" height="8" fill="#6b56e8"/><rect x="36" y="66" width="8" height="8" fill="#6b56e8"/><rect x="46" y="66" width="8" height="8" fill="#6b56e8"/><rect x="56" y="66" width="8" height="8" fill="#6b56e8"/></svg>
+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><mask id="cut" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><path d="M10.5 3.5 8.5 5.4" stroke="#000" stroke-width="3.6" stroke-linecap="round"/></mask><g mask="url(#cut)"><rect x="3" y="5.5" width="2" height="8.5" rx="1" fill="#b6a8ff" fill-opacity="0.5"/><rect x="6" y="4" width="2" height="10" rx="1" fill="#b6a8ff" fill-opacity="0.85"/></g><rect x="9" y="2" width="3" height="12" rx="1.5" fill="#ededef"/><path d="M10.5 3.5 8.5 5.4" fill="none" stroke="#ededef" stroke-width="2.2" stroke-linecap="round"/></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect x="7" y="1" width="2" height="2" fill="#b6a8ff"/><rect x="5" y="3" width="2" height="2" fill="#b6a8ff"/><rect x="7" y="3" width="2" height="2" fill="#b6a8ff"/><rect x="3" y="5" width="2" height="2" fill="#b6a8ff"/><rect x="7" y="5" width="2" height="2" fill="#b6a8ff"/><rect x="7" y="7" width="2" height="2" fill="#b6a8ff"/><rect x="7" y="9" width="2" height="2" fill="#b6a8ff"/><rect x="7" y="11" width="2" height="2" fill="#b6a8ff"/><rect x="3" y="13" width="2" height="2" fill="#b6a8ff"/><rect x="5" y="13" width="2" height="2" fill="#b6a8ff"/><rect x="7" y="13" width="2" height="2" fill="#b6a8ff"/><rect x="9" y="13" width="2" height="2" fill="#b6a8ff"/><rect x="11" y="13" width="2" height="2" fill="#b6a8ff"/></svg>
+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><g transform="translate(0.7 0.5)"><rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="#b6a8ff" fill-opacity="0.35"/><rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="#b6a8ff" fill-opacity="0.65"/><rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="#ededef"/><path d="M21.7 7.2 17.6 10.9" fill="none" stroke="#ededef" stroke-width="4.6" stroke-linecap="round"/></g></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect x="36" y="6" width="8" height="8" fill="#b6a8ff"/><rect x="26" y="16" width="8" height="8" fill="#b6a8ff"/><rect x="36" y="16" width="8" height="8" fill="#b6a8ff"/><rect x="16" y="26" width="8" height="8" fill="#b6a8ff"/><rect x="36" y="26" width="8" height="8" fill="#b6a8ff"/><rect x="36" y="36" width="8" height="8" fill="#b6a8ff"/><rect x="36" y="46" width="8" height="8" fill="#b6a8ff"/><rect x="36" y="56" width="8" height="8" fill="#b6a8ff"/><rect x="16" y="66" width="8" height="8" fill="#b6a8ff"/><rect x="26" y="66" width="8" height="8" fill="#b6a8ff"/><rect x="36" y="66" width="8" height="8" fill="#b6a8ff"/><rect x="46" y="66" width="8" height="8" fill="#b6a8ff"/><rect x="56" y="66" width="8" height="8" fill="#b6a8ff"/></svg>
+1−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 transform="translate(379.41 160.66) scale(1.9)"><g transform="translate(-22.77 -16.35) scale(3.3)"><g transform="translate(0.7 0.5)"><rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="#b6a8ff" fill-opacity="0.35"/><rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="#b6a8ff" fill-opacity="0.65"/><rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="#ededef"/><path d="M21.7 7.2 17.6 10.9" fill="none" stroke="#ededef" stroke-width="4.6" stroke-linecap="round"/></g></g><path transform="translate(101.08 72.75)" fill="#ededef" d="M30.42 21.29Q22.8 21.29 17.5 19.26Q12.21 17.24 9.16 13.67Q6.1 10.11 5.22 5.42L20.75 4.1Q21.19 5.66 22.46 6.86Q23.73 8.06 25.71 8.74Q27.69 9.42 30.37 9.42Q35.3 9.42 37.89 6.93Q40.48 4.44 40.48 -0.59V-9.28H39.89Q38.72 -6.54 36.65 -4.57Q34.57 -2.59 31.67 -1.56Q28.76 -0.54 25.1 -0.54Q18.7 -0.54 13.7 -3.64Q8.69 -6.74 5.83 -12.6Q2.98 -18.46 2.98 -26.76Q2.98 -35.3 5.91 -41.36Q8.84 -47.41 13.82 -50.63Q18.8 -53.86 25 -53.86Q28.81 -53.86 31.71 -52.66Q34.62 -51.46 36.77 -49.41Q38.92 -47.36 40.23 -44.73H40.48V-52.93H57.23V-2.1Q57.23 5.91 53.91 11.08Q50.59 16.26 44.56 18.77Q38.53 21.29 30.42 21.29ZM30.32 -13.38Q33.54 -13.38 35.89 -15.01Q38.23 -16.65 39.53 -19.7Q40.82 -22.75 40.82 -26.95Q40.82 -31.15 39.53 -34.23Q38.23 -37.3 35.89 -38.99Q33.54 -40.67 30.32 -40.67Q27.15 -40.67 24.88 -38.99Q22.61 -37.3 21.41 -34.23Q20.21 -31.15 20.21 -26.95Q20.21 -22.71 21.41 -19.65Q22.61 -16.6 24.88 -14.99Q27.15 -13.38 30.32 -13.38ZM94.12 -72.75V0H77.08V-57.37H76.84L60.33 -46.53V-61.57L77.47 -72.75ZM130.48 -52.93V-40.09H95.18V-52.93ZM103.28 -66.5H120.22V-17.04Q120.22 -14.75 121.23 -13.75Q122.23 -12.74 124.81 -12.74Q125.89 -12.74 127.62 -12.84Q129.36 -12.94 130.14 -13.04L131.11 -0.29Q129.11 0.1 126.43 0.22Q123.74 0.34 121.1 0.34Q112.07 0.34 107.68 -3.42Q103.28 -7.18 103.28 -14.79Z"/></g><text x="600" y="470" text-anchor="middle" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="40" font-weight="500" fill="#a0a0a8" letter-spacing="-0.5">A git forge for agents</text><text x="600" y="560" text-anchor="middle" font-family="JetBrains Mono, ui-monospace, Consolas, monospace" font-size="26" fill="#b6a8ff">g1t.sh</text></svg>
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>
+17−1
11 # Contributing to g1t
22
33 g1t is built the way it asks others to build: issues and pull requests on
4−[g1t.sh/syntaqx/g1t](https://g1t.sh/syntaqx/g1t), checks that prove a change
4+[g1t.sh/flagon-io/g1t](https://g1t.sh/flagon-io/g1t), checks that prove a change
55 done, and a merge queue that keeps `main` passing.
66
77 ## A change ships with its docs
2929 cd apps/docs && npm run build
3030 ```
3131
32+## Icons
33+
34+- **Interface icons come from [Lucide](https://lucide.dev/icons).** In the
35+ apps, import them from `lucide-react`. In the docs, use `@lucide/astro`:
36+ import an icon component, or give `apps/docs/src/components/Card.astro`
37+ and `Aside.astro` a Lucide name such as `icon="git-branch"`. Starlight's
38+ own `<Card>`, `<LinkCard>` and `<Aside>` draw Starlight's icon set, so the
39+ docs import the wrappers in `apps/docs/src/components/` instead.
40+- **Brand marks come from [Simple Icons](https://simpleicons.org)**
41+ (`simple-icons`), and follow each brand's own usage guidelines. Add the
42+ dependency with the first one you show.
43+- **The g1t logo and illustrations are our own artwork**, in
44+ `apps/web/app/components/logo.tsx` and `art.tsx`.
45+- Never hand-copy an icon's SVG paths into a file. If Lucide lacks the
46+ icon you need, pick the closest one that it has.
47+
3248 ## Before you push
3349
3450 - `cargo test` in the crate or service you changed.
+1−0
947947 "base64 0.22.1",
948948 "g1t-contracts",
949949 "g1t-kit",
950+ "g1t-secrets",
950951 "getrandom 0.2.17",
951952 "hex",
952953 "pbkdf2 0.12.2",
+1−1
55 [workspace.package]
66 edition = "2024"
77 license = "MIT"
8−repository = "https://g1t.sh/syntaqx/g1t"
8+repository = "https://g1t.sh/flagon-io/g1t"
99
1010 [workspace.dependencies]
1111 g1t-actions = { path = "crates/actions" }
+30−19
11 # g1t
22
3−Git for AI scale: a forge for thousands of agents working on the same code at
4−once, running on Cloudflare Workers and Artifacts.
5−
6−g1t has the issues and pull requests you already know. What changes is how
7−many there are. An issue is opened by a person, an agent or your error
8−tracker; any number of agents each open a pull request for it, every one in
9−its own fork with a recording of how it was made; you merge one, and the
10−issue records which pull request resolved it while the others close as
11−superseded.
12−
13−## Why this exists
14−
15−g1t is an entry in Cloudflare's **Build the Next-Gen Git Platform**
16−competition, which asks what a git platform looks like when most of the
17−people using it are agents.
3+The open-source git platform where people and agents ship software
4+together, from the first issue to production on the edge. It runs on
5+Cloudflare Workers and Artifacts.
186
19−- The challenge: <https://blog.cloudflare.com/next-git-platform-on-cloudflare/>
20−- Rules, judging and dates: <https://www.cloudflare.com/git-competition/>
7+- **Collaborate.** Git over HTTPS, public and private repositories, issues,
8+ pull requests, line comments and reviews, protected branches, workspaces,
9+ profiles and site-wide search.
10+- **Agents as teammates.** Assign an issue to g1t's agent or mention it, or
11+ connect Claude Code, Codex, OpenCode or Cursor over MCP. Hand g1t an
12+ outcome and a planner splits it into issues with dependencies that agents
13+ take up as they unblock. Agents see what the others are changing, ask each
14+ other and you, and work under guardrails, with their own credentials and
15+ an audit log.
16+- **Ship safely.** Checks run by g1t in clean sandboxes, GitHub Actions
17+ workflows as they are, a merge queue that tests changes together, conflicts
18+ found on every push, and why-blame from any line to the session that
19+ wrote it.
20+- **Run it.** A preview of every pull request and production on merge, on
21+ `g1t.page`, with custom domains. Apps nobody visits cost nothing.
22+- **Secure and healthy.** Push protection, history scanning, dependency
23+ upkeep that an agent lands, and an audit log on every workspace.
24+- **Open and fair.** MIT licensed and self-hostable (an early Docker Compose
25+ version of the core forge). The forge is free; compute is what it costs
26+ plus 20%, never per seat.
2127
22−Submissions close on October 14, 2026. [docs/PLAN.md](docs/PLAN.md) says how
23−g1t answers the brief and what is built so far.
28+g1t is made by Flagon, Inc. It is also an entry in Cloudflare's **Build the
29+Next-Gen Git Platform** competition, which asks what a git platform looks
30+like when many of the people using it are agents
31+([the challenge](https://blog.cloudflare.com/next-git-platform-on-cloudflare/),
32+[rules and dates](https://www.cloudflare.com/git-competition/)).
33+[docs/PLAN.md](docs/PLAN.md) says how g1t answers the brief and what is
34+built so far.
2435
2536 ## Where things are
2637
+12−7
157157 .map(|(name, value)| (name.to_lowercase(), value))
158158 .collect();
159159 let body = request.text().await.unwrap_or_default();
160− if body.len() > 1_000_000 {
160+ // A push to GitHub with many commits makes a large payload.
161+ let limit = if id == "github" { 10_000_000 } else { 1_000_000 };
162+ if body.len() > limit {
161163 return Ok(reply(&json!({ "message": "The body is too large." }))?.with_status(413));
162164 }
163− let received: g1t_contracts::integrations::Received = g1t_kit::call(
164− &services.integrations,
165− "receive",
166− &json!({ "id": id, "headers": headers, "body": body }),
167− )
168− .await?;
165+ // g1t's GitHub App has one webhook for every installation; it is
166+ // checked against the app's own secret.
167+ let (method, args) = if id == "github" {
168+ ("github_receive", json!({ "headers": headers, "body": body }))
169+ } else {
170+ ("receive", json!({ "id": id, "headers": headers, "body": body }))
171+ };
172+ let received: g1t_contracts::integrations::Received =
173+ g1t_kit::call(&services.integrations, method, &args).await?;
169174 Ok(reply(&json!({ "message": received.message }))?.with_status(received.status))
170175 }
171176
+91−13
1414 const SECTIONS: &[(&str, &str, &[Op])] = &[
1515 (
1616 "Accounts",
17− "Signing in from a tool, and who a token acts as.",
18− &[Op::Whoami],
17+ "Signing in from a tool, who a token acts as, and your email addresses.",
18+ &[Op::Whoami, Op::ListEmails, Op::AddEmail, Op::RemoveEmail, Op::UpdateEmailSettings],
1919 ),
2020 (
2121 "Workspaces",
2222 "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.",
23− &[Op::CreateWorkspace],
23+ &[Op::CreateWorkspace, Op::DeleteWorkspace],
24+ ),
25+ (
26+ "Invites",
27+ "While g1t is invite-only, every new account needs an invite. Your invites, and inviting people into a workspace by email.",
28+ &[
29+ Op::ListInvites,
30+ Op::CreateInvite,
31+ Op::RevokeInvite,
32+ Op::ListWorkspaceInvites,
33+ Op::InviteMember,
34+ Op::RevokeWorkspaceInvite,
35+ ],
2436 ),
2537 (
2638 "Repositories",
27− "A repository, how it handles pull requests, and its timeline.",
39+ "A repository, how it handles pull requests, and its timeline: renaming, archiving, moving and deleting it.",
2840 &[
2941 Op::ListRepos,
3042 Op::CreateRepo,
3143 Op::GetRepo,
3244 Op::UpdateRepo,
45+ Op::RenameRepo,
46+ Op::RenameBranch,
47+ Op::SetRepoVisibility,
48+ Op::ArchiveRepo,
49+ Op::UnarchiveRepo,
50+ Op::TransferRepo,
51+ Op::DeleteRepo,
52+ Op::ListDeletedRepos,
53+ Op::RestoreRepo,
54+ Op::PurgeRepo,
3355 Op::GetRepoSettings,
3456 Op::UpdateRepoSettings,
3557 Op::ListEvents,
3658 ],
3759 ),
3860 (
61+ "Access",
62+ "Who can do what in a repository: repository roles, people given a role on one repository (outside collaborators when they are not members), invitations, and a workspace's base permission.",
63+ &[
64+ Op::ListCollaborators,
65+ Op::AddCollaborator,
66+ Op::UpdateCollaborator,
67+ Op::RemoveCollaborator,
68+ Op::GetCollaboratorPermission,
69+ Op::ListRepoInvitations,
70+ Op::RevokeRepoInvitation,
71+ Op::ListMyRepoInvitations,
72+ Op::AcceptRepoInvitation,
73+ Op::DeclineRepoInvitation,
74+ Op::SetBasePermission,
75+ Op::ListOutsideCollaborators,
76+ ],
77+ ),
78+ (
3979 "Issues",
4080 "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.",
4181 &[
161201 match op {
162202 Op::Whoami => "Get the current user",
163203 Op::CreateWorkspace => "Create a workspace",
204+ Op::DeleteWorkspace => "Delete a workspace",
205+ Op::ListEmails => "List your email addresses",
206+ Op::AddEmail => "Add an email address",
207+ Op::RemoveEmail => "Remove an email address",
208+ Op::UpdateEmailSettings => "Change your email settings",
209+ Op::ListInvites => "List your invites",
210+ Op::CreateInvite => "Create an invite",
211+ Op::RevokeInvite => "Revoke an invite",
212+ Op::ListWorkspaceInvites => "List a workspace's invites",
213+ Op::InviteMember => "Invite someone to a workspace",
214+ Op::RevokeWorkspaceInvite => "Revoke a workspace's invite",
215+ Op::TransferRepo => "Transfer a repository",
216+ Op::RenameRepo => "Rename a repository",
217+ Op::RenameBranch => "Rename a branch",
218+ Op::ArchiveRepo => "Archive a repository",
219+ Op::UnarchiveRepo => "Unarchive a repository",
220+ Op::SetRepoVisibility => "Change a repository's visibility",
221+ Op::DeleteRepo => "Delete a repository",
222+ Op::ListDeletedRepos => "List recently deleted repositories",
223+ Op::RestoreRepo => "Restore a deleted repository",
224+ Op::PurgeRepo => "Purge a deleted repository",
164225 Op::ListRepos => "List repositories",
165226 Op::GetRepo => "Get a repository",
166227 Op::CreateRepo => "Create a repository",
228289 Op::ListActionsVariables => "List variables",
229290 Op::SetActionsVariable => "Set a variable",
230291 Op::DeleteActionsVariable => "Delete a variable",
292+ Op::ListCollaborators => "List who has access",
293+ Op::AddCollaborator => "Add a collaborator",
294+ Op::UpdateCollaborator => "Change a collaborator's role",
295+ Op::RemoveCollaborator => "Remove a collaborator",
296+ Op::GetCollaboratorPermission => "Get someone's permission",
297+ Op::ListRepoInvitations => "List a repository's invitations",
298+ Op::RevokeRepoInvitation => "Revoke a repository invitation",
299+ Op::ListMyRepoInvitations => "List your repository invitations",
300+ Op::AcceptRepoInvitation => "Accept a repository invitation",
301+ Op::DeclineRepoInvitation => "Decline a repository invitation",
302+ Op::SetBasePermission => "Set the base permission",
303+ Op::ListOutsideCollaborators => "List outside collaborators",
231304 }
232305 }
233306
234−/// Whether an operation can be refused with `402 payment_required`: the
235−/// ones that start an agent, when the workspace has no credit.
236−fn may_need_payment(op: Op) -> bool {
237− matches!(op, Op::AssignIssue | Op::PlanWork | Op::ApplyPlan)
307+/// Why an operation can be refused with `402 payment_required`, if it
308+/// can: the ones that start an agent, when the workspace has no credit,
309+/// and the ones that make a repository private in a workspace, when a free
310+/// workspace's private storage has no room for it.
311+fn may_need_payment(op: Op) -> Option<&'static str> {
312+ match op {
313+ Op::AssignIssue | Op::PlanWork | Op::ApplyPlan => Some("The workspace has no agent credit."),
314+ Op::UpdateRepo | Op::SetRepoVisibility | Op::TransferRepo => Some(
315+ "A free workspace's private storage has no room for this private repository.",
316+ ),
317+ _ => None,
318+ }
238319 }
239320
240321 /// What the reference says beyond each operation's own description, keyed
382463 "401".into(),
383464 error_response("A token is required, or the one sent is not valid."),
384465 );
385− if may_need_payment(op) {
386− responses.insert(
387− "402".into(),
388− error_response("The workspace has no agent credit."),
389− );
466+ if let Some(reason) = may_need_payment(op) {
467+ responses.insert("402".into(), error_response(reason));
390468 }
391469 responses.insert("403".into(), error_response("Signed in, but not allowed to do this."));
392470 if !matches!(op, Op::Whoami | Op::ListRepos | Op::Search) {
+898−28
44 //! [`Op`], so the surfaces cannot drift apart: adding a variant without
55 //! describing it or running it does not compile.
66
7+use g1t_contracts::access::{
8+ AddCollaboratorArgs, BasePermission, Capability, CollaboratorPermissionArgs, MyRepoInvitationsArgs,
9+ OutsideCollaboratorsArgs, RemoveCollaboratorArgs, RepoAccess, RepoAccessArgs, RepoInvitation, RepoRole,
10+ RespondRepoInvitationArgs, RevokeRepoInvitationArgs, SetBasePermissionArgs, SetCollaboratorRoleArgs,
11+};
712 use g1t_contracts::identity::AgentScope;
813 use g1t_contracts::events::{Event, ListArgs as ListEventsArgs};
914 use g1t_contracts::identity::CreateWorkspaceArgs;
6065 pub enum Op {
6166 Whoami,
6267 CreateWorkspace,
68+ DeleteWorkspace,
69+ ListEmails,
70+ AddEmail,
71+ RemoveEmail,
72+ UpdateEmailSettings,
73+ ListInvites,
74+ CreateInvite,
75+ RevokeInvite,
76+ ListWorkspaceInvites,
77+ InviteMember,
78+ RevokeWorkspaceInvite,
6379 ListRepos,
6480 GetRepo,
6581 CreateRepo,
6682 UpdateRepo,
83+ TransferRepo,
84+ RenameRepo,
85+ RenameBranch,
86+ ArchiveRepo,
87+ UnarchiveRepo,
88+ SetRepoVisibility,
89+ DeleteRepo,
90+ ListDeletedRepos,
91+ RestoreRepo,
92+ PurgeRepo,
6793 GetRepoSettings,
6894 UpdateRepoSettings,
6995 GetMergeQueue,
127153 ListActionsVariables,
128154 SetActionsVariable,
129155 DeleteActionsVariable,
156+ ListCollaborators,
157+ AddCollaborator,
158+ UpdateCollaborator,
159+ RemoveCollaborator,
160+ GetCollaboratorPermission,
161+ ListRepoInvitations,
162+ RevokeRepoInvitation,
163+ ListMyRepoInvitations,
164+ AcceptRepoInvitation,
165+ DeclineRepoInvitation,
166+ SetBasePermission,
167+ ListOutsideCollaborators,
130168 }
131169
132170 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
230268 }
231269
232270 fn workspace_schema() -> Value {
233− json!({ "type": "string", "description": "The workspace's slug, e.g. \"syntaqx\"." })
271+ json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." })
234272 }
235273
236274 /// An object's keys in `camelCase`, the way the services read them, from
294332 fn repo_schema() -> Value {
295333 json!({
296334 "type": "string",
297− "description": "Repository as \"owner/name\", e.g. \"syntaqx/hello\".",
335+ "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\".",
298336 })
299337 }
300338
339+fn username_schema() -> Value {
340+ json!({ "type": "string", "description": "The person's username." })
341+}
342+
343+/// A role on a repository, least first.
344+fn role_schema() -> Value {
345+ json!({
346+ "type": "string",
347+ "enum": RepoRole::ALL.map(RepoRole::as_str),
348+ "description": "read: read and comment. triage: also label, assign and close. write: also push, merge and put agents to work. maintain: also settings and branch protection. admin: everything, including who has access.",
349+ })
350+}
351+
301352 impl Op {
302− pub const ALL: [Op; 69] = [
353+ pub const ALL: [Op; 102] = [
303354 Op::Whoami,
304355 Op::CreateWorkspace,
356+ Op::DeleteWorkspace,
357+ Op::ListEmails,
358+ Op::AddEmail,
359+ Op::RemoveEmail,
360+ Op::UpdateEmailSettings,
361+ Op::ListInvites,
362+ Op::CreateInvite,
363+ Op::RevokeInvite,
364+ Op::ListWorkspaceInvites,
365+ Op::InviteMember,
366+ Op::RevokeWorkspaceInvite,
305367 Op::ListRepos,
306368 Op::GetRepo,
307369 Op::CreateRepo,
308370 Op::UpdateRepo,
371+ Op::TransferRepo,
372+ Op::RenameRepo,
373+ Op::RenameBranch,
374+ Op::ArchiveRepo,
375+ Op::UnarchiveRepo,
376+ Op::SetRepoVisibility,
377+ Op::DeleteRepo,
378+ Op::ListDeletedRepos,
379+ Op::RestoreRepo,
380+ Op::PurgeRepo,
309381 Op::GetRepoSettings,
310382 Op::UpdateRepoSettings,
311383 Op::GetMergeQueue,
369441 Op::ListActionsVariables,
370442 Op::SetActionsVariable,
371443 Op::DeleteActionsVariable,
444+ Op::ListCollaborators,
445+ Op::AddCollaborator,
446+ Op::UpdateCollaborator,
447+ Op::RemoveCollaborator,
448+ Op::GetCollaboratorPermission,
449+ Op::ListRepoInvitations,
450+ Op::RevokeRepoInvitation,
451+ Op::ListMyRepoInvitations,
452+ Op::AcceptRepoInvitation,
453+ Op::DeclineRepoInvitation,
454+ Op::SetBasePermission,
455+ Op::ListOutsideCollaborators,
372456 ];
373457
374458 pub fn by_name(name: &str) -> Option<Op> {
380464 match self {
381465 Op::Whoami => "whoami",
382466 Op::CreateWorkspace => "create_workspace",
467+ Op::DeleteWorkspace => "delete_workspace",
468+ Op::ListEmails => "list_emails",
469+ Op::AddEmail => "add_email",
470+ Op::RemoveEmail => "remove_email",
471+ Op::UpdateEmailSettings => "update_email_settings",
472+ Op::ListInvites => "list_invites",
473+ Op::CreateInvite => "create_invite",
474+ Op::RevokeInvite => "revoke_invite",
475+ Op::ListWorkspaceInvites => "list_workspace_invites",
476+ Op::InviteMember => "invite_member",
477+ Op::RevokeWorkspaceInvite => "revoke_workspace_invite",
383478 Op::ListRepos => "list_repos",
384479 Op::GetRepo => "get_repo",
385480 Op::CreateRepo => "create_repo",
386481 Op::UpdateRepo => "update_repo",
482+ Op::TransferRepo => "transfer_repo",
483+ Op::RenameRepo => "rename_repo",
484+ Op::RenameBranch => "rename_branch",
485+ Op::ArchiveRepo => "archive_repo",
486+ Op::UnarchiveRepo => "unarchive_repo",
487+ Op::SetRepoVisibility => "set_repo_visibility",
488+ Op::DeleteRepo => "delete_repo",
489+ Op::ListDeletedRepos => "list_deleted_repos",
490+ Op::RestoreRepo => "restore_repo",
491+ Op::PurgeRepo => "purge_repo",
387492 Op::GetRepoSettings => "get_repo_settings",
388493 Op::GetMergeQueue => "get_merge_queue",
389494 Op::MessageAgent => "message_agent",
447552 Op::ListActionsVariables => "list_actions_variables",
448553 Op::SetActionsVariable => "set_actions_variable",
449554 Op::DeleteActionsVariable => "delete_actions_variable",
555+ Op::ListCollaborators => "list_collaborators",
556+ Op::AddCollaborator => "add_collaborator",
557+ Op::UpdateCollaborator => "update_collaborator",
558+ Op::RemoveCollaborator => "remove_collaborator",
559+ Op::GetCollaboratorPermission => "get_collaborator_permission",
560+ Op::ListRepoInvitations => "list_repo_invitations",
561+ Op::RevokeRepoInvitation => "revoke_repo_invitation",
562+ Op::ListMyRepoInvitations => "list_my_repo_invitations",
563+ Op::AcceptRepoInvitation => "accept_repo_invitation",
564+ Op::DeclineRepoInvitation => "decline_repo_invitation",
565+ Op::SetBasePermission => "set_base_permission",
566+ Op::ListOutsideCollaborators => "list_outside_collaborators",
450567 }
451568 }
452569
458575 Op::CreateWorkspace => {
459576 "Create a workspace. A workspace owns repositories and is the first part of their address: g1t.sh/{workspace}/{repo}. The whoami tool lists the ones you already belong to."
460577 }
578+ Op::ListEmails => {
579+ "Your email addresses: each one's `email`, whether it is `verified` (confirmed), `primary` or the `backup`, and when it was added and confirmed. Also whether you keep your address private (`private_email`), your `noreply` address, and `commit_email`, the address on commits g1t makes for you. People only: an agent's or a workspace's token cannot read or change addresses."
580+ }
581+ Op::AddEmail => {
582+ "Add an email address to your account. g1t emails it a link to confirm it; until then it cannot be primary and does not sign you in. Adding an address you added before and have not confirmed sends the link again. An address another account has confirmed cannot be added. An account has at most 10. Needs your account `password`; your confirmed addresses are told. People only."
583+ }
584+ Op::RemoveEmail => {
585+ "Remove an email address from your account. Never your primary address (make another primary first) and never your last confirmed one. Needs your account `password`; every confirmed address, the removed one included, is told. People only."
586+ }
587+ Op::UpdateEmailSettings => {
588+ "Change what your addresses do; only the fields given change. `primary` is a confirmed address to make primary: account mail and password resets go there. `backup` is a confirmed address that also gets security notices, or an empty string for the primary only. Changing either needs your account `password`, and every confirmed address is told. `private_email` keeps your address off commits g1t makes for you (merges and changes made on the web, and agents' commits for you), which use your noreply address instead; `block_private_pushes` refuses pushes whose commits carry one of your addresses while it is private. People only."
589+ }
590+ Op::ListInvites => {
591+ "Your invites, newest first, and how many you have left. While g1t is invite-only, every new account needs an invite code. You may have 5 invites out at once: pending and used ones count, and one revoked or expired before it was used comes back. `allowance.limit` is null when you have no limit. `workspaces` lists the workspaces you own that were granted invites to share. A pending invite's `code` is shown to you; `status` is pending, redeemed, expired or revoked."
592+ }
593+ Op::CreateInvite => {
594+ "Make an invite. With `email`, it is sent there and only that address can use it; without, anyone with the code can, once. It works for 30 days. It uses one of your invites, or with `workspace`, one of the invites g1t granted that workspace (its owners only). Returns the invite with its `code`; the link is https://g1t.sh/invite/<code>. People only: an agent's token or a workspace's token cannot make invites."
595+ }
596+ Op::RevokeInvite => {
597+ "Revoke a pending invite you made, or one made for a workspace you own. It stops working at once, and the invite comes back to whoever it was charged to."
598+ }
599+ Op::ListWorkspaceInvites => {
600+ "The invites made for a workspace, newest first, with each pending one's `code`. Owners only."
601+ }
602+ Op::InviteMember => {
603+ "Invite an email address into a workspace. It always makes an invite bound to that address and emails it the link, so the answer never says whether the address has a g1t account. Without one, accepting makes the account and joins the workspace in one step, and uses one of the workspace's granted invites, or else one of yours. With one, it costs nothing, and they join when they accept. To add someone by username at once, use the workspace's People page. Owners only."
604+ }
605+ Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.",
606+ Op::DeleteWorkspace => {
607+ "Delete a workspace. Owners only, signed in as a person, and confirm must be the workspace's slug. It must hold no repositories (move them with transfer_repo first) and no projects, and billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once. Its members, access tokens, webhooks, integrations and workspace secrets are removed; its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again."
608+ }
461609 Op::ListRepos => "Repositories you can see, optionally filtered by a search query.",
462610 Op::GetRepo => "One repository's details.",
463611 Op::UpdateRepo => {
464− "Change a repository's description, its topics, whether it is private, and whether its default branch is protected. A protected branch refuses pushes and changes only by merging a pull request. Only the fields given are changed. Members of its workspace only."
612+ "Change a repository's description, website, topics and default branch, whether its default branch is protected, and whether it is private. Only the fields given are changed. Its description, website and topics, and protecting its default branch, need the Maintain role or higher; making it public or private and changing its default branch need the Admin role, and a free workspace takes a private repository only while its private storage has room. A protected branch refuses pushes and changes only by merging a pull request. A new default branch must already exist; open pull requests then merge into it."
613+ }
614+ Op::RenameRepo => {
615+ "Give a repository a new name in its workspace. Needs the Admin role. Everything stays with it: git data, issues, pull requests, workflow runs, deployments, secrets and webhooks. Its old address keeps working: web pages, git remotes and API calls redirect to the new one until a repository is made at the old address. The new name must be free in the workspace, including names held by recently deleted repositories."
616+ }
617+ Op::RenameBranch => {
618+ "Rename a branch. Needs the Write role or higher; the default branch, which stays the default, needs the Admin role. Open pull requests from the branch follow it, and web addresses that name the old branch redirect until a branch of that name is made again. Git remotes do not follow: fetch, then rename or re-track the branch in your clone. Give a branch with slashes URL-encoded in the path, e.g. feature%2Flogin."
619+ }
620+ Op::ArchiveRepo => {
621+ "Archive a repository: make it read-only. Needs the Admin role. Pushes and merges are refused, issues and pull requests are locked, and agents and workflows do not run. It can still be read, cloned and searched, and its deployments keep serving. unarchive_repo makes it writable again."
622+ }
623+ Op::UnarchiveRepo => {
624+ "Unarchive a repository: make it writable again. Needs the Admin role. Pushes, merges, issues, pull requests, agents and workflows work again; nothing that was refused while it was archived runs by itself."
625+ }
626+ Op::SetRepoVisibility => {
627+ "Make a repository public or private. Needs the Admin role, and confirm must be its full name, owner/name. Making it public shows it, its code, issues and pull requests to everyone and adds it to search for everyone. Making it private hides it from everyone without a role on it; a free workspace takes it only while its private storage has room. Nothing else about it changes."
628+ }
629+ Op::DeleteRepo => {
630+ "Delete a repository. Owners only, and confirm must be its full name, owner/name. It disappears at once: git refuses it, agents and workflows stop, its deployments are taken down, and search drops it. For 30 days an owner can restore it with restore_repo, as it was; then it is purged, its git data with it. Its name stays taken until it is purged. list_deleted_repos shows what can be restored."
631+ }
632+ Op::ListDeletedRepos => {
633+ "A workspace's recently deleted repositories, newest first, each with when it was deleted, by whom, and when it will be purged. Owners only; anyone else gets an empty list."
634+ }
635+ Op::RestoreRepo => {
636+ "Restore a deleted repository at the address it had, as it was when it was deleted: git data, issues, pull requests, settings, secrets and webhooks. Owners only. Its deployments are built again. Agents and workflows do not catch up on what they missed while it was deleted."
465637 }
638+ Op::PurgeRepo => {
639+ "Permanently remove a deleted repository now, instead of waiting for its 30 days to end. Owners only, and confirm must be its full name, owner/name. Its git data, issues, pull requests, deployments and custom domains are removed and cannot be recovered, and its name is free to use again."
640+ }
641+ Op::TransferRepo => {
642+ "Move a repository to another workspace, keeping its name. You must own both workspaces, and the destination must not already have a repository of that name; a free destination takes a private repository only if its private storage has room. Everything moves with it: git data, issues, pull requests, comments, labels, workflow runs, deployments, its project, and its own secrets, variables and webhooks. Its old address keeps working: web pages, git remotes and API calls redirect to the new one until a repository is made at the old address. Usage from now on is charged to the new workspace."
643+ }
466644 Op::GetRepoSettings => {
467645 "How a repository handles pull requests: the approvals a merge needs, whether failed checks can be overridden, whether a pull request must be up to date, and how g1t's agents are reviewed, revised and merged."
468646 }
469647 Op::UpdateRepoSettings => {
470− "Change how a repository handles pull requests. Only the fields given are changed. Members of its workspace only."
648+ "Change how a repository handles pull requests. Only the fields given are changed. Needs the Maintain role or higher."
471649 }
472650 Op::MessageAgent => {
473− "Send the agent working on a pull request a message: a correction, a hint, a change of plan. It receives it at its next step, and it is recorded in the pull request's session. The pull request's author and members of its workspace only. An agent uses it to ask the agent on another pull request a question (kind: question) or hand it work that belongs there (kind: handoff), giving its own pull request as from_number; the answer comes back to it at its next step."
651+ "Send the agent working on a pull request a message: a correction, a hint, a change of plan. It receives it at its next step, and it is recorded in the pull request's session. The pull request's author, and anyone with the Write role or higher. An agent uses it to ask the agent on another pull request a question (kind: question) or hand it work that belongs there (kind: handoff), giving its own pull request as from_number; the answer comes back to it at its next step."
474652 }
475653 Op::AnswerMessage => {
476654 "Answer a question or a handoff another agent sent you, by the message's id. For a handoff, set decline to say it is not yours to take. The answer reaches the asking agent at its next step."
507685 }
508686 Op::CreateIssue => "Open an issue on a repository.",
509687 Op::UpdateIssue => {
510− "Change an issue's title, body, labels or the people it is assigned to. Only the fields given are changed; labels and assignees each replace the whole set."
688+ "Change an issue's title, body, labels or the people it is assigned to. Only the fields given are changed; labels and assignees each replace the whole set. Its author may change their own issue; anyone else needs the Triage role or higher."
511689 }
512690 Op::CloseIssue => {
513− "Close an issue without a pull request. Merging a pull request made for an issue closes it for you."
691+ "Close an issue without a pull request. Merging a pull request made for an issue closes it for you. Its author may close their own issue; anyone else needs the Triage role or higher."
514692 }
515− Op::ReopenIssue => "Reopen a closed issue.",
693+ Op::ReopenIssue => "Reopen a closed issue. Its author may reopen their own issue; anyone else needs the Triage role or higher.",
516694 Op::PlanWork => {
517− "Turn an outcome into a plan. An agent reads the repository and proposes the issues that would get there: what each changes, the checks it must pass, the files it will touch, and which must merge before which. Returns the plan's id at once; the plan takes a minute or two to write, so read it with get_plan until its status is ready. Nothing is opened until apply_plan. Members of the repository's workspace only."
695+ "Turn an outcome into a plan. An agent reads the repository and proposes the issues that would get there: what each changes, the checks it must pass, the files it will touch, and which must merge before which. Returns the plan's id at once; the plan takes a minute or two to write, so read it with get_plan until its status is ready. Nothing is opened until apply_plan. Needs the Write role or higher."
518696 }
519697 Op::GetPlan => {
520698 "A plan: the outcome asked for, its status (planning, ready, failed or applied), and the issues it proposes with their dependencies."
521699 }
522700 Op::ApplyPlan => {
523− "Open a plan's issues, each blocked by the ones it depends on. With assign, g1t agents start at once on every issue that depends on nothing, working in parallel, and on the others as what they depend on merges. keep limits it to some of the proposed issues, by their positions counting from 1. A plan is applied once."
701+ "Open a plan's issues, each blocked by the ones it depends on. With assign, g1t agents start at once on every issue that depends on nothing, working in parallel, and on the others as what they depend on merges. keep limits it to some of the proposed issues, by their positions counting from 1. A plan is applied once. Needs the Write role or higher."
524702 }
525703 Op::AssignIssue => {
526− "Assign an issue to the g1t agent. It opens a pull request for the issue in a sandbox of its own and sees it through: the issue's acceptance checks, a review by a second agent, revision if either finds something, and catching up when main moves. Returns the pull request at once; follow its progress with get_pull_request. There is no model or agent count to choose. To put many agents to work, assign many issues. In preview: only for accounts g1t agents are enabled for."
704+ "Assign an issue to the g1t agent. It opens a pull request for the issue in a sandbox of its own and sees it through: the issue's acceptance checks, a review by a second agent, revision if either finds something, and catching up when main moves. Returns the pull request at once; follow its progress with get_pull_request. There is no model or agent count to choose. To put many agents to work, assign many issues. Needs the Write role or higher. In preview: only for accounts g1t agents are enabled for."
527705 }
528706 Op::ListLabels => "The labels available on a repository's issues.",
529707 Op::AddComment => {
548726 Op::MarkPullRequestReady => {
549727 "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why."
550728 }
551− Op::ClosePullRequest => "Close a pull request without merging it.",
729+ Op::ClosePullRequest => "Close a pull request without merging it. Its author may close their own; anyone else needs the Triage role or higher.",
552730 Op::GetPullRequestChanges => {
553731 "What a pull request changes: the files it touches and their line-by-line diff against the commit it started from. Use it to review a pull request or to compare several made for the same issue."
554732 }
555733 Op::MergePullRequest => {
556− "Land a pull request on the repository's main branch. Only members of the repository's workspace can merge, and only once it is marked ready and its acceptance checks have passed. Merging resolves the issue it was made for: the issue closes recording this pull request, and the other pull requests still in progress for that issue close as superseded. Where the repository has a merge queue, it joins the queue instead of landing at once. If main has moved since the pull request was opened, it is brought up to date first and lands when that is done; a repository that requires pull requests to be up to date refuses instead, so pull main into its fork or branch, push, and merge again. Check status in the result to see whether it has landed."
734+ "Land a pull request on the repository's main branch. Merging needs the Write role or higher, and only once it is marked ready and its acceptance checks have passed. Merging resolves the issue it was made for: the issue closes recording this pull request, and the other pull requests still in progress for that issue close as superseded. Where the repository has a merge queue, it joins the queue instead of landing at once. If main has moved since the pull request was opened, it is brought up to date first and lands when that is done; a repository that requires pull requests to be up to date refuses instead, so pull main into its fork or branch, push, and merge again. Check status in the result to see whether it has landed."
557735 }
558736 Op::ListEvents => {
559737 "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first."
580758 "Replace a workspace's model routes. Each route names a task (default, implement, review, plan or update), a connection_id (null for g1t's hosted models) and a model at that provider. Providers that speak OpenAI's API need a model. Owners only."
581759 }
582760 Op::ListWebhooks => {
583− "A repository's webhooks, or with workspace instead of repo, the workspace's own, which are sent the events of all its repositories. Secrets are never returned. Members only."
761+ "A repository's webhooks, or with workspace instead of repo, the workspace's own, which are sent the events of all its repositories. Secrets are never returned. A repository's need the Admin role on it; a workspace's, a member."
584762 }
585763 Op::CreateWebhook => {
586− "Register an HTTPS address to be sent events as they happen: a signed JSON POST for each, retried for hours if the receiver does not answer with a 2xx. events lists the event types, or leave it out for all. Without a secret, g1t makes one and returns it once. A ping is sent at once. Members, for a repository; owners, for a workspace."
764+ "Register an HTTPS address to be sent events as they happen: a signed JSON POST for each, retried for hours if the receiver does not answer with a 2xx. events lists the event types, or leave it out for all. Without a secret, g1t makes one and returns it once. A ping is sent at once. The Admin role, for a repository; owners, for a workspace."
587765 }
588766 Op::UpdateWebhook => {
589767 "Change a webhook's address, its events, or whether it is active. Only the fields given change."
607785 "A job's log, in order, after `after` (a sequence number from an earlier call). `done` says whether more will come. Lines starting ##[group], ##[endgroup], ##[error] and ##[warning] mark groups and messages."
608786 }
609787 Op::DispatchWorkflow => {
610− "Run a workflow that has `on: workflow_dispatch`, on a branch or tag (the default branch if none), with its inputs. Members only."
788+ "Run a workflow that has `on: workflow_dispatch`, on a branch or tag (the default branch if none), with its inputs. Needs the Write role or higher."
611789 }
612− Op::CancelWorkflowRun => "Cancel a run that is still going: its waiting jobs are cancelled and its running ones stopped. Members only.",
790+ Op::CancelWorkflowRun => "Cancel a run that is still going: its waiting jobs are cancelled and its running ones stopped. Needs the Write role or higher.",
613791 Op::RerunWorkflowRun => {
614− "Run a finished workflow run again: every job, or with failed_only the jobs that did not succeed and the jobs that need them. Members only."
792+ "Run a finished workflow run again: every job, or with failed_only the jobs that did not succeed and the jobs that need them. Needs the Write role or higher."
615793 }
616− Op::UpdateWorkflow => "Turn a workflow on or off without changing its file. Members only.",
794+ Op::UpdateWorkflow => "Turn a workflow on or off without changing its file. Needs the Maintain role or higher.",
617795 Op::ListActionsSecrets => {
618− "The secrets of a repository (with the workspace's rows that reach it) or of a workspace: each row's key, the environments it applies to, and whether workflows (`secrets.NAME`), deployments, or both read it. Values are never returned. Members only."
796+ "The secrets of a repository (with the workspace's rows that reach it) or of a workspace: each row's key, the environments it applies to, and whether workflows (`secrets.NAME`), deployments, or both read it. Values are never returned. A repository's need the Admin role on it; a workspace's, a member."
619797 }
620798 Op::SetActionsSecret => {
621− "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `available_to` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. A repository's need a member; a workspace's an owner. Workspace tokens, G1T_TOKEN included, cannot change them."
799+ "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `available_to` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. A repository's need the Admin role on it; a workspace's, an owner. Workspace tokens, G1T_TOKEN included, cannot change them."
622800 }
623801 Op::DeleteActionsSecret => "Remove a secret: one row by `id`, or every row of the key.",
624802 Op::ListActionsVariables => {
625− "The variables (Config) of a repository, with the workspace's rows that reach it, or of a workspace, with their values: each row's key, environments and readers (workflows read them as `vars.NAME`). Members only."
803+ "The variables (Config) of a repository, with the workspace's rows that reach it, or of a workspace, with their values: each row's key, environments and readers (workflows read them as `vars.NAME`). A repository's need the Admin role on it; a workspace's, a member."
626804 }
627805 Op::SetActionsVariable => "Add or change a variable's row, as for secrets.",
628806 Op::DeleteActionsVariable => "Remove a variable: one row by `id`, or every row of the key.",
629807 Op::ImportIssue => {
630808 "Open an issue from a ticket in Jira or Linear, or from a Sentry issue, by its key or address. The issue is linked to it: agents read the original, and when the work lands the ticket is told. Importing the same ticket again returns the issue already made. With assign, a g1t agent starts on it."
631809 }
810+ Op::ListCollaborators => {
811+ "Who has access to a repository: the workspace's `base_permission`, and `people`, everyone with a role on it other than through it being public. Each person has their effective `role` (read, triage, write, maintain or admin), its `source` (`owner` of the workspace, the workspace's `base` permission, or a `direct` role on this repository), their `direct` role if they have one, and their `workspace_role` (`owner`, `member`, or null for an outside collaborator). Pending `invitations` are listed for those with the Admin role, and empty for anyone else. `viewer_role` is your own role, and `can_manage` whether you may change who has access. Needs the Write role or higher. People only."
812+ }
813+ Op::AddCollaborator => {
814+ "Give someone a role on a repository, by username or email address. A member of its workspace gets the role at once (`result` is `granted`, with the `collaborator`). Anyone else becomes an outside collaborator once they accept an invitation, which is emailed to them and waits 7 days (`result` is `invited`, with the `invitation`); an address with no g1t account is sent an invite that makes the account and accepts in one step. The role is read, triage, write, maintain or admin. Needs the Admin role on the repository, signed in as a person with a confirmed email address; agents' and workspaces' tokens are refused."
815+ }
816+ Op::UpdateCollaborator => {
817+ "Change the role someone was given on a repository directly, or the role of their pending invitation. A role from ownership or the workspace's base permission is not changed here: an owner always has Admin, and a member never has less than the base permission. Needs the Admin role. People only."
818+ }
819+ Op::RemoveCollaborator => {
820+ "Take away the role someone was given on a repository directly. Anyone may remove their own. An outside collaborator then has no access; a member keeps the workspace's base permission (change it with set_base_permission, or remove them from the workspace). Needs the Admin role, unless it is your own. People only."
821+ }
822+ Op::GetCollaboratorPermission => {
823+ "Someone's permission on a repository: their `role` and its `source` (`owner`, `base` or `direct`), or null for both when they have none, and the `capabilities` that role has, from the permission table. Being able to read a public repository does not count as a role. Needs the Write role or higher, or to ask about yourself."
824+ }
825+ Op::ListRepoInvitations => {
826+ "A repository's pending invitations: who each is for (`invitee`, or the `email` it was sent to when they had no account), the `role` it gives, who sent it and when it expires. Needs the Admin role. People only."
827+ }
828+ Op::RevokeRepoInvitation => {
829+ "Withdraw a pending invitation to a repository. Its link stops working at once. Needs the Admin role. People only."
830+ }
831+ Op::ListMyRepoInvitations => {
832+ "The invitations to repositories waiting for you to answer, sent to your username or to one of your confirmed email addresses, newest first. Accept or decline each by its `id`. People only; an agent's or a workspace's token gets an empty list."
833+ }
834+ Op::AcceptRepoInvitation => {
835+ "Accept an invitation to a repository sent to you. You get its role on that repository at once, as an outside collaborator unless you belong to its workspace. Refused when the workspace asks something of everyone with access that your account does not meet, such as two-factor authentication. People only."
836+ }
837+ Op::DeclineRepoInvitation => {
838+ "Decline an invitation to a repository sent to you. Whoever sent it can invite you again. People only."
839+ }
840+ Op::SetBasePermission => {
841+ "Set what every member of a workspace gets on each of its repositories: none, read, write (the default) or admin. Owners always have Admin, and a role given on a repository directly still counts where it is higher. With none, members see only the private repositories they are given a role on. Owners only, signed in as a person."
842+ }
843+ Op::ListOutsideCollaborators => {
844+ "The people with a role on some of a workspace's repositories who are not its members, each with the repositories they can reach and their role on each. Owners only."
845+ }
632846 }
633847 }
634848
655869 }),
656870 &[],
657871 ),
872+ Op::ListEmails => object(json!({}), &[]),
873+ Op::AddEmail => object(
874+ json!({
875+ "email": { "type": "string", "description": "The address to add." },
876+ "password": {
877+ "type": "string",
878+ "description": "Your account password, to confirm it is you. An account that signs in only with GitHub changes its addresses on g1t.sh.",
879+ },
880+ }),
881+ &["email", "password"],
882+ ),
883+ Op::RemoveEmail => object(
884+ json!({
885+ "email": { "type": "string", "description": "The address to remove." },
886+ "password": {
887+ "type": "string",
888+ "description": "Your account password, to confirm it is you. An account that signs in only with GitHub changes its addresses on g1t.sh.",
889+ },
890+ }),
891+ &["email", "password"],
892+ ),
893+ Op::UpdateEmailSettings => object(
894+ json!({
895+ "primary": { "type": "string", "description": "A confirmed address to make primary." },
896+ "backup": { "type": "string", "description": "A confirmed address that also gets security notices; an empty string for the primary only." },
897+ "private_email": { "type": "boolean", "description": "Use your noreply address on commits g1t makes for you." },
898+ "block_private_pushes": { "type": "boolean", "description": "Refuse pushes whose commits carry one of your addresses while it is private." },
899+ "password": {
900+ "type": "string",
901+ "description": "Your account password, to confirm it is you. An account that signs in only with GitHub changes its addresses on g1t.sh.",
902+ },
903+ }),
904+ &[],
905+ ),
906+ Op::ListInvites => object(json!({}), &[]),
907+ Op::CreateInvite => object(
908+ json!({
909+ "email": {
910+ "type": "string",
911+ "description": "Only this address can use it, and it is emailed there. Left out, anyone with the code can.",
912+ },
913+ "workspace": {
914+ "type": "string",
915+ "description": "Use one of the invites g1t granted this workspace instead of yours, by slug. Owners only.",
916+ },
917+ }),
918+ &[],
919+ ),
920+ Op::RevokeInvite => object(
921+ json!({ "id": { "type": "string", "description": "The invite's id, such as inv_01k…" } }),
922+ &["id"],
923+ ),
924+ Op::ListWorkspaceInvites => object(json!({ "workspace": workspace_schema() }), &["workspace"]),
925+ Op::InviteMember => object(
926+ json!({
927+ "workspace": workspace_schema(),
928+ "email": { "type": "string", "description": "The address to invite." },
929+ }),
930+ &["workspace", "email"],
931+ ),
932+ Op::RevokeWorkspaceInvite => object(
933+ json!({
934+ "workspace": workspace_schema(),
935+ "id": { "type": "string", "description": "The invite's id." },
936+ }),
937+ &["workspace", "id"],
938+ ),
939+ Op::DeleteWorkspace => object(
940+ json!({
941+ "workspace": workspace_schema(),
942+ "confirm": {
943+ "type": "string",
944+ "description": "The workspace's slug again, typed out, to confirm.",
945+ },
946+ }),
947+ &["workspace", "confirm"],
948+ ),
949+ Op::TransferRepo => object(
950+ json!({
951+ "repo": repo_schema(),
952+ "to": {
953+ "type": "string",
954+ "description": "The slug of the workspace to move it to, e.g. \"flagon-io\". You must own it.",
955+ },
956+ }),
957+ &["repo", "to"],
958+ ),
658959 Op::GetRepo | Op::ListLabels => repo_only(),
659960 Op::UpdateRepo => object(
660961 json!({
670971 "items": { "type": "string" },
671972 "description": "Replaces its topics, which search and Explore show: lowercase letters, digits and hyphens, at most 20. An empty list clears them.",
672973 },
974+ "website": {
975+ "type": "string",
976+ "description": "Its home page, an http or https address shown beside its description; https:// is added when no scheme is given. An empty string clears it.",
977+ },
978+ "default_branch": {
979+ "type": "string",
980+ "description": "Make this existing branch the default: the one clones check out and pull requests merge into.",
981+ },
673982 }),
674983 &["repo"],
675984 ),
985+ Op::RenameRepo => object(
986+ json!({
987+ "repo": repo_schema(),
988+ "name": {
989+ "type": "string",
990+ "description": "The new name: lowercase letters, digits, dots, hyphens and underscores, at most 100 characters, not starting with a dot or ending in .git.",
991+ },
992+ }),
993+ &["repo", "name"],
994+ ),
995+ Op::RenameBranch => object(
996+ json!({
997+ "repo": repo_schema(),
998+ "branch": {
999+ "type": "string",
1000+ "description": "The branch's name now, e.g. \"feature/login\". URL-encode slashes in the path.",
1001+ },
1002+ "new_name": { "type": "string", "description": "What to call it." },
1003+ }),
1004+ &["repo", "branch", "new_name"],
1005+ ),
1006+ Op::ArchiveRepo | Op::UnarchiveRepo | Op::RestoreRepo => repo_only(),
1007+ Op::SetRepoVisibility => object(
1008+ json!({
1009+ "repo": repo_schema(),
1010+ "private": {
1011+ "type": "boolean",
1012+ "description": "true to make it private, false to make it public.",
1013+ },
1014+ "confirm": {
1015+ "type": "string",
1016+ "description": "Its full name, owner/name, typed out, to confirm.",
1017+ },
1018+ }),
1019+ &["repo", "private", "confirm"],
1020+ ),
1021+ Op::DeleteRepo | Op::PurgeRepo => object(
1022+ json!({
1023+ "repo": repo_schema(),
1024+ "confirm": {
1025+ "type": "string",
1026+ "description": "Its full name, owner/name, typed out, to confirm.",
1027+ },
1028+ }),
1029+ &["repo", "confirm"],
1030+ ),
1031+ Op::ListDeletedRepos => object(json!({ "workspace": workspace_schema() }), &["workspace"]),
6761032 Op::GetRepoSettings => object(json!({ "repo": repo_schema() }), &["repo"]),
6771033 Op::GetMergeQueue => object(json!({ "repo": repo_schema() }), &["repo"]),
6781034 Op::MessageAgent => object(
12031559 }),
12041560 &["repo", "reference"],
12051561 ),
1562+ Op::ListCollaborators | Op::ListRepoInvitations => repo_only(),
1563+ Op::AddCollaborator => object(
1564+ json!({
1565+ "repo": repo_schema(),
1566+ "invitee": {
1567+ "type": "string",
1568+ "description": "A username, or an email address. An address confirmed on an account invites that account; any other address is sent an invite that makes the account.",
1569+ },
1570+ "role": role_schema(),
1571+ }),
1572+ &["repo", "invitee", "role"],
1573+ ),
1574+ Op::UpdateCollaborator => object(
1575+ json!({
1576+ "repo": repo_schema(),
1577+ "username": username_schema(),
1578+ "role": role_schema(),
1579+ }),
1580+ &["repo", "username", "role"],
1581+ ),
1582+ Op::RemoveCollaborator | Op::GetCollaboratorPermission => object(
1583+ json!({ "repo": repo_schema(), "username": username_schema() }),
1584+ &["repo", "username"],
1585+ ),
1586+ Op::RevokeRepoInvitation => object(
1587+ json!({
1588+ "repo": repo_schema(),
1589+ "id": { "type": "string", "description": "The invitation's id, from list_repo_invitations." },
1590+ }),
1591+ &["repo", "id"],
1592+ ),
1593+ Op::ListMyRepoInvitations => object(json!({}), &[]),
1594+ Op::AcceptRepoInvitation | Op::DeclineRepoInvitation => object(
1595+ json!({
1596+ "id": { "type": "string", "description": "The invitation's id, from list_my_repo_invitations." },
1597+ }),
1598+ &["id"],
1599+ ),
1600+ Op::SetBasePermission => object(
1601+ json!({
1602+ "workspace": workspace_schema(),
1603+ "base_permission": {
1604+ "type": "string",
1605+ "enum": g1t_contracts::access::BasePermission::ALL.map(|base| base.as_str()),
1606+ "description": "What every member gets on each repository: none, read, write or admin.",
1607+ },
1608+ }),
1609+ &["workspace", "base_permission"],
1610+ ),
1611+ Op::ListOutsideCollaborators => object(json!({ "workspace": workspace_schema() }), &["workspace"]),
12061612 }
12071613 }
12081614
12371643 self,
12381644 Op::Whoami
12391645 | Op::CreateWorkspace
1646+ | Op::DeleteWorkspace
1647+ | Op::ListEmails
1648+ | Op::AddEmail
1649+ | Op::RemoveEmail
1650+ | Op::UpdateEmailSettings
1651+ | Op::ListInvites
1652+ | Op::CreateInvite
1653+ | Op::RevokeInvite
1654+ | Op::ListWorkspaceInvites
1655+ | Op::InviteMember
1656+ | Op::RevokeWorkspaceInvite
1657+ | Op::ListDeletedRepos
12401658 | Op::SearchContext
12411659 | Op::GetEntity
12421660 | Op::Search
12611679 | Op::ListActionsVariables
12621680 | Op::SetActionsVariable
12631681 | Op::DeleteActionsVariable
1682+ | Op::ListMyRepoInvitations
1683+ | Op::AcceptRepoInvitation
1684+ | Op::DeclineRepoInvitation
1685+ | Op::SetBasePermission
1686+ | Op::ListOutsideCollaborators
12641687 )
12651688 }
12661689
1690+ /// Whether the operation acts on the repository at exactly the path it
1691+ /// names, never on one that has moved away from it: moving, renaming,
1692+ /// deleting, restoring and purging, and changing who can see it.
1693+ fn names_the_repo_as_it_is(self) -> bool {
1694+ matches!(
1695+ self,
1696+ Op::TransferRepo
1697+ | Op::RenameRepo
1698+ | Op::SetRepoVisibility
1699+ | Op::DeleteRepo
1700+ | Op::RestoreRepo
1701+ | Op::PurgeRepo
1702+ )
1703+ }
1704+
12671705 /// Runs the operation. One that found nothing, or was refused, under a
12681706 /// workspace slug that has since been renamed runs again under the
1269− /// workspace's current slug; neither outcome changed anything.
1707+ /// workspace's current slug, and one naming a repository by a path it
1708+ /// was transferred away from runs again at its path now; neither
1709+ /// outcome changed anything.
12701710 pub async fn run(
12711711 self,
12721712 services: &Services,
12801720 {
12811721 return self.run_once(services, viewer, &retargeted).await;
12821722 }
1723+ // A repository transferred to another workspace or renamed: the
1724+ // same, at its path now. Never for the operations that name it as
1725+ // it is, or name a deleted one, which must not act on whatever has
1726+ // its old path now.
1727+ if let Outcome::Fail(failure) = &outcome
1728+ && matches!(failure.code, FailureCode::NotFound | FailureCode::Forbidden)
1729+ && !self.names_the_repo_as_it_is()
1730+ && let Some(moved) = crate::renamed::transferred(services, input).await?
1731+ {
1732+ return self.run_once(services, viewer, &moved).await;
1733+ }
12831734 Ok(outcome)
12841735 }
12851736
13761827 )
13771828 .await
13781829 }
1830+ // A person's addresses: identity refuses anyone but a person, and
1831+ // the password is the proof a sensitive change needs.
1832+ Op::ListEmails => pass(identity, "list_emails", &json!({ "user": actor() })).await,
1833+ Op::AddEmail | Op::RemoveEmail => {
1834+ let method = if self == Op::AddEmail { "add_email" } else { "remove_email" };
1835+ pass(
1836+ identity,
1837+ method,
1838+ &json!({
1839+ "user": actor(),
1840+ "email": text(input, "email"),
1841+ "reauth": { "password": optional_text(input, "password") },
1842+ }),
1843+ )
1844+ .await
1845+ }
1846+ Op::UpdateEmailSettings => {
1847+ pass(
1848+ identity,
1849+ "update_email_settings",
1850+ &json!({
1851+ "user": actor(),
1852+ "primary": optional_text(input, "primary"),
1853+ "backup": input["backup"].as_str(),
1854+ "privateEmail": input["private_email"].as_bool(),
1855+ "blockPrivatePushes": input["block_private_pushes"].as_bool(),
1856+ "reauth": { "password": optional_text(input, "password") },
1857+ }),
1858+ )
1859+ .await
1860+ }
1861+ Op::ListInvites => {
1862+ let overview: g1t_contracts::identity::InvitesOverview =
1863+ g1t_kit::call(identity, "list_invites", &json!({ "user": actor() })).await?;
1864+ ok(&overview)
1865+ }
1866+ Op::CreateInvite => {
1867+ pass(
1868+ identity,
1869+ "create_invite",
1870+ &json!({
1871+ "user": actor(),
1872+ "email": optional_text(input, "email"),
1873+ "workspace": optional_text(input, "workspace"),
1874+ "surface": services.audit.surface,
1875+ }),
1876+ )
1877+ .await
1878+ }
1879+ Op::RevokeInvite => {
1880+ pass(identity, "revoke_invite", &json!({ "user": actor(), "id": text(input, "id") })).await
1881+ }
1882+ Op::ListWorkspaceInvites => {
1883+ pass(identity, "workspace_invites", &json!({ "slug": workspace(), "viewer": viewer })).await
1884+ }
1885+ Op::InviteMember => {
1886+ pass(
1887+ identity,
1888+ "invite_member",
1889+ &json!({
1890+ "actor": actor(),
1891+ "slug": workspace(),
1892+ "email": text(input, "email"),
1893+ "surface": services.audit.surface,
1894+ }),
1895+ )
1896+ .await
1897+ }
1898+ Op::RevokeWorkspaceInvite => {
1899+ pass(
1900+ identity,
1901+ "revoke_workspace_invite",
1902+ &json!({ "actor": actor(), "slug": workspace(), "id": text(input, "id") }),
1903+ )
1904+ .await
1905+ }
1906+ Op::DeleteWorkspace => {
1907+ pass(
1908+ identity,
1909+ "delete_workspace",
1910+ &json!({
1911+ "actor": actor(),
1912+ "slug": workspace(),
1913+ "confirm": text(input, "confirm"),
1914+ "surface": services.audit.surface,
1915+ }),
1916+ )
1917+ .await
1918+ }
1919+ Op::TransferRepo => {
1920+ pass(
1921+ repos,
1922+ "transfer",
1923+ &json!({
1924+ "actor": actor(),
1925+ "path": repo,
1926+ "to": text(input, "to").to_lowercase(),
1927+ "surface": services.audit.surface,
1928+ }),
1929+ )
1930+ .await
1931+ }
13791932 Op::ListRepos => {
13801933 let found: Vec<Repo> = g1t_kit::call(
13811934 repos,
14021955 .await
14031956 }
14041957 Op::UpdateRepo => {
1405− pass(
1958+ let updated = pass(
14061959 repos,
14071960 "update",
14081961 &json!({
14121965 "isPrivate": input["private"].as_bool(),
14131966 "protected": input["protected"].as_bool(),
14141967 "topics": strings(input, "topics"),
1968+ "website": input["website"].as_str(),
1969+ "surface": services.audit.surface,
1970+ }),
1971+ )
1972+ .await?;
1973+ // A new default branch, once the rest has been changed.
1974+ match (&updated, optional_text(input, "default_branch")) {
1975+ (Outcome::Ok(_), Some(branch)) => {
1976+ pass(
1977+ repos,
1978+ "set_default_branch",
1979+ &json!({
1980+ "actor": actor(),
1981+ "path": repo,
1982+ "branch": branch,
1983+ "surface": services.audit.surface,
1984+ }),
1985+ )
1986+ .await
1987+ }
1988+ _ => Ok(updated),
1989+ }
1990+ }
1991+ Op::RenameRepo => {
1992+ pass(
1993+ repos,
1994+ "rename",
1995+ &json!({
1996+ "actor": actor(),
1997+ "path": repo,
1998+ "name": text(input, "name"),
1999+ "surface": services.audit.surface,
2000+ }),
2001+ )
2002+ .await
2003+ }
2004+ Op::RenameBranch => {
2005+ pass(
2006+ repos,
2007+ "rename_branch",
2008+ &json!({
2009+ "actor": actor(),
2010+ "path": repo,
2011+ "from": text(input, "branch"),
2012+ "to": text(input, "new_name"),
2013+ "surface": services.audit.surface,
2014+ }),
2015+ )
2016+ .await
2017+ }
2018+ Op::ArchiveRepo | Op::UnarchiveRepo => {
2019+ pass(
2020+ repos,
2021+ "archive",
2022+ &json!({
2023+ "actor": actor(),
2024+ "path": repo,
2025+ "archived": self == Op::ArchiveRepo,
2026+ "surface": services.audit.surface,
2027+ }),
2028+ )
2029+ .await
2030+ }
2031+ Op::SetRepoVisibility => {
2032+ let Some(private) = input["private"].as_bool() else {
2033+ return failed(
2034+ FailureCode::Invalid,
2035+ "Say whether to make it private: private is true or false.",
2036+ );
2037+ };
2038+ pass(
2039+ repos,
2040+ "set_visibility",
2041+ &json!({
2042+ "actor": actor(),
2043+ "path": repo,
2044+ "isPrivate": private,
2045+ "confirm": text(input, "confirm"),
2046+ "surface": services.audit.surface,
2047+ }),
2048+ )
2049+ .await
2050+ }
2051+ Op::DeleteRepo => {
2052+ pass(
2053+ repos,
2054+ "delete",
2055+ &json!({
2056+ "actor": actor(),
2057+ "path": repo,
2058+ "confirm": text(input, "confirm"),
2059+ "surface": services.audit.surface,
14152060 }),
14162061 )
14172062 .await
14182063 }
2064+ Op::ListDeletedRepos => {
2065+ let found: Vec<g1t_contracts::repos::DeletedRepo> = g1t_kit::call(
2066+ repos,
2067+ "deleted",
2068+ &json!({ "viewer": viewer, "namespace": workspace() }),
2069+ )
2070+ .await?;
2071+ ok(&found)
2072+ }
2073+ Op::RestoreRepo | Op::PurgeRepo => {
2074+ pass(
2075+ repos,
2076+ if self == Op::RestoreRepo { "restore" } else { "purge" },
2077+ &json!({
2078+ "actor": actor(),
2079+ "path": repo,
2080+ "confirm": optional_text(input, "confirm"),
2081+ "surface": services.audit.surface,
2082+ }),
2083+ )
2084+ .await
2085+ }
14192086 Op::GetRepoSettings => {
14202087 pass(
14212088 work,
16232290 description: optional_text(input, "description"),
16242291 is_private: input["private"].as_bool() == Some(true),
16252292 import_url: optional_text(input, "import_url"),
2293+ import_token: None,
16262294 },
16272295 )
16282296 .await
21032771 .await?;
21042772 ok(&timeline)
21052773 }
2774+ // Who has access: identity decides, from the repository as the
2775+ // caller sees it, and refuses every token but a person's for
2776+ // changes. See g1t_contracts::access.
2777+ Op::ListCollaborators => {
2778+ pass(identity, "repo_access", &RepoAccessArgs { viewer: viewer.clone(), path: repo }).await
2779+ }
2780+ Op::ListRepoInvitations => {
2781+ let access: Outcome<RepoAccess> =
2782+ call(identity, "repo_access", &RepoAccessArgs { viewer: viewer.clone(), path: repo }).await?;
2783+ match access {
2784+ Outcome::Ok(access) if access.can_manage => ok(&access.invitations),
2785+ Outcome::Ok(access) => failed(
2786+ FailureCode::Forbidden,
2787+ &g1t_contracts::access::needs(Capability::ManageAccess, &access.repo),
2788+ ),
2789+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
2790+ }
2791+ }
2792+ Op::AddCollaborator => {
2793+ let Some(role) = repo_role(input) else {
2794+ return failed(FailureCode::Invalid, ROLE_NEEDED);
2795+ };
2796+ pass(
2797+ identity,
2798+ "add_collaborator",
2799+ &AddCollaboratorArgs {
2800+ actor: actor(),
2801+ path: repo,
2802+ invitee: text(input, "invitee").trim().to_owned(),
2803+ role,
2804+ surface: Some(services.audit.surface),
2805+ },
2806+ )
2807+ .await
2808+ }
2809+ Op::UpdateCollaborator => {
2810+ let Some(role) = repo_role(input) else {
2811+ return failed(FailureCode::Invalid, ROLE_NEEDED);
2812+ };
2813+ pass(
2814+ identity,
2815+ "set_collaborator_role",
2816+ &SetCollaboratorRoleArgs {
2817+ actor: actor(),
2818+ path: repo,
2819+ username: text(input, "username"),
2820+ role,
2821+ surface: Some(services.audit.surface),
2822+ },
2823+ )
2824+ .await
2825+ }
2826+ Op::RemoveCollaborator => {
2827+ pass(
2828+ identity,
2829+ "remove_collaborator",
2830+ &RemoveCollaboratorArgs {
2831+ actor: actor(),
2832+ path: repo,
2833+ username: text(input, "username"),
2834+ surface: Some(services.audit.surface),
2835+ },
2836+ )
2837+ .await
2838+ }
2839+ Op::GetCollaboratorPermission => {
2840+ pass(
2841+ identity,
2842+ "collaborator_permission",
2843+ &CollaboratorPermissionArgs {
2844+ viewer: viewer.clone(),
2845+ path: repo,
2846+ username: text(input, "username"),
2847+ },
2848+ )
2849+ .await
2850+ }
2851+ Op::RevokeRepoInvitation => {
2852+ pass(
2853+ identity,
2854+ "revoke_repo_invitation",
2855+ &RevokeRepoInvitationArgs {
2856+ actor: actor(),
2857+ path: repo,
2858+ id: text(input, "id"),
2859+ surface: Some(services.audit.surface),
2860+ },
2861+ )
2862+ .await
2863+ }
2864+ Op::ListMyRepoInvitations => {
2865+ let waiting: Vec<RepoInvitation> =
2866+ g1t_kit::call(identity, "my_repo_invitations", &MyRepoInvitationsArgs { user: actor() }).await?;
2867+ ok(&waiting)
2868+ }
2869+ Op::AcceptRepoInvitation | Op::DeclineRepoInvitation => {
2870+ pass(
2871+ identity,
2872+ "respond_repo_invitation",
2873+ &RespondRepoInvitationArgs {
2874+ user: actor(),
2875+ id: text(input, "id"),
2876+ accept: self == Op::AcceptRepoInvitation,
2877+ },
2878+ )
2879+ .await
2880+ }
2881+ Op::SetBasePermission => {
2882+ let Some(base) = input["base_permission"].as_str().and_then(BasePermission::parse) else {
2883+ return failed(
2884+ FailureCode::Invalid,
2885+ "Give base_permission: none, read, write or admin.",
2886+ );
2887+ };
2888+ let set: Outcome<BasePermission> = call(
2889+ identity,
2890+ "set_base_permission",
2891+ &SetBasePermissionArgs {
2892+ actor: actor(),
2893+ slug: workspace(),
2894+ base_permission: base,
2895+ surface: Some(services.audit.surface),
2896+ },
2897+ )
2898+ .await?;
2899+ match set {
2900+ Outcome::Ok(base) => ok(&json!({ "workspace": workspace(), "base_permission": base })),
2901+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
2902+ }
2903+ }
2904+ Op::ListOutsideCollaborators => {
2905+ pass(
2906+ identity,
2907+ "outside_collaborators",
2908+ &OutsideCollaboratorsArgs { viewer: viewer.clone(), slug: workspace() },
2909+ )
2910+ .await
2911+ }
21062912 }
21072913 }
21082914 }
21092915
2916+const ROLE_NEEDED: &str = "Give a role: read, triage, write, maintain or admin.";
2917+
2918+/// The role named by `role`.
2919+fn repo_role(input: &Value) -> Option<RepoRole> {
2920+ input["role"].as_str().and_then(RepoRole::parse)
2921+}
2922+
21102923 impl Op {
21112924 /// The properties of the operation's input schema.
21122925 pub fn properties(self) -> Map<String, Value> {
21572970
21582971 #[test]
21592972 fn a_repository_is_owner_slash_name() {
2160− let path = repo_path(&json!({ "repo": "syntaqx/hello" })).unwrap();
2973+ let path = repo_path(&json!({ "repo": "flagon-io/hello" })).unwrap();
21612974 assert_eq!(
21622975 (path.namespace.as_str(), path.name.as_str()),
2163− ("syntaqx", "hello")
2976+ ("flagon-io", "hello")
21642977 );
2165− for bad in ["syntaqx", "a/b/c", "/hello", "syntaqx/", ""] {
2978+ for bad in ["flagon-io", "a/b/c", "/hello", "flagon-io/", ""] {
21662979 assert!(repo_path(&json!({ "repo": bad })).is_none(), "{bad}");
21672980 }
21682981 }
21742987 assert_eq!(integer(&json!({ "number": "x" }), "number"), None);
21752988 assert_eq!(integer(&json!({}), "number"), None);
21762989 }
2990+
2991+ const ACCESS: [Op; 12] = [
2992+ Op::ListCollaborators,
2993+ Op::AddCollaborator,
2994+ Op::UpdateCollaborator,
2995+ Op::RemoveCollaborator,
2996+ Op::GetCollaboratorPermission,
2997+ Op::ListRepoInvitations,
2998+ Op::RevokeRepoInvitation,
2999+ Op::ListMyRepoInvitations,
3000+ Op::AcceptRepoInvitation,
3001+ Op::DeclineRepoInvitation,
3002+ Op::SetBasePermission,
3003+ Op::ListOutsideCollaborators,
3004+ ];
3005+
3006+ /// Who has access is for people: no run's scope lists these, and the
3007+ /// ones that change or reveal access are refused whatever a scope says.
3008+ #[test]
3009+ fn agents_never_manage_access() {
3010+ use g1t_contracts::credentials::{CredentialUse, NEVER, RunCredentialKind, operations_for};
3011+ for kind in RunCredentialKind::ALL {
3012+ for usage in [CredentialUse::Runner, CredentialUse::Tools] {
3013+ let operations = operations_for(kind, usage);
3014+ for op in ACCESS {
3015+ assert!(!operations.contains(&op.name()), "{} in a {kind:?} run", op.name());
3016+ }
3017+ }
3018+ }
3019+ for op in ACCESS {
3020+ assert!(NEVER.contains(&op.name()), "{} is not in NEVER", op.name());
3021+ }
3022+ }
3023+
3024+ #[test]
3025+ fn roles_and_base_permissions_are_read_as_words() {
3026+ assert_eq!(repo_role(&json!({ "role": "Maintain" })), Some(RepoRole::Maintain));
3027+ assert_eq!(repo_role(&json!({ "role": "owner" })), None);
3028+ assert_eq!(repo_role(&json!({})), None);
3029+ assert_eq!(Op::AddCollaborator.input()["properties"]["role"]["enum"], json!(["read", "triage", "write", "maintain", "admin"]));
3030+ assert_eq!(
3031+ Op::SetBasePermission.input()["properties"]["base_permission"]["enum"],
3032+ json!(["none", "read", "write", "admin"])
3033+ );
3034+ }
3035+
3036+ /// The operations about one person's own invitations, and a
3037+ /// workspace's settings, name no repository.
3038+ #[test]
3039+ fn access_operations_name_a_repository_only_when_they_are_about_one() {
3040+ for op in [Op::ListMyRepoInvitations, Op::AcceptRepoInvitation, Op::DeclineRepoInvitation, Op::SetBasePermission, Op::ListOutsideCollaborators] {
3041+ assert!(!op.needs_repo(), "{}", op.name());
3042+ }
3043+ for op in ACCESS {
3044+ assert!(op.needs_user(), "{}", op.name());
3045+ }
3046+ }
21773047 }
+905−62
3232 "workspaces": [
3333 {
3434 "slug": "acme",
35− "role": "member"
35+ "role": "member",
36+ "base_permission": "write"
3637 },
3738 {
38− "slug": "syntaqx",
39− "role": "owner"
39+ "slug": "flagon-io",
40+ "role": "owner",
41+ "base_permission": "write"
4042 }
4143 ]
4244 },
43− "notes": "`kind` is `user`, `workspace` for a [workspace access token](/guides/workspaces/#workspace-access-tokens), or `agent` for the token a g1t agent works with. For a workspace token, `username` is the workspace's slug and `workspaces` holds only that workspace."
45+ "notes": "`kind` is `user`, `workspace` for a [workspace access token](/guides/workspaces/#workspace-access-tokens), or `agent` for the token a g1t agent works with. For a workspace token, `username` is the workspace's slug and `workspaces` holds only that workspace. Each workspace carries its `base_permission`: what a member gets on each of its repositories (owners have Admin). Roles given on single repositories are in `grants`, each with `repo_id`, `workspace` and `role`; it is left out when there are none. See [Access and roles](/guides/access-and-roles/)."
4446 },
4547 "create_workspace": {
4648 "request": {
47− "slug": "syntaqx-labs",
48− "name": "Syntaqx Labs"
49+ "slug": "acme-labs",
50+ "name": "Acme Labs"
4951 },
5052 "response": {
5153 "id": "wsp_01m43teqa9em6bje0bhvdj4jkb",
52− "slug": "syntaqx-labs",
53− "name": "Syntaqx Labs",
54+ "slug": "acme-labs",
55+ "name": "Acme Labs",
5456 "description": null,
5557 "created_at": "2026-10-04T16:02:51.337Z",
56− "member_count": 1
58+ "member_count": 1,
59+ "base_permission": "write"
60+ },
61+ "notes": "`base_permission` is what every member gets on each of its repositories: `write` until an owner changes it with `PATCH /workspaces/{workspace}`. See [Access and roles](/guides/access-and-roles/)."
62+ },
63+ "delete_workspace": {
64+ "request": {
65+ "confirm": "acme-labs"
66+ },
67+ "response": true,
68+ "notes": "Refused with `409` while the workspace holds repositories or projects, and with `402` while billing cannot settle it, each with a message that says what to do. See [Delete a workspace](/guides/workspaces/#delete-a-workspace)."
69+ },
70+ "list_emails": {
71+ "response": {
72+ "emails": [
73+ {
74+ "email": "ada@example.com",
75+ "verified": true,
76+ "primary": true,
77+ "backup": false,
78+ "created_at": "2026-09-01T10:00:00.000Z",
79+ "verified_at": "2026-09-01T10:02:11.000Z"
80+ },
81+ {
82+ "email": "ada@acme.com",
83+ "verified": true,
84+ "primary": false,
85+ "backup": true,
86+ "created_at": "2026-10-02T09:30:00.000Z",
87+ "verified_at": "2026-10-02T09:31:40.000Z"
88+ },
89+ {
90+ "email": "ada.l@example.org",
91+ "verified": false,
92+ "primary": false,
93+ "backup": false,
94+ "created_at": "2026-10-05T14:12:03.000Z",
95+ "verified_at": null
96+ }
97+ ],
98+ "private_email": true,
99+ "block_private_pushes": false,
100+ "noreply": "6c1d0efg+ada@users.noreply.g1t.sh",
101+ "commit_email": "6c1d0efg+ada@users.noreply.g1t.sh",
102+ "limit": 10
103+ },
104+ "notes": "Your primary address comes first, then confirmed addresses, then unconfirmed ones. Any confirmed address signs you in, can reset your password and attributes commits to you. See [Email addresses](/guides/authentication/#email-addresses)."
105+ },
106+ "add_email": {
107+ "request": {
108+ "email": "ada.l@example.org",
109+ "password": "…"
110+ },
111+ "response": {
112+ "emails": [
113+ {
114+ "email": "ada@example.com",
115+ "verified": true,
116+ "primary": true,
117+ "backup": false,
118+ "created_at": "2026-09-01T10:00:00.000Z",
119+ "verified_at": "2026-09-01T10:02:11.000Z"
120+ },
121+ {
122+ "email": "ada@acme.com",
123+ "verified": true,
124+ "primary": false,
125+ "backup": true,
126+ "created_at": "2026-10-02T09:30:00.000Z",
127+ "verified_at": "2026-10-02T09:31:40.000Z"
128+ },
129+ {
130+ "email": "ada.l@example.org",
131+ "verified": false,
132+ "primary": false,
133+ "backup": false,
134+ "created_at": "2026-10-05T14:12:03.000Z",
135+ "verified_at": null
136+ }
137+ ],
138+ "private_email": true,
139+ "block_private_pushes": false,
140+ "noreply": "6c1d0efg+ada@users.noreply.g1t.sh",
141+ "commit_email": "6c1d0efg+ada@users.noreply.g1t.sh",
142+ "limit": 10
143+ },
144+ "notes": "Answers `403` with code `reauth_required` when `password` is missing or wrong, and `409` when another account has confirmed the address. The new address stays unconfirmed until its link is followed."
145+ },
146+ "remove_email": {
147+ "request": {
148+ "password": "…"
149+ },
150+ "response": {
151+ "emails": [
152+ {
153+ "email": "ada@example.com",
154+ "verified": true,
155+ "primary": true,
156+ "backup": false,
157+ "created_at": "2026-09-01T10:00:00.000Z",
158+ "verified_at": "2026-09-01T10:02:11.000Z"
159+ },
160+ {
161+ "email": "ada@acme.com",
162+ "verified": true,
163+ "primary": false,
164+ "backup": true,
165+ "created_at": "2026-10-02T09:30:00.000Z",
166+ "verified_at": "2026-10-02T09:31:40.000Z"
167+ },
168+ {
169+ "email": "ada.l@example.org",
170+ "verified": false,
171+ "primary": false,
172+ "backup": false,
173+ "created_at": "2026-10-05T14:12:03.000Z",
174+ "verified_at": null
175+ }
176+ ],
177+ "private_email": true,
178+ "block_private_pushes": false,
179+ "noreply": "6c1d0efg+ada@users.noreply.g1t.sh",
180+ "commit_email": "6c1d0efg+ada@users.noreply.g1t.sh",
181+ "limit": 10
182+ },
183+ "notes": "Give the address in the path, URL-encoded: `DELETE /user/emails/ada.l%40example.org`. Answers `409` for your primary address or your last confirmed one."
184+ },
185+ "update_email_settings": {
186+ "request": {
187+ "primary": "ada@acme.com",
188+ "password": "…"
189+ },
190+ "response": {
191+ "emails": [
192+ {
193+ "email": "ada@example.com",
194+ "verified": true,
195+ "primary": true,
196+ "backup": false,
197+ "created_at": "2026-09-01T10:00:00.000Z",
198+ "verified_at": "2026-09-01T10:02:11.000Z"
199+ },
200+ {
201+ "email": "ada@acme.com",
202+ "verified": true,
203+ "primary": false,
204+ "backup": true,
205+ "created_at": "2026-10-02T09:30:00.000Z",
206+ "verified_at": "2026-10-02T09:31:40.000Z"
207+ },
208+ {
209+ "email": "ada.l@example.org",
210+ "verified": false,
211+ "primary": false,
212+ "backup": false,
213+ "created_at": "2026-10-05T14:12:03.000Z",
214+ "verified_at": null
215+ }
216+ ],
217+ "private_email": true,
218+ "block_private_pushes": false,
219+ "noreply": "6c1d0efg+ada@users.noreply.g1t.sh",
220+ "commit_email": "6c1d0efg+ada@users.noreply.g1t.sh",
221+ "limit": 10
222+ },
223+ "notes": "Only the fields given change. `primary` and `backup` need `password`; `private_email` and `block_private_pushes` do not."
224+ },
225+ "list_invites": {
226+ "response": {
227+ "mode": "invite",
228+ "allowance": {
229+ "limit": 5,
230+ "used": 2,
231+ "remaining": 3
232+ },
233+ "workspaces": [
234+ {
235+ "slug": "acme-labs",
236+ "allowance": {
237+ "limit": 20,
238+ "used": 4,
239+ "remaining": 16
240+ }
241+ }
242+ ],
243+ "invites": [
244+ {
245+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
246+ "code": "g1t-k7m2-q9xd-4hpw-…",
247+ "hint": "g1t-k7m2",
248+ "email": "ada@example.com",
249+ "kind": "account",
250+ "workspace": null,
251+ "status": "pending",
252+ "charged_to": "user",
253+ "invited_by": "syntaqx",
254+ "redeemed_by": null,
255+ "created_at": "2026-10-05T17:00:00.000Z",
256+ "expires_at": "2026-11-04T17:00:00.000Z",
257+ "redeemed_at": null,
258+ "revoked_at": null
259+ },
260+ {
261+ "id": "inv_01kp1v3c4d5e6f7g8h9j0k1m2n",
262+ "code": null,
263+ "hint": "g1t-p3rt",
264+ "email": null,
265+ "kind": "account",
266+ "workspace": null,
267+ "status": "redeemed",
268+ "charged_to": "user",
269+ "invited_by": "syntaqx",
270+ "redeemed_by": "grace",
271+ "created_at": "2026-10-02T09:12:40.000Z",
272+ "expires_at": "2026-11-01T09:12:40.000Z",
273+ "redeemed_at": "2026-10-02T11:30:05.000Z",
274+ "revoked_at": null
275+ }
276+ ]
277+ },
278+ "notes": "`allowance.limit` and `allowance.remaining` are null when you have no limit. A revoked or expired invite that was never used is not counted. Ask for more at hey@flagon.io with the subject `[g1t Invites]`. See [Invites](/guides/authentication/#invites)."
279+ },
280+ "create_invite": {
281+ "request": {
282+ "email": "ada@example.com"
283+ },
284+ "response": {
285+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
286+ "code": "g1t-k7m2-q9xd-4hpw-…",
287+ "hint": "g1t-k7m2",
288+ "email": "ada@example.com",
289+ "kind": "account",
290+ "workspace": null,
291+ "status": "pending",
292+ "charged_to": "user",
293+ "invited_by": "syntaqx",
294+ "redeemed_by": null,
295+ "created_at": "2026-10-05T17:00:00.000Z",
296+ "expires_at": "2026-11-04T17:00:00.000Z",
297+ "redeemed_at": null,
298+ "revoked_at": null
299+ },
300+ "notes": "Refused with `402` (`limit`) when you have no invites left, with `409` when you already have a pending invite for that address or it already has a g1t account, and with `403` for an agent's or a workspace's token. The code is returned in full; send people the link `https://g1t.sh/invite/<code>`."
301+ },
302+ "revoke_invite": {
303+ "response": {
304+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
305+ "code": null,
306+ "hint": "g1t-k7m2",
307+ "email": "ada@example.com",
308+ "kind": "account",
309+ "workspace": null,
310+ "status": "revoked",
311+ "charged_to": "user",
312+ "invited_by": "syntaqx",
313+ "redeemed_by": null,
314+ "created_at": "2026-10-05T17:00:00.000Z",
315+ "expires_at": "2026-11-04T17:00:00.000Z",
316+ "redeemed_at": null,
317+ "revoked_at": "2026-10-06T08:00:00.000Z"
57318 }
58319 },
320+ "list_workspace_invites": {
321+ "response": [
322+ {
323+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
324+ "code": "g1t-k7m2-q9xd-4hpw-…",
325+ "hint": "g1t-k7m2",
326+ "email": "ada@example.com",
327+ "kind": "account",
328+ "workspace": "acme-labs",
329+ "status": "pending",
330+ "charged_to": "workspace",
331+ "invited_by": "syntaqx",
332+ "redeemed_by": null,
333+ "created_at": "2026-10-05T17:00:00.000Z",
334+ "expires_at": "2026-11-04T17:00:00.000Z",
335+ "redeemed_at": null,
336+ "revoked_at": null
337+ },
338+ {
339+ "id": "inv_01kp1z9y8x7w6v5t4s3r2q1p0n",
340+ "code": "g1t-k7m2-q9xd-4hpw-…",
341+ "hint": "g1t-w2vb",
342+ "email": "linus@example.com",
343+ "kind": "workspace",
344+ "workspace": "acme-labs",
345+ "status": "pending",
346+ "charged_to": "none",
347+ "invited_by": "syntaqx",
348+ "redeemed_by": null,
349+ "created_at": "2026-10-05T17:00:00.000Z",
350+ "expires_at": "2026-11-04T17:00:00.000Z",
351+ "redeemed_at": null,
352+ "revoked_at": null
353+ }
354+ ]
355+ },
356+ "invite_member": {
357+ "request": {
358+ "email": "ada@example.com"
359+ },
360+ "response": {
361+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
362+ "code": "g1t-k7m2-q9xd-4hpw-…",
363+ "hint": "g1t-k7m2",
364+ "email": "ada@example.com",
365+ "kind": "account",
366+ "workspace": "acme-labs",
367+ "status": "pending",
368+ "charged_to": "workspace",
369+ "invited_by": "syntaqx",
370+ "redeemed_by": null,
371+ "created_at": "2026-10-05T17:00:00.000Z",
372+ "expires_at": "2026-11-04T17:00:00.000Z",
373+ "redeemed_at": null,
374+ "revoked_at": null
375+ },
376+ "notes": "`kind` is `workspace` when the address already has a g1t account (`charged_to` is then `none`), and `account` when accepting makes one. Both answers look alike to the caller on purpose: the invite is emailed either way."
377+ },
378+ "revoke_workspace_invite": {
379+ "response": {
380+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
381+ "code": null,
382+ "hint": "g1t-k7m2",
383+ "email": "ada@example.com",
384+ "kind": "account",
385+ "workspace": "acme-labs",
386+ "status": "revoked",
387+ "charged_to": "workspace",
388+ "invited_by": "syntaqx",
389+ "redeemed_by": null,
390+ "created_at": "2026-10-05T17:00:00.000Z",
391+ "expires_at": "2026-11-04T17:00:00.000Z",
392+ "redeemed_at": null,
393+ "revoked_at": "2026-10-06T08:00:00.000Z"
394+ }
395+ },
59396 "list_repos": {
60397 "query": {
61398 "q": "hello"
63400 "response": [
64401 {
65402 "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
66− "namespace": "syntaqx",
403+ "namespace": "flagon-io",
67404 "name": "hello",
68405 "description": "A tiny service that says hello.",
69406 "is_private": false,
71408 "default_branch": "main",
72409 "fork_of": null,
73410 "protected": true,
74− "created_at": "2026-09-28T14:11:52.640Z"
411+ "created_at": "2026-09-28T14:11:52.640Z",
412+ "website": "https://hello.g1t.page",
413+ "archived_at": null
75414 }
76415 ],
77416 "notes": "Returns at most 50 repositories, newest first. Forks made for pull requests are left out."
78417 },
79418 "create_repo": {
80419 "request": {
81− "workspace": "syntaqx",
420+ "workspace": "flagon-io",
82421 "name": "hello-cli",
83422 "description": "Command-line client for hello.",
84423 "private": false
85424 },
86425 "response": {
87426 "id": "rep_01m43tk1jhehztx23drs87f5fc",
88− "namespace": "syntaqx",
427+ "namespace": "flagon-io",
89428 "name": "hello-cli",
90429 "description": "Command-line client for hello.",
91430 "is_private": false,
93432 "default_branch": "main",
94433 "fork_of": null,
95434 "protected": false,
96− "created_at": "2026-10-04T16:05:12.913Z"
435+ "created_at": "2026-10-04T16:05:12.913Z",
436+ "website": "https://hello.g1t.page",
437+ "archived_at": null
97438 },
98439 "notes": "`owner_id` is the id of the person who created the repository. With `import_url`, `default_branch` is the default branch of the repository copied."
99440 },
100441 "get_repo": {
101442 "response": {
102443 "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
103− "namespace": "syntaqx",
444+ "namespace": "flagon-io",
104445 "name": "hello",
105446 "description": "A tiny service that says hello.",
106447 "is_private": false,
108449 "default_branch": "main",
109450 "fork_of": null,
110451 "protected": true,
111− "created_at": "2026-09-28T14:11:52.640Z"
452+ "created_at": "2026-09-28T14:11:52.640Z",
453+ "website": "https://hello.g1t.page",
454+ "archived_at": null
112455 }
113456 },
114457 "update_repo": {
115458 "request": {
116459 "description": "Says hello, politely.",
460+ "website": "hello.g1t.page",
461+ "default_branch": "trunk",
117462 "protected": true
118463 },
119464 "response": {
120465 "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
121− "namespace": "syntaqx",
466+ "namespace": "flagon-io",
122467 "name": "hello",
123468 "description": "Says hello, politely.",
124469 "is_private": false,
125470 "owner_id": "usr_01kkntcg1eeb98j62xjm7eh09p",
471+ "default_branch": "trunk",
472+ "fork_of": null,
473+ "protected": true,
474+ "created_at": "2026-09-28T14:11:52.640Z",
475+ "topics": [
476+ "http",
477+ "example"
478+ ],
479+ "website": "https://hello.g1t.page",
480+ "archived_at": null
481+ },
482+ "notes": "Only the fields given change. `website` gets `https://` when no scheme is given; an empty string clears it. `default_branch` must name a branch that exists (`422` otherwise); it is changed after the other fields. `description`, `website`, `topics` and `protected` need the Maintain role or higher, and `private` and `default_branch` the Admin role (`403` otherwise, saying which role). Changing `private` here needs no confirmation; `POST /repos/{owner}/{name}/visibility` asks for one. Making it private is refused with `402` when a free workspace's private storage has no room for it. See [Managing a repository](/guides/managing-repositories/) and [Access and roles](/guides/access-and-roles/)."
483+ },
484+ "transfer_repo": {
485+ "request": {
486+ "to": "flagon-io"
487+ },
488+ "response": {
489+ "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
490+ "namespace": "flagon-io",
491+ "name": "hello",
492+ "description": "A tiny service that says hello.",
493+ "is_private": false,
494+ "owner_id": "usr_01kkntcg1eeb98j62xjm7eh09p",
126495 "default_branch": "main",
127496 "fork_of": null,
128497 "protected": true,
129− "created_at": "2026-09-28T14:11:52.640Z"
130− }
498+ "created_at": "2026-09-28T14:11:52.640Z",
499+ "website": "https://hello.g1t.page",
500+ "archived_at": null
501+ },
502+ "notes": "The repository at its new address. Refused with `409` when the destination already has a repository of that name, `403` unless you own both workspaces, and `402` when a free destination has no room for a private repository. The old address redirects. See [Transferring a repository](/guides/transferring-repositories/)."
131503 },
504+ "rename_repo": {
505+ "request": {
506+ "name": "greeter"
507+ },
508+ "response": {
509+ "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
510+ "namespace": "flagon-io",
511+ "name": "greeter",
512+ "description": "A tiny service that says hello.",
513+ "is_private": false,
514+ "owner_id": "usr_01kkntcg1eeb98j62xjm7eh09p",
515+ "default_branch": "main",
516+ "fork_of": null,
517+ "protected": true,
518+ "created_at": "2026-09-28T14:11:52.640Z",
519+ "topics": [
520+ "http",
521+ "example"
522+ ],
523+ "website": "https://hello.g1t.page",
524+ "archived_at": null
525+ },
526+ "notes": "The repository under its new name. Needs the Admin role (`403` otherwise). Refused with `409` when the workspace already has a repository of that name, a recently deleted one included, and with `422` when the name is not valid. The old address redirects. See [Rename a repository](/guides/managing-repositories/#rename-a-repository)."
527+ },
528+ "rename_branch": {
529+ "params": {
530+ "owner": "flagon-io",
531+ "name": "hello",
532+ "branch": "feature/login"
533+ },
534+ "request": {
535+ "new_name": "feature/sign-in"
536+ },
537+ "response": {
538+ "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
539+ "namespace": "flagon-io",
540+ "name": "hello",
541+ "description": "A tiny service that says hello.",
542+ "is_private": false,
543+ "owner_id": "usr_01kkntcg1eeb98j62xjm7eh09p",
544+ "default_branch": "main",
545+ "fork_of": null,
546+ "protected": true,
547+ "created_at": "2026-09-28T14:11:52.640Z",
548+ "topics": [
549+ "http",
550+ "example"
551+ ],
552+ "website": "https://hello.g1t.page",
553+ "archived_at": null
554+ },
555+ "notes": "The repository after the rename; renaming the default branch changes `default_branch`. Send a branch name with slashes URL-encoded, as one path segment. Refused with `404` when there is no such branch, `409` when a branch named `new_name` exists, `422` when `new_name` is not a valid branch name, and `403` without the Write role, or without the Admin role for the default branch. See [Rename a branch](/guides/managing-repositories/#rename-a-branch)."
556+ },
557+ "set_repo_visibility": {
558+ "request": {
559+ "private": true,
560+ "confirm": "flagon-io/hello"
561+ },
562+ "response": {
563+ "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
564+ "namespace": "flagon-io",
565+ "name": "hello",
566+ "description": "A tiny service that says hello.",
567+ "is_private": true,
568+ "owner_id": "usr_01kkntcg1eeb98j62xjm7eh09p",
569+ "default_branch": "main",
570+ "fork_of": null,
571+ "protected": true,
572+ "created_at": "2026-09-28T14:11:52.640Z",
573+ "topics": [
574+ "http",
575+ "example"
576+ ],
577+ "website": "https://hello.g1t.page",
578+ "archived_at": null
579+ },
580+ "notes": "Needs the Admin role (`403` otherwise). Refused with `422` when `confirm` is not the repository's full name, and with `402` when a free workspace's private storage has no room for it. See [Change who can see a repository](/guides/managing-repositories/#change-who-can-see-a-repository)."
581+ },
582+ "archive_repo": {
583+ "response": {
584+ "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
585+ "namespace": "flagon-io",
586+ "name": "hello",
587+ "description": "A tiny service that says hello.",
588+ "is_private": false,
589+ "owner_id": "usr_01kkntcg1eeb98j62xjm7eh09p",
590+ "default_branch": "main",
591+ "fork_of": null,
592+ "protected": true,
593+ "created_at": "2026-09-28T14:11:52.640Z",
594+ "topics": [
595+ "http",
596+ "example"
597+ ],
598+ "website": "https://hello.g1t.page",
599+ "archived_at": "2026-10-05T09:30:00.000Z"
600+ },
601+ "notes": "The repository, with `archived_at` set. Needs the Admin role (`403` otherwise). See [Archive a repository](/guides/managing-repositories/#archive-a-repository)."
602+ },
603+ "unarchive_repo": {
604+ "response": {
605+ "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
606+ "namespace": "flagon-io",
607+ "name": "hello",
608+ "description": "A tiny service that says hello.",
609+ "is_private": false,
610+ "owner_id": "usr_01kkntcg1eeb98j62xjm7eh09p",
611+ "default_branch": "main",
612+ "fork_of": null,
613+ "protected": true,
614+ "created_at": "2026-09-28T14:11:52.640Z",
615+ "topics": [
616+ "http",
617+ "example"
618+ ],
619+ "website": "https://hello.g1t.page",
620+ "archived_at": null
621+ },
622+ "notes": "The repository, with `archived_at` null. Needs the Admin role (`403` otherwise)."
623+ },
624+ "delete_repo": {
625+ "request": {
626+ "confirm": "flagon-io/hello"
627+ },
628+ "response": {
629+ "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
630+ "namespace": "flagon-io",
631+ "name": "hello",
632+ "description": "A tiny service that says hello.",
633+ "is_private": false,
634+ "deleted_at": "2026-10-05T09:30:00.000Z",
635+ "deleted_by": "syntaqx",
636+ "purge_after": "2026-11-04T09:30:00.000Z"
637+ },
638+ "notes": "The deleted repository and when it will be purged, 30 days on. Owners of the workspace only (`403`), whatever their role on the repository. Refused with `422` when `confirm` is not its full name. Its name stays taken until it is purged. See [Delete a repository](/guides/managing-repositories/#delete-a-repository)."
639+ },
640+ "list_deleted_repos": {
641+ "params": {
642+ "workspace": "flagon-io"
643+ },
644+ "response": [
645+ {
646+ "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
647+ "namespace": "flagon-io",
648+ "name": "hello",
649+ "description": "A tiny service that says hello.",
650+ "is_private": false,
651+ "deleted_at": "2026-10-05T09:30:00.000Z",
652+ "deleted_by": "syntaqx",
653+ "purge_after": "2026-11-04T09:30:00.000Z"
654+ }
655+ ],
656+ "notes": "Newest first. Empty for anyone who is not an owner of the workspace."
657+ },
658+ "restore_repo": {
659+ "response": {
660+ "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
661+ "namespace": "flagon-io",
662+ "name": "hello",
663+ "description": "A tiny service that says hello.",
664+ "is_private": false,
665+ "owner_id": "usr_01kkntcg1eeb98j62xjm7eh09p",
666+ "default_branch": "main",
667+ "fork_of": null,
668+ "protected": true,
669+ "created_at": "2026-09-28T14:11:52.640Z",
670+ "topics": [
671+ "http",
672+ "example"
673+ ],
674+ "website": "https://hello.g1t.page",
675+ "archived_at": null
676+ },
677+ "notes": "The repository, back at its address. Name it by the path it had. Owners only (`403`); `404` when no deleted repository had that path, or it has been purged. See [Restore a repository](/guides/managing-repositories/#restore-a-repository)."
678+ },
679+ "purge_repo": {
680+ "request": {
681+ "confirm": "flagon-io/hello"
682+ },
683+ "response": true,
684+ "notes": "Permanent: it cannot be restored afterwards, and its name is free again. Only a deleted repository can be purged (`404` otherwise). Owners only (`403`); `422` when `confirm` is not its full name."
685+ },
132686 "get_repo_settings": {
133687 "response": {
134688 "auto_merge": false,
5931147 "verified": true,
5941148 "workspaces": [
5951149 {
596− "slug": "syntaqx",
1150+ "slug": "flagon-io",
5971151 "role": "owner"
5981152 }
5991153 ]
10891643 "verified": true,
10901644 "workspaces": [
10911645 {
1092− "slug": "syntaqx",
1646+ "slug": "flagon-io",
10931647 "role": "member"
10941648 }
10951649 ]
17862340 },
17872341 "list_actions_secrets_for_workspace": {
17882342 "params": {
1789− "workspace": "syntaqx"
2343+ "workspace": "flagon-io"
17902344 },
17912345 "response": [
17922346 {
18432397 },
18442398 "set_actions_secret_for_workspace": {
18452399 "params": {
1846− "workspace": "syntaqx",
2400+ "workspace": "flagon-io",
18472401 "setting": "NPM_TOKEN"
18482402 },
18492403 "request": {
18782432 },
18792433 "delete_actions_secret_for_workspace": {
18802434 "params": {
1881− "workspace": "syntaqx",
2435+ "workspace": "flagon-io",
18822436 "setting": "NPM_TOKEN"
18832437 },
18842438 "response": true
19062460 },
19072461 "list_actions_variables_for_workspace": {
19082462 "params": {
1909− "workspace": "syntaqx"
2463+ "workspace": "flagon-io"
19102464 },
19112465 "response": [
19122466 {
19522506 },
19532507 "set_actions_variable_for_workspace": {
19542508 "params": {
1955− "workspace": "syntaqx"
2509+ "workspace": "flagon-io"
19562510 },
19572511 "request": {
19582512 "setting": "NODE_VERSION",
20012555 },
20022556 "update_actions_variable_for_workspace": {
20032557 "params": {
2004− "workspace": "syntaqx",
2558+ "workspace": "flagon-io",
20052559 "setting": "NODE_VERSION"
20062560 },
20072561 "request": {
20322586 },
20332587 "delete_actions_variable_for_workspace": {
20342588 "params": {
2035− "workspace": "syntaqx",
2589+ "workspace": "flagon-io",
20362590 "setting": "NODE_VERSION"
20372591 },
20382592 "response": true
20422596 {
20432597 "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd",
20442598 "scope": "repo",
2045− "workspace": "syntaqx",
2046− "repo": "syntaqx/hello",
2599+ "workspace": "flagon-io",
2600+ "repo": "flagon-io/hello",
20472601 "url": "https://hooks.example.com/g1t",
20482602 "events": [
20492603 "git.push",
20602614 },
20612615 "list_webhooks_for_workspace": {
20622616 "params": {
2063− "workspace": "syntaqx"
2617+ "workspace": "flagon-io"
20642618 },
20652619 "response": [
20662620 {
20672621 "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd",
20682622 "scope": "workspace",
2069− "workspace": "syntaqx",
2623+ "workspace": "flagon-io",
20702624 "repo": null,
20712625 "url": "https://hooks.example.com/g1t",
20722626 "events": [
20942648 "hook": {
20952649 "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd",
20962650 "scope": "repo",
2097− "workspace": "syntaqx",
2098− "repo": "syntaqx/hello",
2651+ "workspace": "flagon-io",
2652+ "repo": "flagon-io/hello",
20992653 "url": "https://hooks.example.com/g1t",
21002654 "events": [
21012655 "git.push",
21142668 },
21152669 "create_webhook_for_workspace": {
21162670 "params": {
2117− "workspace": "syntaqx"
2671+ "workspace": "flagon-io"
21182672 },
21192673 "request": {
21202674 "url": "https://hooks.example.com/g1t"
21232677 "hook": {
21242678 "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd",
21252679 "scope": "workspace",
2126− "workspace": "syntaqx",
2680+ "workspace": "flagon-io",
21272681 "repo": null,
21282682 "url": "https://hooks.example.com/g1t",
21292683 "events": [
21502704 "response": {
21512705 "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd",
21522706 "scope": "repo",
2153− "workspace": "syntaqx",
2154− "repo": "syntaqx/hello",
2707+ "workspace": "flagon-io",
2708+ "repo": "flagon-io/hello",
21552709 "url": "https://hooks.example.com/g1t",
21562710 "events": [
21572711 "git.push",
21672721 },
21682722 "update_webhook_for_workspace": {
21692723 "params": {
2170− "workspace": "syntaqx",
2724+ "workspace": "flagon-io",
21712725 "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd"
21722726 },
21732727 "request": {
21762730 "response": {
21772731 "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd",
21782732 "scope": "workspace",
2179− "workspace": "syntaqx",
2733+ "workspace": "flagon-io",
21802734 "repo": null,
21812735 "url": "https://hooks.example.com/g1t",
21822736 "events": [
21992753 },
22002754 "delete_webhook_for_workspace": {
22012755 "params": {
2202− "workspace": "syntaqx",
2756+ "workspace": "flagon-io",
22032757 "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd"
22042758 },
22052759 "response": true
22282782 },
22292783 "ping_webhook_for_workspace": {
22302784 "params": {
2231− "workspace": "syntaqx",
2785+ "workspace": "flagon-io",
22322786 "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd"
22332787 },
22342788 "response": {
22902844 },
22912845 "list_webhook_deliveries_for_workspace": {
22922846 "params": {
2293− "workspace": "syntaqx",
2847+ "workspace": "flagon-io",
22942848 "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd"
22952849 },
22962850 "response": [
23532907 },
23542908 "redeliver_webhook_for_workspace": {
23552909 "params": {
2356− "workspace": "syntaqx",
2910+ "workspace": "flagon-io",
23572911 "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd",
23582912 "delivery": "dlv_01kpx6a2b3c4d5e6f7g8h9j0kz"
23592913 },
23762930 },
23772931 "list_integrations": {
23782932 "params": {
2379− "workspace": "syntaqx"
2933+ "workspace": "flagon-io"
23802934 },
23812935 "response": [
23822936 {
23832937 "id": "con_01kpx3m7q2f8r9t4v6w0y1z2ab",
2384− "workspace": "syntaqx",
2938+ "workspace": "flagon-io",
23852939 "provider": "anthropic",
23862940 "kind": "models",
23872941 "name": "Anthropic",
24022956 },
24032957 {
24042958 "id": "con_01kpx4n8r3g9s0v5w7x1z2a3bc",
2405− "workspace": "syntaqx",
2959+ "workspace": "flagon-io",
24062960 "provider": "jira",
24072961 "kind": "tracker",
24082962 "name": "Jira",
24282982 },
24292983 "connect_integration": {
24302984 "params": {
2431− "workspace": "syntaqx"
2985+ "workspace": "flagon-io"
24322986 },
24332987 "request": {
24342988 "provider": "webhook",
24352989 "config": {
2436− "repo": "syntaqx/hello",
2990+ "repo": "flagon-io/hello",
24372991 "label": "bug"
24382992 }
24392993 },
24402994 "response": {
24412995 "connection": {
24422996 "id": "con_01kpx4n8r3g9s0v5w7x1z2a3bd",
2443− "workspace": "syntaqx",
2997+ "workspace": "flagon-io",
24442998 "provider": "webhook",
24452999 "kind": "alerts",
24463000 "name": "Webhook",
24473001 "config": {
2448− "repo": "syntaqx/hello",
3002+ "repo": "flagon-io/hello",
24493003 "label": "bug",
24503004 "assign": false,
24513005 "write_back": true
24643018 },
24653019 "disconnect_integration": {
24663020 "params": {
2467− "workspace": "syntaqx",
3021+ "workspace": "flagon-io",
24683022 "id": "con_01kpx3m7q2f8r9t4v6w0y1z2ab"
24693023 },
24703024 "response": true
24713025 },
24723026 "test_integration": {
24733027 "params": {
2474− "workspace": "syntaqx",
3028+ "workspace": "flagon-io",
24753029 "id": "con_01kpx3m7q2f8r9t4v6w0y1z2ab"
24763030 },
24773031 "response": {
24823036 },
24833037 "get_model_routes": {
24843038 "params": {
2485− "workspace": "syntaqx"
3039+ "workspace": "flagon-io"
24863040 },
24873041 "response": [
24883042 {
24993053 },
25003054 "set_model_routes": {
25013055 "params": {
2502− "workspace": "syntaqx"
3056+ "workspace": "flagon-io"
25033057 },
25043058 "request": {
25053059 "routes": [
25713125 "response": {
25723126 "id": "mem_01m43v8q2w3e4r5t6y7u8i9o0p",
25733127 "scope": "project",
2574− "workspace": "syntaqx",
3128+ "workspace": "flagon-io",
25753129 "repo": {
2576− "namespace": "syntaqx",
3130+ "namespace": "flagon-io",
25773131 "name": "hello"
25783132 },
25793133 "text": "The tests need TZ=UTC or the date tests fail.",
25823136 "kind": "run",
25833137 "run_id": "arn_01m43v2c1p9k8d7e6f5a4b3c2d",
25843138 "repo": {
2585− "namespace": "syntaqx",
3139+ "namespace": "flagon-io",
25863140 "name": "hello"
25873141 },
25883142 "number": 14
26053159 {
26063160 "id": "mem_01m43t0a1s2d3f4g5h6j7k8l9z",
26073161 "scope": "workspace",
2608− "workspace": "syntaqx",
3162+ "workspace": "flagon-io",
26093163 "repo": null,
26103164 "text": "We use pnpm everywhere, never npm or yarn.",
26113165 "kind": "convention",
26233177 }
26243178 ]
26253179 },
2626− "notes": "Members of the workspace only. Each memory returned is marked used, which keeps it near the front of what agents are given."
3180+ "notes": "Anyone who can read the repository gets its project memory; `workspace` is filled only for members of the workspace, and is empty for anyone else. Each memory returned is marked used, which keeps it near the front of what agents are given."
26273181 },
26283182 "search": {
26293183 "query": {
27043258 ],
27053259 "notes": []
27063260 },
2707− "notes": "`q` takes words, `\"exact phrases\"`, `-words` to leave out, and qualifiers: `repo:owner/name`, `org:` (or `workspace:`), `language:`, `path:` (a glob when it has `*`), `is:issue`, `is:pr`, `is:open`, `is:closed`, `is:merged`, `is:draft`, `is:public`, `is:private`, `author:` and `label:`; most can be left out with a leading `-`, as in `-label:wontfix`. `type` is `repositories`, `code`, `issues`, `pulls` or `people`; without it, `path:` means code, `is:pr` pull requests, `is:open`, `author:` or `label:` issues, and anything else repositories. `counts` says how many results each type has, up to 1,000. Each result's `snippet` (or, for code, each of its `lines`) is a list of parts, `highlight` true where the query matched. Code is searched on default branches and needs a word of three characters or more, unless the query names a `repo:`. Public content is returned to anyone, without a token; private content only to members of its workspace, checked when the search runs, so a repository made private stops appearing at once. A g1t agent's token can search too."
3261+ "notes": "`q` takes words, `\"exact phrases\"`, `-words` to leave out, and qualifiers: `repo:owner/name`, `org:` (or `workspace:`), `language:`, `path:` (a glob when it has `*`), `is:issue`, `is:pr`, `is:open`, `is:closed`, `is:merged`, `is:draft`, `is:public`, `is:private`, `author:` and `label:`; most can be left out with a leading `-`, as in `-label:wontfix`. `type` is `repositories`, `code`, `issues`, `pulls` or `people`; without it, `path:` means code, `is:pr` pull requests, `is:open`, `author:` or `label:` issues, and anything else repositories. `counts` says how many results each type has, up to 1,000. Each result's `snippet` (or, for code, each of its `lines`) is a list of parts, `highlight` true where the query matched. Code is searched on default branches and needs a word of three characters or more, unless the query names a `repo:`. Public content is returned to anyone, without a token; private content only to people who can read it (members of its workspace, and people given a role on the repository), checked when the search runs, so a repository made private, or a role taken away, stops appearing at once. A g1t agent's token can search too."
27083262 },
27093263 "search_context": {
27103264 "params": {
27563310 }
27573311 ]
27583312 },
2759− "notes": "Give `workspace`, or `repo` as `owner/name` for its workspace. `kinds` narrows to some of `project`, `app`, `api`, `package`, `language`, `owner`, `environment`, `integration`, `doc`, `memory`, `issue` and `pull`; in a URL, comma-separated. `mode` is `text` when the search index could not answer and words were matched instead. Memory, and anything from a private project, is returned only to members of the workspace and its agents. A g1t agent searches its own workspace only."
3313+ "notes": "Give `workspace`, or `repo` as `owner/name` for its workspace. `kinds` narrows to some of `project`, `app`, `api`, `package`, `language`, `owner`, `environment`, `integration`, `doc`, `memory`, `issue` and `pull`; in a URL, comma-separated. `mode` is `text` when the search index could not answer and words were matched instead. Memory is returned only to members of the workspace and its agents; anything from a private project, only to those who can read it (members, unless the base permission is None, and people given a role on its repository). A g1t agent searches its own workspace only."
27603314 },
27613315 "get_entity": {
27623316 "params": {
28343388 ]
28353389 },
28363390 "notes": "`id` is the entry's id, or its key: a project's or app's slug, `npm:<name>` (or `cargo:`, `go:`, `pypi:`) for a package, a username for an owner, `<project>/production` for an environment. `data` depends on the kind: a package's version and dependencies, an API's routes, an app's production address."
3391+ },
3392+ "list_collaborators": {
3393+ "response": {
3394+ "repo": "flagon-io/hello",
3395+ "base_permission": "write",
3396+ "people": [
3397+ {
3398+ "username": "syntaqx",
3399+ "name": "Chase Pierce",
3400+ "avatar": null,
3401+ "role": "admin",
3402+ "source": "owner",
3403+ "direct": null,
3404+ "workspace_role": "owner"
3405+ },
3406+ {
3407+ "username": "linus",
3408+ "name": null,
3409+ "avatar": null,
3410+ "role": "maintain",
3411+ "source": "direct",
3412+ "direct": "maintain",
3413+ "workspace_role": "member"
3414+ },
3415+ {
3416+ "username": "grace",
3417+ "name": "Grace Hopper",
3418+ "avatar": null,
3419+ "role": "write",
3420+ "source": "base",
3421+ "direct": null,
3422+ "workspace_role": "member"
3423+ },
3424+ {
3425+ "username": "ada",
3426+ "name": "Ada Lovelace",
3427+ "avatar": null,
3428+ "role": "triage",
3429+ "source": "direct",
3430+ "direct": "triage",
3431+ "workspace_role": null
3432+ }
3433+ ],
3434+ "invitations": [
3435+ {
3436+ "id": "rin_01kp3c4d5e6f7g8h9j0k1m2n3p",
3437+ "repo": "flagon-io/hello",
3438+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
3439+ "invitee": "alan",
3440+ "email": null,
3441+ "role": "write",
3442+ "invited_by": "syntaqx",
3443+ "inviter_avatar": null,
3444+ "status": "pending",
3445+ "created_at": "2026-10-05T17:00:00.000Z",
3446+ "expires_at": "2026-10-12T17:00:00.000Z"
3447+ },
3448+ {
3449+ "id": "rin_01kp3d7e8f9g0h1j2k3m4n5p6q",
3450+ "repo": "flagon-io/hello",
3451+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
3452+ "invitee": null,
3453+ "email": "joan@example.com",
3454+ "role": "read",
3455+ "invited_by": "syntaqx",
3456+ "inviter_avatar": null,
3457+ "status": "pending",
3458+ "created_at": "2026-10-05T17:00:00.000Z",
3459+ "expires_at": "2026-10-12T17:00:00.000Z"
3460+ }
3461+ ],
3462+ "viewer_role": "admin",
3463+ "can_manage": true
3464+ },
3465+ "notes": "Owners are listed with `source` `owner`, members with the base permission with `base`, and anyone given a role on this repository with `direct`; `role` is the highest of these, and `direct` shows a direct role even when ownership or the base permission gives more. `workspace_role` null means an outside collaborator. Anyone can read a public repository, which is not listed. `invitations` is empty unless you have the Admin role (`can_manage`). Refused with `403` below the Write role, and `404` for a private repository you cannot see. See [Access and roles](/guides/access-and-roles/)."
3466+ },
3467+ "add_collaborator": {
3468+ "request": {
3469+ "invitee": "ada",
3470+ "role": "triage"
3471+ },
3472+ "response": {
3473+ "result": "invited",
3474+ "invitation": {
3475+ "id": "rin_01kp3e9f0g1h2j3k4m5n6p7q8r",
3476+ "repo": "flagon-io/hello",
3477+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
3478+ "invitee": "ada",
3479+ "email": null,
3480+ "role": "triage",
3481+ "invited_by": "syntaqx",
3482+ "inviter_avatar": null,
3483+ "status": "pending",
3484+ "created_at": "2026-10-05T17:00:00.000Z",
3485+ "expires_at": "2026-10-12T17:00:00.000Z"
3486+ }
3487+ },
3488+ "notes": "`invitee` is a username or an email address. A member of the workspace answers `{\"result\": \"granted\", \"collaborator\": {…}}` with the role already given, shown as list_collaborators shows a person. Anyone else answers `{\"result\": \"invited\", \"invitation\": {…}}`: the invitation is emailed and waits 7 days, and the role is theirs once they accept it. An email address without an account gets an invitation with `email` set and `invitee` null, and an invite that makes the account and accepts in one step. Refused with `404` when no account has that username, `409` when they already have a role of their own or a pending invitation (change it with update_collaborator), and `403` without the Admin role, from an agent's or a workspace's token, or when the person does not meet what the workspace asks of everyone with access. The `repo.collaborator_added` webhook event is sent once they have the role. See [Access and roles](/guides/access-and-roles/)."
3489+ },
3490+ "update_collaborator": {
3491+ "params": {
3492+ "owner": "flagon-io",
3493+ "name": "hello",
3494+ "username": "ada"
3495+ },
3496+ "request": {
3497+ "role": "write"
3498+ },
3499+ "response": {
3500+ "username": "ada",
3501+ "name": "Ada Lovelace",
3502+ "avatar": null,
3503+ "role": "write",
3504+ "source": "direct",
3505+ "direct": "write",
3506+ "workspace_role": null
3507+ },
3508+ "notes": "Changes a direct role, or the role of the person's pending invitation. `404` when they have neither: an owner's Admin and a member's base permission are not changed here. Changing a direct role sends the `repo.collaborator_role_changed` webhook event. See [Access and roles](/guides/access-and-roles/)."
3509+ },
3510+ "remove_collaborator": {
3511+ "params": {
3512+ "owner": "flagon-io",
3513+ "name": "hello",
3514+ "username": "ada"
3515+ },
3516+ "response": true,
3517+ "notes": "Takes away a direct role. Anyone may remove their own, to leave a repository. `404` when the person has no role of their own on it. A member keeps the workspace's base permission. Sends the `repo.collaborator_removed` webhook event. See [Access and roles](/guides/access-and-roles/)."
3518+ },
3519+ "get_collaborator_permission": {
3520+ "params": {
3521+ "owner": "flagon-io",
3522+ "name": "hello",
3523+ "username": "ada"
3524+ },
3525+ "response": {
3526+ "username": "ada",
3527+ "role": "triage",
3528+ "source": "direct",
3529+ "capabilities": [
3530+ "read",
3531+ "participate",
3532+ "triage"
3533+ ]
3534+ },
3535+ "notes": "`capabilities` lists what the role allows, in the table's order: `read`, `participate`, `triage`, `push`, `merge`, `run`, `manage_settings`, `manage_protection`, `manage_integrations`, `manage_access`, `administer` and `delete` (which also takes an owner of the workspace). `role` and `source` are null, and `capabilities` empty, for someone with no role. Refused with `403` below the Write role, unless you ask about yourself. See [Access and roles](/guides/access-and-roles/)."
3536+ },
3537+ "list_repo_invitations": {
3538+ "response": [
3539+ {
3540+ "id": "rin_01kp3c4d5e6f7g8h9j0k1m2n3p",
3541+ "repo": "flagon-io/hello",
3542+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
3543+ "invitee": "alan",
3544+ "email": null,
3545+ "role": "write",
3546+ "invited_by": "syntaqx",
3547+ "inviter_avatar": null,
3548+ "status": "pending",
3549+ "created_at": "2026-10-05T17:00:00.000Z",
3550+ "expires_at": "2026-10-12T17:00:00.000Z"
3551+ },
3552+ {
3553+ "id": "rin_01kp3d7e8f9g0h1j2k3m4n5p6q",
3554+ "repo": "flagon-io/hello",
3555+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
3556+ "invitee": null,
3557+ "email": "joan@example.com",
3558+ "role": "read",
3559+ "invited_by": "syntaqx",
3560+ "inviter_avatar": null,
3561+ "status": "pending",
3562+ "created_at": "2026-10-05T17:00:00.000Z",
3563+ "expires_at": "2026-10-12T17:00:00.000Z"
3564+ }
3565+ ],
3566+ "notes": "Pending invitations only, newest first. `email` is set for an invitation sent to an address that had no account. Refused with `403` without the Admin role. See [Access and roles](/guides/access-and-roles/)."
3567+ },
3568+ "revoke_repo_invitation": {
3569+ "params": {
3570+ "owner": "flagon-io",
3571+ "name": "hello",
3572+ "id": "rin_01kp3c4d5e6f7g8h9j0k1m2n3p"
3573+ },
3574+ "response": {
3575+ "id": "rin_01kp3c4d5e6f7g8h9j0k1m2n3p",
3576+ "repo": "flagon-io/hello",
3577+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
3578+ "invitee": "alan",
3579+ "email": null,
3580+ "role": "write",
3581+ "invited_by": "syntaqx",
3582+ "inviter_avatar": null,
3583+ "status": "revoked",
3584+ "created_at": "2026-10-05T17:00:00.000Z",
3585+ "expires_at": "2026-10-12T17:00:00.000Z"
3586+ },
3587+ "notes": "`404` when there is no pending invitation with that id on this repository."
3588+ },
3589+ "list_my_repo_invitations": {
3590+ "response": [
3591+ {
3592+ "id": "rin_01kp3e9f0g1h2j3k4m5n6p7q8r",
3593+ "repo": "flagon-io/hello",
3594+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
3595+ "invitee": "ada",
3596+ "email": null,
3597+ "role": "triage",
3598+ "invited_by": "syntaqx",
3599+ "inviter_avatar": null,
3600+ "status": "pending",
3601+ "created_at": "2026-10-05T17:00:00.000Z",
3602+ "expires_at": "2026-10-12T17:00:00.000Z"
3603+ }
3604+ ],
3605+ "notes": "Invitations sent to your username, and to any of your confirmed addresses before you had an account, newest first. Accept one with `PATCH /user/repository_invitations/{id}`, or decline it with `DELETE`. Expired and answered ones are left out."
3606+ },
3607+ "accept_repo_invitation": {
3608+ "params": {
3609+ "id": "rin_01kp3e9f0g1h2j3k4m5n6p7q8r"
3610+ },
3611+ "response": {
3612+ "id": "rin_01kp3e9f0g1h2j3k4m5n6p7q8r",
3613+ "repo": "flagon-io/hello",
3614+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
3615+ "invitee": "ada",
3616+ "email": null,
3617+ "role": "triage",
3618+ "invited_by": "syntaqx",
3619+ "inviter_avatar": null,
3620+ "status": "accepted",
3621+ "created_at": "2026-10-05T17:00:00.000Z",
3622+ "expires_at": "2026-10-12T17:00:00.000Z"
3623+ },
3624+ "notes": "The role is yours at once. `404` when you have no pending invitation with that id (it may have expired or been revoked), and `403` when your account does not meet what the workspace asks of everyone with access, such as two-factor authentication, with a message that says what to turn on. See [Access and roles](/guides/access-and-roles/)."
3625+ },
3626+ "decline_repo_invitation": {
3627+ "params": {
3628+ "id": "rin_01kp3e9f0g1h2j3k4m5n6p7q8r"
3629+ },
3630+ "response": {
3631+ "id": "rin_01kp3e9f0g1h2j3k4m5n6p7q8r",
3632+ "repo": "flagon-io/hello",
3633+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
3634+ "invitee": "ada",
3635+ "email": null,
3636+ "role": "triage",
3637+ "invited_by": "syntaqx",
3638+ "inviter_avatar": null,
3639+ "status": "declined",
3640+ "created_at": "2026-10-05T17:00:00.000Z",
3641+ "expires_at": "2026-10-12T17:00:00.000Z"
3642+ },
3643+ "notes": "`404` when you have no pending invitation with that id."
3644+ },
3645+ "set_base_permission": {
3646+ "params": {
3647+ "workspace": "flagon-io"
3648+ },
3649+ "request": {
3650+ "base_permission": "read"
3651+ },
3652+ "response": {
3653+ "workspace": "flagon-io",
3654+ "base_permission": "read"
3655+ },
3656+ "notes": "`base_permission` is `none`, `read`, `write` (the default) or `admin`. Owners keep Admin, and a role given on a repository directly still counts where it is higher. Refused with `403` for anyone but an owner signed in as a person. See [Access and roles](/guides/access-and-roles/)."
3657+ },
3658+ "list_outside_collaborators": {
3659+ "params": {
3660+ "workspace": "flagon-io"
3661+ },
3662+ "response": [
3663+ {
3664+ "username": "ada",
3665+ "name": "Ada Lovelace",
3666+ "avatar": null,
3667+ "repos": [
3668+ {
3669+ "repo": "flagon-io/docs",
3670+ "role": "write"
3671+ },
3672+ {
3673+ "repo": "flagon-io/hello",
3674+ "role": "triage"
3675+ }
3676+ ]
3677+ }
3678+ ],
3679+ "notes": "By username, each with the repositories they have a role on. Refused with `403` for anyone but an owner. See [Access and roles](/guides/access-and-roles/)."
28373680 }
28383681 }
+39−0
1+//! Old addresses: a workspace's slug after a rename, and a repository's
2+//! path after a transfer.
3+//!
14 //! Old workspace slugs, after a rename. An operation that names a
25 //! repository (`owner/name`) or a workspace by a slug the workspace has
36 //! since been renamed from is run again under the slug it has now, so
5558 Ok(current.map(|new| rewrite(input, old, &new)))
5659 }
5760
61+/// The input under a transferred repository's path now, if `repo` names
62+/// the path it left.
63+pub async fn transferred(services: &Services, input: &Value) -> Result<Option<Value>> {
64+ let Some((namespace, name)) = input["repo"].as_str().and_then(|repo| repo.split_once('/')) else {
65+ return Ok(None);
66+ };
67+ let now: Option<g1t_contracts::repos::RepoPath> = g1t_kit::call(
68+ &services.repos,
69+ "resolve_path",
70+ &g1t_contracts::repos::ResolvePathArgs {
71+ path: g1t_contracts::repos::RepoPath {
72+ namespace: namespace.to_owned(),
73+ name: name.to_owned(),
74+ },
75+ },
76+ )
77+ .await?;
78+ Ok(now.map(|path| moved_to(input, &format!("{}/{}", path.namespace, path.name))))
79+}
80+
81+/// `input` with `repo` replaced by `path`.
82+pub fn moved_to(input: &Value, path: &str) -> Value {
83+ let mut input = input.clone();
84+ input["repo"] = Value::String(path.to_owned());
85+ input
86+}
87+
5888 #[cfg(test)]
5989 mod tests {
6090 use super::*;
6898 }
6999
70100 #[test]
101+ fn a_transferred_repository_is_named_by_its_new_path() {
102+ let input = json!({ "repo": "syntaqx/g1t", "number": 4 });
103+ assert_eq!(
104+ moved_to(&input, "flagon-io/g1t"),
105+ json!({ "repo": "flagon-io/g1t", "number": 4 })
106+ );
107+ }
108+
109+ #[test]
71110 fn rewrites_only_the_old_slug() {
72111 let input = json!({ "repo": "Acme/rocket", "workspace": "acme", "title": "acme/x" });
73112 assert_eq!(
+40−3
77 //! encoded again, so that every field the type has is sent, not only the
88 //! ones an example shows.
99
10−use g1t_contracts::{actions, integrations, repos, search, webhooks, work};
10+use g1t_contracts::{access, actions, integrations, repos, search, webhooks, work};
1111 use g1t_kit::wire::{self, USER_KEYED};
1212 use serde::Serialize;
1313 use serde::de::DeserializeOwned;
6363
6464 /// What the service behind an operation sends, from its example.
6565 fn sample(op: Op, example: &Value) -> Value {
66+ // Types serde already writes in `snake_case`: a person, and who has
67+ // access. Sent as they are.
68+ let as_is = example.clone();
69+ match op {
70+ Op::Whoami => return through::<g1t_contracts::User>(op, as_is),
71+ Op::ListCollaborators => return through::<access::RepoAccess>(op, as_is),
72+ Op::AddCollaborator => return through::<access::Added>(op, as_is),
73+ Op::UpdateCollaborator => return through::<access::Collaborator>(op, as_is),
74+ Op::GetCollaboratorPermission => return through::<access::PermissionInfo>(op, as_is),
75+ Op::ListRepoInvitations | Op::ListMyRepoInvitations => {
76+ return through::<Vec<access::RepoInvitation>>(op, as_is);
77+ }
78+ Op::RevokeRepoInvitation | Op::AcceptRepoInvitation | Op::DeclineRepoInvitation => {
79+ return through::<access::RepoInvitation>(op, as_is);
80+ }
81+ Op::ListOutsideCollaborators => return through::<Vec<access::OutsideCollaborator>>(op, as_is),
82+ _ => {}
83+ }
6684 let sent = as_services_send(example);
6785 match op {
86+ Op::CreateWorkspace => through::<g1t_contracts::identity::Workspace>(op, sent),
6887 Op::ListRepos => through::<Vec<repos::Repo>>(op, sent),
6988 Op::Search => through::<search::SearchResults>(op, sent),
70− Op::GetRepo | Op::CreateRepo | Op::UpdateRepo => through::<repos::Repo>(op, sent),
89+ Op::GetRepo
90+ | Op::CreateRepo
91+ | Op::UpdateRepo
92+ | Op::TransferRepo
93+ | Op::RenameRepo
94+ | Op::RenameBranch
95+ | Op::ArchiveRepo
96+ | Op::UnarchiveRepo
97+ | Op::SetRepoVisibility
98+ | Op::RestoreRepo => through::<repos::Repo>(op, sent),
99+ Op::DeleteRepo => through::<repos::DeletedRepo>(op, sent),
100+ Op::ListDeletedRepos => through::<Vec<repos::DeletedRepo>>(op, sent),
71101 Op::GetRepoSettings | Op::UpdateRepoSettings => through::<work::RepoSettings>(op, sent),
72102 Op::GetMergeQueue => through::<work::QueueView>(op, sent),
73103 Op::ListIssues => through::<Vec<work::Issue>>(op, sent),
92122 Op::ListIntegrations => through::<Vec<integrations::Connection>>(op, sent),
93123 Op::GetModelRoutes | Op::SetModelRoutes => through::<Vec<integrations::ModelRoute>>(op, sent),
94124 Op::ListEvents => through::<Vec<g1t_contracts::events::Event>>(op, sent),
95− Op::Whoami => through::<g1t_contracts::User>(op, sent),
125+ Op::ListEmails | Op::AddEmail | Op::RemoveEmail | Op::UpdateEmailSettings => {
126+ through::<g1t_contracts::accounts::AccountEmails>(op, sent)
127+ }
128+ Op::ListInvites => through::<g1t_contracts::identity::InvitesOverview>(op, sent),
129+ Op::CreateInvite | Op::RevokeInvite | Op::InviteMember | Op::RevokeWorkspaceInvite => {
130+ through::<g1t_contracts::identity::Invite>(op, sent)
131+ }
132+ Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent),
96133 _ => sent,
97134 }
98135 }
+127−3
3030 pub const ROUTES: &[Route] = &[
3131 route("GET", "/user", Op::Whoami, &[]),
3232 route("POST", "/workspaces", Op::CreateWorkspace, &[]),
33+ route("DELETE", "/workspaces/:workspace", Op::DeleteWorkspace, &[]),
34+ route("GET", "/user/emails", Op::ListEmails, &[]),
35+ route("POST", "/user/emails", Op::AddEmail, &[]),
36+ route("DELETE", "/user/emails/:email", Op::RemoveEmail, &[]),
37+ route("PATCH", "/user/email-settings", Op::UpdateEmailSettings, &[]),
38+ route("GET", "/user/invites", Op::ListInvites, &[]),
39+ route("POST", "/user/invites", Op::CreateInvite, &[]),
40+ route("DELETE", "/user/invites/:id", Op::RevokeInvite, &[]),
41+ route("GET", "/workspaces/:workspace/invitations", Op::ListWorkspaceInvites, &[]),
42+ route("POST", "/workspaces/:workspace/invitations", Op::InviteMember, &[]),
43+ route("DELETE", "/workspaces/:workspace/invitations/:id", Op::RevokeWorkspaceInvite, &[]),
44+ // Who has access. GitHub's addresses, but for adding someone, which
45+ // takes an email address as well as a username.
46+ route("GET", "/repos/:owner/:name/collaborators", Op::ListCollaborators, &[]),
47+ route("POST", "/repos/:owner/:name/collaborators", Op::AddCollaborator, &[]),
48+ route("PATCH", "/repos/:owner/:name/collaborators/:username", Op::UpdateCollaborator, &[]),
49+ route("DELETE", "/repos/:owner/:name/collaborators/:username", Op::RemoveCollaborator, &[]),
50+ route(
51+ "GET",
52+ "/repos/:owner/:name/collaborators/:username/permission",
53+ Op::GetCollaboratorPermission,
54+ &[],
55+ ),
56+ route("GET", "/repos/:owner/:name/invitations", Op::ListRepoInvitations, &[]),
57+ route("DELETE", "/repos/:owner/:name/invitations/:id", Op::RevokeRepoInvitation, &[]),
58+ route("GET", "/user/repository_invitations", Op::ListMyRepoInvitations, &[]),
59+ route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]),
60+ route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]),
61+ route("PATCH", "/workspaces/:workspace", Op::SetBasePermission, &[]),
62+ route(
63+ "GET",
64+ "/workspaces/:workspace/outside_collaborators",
65+ Op::ListOutsideCollaborators,
66+ &[],
67+ ),
3368 route("GET", "/repos", Op::ListRepos, &[("q", "query")]),
3469 route(
3570 "GET",
4075 route("POST", "/repos", Op::CreateRepo, &[]),
4176 route("GET", "/repos/:owner/:name", Op::GetRepo, &[]),
4277 route("PATCH", "/repos/:owner/:name", Op::UpdateRepo, &[]),
78+ route("POST", "/repos/:owner/:name/transfer", Op::TransferRepo, &[]),
79+ route("DELETE", "/repos/:owner/:name", Op::DeleteRepo, &[]),
80+ route(
81+ "GET",
82+ "/workspaces/:workspace/repos/deleted",
83+ Op::ListDeletedRepos,
84+ &[],
85+ ),
86+ route("POST", "/repos/:owner/:name/restore", Op::RestoreRepo, &[]),
87+ route("POST", "/repos/:owner/:name/purge", Op::PurgeRepo, &[]),
88+ route("POST", "/repos/:owner/:name/rename", Op::RenameRepo, &[]),
89+ route("POST", "/repos/:owner/:name/archive", Op::ArchiveRepo, &[]),
90+ route("POST", "/repos/:owner/:name/unarchive", Op::UnarchiveRepo, &[]),
91+ route(
92+ "POST",
93+ "/repos/:owner/:name/visibility",
94+ Op::SetRepoVisibility,
95+ &[],
96+ ),
97+ route(
98+ "POST",
99+ "/repos/:owner/:name/branches/:branch/rename",
100+ Op::RenameBranch,
101+ &[],
102+ ),
43103 route(
44104 "GET",
45105 "/repos/:owner/:name/settings",
523583 }
524584 }
525585
586+/// A path segment with its `%XX` escapes decoded; as given when that is not
587+/// UTF-8.
588+fn percent_decoded(segment: &str) -> String {
589+ let bytes = segment.as_bytes();
590+ let mut out = Vec::with_capacity(bytes.len());
591+ let mut i = 0;
592+ while i < bytes.len() {
593+ let escaped = (bytes[i] == b'%')
594+ .then(|| segment.get(i + 1..i + 3))
595+ .flatten()
596+ .filter(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
597+ .and_then(|hex| u8::from_str_radix(hex, 16).ok());
598+ match escaped {
599+ Some(byte) => {
600+ out.push(byte);
601+ i += 3;
602+ }
603+ None => {
604+ out.push(bytes[i]);
605+ i += 1;
606+ }
607+ }
608+ }
609+ String::from_utf8(out).unwrap_or_else(|_| segment.to_owned())
610+}
611+
526612 /// The route for a request, and the operation input it describes.
527613 ///
528614 /// The input is the JSON body, overlaid with the query parameters the route
557643 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
558644 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
559645 }
560− for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting"] {
646+ for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting", "username"] {
561647 if let Some(value) = param(key) {
562648 input.insert(key.to_owned(), Value::String(value.to_owned()));
563649 }
564650 }
651+ // A branch name may hold slashes, sent URL-encoded as one segment.
652+ if let Some(branch) = param("branch") {
653+ input.insert("branch".to_owned(), Value::String(percent_decoded(branch)));
654+ }
565655 // GitHub says some things with the path alone.
566656 if route.path.ends_with("/enable") || route.path.ends_with("/disable") {
567657 input.insert("enabled".to_owned(), Value::Bool(route.path.ends_with("/enable")));
589679 fn a_path_resolves_to_its_operation_and_input() {
590680 let (route, input) = resolve(
591681 "POST",
592− "/repos/syntaqx/hello/pulls/14/merge",
682+ "/repos/flagon-io/hello/pulls/14/merge",
593683 &[],
594684 json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
595685 )
598688 // What the path names wins over the body.
599689 assert_eq!(
600690 input,
601− json!({ "keep_issue_open": true, "number": 14, "repo": "syntaqx/hello" })
691+ json!({ "keep_issue_open": true, "number": 14, "repo": "flagon-io/hello" })
602692 );
603693 }
604694
605695 #[test]
696+ fn a_branch_with_slashes_is_one_encoded_segment() {
697+ let (route, input) = resolve(
698+ "POST",
699+ "/repos/flagon-io/hello/branches/feature%2Flogin/rename",
700+ &[],
701+ json!({ "new_name": "feature/sign-in" }),
702+ )
703+ .unwrap();
704+ assert_eq!(route.op, Op::RenameBranch);
705+ assert_eq!(
706+ input,
707+ json!({ "new_name": "feature/sign-in", "branch": "feature/login", "repo": "flagon-io/hello" })
708+ );
709+ assert_eq!(percent_decoded("100%"), "100%");
710+ assert_eq!(percent_decoded("a%2bb%zz"), "a+b%zz");
711+ }
712+
713+ #[test]
714+ fn a_collaborator_is_named_by_username() {
715+ let (route, input) = resolve(
716+ "PATCH",
717+ "/repos/flagon-io/hello/collaborators/ada",
718+ &[],
719+ json!({ "role": "maintain" }),
720+ )
721+ .unwrap();
722+ assert_eq!(route.op, Op::UpdateCollaborator);
723+ assert_eq!(input, json!({ "role": "maintain", "username": "ada", "repo": "flagon-io/hello" }));
724+ let (route, input) = resolve("DELETE", "/user/repository_invitations/rin_1", &[], Value::Null).unwrap();
725+ assert_eq!(route.op, Op::DeclineRepoInvitation);
726+ assert_eq!(input, json!({ "id": "rin_1" }));
727+ }
728+
729+ #[test]
606730 fn query_parameters_are_renamed() {
607731 let query = [
608732 ("q".to_owned(), "parser".to_owned()),
+8−17
1414 title: 'g1t docs',
1515 description: 'Guides and reference for g1t, the git forge for teams of agents.',
1616 components: {
17+ Footer: './src/components/Footer.astro',
18+ Head: './src/components/Head.astro',
1719 Header: './src/components/Header.astro',
1820 PageTitle: './src/components/PageTitle.astro',
1921 SiteTitle: './src/components/SiteTitle.astro',
4244 },
4345 logo: { src: '@g1t/theme/mark.svg', alt: '' },
4446 favicon: '/favicon.svg',
45− customCss: ['@g1t/theme/tokens.css', './src/styles/g1t.css'],
46− social: [
47− { icon: 'seti:git', label: 'Source on g1t', href: 'https://g1t.sh/syntaqx/g1t' },
48− ],
47+ customCss: ['@g1t/theme/fonts.css', '@g1t/theme/tokens.css', './src/styles/g1t.css'],
4948 editLink: {
50− baseUrl: 'https://g1t.sh/syntaqx/g1t/blob/main/apps/docs/',
49+ baseUrl: 'https://g1t.sh/flagon-io/g1t/blob/main/apps/docs/',
5150 },
5251 lastUpdated: true,
5352 head: [
5453 { tag: 'link', attrs: { rel: 'icon', href: '/favicon.ico', sizes: '32x32' } },
5554 { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '192x192', href: '/icon-192.png' } },
5655 { tag: 'link', attrs: { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' } },
57− { tag: 'link', attrs: { rel: 'preconnect', href: 'https://fonts.googleapis.com' } },
58− {
59− tag: 'link',
60− attrs: { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: true },
61− },
62− {
63− tag: 'link',
64− attrs: {
65− rel: 'stylesheet',
66− href: 'https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400..700&family=JetBrains+Mono:wght@400;500;600&display=swap',
67− },
68− },
6956 ],
7057 sidebar: [
7158 {
119106 label: 'Workspaces',
120107 items: [
121108 { label: 'Accounts and sign-in', slug: 'guides/authentication' },
109+ { label: 'GitHub', slug: 'guides/github' },
122110 { label: 'Workspaces and tokens', slug: 'guides/workspaces' },
111+ { label: 'Access and roles', slug: 'guides/access-and-roles' },
112+ { label: 'Managing a repository', slug: 'guides/managing-repositories' },
113+ { label: 'Transferring a repository', slug: 'guides/transferring-repositories' },
123114 { label: 'Audit log', slug: 'guides/audit-log' },
124115 { label: 'Usage and billing', slug: 'guides/usage-and-billing' },
125116 { label: 'Git', slug: 'guides/git' },
+3−2
1010 },
1111 "dependencies": {
1212 "@astrojs/starlight": "^0.42.5",
13+ "@g1t/theme": "*",
14+ "@lucide/astro": "^1.52.0",
1315 "astro": "^7.2.10",
14− "sharp": "^0.35.3",
15− "@g1t/theme": "*"
16+ "sharp": "^0.35.3"
1617 },
1718 "private": true,
1819 "license": "MIT"
+3−0
1+# Fingerprinted by the build (typefaces, styles, scripts): kept for good.
2+/_astro/*
3+ Cache-Control: public, max-age=31536000, immutable
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+1−1
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.f{fill:#161618}.f.s{fill:none;stroke:#161618}.m{fill:#6b56e8}@media (prefers-color-scheme: dark){.f{fill:#ededef}.f.s{fill:none;stroke:#ededef}.m{fill:#b6a8ff}}</style><mask id="cut" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><path d="M10.5 3.5 8.5 5.4" stroke="#000" stroke-width="3.6" stroke-linecap="round"/></mask><g mask="url(#cut)"><rect class="m" x="3" y="5.5" width="2" height="8.5" rx="1" fill-opacity="0.5"/><rect class="m" x="6" y="4" width="2" height="10" rx="1" fill-opacity="0.85"/></g><rect class="f" x="9" y="2" width="3" height="12" rx="1.5"/><path class="f s" d="M10.5 3.5 8.5 5.4" stroke-width="2.2" stroke-linecap="round"/></svg>
1+<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><rect x="7" y="1" width="2" height="2"/><rect x="5" y="3" width="2" height="2"/><rect x="7" y="3" width="2" height="2"/><rect x="3" y="5" width="2" height="2"/><rect x="7" y="5" width="2" height="2"/><rect x="7" y="7" width="2" height="2"/><rect x="7" y="9" width="2" height="2"/><rect x="7" y="11" width="2" height="2"/><rect x="3" y="13" width="2" height="2"/><rect x="5" y="13" width="2" height="2"/><rect x="7" y="13" width="2" height="2"/><rect x="9" y="13" width="2" height="2"/><rect x="11" y="13" width="2" height="2"/></svg>
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+2−2
2222 const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
2323
2424 /** Values for path parameters an example does not give. */
25−const DEFAULT_PARAMS = { owner: 'syntaqx', name: 'hello', workspace: 'syntaqx' };
25+const DEFAULT_PARAMS = { owner: 'flagon-io', name: 'hello', workspace: 'flagon-io' };
2626
2727 const STATUS = {
2828 401: ['unauthenticated', 'A token is required, or the one sent is not valid.'],
29− 402: ['payment_required', 'The workspace has no agent credit. See [usage and billing](/guides/usage-and-billing/#when-credit-runs-out).'],
29+ 402: ['payment_required', 'The workspace cannot start this work: it needs the g1t plan or a card check, or it is at a limit. See [usage and billing](/guides/usage-and-billing/#when-work-is-stopped).'],
3030 403: ['forbidden', 'The token is valid but not allowed to do this, such as a member-only change or an agent token outside its repository.'],
3131 404: ['not_found', 'It does not exist, or you cannot see it.'],
3232 409: ['conflict', 'The request conflicts with the current state.'],
+32−0
1+---
2+// How to connect a coding agent to g1t: a tab per agent. Every block on
3+// the page switches together, and Starlight remembers the choice
4+// (`syncKey`). The snippets live in `src/data/agents.ts`.
5+import { Code, TabItem, Tabs } from '@astrojs/starlight/components';
6+
7+import { AGENTS, AGENTS_WITH_TOKEN } from '../data/agents';
8+
9+interface Props {
10+ /** `token` shows each agent with a bearer token instead of signing in through the browser. */
11+ variant?: 'browser' | 'token';
12+}
13+
14+const { variant = 'browser' } = Astro.props;
15+const agents = variant === 'token' ? AGENTS_WITH_TOKEN : AGENTS;
16+const parts = (text: string) => text.split('`').map((part, index) => ({ part, code: index % 2 === 1 }));
17+---
18+
19+<Tabs syncKey="agent">
20+ {
21+ agents.map((agent) => (
22+ <TabItem label={agent.label}>
23+ <Code code={agent.code} lang={agent.lang} title={agent.file ?? undefined} frame={agent.file ? 'code' : 'none'} />
24+ {agent.then && (
25+ <p>
26+ {parts(agent.then).map(({ part, code }) => (code ? <code>{part}</code> : part))}
27+ </p>
28+ )}
29+ </TabItem>
30+ ))
31+ }
32+</Tabs>
+31−0
1+---
2+// Starlight's <Aside>, with a Lucide icon for each type (or `icon`, a name
3+// from lucide.dev). Its look comes from Starlight's global aside styles.
4+import LucideIcon from './LucideIcon.astro';
5+
6+type AsideType = 'note' | 'tip' | 'caution' | 'danger';
7+
8+interface Props {
9+ type?: AsideType;
10+ title?: string;
11+ icon?: string;
12+}
13+
14+const ICONS: Record<AsideType, string> = {
15+ note: 'info',
16+ tip: 'lightbulb',
17+ caution: 'triangle-alert',
18+ danger: 'octagon-alert',
19+};
20+
21+const { type = 'note', icon } = Astro.props;
22+if (!(type in ICONS)) throw new Error(`Aside type must be one of ${Object.keys(ICONS).join(', ')}, not "${type}".`);
23+const title = Astro.props.title ?? Astro.locals.t(`aside.${type}`);
24+---
25+
26+<aside aria-label={title} class={`starlight-aside starlight-aside--${type}`}>
27+ <p class="starlight-aside__title" aria-hidden="true">
28+ <LucideIcon name={icon ?? ICONS[type]} class="starlight-aside__icon" />{title}
29+ </p>
30+ <div class="starlight-aside__content"><slot /></div>
31+</aside>
+50−0
1+---
2+// Starlight's <Card>, with a Lucide icon: `icon` is a name from lucide.dev.
3+// The icon is a little larger than Starlight's so a line icon reads inside
4+// its padded tile.
5+import LucideIcon from './LucideIcon.astro';
6+
7+interface Props {
8+ icon?: string;
9+ title: string;
10+}
11+
12+const { icon, title } = Astro.props;
13+---
14+
15+<article class="card sl-flex">
16+ <p class="title sl-flex">
17+ {icon && <LucideIcon name={icon} class="icon" size="1.6em" />}
18+ <span set:html={title} />
19+ </p>
20+ <div class="body"><slot /></div>
21+</article>
22+
23+<style>
24+ @layer starlight.components {
25+ .card {
26+ border: 1px solid var(--sl-color-gray-5);
27+ background-color: var(--sl-color-black);
28+ padding: clamp(1rem, calc(0.125rem + 3vw), 2.5rem);
29+ flex-direction: column;
30+ gap: clamp(0.5rem, calc(0.125rem + 1vw), 1rem);
31+ }
32+ .title {
33+ font-weight: 600;
34+ font-size: var(--sl-text-h4);
35+ color: var(--sl-color-white);
36+ line-height: var(--sl-line-height-headings);
37+ gap: 1rem;
38+ align-items: center;
39+ }
40+ .card :global(.icon) {
41+ padding: 0.2em;
42+ border-radius: 0.25rem;
43+ flex-shrink: 0;
44+ }
45+ .card .body {
46+ margin: 0;
47+ font-size: clamp(var(--sl-text-sm), calc(0.5rem + 1vw), var(--sl-text-body));
48+ }
49+ }
50+</style>
+56−0
1+---
2+// Starlight's footer, then the same row as g1t.sh's: status, help, the
3+// policies, and who makes g1t.
4+import Default from '@astrojs/starlight/components/Footer.astro';
5+
6+const year = new Date().getUTCFullYear();
7+const links: [string, string][] = [
8+ ['Status', 'https://g1t.sh/status'],
9+ ['Support', 'https://g1t.sh/support'],
10+ ['Policies', 'https://g1t.sh/policies'],
11+ ['Privacy', 'https://g1t.sh/policies/privacy'],
12+ ['Security', 'https://g1t.sh/security'],
13+];
14+---
15+
16+<Default />
17+<nav class="g1t-legal" aria-label="About g1t">
18+ <ul>
19+ {
20+ links.map(([label, href]) => (
21+ <li>
22+ <a href={href}>{label}</a>
23+ </li>
24+ ))
25+ }
26+ <li>© {year} <a href="https://www.flagon.io">Flagon, Inc.</a></li>
27+ </ul>
28+</nav>
29+
30+<style>
31+ .g1t-legal {
32+ margin-top: 2.5rem;
33+ padding-top: 1.25rem;
34+ border-top: 1px solid var(--g1t-line);
35+ font-size: var(--sl-text-xs);
36+ color: var(--g1t-faint);
37+ }
38+ .g1t-legal ul {
39+ display: flex;
40+ flex-wrap: wrap;
41+ gap: 0.375rem 1rem;
42+ margin: 0;
43+ padding: 0;
44+ list-style: none;
45+ }
46+ .g1t-legal li {
47+ margin: 0;
48+ }
49+ .g1t-legal a {
50+ color: var(--g1t-muted);
51+ text-decoration: none;
52+ }
53+ .g1t-legal a:hover {
54+ color: var(--g1t-fg);
55+ }
56+</style>
+12−0
1+---
2+// Starlight's head, then the text and headline faces (from @g1t/theme),
3+// asked for at once rather than once the stylesheet naming them is read. The URLs are
4+// the fingerprinted ones the build gives the files.
5+import Default from '@astrojs/starlight/components/Head.astro';
6+import display from '@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url';
7+import sans from '@g1t/theme/fonts/hanken-grotesk-latin.woff2?url';
8+---
9+
10+<Default />
11+<link rel="preload" href={sans} as="font" type="font/woff2" crossorigin />
12+<link rel="preload" href={display} as="font" type="font/woff2" crossorigin />
+71−0
1+---
2+// Starlight's <LinkCard>, with Lucide's arrow.
3+import type { HTMLAttributes } from 'astro/types';
4+
5+import LucideIcon from './LucideIcon.astro';
6+
7+interface Props extends Omit<HTMLAttributes<'a'>, 'title'> {
8+ title: string;
9+ description?: string;
10+}
11+
12+const { title, description, ...attributes } = Astro.props;
13+---
14+
15+<div class="sl-link-card">
16+ <span class="sl-flex stack">
17+ <a {...attributes}>
18+ <span class="title" set:html={title} />
19+ </a>
20+ {description && <span class="description" set:html={description} />}
21+ </span>
22+ <LucideIcon name="arrow-right" size="1.333em" class="icon rtl:flip" />
23+</div>
24+
25+<style>
26+ @layer starlight.components {
27+ .sl-link-card {
28+ display: grid;
29+ grid-template-columns: 1fr auto;
30+ gap: 0.5rem;
31+ border: 1px solid var(--sl-color-gray-5);
32+ border-radius: 0.5rem;
33+ padding: 1rem;
34+ box-shadow: var(--sl-shadow-sm);
35+ position: relative;
36+ }
37+ a {
38+ text-decoration: none;
39+ line-height: var(--sl-line-height-headings);
40+ }
41+ /* The whole card is the link. */
42+ a::before {
43+ content: '';
44+ position: absolute;
45+ inset: 0;
46+ }
47+ .stack {
48+ flex-direction: column;
49+ gap: 0.5rem;
50+ }
51+ .title {
52+ color: var(--sl-color-white);
53+ font-weight: 600;
54+ font-size: var(--sl-text-lg);
55+ }
56+ .description {
57+ color: var(--sl-color-gray-3);
58+ line-height: 1.5;
59+ }
60+ .sl-link-card :global(.icon) {
61+ color: var(--sl-color-gray-3);
62+ }
63+ .sl-link-card:hover {
64+ background: var(--sl-color-gray-7, var(--sl-color-gray-6));
65+ border-color: var(--sl-color-gray-2);
66+ }
67+ .sl-link-card:hover :global(.icon) {
68+ color: var(--sl-color-white);
69+ }
70+ }
71+</style>
+15−0
1+---
2+// One Lucide icon by its name on lucide.dev (kebab-case, like `git-branch`).
3+// Every interface icon in the docs comes from Lucide; see CONTRIBUTING.md.
4+import { icons } from '@lucide/astro';
5+import type { LucideProps } from '@lucide/astro';
6+
7+type Props = LucideProps & { name: string };
8+
9+const { name, ...rest } = Astro.props;
10+const key = name.replace(/(^|-)([a-z0-9])/g, (_, __, c: string) => c.toUpperCase()) as keyof typeof icons;
11+const Icon = icons[key];
12+if (!Icon) throw new Error(`No Lucide icon named "${name}". Pick one from https://lucide.dev/icons.`);
13+---
14+
15+<Icon {...rest} />
+8−12
22 // A page's title, what it is about beneath it, and a way to take the page
33 // to an AI assistant: copied as Markdown, read as Markdown, or opened in
44 // Claude or ChatGPT.
5+import { ChevronDown, Copy } from '@lucide/astro';
6+
57 const { entry, id } = Astro.locals.starlightRoute;
68 const { title, description } = entry.data;
79 const markdown = `/${id === '' || id === 'index' ? 'index' : id}.md`;
1416 <h1 id="_top">{title}</h1>
1517 <div class="g1t-copy">
1618 <button type="button" class="g1t-copy-main" data-markdown={markdown}>
17− <svg viewBox="0 0 16 16" aria-hidden="true">
18− <rect x="5" y="5" width="9" height="9" rx="1.5"></rect>
19− <path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5"></path>
20− </svg>
19+ <Copy />
2120 <span>Copy page</span>
2221 </button>
2322 <details class="g1t-copy-more">
2423 <summary aria-label="More ways to use this page">
25− <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4"></path></svg>
24+ <ChevronDown />
2625 </summary>
2726 <div class="g1t-copy-menu">
2827 <a href={markdown} target="_blank" rel="noopener">
7877 }
7978 h1 {
8079 margin: 0;
80+ font-family: var(--g1t-font-display);
81+ font-optical-sizing: auto;
8182 font-size: 2rem;
8283 font-weight: 600;
83− letter-spacing: -0.025em;
84+ letter-spacing: -0.015em;
8485 line-height: 1.2;
8586 color: var(--g1t-fg);
8687 }
99100 border-radius: 0.5rem;
100101 box-shadow: inset 0 0 0 1px var(--g1t-line);
101102 }
102− .g1t-copy svg {
103+ .g1t-copy :global(svg) {
103104 width: 0.9rem;
104105 height: 0.9rem;
105− fill: none;
106− stroke: currentColor;
107− stroke-width: 1.3;
108− stroke-linecap: round;
109− stroke-linejoin: round;
110106 }
111107 .g1t-copy-main,
112108 .g1t-copy-more summary {
+23−19
11 ---
2−// The fleet, the name and "Docs": the same mark as g1t.sh, so the two read
3−// as one product.
2+// The wordmark and "Docs": the same G1T in pixel capitals as g1t.sh, the 1
3+// in lavender, so the two read as one product. Copied from
4+// apps/web/app/components/logo.tsx; keep the two in step.
5+const G = ['.###.', '#...#', '#....', '#.###', '#...#', '#...#', '.###.'];
6+const ONE = ['..#..', '.##..', '#.#..', '..#..', '..#..', '..#..', '#####'];
7+const T = ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'];
8+const pixels = (rows: string[], dx: number, one: boolean) =>
9+ rows.flatMap((row, y) => [...row].flatMap((c, x) => (c === '#' ? [{ x: dx + x, y, one }] : [])));
10+const word = [...pixels(G, 0, false), ...pixels(ONE, 6, true), ...pixels(T, 10, false)];
411 ---
512
613 <a href="/" class="g1t-title" aria-label="g1t docs home">
714 <span class="g1t-title-lockup">
8− <svg viewBox="6.9 5 18.2 22" aria-hidden="true">
9− <g transform="translate(0.7 0.5)">
10− <rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="var(--g1t-merged)" fill-opacity="0.35"></rect>
11− <rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="var(--g1t-merged)" fill-opacity="0.65"></rect>
12− <rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="currentColor"></rect>
13− <path d="M21.7 7.2 17.6 10.9" fill="none" stroke="currentColor" stroke-width="4.6" stroke-linecap="round"></path>
14− </g>
15− </svg>
16− <span class="g1t-title-name">g1t</span>
15+ <svg viewBox="1 1 148 68" role="img" aria-label="g1t">
16+ {word.map((p) => (
17+ <rect
18+ x={p.x * 10 + 1}
19+ y={p.y * 10 + 1}
20+ width="8"
21+ height="8"
22+ fill={p.one ? 'var(--g1t-merged)' : 'currentColor'}
23+ />
24+ ))}
25+ </svg>
1726 </span>
1827 <span class="g1t-title-docs">Docs</span>
1928 </a>
2635 color: var(--g1t-fg);
2736 text-decoration: none;
2837 }
29− /* As on g1t.sh: the mark as tall as the capitals, on the baseline. */
38+ /* As on g1t.sh: a cell is 0.125em, the letters 0.85em tall. */
3039 .g1t-title-lockup {
3140 display: inline-flex;
3241 align-items: baseline;
33− gap: 0.3em;
34− font-size: 1.25rem;
35− font-weight: 700;
42+ font-size: 1.5rem;
3643 line-height: 1;
3744 }
3845 svg {
39− height: 0.74em;
46+ height: 0.85em;
4047 width: auto;
4148 flex-shrink: 0;
42− }
43− .g1t-title-name {
44− letter-spacing: -0.045em;
4549 }
4650 .g1t-title-docs {
4751 border-radius: 999px;
+9−10
11 ---
22 // The header's right side: where people go from the docs, then the source.
3+import { GitBranch } from '@lucide/astro';
4+
5+// g1t.sh is invite-only for now. A build with PUBLIC_G1T_REGISTRATION=open
6+// (an installation that lets anyone register) says Sign up again.
7+const inviteOnly = (import.meta.env.PUBLIC_G1T_REGISTRATION ?? 'invite') !== 'open';
38 ---
49
510 <nav class="g1t-nav" aria-label="g1t">
611 <a href="https://g1t.sh/">g1t.sh</a>
7− <a href="https://g1t.sh/register" class="g1t-nav-cta">Sign up</a>
8− <a href="https://g1t.sh/syntaqx/g1t" aria-label="Source on g1t" title="Source on g1t" class="g1t-nav-icon">
9− <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
10− <circle cx="6" cy="6" r="3"></circle>
11− <circle cx="6" cy="18" r="3"></circle>
12− <circle cx="18" cy="9" r="3"></circle>
13− <path d="M6 9v6"></path>
14− <path d="M18 12c0 3-3 3-6 3H9"></path>
15− </svg>
12+ <a href="https://g1t.sh/register" class="g1t-nav-cta">{inviteOnly ? 'Request access' : 'Sign up'}</a>
13+ <a href="https://g1t.sh/flagon-io/g1t" aria-label="Source on g1t" title="Source on g1t" class="g1t-nav-icon">
14+ <GitBranch />
1615 </a>
1716 </nav>
1817
4847 display: inline-flex;
4948 padding: 0.4rem;
5049 }
51− .g1t-nav-icon svg {
50+ .g1t-nav-icon :global(svg) {
5251 width: 1rem;
5352 height: 1rem;
5453 }
+95−0
1+/**
2+ * How each coding agent connects to g1t's MCP server, from each agent's own
3+ * documentation for adding a remote MCP server. The site's copy is
4+ * `apps/web/app/lib/agent-setup.ts`; keep the two in step.
5+ */
6+
7+export const MCP_URL = 'https://mcp.g1t.sh';
8+
9+export type AgentSetup = {
10+ label: string;
11+ /** The file the snippet goes in, or null for a terminal command. */
12+ file: string | null;
13+ lang: 'sh' | 'json' | 'toml';
14+ code: string;
15+ /** What to do after; Markdown is not parsed, `code` in backticks is. */
16+ then: string;
17+};
18+
19+const json = (value: unknown) => JSON.stringify(value, null, 2);
20+
21+export const AGENTS: AgentSetup[] = [
22+ {
23+ label: 'Claude Code',
24+ file: null,
25+ lang: 'sh',
26+ code: `claude mcp add --transport http g1t ${MCP_URL}`,
27+ then: 'Then run `/mcp` in Claude Code and choose g1t to sign in through your browser.',
28+ },
29+ {
30+ label: 'Codex',
31+ file: null,
32+ lang: 'sh',
33+ code: `codex mcp add g1t --url ${MCP_URL}\ncodex mcp login g1t`,
34+ then: 'The login opens your browser to sign in to g1t.',
35+ },
36+ {
37+ label: 'OpenCode',
38+ file: 'opencode.json',
39+ lang: 'json',
40+ code: json({
41+ $schema: 'https://opencode.ai/config.json',
42+ mcp: { g1t: { type: 'remote', url: MCP_URL, enabled: true } },
43+ }),
44+ then: 'OpenCode signs you in through your browser when it first connects; `opencode mcp auth g1t` starts it by hand.',
45+ },
46+ {
47+ label: 'Cursor',
48+ file: '.cursor/mcp.json',
49+ lang: 'json',
50+ code: json({ mcpServers: { g1t: { url: MCP_URL } } }),
51+ then: 'Use `~/.cursor/mcp.json` for every project. Cursor signs you in through your browser when it first connects.',
52+ },
53+];
54+
55+/** The same, with a bearer token instead of signing in through the browser. */
56+export const AGENTS_WITH_TOKEN: AgentSetup[] = [
57+ {
58+ label: 'Claude Code',
59+ file: null,
60+ lang: 'sh',
61+ code: `claude mcp add --transport http g1t ${MCP_URL} \\\n --header "Authorization: Bearer $G1T_TOKEN"`,
62+ then: '',
63+ },
64+ {
65+ label: 'Codex',
66+ file: '~/.codex/config.toml',
67+ lang: 'toml',
68+ code: `[mcp_servers.g1t]\nurl = "${MCP_URL}"\nbearer_token_env_var = "G1T_TOKEN"`,
69+ then: '',
70+ },
71+ {
72+ label: 'OpenCode',
73+ file: 'opencode.json',
74+ lang: 'json',
75+ code: json({
76+ $schema: 'https://opencode.ai/config.json',
77+ mcp: {
78+ g1t: {
79+ type: 'remote',
80+ url: MCP_URL,
81+ oauth: false,
82+ headers: { Authorization: 'Bearer {env:G1T_TOKEN}' },
83+ },
84+ },
85+ }),
86+ then: '',
87+ },
88+ {
89+ label: 'Cursor',
90+ file: '.cursor/mcp.json',
91+ lang: 'json',
92+ code: json({ mcpServers: { g1t: { url: MCP_URL, headers: { Authorization: 'Bearer ${env:G1T_TOKEN}' } } } }),
93+ then: '',
94+ },
95+];
+0−0

Binary or large file; its contents are not shown.

+23−1
11 import type { APIRoute, GetStaticPaths } from 'astro';
22 import { getCollection } from 'astro:content';
33
4+import { AGENTS, AGENTS_WITH_TOKEN, type AgentSetup } from '../data/agents';
5+
6+/** The agent toggle, as plain Markdown: each agent's snippet under its name. */
7+function agentsMarkdown(agents: AgentSetup[]): string {
8+ return agents
9+ .map((agent) => {
10+ const where = agent.file ? ` (in \`${agent.file}\`)` : '';
11+ const then = agent.then ? `\n\n${agent.then}` : '';
12+ const fence = '```';
13+ return `**${agent.label}**${where}:\n\n${fence}${agent.lang}\n${agent.code}\n${fence}${then}`;
14+ })
15+ .join('\n\n');
16+}
17+
418 /**
519 * Every page as Markdown, at its path plus `.md`, for "Copy page" and for
620 * agents that read the docs: the title, what it is about, then the page.
1731 const { page } = props as { page: Awaited<ReturnType<typeof getCollection<'docs'>>>[number] };
1832 const { title, description } = page.data;
1933 // MDX pages keep their markup; strip imports and leave the rest readable.
20− const body = (page.body ?? '').replace(/^import .*$/gm, '').trim();
34+ const body = (page.body ?? '')
35+ .replace(/^import .*$/gm, '')
36+ .replace(/^([ \t]*)<AgentSetup( variant="token")? \/>$/gm, (_match, indent: string, token?: string) =>
37+ agentsMarkdown(token ? AGENTS_WITH_TOKEN : AGENTS)
38+ .split('\n')
39+ .map((line) => (line ? indent + line : line))
40+ .join('\n'),
41+ )
42+ .trim();
2143 const text = `# ${title}\n\n${description ? `> ${description}\n\n` : ''}${body}\n`;
2244 return new Response(text, { headers: { 'content-type': 'text/markdown; charset=utf-8' } });
2345 };
+15−3
137137 border-top: 0;
138138 padding-top: 0.5rem;
139139 }
140+/* Headlines in Bricolage Grotesque, its optical size following the size. */
141+.sl-markdown-content :is(h1, h2) {
142+ font-family: var(--g1t-font-display);
143+ font-optical-sizing: auto;
144+}
140145 .sl-markdown-content h1 {
141146 font-size: 2.25rem;
142147 font-weight: 650;
143− letter-spacing: -0.03em;
148+ letter-spacing: -0.02em;
144149 line-height: 1.15;
145150 color: var(--g1t-fg);
146151 }
151156 .sl-markdown-content h2 {
152157 font-size: 1.5rem;
153158 font-weight: 600;
154− letter-spacing: -0.02em;
159+ letter-spacing: -0.01em;
155160 color: var(--g1t-fg);
156161 }
157162 .sl-markdown-content h3 {
158163 margin-top: 2rem;
159164 font-size: 1.1rem;
160165 font-weight: 600;
161− letter-spacing: -0.01em;
166+ letter-spacing: -0.005em;
162167 color: var(--g1t-fg);
163168 }
164169 .sl-markdown-content p,
188193 border-collapse: collapse;
189194 font-size: 0.875rem;
190195 }
196+/* On a phone a wide table scrolls inside itself, not the page. */
197+@media (max-width: 50rem) {
198+ .sl-markdown-content table {
199+ display: block;
200+ overflow-x: auto;
201+ }
202+}
191203 .sl-markdown-content th {
192204 border-bottom: 1px solid var(--g1t-line-strong);
193205 background: transparent;
+8−1
11 @import "tailwindcss" source(".");
22 @import "@g1t/theme/tokens.css";
3+@import "@g1t/theme/fonts.css";
34
45 /* Tailwind's names for the shared g1t tokens, as in apps/web. */
56 @theme {
67 --font-sans: var(--g1t-font-sans);
8+ --font-display: var(--g1t-font-display);
79 --font-mono: var(--g1t-font-mono);
810
911 --color-bg: var(--g1t-bg);
3234
3335 body {
3436 @apply bg-bg font-sans text-fg antialiased;
35− font-feature-settings: "cv11", "ss01";
3637 }
3738
3839 ::selection {
4950 min-width: 0;
5051 }
5152
53+ /* Page titles in the headline face, as on g1t.sh. */
54+ :where(h1) {
55+ font-family: var(--font-display);
56+ font-optical-sizing: auto;
57+ }
58+
5259 :root {
5360 accent-color: var(--color-merged);
5461 }
+139−17
11 /**
22 * The billing sections shared by a workspace's page and an enterprise's:
3− * confirmations, terms, credit, the Stripe billing link, the ledger and the
4− * audit log. Plain forms; sudo ships no JavaScript.
3+ * confirmations, terms, plan, pools and caps, credit, bank transfers, the
4+ * Stripe billing link, the ledger and the audit log. Plain forms; sudo ships no JavaScript.
55 */
6−import { CreditCard, Gift, ScrollText, UserRound } from "lucide-react";
6+import { CreditCard, Gift, Landmark, ScrollText, UserRound } from "lucide-react";
77 import type { ReactNode } from "react";
88 import { Link } from "react-router";
99
1212 import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
1313 import { actionLabel } from "~/lib/ledgers";
1414 import { dollarsField, usd } from "~/lib/money";
15+import { givenParts } from "~/lib/pricing";
1516 import type { Review, SectionError } from "~/lib/review";
1617
1718 export function Hidden({ values }: { values: Record<string, string> }) {
259260 );
260261 }
261262
262−// --- Plan and pools -----------------------------------------------------------
263+// --- Plan, pools and caps -------------------------------------------------------
263264
265+/** What staff have set beyond the terms, one line each; defaults left out. */
266+export function allowanceLines(allowances: Allowances | undefined, comped: boolean): string[] {
267+ const lines: string[] = [];
268+ if (comped) lines.push("The g1t plan, without its price (comped)");
269+ else if (allowances?.plan) lines.push("The g1t plan, without its price");
270+ if (!allowances) return lines;
271+ if (allowances.ossRepoMicros != null) lines.push(`Open-source pool: ${usd(allowances.ossRepoMicros)} a month for each public repository`);
272+ if (allowances.trialMicros != null) lines.push(`Trial: ${usd(allowances.trialMicros)}, outside the monthly pool`);
273+ if (allowances.maxConcurrentAgents != null) {
274+ lines.push(`${allowances.maxConcurrentAgents} agent${allowances.maxConcurrentAgents === 1 ? "" : "s"} at once`);
275+ }
276+ if (allowances.runCapMicros != null) lines.push(`Run cap: ${usd(allowances.runCapMicros)} a run`);
277+ if (allowances.issueCapMicros != null) lines.push(`Issue cap: ${usd(allowances.issueCapMicros)} an issue`);
278+ if (allowances.hold) lines.push(`Held: ${allowances.hold}`);
279+ return lines;
280+}
281+
264282 /**
265− * The Team plan without charge, and the account's share of g1t's pools.
266− * Comped accounts have Team anyway; this is for partners on other terms.
283+ * The g1t plan without its price, the account's share of g1t's pools, and
284+ * staff's overrides of what owners set: agents at once, the run and issue
285+ * caps, and a hold on new compute. Comped accounts have the plan anyway.
267286 */
268287 export function AllowancesForm({
269288 allowances,
277296 error: SectionError;
278297 }) {
279298 const values = error?.values;
280− const current = allowances ?? { team: false, ossRepoMicros: null, trialMicros: null };
299+ const current: Allowances = allowances ?? { plan: false, ossRepoMicros: null, trialMicros: null };
300+ const lines = allowanceLines(allowances, comped);
281301 return (
282302 <Section
283303 id="allowances"
284− title="Plan and pools"
304+ title="Plan, pools and caps"
285305 description={
286306 comped
287− ? "Comped: the Team plan is on without charge whatever is set here. The pool shares still apply."
288− : "The Team plan without charge, and this account's share of g1t's open-source pool and of trials."
307+ ? "Comped: the g1t plan is on without its price whatever is set here. The pools and caps still apply."
308+ : "The g1t plan without its price, this account's share of g1t's pools, and overrides of what owners set."
289309 }
290310 >
311+ <div className="mb-4 rounded-md border border-line bg-bg px-3.5 py-2.5 text-sm">
312+ <p className="text-xs text-faint">Set now</p>
313+ {lines.length === 0 ? (
314+ <p className="mt-0.5 text-muted">Nothing: the defaults.</p>
315+ ) : (
316+ <ul className="mt-1 space-y-0.5">
317+ {lines.map((line) => (
318+ <li key={line} className={line.startsWith("Held:") ? "text-danger" : "text-fg-soft"}>
319+ {line}
320+ </li>
321+ ))}
322+ </ul>
323+ )}
324+ </div>
291325 <form method="post" action={`${pathname}#allowances`} className="space-y-4">
292326 <input type="hidden" name="intent" value="allowances" />
293327 {error && <Notice tone="error">{error.error}</Notice>}
294328 <label className="flex cursor-pointer items-start gap-2.5 text-sm">
295− <input type="checkbox" name="team" defaultChecked={values ? values.team === "on" : current.team} className="mt-0.5" />
329+ <input type="checkbox" name="plan" defaultChecked={values ? values.plan === "on" : current.plan} className="mt-0.5" />
296330 <span>
297− <span className="block font-medium">Team, without charge</span>
298− <span className="block text-xs text-muted">Its credit, storage and audit log, with no plan to pay for.</span>
331+ <span className="block font-medium">The g1t plan, without its price</span>
332+ <span className="block text-xs text-muted">
333+ Its included usage, storage and caps, with no $20 a month. Usage past it is charged as usual.
334+ </span>
299335 </span>
300336 </label>
301337 <div className="grid gap-4 sm:grid-cols-2">
302− <Field label="Open-source share $" hint="Each public repository, a month. Blank: the default.">
338+ <Field label="Open-source share $" hint="Each public repository, a month. Blank: the default ($2).">
303339 <Input name="oss" inputMode="decimal" placeholder="Default" defaultValue={values?.oss ?? dollarsField(current.ossRepoMicros)} />
304340 </Field>
305− <Field label="Trial credit $" hint="Each workspace, outside the monthly pool. Blank: the default.">
341+ <Field label="Trial $" hint="Each workspace, outside the monthly pool. Blank: the default ($5).">
306342 <Input name="trial" inputMode="decimal" placeholder="Default" defaultValue={values?.trial ?? dollarsField(current.trialMicros)} />
307343 </Field>
344+ <Field label="Agents at once" hint="Blank: the plan's (2 in the first month or on the trial, then 10).">
345+ <Input
346+ name="agents"
347+ inputMode="numeric"
348+ pattern="\d{1,3}"
349+ placeholder="Default"
350+ defaultValue={values?.agents ?? (current.maxConcurrentAgents != null ? String(current.maxConcurrentAgents) : "")}
351+ />
352+ </Field>
353+ <Field label="Run cap $" hint="One run's spend, over the owners'. Blank: theirs, or $2.">
354+ <Input name="runCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.runCap ?? dollarsField(current.runCapMicros)} />
355+ </Field>
356+ <Field label="Issue cap $" hint="One issue's agents in all, over the owners'. Blank: theirs, or $10.">
357+ <Input name="issueCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.issueCap ?? dollarsField(current.issueCapMicros)} />
358+ </Field>
359+ <Field label="Hold" hint="Pauses new compute and tells the owners why. Blank: no hold.">
360+ <Input name="hold" maxLength={200} placeholder="No hold" defaultValue={values?.hold ?? current.hold ?? ""} />
361+ </Field>
308362 </div>
309363 <Field label="Note" hint="Required. Why, for whoever looks next.">
310− <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Open-source foundation, larger share through 2027" />
364+ <Textarea
365+ name="note"
366+ rows={2}
367+ required
368+ maxLength={500}
369+ defaultValue={values?.note ?? ""}
370+ placeholder="e.g. Open-source foundation, larger share through 2027"
371+ />
372+ </Field>
373+ <div className="flex justify-end">
374+ <Button type="submit">Save plan, pools and caps</Button>
375+ </div>
376+ </form>
377+ </Section>
378+ );
379+}
380+
381+// --- Bank transfer ---------------------------------------------------------------
382+
383+/**
384+ * Money that reached g1t outside Stripe's page, such as a bank transfer
385+ * for a $1,000+ prepayment, entered as a payment.
386+ */
387+export function PaymentForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) {
388+ const values = error?.values;
389+ return (
390+ <Section
391+ id="payment"
392+ title="Record a bank transfer"
393+ description="Money that reached g1t's bank without Stripe's page, such as a $1,000+ prepayment. It goes on the statement as a payment and counts toward the limit at once."
394+ >
395+ <form method="post" action={`${pathname}#payment`} className="space-y-4">
396+ <input type="hidden" name="intent" value="payment" />
397+ {error && <Notice tone="error">{error.error}</Notice>}
398+ <div className="grid gap-4 sm:grid-cols-2">
399+ <Field label="Amount $" hint="As it arrived, up to $100,000.">
400+ <Input name="amount" inputMode="decimal" required placeholder="1,500.00" defaultValue={values?.amount ?? ""} />
401+ </Field>
402+ <Field label="Reference" hint="As the bank shows it. Each is recorded once.">
403+ <Input name="reference" required maxLength={100} className="font-mono" defaultValue={values?.reference ?? ""} />
404+ </Field>
405+ </div>
406+ <Field label="Note" hint="Required. Shown on the statement after “Paid by bank transfer”.">
407+ <Textarea
408+ name="note"
409+ rows={2}
410+ required
411+ maxLength={500}
412+ placeholder="e.g. Prepayment for October, invoice INV-0042"
413+ defaultValue={values?.note ?? ""}
414+ />
311415 </Field>
416+ <Field
417+ label="Confirm"
418+ hint={
419+ <>
420+ Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to record it.
421+ </>
422+ }
423+ >
424+ <Input name="confirmation" required placeholder={workspace} className="font-mono" />
425+ </Field>
312426 <div className="flex justify-end">
313− <Button type="submit">Save plan and pools</Button>
427+ <Button type="submit" variant="lavender">
428+ <Landmark size={14} />
429+ Record the payment
430+ </Button>
314431 </div>
315432 </form>
316433 </Section>
471588 </Link>
472589 )}
473590 <span className="break-words">{entry.description}</span>
591+ {givenParts(entry).map((part) => (
592+ <Badge key={part.label} tone="lavender">
593+ Given · {part.label} {usd(part.micros)}
594+ </Badge>
595+ ))}
474596 </div>
475597 <p className="mt-0.5 font-mono text-xs text-faint">
476598 {[
+7−3
110110 <span className="hidden group-open:inline">Hide the table</span>
111111 </summary>
112112 <div className="mt-2 overflow-x-auto">
113− <table className="w-full min-w-[26rem] text-xs">
113+ <table className="w-full min-w-[34rem] text-xs">
114114 <thead>
115115 <tr className="border-b border-line text-left text-muted">
116116 <th className="py-1.5 pr-3 font-medium">Month</th>
117117 <th className="py-1.5 pr-3 text-right font-medium">Charged</th>
118+ <th className="py-1.5 pr-3 text-right font-medium">Plans</th>
118119 <th className="py-1.5 pr-3 text-right font-medium">Cost</th>
119− <th className="py-1.5 pr-3 text-right font-medium">Margin</th>
120+ <th className="py-1.5 pr-3 text-right font-medium">Given</th>
121+ <th className="py-1.5 pr-3 text-right font-medium">Net</th>
120122 <th className="py-1.5 text-right font-medium">Paid</th>
121123 </tr>
122124 </thead>
123125 <tbody>
124126 {months.map((month) => {
125− const margin = marginOf(month.chargedMicros, month.costMicros);
127+ const margin = marginOf(month.chargedMicros + (month.plansMicros ?? 0), month.costMicros);
126128 return (
127129 <tr key={month.month} className="tabular border-b border-line last:border-0">
128130 <td className="py-1.5 pr-3 text-fg-soft">{monthLong(month.month)}</td>
129131 <td className="py-1.5 pr-3 text-right">{usd(month.chargedMicros)}</td>
132+ <td className="py-1.5 pr-3 text-right">{usd(month.plansMicros ?? 0)}</td>
130133 <td className="py-1.5 pr-3 text-right text-muted">{usd(month.costMicros)}</td>
134+ <td className="py-1.5 pr-3 text-right text-merged">{usd(month.givenMicros ?? 0)}</td>
131135 <td className={`py-1.5 pr-3 text-right ${margin.micros < 0 ? "text-danger" : "text-fg-soft"}`}>
132136 {usd(margin.micros)}
133137 {margin.percent != null && <span className="text-faint"> {margin.percent}%</span>}
+46−14
11 /**
2− * g1t's mark, "the fleet", copied from apps/web/app/components/logo.tsx:
3− * three 1s stepping back in depth. Keep the two in step.
2+ * Copied from apps/web/app/components/logo.tsx; keep the two in step.
3+ * g1t's logo: G1T in 5×7 pixel capitals, the 1 in lavender. Each pixel is
4+ * its own square, 0.8 of its cell. The icon is the 1 alone. The same
5+ * shapes as the favicon and the brand files in .g1t/brand and public/brand.
6+ * The product name stays "g1t" in text; only the artwork is capitals.
7+ */
8+
9+const G = [".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."];
10+const ONE = ["..#..", ".##..", "#.#..", "..#..", "..#..", "..#..", "#####"];
11+const T = ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."];
12+
13+type Pixel = { x: number; y: number; one: boolean };
14+
15+function pixels(rows: string[], dx: number, one: boolean): Pixel[] {
16+ return rows.flatMap((row, y) => [...row].flatMap((c, x) => (c === "#" ? [{ x: dx + x, y, one }] : [])));
17+}
18+
19+/** G at 0, the 1 at 6, the T at 10: its bar tucks in over the 1's foot. */
20+const WORD = [...pixels(G, 0, false), ...pixels(ONE, 6, true), ...pixels(T, 10, false)];
21+const ICON = pixels(ONE, 0, true);
22+
23+/** Cells of 10 units, squares of 8, centred. */
24+function Pixels({ of }: { of: Pixel[] }) {
25+ return of.map((p) => (
26+ <rect
27+ key={`${p.x}.${p.y}`}
28+ x={p.x * 10 + 1}
29+ y={p.y * 10 + 1}
30+ width="8"
31+ height="8"
32+ fill={p.one ? "var(--g1t-merged)" : "currentColor"}
33+ />
34+ ));
35+}
36+
37+/**
38+ * The icon: the pixel 1, centred in a square. `tight` crops to the ink, for
39+ * setting it beside type, where it must stand on the baseline.
440 */
541 export function Mark({ className, tight = false }: { className?: string; tight?: boolean }) {
642 return (
7− <svg viewBox={tight ? "6.9 5 18.2 22" : "0 0 32 32"} className={className} aria-hidden="true">
8− <g transform="translate(0.7 0.5)">
9− <rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="var(--g1t-merged)" fillOpacity="0.35" />
10− <rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="var(--g1t-merged)" fillOpacity="0.65" />
11− <rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="currentColor" />
12− <path d="M21.7 7.2 17.6 10.9" fill="none" stroke="currentColor" strokeWidth="4.6" strokeLinecap="round" />
13− </g>
43+ <svg viewBox={tight ? "1 1 48 68" : "-15 -5 80 80"} className={className} aria-hidden="true">
44+ <Pixels of={ICON} />
1445 </svg>
1546 );
1647 }
1748
18−/** The lockup, with sudo's badge beside it. */
19−export function Logo({ className = "text-[1.3rem]" }: { className?: string }) {
49+/** The wordmark, with sudo's badge beside it. */
50+export function Logo({ className = "text-[1.5rem]" }: { className?: string }) {
2051 return (
2152 <span className="inline-flex items-center gap-2">
22− <span className={`inline-flex items-baseline gap-[0.3em] leading-none font-bold text-fg ${className}`}>
23− <Mark tight className="h-[0.74em] w-auto shrink-0 self-baseline" />
24− <span className="tracking-[-0.045em]">g1t</span>
53+ <span className={`inline-flex items-baseline leading-none text-fg ${className}`}>
54+ <svg viewBox="1 1 148 68" role="img" aria-label="g1t" className="h-[0.85em] w-auto shrink-0 self-baseline">
55+ <Pixels of={WORD} />
56+ </svg>
2557 </span>
2658 <span className="rounded-full bg-merged/12 px-2 py-0.5 font-mono text-[0.7rem] font-semibold tracking-wide text-merged ring-1 ring-merged/35 ring-inset">
2759 sudo
+11−2
77 * holding the current page. All of it comes from lib/nav.ts.
88 */
99 import {
10+ Activity,
1011 Bot,
1112 Building2,
1213 Boxes,
1819 FileText,
1920 Gauge,
2021 HandCoins,
22+ HandHelping,
2123 Inbox,
2224 LayoutDashboard,
2325 LifeBuoy,
2931 ShieldAlert,
3032 ShieldCheck,
3133 Tags,
34+ Ticket,
35+ TrendingUp,
3236 UserCog,
3337 Users,
3438 UsersRound,
5963 announcements: Megaphone,
6064 staff: UserCog,
6165 audit: ScrollText,
66+ requests: HandHelping,
67+ overages: TrendingUp,
68+ velocity: Activity,
69+ invites: Ticket,
6270 customers: UsersRound,
71+ spend: Gauge,
6372 revenue: CircleDollarSign,
6473 platform: Server,
6574 support: LifeBuoy,
193202 return (
194203 <aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-line bg-surface/50 lg:flex">
195204 <div className="flex h-14 shrink-0 items-center px-4">
196− <Link to="/" aria-label="sudo overview">
205+ <Link to="/" aria-label="sudo overview" className="flex">
197206 <Logo />
198207 </Link>
199208 </div>
213222 return (
214223 <header className="sticky top-0 z-40 border-b border-line bg-surface/95 backdrop-blur lg:hidden">
215224 <div className="flex h-14 items-center justify-between gap-2 px-4">
216− <Link to="/" aria-label="sudo overview">
225+ <Link to="/" aria-label="sudo overview" className="flex">
217226 <Logo />
218227 </Link>
219228 {/* A details element: it opens and closes with no script. A link
+42−4
88 import type { Terms } from "@g1t/contracts";
99 import { data, redirect } from "react-router";
1010
11−import { fields, parseAllowances, parseCredit, parseEmail, parseNote, parseSlug, parseTerms, text } from "./forms";
11+import { fields, parseAllowances, parseCredit, parseEmail, parseGoodwill, parseNote, parsePayment, parseSlug, parseTerms, text } from "./forms";
12+import { FORGIVE_COST_MICROS, goodwillWarning } from "./pricing";
1213 import type { ActionData } from "./review";
13−import { admin, identity } from "./services.server";
14+import { admin, identity, priceBook } from "./services.server";
1415 import type { Staff } from "./staff";
1516 import type { Enterprise } from "./workspaces";
1617
4445 }
4546
4647 if (intent === "allowances") {
47− // Team without charge, and the account's share of g1t's pools.
48− const values = fields(form, "team", "oss", "trial", "note");
48+ // The plan without its price, the account's share of g1t's pools, and staff's overrides.
49+ const values = fields(form, "plan", "oss", "trial", "agents", "runCap", "issueCap", "hold", "note");
4950 if (subject.kind === "workspace" && subject.billedTo) {
5051 return failed("allowances", `This workspace is on ${subject.billedTo.name}. Set its plan and pools on the enterprise.`, values);
5152 }
120121 return back("credit");
121122 }
122123
124+ if (intent === "payment") {
125+ // A bank transfer that reached g1t outside Stripe's page.
126+ const values = fields(form, "amount", "reference", "note", "confirmation");
127+ if (subject.kind !== "workspace") return failed("top", "Record a payment on the workspace it is for.");
128+ const payment = parsePayment(form, subject.slug);
129+ if (!payment.ok) return failed("payment", payment.error, { ...values, confirmation: "" });
130+ const { amountMicros, reference, note } = payment.value;
131+ const result = await admin.recordPayment(subject.slug, amountMicros, reference, note, staff.email);
132+ if (!result.ok) return failed("payment", result.error.message, values);
133+ return back("payment");
134+ }
135+
136+ if (intent === "goodwill") {
137+ // A goodwill credit from the workspace's page: the overage, as billing quotes it now.
138+ const values = fields(form, "amount", "reason", "day");
139+ if (subject.kind !== "workspace") return failed("top", "Goodwill is given to one workspace.");
140+ const quote = await goodwillQuote(subject.slug);
141+ const parsed = parseGoodwill(form, (amount) =>
142+ // A reason past the one-click credit, a second within 12 months, or past the cap on real cost.
143+ quote ? ((w) => w.needsReason || w.overCap)(goodwillWarning(amount, quote.goodwill, quote.lastGoodwillAt, quote.cap)) : amount != null,
144+ );
145+ if (!parsed.ok) return failed("goodwill", parsed.error, values);
146+ const result = await admin.goodwill(subject.slug, parsed.value.amountMicros, parsed.value.reason, staff.email, parsed.value.day);
147+ if (!result.ok) return failed("goodwill", result.error.message, values);
148+ return back("goodwill");
149+ }
150+
123151 if (intent === "billing-link") {
124152 if (subject.kind !== "workspace") return failed("top", "Billing links are made from a workspace's page.");
125153 if (!confirmed) return { review: { intent, workspace: subject.slug, fields: {} } } satisfies ActionData;
160188
161189 return failed("top", "Unknown action.");
162190 }
191+
192+/** The workspace's goodwill quote from the Overages queue, if it is in it, and the cap on real cost. */
193+export async function goodwillQuote(
194+ workspace: string,
195+): Promise<{ goodwill: { creditMicros: number; marginMicros: number }; lastGoodwillAt: string | null; cap: number } | null> {
196+ const [queue, book] = await Promise.all([admin.overages().catch(() => []), priceBook().catch(() => null)]);
197+ const row = queue.find((overage) => overage.workspace === workspace);
198+ const cap = book?.free?.overageForgiveCostMicros || FORGIVE_COST_MICROS;
199+ return row ? { goodwill: row.goodwill, lastGoodwillAt: row.lastGoodwillAt, cap } : null;
200+}
+88−10
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { parseAllowances, parseCredit, parseEmail, parseSales, parseSalesNote, parseSlugList, parseTerms } from "./forms.ts";
4+import {
5+ parseAllowances,
6+ parseCredit,
7+ parseDecision,
8+ parseEmail,
9+ parseGoodwill,
10+ parsePayment,
11+ parseSales,
12+ parseSalesNote,
13+ parseSlugList,
14+ parseTerms,
15+} from "./forms.ts";
516
617 const NOW = new Date("2026-10-04T12:00:00Z");
718
8798 assert.equal(parseSalesNote("x".repeat(2001)).ok, false);
8899 });
89100
90−test("allowances: Team on or off, and pool shares in dollars or the default", () => {
91− const form = (entries: Record<string, string>) => {
92− const data = new FormData();
93− for (const [name, value] of Object.entries(entries)) data.set(name, value);
94− return data;
95− };
96− assert.deepEqual(parseAllowances(form({})), { ok: true, value: { team: false, ossRepoMicros: null, trialMicros: null } });
97− assert.deepEqual(parseAllowances(form({ team: "on", oss: "5", trial: "2.50" })), {
101+
102+test("allowances: the plan without its price, pool shares, and staff's overrides", () => {
103+ assert.deepEqual(parseAllowances(form({})), {
98104 ok: true,
99− value: { team: true, ossRepoMicros: 5_000_000, trialMicros: 2_500_000 },
105+ value: { plan: false, ossRepoMicros: null, trialMicros: null, maxConcurrentAgents: null, runCapMicros: null, issueCapMicros: null, hold: null },
100106 });
107+ assert.deepEqual(
108+ parseAllowances(form({ plan: "on", oss: "5", trial: "2.50", agents: "20", runCap: "25", issueCap: "500", hold: " Card disputed;\nwaiting on the bank " })),
109+ {
110+ ok: true,
111+ value: {
112+ plan: true,
113+ ossRepoMicros: 5_000_000,
114+ trialMicros: 2_500_000,
115+ maxConcurrentAgents: 20,
116+ runCapMicros: 25_000_000,
117+ issueCapMicros: 500_000_000,
118+ hold: "Card disputed; waiting on the bank",
119+ },
120+ },
121+ );
101122 assert.equal(parseAllowances(form({ oss: "lots" })).ok, false);
102123 assert.equal(parseAllowances(form({ trial: "5000" })).ok, false);
124+ assert.equal(parseAllowances(form({ agents: "0" })).ok, false);
125+ assert.equal(parseAllowances(form({ agents: "101" })).ok, false);
126+ assert.equal(parseAllowances(form({ agents: "2.5" })).ok, false);
127+ assert.equal(parseAllowances(form({ runCap: "0.05" })).ok, false);
128+ assert.equal(parseAllowances(form({ runCap: "1000.01" })).ok, false);
129+ assert.equal(parseAllowances(form({ issueCap: "10000.01" })).ok, false);
130+ assert.equal(parseAllowances(form({ hold: "x".repeat(201) })).ok, false);
131+});
132+
133+test("a bank transfer: an amount, its reference, a note, and the slug typed out", () => {
134+ const ok = { amount: "1,500", reference: " TRX 0042 ", note: "Invoice INV-7, by wire", confirmation: "acme" };
135+ assert.deepEqual(parsePayment(form(ok), "acme"), { ok: true, value: { amountMicros: 1_500_000_000, reference: "TRX 0042", note: "Invoice INV-7, by wire" } });
136+ assert.equal(parsePayment(form({ ...ok, amount: "0" }), "acme").ok, false);
137+ assert.equal(parsePayment(form({ ...ok, amount: "100000.01" }), "acme").ok, false);
138+ assert.equal(parsePayment(form({ ...ok, reference: "" }), "acme").ok, false);
139+ assert.equal(parsePayment(form({ ...ok, note: "" }), "acme").ok, false);
140+ assert.equal(parsePayment(form({ ...ok, confirmation: "Acme" }), "acme").ok, false);
141+});
142+
143+test("a decision: approve as asked or at another amount; decline needs a note", () => {
144+ assert.deepEqual(parseDecision(form({ id: "lr_1", decision: "approve" })), {
145+ ok: true,
146+ value: { id: "lr_1", decision: "approve", amountMicros: null, note: "" },
147+ });
148+ assert.deepEqual(parseDecision(form({ id: "lr_1", decision: "approve", amount: "750", note: "Half now." })), {
149+ ok: true,
150+ value: { id: "lr_1", decision: "approve", amountMicros: 750_000_000, note: "Half now." },
151+ });
152+ assert.equal(parseDecision(form({ id: "lr_1", decision: "approve", amount: "0.50" })).ok, false);
153+ assert.equal(parseDecision(form({ id: "lr_1", decision: "approve", amount: "1000000.01" })).ok, false);
154+ assert.equal(parseDecision(form({ id: "lr_1", decision: "decline" })).ok, false);
155+ assert.deepEqual(parseDecision(form({ id: "lr_1", decision: "decline", note: "After a month of payments.", amount: "5" })), {
156+ ok: true,
157+ value: { id: "lr_1", decision: "decline", amountMicros: null, note: "After a month of payments." },
158+ });
159+ assert.equal(parseDecision(form({ id: "../x", decision: "approve" })).ok, false);
160+ assert.equal(parseDecision(form({ id: "lr_1", decision: "maybe" })).ok, false);
161+});
162+
163+test("goodwill: no amount is the one-click credit; a reason when one is needed; a day not in the future", () => {
164+ const never = () => false;
165+ const always = () => true;
166+ assert.deepEqual(parseGoodwill(form({}), never, NOW), { ok: true, value: { amountMicros: null, reason: "", day: null } });
167+ assert.deepEqual(parseGoodwill(form({ amount: "60", reason: "Looping agent on #12, owner caught it late.", day: "2026-10-03" }), always, NOW), {
168+ ok: true,
169+ value: { amountMicros: 60_000_000, reason: "Looping agent on #12, owner caught it late.", day: "2026-10-03" },
170+ });
171+ assert.equal(parseGoodwill(form({ amount: "60", reason: "oops" }), always, NOW).ok, false);
172+ assert.equal(parseGoodwill(form({ amount: "0" }), never, NOW).ok, false);
173+ assert.equal(parseGoodwill(form({ amount: "10000.01" }), never, NOW).ok, false);
174+ assert.equal(parseGoodwill(form({ day: "2026-10-05" }), never, NOW).ok, false);
175+ assert.equal(parseGoodwill(form({ day: "2026-02-30" }), never, NOW).ok, false);
176+ // The check is given the amount typed, or null for the one-click credit.
177+ const seen: (number | null)[] = [];
178+ parseGoodwill(form({ amount: "12.50" }), (amount) => (seen.push(amount), false), NOW);
179+ parseGoodwill(form({}), (amount) => (seen.push(amount), false), NOW);
180+ assert.deepEqual(seen, [12_500_000, null]);
103181 });
+149−9
179179
180180 /** The most staff can set an account's share of a pool to, against a slipped finger. */
181181 export const MAX_POOL_SHARE_MICROS = 1_000 * MICROS_PER_DOLLAR;
182+/** The most agents at once staff can allow one account. */
183+export const MAX_AGENTS_AT_ONCE = 100;
184+/** Staff's overrides of the owners' caps: one run, and one issue's agents in all. */
185+export const MAX_RUN_CAP_MICROS = 1_000 * MICROS_PER_DOLLAR;
186+export const MAX_ISSUE_CAP_MICROS = 10_000 * MICROS_PER_DOLLAR;
187+/** The smallest cap: ten cents, as owners may set. */
188+const MIN_CAP_MICROS = 100_000;
189+const MAX_HOLD = 200;
182190
183191 /**
184− * Allowances from the plan-and-pools form: Team without charge, and the
185− * account's share of the open-source pool and of trials. A blank amount
186− * means the default.
192+ * Allowances from the plan-and-pools form: the g1t plan without its price,
193+ * the account's share of the open-source pool and of trials, and staff's
194+ * overrides of agents at once, the run cap and the issue cap. A blank
195+ * amount means the default (for a cap, no override). A hold is a line
196+ * saying why new compute is held; blank is no hold.
187197 */
188198 export function parseAllowances(form: FormData): Parsed<Allowances> {
189− const amount = (name: string, what: string): Parsed<number | null> => {
199+ const amount = (name: string, what: string, max: number, maxText: string, min = 0): Parsed<number | null> => {
190200 const raw = text(form, name);
191201 if (!raw) return { ok: true, value: null };
192202 const micros = parseDollars(raw);
193− if (micros == null || micros < 0) return { ok: false, error: `${what} is dollars and cents, such as 5 or 2.50, or blank for the default.` };
194− if (micros > MAX_POOL_SHARE_MICROS) return { ok: false, error: `${what} is at most $1,000.` };
203+ if (micros == null) return { ok: false, error: `${what} is dollars and cents, such as 5 or 2.50, or blank for the default.` };
204+ if (micros < min) return { ok: false, error: `${what} is at least $${(min / MICROS_PER_DOLLAR).toFixed(2)}, or blank for the default.` };
205+ if (micros > max) return { ok: false, error: `${what} is at most ${maxText}.` };
195206 return { ok: true, value: micros };
196207 };
197− const oss = amount("oss", "The open-source share");
208+ const oss = amount("oss", "The open-source share", MAX_POOL_SHARE_MICROS, "$1,000");
198209 if (!oss.ok) return oss;
199− const trial = amount("trial", "The trial credit");
210+ const trial = amount("trial", "The trial credit", MAX_POOL_SHARE_MICROS, "$1,000");
200211 if (!trial.ok) return trial;
201− return { ok: true, value: { team: text(form, "team") === "on", ossRepoMicros: oss.value, trialMicros: trial.value } };
212+ const runCap = amount("runCap", "The run cap", MAX_RUN_CAP_MICROS, "$1,000", MIN_CAP_MICROS);
213+ if (!runCap.ok) return runCap;
214+ const issueCap = amount("issueCap", "The issue cap", MAX_ISSUE_CAP_MICROS, "$10,000", MIN_CAP_MICROS);
215+ if (!issueCap.ok) return issueCap;
216+
217+ let maxConcurrentAgents: number | null = null;
218+ const rawAgents = text(form, "agents");
219+ if (rawAgents) {
220+ if (!/^\d{1,3}$/.test(rawAgents) || Number(rawAgents) < 1 || Number(rawAgents) > MAX_AGENTS_AT_ONCE) {
221+ return { ok: false, error: `Agents at once is a whole number from 1 to ${MAX_AGENTS_AT_ONCE}, or blank for the default.` };
222+ }
223+ maxConcurrentAgents = Number(rawAgents);
224+ }
225+
226+ const hold = text(form, "hold").replace(/\s+/g, " ");
227+ if (hold.length > MAX_HOLD) return { ok: false, error: `Keep the hold's reason under ${MAX_HOLD} characters.` };
228+
229+ return {
230+ ok: true,
231+ value: {
232+ plan: text(form, "plan") === "on",
233+ ossRepoMicros: oss.value,
234+ trialMicros: trial.value,
235+ maxConcurrentAgents,
236+ runCapMicros: runCap.value,
237+ issueCapMicros: issueCap.value,
238+ hold: hold || null,
239+ },
240+ };
241+}
242+
243+/** The most one recorded payment can be; billing refuses more. */
244+export const MAX_PAYMENT_MICROS = 100_000 * MICROS_PER_DOLLAR;
245+const MAX_REFERENCE = 100;
246+
247+export type PaymentInput = { amountMicros: number; reference: string; note: string };
248+
249+/**
250+ * A bank transfer that reached g1t outside Stripe's page, from its form:
251+ * the amount, the transfer's reference (each is recorded once), a note,
252+ * and the workspace's slug typed out to confirm.
253+ */
254+export function parsePayment(form: FormData, workspace: string): Parsed<PaymentInput> {
255+ const amountMicros = parseDollars(text(form, "amount"));
256+ if (amountMicros == null || amountMicros <= 0) {
257+ return { ok: false, error: "The amount is dollars and cents, more than zero, such as 1,500 or 2400.50." };
258+ }
259+ if (amountMicros > MAX_PAYMENT_MICROS) {
260+ return { ok: false, error: "One payment is at most $100,000. Record a larger transfer in parts, each with its own reference." };
261+ }
262+ const reference = text(form, "reference").replace(/\s+/g, " ");
263+ if (!reference) return { ok: false, error: "Give the transfer's reference, as the bank shows it, so it is recorded once." };
264+ if (reference.length > MAX_REFERENCE) return { ok: false, error: `Keep the reference under ${MAX_REFERENCE} characters.` };
265+ const note = parseNote(text(form, "note"));
266+ if (!note.ok) return note;
267+ if (text(form, "confirmation") !== workspace) {
268+ return { ok: false, error: `Type the workspace's slug, ${workspace}, exactly, to record the payment.` };
269+ }
270+ return { ok: true, value: { amountMicros, reference, note: note.value } };
271+}
272+
273+/** The most staff can approve on a request; billing refuses more. */
274+export const MAX_REQUEST_MICROS = 1_000_000 * MICROS_PER_DOLLAR;
275+
276+export type Decision = { id: string; decision: "approve" | "decline"; amountMicros: number | null; note: string };
277+
278+/**
279+ * A decision on a limit or overage request. Approve takes the amount asked
280+ * (blank) or another; decline needs a note, which the owner reads.
281+ */
282+export function parseDecision(form: FormData): Parsed<Decision> {
283+ const id = text(form, "id");
284+ if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: "That is not a request." };
285+ const decision = text(form, "decision");
286+ if (decision !== "approve" && decision !== "decline") return { ok: false, error: "Approve or decline." };
287+ const note = text(form, "note");
288+ if (note.length > MAX_NOTE) return { ok: false, error: `Keep the note under ${MAX_NOTE} characters.` };
289+ if (decision === "decline") {
290+ if (!note) return { ok: false, error: "Say why, for the owner: they read it in the app and by email." };
291+ return { ok: true, value: { id, decision, amountMicros: null, note } };
292+ }
293+ let amountMicros: number | null = null;
294+ const raw = text(form, "amount");
295+ if (raw) {
296+ const micros = parseDollars(raw);
297+ if (micros == null || micros < MICROS_PER_DOLLAR) return { ok: false, error: "The amount is dollars and cents, at least $1, such as 750." };
298+ if (micros > MAX_REQUEST_MICROS) return { ok: false, error: "Approve at most $1,000,000." };
299+ amountMicros = micros;
300+ }
301+ return { ok: true, value: { id, decision, amountMicros, note } };
302+}
303+
304+export type GoodwillInput = { amountMicros: number | null; reason: string; day: string | null };
305+
306+/** A typed reason is a sentence: billing asks for at least this many characters. */
307+export const MIN_REASON = 10;
308+
309+/**
310+ * A goodwill credit from its form. No amount is the one-click credit. A
311+ * reason is required when `needsReason` says so (more than the one-click
312+ * credit, or a second within 12 months). The day is when the accidental
313+ * usage happened; blank lets billing choose it.
314+ */
315+export function parseGoodwill(
316+ form: FormData,
317+ needsReason: (amountMicros: number | null) => boolean,
318+ now = new Date(),
319+): Parsed<GoodwillInput> {
320+ let amountMicros: number | null = null;
321+ const raw = text(form, "amount");
322+ if (raw) {
323+ const micros = parseDollars(raw);
324+ if (micros == null || micros <= 0) return { ok: false, error: "The amount is dollars and cents, more than zero, such as 40 or 12.50." };
325+ if (micros > MAX_CREDIT_MICROS) return { ok: false, error: "A goodwill credit is at most $10,000." };
326+ amountMicros = micros;
327+ }
328+ const reason = text(form, "reason").replace(/\s+/g, " ");
329+ if (reason.length > MAX_NOTE) return { ok: false, error: `Keep the reason under ${MAX_NOTE} characters.` };
330+ if (needsReason(amountMicros) && reason.length < MIN_REASON) {
331+ return { ok: false, error: "This credit needs a reason: say why, in a sentence, for whoever looks next." };
332+ }
333+ let day: string | null = null;
334+ const rawDay = text(form, "day");
335+ if (rawDay) {
336+ const date = /^\d{4}-\d{2}-\d{2}$/.test(rawDay) ? new Date(`${rawDay}T00:00:00Z`) : null;
337+ if (!date || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== rawDay) return { ok: false, error: "The day is not a date." };
338+ if (date.getTime() > now.getTime()) return { ok: false, error: "The day of the usage cannot be in the future." };
339+ day = rawDay;
340+ }
341+ return { ok: true, value: { amountMicros, reason, day } };
202342 }
+5−5
55
66 /**
77 * The pages ship no JavaScript, so no script may run at all; styles and
8− * images come only from sudo itself, fonts from Google Fonts, and forms
9− * post only back to sudo.
8+ * images and fonts come only from sudo itself, and forms post only back
9+ * to sudo.
1010 */
1111 export const CONTENT_SECURITY_POLICY = [
1212 "default-src 'none'",
1313 "script-src 'none'",
14− "style-src 'self' https://fonts.googleapis.com",
15− "font-src https://fonts.gstatic.com",
14+ "style-src 'self'",
15+ "font-src 'self'",
1616 "img-src 'self' data:",
1717 "form-action 'self'",
1818 "frame-ancestors 'none'",
4646 const DENIED_STYLE = `
4747 :root{color-scheme:dark}
4848 body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f0f11;color:#ededef;
49−font:15px/1.6 Inter,ui-sans-serif,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
49+font:15px/1.6 'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
5050 main{max-width:28rem;padding:2rem 1rem;text-align:center}
5151 .badge{display:inline-block;border:1px solid #b6a8ff66;color:#b6a8ff;background:#b6a8ff1a;border-radius:999px;
5252 padding:.1rem .6rem;font:600 12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.02em}
+43−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { INVITES_DONE, invitesHref, parseGrant, parseMintEmail, parseTab, parseWaitlistStatus } from "./invites.ts";
5+
6+const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null });
7+
8+test("the page opens on the waitlist, and each tab has an address", () => {
9+ assert.equal(parseTab(null), "waitlist");
10+ assert.equal(parseTab("tree"), "tree");
11+ assert.equal(parseTab("nope"), "waitlist");
12+ assert.equal(invitesHref("invites", { q: "g1t-k7m2", empty: "" }), "/invites?tab=invites&q=g1t-k7m2");
13+ assert.equal(parseWaitlistStatus(null), "waiting");
14+ assert.equal(parseWaitlistStatus("all"), "all");
15+ assert.equal(parseWaitlistStatus("dismissed"), "dismissed");
16+});
17+
18+test("a grant names who, how many and why", () => {
19+ assert.deepEqual(parseGrant(form({ target: "user", name: "@Ada", amount: "10", note: "Launch partner" })), {
20+ ok: true,
21+ value: { target: "user", name: "ada", amount: 10, note: "Launch partner" },
22+ });
23+ assert.deepEqual(parseGrant(form({ target: "workspace", name: "acme", amount: "-5", note: "Abuse" })), {
24+ ok: true,
25+ value: { target: "workspace", name: "acme", amount: -5, note: "Abuse" },
26+ });
27+ assert.equal(parseGrant(form({ target: "team", name: "acme", amount: "5", note: "why" })).ok, false);
28+ assert.equal(parseGrant(form({ target: "user", name: "not a name", amount: "5", note: "why" })).ok, false);
29+ assert.equal(parseGrant(form({ target: "user", name: "ada", amount: "0", note: "why" })).ok, false);
30+ assert.equal(parseGrant(form({ target: "user", name: "ada", amount: "1.5", note: "why" })).ok, false);
31+ assert.equal(parseGrant(form({ target: "user", name: "ada", amount: "5000", note: "why" })).ok, false);
32+ assert.equal(parseGrant(form({ target: "user", name: "ada", amount: "5", note: "" })).ok, false);
33+});
34+
35+test("a minted invite is for one address, or for anyone", () => {
36+ assert.deepEqual(parseMintEmail(""), { ok: true, value: null });
37+ assert.deepEqual(parseMintEmail(" Ada@Example.com "), { ok: true, value: "ada@example.com" });
38+ assert.equal(parseMintEmail("ada@").ok, false);
39+});
40+
41+test("every outcome has words", () => {
42+ for (const key of ["approved", "dismissed", "revoked", "granted"]) assert.ok(INVITES_DONE[key]);
43+});
+76−0
1+/**
2+ * The Invites page's forms and tabs: the waitlist, every invite, grants of
3+ * more invites, and a person's invite tree. No Workers imports, so it can
4+ * be tested under Node. Identity checks everything again.
5+ */
6+import type { WaitlistStatus } from "@g1t/contracts";
7+
8+import type { Parsed } from "./forms.ts";
9+
10+export const INVITE_TABS = ["waitlist", "invites", "grant", "tree"] as const;
11+export type InviteTab = (typeof INVITE_TABS)[number];
12+
13+export const TAB_LABEL: Record<InviteTab, string> = {
14+ waitlist: "Waitlist",
15+ invites: "Invites",
16+ grant: "Grant & mint",
17+ tree: "Invite tree",
18+};
19+
20+export function parseTab(raw: string | null): InviteTab {
21+ return (INVITE_TABS as readonly string[]).includes(raw ?? "") ? (raw as InviteTab) : "waitlist";
22+}
23+
24+const STATUSES: (WaitlistStatus | "all")[] = ["waiting", "invited", "dismissed", "all"];
25+
26+export function parseWaitlistStatus(raw: string | null): WaitlistStatus | "all" {
27+ return STATUSES.includes(raw as WaitlistStatus) ? (raw as WaitlistStatus | "all") : "waiting";
28+}
29+
30+/** Where a tab lives, with its search. */
31+export function invitesHref(tab: InviteTab, params: Record<string, string | null | undefined> = {}): string {
32+ const search = new URLSearchParams({ tab });
33+ for (const [key, value] of Object.entries(params)) if (value) search.set(key, value);
34+ return `/invites?${search}`;
35+}
36+
37+/** The most one grant gives or takes back; identity holds the same line. */
38+export const MAX_GRANT = 1000;
39+
40+const NAME = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/;
41+
42+export type Grant = { target: "user" | "workspace"; name: string; amount: number; note: string };
43+
44+/** A grant of invites, as typed: who, how many (negative takes back), and why. */
45+export function parseGrant(form: { get(name: string): unknown }): Parsed<Grant> {
46+ const read = (name: string) => {
47+ const value = form.get(name);
48+ return typeof value === "string" ? value.trim() : "";
49+ };
50+ const target = read("target");
51+ if (target !== "user" && target !== "workspace") return { ok: false, error: "Choose a person or a workspace." };
52+ const name = read("name").replace(/^@/, "").toLowerCase();
53+ if (!NAME.test(name)) return { ok: false, error: `“${read("name")}” is not a ${target === "user" ? "username" : "workspace slug"}.` };
54+ const raw = read("amount");
55+ if (!/^-?\d+$/.test(raw)) return { ok: false, error: "Give a whole number of invites, such as 10, or -5 to take some back." };
56+ const amount = Number(raw);
57+ if (amount === 0 || Math.abs(amount) > MAX_GRANT) return { ok: false, error: `Grant between 1 and ${MAX_GRANT} invites.` };
58+ const note = read("note");
59+ if (note.length < 3) return { ok: false, error: "Say why, for the record." };
60+ return { ok: true, value: { target, name, amount, note: note.slice(0, 500) } };
61+}
62+
63+/** An address to mint an invite for, or none: anyone with the code. */
64+export function parseMintEmail(raw: string): Parsed<string | null> {
65+ const email = raw.trim().toLowerCase();
66+ if (!email) return { ok: true, value: null };
67+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? { ok: true, value: email } : { ok: false, error: `“${raw}” is not an email address.` };
68+}
69+
70+/** The flash messages the page redirects back with (`?done=`). */
71+export const INVITES_DONE: Record<string, string> = {
72+ approved: "Approved. The invite is on its way to that address.",
73+ dismissed: "Dismissed.",
74+ revoked: "Invite revoked. It comes back to whoever it was charged to.",
75+ granted: "Granted. It applies at once.",
76+};
+4−1
8585 /** Each kind of change, as staff read it. */
8686 export const AUDIT_ACTIONS: Record<string, string> = {
8787 terms: "Terms changed",
88− allowances: "Plan and pools changed",
88+ allowances: "Plan, pools and caps changed",
89+ payment: "Bank transfer recorded",
90+ goodwill: "Goodwill credit",
91+ request: "Request answered",
8992 create: "Enterprise created",
9093 attach: "Workspace added",
9194 detach: "Workspace removed",
+3−2
66 test("the sidebar: two top-level links, then sections that fold", () => {
77 assert.deepEqual(
88 NAV.map((group) => group.title),
9− [null, "Customers", "Revenue", "Platform", "Support", "Team"],
9+ [null, "Customers", "Spend", "Revenue", "Platform", "Support", "Team"],
1010 );
1111 assert.deepEqual(NAV[0].items.map((item) => item.to), ["/", "/reach-out"]);
1212 });
2222 const open = (path: string) => NAV.filter((group) => group.title && holdsCurrent(group, path)).map((group) => group.title);
2323 assert.deepEqual(open("/workspaces/acme"), ["Customers"]);
2424 assert.deepEqual(open("/stripe"), ["Platform"]);
25+ assert.deepEqual(open("/overages"), ["Spend"]);
2526 assert.deepEqual(open("/audit"), ["Team"]);
2627 assert.deepEqual(open("/"), []);
2728 assert.deepEqual(open("/reach-out"), []);
3940
4041 test("the built pages are not marked soon", () => {
4142 const built = navItems().filter((item) => !item.soon).map((item) => item.to);
42− assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invoices", "/stripe", "/audit"]);
43+ assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/requests", "/overages", "/velocity", "/invoices", "/stripe", "/abuse", "/audit"]);
4344 });
4445
4546 test("every soon page says what it will do, why, and what it will have", () => {
+36−14
2727 | "announcements"
2828 | "staff"
2929 | "audit"
30+ | "requests"
31+ | "overages"
32+ | "velocity"
33+ | "invites"
3034 // Sections
3135 | "customers"
36+ | "spend"
3237 | "revenue"
3338 | "platform"
3439 | "support"
7883 { label: "Workspaces", to: "/workspaces", icon: "workspaces", about: "Every workspace, who owns it, and how it pays." },
7984 { label: "Enterprises", to: "/enterprises", icon: "enterprises", about: "Customers paying for several workspaces with one bill." },
8085 {
86+ label: "Invites",
87+ to: "/invites",
88+ icon: "invites",
89+ about: "The waitlist, every invite, more invites for a person or workspace, and who invited whom.",
90+ },
91+ {
8192 label: "People",
8293 to: "/people",
8394 icon: "people",
99110 ],
100111 },
101112 {
113+ title: "Spend",
114+ icon: "spend",
115+ items: [
116+ {
117+ label: "Requests",
118+ to: "/requests",
119+ icon: "requests",
120+ about: "Owners asking for a higher limit or help with a month: answered within one business day.",
121+ },
122+ {
123+ label: "Overages",
124+ to: "/overages",
125+ icon: "overages",
126+ about: "Workspaces well past their typical month, what caused it, and goodwill credits.",
127+ },
128+ {
129+ label: "Velocity",
130+ to: "/velocity",
131+ icon: "velocity",
132+ about: "Who is spending fastest right now, against their usual hour, and any spikes.",
133+ },
134+ ],
135+ },
136+ {
102137 title: "Revenue",
103138 icon: "revenue",
104139 items: [
210245 label: "Abuse & fraud",
211246 to: "/abuse",
212247 icon: "abuse",
213− about: "Stripe Radar signals, disputes and suspicious sign-ups.",
214− soon: {
215− summary: [
216− "Where trust and safety work happens: Stripe Radar's warnings, disputed payments, and sign-ups that look like abuse (many accounts from one card, throwaway emails, sandboxes used for mining). Limits keep g1t's exposure small, but someone still needs to look.",
217− "Each case ends in an action that is recorded: clear it, lower a workspace's limit, or suspend it, with a note for whoever looks next.",
218− ],
219− plans: [
220− "Disputes with their deadlines, and the evidence to send Stripe",
221− "Radar's early fraud warnings and blocked payments",
222− "Suspicious sign-ups: shared cards, disposable domains, bursts from one network",
223− "Suspend or restore a workspace, with a confirmation and a note",
224− ],
225− meanwhile: { text: "Disputes and declines show up in Reach out.", to: "/reach-out?kind=declined", link: "Declined" },
226− },
248+ about: "Sandboxes stopped for looking like mining, and disputes and declines.",
227249 },
228250 ],
229251 },
+151−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ absorbedMicros,
6+ age,
7+ answerDueAt,
8+ givenLabel,
9+ givenParts,
10+ givenTotal,
11+ goodwillLine,
12+ goodwillNeedsReason,
13+ goodwillWarning,
14+ goodwillChoices,
15+ moneyApart,
16+ isFast,
17+ marginOfMonth,
18+ parseRequestStatus,
19+ ratioLabel,
20+ requestsHref,
21+ revenueOf,
22+ span,
23+ waiting,
24+} from "./pricing.ts";
25+
26+test("the request filter is open unless another status is named", () => {
27+ assert.equal(parseRequestStatus(null), "open");
28+ assert.equal(parseRequestStatus("declined"), "declined");
29+ assert.equal(parseRequestStatus("all"), "all");
30+ assert.equal(parseRequestStatus("closed"), "open");
31+ assert.equal(requestsHref("open"), "/requests");
32+ assert.equal(requestsHref("approved"), "/requests?status=approved");
33+});
34+
35+test("an answer is due one business day after the request", () => {
36+ // Tuesday → Wednesday, same time.
37+ assert.equal(answerDueAt("2026-10-06T15:00:00Z").toISOString(), "2026-10-07T15:00:00.000Z");
38+ // Friday → Monday, same time.
39+ assert.equal(answerDueAt("2026-10-09T15:00:00Z").toISOString(), "2026-10-12T15:00:00.000Z");
40+ // Saturday or Sunday → the end of Monday.
41+ assert.equal(answerDueAt("2026-10-10T09:00:00Z").toISOString(), "2026-10-12T23:59:59.000Z");
42+ assert.equal(answerDueAt("2026-10-11T23:00:00Z").toISOString(), "2026-10-12T23:59:59.000Z");
43+});
44+
45+test("how long a request has waited, and whether it is overdue", () => {
46+ const asked = "2026-10-06T15:00:00Z";
47+ assert.deepEqual(
48+ { label: waiting(asked, new Date("2026-10-06T18:30:00Z")).label, overdue: waiting(asked, new Date("2026-10-06T18:30:00Z")).overdue },
49+ { label: "3 h", overdue: false },
50+ );
51+ const late = waiting(asked, new Date("2026-10-07T19:00:00Z"));
52+ assert.equal(late.label, "1 d 4 h");
53+ assert.equal(late.overdue, true);
54+ assert.equal(waiting("nonsense").label, "—");
55+ assert.equal(span(25 * 60_000), "25 min");
56+ assert.equal(span(48 * 3_600_000), "2 d");
57+});
58+
59+test("a workspace's age in its largest whole unit", () => {
60+ const now = new Date("2026-10-05T00:00:00Z");
61+ assert.equal(age("2026-10-04T12:00:00Z", now), "less than a day");
62+ assert.equal(age("2026-09-23T00:00:00Z", now), "12 days");
63+ assert.equal(age("2026-01-05T00:00:00Z", now), "9 months");
64+ assert.equal(age("2023-10-05T00:00:00Z", now), "3 years");
65+ assert.equal(age(null, now), null);
66+});
67+
68+test("goodwill: g1t absorbs only what is past its margin", () => {
69+ assert.equal(absorbedMicros(30_000_000, 8_000_000), 22_000_000);
70+ assert.equal(absorbedMicros(5_000_000, 8_000_000), 0);
71+ assert.equal(absorbedMicros(5_000_000, -1), 5_000_000);
72+});
73+
74+test("goodwill needs a reason past the one-click credit, or a second time in 12 months", () => {
75+ const credit = 40_000_000;
76+ assert.equal(goodwillNeedsReason(null, credit, null), false);
77+ assert.equal(goodwillNeedsReason(40_000_000, credit, null), false);
78+ assert.equal(goodwillNeedsReason(20_000_000, credit, null), false);
79+ assert.equal(goodwillNeedsReason(40_000_001, credit, null), true);
80+ assert.equal(goodwillNeedsReason(null, credit, "2026-03-01T00:00:00Z"), true);
81+ assert.deepEqual(goodwillWarning(100_000_000, { creditMicros: credit, marginMicros: 8_000_000 }, null), {
82+ needsReason: true,
83+ absorbedMicros: 92_000_000,
84+ overCap: true,
85+ });
86+ assert.deepEqual(goodwillWarning(null, { creditMicros: credit, marginMicros: 8_000_000 }, null), {
87+ needsReason: false,
88+ absorbedMicros: 32_000_000,
89+ overCap: false,
90+ });
91+ // Past the cap only when what g1t absorbs is more than it.
92+ assert.equal(goodwillWarning(58_000_000, { creditMicros: credit, marginMicros: 8_000_000 }, null).overCap, false);
93+ assert.equal(goodwillWarning(58_000_001, { creditMicros: credit, marginMicros: 8_000_000 }, null).overCap, true);
94+ assert.equal(goodwillWarning(20_000_000, { creditMicros: credit, marginMicros: 8_000_000 }, null, 10_000_000).overCap, true);
95+ assert.deepEqual(goodwillChoices({ overageMicros: 100_000_000, creditMicros: credit }), [
96+ { label: "One-click credit", micros: null },
97+ { label: "The whole overage", micros: 100_000_000 },
98+ ]);
99+ assert.deepEqual(goodwillChoices({ overageMicros: 10_000_000, creditMicros: 10_000_000 }), [{ label: "One-click credit", micros: null }]);
100+ assert.equal(goodwillLine("2026-10-03"), "Credit from g1t: accidental usage on 2026-10-03");
101+});
102+
103+test("velocity: five times the usual hour is fast", () => {
104+ assert.equal(isFast(5), true);
105+ assert.equal(isFast(4.99), false);
106+ assert.equal(isFast(Number.NaN), false);
107+ assert.equal(ratioLabel(7.24, 1_000_000), "7.2×");
108+ assert.equal(ratioLabel(12.6, 1_000_000), "13×");
109+ assert.equal(ratioLabel(0, 0), "new");
110+});
111+
112+test("margin counts the plan's price and never what g1t gave", () => {
113+ const month = { chargedMicros: 300_000_000, plansMicros: 200_000_000, costMicros: 250_000_000, givenMicros: 90_000_000 };
114+ assert.equal(revenueOf(month), 500_000_000);
115+ assert.deepEqual(marginOfMonth(month), { micros: 250_000_000, percent: 50 });
116+ assert.deepEqual(marginOfMonth({ chargedMicros: 0, costMicros: 1_000_000 }), { micros: -1_000_000, percent: null });
117+});
118+
119+test("given: labelled by source, totalled at price and at cost", () => {
120+ assert.equal(givenLabel({ source: "oss_pool", label: "" }), "Open-source pool");
121+ assert.equal(givenLabel({ source: "internal", label: "Internal (g1t's own)" }), "Internal (g1t's own)");
122+ assert.equal(givenLabel({ source: "new_thing", label: "" }), "new_thing");
123+ assert.deepEqual(
124+ givenTotal([
125+ { micros: 10_000_000, costMicros: 8_000_000 },
126+ { micros: 5_000_000, costMicros: 4_000_000 },
127+ ]),
128+ { micros: 15_000_000, costMicros: 12_000_000 },
129+ );
130+ assert.deepEqual(givenParts({ trialMicros: 1_000_000, ossMicros: 0 }), [{ label: "Trial", micros: 1_000_000 }]);
131+ assert.deepEqual(givenParts({}), []);
132+});
133+
134+test("margin on what was sold leaves out the cost of what g1t gave", () => {
135+ const month = { chargedMicros: 120_000_000, plansMicros: 200_000_000, costMicros: 160_000_000 };
136+ assert.deepEqual(moneyApart(month, 60_000_000), {
137+ revenueMicros: 320_000_000,
138+ soldCostMicros: 100_000_000,
139+ marginMicros: 220_000_000,
140+ marginPercent: 69,
141+ netMicros: 160_000_000,
142+ });
143+ // Everything given, nothing sold: no margin percent, and net is the loss.
144+ assert.deepEqual(moneyApart({ chargedMicros: 0, costMicros: 5_000_000 }, 5_000_000), {
145+ revenueMicros: 0,
146+ soldCostMicros: 0,
147+ marginMicros: 0,
148+ marginPercent: null,
149+ netMicros: -5_000_000,
150+ });
151+});
+233−0
1+/**
2+ * The arithmetic and wording behind Requests, Overages, Velocity and the
3+ * Overview's money: how long a request has waited against the promise of
4+ * an answer within one business day, what a goodwill credit costs g1t,
5+ * and margin kept apart from what g1t gives. Money is in micros. No
6+ * Workers or React imports, so it can be tested under Node.
7+ */
8+import type { GivenFigures, MonthFigures } from "@g1t/contracts";
9+
10+// --- Requests -------------------------------------------------------------------
11+
12+export const REQUEST_STATUSES = ["open", "approved", "declined", "all"] as const;
13+export type RequestStatus = (typeof REQUEST_STATUSES)[number];
14+
15+/** The status filter from the address: open unless another is named. */
16+export function parseRequestStatus(value: string | null): RequestStatus {
17+ return REQUEST_STATUSES.find((status) => status === value) ?? "open";
18+}
19+
20+export function requestsHref(status: RequestStatus): string {
21+ return status === "open" ? "/requests" : `/requests?status=${status}`;
22+}
23+
24+const HOUR = 60 * 60 * 1000;
25+const DAY = 24 * HOUR;
26+
27+function isWeekend(date: Date): boolean {
28+ const day = date.getUTCDay();
29+ return day === 0 || day === 6;
30+}
31+
32+/**
33+ * When an answer is due: one business day after the request, UTC. A
34+ * request made on a Friday is due Monday at the same time; one made at
35+ * the weekend, by the end of Monday.
36+ */
37+export function answerDueAt(createdAt: string): Date {
38+ let start = new Date(createdAt);
39+ if (Number.isNaN(start.getTime())) return start;
40+ if (isWeekend(start)) {
41+ // Asked at the weekend: the business day is Monday, all of it.
42+ while (isWeekend(start)) start = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate() + 1));
43+ return new Date(start.getTime() + DAY - 1000);
44+ }
45+ let due = new Date(start.getTime() + DAY);
46+ while (isWeekend(due)) due = new Date(due.getTime() + DAY);
47+ return due;
48+}
49+
50+/** A span of time, short: `25 min`, `3 h`, `2 d 4 h`. */
51+export function span(ms: number): string {
52+ const minutes = Math.max(0, Math.floor(ms / 60_000));
53+ if (minutes < 60) return `${minutes} min`;
54+ const hours = Math.floor(minutes / 60);
55+ if (hours < 24) return `${hours} h`;
56+ const days = Math.floor(hours / 24);
57+ const rest = hours % 24;
58+ return rest > 0 ? `${days} d ${rest} h` : `${days} d`;
59+}
60+
61+/** How long an open request has waited, and whether its one business day is up. */
62+export function waiting(createdAt: string, now = new Date()): { label: string; overdue: boolean; dueAt: Date } {
63+ const asked = new Date(createdAt);
64+ const dueAt = answerDueAt(createdAt);
65+ if (Number.isNaN(asked.getTime())) return { label: "—", overdue: false, dueAt };
66+ return { label: span(now.getTime() - asked.getTime()), overdue: now.getTime() > dueAt.getTime(), dueAt };
67+}
68+
69+/** How old a workspace is, in the largest whole unit: `12 days`, `3 months`, `2 years`. */
70+export function age(createdAt: string | null | undefined, now = new Date()): string | null {
71+ if (!createdAt) return null;
72+ const then = new Date(createdAt);
73+ if (Number.isNaN(then.getTime())) return null;
74+ const days = Math.max(0, Math.floor((now.getTime() - then.getTime()) / DAY));
75+ const plural = (count: number, unit: string) => `${count} ${unit}${count === 1 ? "" : "s"}`;
76+ if (days < 1) return "less than a day";
77+ if (days < 60) return plural(days, "day");
78+ if (days < 730) return plural(Math.floor(days / 30), "month");
79+ return plural(Math.floor(days / 365), "year");
80+}
81+
82+// --- Goodwill -------------------------------------------------------------------
83+
84+/**
85+ * What g1t absorbs of real cost on a goodwill credit: whatever is past its
86+ * margin on the overage. The margin is money g1t never had; the rest it
87+ * paid Cloudflare and model providers for.
88+ */
89+export function absorbedMicros(amountMicros: number, marginMicros: number): number {
90+ return Math.max(0, amountMicros - Math.max(0, marginMicros));
91+}
92+
93+/**
94+ * Whether a goodwill credit needs a typed reason: more than the one-click
95+ * credit, or a second within 12 months. No amount is the one-click credit.
96+ * As billing decides it, so the form asks before billing refuses.
97+ */
98+export function goodwillNeedsReason(amountMicros: number | null, creditMicros: number, lastGoodwillAt: string | null): boolean {
99+ const amount = amountMicros ?? creditMicros;
100+ return amount > creditMicros || lastGoodwillAt != null;
101+}
102+
103+/** The most of real cost a one-click goodwill credit absorbs, unless billing's price book says otherwise. */
104+export const FORGIVE_COST_MICROS = 50_000_000;
105+
106+/**
107+ * What a goodwill credit of `amountMicros` (null: the one-click credit)
108+ * means: whether billing asks for a reason, what of it is real cost g1t
109+ * absorbs, and whether that is past the cap, where the red warning shows.
110+ */
111+export function goodwillWarning(
112+ amountMicros: number | null,
113+ goodwill: { creditMicros: number; marginMicros: number },
114+ lastGoodwillAt: string | null,
115+ capMicros: number = FORGIVE_COST_MICROS,
116+): { needsReason: boolean; absorbedMicros: number; overCap: boolean } {
117+ const amount = amountMicros ?? goodwill.creditMicros;
118+ const absorbed = absorbedMicros(amount, goodwill.marginMicros);
119+ return {
120+ needsReason: goodwillNeedsReason(amountMicros, goodwill.creditMicros, lastGoodwillAt),
121+ absorbedMicros: absorbed,
122+ overCap: absorbed > capMicros,
123+ };
124+}
125+
126+/**
127+ * The amounts the Overages page offers beside the one-click credit: the
128+ * whole overage (margin and all its cost), when that is more.
129+ */
130+export function goodwillChoices(goodwill: { overageMicros: number; creditMicros: number }): { label: string; micros: number | null }[] {
131+ const choices: { label: string; micros: number | null }[] = [{ label: "One-click credit", micros: null }];
132+ if (goodwill.overageMicros > goodwill.creditMicros) choices.push({ label: "The whole overage", micros: goodwill.overageMicros });
133+ return choices;
134+}
135+
136+/** The statement line a goodwill credit makes, as billing writes it. */
137+export function goodwillLine(day: string): string {
138+ return `Credit from g1t: accidental usage on ${day}`;
139+}
140+
141+// --- Velocity -------------------------------------------------------------------
142+
143+/** A last hour this many times the usual hour is fast; at $5 and over, it pauses compute. */
144+export const FAST_RATIO = 5;
145+
146+export function isFast(ratio: number): boolean {
147+ return Number.isFinite(ratio) && ratio >= FAST_RATIO;
148+}
149+
150+/** A ratio as staff read it: `7.2×`, `—` when there is no usual hour to compare. */
151+export function ratioLabel(ratio: number, averageHourMicros: number): string {
152+ if (averageHourMicros <= 0 || !Number.isFinite(ratio)) return "new";
153+ return `${ratio >= 10 ? Math.round(ratio) : Math.round(ratio * 10) / 10}×`;
154+}
155+
156+const SPIKE: Record<string, { label: string; tone: "danger" | "warn" | "plain" }> = {
157+ open: { label: "Paused: waiting on the owner", tone: "danger" },
158+ stopped: { label: "Stopped by the owner", tone: "danger" },
159+ continued: { label: "Owner kept going", tone: "warn" },
160+};
161+
162+export function spikeLabel(status: string): { label: string; tone: "danger" | "warn" | "plain" } {
163+ return SPIKE[status] ?? { label: status, tone: "plain" };
164+}
165+
166+// --- Money: margin apart from what g1t gives ------------------------------------
167+
168+/** What came in: usage charged plus the plan's price paid. */
169+export function revenueOf(month: Pick<MonthFigures, "chargedMicros" | "plansMicros">): number {
170+ return month.chargedMicros + (month.plansMicros ?? 0);
171+}
172+
173+/**
174+ * Margin: what came in less what usage cost g1t. What g1t gave is not a
175+ * loss here; it is shown on its own.
176+ */
177+export function marginOfMonth(month: Pick<MonthFigures, "chargedMicros" | "plansMicros" | "costMicros">): { micros: number; percent: number | null } {
178+ const revenue = revenueOf(month);
179+ const micros = revenue - month.costMicros;
180+ return { micros, percent: revenue > 0 ? Math.round((micros / revenue) * 100) : null };
181+}
182+
183+const GIVEN_LABELS: Record<string, string> = {
184+ internal: "Internal use",
185+ trial: "Trials",
186+ oss_pool: "Open-source pool",
187+ covered: "Covered by g1t",
188+ goodwill: "Goodwill credits",
189+};
190+
191+/** What a source of given usage is called; billing's own label first. */
192+export function givenLabel(row: Pick<GivenFigures, "source" | "label">): string {
193+ return row.label || GIVEN_LABELS[row.source] || row.source;
194+}
195+
196+/** Everything given, at price and at cost. */
197+export function givenTotal(rows: Pick<GivenFigures, "micros" | "costMicros">[]): { micros: number; costMicros: number } {
198+ return rows.reduce((sum, row) => ({ micros: sum.micros + row.micros, costMicros: sum.costMicros + row.costMicros }), { micros: 0, costMicros: 0 });
199+}
200+
201+/**
202+ * The parts of a ledger line g1t gave rather than charged: a trial, the
203+ * open-source pool, or covered (internal use, the trial's last run).
204+ */
205+export function givenParts(entry: { trialMicros?: number; ossMicros?: number; givenMicros?: number }): { label: string; micros: number }[] {
206+ return [
207+ { label: "Trial", micros: entry.trialMicros ?? 0 },
208+ { label: "Open-source pool", micros: entry.ossMicros ?? 0 },
209+ { label: "Covered by g1t", micros: entry.givenMicros ?? 0 },
210+ ].filter((part) => part.micros > 0);
211+}
212+
213+/**
214+ * A month's money with what g1t gave kept apart: revenue (usage charged
215+ * and plans paid), margin on what was sold (revenue less the cost of the
216+ * usage that was charged), and net (revenue less all cost, given included).
217+ * `givenCostMicros` is what the given usage cost g1t.
218+ */
219+export function moneyApart(
220+ month: Pick<MonthFigures, "chargedMicros" | "plansMicros" | "costMicros">,
221+ givenCostMicros: number,
222+): { revenueMicros: number; soldCostMicros: number; marginMicros: number; marginPercent: number | null; netMicros: number } {
223+ const revenueMicros = revenueOf(month);
224+ const soldCostMicros = Math.max(0, month.costMicros - Math.max(0, givenCostMicros));
225+ const marginMicros = revenueMicros - soldCostMicros;
226+ return {
227+ revenueMicros,
228+ soldCostMicros,
229+ marginMicros,
230+ marginPercent: revenueMicros > 0 ? Math.round((marginMicros / revenueMicros) * 100) : null,
231+ netMicros: revenueMicros - month.costMicros,
232+ };
233+}
+5−1
2424 /** The flash messages a change redirects back with (`?done=`). */
2525 export const DONE: Record<string, string> = {
2626 terms: "Terms saved. They apply to charges from now on.",
27− allowances: "Plan and pools saved. They apply to charges from now on.",
27+ allowances: "Plan, pools and caps saved. They apply from now on.",
2828 attach: "Workspace moved onto the enterprise.",
2929 detach: "Workspace moved off the enterprise. It pays for itself again.",
3030 credit: "Credit issued.",
3232 "billing-email": "Saved where the enterprise's invoices go.",
3333 sales: "Sales record saved.",
3434 note: "Note added.",
35+ payment: "Payment recorded. It is on the workspace's statement and counts toward its limit.",
36+ approved: "Approved. The owner is told in the app and by email.",
37+ declined: "Declined. The owner is told why, in the app and by email.",
38+ goodwill: "Goodwill credit given. It is on the statement and in the audit log.",
3539 };
3640
3741 /** The `?done=` key, if it is one sudo knows. */
+10−1
11 import { env } from "cloudflare:workers";
22
3−import { billingAdminClient, billingClient, identityAdminClient } from "@g1t/contracts";
3+import { accountsAdminClient, billingAdminClient, billingClient, eventsClient, identityAdminClient } from "@g1t/contracts";
44
55 /** Staff-only billing, on the billing service. */
66 export const admin = billingAdminClient(env.BILLING);
1010
1111 /** Staff-only identity: every workspace, its owners and members. */
1212 export const identity = identityAdminClient(env.IDENTITY);
13+
14+/** Staff-only accounts: a person's email addresses and security log. */
15+export const accountsAdmin = accountsAdminClient(env.IDENTITY);
16+
17+/** The workspace's plan, caps and pause, as billing gives them to every service. */
18+export const entitlements = (workspace: string) => billingClient(env.BILLING).entitlements(workspace);
19+
20+/** The event log, read-only: sudo lists `abuse.flagged`. */
21+export const events = eventsClient(env.EVENTS);
+5−6
22
33 import type { Route } from "./+types/root";
44 import "./app.css";
5+import displayFont from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url";
6+import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url";
57 import { MobileBar, Sidebar } from "./components/shell";
68 import { ButtonLink } from "./components/ui";
79 import { requireStaff } from "./lib/staff";
810
911 export const links: Route.LinksFunction = () => [
1012 { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
11− { rel: "preconnect", href: "https://fonts.googleapis.com" },
12− { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
13− {
14− rel: "stylesheet",
15− href: "https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400..700&family=JetBrains+Mono:wght@400;500;600&display=swap",
16− },
13+ // The faces are served by sudo itself (@g1t/theme); these two are on every page.
14+ { rel: "preload", href: sansFont, as: "font", type: "font/woff2", crossOrigin: "anonymous" },
15+ { rel: "preload", href: displayFont, as: "font", type: "font/woff2", crossOrigin: "anonymous" },
1716 ];
1817
1918 export const meta: Route.MetaFunction = () => [
+6−0
66 index("routes/overview.tsx"),
77 route("workspaces", "routes/workspaces.tsx"),
88 route("workspaces/:slug", "routes/workspace.tsx"),
9+ route("users/:username", "routes/user.tsx"),
910 route("enterprises", "routes/enterprises.tsx"),
11+ route("invites", "routes/invites.tsx"),
1012 route("enterprises/new", "routes/new-enterprise.tsx"),
1113 route("enterprises/:id", "routes/enterprise.tsx"),
1214 route("reach-out", "routes/reach-out.tsx"),
15+ route("requests", "routes/requests.tsx"),
16+ route("overages", "routes/overages.tsx"),
17+ route("velocity", "routes/velocity.tsx"),
18+ route("abuse", "routes/abuse.tsx"),
1319 route("invoices", "routes/invoices.tsx"),
1420 route("stripe", "routes/stripe.tsx"),
1521 route("audit", "routes/audit.tsx"),
+86−0
1+import { Link } from "react-router";
2+
3+import type { G1tEvent } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/abuse";
6+import { Badge, EmptyState, Notice, PageHeader, Section, When } from "~/components/ui";
7+import { events } from "~/lib/services.server";
8+import { settle } from "~/lib/settle";
9+import { reachOutHref } from "~/lib/signals";
10+import { requireStaff } from "~/lib/staff";
11+
12+export const meta: Route.MetaFunction = () => [{ title: "Abuse & fraud · sudo" }, { name: "robots", content: "noindex, nofollow" }];
13+
14+export async function loader({ context }: Route.LoaderArgs) {
15+ requireStaff(context);
16+ const flagged = await settle(events.list({ types: ["abuse.flagged"], limit: 100 }));
17+ return {
18+ flags: flagged.ok ? (flagged.value.filter((event) => event.type === "abuse.flagged") as G1tEvent<"abuse.flagged">[]) : [],
19+ error: flagged.ok ? null : flagged.error,
20+ };
21+}
22+
23+/** A sandbox's measurements, short: `cpu 0.98 · io 0 · miner xmrig`. */
24+function metricsLine(metrics: Record<string, unknown> | null): string {
25+ if (!metrics) return "No measurements";
26+ const parts = Object.entries(metrics)
27+ .filter(([, value]) => value != null && typeof value !== "object")
28+ .map(([key, value]) => `${key.replace(/_/g, " ")} ${typeof value === "number" ? Math.round(value * 100) / 100 : String(value)}`);
29+ return parts.length > 0 ? parts.join(" · ") : "No measurements";
30+}
31+
32+export default function Abuse({ loaderData }: Route.ComponentProps) {
33+ const { flags, error } = loaderData;
34+ return (
35+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
36+ <PageHeader
37+ title="Abuse & fraud"
38+ description="Sandboxes g1t stopped because they looked like mining: CPU pinned with no progress, or a miner seen by name. Each was destroyed as it was flagged, and the owner was told why."
39+ />
40+ {error && (
41+ <div className="mt-5">
42+ <Notice tone="warn">The event log did not answer: {error}</Notice>
43+ </div>
44+ )}
45+ <Section title="Flagged sandboxes" description="The latest 100, newest first." className="mt-6">
46+ {flags.length === 0 ? (
47+ <EmptyState title={error ? "Nothing to show" : "No sandbox has been flagged"} />
48+ ) : (
49+ <ul className="-mx-4 -my-4 divide-y divide-line sm:-mx-5 sm:-my-5">
50+ {flags.map((event) => (
51+ <li key={event.id} className="flex flex-col gap-1 px-4 py-3 sm:px-5">
52+ <p className="flex flex-wrap items-center gap-2 text-sm">
53+ <Link to={`/workspaces/${encodeURIComponent(event.data.workspace)}#billing`} className="font-mono text-fg hover:underline">
54+ {event.data.workspace}
55+ </Link>
56+ <Badge tone="danger">{event.data.kind}</Badge>
57+ {event.data.repo && <span className="font-mono text-xs text-muted">{event.data.repo}</span>}
58+ <span className="ml-auto text-xs text-faint">
59+ <When at={event.time} time />
60+ </span>
61+ </p>
62+ <p className="font-mono text-xs break-words text-faint">
63+ {metricsLine(event.data.metrics)}
64+ {event.data.run ? ` · run ${event.data.run}` : ""} · sandbox {event.data.sandbox}
65+ </p>
66+ </li>
67+ ))}
68+ </ul>
69+ )}
70+ </Section>
71+ <Section title="Not listed here yet" className="mt-6">
72+ <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
73+ <li>
74+ Disputes and declined cards are signals in{" "}
75+ <Link to={reachOutHref({ kind: "declined" })} className="text-merged hover:underline">
76+ Reach out
77+ </Link>
78+ . Stripe Radar's early fraud warnings are not in sudo yet.
79+ </li>
80+ <li>Card checks refused a trial (a card used before, or a prepaid card) are in billing's records, not listed here.</li>
81+ <li>To stop a workspace's compute, set a hold on its page, under Plan, pools and caps.</li>
82+ </ul>
83+ </Section>
84+ </main>
85+ );
86+}
+468−0
1+import { Check, Search, Ticket, X } from "lucide-react";
2+import { Link, data, redirect, useLocation } from "react-router";
3+
4+import type { Invite, InviteTree, InviteTreeNode, WaitlistEntry } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/invites";
7+import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Select, Textarea, When } from "~/components/ui";
8+import { text } from "~/lib/forms";
9+import {
10+ INVITES_DONE,
11+ INVITE_TABS,
12+ type InviteTab,
13+ TAB_LABEL,
14+ invitesHref,
15+ parseGrant,
16+ parseMintEmail,
17+ parseTab,
18+ parseWaitlistStatus,
19+} from "~/lib/invites";
20+import { identity } from "~/lib/services.server";
21+import { settle } from "~/lib/settle";
22+import { requireStaff } from "~/lib/staff";
23+
24+export const meta: Route.MetaFunction = () => [{ title: "Invites · sudo" }, { name: "robots", content: "noindex, nofollow" }];
25+
26+export async function loader({ request, context }: Route.LoaderArgs) {
27+ requireStaff(context);
28+ const url = new URL(request.url);
29+ const tab = parseTab(url.searchParams.get("tab"));
30+ const query = url.searchParams.get("q")?.trim() ?? "";
31+ const status = parseWaitlistStatus(url.searchParams.get("status"));
32+ const name = url.searchParams.get("name")?.trim().toLowerCase() ?? "";
33+ const kind = url.searchParams.get("kind") === "workspace" ? "workspace" : "user";
34+ const done = url.searchParams.get("done");
35+ const [waitlist, invites, tree] = await Promise.all([
36+ tab === "waitlist" ? settle(identity.waitlist(query || null, status === "all" ? null : status)) : null,
37+ tab === "invites" ? settle(identity.invites(query || null)) : null,
38+ tab === "tree" && name ? settle(kind === "workspace" ? identity.workspaceInvites(name) : identity.inviteTree(name)) : null,
39+ ]);
40+ return {
41+ tab,
42+ query,
43+ status,
44+ name,
45+ kind,
46+ waitlist: waitlist?.ok ? waitlist.value : [],
47+ invites: invites?.ok ? invites.value : [],
48+ tree: tree?.ok ? tree.value : null,
49+ error: [waitlist, invites, tree].map((result) => (result && !result.ok ? result.error : null)).find(Boolean) ?? null,
50+ done: done ? (INVITES_DONE[done] ?? null) : null,
51+ };
52+}
53+
54+export async function action({ request, context }: Route.ActionArgs) {
55+ const staff = requireStaff(context);
56+ const form = await request.formData();
57+ const back = (done: string) => {
58+ const url = new URL(request.url);
59+ url.searchParams.set("done", done);
60+ return redirect(`${url.pathname}${url.search}`);
61+ };
62+ switch (text(form, "intent")) {
63+ case "approve":
64+ case "dismiss": {
65+ const approve = text(form, "intent") === "approve";
66+ const result = await identity.decideWaitlist(text(form, "id"), approve, staff.email);
67+ if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
68+ throw back(approve ? "approved" : "dismissed");
69+ }
70+ case "revoke": {
71+ const result = await identity.revokeInvite(text(form, "id"), staff.email);
72+ if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
73+ throw back("revoked");
74+ }
75+ case "grant": {
76+ const grant = parseGrant(form);
77+ if (!grant.ok) return data({ error: grant.error, section: "grant" }, { status: 422 });
78+ const { target, name, amount, note } = grant.value;
79+ const result = await identity.grantInvites(target, name, amount, note, staff.email);
80+ if (!result.ok) return data({ error: result.error.message, section: "grant" }, { status: 422 });
81+ throw redirect(`${invitesHref("tree", { name, kind: target })}&done=granted`);
82+ }
83+ case "mint": {
84+ const email = parseMintEmail(text(form, "email"));
85+ if (!email.ok) return data({ error: email.error, section: "mint" }, { status: 422 });
86+ const result = await identity.mintInvite(email.value, staff.email);
87+ if (!result.ok) return data({ error: result.error.message, section: "mint" }, { status: 422 });
88+ // Shown once, here: staff cannot see it again.
89+ return { minted: result.value };
90+ }
91+ }
92+ return data({ error: "Unknown action." }, { status: 400 });
93+}
94+
95+type ActionData = Route.ComponentProps["actionData"];
96+
97+function errorFor(actionData: ActionData, key: { id?: string; section?: string }): string | null {
98+ if (!actionData || !("error" in actionData)) return null;
99+ if (key.id && "id" in actionData && actionData.id === key.id) return actionData.error;
100+ if (key.section && "section" in actionData && actionData.section === key.section) return actionData.error;
101+ return null;
102+}
103+
104+function statusBadge(invite: Invite) {
105+ switch (invite.status) {
106+ case "pending":
107+ return <Badge tone="lavender">Pending</Badge>;
108+ case "redeemed":
109+ return <Badge tone="mint">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}</Badge>;
110+ case "expired":
111+ return <Badge>Expired</Badge>;
112+ case "revoked":
113+ return <Badge tone="danger">Revoked</Badge>;
114+ }
115+}
116+
117+function SearchForm({ tab, query, placeholder, extra }: { tab: InviteTab; query: string; placeholder: string; extra?: React.ReactNode }) {
118+ return (
119+ <form method="get" className="flex flex-wrap items-end gap-2">
120+ <input type="hidden" name="tab" value={tab} />
121+ <div className="min-w-0 grow basis-60">
122+ <Input name="q" defaultValue={query} placeholder={placeholder} aria-label="Search" />
123+ </div>
124+ {extra}
125+ <Button type="submit" variant="quiet">
126+ <Search size={14} />
127+ Search
128+ </Button>
129+ </form>
130+ );
131+}
132+
133+function Waitlist({ entries, query, status, actionData }: { entries: WaitlistEntry[]; query: string; status: string; actionData: ActionData }) {
134+ const { pathname, search } = useLocation();
135+ return (
136+ <div className="space-y-4">
137+ <SearchForm
138+ tab="waitlist"
139+ query={query}
140+ placeholder="Email or what they said"
141+ extra={
142+ <div className="w-full sm:w-40">
143+ <Select name="status" defaultValue={status} aria-label="Status">
144+ <option value="waiting">Waiting</option>
145+ <option value="invited">Invited</option>
146+ <option value="dismissed">Dismissed</option>
147+ <option value="all">All</option>
148+ </Select>
149+ </div>
150+ }
151+ />
152+ {entries.length === 0 ? (
153+ <EmptyState title="Nobody waiting">People who ask for access on g1t.sh/register show up here, oldest first.</EmptyState>
154+ ) : (
155+ <ul className="divide-y divide-line rounded-lg border border-line bg-surface">
156+ {entries.map((entry) => {
157+ const error = errorFor(actionData, { id: entry.id });
158+ 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+ </>
170+ )}
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+ )}
185+ </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>}
194+ </li>
195+ );
196+ })}
197+ </ul>
198+ )}
199+ </div>
200+ );
201+}
202+
203+function InviteTable({ invites, actionData, revoke = true }: { invites: Invite[]; actionData: ActionData; revoke?: boolean }) {
204+ const { pathname, search } = useLocation();
205+ if (invites.length === 0) return <EmptyState title="No invites">Nothing matches.</EmptyState>;
206+ return (
207+ <div className="overflow-x-auto rounded-lg border border-line bg-surface">
208+ <table className="w-full min-w-[38rem] text-sm">
209+ <thead>
210+ <tr className="text-left text-xs text-faint">
211+ <th className="px-4 py-2 font-medium">Code</th>
212+ <th className="px-4 py-2 font-medium">For</th>
213+ <th className="px-4 py-2 font-medium">From</th>
214+ <th className="px-4 py-2 font-medium">State</th>
215+ <th className="px-4 py-2 font-medium">Made</th>
216+ <th className="px-4 py-2" />
217+ </tr>
218+ </thead>
219+ <tbody>
220+ {invites.map((invite) => {
221+ const error = errorFor(actionData, { id: invite.id });
222+ return (
223+ <tr key={invite.id} id={invite.id} className="scroll-mt-20 border-t border-line align-top">
224+ <td className="px-4 py-2 font-mono text-xs whitespace-nowrap">{invite.hint}…</td>
225+ <td className="px-4 py-2">
226+ <span className="break-all">{invite.email ?? <span className="text-faint">anyone</span>}</span>
227+ {invite.workspace && <span className="block text-xs text-faint">joins {invite.workspace}</span>}
228+ </td>
229+ <td className="px-4 py-2">
230+ {invite.invitedBy ? (
231+ <Link to={invitesHref("tree", { name: invite.invitedBy })} className="font-mono hover:underline">
232+ {invite.invitedBy}
233+ </Link>
234+ ) : (
235+ <span className="text-xs text-muted">staff{invite.staff ? ` · ${invite.staff}` : ""}</span>
236+ )}
237+ <span className="block text-xs text-faint">
238+ {invite.chargedTo === "workspace" ? "workspace's invites" : invite.chargedTo === "user" ? "own invites" : "free"}
239+ </span>
240+ </td>
241+ <td className="px-4 py-2">
242+ {statusBadge(invite)}
243+ {error && <p className="mt-1 text-xs text-danger">{error}</p>}
244+ </td>
245+ <td className="px-4 py-2 text-xs whitespace-nowrap text-muted">
246+ <When at={invite.createdAt} />
247+ </td>
248+ <td className="px-4 py-2 text-right">
249+ {revoke && invite.status === "pending" && (
250+ <form method="post" action={`${pathname}${search}#${invite.id}`}>
251+ <input type="hidden" name="id" value={invite.id} />
252+ <Button type="submit" name="intent" value="revoke" variant="danger">
253+ Revoke
254+ </Button>
255+ </form>
256+ )}
257+ </td>
258+ </tr>
259+ );
260+ })}
261+ </tbody>
262+ </table>
263+ </div>
264+ );
265+}
266+
267+function GrantAndMint({ actionData }: { actionData: ActionData }) {
268+ const minted = actionData && "minted" in actionData ? actionData.minted : null;
269+ return (
270+ <div className="grid gap-5 lg:grid-cols-2">
271+ <Section title="Grant invites" description="More invites for a person, or for a workspace whose owners share them. A negative number takes some back.">
272+ <form method="post" className="space-y-3">
273+ <input type="hidden" name="intent" value="grant" />
274+ <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
275+ <Field label="To">
276+ <Select name="target" defaultValue="user">
277+ <option value="user">Person</option>
278+ <option value="workspace">Workspace</option>
279+ </Select>
280+ </Field>
281+ <Field label="Username or slug">
282+ <Input name="name" required maxLength={40} placeholder="ada" />
283+ </Field>
284+ </div>
285+ <Field label="Invites" hint="Up to 1000 at a time.">
286+ <Input name="amount" required inputMode="numeric" placeholder="10" />
287+ </Field>
288+ <Field label="Why" hint="Kept with the grant.">
289+ <Textarea name="note" rows={2} maxLength={500} required placeholder="e.g. Launch partner bringing their team." />
290+ </Field>
291+ {errorFor(actionData, { section: "grant" }) && <Notice tone="error">{errorFor(actionData, { section: "grant" })}</Notice>}
292+ <div className="flex justify-end">
293+ <Button type="submit" variant="lavender">
294+ Grant
295+ </Button>
296+ </div>
297+ </form>
298+ </Section>
299+ <Section title="Mint an invite" description="An invite that uses nobody's allowance. With an email, it is sent there and only that address can use it.">
300+ <form method="post" className="space-y-3">
301+ <input type="hidden" name="intent" value="mint" />
302+ <Field label="Email (optional)">
303+ <Input name="email" type="email" maxLength={254} placeholder="Anyone with the code" />
304+ </Field>
305+ {errorFor(actionData, { section: "mint" }) && <Notice tone="error">{errorFor(actionData, { section: "mint" })}</Notice>}
306+ {minted?.code && (
307+ <Notice tone="ok">
308+ <p>{minted.email ? `Sent to ${minted.email}. ` : ""}Copy it now; it is not shown again.</p>
309+ <p className="mt-1 font-mono text-xs break-all text-fg">https://g1t.sh/invite/{minted.code}</p>
310+ </Notice>
311+ )}
312+ <div className="flex justify-end">
313+ <Button type="submit">
314+ <Ticket size={14} />
315+ Mint
316+ </Button>
317+ </div>
318+ </form>
319+ </Section>
320+ </div>
321+ );
322+}
323+
324+function Nodes({ nodes }: { nodes: InviteTreeNode[] }) {
325+ if (nodes.length === 0) return null;
326+ return (
327+ <ul className="space-y-1 border-l border-line pl-4">
328+ {nodes.map((node) => (
329+ <li key={node.username}>
330+ <Link to={invitesHref("tree", { name: node.username })} className="font-mono text-sm hover:underline">
331+ {node.username}
332+ </Link>{" "}
333+ <span className="text-xs text-faint">
334+ joined <When at={node.joinedAt} />
335+ </span>
336+ <div className="mt-1">
337+ <Nodes nodes={node.invited} />
338+ </div>
339+ </li>
340+ ))}
341+ </ul>
342+ );
343+}
344+
345+function Tree({ tree, name, kind, actionData }: { tree: InviteTree | null; name: string; kind: string; actionData: ActionData }) {
346+ return (
347+ <div className="space-y-4">
348+ <form method="get" className="flex flex-wrap items-end gap-2">
349+ <input type="hidden" name="tab" value="tree" />
350+ <div className="w-full sm:w-40">
351+ <Select name="kind" defaultValue={kind} aria-label="Kind">
352+ <option value="user">Person</option>
353+ <option value="workspace">Workspace</option>
354+ </Select>
355+ </div>
356+ <div className="min-w-0 grow basis-60">
357+ <Input name="name" defaultValue={name} placeholder="Username or workspace slug" aria-label="Name" />
358+ </div>
359+ <Button type="submit" variant="quiet">
360+ Look up
361+ </Button>
362+ </form>
363+ {name && !tree && <EmptyState title={`No ${kind === "workspace" ? "workspace" : "account"} named ${name}`} />}
364+ {tree && (
365+ <div className="grid gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
366+ <Section title={tree.username} description={kind === "workspace" ? "A workspace's shared invites" : "Where they came from, and whom they brought"}>
367+ <dl className="space-y-2 text-sm">
368+ {kind !== "workspace" && (
369+ <div>
370+ <dt className="text-xs text-faint">Invited by</dt>
371+ <dd className="font-mono">
372+ {tree.invitedBy.length > 0
373+ ? tree.invitedBy.map((username, index) => (
374+ <span key={username}>
375+ {index > 0 && <span className="text-faint"> ← </span>}
376+ <Link to={invitesHref("tree", { name: username })} className="hover:underline">
377+ {username}
378+ </Link>
379+ </span>
380+ ))
381+ : tree.staff
382+ ? `staff (${tree.staff})`
383+ : "nobody: made without an invite"}
384+ </dd>
385+ </div>
386+ )}
387+ <div>
388+ <dt className="text-xs text-faint">Allowance</dt>
389+ <dd>
390+ {tree.allowance.limit == null
391+ ? `No limit · ${tree.allowance.used} out`
392+ : `${tree.allowance.used} of ${tree.allowance.limit} used · ${tree.allowance.remaining} left`}
393+ </dd>
394+ </div>
395+ {tree.grants.length > 0 && (
396+ <div>
397+ <dt className="text-xs text-faint">Grants</dt>
398+ <dd>
399+ <ul className="space-y-1">
400+ {tree.grants.map((grant) => (
401+ <li key={grant.createdAt} className="text-xs">
402+ <span className="tabular font-medium">{grant.amount > 0 ? `+${grant.amount}` : grant.amount}</span> by{" "}
403+ <span className="font-mono">{grant.grantedBy}</span> <When at={grant.createdAt} />
404+ {grant.note && <span className="block text-muted">{grant.note}</span>}
405+ </li>
406+ ))}
407+ </ul>
408+ </dd>
409+ </div>
410+ )}
411+ {kind !== "workspace" && (
412+ <div>
413+ <dt className="mb-1 text-xs text-faint">Invited</dt>
414+ <dd>{tree.invited.length > 0 ? <Nodes nodes={tree.invited} /> : <span className="text-muted">Nobody yet</span>}</dd>
415+ </div>
416+ )}
417+ </dl>
418+ </Section>
419+ <div className="min-w-0">
420+ <h3 className="mb-2 text-sm font-medium">Invites</h3>
421+ <InviteTable invites={tree.invites} actionData={actionData} />
422+ </div>
423+ </div>
424+ )}
425+ </div>
426+ );
427+}
428+
429+export default function Invites({ loaderData, actionData }: Route.ComponentProps) {
430+ const { tab, query, status, waitlist, invites, tree, name, kind, error, done } = loaderData;
431+ return (
432+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
433+ <PageHeader
434+ title="Invites"
435+ description="g1t.sh is invite-only. Approve people from the waitlist, find and revoke invites, grant more to a person or a workspace, and trace who brought whom."
436+ />
437+ <nav aria-label="Invites" className="mt-5 flex flex-wrap gap-2">
438+ {INVITE_TABS.map((value) => (
439+ <Link
440+ key={value}
441+ to={invitesHref(value)}
442+ aria-current={value === tab ? "page" : undefined}
443+ className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
444+ value === tab ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg"
445+ }`}
446+ >
447+ {TAB_LABEL[value]}
448+ </Link>
449+ ))}
450+ </nav>
451+ <div className="mt-5 space-y-3 empty:hidden">
452+ {done && <Notice tone="ok">{done}</Notice>}
453+ {error && <Notice tone="warn">Identity did not answer: {error}</Notice>}
454+ </div>
455+ <div className="mt-5">
456+ {tab === "waitlist" && <Waitlist entries={waitlist} query={query} status={status} actionData={actionData} />}
457+ {tab === "invites" && (
458+ <div className="space-y-4">
459+ <SearchForm tab="invites" query={query} placeholder="A code's start (g1t-k7m2), an email, or a username" />
460+ <InviteTable invites={invites} actionData={actionData} />
461+ </div>
462+ )}
463+ {tab === "grant" && <GrantAndMint actionData={actionData} />}
464+ {tab === "tree" && <Tree tree={tree} name={name} kind={kind} actionData={actionData} />}
465+ </div>
466+ </main>
467+ );
468+}
+283−0
1+import { AlertTriangle, Gift } from "lucide-react";
2+import { Link, data, redirect, useLocation } from "react-router";
3+
4+import type { Overage } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/overages";
7+import { Hidden } from "~/components/billing";
8+import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Stat, Textarea, When } from "~/components/ui";
9+import { fields, parseGoodwill } from "~/lib/forms";
10+import { dollarsField, parseDollars, usd } from "~/lib/money";
11+import { FORGIVE_COST_MICROS, goodwillChoices, goodwillLine, goodwillWarning, spikeLabel } from "~/lib/pricing";
12+import { DONE, doneKey } from "~/lib/review";
13+import { admin, priceBook } from "~/lib/services.server";
14+import { settle } from "~/lib/settle";
15+import { requireStaff } from "~/lib/staff";
16+
17+export const meta: Route.MetaFunction = () => [{ title: "Overages · sudo" }, { name: "robots", content: "noindex, nofollow" }];
18+
19+async function capMicros(): Promise<number> {
20+ const book = await settle(priceBook());
21+ return (book.ok && book.value.free?.overageForgiveCostMicros) || FORGIVE_COST_MICROS;
22+}
23+
24+export async function loader({ request, context }: Route.LoaderArgs) {
25+ requireStaff(context);
26+ const [queue, cap] = await Promise.all([settle(admin.overages()), capMicros()]);
27+ const done = doneKey(request.url);
28+ return {
29+ queue: queue.ok ? queue.value : [],
30+ error: queue.ok ? null : queue.error,
31+ cap,
32+ done: done ? DONE[done] : null,
33+ };
34+}
35+
36+type Review = {
37+ workspace: string;
38+ amount: string;
39+ day: string;
40+ reason: string;
41+ absorbedMicros: number;
42+ overCap: boolean;
43+ needsReason: boolean;
44+ amountMicros: number;
45+};
46+
47+type ActionResult = { error: string; workspace: string; values: Record<string, string> } | { review: Review };
48+
49+export async function action({ request, context }: Route.ActionArgs) {
50+ const staff = requireStaff(context);
51+ const form = await request.formData();
52+ const values = fields(form, "workspace", "amount", "reason", "day");
53+ const fail = (error: string) => data<ActionResult>({ error, workspace: values.workspace, values }, { status: 422 });
54+ const [queue, cap] = await Promise.all([admin.overages(), capMicros()]);
55+ // What is credited comes from billing's queue, not the form.
56+ const overage = queue.find((row) => row.workspace === values.workspace);
57+ if (!overage) return fail("That workspace is no longer in the Overages queue. Give credit from its page instead.");
58+ const parsed = parseGoodwill(form, (amount) => goodwillWarning(amount, overage.goodwill, overage.lastGoodwillAt, cap).needsReason);
59+ // The amount as typed, even when the form is not complete yet: it decides whether the red review shows.
60+ const typed = values.amount ? parseDollars(values.amount) : null;
61+ const warning = goodwillWarning(typed, overage.goodwill, overage.lastGoodwillAt, cap);
62+ // Past the cap, staff see what g1t absorbs in red, with the reason, before it goes.
63+ if (warning.overCap && form.get("confirm") !== "yes") {
64+ return {
65+ review: {
66+ workspace: overage.workspace,
67+ amount: values.amount ?? "",
68+ day: values.day ?? "",
69+ reason: values.reason ?? "",
70+ ...warning,
71+ amountMicros: typed ?? overage.goodwill.creditMicros,
72+ },
73+ } satisfies ActionResult;
74+ }
75+ if (!parsed.ok) return fail(parsed.error);
76+ const result = await admin.goodwill(overage.workspace, parsed.value.amountMicros, parsed.value.reason, staff.email, parsed.value.day);
77+ if (!result.ok) return fail(result.error.message);
78+ throw redirect("/overages?done=goodwill");
79+}
80+
81+export default function Overages({ loaderData, actionData }: Route.ComponentProps) {
82+ const { queue, error, cap, done } = loaderData;
83+ const result = actionData as ActionResult | undefined;
84+ const review = result && "review" in result ? result.review : null;
85+ const totals = queue.reduce(
86+ (sum, row) => ({
87+ overage: sum.overage + row.goodwill.overageMicros,
88+ margin: sum.margin + row.goodwill.marginMicros,
89+ absorbed: sum.absorbed + (row.goodwillAvailable ? row.goodwill.absorbedMicros : 0),
90+ }),
91+ { overage: 0, margin: 0, absorbed: 0 },
92+ );
93+ return (
94+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
95+ <PageHeader
96+ title="Overages"
97+ description={
98+ <>
99+ Workspaces whose month went well past their usual (twice it, and $10 over), hit a spend spike, or asked for help. A goodwill credit
100+ always returns g1t's margin on the overage and absorbs at most {usd(cap)} of real cost in one click, once per workspace in 12 months.
101+ </>
102+ }
103+ />
104+ <div className="mt-5 space-y-3 empty:hidden">
105+ {done && <Notice tone="ok">{done}</Notice>}
106+ {error && <Notice tone="warn">Billing did not answer for overages: {error}</Notice>}
107+ </div>
108+ {queue.length > 0 && (
109+ <div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4">
110+ <Stat label="In the queue" value={String(queue.length)} hint={`${queue.filter((row) => row.request).length} asked for help`} />
111+ <Stat label="Over their usual" value={usd(totals.overage)} hint="This month, above each typical month" />
112+ <Stat label="g1t's margin in it" value={usd(totals.margin)} hint="Returned by any goodwill credit" />
113+ <Stat label="Real cost, if all forgiven" value={usd(totals.absorbed)} hint="One-click credits still available" tone={totals.absorbed > 0 ? "warn" : undefined} />
114+ </div>
115+ )}
116+ {review && <ReviewPanel review={review} cap={cap} />}
117+ <div className="mt-6 space-y-4">
118+ {queue.length === 0 && !error ? (
119+ <EmptyState title="No overages this month">Workspaces show up here when a month runs well past their usual, or a spike pauses them.</EmptyState>
120+ ) : (
121+ queue.map((row) => (
122+ <OverageCard
123+ key={row.workspace}
124+ row={row}
125+ cap={cap}
126+ error={result && "error" in result && result.workspace === row.workspace ? result : null}
127+ />
128+ ))
129+ )}
130+ </div>
131+ </main>
132+ );
133+}
134+
135+/** Past the cap: what g1t absorbs in red, and the reason, before it is given. */
136+function ReviewPanel({ review, cap }: { review: Review; cap: number }) {
137+ const { pathname } = useLocation();
138+ return (
139+ <section id="review" className="mt-6 scroll-mt-20 rounded-lg border border-danger/50 bg-danger/8 p-4 sm:p-5">
140+ <h2 className="flex items-center gap-2 font-semibold tracking-tight text-danger">
141+ <AlertTriangle size={16} />
142+ g1t absorbs {usd(review.absorbedMicros)} of real cost
143+ </h2>
144+ <p className="mt-2 text-sm text-fg-soft">
145+ A {usd(review.amountMicros)} credit to <span className="font-mono">{review.workspace}</span> is past the {usd(cap)} a goodwill credit
146+ absorbs in one click. That is money g1t already paid Cloudflare and model providers. Say why, for whoever looks next; it goes in the
147+ audit log with your email.
148+ </p>
149+ <form method="post" action={`${pathname}#review`} className="mt-4 space-y-3">
150+ <Hidden values={{ workspace: review.workspace, amount: review.amount, day: review.day, confirm: "yes" }} />
151+ <Field label="Reason" hint="Required: a sentence at least.">
152+ <Textarea name="reason" rows={2} required minLength={10} maxLength={500} defaultValue={review.reason} />
153+ </Field>
154+ <div className="flex flex-wrap items-center gap-2">
155+ <Button type="submit" variant="danger">
156+ Give {usd(review.amountMicros)} anyway
157+ </Button>
158+ <Link to="/overages" className="px-2 text-sm text-muted hover:text-fg">
159+ Cancel
160+ </Link>
161+ </div>
162+ </form>
163+ </section>
164+ );
165+}
166+
167+function OverageCard({ row, cap, error }: { row: Overage; cap: number; error: { error: string; values: Record<string, string> } | null }) {
168+ const { pathname } = useLocation();
169+ const { goodwill } = row;
170+ const spike = row.spike ? spikeLabel(row.spike.status) : null;
171+ const choices = goodwillChoices(goodwill).map((choice) => ({
172+ ...choice,
173+ ...goodwillWarning(choice.micros, goodwill, row.lastGoodwillAt, cap),
174+ amount: choice.micros ?? goodwill.creditMicros,
175+ }));
176+ return (
177+ <section id={row.workspace} className="scroll-mt-20 rounded-lg border border-line bg-surface">
178+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-line px-4 py-3 sm:px-5">
179+ <div className="min-w-0">
180+ <p className="flex flex-wrap items-center gap-2">
181+ <Link to={`/workspaces/${encodeURIComponent(row.workspace)}#billing`} className="font-mono font-medium text-fg hover:underline">
182+ {row.workspace}
183+ </Link>
184+ <Badge>{row.plan}</Badge>
185+ {spike && <Badge tone={spike.tone === "plain" ? "plain" : spike.tone}>{spike.label}</Badge>}
186+ {row.request && <Badge tone="warn">Asked for help</Badge>}
187+ {!row.goodwillAvailable && <Badge tone="danger">Goodwill given{" "}<When at={row.lastGoodwillAt} /></Badge>}
188+ </p>
189+ <p className="mt-0.5 text-xs text-faint">
190+ Typical month {usd(row.typicalMonthMicros)} · this month {usd(row.thisMonthMicros)} · cost to g1t {usd(row.costMicros)}
191+ </p>
192+ </div>
193+ </header>
194+ <div className="grid gap-5 p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
195+ <div className="min-w-0 space-y-4">
196+ {row.request && (
197+ <blockquote className="border-l-2 border-warn/50 pl-3 text-sm break-words whitespace-pre-line text-fg-soft">
198+ “{row.request.reason}” <span className="text-xs text-faint">— {row.request.createdBy}</span>
199+ </blockquote>
200+ )}
201+ <div>
202+ <p className="text-xs font-medium text-muted">The goodwill math</p>
203+ <dl className="mt-2 space-y-1 text-sm">
204+ <Line label="Over the typical month" value={usd(goodwill.overageMicros)} />
205+ <Line label="g1t's margin on it, always returned" value={usd(goodwill.marginMicros)} tone="mint" />
206+ <Line label="What it cost g1t" value={usd(goodwill.costMicros)} />
207+ <Line label={`Real cost absorbed in one click (at most ${usd(cap)})`} value={usd(goodwill.absorbedMicros)} tone="warn" />
208+ <Line label="One-click credit" value={usd(goodwill.creditMicros)} strong />
209+ </dl>
210+ </div>
211+ {row.topEntries.length > 0 && (
212+ <div>
213+ <p className="text-xs font-medium text-muted">What caused it</p>
214+ <ul className="mt-2 divide-y divide-line rounded-md border border-line text-xs">
215+ {row.topEntries.slice(0, 5).map((entry) => (
216+ <li key={entry.id} className="flex items-start justify-between gap-3 px-3 py-2">
217+ <span className="min-w-0">
218+ <span className="block break-words text-fg-soft">{entry.description}</span>
219+ <span className="font-mono text-faint">
220+ {[entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo), entry.task, entry.model].filter(Boolean).join(" · ")}
221+ </span>
222+ </span>
223+ <span className="tabular shrink-0">{usd(-entry.amountMicros)}</span>
224+ </li>
225+ ))}
226+ </ul>
227+ </div>
228+ )}
229+ </div>
230+ <div className="min-w-0 space-y-3">
231+ <ul className="space-y-2">
232+ {choices.map((choice) => (
233+ <li
234+ key={choice.label}
235+ className={`rounded-md border px-3 py-2 text-sm ${choice.overCap ? "border-danger/50 bg-danger/8" : "border-line bg-bg"}`}
236+ >
237+ <p className="flex justify-between gap-3">
238+ <span className="font-medium">{choice.label}</span>
239+ <span className="tabular">{usd(choice.amount)}</span>
240+ </p>
241+ <p className={`mt-0.5 text-xs ${choice.overCap ? "text-danger" : "text-muted"}`}>
242+ Returns {usd(Math.min(choice.amount, goodwill.marginMicros))} of margin; g1t absorbs {usd(choice.absorbedMicros)} of real cost
243+ {choice.overCap ? `, past the ${usd(cap)} cap` : ""}.{choice.needsReason ? " Needs a reason." : ""}
244+ </p>
245+ </li>
246+ ))}
247+ </ul>
248+ <form method="post" action={`${pathname}#${row.workspace}`} className="space-y-3">
249+ <input type="hidden" name="workspace" value={row.workspace} />
250+ {error && <Notice tone="error">{error.error}</Notice>}
251+ <div className="grid gap-3 sm:grid-cols-2">
252+ <Field label="Amount $" hint="Blank: the one-click credit.">
253+ <Input name="amount" inputMode="decimal" placeholder={dollarsField(goodwill.creditMicros)} defaultValue={error?.values.amount ?? ""} />
254+ </Field>
255+ <Field label="Day of the usage" hint="Blank: the spike's day, or today.">
256+ <Input type="date" name="day" defaultValue={error?.values.day ?? row.spike?.detectedAt.slice(0, 10) ?? ""} />
257+ </Field>
258+ </div>
259+ <Field label="Reason" hint={row.goodwillAvailable ? "Needed past the one-click credit." : "Needed: a credit was given in the last 12 months."}>
260+ <Textarea name="reason" rows={2} maxLength={500} defaultValue={error?.values.reason ?? ""} placeholder="e.g. A review loop ran overnight on a misconfigured workflow." />
261+ </Field>
262+ <div className="flex flex-wrap items-center justify-between gap-2">
263+ <p className="text-xs text-faint">On their statement as “{goodwillLine("YYYY-MM-DD")}”.</p>
264+ <Button type="submit" variant="lavender">
265+ <Gift size={14} />
266+ Give goodwill credit
267+ </Button>
268+ </div>
269+ </form>
270+ </div>
271+ </div>
272+ </section>
273+ );
274+}
275+
276+function Line({ label, value, tone, strong = false }: { label: string; value: string; tone?: "mint" | "warn"; strong?: boolean }) {
277+ return (
278+ <div className={`flex justify-between gap-4 ${strong ? "border-t border-line pt-1 font-medium" : ""}`}>
279+ <dt className="text-muted">{label}</dt>
280+ <dd className={`tabular ${tone === "mint" ? "text-accent" : tone === "warn" ? "text-warn" : "text-fg"}`}>{value}</dd>
281+ </div>
282+ );
283+}
+110−14
1−import { AlertOctagon, ArrowRight, CalendarClock, CreditCard, FileText, Gauge } from "lucide-react";
1+import { Activity, AlertOctagon, ArrowRight, CalendarClock, CreditCard, FileText, Gauge, HandHelping, TrendingUp } from "lucide-react";
22 import type { ReactNode } from "react";
33 import { Link, redirect } from "react-router";
44
66 import { KindBreakdown, MonthsChart } from "~/components/charts";
77 import { SignalBadge, StaffName, StageBadge } from "~/components/sales";
88 import { Avatar, Notice, PageHeader, Section, Stat } from "~/components/ui";
9−import { change, marginOf, monthLong } from "~/lib/chart";
9+import { change, monthLong } from "~/lib/chart";
10+import { givenLabel, givenTotal, moneyApart } from "~/lib/pricing";
1011 import { usd } from "~/lib/money";
1112 import { admin } from "~/lib/services.server";
1213 import { settle } from "~/lib/settle";
3940 const previous = current ? months[months.indexOf(current) - 1] ?? null : null;
4041 const charged = current?.chargedMicros ?? overview?.byKind.reduce((sum, row) => sum + row.chargedMicros, 0) ?? 0;
4142 const cost = current?.costMicros ?? overview?.byKind.reduce((sum, row) => sum + row.costMicros, 0) ?? 0;
42− const margin = marginOf(charged, cost);
43− const growth = previous ? change(previous.chargedMicros, charged) : null;
43+ const plans = current?.plansMicros ?? 0;
44+ const given = overview?.given ?? [];
45+ const givenSum = givenTotal(given);
46+ const money = moneyApart({ chargedMicros: charged, plansMicros: plans, costMicros: cost }, givenSum.costMicros);
47+ const growth = previous ? change(previous.chargedMicros + (previous.plansMicros ?? 0), money.revenueMicros) : null;
4448
4549 return (
4650 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
5963 <>
6064 <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
6165 <Stat
62− label="Charged this month"
63− value={usd(charged)}
64− hint={growth == null ? "Nothing last month to compare" : `${growth >= 0 ? "+" : "−"}${Math.abs(growth)}% on last month`}
66+ label="Revenue this month"
67+ value={usd(money.revenueMicros)}
68+ hint={
69+ <>
70+ {usd(charged)} usage, {usd(plans)} plans
71+ {growth == null ? "" : ` · ${growth >= 0 ? "+" : "−"}${Math.abs(growth)}% on last month`}
72+ </>
73+ }
6574 tone={growth != null && growth > 0 ? "mint" : undefined}
6675 />
67− <Stat label="Cost to g1t" value={usd(cost)} hint="What the usage cost on Cloudflare and model providers" />
6876 <Stat
69− label="Margin"
70− value={usd(margin.micros)}
71− hint={margin.percent == null ? "Nothing charged yet" : `${margin.percent}% of what was charged`}
72− tone={margin.micros < 0 ? "danger" : undefined}
77+ label="Margin on what was sold"
78+ value={usd(money.marginMicros)}
79+ hint={
80+ money.marginPercent == null
81+ ? "Nothing sold yet"
82+ : `${money.marginPercent}% of revenue, after ${usd(money.soldCostMicros)} of cost`
83+ }
84+ tone={money.marginMicros < 0 ? "danger" : undefined}
85+ />
86+ <Stat
87+ label="Given by g1t"
88+ value={usd(givenSum.micros)}
89+ hint={`At price; it cost g1t ${usd(givenSum.costMicros)}. Not a loss on sales.`}
90+ />
91+ <Stat
92+ label="Plans"
93+ value={String(overview.activePlans ?? 0)}
94+ hint={`${usd(overview.planMrrMicros ?? 0)} a month · ${overview.payingWorkspaces} paying this month`}
7395 />
74− <Stat label="Paying workspaces" value={String(overview.payingWorkspaces)} hint="Charged something this month" />
7596 </div>
97+ <p className="mt-2 text-xs text-faint">
98+ Cost to g1t in all {usd(cost)}; revenue less all of it, given included: {usd(money.netMicros)}.
99+ </p>
76100
77101 {overview.pools && (
78102 <div className="mt-3 grid grid-cols-2 gap-3">
100124 </Section>
101125 </div>
102126
127+ <Section
128+ title="Given this month, apart from margin"
129+ description="Internal use, trials, the open-source pool, goodwill and what g1t covered: at price, and what it cost."
130+ className="mt-6"
131+ >
132+ {given.length === 0 ? (
133+ <p className="text-sm text-muted">Nothing given yet this month.</p>
134+ ) : (
135+ <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
136+ <table className="w-full min-w-[26rem] text-sm">
137+ <thead>
138+ <tr className="border-b border-line text-left text-xs text-muted">
139+ <th className="px-4 py-2 font-medium sm:pl-5">Source</th>
140+ <th className="px-4 py-2 text-right font-medium">At price</th>
141+ <th className="px-4 py-2 text-right font-medium sm:pr-5">Cost to g1t</th>
142+ </tr>
143+ </thead>
144+ <tbody>
145+ {given.map((row) => (
146+ <tr key={row.source} className="border-b border-line last:border-0">
147+ <td className="px-4 py-2 sm:pl-5">{givenLabel(row)}</td>
148+ <td className="tabular px-4 py-2 text-right">{usd(row.micros)}</td>
149+ <td className="tabular px-4 py-2 text-right text-muted sm:pr-5">{usd(row.costMicros)}</td>
150+ </tr>
151+ ))}
152+ </tbody>
153+ </table>
154+ </div>
155+ )}
156+ {(overview.internal ?? []).length > 0 && (
157+ <ul className="mt-6 space-y-1 border-t border-line pt-3 text-xs">
158+ {(overview.internal ?? []).map((row) => (
159+ <li key={row.workspace} className="flex flex-wrap justify-between gap-2">
160+ <span>
161+ <Link to={`/workspaces/${encodeURIComponent(row.workspace)}`} className="font-mono text-fg-soft hover:underline">
162+ {row.workspace}
163+ </Link>{" "}
164+ <span className="text-faint">{row.reason}</span>
165+ </span>
166+ <span className="tabular text-muted">
167+ {usd(row.costMicros)} cost · {row.entries} entries
168+ </span>
169+ </li>
170+ ))}
171+ </ul>
172+ )}
173+ </Section>
174+
103175 <h2 className="mt-10 text-lg font-semibold tracking-tight">Needs attention</h2>
104− <div className="mt-3 grid grid-cols-2 gap-3 lg:grid-cols-5">
176+ <div className="mt-3 grid grid-cols-2 gap-3 lg:grid-cols-4">
177+ <AttentionCard
178+ to="/requests"
179+ icon={<HandHelping size={15} />}
180+ label="Requests"
181+ value={overview.openRequests ?? 0}
182+ hint="Waiting for an answer, within one business day"
183+ tone={(overview.openRequests ?? 0) > 0 ? "warn" : undefined}
184+ />
185+ <AttentionCard
186+ to="/overages"
187+ icon={<TrendingUp size={15} />}
188+ label="Overages"
189+ value={overview.overages ?? 0}
190+ hint="Well past their typical month"
191+ tone={(overview.overages ?? 0) > 0 ? "warn" : undefined}
192+ />
193+ <AttentionCard
194+ to="/velocity"
195+ icon={<Activity size={15} />}
196+ label="Spikes"
197+ value={overview.openSpikes ?? 0}
198+ hint="Paused, waiting on an owner"
199+ tone={(overview.openSpikes ?? 0) > 0 ? "danger" : undefined}
200+ />
105201 <AttentionCard
106202 to={reachOutHref({ kind: "at_limit" })}
107203 icon={<AlertOctagon size={15} />}
+241−0
1+import { Check, Clock, X } from "lucide-react";
2+import { Link, data, redirect, useLocation } from "react-router";
3+
4+import type { LimitRequestReview } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/requests";
7+import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Textarea, When } from "~/components/ui";
8+import { fields, parseDecision } from "~/lib/forms";
9+import { usd } from "~/lib/money";
10+import { REQUEST_STATUSES, type RequestStatus, age, parseRequestStatus, requestsHref, waiting } from "~/lib/pricing";
11+import { DONE, doneKey } from "~/lib/review";
12+import { admin } from "~/lib/services.server";
13+import { settle } from "~/lib/settle";
14+import { requireStaff } from "~/lib/staff";
15+
16+export const meta: Route.MetaFunction = () => [{ title: "Requests · sudo" }, { name: "robots", content: "noindex, nofollow" }];
17+
18+export async function loader({ request, context }: Route.LoaderArgs) {
19+ requireStaff(context);
20+ const url = new URL(request.url);
21+ const status = parseRequestStatus(url.searchParams.get("status"));
22+ const [rows, open] = await Promise.all([
23+ settle(admin.limitRequests(status)),
24+ status === "open" ? Promise.resolve(null) : settle(admin.limitRequests("open")),
25+ ]);
26+ const done = doneKey(request.url);
27+ return {
28+ status,
29+ rows: rows.ok ? rows.value : [],
30+ openCount: status === "open" ? (rows.ok ? rows.value.length : 0) : open?.ok ? open.value.length : null,
31+ error: rows.ok ? null : rows.error,
32+ done: done ? DONE[done] : null,
33+ now: new Date().toISOString(),
34+ };
35+}
36+
37+export async function action({ request, context }: Route.ActionArgs) {
38+ const staff = requireStaff(context);
39+ const form = await request.formData();
40+ const values = fields(form, "id", "amount", "note");
41+ const decision = parseDecision(form);
42+ if (!decision.ok) return data({ error: decision.error, id: values.id, values }, { status: 422 });
43+ const { id, amountMicros, note } = decision.value;
44+ const result = await admin.decideLimitRequest(id, decision.value.decision, amountMicros, note, staff.email);
45+ if (!result.ok) return data({ error: result.error.message, id, values }, { status: 422 });
46+ const back = new URL(request.url);
47+ back.searchParams.set("done", decision.value.decision === "approve" ? "approved" : "declined");
48+ throw redirect(`${back.pathname}${back.search}`);
49+}
50+
51+const STATUS_LABEL: Record<RequestStatus, string> = { open: "Open", approved: "Approved", declined: "Declined", all: "All" };
52+
53+export default function Requests({ loaderData, actionData }: Route.ComponentProps) {
54+ const { status, rows, openCount, error, done, now } = loaderData;
55+ return (
56+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
57+ <PageHeader
58+ title="Requests"
59+ description="Owners asking for a higher limit, or for help with a month that went past what they meant. g1t answers within one business day; the owner is told in the app and by email."
60+ />
61+ <nav aria-label="Status" className="mt-5 flex flex-wrap gap-2">
62+ {REQUEST_STATUSES.map((value) => (
63+ <Link
64+ key={value}
65+ to={requestsHref(value)}
66+ aria-current={value === status ? "page" : undefined}
67+ className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
68+ value === status ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg"
69+ }`}
70+ >
71+ {STATUS_LABEL[value]}
72+ {value === "open" && openCount != null && <span className="tabular text-faint">{openCount}</span>}
73+ </Link>
74+ ))}
75+ </nav>
76+ <div className="mt-5 space-y-3 empty:hidden">
77+ {done && <Notice tone="ok">{done}</Notice>}
78+ {error && <Notice tone="warn">Billing did not answer for requests: {error}</Notice>}
79+ </div>
80+ <div className="mt-5 space-y-4">
81+ {rows.length === 0 && !error ? (
82+ <EmptyState title={status === "open" ? "Nothing waiting" : `No ${STATUS_LABEL[status].toLowerCase()} requests`}>
83+ Requests show up here when an owner uses Raise my limit, or tells g1t a month went past what they meant.
84+ </EmptyState>
85+ ) : (
86+ rows.map((row) => (
87+ <RequestCard
88+ key={row.request.id}
89+ row={row}
90+ now={now}
91+ error={actionData && "id" in actionData && actionData.id === row.request.id ? actionData : null}
92+ />
93+ ))
94+ )}
95+ </div>
96+ </main>
97+ );
98+}
99+
100+function RequestCard({
101+ row,
102+ now,
103+ error,
104+}: {
105+ row: LimitRequestReview;
106+ now: string;
107+ error: { error: string; values?: Record<string, string> } | null;
108+}) {
109+ const { request, history } = row;
110+ const { pathname, search } = useLocation();
111+ const wait = waiting(request.createdAt, new Date(now));
112+ const open = request.status === "open";
113+ const overage = request.kind === "overage";
114+ const workspaceAge = age(history.firstSeen, new Date(now));
115+ const facts: [string, string][] = [
116+ ["Plan", history.plan ?? "—"],
117+ ["Seen for", workspaceAge ?? "—"],
118+ ["Paid, cleared", `${usd(history.paidClearedMicros)} in ${history.payments} payment${history.payments === 1 ? "" : "s"}`],
119+ ["Disputes · declines", `${history.disputes} · ${history.declines}`],
120+ ["Ceiling now", usd(history.ceilingMicros)],
121+ ["Highest ceiling", usd(history.maxCeilingMicros)],
122+ ["Owners' spend limit", history.spendLimitMicros == null ? "Automatic or none" : usd(history.spendLimitMicros)],
123+ ["Last hour · usual hour", `${usd(history.lastHourMicros)} · ${usd(history.averageHourMicros)}`],
124+ ["Last day", usd(history.lastDayMicros)],
125+ ];
126+ return (
127+ <section
128+ id={request.id}
129+ className={`scroll-mt-20 rounded-lg border bg-surface ${open && wait.overdue ? "border-danger/40" : "border-line"}`}
130+ >
131+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-line px-4 py-3 sm:px-5">
132+ <div className="min-w-0">
133+ <p className="flex flex-wrap items-center gap-2">
134+ <Link to={`/workspaces/${encodeURIComponent(request.workspace)}#billing`} className="font-mono font-medium text-fg hover:underline">
135+ {request.workspace}
136+ </Link>
137+ <Badge tone={overage ? "warn" : "lavender"}>{overage ? "Spent more than meant" : "Raise my limit"}</Badge>
138+ {request.status === "approved" && <Badge tone="mint">Approved{request.decidedMicros != null ? ` at ${usd(request.decidedMicros)}` : ""}</Badge>}
139+ {request.status === "declined" && <Badge tone="danger">Declined</Badge>}
140+ </p>
141+ <p className="mt-0.5 text-xs text-faint">
142+ By <span className="font-mono">{request.createdBy}</span> · <When at={request.createdAt} time />
143+ </p>
144+ </div>
145+ {open && (
146+ <p className={`flex items-center gap-1.5 text-xs ${wait.overdue ? "text-danger" : "text-muted"}`}>
147+ <Clock size={13} />
148+ Waiting {wait.label}
149+ {wait.overdue ? " · past one business day" : ""}
150+ </p>
151+ )}
152+ </header>
153+ <div className="grid gap-5 p-4 sm:p-5 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
154+ <div className="min-w-0 space-y-4">
155+ {!overage && (
156+ <dl className="grid grid-cols-2 gap-3">
157+ <div>
158+ <dt className="text-xs text-muted">Asked for</dt>
159+ <dd className="tabular mt-0.5 text-lg font-semibold">{usd(request.amountMicros)}</dd>
160+ </div>
161+ <div>
162+ <dt className="text-xs text-muted">Expects to spend a month</dt>
163+ <dd className="tabular mt-0.5 text-lg font-semibold">{usd(request.expectedMonthlyMicros)}</dd>
164+ </div>
165+ </dl>
166+ )}
167+ <blockquote className="border-l-2 border-line-strong pl-3 text-sm break-words whitespace-pre-line text-fg-soft">{request.reason}</blockquote>
168+ {request.answer && (
169+ <p className="text-sm text-muted">
170+ Answer{request.decidedBy ? <> by <span className="font-mono">{request.decidedBy}</span></> : null}: {request.answer}
171+ </p>
172+ )}
173+ {history.months.length > 0 && (
174+ <div className="overflow-x-auto">
175+ <table className="w-full text-xs">
176+ <thead>
177+ <tr className="text-left text-faint">
178+ <th className="py-1 pr-3 font-medium">Month</th>
179+ <th className="py-1 pr-3 text-right font-medium">Charged</th>
180+ <th className="py-1 pr-3 text-right font-medium">Cost</th>
181+ <th className="py-1 text-right font-medium">Paid</th>
182+ </tr>
183+ </thead>
184+ <tbody>
185+ {history.months.map((month) => (
186+ <tr key={month.month} className="border-t border-line">
187+ <td className="py-1 pr-3 font-mono text-muted">{month.month}</td>
188+ <td className="tabular py-1 pr-3 text-right">{usd(month.chargedMicros)}</td>
189+ <td className="tabular py-1 pr-3 text-right text-muted">{usd(month.costMicros)}</td>
190+ <td className="tabular py-1 text-right">{usd(month.paidMicros)}</td>
191+ </tr>
192+ ))}
193+ </tbody>
194+ </table>
195+ </div>
196+ )}
197+ {open && (
198+ <form method="post" action={`${pathname}${search}#${request.id}`} className="space-y-3 border-t border-line pt-4">
199+ <input type="hidden" name="id" value={request.id} />
200+ {error && <Notice tone="error">{error.error}</Notice>}
201+ {overage ? (
202+ <p className="text-sm text-muted">
203+ Usually answered with a goodwill credit on{" "}
204+ <Link to={`/overages#${encodeURIComponent(request.workspace)}`} className="text-merged hover:underline">
205+ Overages
206+ </Link>
207+ , which also closes this request. Or answer it here.
208+ </p>
209+ ) : (
210+ <Field label="Approve at $" hint={`Blank: the ${usd(request.amountMicros)} asked for. Or type a different amount.`}>
211+ <Input name="amount" inputMode="decimal" placeholder={String(request.amountMicros / 1_000_000)} defaultValue={error?.values?.amount ?? ""} />
212+ </Field>
213+ )}
214+ <Field label="Note for the owner" hint="Required to decline: they read it in the app and by email.">
215+ <Textarea name="note" rows={2} maxLength={500} defaultValue={error?.values?.note ?? ""} placeholder="e.g. Approved for the launch; it grows with your payments from here." />
216+ </Field>
217+ <div className="flex flex-wrap justify-end gap-2">
218+ <Button type="submit" name="decision" value="decline" variant="danger">
219+ <X size={14} />
220+ Decline
221+ </Button>
222+ <Button type="submit" name="decision" value="approve" variant="lavender">
223+ <Check size={14} />
224+ Approve
225+ </Button>
226+ </div>
227+ </form>
228+ )}
229+ </div>
230+ <dl className="space-y-1.5 text-xs">
231+ {facts.map(([label, value]) => (
232+ <div key={label} className="grid grid-cols-[9rem_minmax(0,1fr)] gap-2">
233+ <dt className="text-faint">{label}</dt>
234+ <dd className="tabular text-fg-soft">{value}</dd>
235+ </div>
236+ ))}
237+ </dl>
238+ </div>
239+ </section>
240+ );
241+}
+136−0
1+import { ArrowLeft } from "lucide-react";
2+import { Form, Link, data, redirect } from "react-router";
3+
4+import { securityEventLabel } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/user";
7+import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, When } from "~/components/ui";
8+import { accountsAdmin } from "~/lib/services.server";
9+import { settle } from "~/lib/settle";
10+import { requireStaff } from "~/lib/staff";
11+
12+export const meta: Route.MetaFunction = ({ params }) => [
13+ { title: `${params.username} · sudo` },
14+ { name: "robots", content: "noindex, nofollow" },
15+];
16+
17+export async function loader({ params, request, context }: Route.LoaderArgs) {
18+ requireStaff(context);
19+ const result = await settle(accountsAdmin.user(params.username));
20+ if (result.ok && !result.value) throw data("No such account.", { status: 404 });
21+ return {
22+ user: result.ok ? result.value : null,
23+ error: result.ok ? null : result.error,
24+ removed: new URL(request.url).searchParams.get("removed"),
25+ };
26+}
27+
28+/** Removes an address: the reason is required, recorded and shown to the person. */
29+export async function action({ params, request, context }: Route.ActionArgs) {
30+ const staff = requireStaff(context);
31+ const form = await request.formData();
32+ const email = String(form.get("email") ?? "").trim();
33+ const reason = String(form.get("reason") ?? "").trim();
34+ if (!reason) return data({ error: "Say why. The person sees the reason in their security log.", email }, { status: 422 });
35+ const result = await accountsAdmin.removeEmail(params.username, email, reason, staff.email);
36+ if (!result.ok) return data({ error: result.error.message, email }, { status: 422 });
37+ throw redirect(`/users/${encodeURIComponent(params.username)}?removed=${encodeURIComponent(email)}`);
38+}
39+
40+export default function User({ loaderData, actionData }: Route.ComponentProps) {
41+ const { user, error, removed } = loaderData;
42+ if (!user) {
43+ return (
44+ <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
45+ <Notice tone="error">Could not load the account: {error}</Notice>
46+ </main>
47+ );
48+ }
49+ return (
50+ <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
51+ <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
52+ <ArrowLeft size={14} /> Workspaces
53+ </Link>
54+ <PageHeader
55+ title={user.username}
56+ description={
57+ <>
58+ Account <span className="font-mono">{user.id}</span>, made <When at={user.createdAt} />.{" "}
59+ {user.privateEmail ? "Keeps its address private on commits." : "Shows its primary address on commits."}
60+ </>
61+ }
62+ />
63+ {removed && (
64+ <div className="mt-5">
65+ <Notice tone="ok">Removed {removed}. The person was told, with the reason.</Notice>
66+ </div>
67+ )}
68+
69+ <Section
70+ id="emails"
71+ title="Email addresses"
72+ description="Remove an address someone else needs, or one that is compromised. Never the last confirmed one; removing the primary makes the oldest other confirmed address primary."
73+ className="mt-6"
74+ >
75+ <ul className="divide-y divide-line rounded-md border border-line">
76+ {user.emails.map((email) => (
77+ <li key={email.email} className="space-y-3 px-4 py-3">
78+ <div className="flex flex-wrap items-center gap-2 text-sm">
79+ <span className="font-mono break-all">{email.email}</span>
80+ {email.primary && <Badge tone="lavender">Primary</Badge>}
81+ {email.backup && <Badge>Backup</Badge>}
82+ {email.verified ? <Badge tone="mint">Confirmed</Badge> : <Badge tone="warn">Unconfirmed</Badge>}
83+ <span className="text-xs text-faint">
84+ added <When at={email.createdAt} />
85+ </span>
86+ </div>
87+ <Form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
88+ <input type="hidden" name="email" value={email.email} />
89+ <div className="grow">
90+ <Field label="Reason (the person sees it)">
91+ <Input
92+ name="reason"
93+ required
94+ maxLength={200}
95+ placeholder="Another account needs this address"
96+ />
97+ </Field>
98+ </div>
99+ <Button variant="danger" type="submit">
100+ Remove
101+ </Button>
102+ </Form>
103+ {actionData?.email === email.email && actionData.error && <Notice tone="error">{actionData.error}</Notice>}
104+ </li>
105+ ))}
106+ </ul>
107+ </Section>
108+
109+ <Section id="log" title="Security log" description="What happened to the account's addresses and password, newest first." className="mt-6">
110+ {user.log.length === 0 ? (
111+ <EmptyState title="Nothing yet" />
112+ ) : (
113+ <ul className="divide-y divide-line text-sm">
114+ {user.log.map((event, at) => (
115+ <li key={`${event.createdAt}:${at}`} className="flex flex-col gap-1 py-2 sm:flex-row sm:justify-between sm:gap-4">
116+ <span className="min-w-0 break-words">
117+ {securityEventLabel(event)}
118+ {event.staff && (
119+ <span className="text-muted">
120+ {" "}
121+ · by {event.staff}
122+ {event.reason ? `: ${event.reason}` : ""}
123+ </span>
124+ )}
125+ </span>
126+ <span className="shrink-0 text-xs text-faint">
127+ <When at={event.createdAt} time />
128+ </span>
129+ </li>
130+ ))}
131+ </ul>
132+ )}
133+ </Section>
134+ </main>
135+ );
136+}
+94−0
1+import { Link } from "react-router";
2+
3+import type { Route } from "./+types/velocity";
4+import { Badge, EmptyState, Notice, PageHeader, Stat } from "~/components/ui";
5+import { usd } from "~/lib/money";
6+import { age, isFast, ratioLabel, spikeLabel } from "~/lib/pricing";
7+import { admin } from "~/lib/services.server";
8+import { settle } from "~/lib/settle";
9+import { requireStaff } from "~/lib/staff";
10+
11+export const meta: Route.MetaFunction = () => [{ title: "Velocity · sudo" }, { name: "robots", content: "noindex, nofollow" }];
12+
13+export async function loader({ context }: Route.LoaderArgs) {
14+ requireStaff(context);
15+ const rows = await settle(admin.velocity());
16+ return { rows: rows.ok ? rows.value : [], error: rows.ok ? null : rows.error, now: new Date().toISOString() };
17+}
18+
19+export default function Velocity({ loaderData }: Route.ComponentProps) {
20+ const { rows, error, now } = loaderData;
21+ const fast = rows.filter((row) => isFast(row.ratio) && row.averageHourMicros > 0).length;
22+ const waiting = rows.filter((row) => row.spike?.status === "open").length;
23+ const lastHour = rows.reduce((sum, row) => sum + row.lastHourMicros, 0);
24+ return (
25+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
26+ <PageHeader
27+ title="Velocity"
28+ description="Workspaces spending in the last day, fastest first. An hour at five times the usual hour, and at least $5, is a spike: new compute pauses until an owner chooses Keep going or Stop."
29+ />
30+ {error && (
31+ <div className="mt-5">
32+ <Notice tone="warn">Billing did not answer for velocity: {error}</Notice>
33+ </div>
34+ )}
35+ <div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4">
36+ <Stat label="Spending today" value={String(rows.length)} hint="Workspaces with usage in the last day" />
37+ <Stat label="Last hour, everyone" value={usd(lastHour)} />
38+ <Stat label="Five times their usual" value={String(fast)} tone={fast > 0 ? "warn" : undefined} />
39+ <Stat label="Paused, waiting on an owner" value={String(waiting)} tone={waiting > 0 ? "danger" : undefined} />
40+ </div>
41+ <div className="mt-6">
42+ {rows.length === 0 && !error ? (
43+ <EmptyState title="Nobody is spending right now" />
44+ ) : (
45+ <div className="overflow-x-auto rounded-lg border border-line bg-surface">
46+ <table className="w-full min-w-[46rem] text-sm">
47+ <thead>
48+ <tr className="border-b border-line text-left text-xs text-muted">
49+ <th className="px-4 py-2 font-medium">Workspace</th>
50+ <th className="px-4 py-2 text-right font-medium">Last hour</th>
51+ <th className="px-4 py-2 text-right font-medium">Usual hour</th>
52+ <th className="px-4 py-2 text-right font-medium">Pace</th>
53+ <th className="px-4 py-2 text-right font-medium">Last day</th>
54+ <th className="px-4 py-2 text-right font-medium">This month</th>
55+ <th className="px-4 py-2 font-medium">Spike</th>
56+ </tr>
57+ </thead>
58+ <tbody>
59+ {rows.map((row) => {
60+ const hot = isFast(row.ratio) && row.averageHourMicros > 0;
61+ const spike = row.spike ? spikeLabel(row.spike.status) : null;
62+ const seen = age(row.firstSeen, new Date(now));
63+ return (
64+ <tr key={row.workspace} className="border-b border-line last:border-0">
65+ <td className="px-4 py-2.5">
66+ <Link to={`/workspaces/${encodeURIComponent(row.workspace)}#billing`} className="font-mono text-fg hover:underline">
67+ {row.workspace}
68+ </Link>
69+ <span className="block text-xs text-faint">
70+ {row.plan}
71+ {seen ? ` · seen for ${seen}` : ""}
72+ </span>
73+ </td>
74+ <td className="tabular px-4 py-2.5 text-right">{usd(row.lastHourMicros)}</td>
75+ <td className="tabular px-4 py-2.5 text-right text-muted">{usd(row.averageHourMicros)}</td>
76+ <td className={`tabular px-4 py-2.5 text-right font-medium ${hot ? "text-warn" : "text-fg-soft"}`}>
77+ {ratioLabel(row.ratio, row.averageHourMicros)}
78+ </td>
79+ <td className="tabular px-4 py-2.5 text-right">{usd(row.lastDayMicros)}</td>
80+ <td className="tabular px-4 py-2.5 text-right">{usd(row.thisMonthMicros)}</td>
81+ <td className="px-4 py-2.5">
82+ {spike ? <Badge tone={spike.tone === "plain" ? "plain" : spike.tone}>{spike.label}</Badge> : <span className="text-faint">—</span>}
83+ </td>
84+ </tr>
85+ );
86+ })}
87+ </tbody>
88+ </table>
89+ </div>
90+ )}
91+ </div>
92+ </main>
93+ );
94+}
+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.

+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.

+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.