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.
| 1 | 1 | # g1t brand files | |
| 2 | 2 | ||
| 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. | |
| 8 | 24 | ||
| 9 | 25 | These are the files to upload to Stripe and similar services. The site serves | |
| 10 | 26 | its own copies from `apps/web/public` and `apps/docs/public`. | |
| 13 | 29 | | --- | --- | --- | | |
| 14 | 30 | | `icon-512.png` | 512×512, dark square | Stripe branding icon (square, at least 128px), Slack, GitHub app avatars | | |
| 15 | 31 | | `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 | | |
| 17 | 33 | | `logo-on-dark.png` | 1056×642, dark background | Stripe branding logo (wide), docs and decks on dark | | |
| 18 | 34 | | `logo-on-light.png` | 1056×642, white background | Invoices, receipts and anywhere the background is light | | |
| 19 | 35 | | `og-1200x630.png` | 1200×630 | Social and Open Graph card | | |
| 24 | 40 | ||
| 25 | 41 | | File | What it is | | |
| 26 | 42 | | --- | --- | | |
| 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 | | |
| 37 | 53 | ||
| 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. | |
| 40 | 56 | ||
| 41 | 57 | ## On the web | |
| 42 | 58 | ||
| 43 | 59 | `apps/web/public/brand/` is served at `https://g1t.sh/brand/` for emails and | |
| 44 | 60 | anything that links to the mark: | |
| 45 | 61 | ||
| 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 | |
| 49 | 65 | - `g1t-og.png`: the brand social card as `services/og` draws it, which that service falls back to if drawing fails | |
| 50 | 66 | ||
| 51 | 67 | ## Social cards | |
| 52 | 68 | ||
| 53 | 69 | 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. |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
| 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 | − | <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 | − | <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 | − | <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 | − | <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 | − | <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 | − | <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 | − | <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 | − | <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 | − | <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 | − | <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 | − | <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> |
| 1 | 1 | # Contributing to g1t | |
| 2 | 2 | ||
| 3 | 3 | 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 | |
| 5 | 5 | done, and a merge queue that keeps `main` passing. | |
| 6 | 6 | ||
| 7 | 7 | ## A change ships with its docs | |
| 29 | 29 | cd apps/docs && npm run build | |
| 30 | 30 | ``` | |
| 31 | 31 | ||
| 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 | + | ||
| 32 | 48 | ## Before you push | |
| 33 | 49 | ||
| 34 | 50 | - `cargo test` in the crate or service you changed. |
| 947 | 947 | "base64 0.22.1", | |
| 948 | 948 | "g1t-contracts", | |
| 949 | 949 | "g1t-kit", | |
| 950 | + | "g1t-secrets", | |
| 950 | 951 | "getrandom 0.2.17", | |
| 951 | 952 | "hex", | |
| 952 | 953 | "pbkdf2 0.12.2", |
| 5 | 5 | [workspace.package] | |
| 6 | 6 | edition = "2024" | |
| 7 | 7 | license = "MIT" | |
| 8 | − | repository = "https://g1t.sh/syntaqx/g1t" | |
| 8 | + | repository = "https://g1t.sh/flagon-io/g1t" | |
| 9 | 9 | ||
| 10 | 10 | [workspace.dependencies] | |
| 11 | 11 | g1t-actions = { path = "crates/actions" } |
| 1 | 1 | # g1t | |
| 2 | 2 | ||
| 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. | |
| 18 | 6 | ||
| 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. | |
| 21 | 27 | ||
| 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. | |
| 24 | 35 | ||
| 25 | 36 | ## Where things are | |
| 26 | 37 |
| 157 | 157 | .map(|(name, value)| (name.to_lowercase(), value)) | |
| 158 | 158 | .collect(); | |
| 159 | 159 | 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 { | |
| 161 | 163 | return Ok(reply(&json!({ "message": "The body is too large." }))?.with_status(413)); | |
| 162 | 164 | } | |
| 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?; | |
| 169 | 174 | Ok(reply(&json!({ "message": received.message }))?.with_status(received.status)) | |
| 170 | 175 | } | |
| 171 | 176 |
| 14 | 14 | const SECTIONS: &[(&str, &str, &[Op])] = &[ | |
| 15 | 15 | ( | |
| 16 | 16 | "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], | |
| 19 | 19 | ), | |
| 20 | 20 | ( | |
| 21 | 21 | "Workspaces", | |
| 22 | 22 | "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 | + | ], | |
| 24 | 36 | ), | |
| 25 | 37 | ( | |
| 26 | 38 | "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.", | |
| 28 | 40 | &[ | |
| 29 | 41 | Op::ListRepos, | |
| 30 | 42 | Op::CreateRepo, | |
| 31 | 43 | Op::GetRepo, | |
| 32 | 44 | 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, | |
| 33 | 55 | Op::GetRepoSettings, | |
| 34 | 56 | Op::UpdateRepoSettings, | |
| 35 | 57 | Op::ListEvents, | |
| 36 | 58 | ], | |
| 37 | 59 | ), | |
| 38 | 60 | ( | |
| 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 | + | ( | |
| 39 | 79 | "Issues", | |
| 40 | 80 | "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.", | |
| 41 | 81 | &[ | |
| 161 | 201 | match op { | |
| 162 | 202 | Op::Whoami => "Get the current user", | |
| 163 | 203 | 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", | |
| 164 | 225 | Op::ListRepos => "List repositories", | |
| 165 | 226 | Op::GetRepo => "Get a repository", | |
| 166 | 227 | Op::CreateRepo => "Create a repository", | |
| 228 | 289 | Op::ListActionsVariables => "List variables", | |
| 229 | 290 | Op::SetActionsVariable => "Set a variable", | |
| 230 | 291 | 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", | |
| 231 | 304 | } | |
| 232 | 305 | } | |
| 233 | 306 | ||
| 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 | + | } | |
| 238 | 319 | } | |
| 239 | 320 | ||
| 240 | 321 | /// What the reference says beyond each operation's own description, keyed | |
| 382 | 463 | "401".into(), | |
| 383 | 464 | error_response("A token is required, or the one sent is not valid."), | |
| 384 | 465 | ); | |
| 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)); | |
| 390 | 468 | } | |
| 391 | 469 | responses.insert("403".into(), error_response("Signed in, but not allowed to do this.")); | |
| 392 | 470 | if !matches!(op, Op::Whoami | Op::ListRepos | Op::Search) { |
| 4 | 4 | //! [`Op`], so the surfaces cannot drift apart: adding a variant without | |
| 5 | 5 | //! describing it or running it does not compile. | |
| 6 | 6 | ||
| 7 | + | use g1t_contracts::access::{ | |
| 8 | + | AddCollaboratorArgs, BasePermission, Capability, CollaboratorPermissionArgs, MyRepoInvitationsArgs, | |
| 9 | + | OutsideCollaboratorsArgs, RemoveCollaboratorArgs, RepoAccess, RepoAccessArgs, RepoInvitation, RepoRole, | |
| 10 | + | RespondRepoInvitationArgs, RevokeRepoInvitationArgs, SetBasePermissionArgs, SetCollaboratorRoleArgs, | |
| 11 | + | }; | |
| 7 | 12 | use g1t_contracts::identity::AgentScope; | |
| 8 | 13 | use g1t_contracts::events::{Event, ListArgs as ListEventsArgs}; | |
| 9 | 14 | use g1t_contracts::identity::CreateWorkspaceArgs; | |
| 60 | 65 | pub enum Op { | |
| 61 | 66 | Whoami, | |
| 62 | 67 | CreateWorkspace, | |
| 68 | + | DeleteWorkspace, | |
| 69 | + | ListEmails, | |
| 70 | + | AddEmail, | |
| 71 | + | RemoveEmail, | |
| 72 | + | UpdateEmailSettings, | |
| 73 | + | ListInvites, | |
| 74 | + | CreateInvite, | |
| 75 | + | RevokeInvite, | |
| 76 | + | ListWorkspaceInvites, | |
| 77 | + | InviteMember, | |
| 78 | + | RevokeWorkspaceInvite, | |
| 63 | 79 | ListRepos, | |
| 64 | 80 | GetRepo, | |
| 65 | 81 | CreateRepo, | |
| 66 | 82 | UpdateRepo, | |
| 83 | + | TransferRepo, | |
| 84 | + | RenameRepo, | |
| 85 | + | RenameBranch, | |
| 86 | + | ArchiveRepo, | |
| 87 | + | UnarchiveRepo, | |
| 88 | + | SetRepoVisibility, | |
| 89 | + | DeleteRepo, | |
| 90 | + | ListDeletedRepos, | |
| 91 | + | RestoreRepo, | |
| 92 | + | PurgeRepo, | |
| 67 | 93 | GetRepoSettings, | |
| 68 | 94 | UpdateRepoSettings, | |
| 69 | 95 | GetMergeQueue, | |
| 127 | 153 | ListActionsVariables, | |
| 128 | 154 | SetActionsVariable, | |
| 129 | 155 | 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, | |
| 130 | 168 | } | |
| 131 | 169 | ||
| 132 | 170 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 230 | 268 | } | |
| 231 | 269 | ||
| 232 | 270 | 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\"." }) | |
| 234 | 272 | } | |
| 235 | 273 | ||
| 236 | 274 | /// An object's keys in `camelCase`, the way the services read them, from | |
| 294 | 332 | fn repo_schema() -> Value { | |
| 295 | 333 | json!({ | |
| 296 | 334 | "type": "string", | |
| 297 | − | "description": "Repository as \"owner/name\", e.g. \"syntaqx/hello\".", | |
| 335 | + | "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\".", | |
| 298 | 336 | }) | |
| 299 | 337 | } | |
| 300 | 338 | ||
| 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 | + | ||
| 301 | 352 | impl Op { | |
| 302 | − | pub const ALL: [Op; 69] = [ | |
| 353 | + | pub const ALL: [Op; 102] = [ | |
| 303 | 354 | Op::Whoami, | |
| 304 | 355 | 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, | |
| 305 | 367 | Op::ListRepos, | |
| 306 | 368 | Op::GetRepo, | |
| 307 | 369 | Op::CreateRepo, | |
| 308 | 370 | 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, | |
| 309 | 381 | Op::GetRepoSettings, | |
| 310 | 382 | Op::UpdateRepoSettings, | |
| 311 | 383 | Op::GetMergeQueue, | |
| 369 | 441 | Op::ListActionsVariables, | |
| 370 | 442 | Op::SetActionsVariable, | |
| 371 | 443 | 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, | |
| 372 | 456 | ]; | |
| 373 | 457 | ||
| 374 | 458 | pub fn by_name(name: &str) -> Option<Op> { | |
| 380 | 464 | match self { | |
| 381 | 465 | Op::Whoami => "whoami", | |
| 382 | 466 | 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", | |
| 383 | 478 | Op::ListRepos => "list_repos", | |
| 384 | 479 | Op::GetRepo => "get_repo", | |
| 385 | 480 | Op::CreateRepo => "create_repo", | |
| 386 | 481 | 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", | |
| 387 | 492 | Op::GetRepoSettings => "get_repo_settings", | |
| 388 | 493 | Op::GetMergeQueue => "get_merge_queue", | |
| 389 | 494 | Op::MessageAgent => "message_agent", | |
| 447 | 552 | Op::ListActionsVariables => "list_actions_variables", | |
| 448 | 553 | Op::SetActionsVariable => "set_actions_variable", | |
| 449 | 554 | 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", | |
| 450 | 567 | } | |
| 451 | 568 | } | |
| 452 | 569 | ||
| 458 | 575 | Op::CreateWorkspace => { | |
| 459 | 576 | "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." | |
| 460 | 577 | } | |
| 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 | + | } | |
| 461 | 609 | Op::ListRepos => "Repositories you can see, optionally filtered by a search query.", | |
| 462 | 610 | Op::GetRepo => "One repository's details.", | |
| 463 | 611 | 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." | |
| 465 | 637 | } | |
| 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 | + | } | |
| 466 | 644 | Op::GetRepoSettings => { | |
| 467 | 645 | "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." | |
| 468 | 646 | } | |
| 469 | 647 | 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." | |
| 471 | 649 | } | |
| 472 | 650 | 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." | |
| 474 | 652 | } | |
| 475 | 653 | Op::AnswerMessage => { | |
| 476 | 654 | "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." | |
| 507 | 685 | } | |
| 508 | 686 | Op::CreateIssue => "Open an issue on a repository.", | |
| 509 | 687 | 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." | |
| 511 | 689 | } | |
| 512 | 690 | 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." | |
| 514 | 692 | } | |
| 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.", | |
| 516 | 694 | 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." | |
| 518 | 696 | } | |
| 519 | 697 | Op::GetPlan => { | |
| 520 | 698 | "A plan: the outcome asked for, its status (planning, ready, failed or applied), and the issues it proposes with their dependencies." | |
| 521 | 699 | } | |
| 522 | 700 | 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." | |
| 524 | 702 | } | |
| 525 | 703 | 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." | |
| 527 | 705 | } | |
| 528 | 706 | Op::ListLabels => "The labels available on a repository's issues.", | |
| 529 | 707 | Op::AddComment => { | |
| 548 | 726 | Op::MarkPullRequestReady => { | |
| 549 | 727 | "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why." | |
| 550 | 728 | } | |
| 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.", | |
| 552 | 730 | Op::GetPullRequestChanges => { | |
| 553 | 731 | "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." | |
| 554 | 732 | } | |
| 555 | 733 | 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." | |
| 557 | 735 | } | |
| 558 | 736 | Op::ListEvents => { | |
| 559 | 737 | "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first." | |
| 580 | 758 | "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." | |
| 581 | 759 | } | |
| 582 | 760 | 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." | |
| 584 | 762 | } | |
| 585 | 763 | 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." | |
| 587 | 765 | } | |
| 588 | 766 | Op::UpdateWebhook => { | |
| 589 | 767 | "Change a webhook's address, its events, or whether it is active. Only the fields given change." | |
| 607 | 785 | "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." | |
| 608 | 786 | } | |
| 609 | 787 | 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." | |
| 611 | 789 | } | |
| 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.", | |
| 613 | 791 | 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." | |
| 615 | 793 | } | |
| 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.", | |
| 617 | 795 | 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." | |
| 619 | 797 | } | |
| 620 | 798 | 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." | |
| 622 | 800 | } | |
| 623 | 801 | Op::DeleteActionsSecret => "Remove a secret: one row by `id`, or every row of the key.", | |
| 624 | 802 | 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." | |
| 626 | 804 | } | |
| 627 | 805 | Op::SetActionsVariable => "Add or change a variable's row, as for secrets.", | |
| 628 | 806 | Op::DeleteActionsVariable => "Remove a variable: one row by `id`, or every row of the key.", | |
| 629 | 807 | Op::ImportIssue => { | |
| 630 | 808 | "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." | |
| 631 | 809 | } | |
| 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 | + | } | |
| 632 | 846 | } | |
| 633 | 847 | } | |
| 634 | 848 | ||
| 655 | 869 | }), | |
| 656 | 870 | &[], | |
| 657 | 871 | ), | |
| 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 | + | ), | |
| 658 | 959 | Op::GetRepo | Op::ListLabels => repo_only(), | |
| 659 | 960 | Op::UpdateRepo => object( | |
| 660 | 961 | json!({ | |
| 670 | 971 | "items": { "type": "string" }, | |
| 671 | 972 | "description": "Replaces its topics, which search and Explore show: lowercase letters, digits and hyphens, at most 20. An empty list clears them.", | |
| 672 | 973 | }, | |
| 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 | + | }, | |
| 673 | 982 | }), | |
| 674 | 983 | &["repo"], | |
| 675 | 984 | ), | |
| 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"]), | |
| 676 | 1032 | Op::GetRepoSettings => object(json!({ "repo": repo_schema() }), &["repo"]), | |
| 677 | 1033 | Op::GetMergeQueue => object(json!({ "repo": repo_schema() }), &["repo"]), | |
| 678 | 1034 | Op::MessageAgent => object( | |
| 1203 | 1559 | }), | |
| 1204 | 1560 | &["repo", "reference"], | |
| 1205 | 1561 | ), | |
| 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"]), | |
| 1206 | 1612 | } | |
| 1207 | 1613 | } | |
| 1208 | 1614 | ||
| 1237 | 1643 | self, | |
| 1238 | 1644 | Op::Whoami | |
| 1239 | 1645 | | 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 | |
| 1240 | 1658 | | Op::SearchContext | |
| 1241 | 1659 | | Op::GetEntity | |
| 1242 | 1660 | | Op::Search | |
| 1261 | 1679 | | Op::ListActionsVariables | |
| 1262 | 1680 | | Op::SetActionsVariable | |
| 1263 | 1681 | | Op::DeleteActionsVariable | |
| 1682 | + | | Op::ListMyRepoInvitations | |
| 1683 | + | | Op::AcceptRepoInvitation | |
| 1684 | + | | Op::DeclineRepoInvitation | |
| 1685 | + | | Op::SetBasePermission | |
| 1686 | + | | Op::ListOutsideCollaborators | |
| 1264 | 1687 | ) | |
| 1265 | 1688 | } | |
| 1266 | 1689 | ||
| 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 | + | ||
| 1267 | 1705 | /// Runs the operation. One that found nothing, or was refused, under a | |
| 1268 | 1706 | /// 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. | |
| 1270 | 1710 | pub async fn run( | |
| 1271 | 1711 | self, | |
| 1272 | 1712 | services: &Services, | |
| 1280 | 1720 | { | |
| 1281 | 1721 | return self.run_once(services, viewer, &retargeted).await; | |
| 1282 | 1722 | } | |
| 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 | + | } | |
| 1283 | 1734 | Ok(outcome) | |
| 1284 | 1735 | } | |
| 1285 | 1736 | ||
| 1376 | 1827 | ) | |
| 1377 | 1828 | .await | |
| 1378 | 1829 | } | |
| 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 | + | } | |
| 1379 | 1932 | Op::ListRepos => { | |
| 1380 | 1933 | let found: Vec<Repo> = g1t_kit::call( | |
| 1381 | 1934 | repos, | |
| 1402 | 1955 | .await | |
| 1403 | 1956 | } | |
| 1404 | 1957 | Op::UpdateRepo => { | |
| 1405 | − | pass( | |
| 1958 | + | let updated = pass( | |
| 1406 | 1959 | repos, | |
| 1407 | 1960 | "update", | |
| 1408 | 1961 | &json!({ | |
| 1412 | 1965 | "isPrivate": input["private"].as_bool(), | |
| 1413 | 1966 | "protected": input["protected"].as_bool(), | |
| 1414 | 1967 | "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, | |
| 1415 | 2060 | }), | |
| 1416 | 2061 | ) | |
| 1417 | 2062 | .await | |
| 1418 | 2063 | } | |
| 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 | + | } | |
| 1419 | 2086 | Op::GetRepoSettings => { | |
| 1420 | 2087 | pass( | |
| 1421 | 2088 | work, | |
| 1623 | 2290 | description: optional_text(input, "description"), | |
| 1624 | 2291 | is_private: input["private"].as_bool() == Some(true), | |
| 1625 | 2292 | import_url: optional_text(input, "import_url"), | |
| 2293 | + | import_token: None, | |
| 1626 | 2294 | }, | |
| 1627 | 2295 | ) | |
| 1628 | 2296 | .await | |
| 2103 | 2771 | .await?; | |
| 2104 | 2772 | ok(&timeline) | |
| 2105 | 2773 | } | |
| 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 | + | } | |
| 2106 | 2912 | } | |
| 2107 | 2913 | } | |
| 2108 | 2914 | } | |
| 2109 | 2915 | ||
| 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 | + | ||
| 2110 | 2923 | impl Op { | |
| 2111 | 2924 | /// The properties of the operation's input schema. | |
| 2112 | 2925 | pub fn properties(self) -> Map<String, Value> { | |
| 2157 | 2970 | ||
| 2158 | 2971 | #[test] | |
| 2159 | 2972 | 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(); | |
| 2161 | 2974 | assert_eq!( | |
| 2162 | 2975 | (path.namespace.as_str(), path.name.as_str()), | |
| 2163 | − | ("syntaqx", "hello") | |
| 2976 | + | ("flagon-io", "hello") | |
| 2164 | 2977 | ); | |
| 2165 | − | for bad in ["syntaqx", "a/b/c", "/hello", "syntaqx/", ""] { | |
| 2978 | + | for bad in ["flagon-io", "a/b/c", "/hello", "flagon-io/", ""] { | |
| 2166 | 2979 | assert!(repo_path(&json!({ "repo": bad })).is_none(), "{bad}"); | |
| 2167 | 2980 | } | |
| 2168 | 2981 | } | |
| 2174 | 2987 | assert_eq!(integer(&json!({ "number": "x" }), "number"), None); | |
| 2175 | 2988 | assert_eq!(integer(&json!({}), "number"), None); | |
| 2176 | 2989 | } | |
| 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 | + | } | |
| 2177 | 3047 | } |
| 32 | 32 | "workspaces": [ | |
| 33 | 33 | { | |
| 34 | 34 | "slug": "acme", | |
| 35 | − | "role": "member" | |
| 35 | + | "role": "member", | |
| 36 | + | "base_permission": "write" | |
| 36 | 37 | }, | |
| 37 | 38 | { | |
| 38 | − | "slug": "syntaqx", | |
| 39 | − | "role": "owner" | |
| 39 | + | "slug": "flagon-io", | |
| 40 | + | "role": "owner", | |
| 41 | + | "base_permission": "write" | |
| 40 | 42 | } | |
| 41 | 43 | ] | |
| 42 | 44 | }, | |
| 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/)." | |
| 44 | 46 | }, | |
| 45 | 47 | "create_workspace": { | |
| 46 | 48 | "request": { | |
| 47 | − | "slug": "syntaqx-labs", | |
| 48 | − | "name": "Syntaqx Labs" | |
| 49 | + | "slug": "acme-labs", | |
| 50 | + | "name": "Acme Labs" | |
| 49 | 51 | }, | |
| 50 | 52 | "response": { | |
| 51 | 53 | "id": "wsp_01m43teqa9em6bje0bhvdj4jkb", | |
| 52 | − | "slug": "syntaqx-labs", | |
| 53 | − | "name": "Syntaqx Labs", | |
| 54 | + | "slug": "acme-labs", | |
| 55 | + | "name": "Acme Labs", | |
| 54 | 56 | "description": null, | |
| 55 | 57 | "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" | |
| 57 | 318 | } | |
| 58 | 319 | }, | |
| 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 | + | }, | |
| 59 | 396 | "list_repos": { | |
| 60 | 397 | "query": { | |
| 61 | 398 | "q": "hello" | |
| 63 | 400 | "response": [ | |
| 64 | 401 | { | |
| 65 | 402 | "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 66 | − | "namespace": "syntaqx", | |
| 403 | + | "namespace": "flagon-io", | |
| 67 | 404 | "name": "hello", | |
| 68 | 405 | "description": "A tiny service that says hello.", | |
| 69 | 406 | "is_private": false, | |
| 71 | 408 | "default_branch": "main", | |
| 72 | 409 | "fork_of": null, | |
| 73 | 410 | "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 | |
| 75 | 414 | } | |
| 76 | 415 | ], | |
| 77 | 416 | "notes": "Returns at most 50 repositories, newest first. Forks made for pull requests are left out." | |
| 78 | 417 | }, | |
| 79 | 418 | "create_repo": { | |
| 80 | 419 | "request": { | |
| 81 | − | "workspace": "syntaqx", | |
| 420 | + | "workspace": "flagon-io", | |
| 82 | 421 | "name": "hello-cli", | |
| 83 | 422 | "description": "Command-line client for hello.", | |
| 84 | 423 | "private": false | |
| 85 | 424 | }, | |
| 86 | 425 | "response": { | |
| 87 | 426 | "id": "rep_01m43tk1jhehztx23drs87f5fc", | |
| 88 | − | "namespace": "syntaqx", | |
| 427 | + | "namespace": "flagon-io", | |
| 89 | 428 | "name": "hello-cli", | |
| 90 | 429 | "description": "Command-line client for hello.", | |
| 91 | 430 | "is_private": false, | |
| 93 | 432 | "default_branch": "main", | |
| 94 | 433 | "fork_of": null, | |
| 95 | 434 | "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 | |
| 97 | 438 | }, | |
| 98 | 439 | "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." | |
| 99 | 440 | }, | |
| 100 | 441 | "get_repo": { | |
| 101 | 442 | "response": { | |
| 102 | 443 | "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 103 | − | "namespace": "syntaqx", | |
| 444 | + | "namespace": "flagon-io", | |
| 104 | 445 | "name": "hello", | |
| 105 | 446 | "description": "A tiny service that says hello.", | |
| 106 | 447 | "is_private": false, | |
| 108 | 449 | "default_branch": "main", | |
| 109 | 450 | "fork_of": null, | |
| 110 | 451 | "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 | |
| 112 | 455 | } | |
| 113 | 456 | }, | |
| 114 | 457 | "update_repo": { | |
| 115 | 458 | "request": { | |
| 116 | 459 | "description": "Says hello, politely.", | |
| 460 | + | "website": "hello.g1t.page", | |
| 461 | + | "default_branch": "trunk", | |
| 117 | 462 | "protected": true | |
| 118 | 463 | }, | |
| 119 | 464 | "response": { | |
| 120 | 465 | "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 121 | − | "namespace": "syntaqx", | |
| 466 | + | "namespace": "flagon-io", | |
| 122 | 467 | "name": "hello", | |
| 123 | 468 | "description": "Says hello, politely.", | |
| 124 | 469 | "is_private": false, | |
| 125 | 470 | "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", | |
| 126 | 495 | "default_branch": "main", | |
| 127 | 496 | "fork_of": null, | |
| 128 | 497 | "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/)." | |
| 131 | 503 | }, | |
| 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 | + | }, | |
| 132 | 686 | "get_repo_settings": { | |
| 133 | 687 | "response": { | |
| 134 | 688 | "auto_merge": false, | |
| 593 | 1147 | "verified": true, | |
| 594 | 1148 | "workspaces": [ | |
| 595 | 1149 | { | |
| 596 | − | "slug": "syntaqx", | |
| 1150 | + | "slug": "flagon-io", | |
| 597 | 1151 | "role": "owner" | |
| 598 | 1152 | } | |
| 599 | 1153 | ] | |
| 1089 | 1643 | "verified": true, | |
| 1090 | 1644 | "workspaces": [ | |
| 1091 | 1645 | { | |
| 1092 | − | "slug": "syntaqx", | |
| 1646 | + | "slug": "flagon-io", | |
| 1093 | 1647 | "role": "member" | |
| 1094 | 1648 | } | |
| 1095 | 1649 | ] | |
| 1786 | 2340 | }, | |
| 1787 | 2341 | "list_actions_secrets_for_workspace": { | |
| 1788 | 2342 | "params": { | |
| 1789 | − | "workspace": "syntaqx" | |
| 2343 | + | "workspace": "flagon-io" | |
| 1790 | 2344 | }, | |
| 1791 | 2345 | "response": [ | |
| 1792 | 2346 | { | |
| 1843 | 2397 | }, | |
| 1844 | 2398 | "set_actions_secret_for_workspace": { | |
| 1845 | 2399 | "params": { | |
| 1846 | − | "workspace": "syntaqx", | |
| 2400 | + | "workspace": "flagon-io", | |
| 1847 | 2401 | "setting": "NPM_TOKEN" | |
| 1848 | 2402 | }, | |
| 1849 | 2403 | "request": { | |
| 1878 | 2432 | }, | |
| 1879 | 2433 | "delete_actions_secret_for_workspace": { | |
| 1880 | 2434 | "params": { | |
| 1881 | − | "workspace": "syntaqx", | |
| 2435 | + | "workspace": "flagon-io", | |
| 1882 | 2436 | "setting": "NPM_TOKEN" | |
| 1883 | 2437 | }, | |
| 1884 | 2438 | "response": true | |
| 1906 | 2460 | }, | |
| 1907 | 2461 | "list_actions_variables_for_workspace": { | |
| 1908 | 2462 | "params": { | |
| 1909 | − | "workspace": "syntaqx" | |
| 2463 | + | "workspace": "flagon-io" | |
| 1910 | 2464 | }, | |
| 1911 | 2465 | "response": [ | |
| 1912 | 2466 | { | |
| 1952 | 2506 | }, | |
| 1953 | 2507 | "set_actions_variable_for_workspace": { | |
| 1954 | 2508 | "params": { | |
| 1955 | − | "workspace": "syntaqx" | |
| 2509 | + | "workspace": "flagon-io" | |
| 1956 | 2510 | }, | |
| 1957 | 2511 | "request": { | |
| 1958 | 2512 | "setting": "NODE_VERSION", | |
| 2001 | 2555 | }, | |
| 2002 | 2556 | "update_actions_variable_for_workspace": { | |
| 2003 | 2557 | "params": { | |
| 2004 | − | "workspace": "syntaqx", | |
| 2558 | + | "workspace": "flagon-io", | |
| 2005 | 2559 | "setting": "NODE_VERSION" | |
| 2006 | 2560 | }, | |
| 2007 | 2561 | "request": { | |
| 2032 | 2586 | }, | |
| 2033 | 2587 | "delete_actions_variable_for_workspace": { | |
| 2034 | 2588 | "params": { | |
| 2035 | − | "workspace": "syntaqx", | |
| 2589 | + | "workspace": "flagon-io", | |
| 2036 | 2590 | "setting": "NODE_VERSION" | |
| 2037 | 2591 | }, | |
| 2038 | 2592 | "response": true | |
| 2042 | 2596 | { | |
| 2043 | 2597 | "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd", | |
| 2044 | 2598 | "scope": "repo", | |
| 2045 | − | "workspace": "syntaqx", | |
| 2046 | − | "repo": "syntaqx/hello", | |
| 2599 | + | "workspace": "flagon-io", | |
| 2600 | + | "repo": "flagon-io/hello", | |
| 2047 | 2601 | "url": "https://hooks.example.com/g1t", | |
| 2048 | 2602 | "events": [ | |
| 2049 | 2603 | "git.push", | |
| 2060 | 2614 | }, | |
| 2061 | 2615 | "list_webhooks_for_workspace": { | |
| 2062 | 2616 | "params": { | |
| 2063 | − | "workspace": "syntaqx" | |
| 2617 | + | "workspace": "flagon-io" | |
| 2064 | 2618 | }, | |
| 2065 | 2619 | "response": [ | |
| 2066 | 2620 | { | |
| 2067 | 2621 | "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd", | |
| 2068 | 2622 | "scope": "workspace", | |
| 2069 | − | "workspace": "syntaqx", | |
| 2623 | + | "workspace": "flagon-io", | |
| 2070 | 2624 | "repo": null, | |
| 2071 | 2625 | "url": "https://hooks.example.com/g1t", | |
| 2072 | 2626 | "events": [ | |
| 2094 | 2648 | "hook": { | |
| 2095 | 2649 | "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd", | |
| 2096 | 2650 | "scope": "repo", | |
| 2097 | − | "workspace": "syntaqx", | |
| 2098 | − | "repo": "syntaqx/hello", | |
| 2651 | + | "workspace": "flagon-io", | |
| 2652 | + | "repo": "flagon-io/hello", | |
| 2099 | 2653 | "url": "https://hooks.example.com/g1t", | |
| 2100 | 2654 | "events": [ | |
| 2101 | 2655 | "git.push", | |
| 2114 | 2668 | }, | |
| 2115 | 2669 | "create_webhook_for_workspace": { | |
| 2116 | 2670 | "params": { | |
| 2117 | − | "workspace": "syntaqx" | |
| 2671 | + | "workspace": "flagon-io" | |
| 2118 | 2672 | }, | |
| 2119 | 2673 | "request": { | |
| 2120 | 2674 | "url": "https://hooks.example.com/g1t" | |
| 2123 | 2677 | "hook": { | |
| 2124 | 2678 | "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd", | |
| 2125 | 2679 | "scope": "workspace", | |
| 2126 | − | "workspace": "syntaqx", | |
| 2680 | + | "workspace": "flagon-io", | |
| 2127 | 2681 | "repo": null, | |
| 2128 | 2682 | "url": "https://hooks.example.com/g1t", | |
| 2129 | 2683 | "events": [ | |
| 2150 | 2704 | "response": { | |
| 2151 | 2705 | "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd", | |
| 2152 | 2706 | "scope": "repo", | |
| 2153 | − | "workspace": "syntaqx", | |
| 2154 | − | "repo": "syntaqx/hello", | |
| 2707 | + | "workspace": "flagon-io", | |
| 2708 | + | "repo": "flagon-io/hello", | |
| 2155 | 2709 | "url": "https://hooks.example.com/g1t", | |
| 2156 | 2710 | "events": [ | |
| 2157 | 2711 | "git.push", | |
| 2167 | 2721 | }, | |
| 2168 | 2722 | "update_webhook_for_workspace": { | |
| 2169 | 2723 | "params": { | |
| 2170 | − | "workspace": "syntaqx", | |
| 2724 | + | "workspace": "flagon-io", | |
| 2171 | 2725 | "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd" | |
| 2172 | 2726 | }, | |
| 2173 | 2727 | "request": { | |
| 2176 | 2730 | "response": { | |
| 2177 | 2731 | "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd", | |
| 2178 | 2732 | "scope": "workspace", | |
| 2179 | − | "workspace": "syntaqx", | |
| 2733 | + | "workspace": "flagon-io", | |
| 2180 | 2734 | "repo": null, | |
| 2181 | 2735 | "url": "https://hooks.example.com/g1t", | |
| 2182 | 2736 | "events": [ | |
| 2199 | 2753 | }, | |
| 2200 | 2754 | "delete_webhook_for_workspace": { | |
| 2201 | 2755 | "params": { | |
| 2202 | − | "workspace": "syntaqx", | |
| 2756 | + | "workspace": "flagon-io", | |
| 2203 | 2757 | "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd" | |
| 2204 | 2758 | }, | |
| 2205 | 2759 | "response": true | |
| 2228 | 2782 | }, | |
| 2229 | 2783 | "ping_webhook_for_workspace": { | |
| 2230 | 2784 | "params": { | |
| 2231 | − | "workspace": "syntaqx", | |
| 2785 | + | "workspace": "flagon-io", | |
| 2232 | 2786 | "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd" | |
| 2233 | 2787 | }, | |
| 2234 | 2788 | "response": { | |
| 2290 | 2844 | }, | |
| 2291 | 2845 | "list_webhook_deliveries_for_workspace": { | |
| 2292 | 2846 | "params": { | |
| 2293 | − | "workspace": "syntaqx", | |
| 2847 | + | "workspace": "flagon-io", | |
| 2294 | 2848 | "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd" | |
| 2295 | 2849 | }, | |
| 2296 | 2850 | "response": [ | |
| 2353 | 2907 | }, | |
| 2354 | 2908 | "redeliver_webhook_for_workspace": { | |
| 2355 | 2909 | "params": { | |
| 2356 | − | "workspace": "syntaqx", | |
| 2910 | + | "workspace": "flagon-io", | |
| 2357 | 2911 | "id": "hk_01kpx5p9s4h0t1w6x8y2a3b4cd", | |
| 2358 | 2912 | "delivery": "dlv_01kpx6a2b3c4d5e6f7g8h9j0kz" | |
| 2359 | 2913 | }, | |
| 2376 | 2930 | }, | |
| 2377 | 2931 | "list_integrations": { | |
| 2378 | 2932 | "params": { | |
| 2379 | − | "workspace": "syntaqx" | |
| 2933 | + | "workspace": "flagon-io" | |
| 2380 | 2934 | }, | |
| 2381 | 2935 | "response": [ | |
| 2382 | 2936 | { | |
| 2383 | 2937 | "id": "con_01kpx3m7q2f8r9t4v6w0y1z2ab", | |
| 2384 | − | "workspace": "syntaqx", | |
| 2938 | + | "workspace": "flagon-io", | |
| 2385 | 2939 | "provider": "anthropic", | |
| 2386 | 2940 | "kind": "models", | |
| 2387 | 2941 | "name": "Anthropic", | |
| 2402 | 2956 | }, | |
| 2403 | 2957 | { | |
| 2404 | 2958 | "id": "con_01kpx4n8r3g9s0v5w7x1z2a3bc", | |
| 2405 | − | "workspace": "syntaqx", | |
| 2959 | + | "workspace": "flagon-io", | |
| 2406 | 2960 | "provider": "jira", | |
| 2407 | 2961 | "kind": "tracker", | |
| 2408 | 2962 | "name": "Jira", | |
| 2428 | 2982 | }, | |
| 2429 | 2983 | "connect_integration": { | |
| 2430 | 2984 | "params": { | |
| 2431 | − | "workspace": "syntaqx" | |
| 2985 | + | "workspace": "flagon-io" | |
| 2432 | 2986 | }, | |
| 2433 | 2987 | "request": { | |
| 2434 | 2988 | "provider": "webhook", | |
| 2435 | 2989 | "config": { | |
| 2436 | − | "repo": "syntaqx/hello", | |
| 2990 | + | "repo": "flagon-io/hello", | |
| 2437 | 2991 | "label": "bug" | |
| 2438 | 2992 | } | |
| 2439 | 2993 | }, | |
| 2440 | 2994 | "response": { | |
| 2441 | 2995 | "connection": { | |
| 2442 | 2996 | "id": "con_01kpx4n8r3g9s0v5w7x1z2a3bd", | |
| 2443 | − | "workspace": "syntaqx", | |
| 2997 | + | "workspace": "flagon-io", | |
| 2444 | 2998 | "provider": "webhook", | |
| 2445 | 2999 | "kind": "alerts", | |
| 2446 | 3000 | "name": "Webhook", | |
| 2447 | 3001 | "config": { | |
| 2448 | − | "repo": "syntaqx/hello", | |
| 3002 | + | "repo": "flagon-io/hello", | |
| 2449 | 3003 | "label": "bug", | |
| 2450 | 3004 | "assign": false, | |
| 2451 | 3005 | "write_back": true | |
| 2464 | 3018 | }, | |
| 2465 | 3019 | "disconnect_integration": { | |
| 2466 | 3020 | "params": { | |
| 2467 | − | "workspace": "syntaqx", | |
| 3021 | + | "workspace": "flagon-io", | |
| 2468 | 3022 | "id": "con_01kpx3m7q2f8r9t4v6w0y1z2ab" | |
| 2469 | 3023 | }, | |
| 2470 | 3024 | "response": true | |
| 2471 | 3025 | }, | |
| 2472 | 3026 | "test_integration": { | |
| 2473 | 3027 | "params": { | |
| 2474 | − | "workspace": "syntaqx", | |
| 3028 | + | "workspace": "flagon-io", | |
| 2475 | 3029 | "id": "con_01kpx3m7q2f8r9t4v6w0y1z2ab" | |
| 2476 | 3030 | }, | |
| 2477 | 3031 | "response": { | |
| 2482 | 3036 | }, | |
| 2483 | 3037 | "get_model_routes": { | |
| 2484 | 3038 | "params": { | |
| 2485 | − | "workspace": "syntaqx" | |
| 3039 | + | "workspace": "flagon-io" | |
| 2486 | 3040 | }, | |
| 2487 | 3041 | "response": [ | |
| 2488 | 3042 | { | |
| 2499 | 3053 | }, | |
| 2500 | 3054 | "set_model_routes": { | |
| 2501 | 3055 | "params": { | |
| 2502 | − | "workspace": "syntaqx" | |
| 3056 | + | "workspace": "flagon-io" | |
| 2503 | 3057 | }, | |
| 2504 | 3058 | "request": { | |
| 2505 | 3059 | "routes": [ | |
| 2571 | 3125 | "response": { | |
| 2572 | 3126 | "id": "mem_01m43v8q2w3e4r5t6y7u8i9o0p", | |
| 2573 | 3127 | "scope": "project", | |
| 2574 | − | "workspace": "syntaqx", | |
| 3128 | + | "workspace": "flagon-io", | |
| 2575 | 3129 | "repo": { | |
| 2576 | − | "namespace": "syntaqx", | |
| 3130 | + | "namespace": "flagon-io", | |
| 2577 | 3131 | "name": "hello" | |
| 2578 | 3132 | }, | |
| 2579 | 3133 | "text": "The tests need TZ=UTC or the date tests fail.", | |
| 2582 | 3136 | "kind": "run", | |
| 2583 | 3137 | "run_id": "arn_01m43v2c1p9k8d7e6f5a4b3c2d", | |
| 2584 | 3138 | "repo": { | |
| 2585 | − | "namespace": "syntaqx", | |
| 3139 | + | "namespace": "flagon-io", | |
| 2586 | 3140 | "name": "hello" | |
| 2587 | 3141 | }, | |
| 2588 | 3142 | "number": 14 | |
| 2605 | 3159 | { | |
| 2606 | 3160 | "id": "mem_01m43t0a1s2d3f4g5h6j7k8l9z", | |
| 2607 | 3161 | "scope": "workspace", | |
| 2608 | − | "workspace": "syntaqx", | |
| 3162 | + | "workspace": "flagon-io", | |
| 2609 | 3163 | "repo": null, | |
| 2610 | 3164 | "text": "We use pnpm everywhere, never npm or yarn.", | |
| 2611 | 3165 | "kind": "convention", | |
| 2623 | 3177 | } | |
| 2624 | 3178 | ] | |
| 2625 | 3179 | }, | |
| 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." | |
| 2627 | 3181 | }, | |
| 2628 | 3182 | "search": { | |
| 2629 | 3183 | "query": { | |
| 2704 | 3258 | ], | |
| 2705 | 3259 | "notes": [] | |
| 2706 | 3260 | }, | |
| 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." | |
| 2708 | 3262 | }, | |
| 2709 | 3263 | "search_context": { | |
| 2710 | 3264 | "params": { | |
| 2756 | 3310 | } | |
| 2757 | 3311 | ] | |
| 2758 | 3312 | }, | |
| 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." | |
| 2760 | 3314 | }, | |
| 2761 | 3315 | "get_entity": { | |
| 2762 | 3316 | "params": { | |
| 2834 | 3388 | ] | |
| 2835 | 3389 | }, | |
| 2836 | 3390 | "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/)." | |
| 2837 | 3680 | } | |
| 2838 | 3681 | } |
| 1 | + | //! Old addresses: a workspace's slug after a rename, and a repository's | |
| 2 | + | //! path after a transfer. | |
| 3 | + | //! | |
| 1 | 4 | //! Old workspace slugs, after a rename. An operation that names a | |
| 2 | 5 | //! repository (`owner/name`) or a workspace by a slug the workspace has | |
| 3 | 6 | //! since been renamed from is run again under the slug it has now, so | |
| 55 | 58 | Ok(current.map(|new| rewrite(input, old, &new))) | |
| 56 | 59 | } | |
| 57 | 60 | ||
| 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 | + | ||
| 58 | 88 | #[cfg(test)] | |
| 59 | 89 | mod tests { | |
| 60 | 90 | use super::*; | |
| 68 | 98 | } | |
| 69 | 99 | ||
| 70 | 100 | #[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] | |
| 71 | 110 | fn rewrites_only_the_old_slug() { | |
| 72 | 111 | let input = json!({ "repo": "Acme/rocket", "workspace": "acme", "title": "acme/x" }); | |
| 73 | 112 | assert_eq!( |
| 7 | 7 | //! encoded again, so that every field the type has is sent, not only the | |
| 8 | 8 | //! ones an example shows. | |
| 9 | 9 | ||
| 10 | − | use g1t_contracts::{actions, integrations, repos, search, webhooks, work}; | |
| 10 | + | use g1t_contracts::{access, actions, integrations, repos, search, webhooks, work}; | |
| 11 | 11 | use g1t_kit::wire::{self, USER_KEYED}; | |
| 12 | 12 | use serde::Serialize; | |
| 13 | 13 | use serde::de::DeserializeOwned; | |
| 63 | 63 | ||
| 64 | 64 | /// What the service behind an operation sends, from its example. | |
| 65 | 65 | 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 | + | } | |
| 66 | 84 | let sent = as_services_send(example); | |
| 67 | 85 | match op { | |
| 86 | + | Op::CreateWorkspace => through::<g1t_contracts::identity::Workspace>(op, sent), | |
| 68 | 87 | Op::ListRepos => through::<Vec<repos::Repo>>(op, sent), | |
| 69 | 88 | 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), | |
| 71 | 101 | Op::GetRepoSettings | Op::UpdateRepoSettings => through::<work::RepoSettings>(op, sent), | |
| 72 | 102 | Op::GetMergeQueue => through::<work::QueueView>(op, sent), | |
| 73 | 103 | Op::ListIssues => through::<Vec<work::Issue>>(op, sent), | |
| 92 | 122 | Op::ListIntegrations => through::<Vec<integrations::Connection>>(op, sent), | |
| 93 | 123 | Op::GetModelRoutes | Op::SetModelRoutes => through::<Vec<integrations::ModelRoute>>(op, sent), | |
| 94 | 124 | 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), | |
| 96 | 133 | _ => sent, | |
| 97 | 134 | } | |
| 98 | 135 | } |
| 30 | 30 | pub const ROUTES: &[Route] = &[ | |
| 31 | 31 | route("GET", "/user", Op::Whoami, &[]), | |
| 32 | 32 | 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 | + | ), | |
| 33 | 68 | route("GET", "/repos", Op::ListRepos, &[("q", "query")]), | |
| 34 | 69 | route( | |
| 35 | 70 | "GET", | |
| 40 | 75 | route("POST", "/repos", Op::CreateRepo, &[]), | |
| 41 | 76 | route("GET", "/repos/:owner/:name", Op::GetRepo, &[]), | |
| 42 | 77 | 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 | + | ), | |
| 43 | 103 | route( | |
| 44 | 104 | "GET", | |
| 45 | 105 | "/repos/:owner/:name/settings", | |
| 523 | 583 | } | |
| 524 | 584 | } | |
| 525 | 585 | ||
| 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 | + | ||
| 526 | 612 | /// The route for a request, and the operation input it describes. | |
| 527 | 613 | /// | |
| 528 | 614 | /// The input is the JSON body, overlaid with the query parameters the route | |
| 557 | 643 | if let (Some(owner), Some(name)) = (param("owner"), param("name")) { | |
| 558 | 644 | input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}"))); | |
| 559 | 645 | } | |
| 560 | − | for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting"] { | |
| 646 | + | for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting", "username"] { | |
| 561 | 647 | if let Some(value) = param(key) { | |
| 562 | 648 | input.insert(key.to_owned(), Value::String(value.to_owned())); | |
| 563 | 649 | } | |
| 564 | 650 | } | |
| 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 | + | } | |
| 565 | 655 | // GitHub says some things with the path alone. | |
| 566 | 656 | if route.path.ends_with("/enable") || route.path.ends_with("/disable") { | |
| 567 | 657 | input.insert("enabled".to_owned(), Value::Bool(route.path.ends_with("/enable"))); | |
| 589 | 679 | fn a_path_resolves_to_its_operation_and_input() { | |
| 590 | 680 | let (route, input) = resolve( | |
| 591 | 681 | "POST", | |
| 592 | − | "/repos/syntaqx/hello/pulls/14/merge", | |
| 682 | + | "/repos/flagon-io/hello/pulls/14/merge", | |
| 593 | 683 | &[], | |
| 594 | 684 | json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }), | |
| 595 | 685 | ) | |
| 598 | 688 | // What the path names wins over the body. | |
| 599 | 689 | assert_eq!( | |
| 600 | 690 | input, | |
| 601 | − | json!({ "keep_issue_open": true, "number": 14, "repo": "syntaqx/hello" }) | |
| 691 | + | json!({ "keep_issue_open": true, "number": 14, "repo": "flagon-io/hello" }) | |
| 602 | 692 | ); | |
| 603 | 693 | } | |
| 604 | 694 | ||
| 605 | 695 | #[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] | |
| 606 | 730 | fn query_parameters_are_renamed() { | |
| 607 | 731 | let query = [ | |
| 608 | 732 | ("q".to_owned(), "parser".to_owned()), |
| 14 | 14 | title: 'g1t docs', | |
| 15 | 15 | description: 'Guides and reference for g1t, the git forge for teams of agents.', | |
| 16 | 16 | components: { | |
| 17 | + | Footer: './src/components/Footer.astro', | |
| 18 | + | Head: './src/components/Head.astro', | |
| 17 | 19 | Header: './src/components/Header.astro', | |
| 18 | 20 | PageTitle: './src/components/PageTitle.astro', | |
| 19 | 21 | SiteTitle: './src/components/SiteTitle.astro', | |
| 42 | 44 | }, | |
| 43 | 45 | logo: { src: '@g1t/theme/mark.svg', alt: '' }, | |
| 44 | 46 | 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'], | |
| 49 | 48 | editLink: { | |
| 50 | − | baseUrl: 'https://g1t.sh/syntaqx/g1t/blob/main/apps/docs/', | |
| 49 | + | baseUrl: 'https://g1t.sh/flagon-io/g1t/blob/main/apps/docs/', | |
| 51 | 50 | }, | |
| 52 | 51 | lastUpdated: true, | |
| 53 | 52 | head: [ | |
| 54 | 53 | { tag: 'link', attrs: { rel: 'icon', href: '/favicon.ico', sizes: '32x32' } }, | |
| 55 | 54 | { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '192x192', href: '/icon-192.png' } }, | |
| 56 | 55 | { 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 | − | }, | |
| 69 | 56 | ], | |
| 70 | 57 | sidebar: [ | |
| 71 | 58 | { | |
| 119 | 106 | label: 'Workspaces', | |
| 120 | 107 | items: [ | |
| 121 | 108 | { label: 'Accounts and sign-in', slug: 'guides/authentication' }, | |
| 109 | + | { label: 'GitHub', slug: 'guides/github' }, | |
| 122 | 110 | { 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' }, | |
| 123 | 114 | { label: 'Audit log', slug: 'guides/audit-log' }, | |
| 124 | 115 | { label: 'Usage and billing', slug: 'guides/usage-and-billing' }, | |
| 125 | 116 | { label: 'Git', slug: 'guides/git' }, |
| 10 | 10 | }, | |
| 11 | 11 | "dependencies": { | |
| 12 | 12 | "@astrojs/starlight": "^0.42.5", | |
| 13 | + | "@g1t/theme": "*", | |
| 14 | + | "@lucide/astro": "^1.52.0", | |
| 13 | 15 | "astro": "^7.2.10", | |
| 14 | − | "sharp": "^0.35.3", | |
| 15 | − | "@g1t/theme": "*" | |
| 16 | + | "sharp": "^0.35.3" | |
| 16 | 17 | }, | |
| 17 | 18 | "private": true, | |
| 18 | 19 | "license": "MIT" |
| 1 | + | # Fingerprinted by the build (typefaces, styles, scripts): kept for good. | |
| 2 | + | /_astro/* | |
| 3 | + | Cache-Control: public, max-age=31536000, immutable |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
| 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> |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
| 22 | 22 | const METHODS = ['get', 'post', 'put', 'patch', 'delete']; | |
| 23 | 23 | ||
| 24 | 24 | /** 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' }; | |
| 26 | 26 | ||
| 27 | 27 | const STATUS = { | |
| 28 | 28 | 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).'], | |
| 30 | 30 | 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.'], | |
| 31 | 31 | 404: ['not_found', 'It does not exist, or you cannot see it.'], | |
| 32 | 32 | 409: ['conflict', 'The request conflicts with the current state.'], |
| 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> |
| 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> |
| 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> |
| 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 /> |
| 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> |
| 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} /> |
| 2 | 2 | // A page's title, what it is about beneath it, and a way to take the page | |
| 3 | 3 | // to an AI assistant: copied as Markdown, read as Markdown, or opened in | |
| 4 | 4 | // Claude or ChatGPT. | |
| 5 | + | import { ChevronDown, Copy } from '@lucide/astro'; | |
| 6 | + | ||
| 5 | 7 | const { entry, id } = Astro.locals.starlightRoute; | |
| 6 | 8 | const { title, description } = entry.data; | |
| 7 | 9 | const markdown = `/${id === '' || id === 'index' ? 'index' : id}.md`; | |
| 14 | 16 | <h1 id="_top">{title}</h1> | |
| 15 | 17 | <div class="g1t-copy"> | |
| 16 | 18 | <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 /> | |
| 21 | 20 | <span>Copy page</span> | |
| 22 | 21 | </button> | |
| 23 | 22 | <details class="g1t-copy-more"> | |
| 24 | 23 | <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 /> | |
| 26 | 25 | </summary> | |
| 27 | 26 | <div class="g1t-copy-menu"> | |
| 28 | 27 | <a href={markdown} target="_blank" rel="noopener"> | |
| 78 | 77 | } | |
| 79 | 78 | h1 { | |
| 80 | 79 | margin: 0; | |
| 80 | + | font-family: var(--g1t-font-display); | |
| 81 | + | font-optical-sizing: auto; | |
| 81 | 82 | font-size: 2rem; | |
| 82 | 83 | font-weight: 600; | |
| 83 | − | letter-spacing: -0.025em; | |
| 84 | + | letter-spacing: -0.015em; | |
| 84 | 85 | line-height: 1.2; | |
| 85 | 86 | color: var(--g1t-fg); | |
| 86 | 87 | } | |
| 99 | 100 | border-radius: 0.5rem; | |
| 100 | 101 | box-shadow: inset 0 0 0 1px var(--g1t-line); | |
| 101 | 102 | } | |
| 102 | − | .g1t-copy svg { | |
| 103 | + | .g1t-copy :global(svg) { | |
| 103 | 104 | width: 0.9rem; | |
| 104 | 105 | height: 0.9rem; | |
| 105 | − | fill: none; | |
| 106 | − | stroke: currentColor; | |
| 107 | − | stroke-width: 1.3; | |
| 108 | − | stroke-linecap: round; | |
| 109 | − | stroke-linejoin: round; | |
| 110 | 106 | } | |
| 111 | 107 | .g1t-copy-main, | |
| 112 | 108 | .g1t-copy-more summary { |
| 1 | 1 | --- | |
| 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)]; | |
| 4 | 11 | --- | |
| 5 | 12 | ||
| 6 | 13 | <a href="/" class="g1t-title" aria-label="g1t docs home"> | |
| 7 | 14 | <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> | |
| 17 | 26 | </span> | |
| 18 | 27 | <span class="g1t-title-docs">Docs</span> | |
| 19 | 28 | </a> | |
| 26 | 35 | color: var(--g1t-fg); | |
| 27 | 36 | text-decoration: none; | |
| 28 | 37 | } | |
| 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. */ | |
| 30 | 39 | .g1t-title-lockup { | |
| 31 | 40 | display: inline-flex; | |
| 32 | 41 | align-items: baseline; | |
| 33 | − | gap: 0.3em; | |
| 34 | − | font-size: 1.25rem; | |
| 35 | − | font-weight: 700; | |
| 42 | + | font-size: 1.5rem; | |
| 36 | 43 | line-height: 1; | |
| 37 | 44 | } | |
| 38 | 45 | svg { | |
| 39 | − | height: 0.74em; | |
| 46 | + | height: 0.85em; | |
| 40 | 47 | width: auto; | |
| 41 | 48 | flex-shrink: 0; | |
| 42 | − | } | |
| 43 | − | .g1t-title-name { | |
| 44 | − | letter-spacing: -0.045em; | |
| 45 | 49 | } | |
| 46 | 50 | .g1t-title-docs { | |
| 47 | 51 | border-radius: 999px; |
| 1 | 1 | --- | |
| 2 | 2 | // 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'; | |
| 3 | 8 | --- | |
| 4 | 9 | ||
| 5 | 10 | <nav class="g1t-nav" aria-label="g1t"> | |
| 6 | 11 | <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 /> | |
| 16 | 15 | </a> | |
| 17 | 16 | </nav> | |
| 18 | 17 | ||
| 48 | 47 | display: inline-flex; | |
| 49 | 48 | padding: 0.4rem; | |
| 50 | 49 | } | |
| 51 | − | .g1t-nav-icon svg { | |
| 50 | + | .g1t-nav-icon :global(svg) { | |
| 52 | 51 | width: 1rem; | |
| 53 | 52 | height: 1rem; | |
| 54 | 53 | } |
| 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 | + | ]; |
Binary or large file; its contents are not shown.
| 1 | 1 | import type { APIRoute, GetStaticPaths } from 'astro'; | |
| 2 | 2 | import { getCollection } from 'astro:content'; | |
| 3 | 3 | ||
| 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 | + | ||
| 4 | 18 | /** | |
| 5 | 19 | * Every page as Markdown, at its path plus `.md`, for "Copy page" and for | |
| 6 | 20 | * agents that read the docs: the title, what it is about, then the page. | |
| 17 | 31 | const { page } = props as { page: Awaited<ReturnType<typeof getCollection<'docs'>>>[number] }; | |
| 18 | 32 | const { title, description } = page.data; | |
| 19 | 33 | // 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(); | |
| 21 | 43 | const text = `# ${title}\n\n${description ? `> ${description}\n\n` : ''}${body}\n`; | |
| 22 | 44 | return new Response(text, { headers: { 'content-type': 'text/markdown; charset=utf-8' } }); | |
| 23 | 45 | }; |
| 137 | 137 | border-top: 0; | |
| 138 | 138 | padding-top: 0.5rem; | |
| 139 | 139 | } | |
| 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 | + | } | |
| 140 | 145 | .sl-markdown-content h1 { | |
| 141 | 146 | font-size: 2.25rem; | |
| 142 | 147 | font-weight: 650; | |
| 143 | − | letter-spacing: -0.03em; | |
| 148 | + | letter-spacing: -0.02em; | |
| 144 | 149 | line-height: 1.15; | |
| 145 | 150 | color: var(--g1t-fg); | |
| 146 | 151 | } | |
| 151 | 156 | .sl-markdown-content h2 { | |
| 152 | 157 | font-size: 1.5rem; | |
| 153 | 158 | font-weight: 600; | |
| 154 | − | letter-spacing: -0.02em; | |
| 159 | + | letter-spacing: -0.01em; | |
| 155 | 160 | color: var(--g1t-fg); | |
| 156 | 161 | } | |
| 157 | 162 | .sl-markdown-content h3 { | |
| 158 | 163 | margin-top: 2rem; | |
| 159 | 164 | font-size: 1.1rem; | |
| 160 | 165 | font-weight: 600; | |
| 161 | − | letter-spacing: -0.01em; | |
| 166 | + | letter-spacing: -0.005em; | |
| 162 | 167 | color: var(--g1t-fg); | |
| 163 | 168 | } | |
| 164 | 169 | .sl-markdown-content p, | |
| 188 | 193 | border-collapse: collapse; | |
| 189 | 194 | font-size: 0.875rem; | |
| 190 | 195 | } | |
| 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 | + | } | |
| 191 | 203 | .sl-markdown-content th { | |
| 192 | 204 | border-bottom: 1px solid var(--g1t-line-strong); | |
| 193 | 205 | background: transparent; |
| 1 | 1 | @import "tailwindcss" source("."); | |
| 2 | 2 | @import "@g1t/theme/tokens.css"; | |
| 3 | + | @import "@g1t/theme/fonts.css"; | |
| 3 | 4 | ||
| 4 | 5 | /* Tailwind's names for the shared g1t tokens, as in apps/web. */ | |
| 5 | 6 | @theme { | |
| 6 | 7 | --font-sans: var(--g1t-font-sans); | |
| 8 | + | --font-display: var(--g1t-font-display); | |
| 7 | 9 | --font-mono: var(--g1t-font-mono); | |
| 8 | 10 | ||
| 9 | 11 | --color-bg: var(--g1t-bg); | |
| 32 | 34 | ||
| 33 | 35 | body { | |
| 34 | 36 | @apply bg-bg font-sans text-fg antialiased; | |
| 35 | − | font-feature-settings: "cv11", "ss01"; | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 38 | 39 | ::selection { | |
| 49 | 50 | min-width: 0; | |
| 50 | 51 | } | |
| 51 | 52 | ||
| 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 | + | ||
| 52 | 59 | :root { | |
| 53 | 60 | accent-color: var(--color-merged); | |
| 54 | 61 | } |
| 1 | 1 | /** | |
| 2 | 2 | * 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. | |
| 5 | 5 | */ | |
| 6 | − | import { CreditCard, Gift, ScrollText, UserRound } from "lucide-react"; | |
| 6 | + | import { CreditCard, Gift, Landmark, ScrollText, UserRound } from "lucide-react"; | |
| 7 | 7 | import type { ReactNode } from "react"; | |
| 8 | 8 | import { Link } from "react-router"; | |
| 9 | 9 | ||
| 12 | 12 | import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui"; | |
| 13 | 13 | import { actionLabel } from "~/lib/ledgers"; | |
| 14 | 14 | import { dollarsField, usd } from "~/lib/money"; | |
| 15 | + | import { givenParts } from "~/lib/pricing"; | |
| 15 | 16 | import type { Review, SectionError } from "~/lib/review"; | |
| 16 | 17 | ||
| 17 | 18 | export function Hidden({ values }: { values: Record<string, string> }) { | |
| 259 | 260 | ); | |
| 260 | 261 | } | |
| 261 | 262 | ||
| 262 | − | // --- Plan and pools ----------------------------------------------------------- | |
| 263 | + | // --- Plan, pools and caps ------------------------------------------------------- | |
| 263 | 264 | ||
| 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 | + | ||
| 264 | 282 | /** | |
| 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. | |
| 267 | 286 | */ | |
| 268 | 287 | export function AllowancesForm({ | |
| 269 | 288 | allowances, | |
| 277 | 296 | error: SectionError; | |
| 278 | 297 | }) { | |
| 279 | 298 | 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); | |
| 281 | 301 | return ( | |
| 282 | 302 | <Section | |
| 283 | 303 | id="allowances" | |
| 284 | − | title="Plan and pools" | |
| 304 | + | title="Plan, pools and caps" | |
| 285 | 305 | description={ | |
| 286 | 306 | 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." | |
| 289 | 309 | } | |
| 290 | 310 | > | |
| 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> | |
| 291 | 325 | <form method="post" action={`${pathname}#allowances`} className="space-y-4"> | |
| 292 | 326 | <input type="hidden" name="intent" value="allowances" /> | |
| 293 | 327 | {error && <Notice tone="error">{error.error}</Notice>} | |
| 294 | 328 | <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" /> | |
| 296 | 330 | <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> | |
| 299 | 335 | </span> | |
| 300 | 336 | </label> | |
| 301 | 337 | <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)."> | |
| 303 | 339 | <Input name="oss" inputMode="decimal" placeholder="Default" defaultValue={values?.oss ?? dollarsField(current.ossRepoMicros)} /> | |
| 304 | 340 | </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)."> | |
| 306 | 342 | <Input name="trial" inputMode="decimal" placeholder="Default" defaultValue={values?.trial ?? dollarsField(current.trialMicros)} /> | |
| 307 | 343 | </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> | |
| 308 | 362 | </div> | |
| 309 | 363 | <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 | + | /> | |
| 311 | 415 | </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> | |
| 312 | 426 | <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> | |
| 314 | 431 | </div> | |
| 315 | 432 | </form> | |
| 316 | 433 | </Section> | |
| 471 | 588 | </Link> | |
| 472 | 589 | )} | |
| 473 | 590 | <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 | + | ))} | |
| 474 | 596 | </div> | |
| 475 | 597 | <p className="mt-0.5 font-mono text-xs text-faint"> | |
| 476 | 598 | {[ |
| 110 | 110 | <span className="hidden group-open:inline">Hide the table</span> | |
| 111 | 111 | </summary> | |
| 112 | 112 | <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"> | |
| 114 | 114 | <thead> | |
| 115 | 115 | <tr className="border-b border-line text-left text-muted"> | |
| 116 | 116 | <th className="py-1.5 pr-3 font-medium">Month</th> | |
| 117 | 117 | <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> | |
| 118 | 119 | <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> | |
| 120 | 122 | <th className="py-1.5 text-right font-medium">Paid</th> | |
| 121 | 123 | </tr> | |
| 122 | 124 | </thead> | |
| 123 | 125 | <tbody> | |
| 124 | 126 | {months.map((month) => { | |
| 125 | − | const margin = marginOf(month.chargedMicros, month.costMicros); | |
| 127 | + | const margin = marginOf(month.chargedMicros + (month.plansMicros ?? 0), month.costMicros); | |
| 126 | 128 | return ( | |
| 127 | 129 | <tr key={month.month} className="tabular border-b border-line last:border-0"> | |
| 128 | 130 | <td className="py-1.5 pr-3 text-fg-soft">{monthLong(month.month)}</td> | |
| 129 | 131 | <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> | |
| 130 | 133 | <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> | |
| 131 | 135 | <td className={`py-1.5 pr-3 text-right ${margin.micros < 0 ? "text-danger" : "text-fg-soft"}`}> | |
| 132 | 136 | {usd(margin.micros)} | |
| 133 | 137 | {margin.percent != null && <span className="text-faint"> {margin.percent}%</span>} |
| 1 | 1 | /** | |
| 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. | |
| 4 | 40 | */ | |
| 5 | 41 | export function Mark({ className, tight = false }: { className?: string; tight?: boolean }) { | |
| 6 | 42 | 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} /> | |
| 14 | 45 | </svg> | |
| 15 | 46 | ); | |
| 16 | 47 | } | |
| 17 | 48 | ||
| 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 }) { | |
| 20 | 51 | return ( | |
| 21 | 52 | <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> | |
| 25 | 57 | </span> | |
| 26 | 58 | <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"> | |
| 27 | 59 | sudo |
| 7 | 7 | * holding the current page. All of it comes from lib/nav.ts. | |
| 8 | 8 | */ | |
| 9 | 9 | import { | |
| 10 | + | Activity, | |
| 10 | 11 | Bot, | |
| 11 | 12 | Building2, | |
| 12 | 13 | Boxes, | |
| 18 | 19 | FileText, | |
| 19 | 20 | Gauge, | |
| 20 | 21 | HandCoins, | |
| 22 | + | HandHelping, | |
| 21 | 23 | Inbox, | |
| 22 | 24 | LayoutDashboard, | |
| 23 | 25 | LifeBuoy, | |
| 29 | 31 | ShieldAlert, | |
| 30 | 32 | ShieldCheck, | |
| 31 | 33 | Tags, | |
| 34 | + | Ticket, | |
| 35 | + | TrendingUp, | |
| 32 | 36 | UserCog, | |
| 33 | 37 | Users, | |
| 34 | 38 | UsersRound, | |
| 59 | 63 | announcements: Megaphone, | |
| 60 | 64 | staff: UserCog, | |
| 61 | 65 | audit: ScrollText, | |
| 66 | + | requests: HandHelping, | |
| 67 | + | overages: TrendingUp, | |
| 68 | + | velocity: Activity, | |
| 69 | + | invites: Ticket, | |
| 62 | 70 | customers: UsersRound, | |
| 71 | + | spend: Gauge, | |
| 63 | 72 | revenue: CircleDollarSign, | |
| 64 | 73 | platform: Server, | |
| 65 | 74 | support: LifeBuoy, | |
| 193 | 202 | return ( | |
| 194 | 203 | <aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-line bg-surface/50 lg:flex"> | |
| 195 | 204 | <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"> | |
| 197 | 206 | <Logo /> | |
| 198 | 207 | </Link> | |
| 199 | 208 | </div> | |
| 213 | 222 | return ( | |
| 214 | 223 | <header className="sticky top-0 z-40 border-b border-line bg-surface/95 backdrop-blur lg:hidden"> | |
| 215 | 224 | <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"> | |
| 217 | 226 | <Logo /> | |
| 218 | 227 | </Link> | |
| 219 | 228 | {/* A details element: it opens and closes with no script. A link |
| 8 | 8 | import type { Terms } from "@g1t/contracts"; | |
| 9 | 9 | import { data, redirect } from "react-router"; | |
| 10 | 10 | ||
| 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"; | |
| 12 | 13 | import type { ActionData } from "./review"; | |
| 13 | − | import { admin, identity } from "./services.server"; | |
| 14 | + | import { admin, identity, priceBook } from "./services.server"; | |
| 14 | 15 | import type { Staff } from "./staff"; | |
| 15 | 16 | import type { Enterprise } from "./workspaces"; | |
| 16 | 17 | ||
| 44 | 45 | } | |
| 45 | 46 | ||
| 46 | 47 | 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"); | |
| 49 | 50 | if (subject.kind === "workspace" && subject.billedTo) { | |
| 50 | 51 | return failed("allowances", `This workspace is on ${subject.billedTo.name}. Set its plan and pools on the enterprise.`, values); | |
| 51 | 52 | } | |
| 120 | 121 | return back("credit"); | |
| 121 | 122 | } | |
| 122 | 123 | ||
| 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 | + | ||
| 123 | 151 | if (intent === "billing-link") { | |
| 124 | 152 | if (subject.kind !== "workspace") return failed("top", "Billing links are made from a workspace's page."); | |
| 125 | 153 | if (!confirmed) return { review: { intent, workspace: subject.slug, fields: {} } } satisfies ActionData; | |
| 160 | 188 | ||
| 161 | 189 | return failed("top", "Unknown action."); | |
| 162 | 190 | } | |
| 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 | + | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 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"; | |
| 5 | 16 | ||
| 6 | 17 | const NOW = new Date("2026-10-04T12:00:00Z"); | |
| 7 | 18 | ||
| 87 | 98 | assert.equal(parseSalesNote("x".repeat(2001)).ok, false); | |
| 88 | 99 | }); | |
| 89 | 100 | ||
| 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({})), { | |
| 98 | 104 | 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 }, | |
| 100 | 106 | }); | |
| 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 | + | ); | |
| 101 | 122 | assert.equal(parseAllowances(form({ oss: "lots" })).ok, false); | |
| 102 | 123 | 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]); | |
| 103 | 181 | }); |
| 179 | 179 | ||
| 180 | 180 | /** The most staff can set an account's share of a pool to, against a slipped finger. */ | |
| 181 | 181 | 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; | |
| 182 | 190 | ||
| 183 | 191 | /** | |
| 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. | |
| 187 | 197 | */ | |
| 188 | 198 | 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> => { | |
| 190 | 200 | const raw = text(form, name); | |
| 191 | 201 | if (!raw) return { ok: true, value: null }; | |
| 192 | 202 | 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}.` }; | |
| 195 | 206 | return { ok: true, value: micros }; | |
| 196 | 207 | }; | |
| 197 | − | const oss = amount("oss", "The open-source share"); | |
| 208 | + | const oss = amount("oss", "The open-source share", MAX_POOL_SHARE_MICROS, "$1,000"); | |
| 198 | 209 | 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"); | |
| 200 | 211 | 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 } }; | |
| 202 | 342 | } |
| 5 | 5 | ||
| 6 | 6 | /** | |
| 7 | 7 | * 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. | |
| 10 | 10 | */ | |
| 11 | 11 | export const CONTENT_SECURITY_POLICY = [ | |
| 12 | 12 | "default-src 'none'", | |
| 13 | 13 | "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'", | |
| 16 | 16 | "img-src 'self' data:", | |
| 17 | 17 | "form-action 'self'", | |
| 18 | 18 | "frame-ancestors 'none'", | |
| 46 | 46 | const DENIED_STYLE = ` | |
| 47 | 47 | :root{color-scheme:dark} | |
| 48 | 48 | 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} | |
| 50 | 50 | main{max-width:28rem;padding:2rem 1rem;text-align:center} | |
| 51 | 51 | .badge{display:inline-block;border:1px solid #b6a8ff66;color:#b6a8ff;background:#b6a8ff1a;border-radius:999px; | |
| 52 | 52 | padding:.1rem .6rem;font:600 12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.02em} |
| 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 | + | }); |
| 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 | + | }; |
| 85 | 85 | /** Each kind of change, as staff read it. */ | |
| 86 | 86 | export const AUDIT_ACTIONS: Record<string, string> = { | |
| 87 | 87 | 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", | |
| 89 | 92 | create: "Enterprise created", | |
| 90 | 93 | attach: "Workspace added", | |
| 91 | 94 | detach: "Workspace removed", |
| 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 | + | }); |
| 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 | + | } |
| 24 | 24 | /** The flash messages a change redirects back with (`?done=`). */ | |
| 25 | 25 | export const DONE: Record<string, string> = { | |
| 26 | 26 | 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.", | |
| 28 | 28 | attach: "Workspace moved onto the enterprise.", | |
| 29 | 29 | detach: "Workspace moved off the enterprise. It pays for itself again.", | |
| 30 | 30 | credit: "Credit issued.", | |
| 32 | 32 | "billing-email": "Saved where the enterprise's invoices go.", | |
| 33 | 33 | sales: "Sales record saved.", | |
| 34 | 34 | 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.", | |
| 35 | 39 | }; | |
| 36 | 40 | ||
| 37 | 41 | /** The `?done=` key, if it is one sudo knows. */ |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | ||
| 3 | − | import { billingAdminClient, billingClient, identityAdminClient } from "@g1t/contracts"; | |
| 3 | + | import { accountsAdminClient, billingAdminClient, billingClient, eventsClient, identityAdminClient } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | /** Staff-only billing, on the billing service. */ | |
| 6 | 6 | export const admin = billingAdminClient(env.BILLING); | |
| 10 | 10 | ||
| 11 | 11 | /** Staff-only identity: every workspace, its owners and members. */ | |
| 12 | 12 | 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); |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/root"; | |
| 4 | 4 | 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"; | |
| 5 | 7 | import { MobileBar, Sidebar } from "./components/shell"; | |
| 6 | 8 | import { ButtonLink } from "./components/ui"; | |
| 7 | 9 | import { requireStaff } from "./lib/staff"; | |
| 8 | 10 | ||
| 9 | 11 | export const links: Route.LinksFunction = () => [ | |
| 10 | 12 | { 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" }, | |
| 17 | 16 | ]; | |
| 18 | 17 | ||
| 19 | 18 | export const meta: Route.MetaFunction = () => [ |
| 6 | 6 | index("routes/overview.tsx"), | |
| 7 | 7 | route("workspaces", "routes/workspaces.tsx"), | |
| 8 | 8 | route("workspaces/:slug", "routes/workspace.tsx"), | |
| 9 | + | route("users/:username", "routes/user.tsx"), | |
| 9 | 10 | route("enterprises", "routes/enterprises.tsx"), | |
| 11 | + | route("invites", "routes/invites.tsx"), | |
| 10 | 12 | route("enterprises/new", "routes/new-enterprise.tsx"), | |
| 11 | 13 | route("enterprises/:id", "routes/enterprise.tsx"), | |
| 12 | 14 | 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"), | |
| 13 | 19 | route("invoices", "routes/invoices.tsx"), | |
| 14 | 20 | route("stripe", "routes/stripe.tsx"), | |
| 15 | 21 | route("audit", "routes/audit.tsx"), |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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"; | |
| 2 | 2 | import type { ReactNode } from "react"; | |
| 3 | 3 | import { Link, redirect } from "react-router"; | |
| 4 | 4 | ||
| 6 | 6 | import { KindBreakdown, MonthsChart } from "~/components/charts"; | |
| 7 | 7 | import { SignalBadge, StaffName, StageBadge } from "~/components/sales"; | |
| 8 | 8 | 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"; | |
| 10 | 11 | import { usd } from "~/lib/money"; | |
| 11 | 12 | import { admin } from "~/lib/services.server"; | |
| 12 | 13 | import { settle } from "~/lib/settle"; | |
| 39 | 40 | const previous = current ? months[months.indexOf(current) - 1] ?? null : null; | |
| 40 | 41 | const charged = current?.chargedMicros ?? overview?.byKind.reduce((sum, row) => sum + row.chargedMicros, 0) ?? 0; | |
| 41 | 42 | 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; | |
| 44 | 48 | ||
| 45 | 49 | return ( | |
| 46 | 50 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 59 | 63 | <> | |
| 60 | 64 | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| 61 | 65 | <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 | + | } | |
| 65 | 74 | tone={growth != null && growth > 0 ? "mint" : undefined} | |
| 66 | 75 | /> | |
| 67 | − | <Stat label="Cost to g1t" value={usd(cost)} hint="What the usage cost on Cloudflare and model providers" /> | |
| 68 | 76 | <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`} | |
| 73 | 95 | /> | |
| 74 | − | <Stat label="Paying workspaces" value={String(overview.payingWorkspaces)} hint="Charged something this month" /> | |
| 75 | 96 | </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> | |
| 76 | 100 | ||
| 77 | 101 | {overview.pools && ( | |
| 78 | 102 | <div className="mt-3 grid grid-cols-2 gap-3"> | |
| 100 | 124 | </Section> | |
| 101 | 125 | </div> | |
| 102 | 126 | ||
| 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 | + | ||
| 103 | 175 | <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 | + | /> | |
| 105 | 201 | <AttentionCard | |
| 106 | 202 | to={reachOutHref({ kind: "at_limit" })} | |
| 107 | 203 | icon={<AlertOctagon size={15} />} |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.