flagon-io/g1t

public

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

Commit

Billing accounts, terms and enterprises; g1t is no longer free

Every workspace is paid for by a billing account: its own by default, or an enterprise's, which pays for several workspaces with one limit and one set of terms, as GitHub Enterprise does. Terms are standard, comped (nothing charged, usage recorded; paid features on) or custom (a discount, a ceiling, an end date). syntaqx is comped by migration rather than an exempt list. Staff methods (admin_*) back sudo.g1t.sh, and every change is kept in admin_actions with who made it. FREE_WHILE_BUILDING is off: usage is charged on the account's terms. The free allowance on hosted models now covers its share of a run's cost rather than everything. Prepaid credit no longer gates work; usage limits do, and credit is a prepayment. So that limits stop non-payers without stopping payers: near the limit a workspace with a card on file is charged for what it owes (live payments only), which lowers what is owed and raises the limit. A declined card stops work until it is paid; an owner's own spend limit still means stop.

syntaqxcommitted Parent810a78cBrowse files
54 files+4028−1460/54 viewed
+3−1
125125 Workflows run in every workspace that can use g1t's agents: one with its
126126 own [model provider](/guides/models/) connected, or one on
127127 [the free allowance](/guides/usage-and-billing/#the-free-allowance) while
128−it lasts. They are free while g1t is being built out. Elsewhere a run is
128+it lasts. Each job's sandbox is charged as
129+[sandbox time](/guides/usage-and-billing/#sandbox-time), past the free
130+minutes. Elsewhere a run is
129131 recorded with its jobs failed and the reason, and the Actions page says so
130132 before the first run.
131133
+5−5
205205
206206 ## What it costs
207207
208−While g1t is being built out, its agents cost nothing: runs are recorded
209−with what they cost, and nothing is charged. Once pricing starts, a
210−workspace will pay for the g1t agents that work on its repositories from
211−credit an owner buys in advance: each run charged what the model cost, plus
212−20%, and acceptance checks free.
208+A workspace pays for the g1t agents that work on its repositories, after
209+they run: each run is charged what AI Gateway priced its model requests
210+at, plus 20%, and its sandbox by the second past the free minutes. See
211+[Usage and billing](/guides/usage-and-billing/) for how prices are set and
212+the limits on usage not yet paid for.
213213 The workspace's **Usage** page shows what its agents have cost, by day,
214214 kind of work, repository, model and pull request. See
215215 [usage and billing](/guides/usage-and-billing/).
+3−4
8585
8686 ## What it costs
8787
88−While g1t is being built out, g1t charges nothing for runs on your own
89−providers: they bill you for the models, and that is all. Once pricing
90−starts, g1t will charge your credit a flat **$0.10 per run** for the
91−sandbox and orchestration. A
88+Your providers bill you for the models. g1t charges a flat **$0.10 per
89+run** for its orchestration, plus the run's
90+[sandbox time](/guides/usage-and-billing/#sandbox-time). A
9291 change, a review, a revision, a catch-up and a plan are each a run. The
9392 statement marks these runs "on your own model provider" and names the
9493 model and provider; the Usage page shows what they cost at the provider,
+60−34
11 ---
22 title: Usage and billing
3−description: What g1t agents cost, how a workspace pays for them, and what is free.
3+description: What g1t costs, how a workspace pays, the limits that keep unpaid usage in check, and enterprise billing.
44 ---
5−
6−> **Free while g1t is being built out.** For now, using g1t costs nothing:
7−> agents, reviews, checks and workflows. Bring your own model provider and
8−> its usage is billed by that provider, not by g1t. Runs are still recorded
9−> with what they cost, so the Usage page shows what you are using. This is
10−> for now, not forever: the pricing below is how g1t will charge once it
11−> starts, and we will say so well before anything is charged.
125
136 Hosting repositories, issues, pull requests, review and your own agent cost
14−nothing on g1t. What costs money is g1t's own agents: each run uses a
15−model, and a workspace pays for the runs on its repositories from credit it
16−buys in advance. There is no seat price.
7+nothing on g1t. What costs money is what g1t runs for you: its agents'
8+models, the sandboxes they and your checks run in, and deployed apps. Each
9+is charged at what it costs g1t plus a set markup, after it is used, to the
10+workspace that owns the repository. There is no seat price.
1711
1812 Some features are paid for with a monthly plan the workspace turns on, and
1913 are never free, including while the rest of g1t is. See
7973 it, plus 20%. A small change costs a few cents.
8074
8175 Work a workspace routes to [its own model providers](/guides/models/) is
82−paid for at those providers instead, and each such run here will be a flat
83−$0.10 for the sandbox and orchestration once pricing starts (nothing while
84−g1t is being built out).
76+paid for at those providers instead. Each such run here is a flat $0.10
77+for g1t's orchestration, plus its [sandbox time](#sandbox-time).
8578
8679 The charge goes to the workspace that owns the repository, whoever
8780 assigned the issue. That is why only members of a workspace can put g1t
134127 | | |
135128 | --- | --- |
136129 | Free each month | 500 minutes (calendar month, UTC) |
137−| Past that | $0.003 a minute, by the second, at today's cost |
138−| What it costs g1t | about $0.0013 a minute (Containers, standard-1) |
130+| Past that | about $0.002 a minute, by the second |
131+| What it costs g1t | about $0.0009 a minute (Containers, standard-1, at the CPU sandboxes really use) |
139132
140−Both follow what Cloudflare bills; see [How prices are set](#how-prices-are-set).
133+Both follow what Cloudflare bills, so they move; today's exact figures are
134+on [g1t.sh/pricing](https://g1t.sh/pricing). See
135+[How prices are set](#how-prices-are-set).
141136
142137 Deploy builds are not counted here: [Deployments](/guides/deployments/)
143138 charges them by the second on its own plan.
144139
145140 Each sandbox is one line on the [statement](#the-statement), such as
146141 *Checks on acme/api#12: 3m 12s of sandbox time*, with whether it fell
147−within the free minutes. While g1t is being built out it is recorded but
148−not charged.
142+within the free minutes.
149143
150144 ## Usage limits
151145
164158 again, g1t rebuilds each one from the commit it was serving, by itself.
165159
166160 What counts is this month's usage (UTC), each item at what it cost g1t or
167−what it is charged, whichever is more, less what was paid this month. It
168−counts while g1t is free too: free is a price of nothing, not a way around
169−the limit.
161+what it is charged, whichever is more, less what was paid this month. Even
162+usage that is free to you, such as the free minutes, counts at its cost:
163+the limit is about what g1t has spent on a workspace's behalf.
170164
171165 | Workspace | Limit |
172166 | --- | --- |
173167 | **New**: has not paid g1t yet | $3: the free allowances and a little more |
174168 | **Paid**: has paid g1t | twice what it has paid, from $25 up to $1,000 |
175169 | **Reviewed** | what g1t set for it, after talking with you |
170+| **Comped** | none: g1t covers it |
171+
172+The limit is there to stop accounts that will never pay, not to slow down
173+ones that do. So:
174+
175+- **With a card on file, work does not stop.** As a workspace nears its
176+ limit (80%), g1t charges its card for what it owes. That payment lowers
177+ what is owed and raises the limit, since the limit grows with what a
178+ workspace has paid. A workspace that pays as it goes keeps going.
179+- **A declined card stops work** until it is paid, with a message saying
180+ so, and the pull requests that were waiting say **Needs you**. Paying
181+ under Billing with another card clears it at once.
182+- **Your own spend limit means stop.** An owner can set a lower monthly
183+ limit under **Settings → Billing → Usage limit**. At that one, g1t stops
184+ work and does not charge the card past it.
176185
177−Payments in test mode are not money, so they do not raise the limit. To
178−go past $1,000, write to support.
186+Payments in test mode are not money: they neither lower what is owed nor
187+raise the limit, and automatic charges only happen with live payments.
188+Credits g1t gives, such as refunds, lower what is owed but do not raise
189+the limit. To go past $1,000, write to support.
190+
191+## Enterprises and custom terms
179192
180−At 80% the Billing page turns amber and says how close the workspace is.
181−An owner can set a lower **spend limit** of their own under **Settings →
182−Billing → Usage limit**; work stops at whichever is lower.
193+Some accounts are billed differently, set up by g1t with you:
183194
195+- **Enterprise**: one billing account paying for several workspaces, as
196+ GitHub Enterprise does. Their usage and payments count together, against
197+ one limit, on one set of terms, and each workspace's Billing page says
198+ which enterprise pays for it.
199+- **Comped**: g1t covers the account's usage. Usage is still recorded with
200+ what it cost, so the Usage page stays accurate, and paid features are on
201+ without a plan.
202+- **Custom**: a discount on every usage charge, a limit of its own, or
203+ both, sometimes until a date, after which standard terms apply.
204+
205+Each change is made by g1t staff in g1t's billing console and recorded with
206+who made it and why. To ask for one, write to support.
207+
184208 ## Add credit
185209
186−Only an owner of the workspace can add credit.
210+Credit is a payment in advance: it pays for usage as it happens, and lowers
211+what the workspace owes against its limit. It is never needed to start
212+work. Only an owner of the workspace can add credit.
187213
188214 1. Open the workspace's **Settings → Billing**, `g1t.sh/<workspace>/-/billing`.
189215 2. Under **Add credit by card**, choose an amount: $10, $25, $50 or $100.
197223 test card `4242 4242 4242 4242` with any future date and any code. The
198224 Billing page says when payments are in test mode.
199225
200−## When credit runs out
226+## When work is stopped
201227
202−With no credit, g1t agents do not start. Assigning an issue, planning, or
203−asking for a review is refused with `402` and a message saying the
204−workspace has no agent credit:
228+A workspace at its limit, or with a declined card, starts nothing new.
229+Assigning an issue, planning, or asking for a review is refused with `402`
230+and the reason:
205231
206232 ```json
207233 {
208234 "error": {
209235 "code": "payment_required",
210− "message": "The acme workspace has no agent credit. An owner can add some under Billing on the workspace's page."
236+ "message": "The acme workspace reached its $3.00 limit for usage not yet paid for, so its sandboxes, builds and apps are stopped. The limit grows as a workspace pays g1t; an owner can pay under Billing, or write to support to have it raised."
211237 }
212238 }
213239 ```
214240
215241 A step g1t would take by itself, such as a revision or a review, stops
216242 instead, and the pull request says **Needs you** with the reason. Runs
217−already under way finish, so a balance can dip slightly below zero.
243+already under way finish, so usage can go slightly past the limit.
218244
219245 ## The Usage page
220246
+14−0
1+.DS_Store
2+.env
3+/node_modules/
4+*.tsbuildinfo
5+
6+# React Router
7+/.react-router/
8+/build/
9+
10+# Cloudflare
11+.mf
12+.wrangler
13+.dev.vars*
14+worker-configuration.d.ts
+71−0
1+# sudo
2+
3+g1t's staff console, at <https://sudo.g1t.sh>. Staff use it to manage how
4+accounts pay: comp a workspace, set custom terms (a discount, a ceiling on
5+unpaid usage, an end date), create Enterprise accounts that pay for several
6+workspaces, move workspaces on and off them, issue credits, and see where
7+every account stands this month with its ledger and audit log.
8+
9+It holds no data. Everything goes to the billing service's staff methods
10+(`admin_*`, see `BillingAdminApi` in `packages/contracts/src/billing.ts`),
11+and each change is recorded there with the staff member's email.
12+
13+## How it is locked
14+
15+1. **Cloudflare Access** sits in front of `sudo.g1t.sh` and signs people in.
16+2. **The worker checks Access's work** on every request, the stylesheet
17+ included (`run_worker_first`): it verifies the `Cf-Access-Jwt-Assertion`
18+ JWT itself (RS256 against the team's published keys, audience, issuer,
19+ expiry), then requires its email to be in `STAFF_EMAILS`. That email is
20+ who every change is recorded as. See `app/lib/access.ts`.
21+3. **It fails closed.** Until `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` and
22+ `STAFF_EMAILS` are all set, every request gets a 403 saying sudo is not
23+ configured.
24+4. **Changes** are POSTs only, and only from sudo's own pages (`Origin`, or
25+ `Referer`, must be `https://sudo.g1t.sh`). Terms, enterprise moves and new
26+ enterprises show a confirmation step first; a credit needs the workspace's
27+ slug typed out.
28+5. **The pages ship no JavaScript.** The content security policy forbids
29+ every script and inline style; responses are `no-store`, `noindex` and
30+ cannot be framed. The worker has no `workers.dev` address or preview URLs.
31+
32+## Setting up Access (once, in the Cloudflare dashboard)
33+
34+1. **Zero Trust → Access → Applications → Add an application → Self-hosted.**
35+ - Application name: `sudo`.
36+ - Session duration: short, such as 8 hours.
37+ - Public hostname: `sudo.g1t.sh` (path empty, so it covers everything).
38+2. **Add a policy:** action *Allow*, include *Emails* → the owner's address
39+ (the same addresses as `STAFF_EMAILS`). Add more staff here *and* in
40+ `STAFF_EMAILS`; either one alone is not enough.
41+3. Save, then open the application's **Overview** (or *Basic information*)
42+ and copy the **Application Audience (AUD) tag**.
43+4. Find the **team domain** under **Zero Trust → Settings → Custom pages**
44+ (or *Team name and domain*): it looks like `<team>.cloudflareaccess.com`.
45+5. Put both into `wrangler.jsonc`:
46+
47+ ```jsonc
48+ "vars": {
49+ "ACCESS_TEAM_DOMAIN": "<team>.cloudflareaccess.com",
50+ "ACCESS_AUD": "<the AUD tag>",
51+ "STAFF_EMAILS": "syntaqx@gmail.com"
52+ }
53+ ```
54+
55+6. Deploy: `scripts/deploy.sh sudo` (after `billing`, whose `admin_*`
56+ methods it calls).
57+
58+Visit <https://sudo.g1t.sh>: Access asks you to sign in, then the accounts
59+list opens. Anyone else gets Access's own refusal; anyone Access lets in who
60+is not in `STAFF_EMAILS` gets a 403 from the worker.
61+
62+## Working on it
63+
64+```sh
65+npm run typecheck -w @g1t/sudo
66+npm test -w @g1t/sudo # JWT verification, forms, money
67+npm run build -w @g1t/sudo
68+```
69+
70+`npm run dev` serves the pages, but every request is refused without a real
71+Access token, by design.
+60−0
1+@import "tailwindcss" source(".");
2+@import "@g1t/theme/tokens.css";
3+
4+/* Tailwind's names for the shared g1t tokens, as in apps/web. */
5+@theme {
6+ --font-sans: var(--g1t-font-sans);
7+ --font-mono: var(--g1t-font-mono);
8+
9+ --color-bg: var(--g1t-bg);
10+ --color-surface: var(--g1t-surface);
11+ --color-raised: var(--g1t-raised);
12+ --color-line: var(--g1t-line);
13+ --color-line-strong: var(--g1t-line-strong);
14+
15+ --color-fg: var(--g1t-fg);
16+ --color-fg-soft: var(--g1t-fg-soft);
17+ --color-muted: var(--g1t-muted);
18+ --color-faint: var(--g1t-faint);
19+
20+ --color-accent: var(--g1t-accent);
21+ --color-accent-dim: var(--g1t-accent-dim);
22+
23+ --color-info: var(--g1t-info);
24+ --color-merged: var(--g1t-merged);
25+ --color-warn: var(--g1t-warn);
26+ --color-danger: var(--g1t-danger);
27+}
28+
29+html {
30+ color-scheme: dark;
31+}
32+
33+body {
34+ @apply bg-bg font-sans text-fg antialiased;
35+ font-feature-settings: "cv11", "ss01";
36+}
37+
38+::selection {
39+ background: color-mix(in srgb, var(--color-merged) 35%, transparent);
40+}
41+
42+@layer base {
43+ :focus-visible {
44+ outline: 2px solid var(--color-merged);
45+ outline-offset: 2px;
46+ }
47+
48+ .grid > * {
49+ min-width: 0;
50+ }
51+
52+ :root {
53+ accent-color: var(--color-merged);
54+ }
55+
56+ /* Figures line up in columns of money. */
57+ .tabular {
58+ font-variant-numeric: tabular-nums;
59+ }
60+}
+31−0
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.
4+ */
5+export function Mark({ className, tight = false }: { className?: string; tight?: boolean }) {
6+ 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>
14+ </svg>
15+ );
16+}
17+
18+/** The lockup, with sudo's badge beside it. */
19+export function Logo({ className = "text-[1.3rem]" }: { className?: string }) {
20+ return (
21+ <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>
25+ </span>
26+ <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+ sudo
28+ </span>
29+ </span>
30+ );
31+}
+255−0
1+/**
2+ * sudo's building blocks, after apps/web/app/components/ui. None of them
3+ * sets a `style` attribute: the content security policy allows no inline
4+ * styles, so sizes and colours that vary are drawn as SVG attributes.
5+ */
6+import { AlertTriangle, CheckCircle2, Info } from "lucide-react";
7+import type { ComponentProps, ReactNode } from "react";
8+import { Link, type LinkProps } from "react-router";
9+
10+import type { Limit, PayingAccount, Terms, Trust } from "@g1t/contracts";
11+
12+import { usd } from "~/lib/money";
13+
14+export function Field({
15+ label,
16+ hint,
17+ children,
18+ className = "",
19+}: {
20+ label: string;
21+ hint?: ReactNode;
22+ children: ReactNode;
23+ className?: string;
24+}) {
25+ return (
26+ <label className={`block ${className}`}>
27+ <span className="mb-1.5 block text-sm font-medium text-muted">{label}</span>
28+ {children}
29+ {hint && <span className="mt-1.5 block text-xs text-faint">{hint}</span>}
30+ </label>
31+ );
32+}
33+
34+const CONTROL =
35+ "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-merged/60";
36+
37+/** Tells password managers that a field is not a login. */
38+const NOT_A_CREDENTIAL = {
39+ autoComplete: "off",
40+ "data-1p-ignore": true,
41+ "data-lpignore": "true",
42+ "data-bwignore": true,
43+ "data-form-type": "other",
44+};
45+
46+export function Input({ className = "", ...props }: ComponentProps<"input">) {
47+ return <input {...NOT_A_CREDENTIAL} {...props} className={`${CONTROL} ${className}`} />;
48+}
49+
50+export function Textarea({ className = "", ...props }: ComponentProps<"textarea">) {
51+ return <textarea {...NOT_A_CREDENTIAL} {...props} className={`${CONTROL} ${className}`} />;
52+}
53+
54+export function Select({ className = "", ...props }: ComponentProps<"select">) {
55+ return <select {...props} className={`${CONTROL} ${className}`} />;
56+}
57+
58+type Variant = "primary" | "quiet" | "danger" | "lavender";
59+
60+const BUTTON_BASE =
61+ "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium whitespace-nowrap transition-colors disabled:opacity-50";
62+
63+const BUTTON_VARIANTS: Record<Variant, string> = {
64+ primary: "bg-fg text-bg hover:bg-white",
65+ lavender: "bg-merged text-bg hover:bg-[#c8bdff]",
66+ quiet: "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
67+ danger: "border border-danger/40 text-danger hover:border-danger/70 hover:bg-danger/10",
68+};
69+
70+export function Button({ variant = "primary", className = "", ...props }: ComponentProps<"button"> & { variant?: Variant }) {
71+ return <button {...props} className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]} ${className}`} />;
72+}
73+
74+export function ButtonLink({ variant = "primary", className = "", ...props }: LinkProps & { variant?: Variant }) {
75+ return <Link {...props} className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]} ${className}`} />;
76+}
77+
78+/** A bordered panel with a heading. */
79+export function Section({
80+ id,
81+ title,
82+ description,
83+ actions,
84+ children,
85+ className = "",
86+}: {
87+ id?: string;
88+ title: string;
89+ description?: ReactNode;
90+ actions?: ReactNode;
91+ children: ReactNode;
92+ className?: string;
93+}) {
94+ return (
95+ <section id={id} className={`scroll-mt-20 rounded-lg border border-line bg-surface ${className}`}>
96+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-line px-4 py-3 sm:px-5">
97+ <div>
98+ <h2 className="text-[0.9375rem] font-semibold tracking-tight">{title}</h2>
99+ {description && <p className="mt-0.5 text-sm text-muted">{description}</p>}
100+ </div>
101+ {actions}
102+ </header>
103+ <div className="p-4 sm:p-5">{children}</div>
104+ </section>
105+ );
106+}
107+
108+export function EmptyState({ title, children }: { title: string; children?: ReactNode }) {
109+ return (
110+ <div className="rounded-lg border border-dashed border-line px-6 py-10 text-center">
111+ <p className="font-medium">{title}</p>
112+ {children && <div className="mt-1.5 text-sm text-muted">{children}</div>}
113+ </div>
114+ );
115+}
116+
117+const NOTICE = {
118+ ok: { icon: CheckCircle2, className: "border-accent/30 bg-accent/8 text-accent" },
119+ error: { icon: AlertTriangle, className: "border-danger/30 bg-danger/8 text-danger" },
120+ warn: { icon: AlertTriangle, className: "border-warn/30 bg-warn/8 text-warn" },
121+ info: { icon: Info, className: "border-merged/30 bg-merged/8 text-merged" },
122+} as const;
123+
124+export function Notice({ tone, children }: { tone: keyof typeof NOTICE; children: ReactNode }) {
125+ const { icon: Icon, className } = NOTICE[tone];
126+ return (
127+ <div role={tone === "error" ? "alert" : "status"} className={`flex gap-2.5 rounded-md border px-3.5 py-2.5 text-sm ${className}`}>
128+ <Icon size={16} className="mt-0.5 shrink-0" />
129+ <div className="min-w-0 text-fg-soft">{children}</div>
130+ </div>
131+ );
132+}
133+
134+/** A small label; `tone` colours it. */
135+export function Badge({ tone = "plain", children }: { tone?: "plain" | "lavender" | "mint" | "warn" | "danger" | "info"; children: ReactNode }) {
136+ const tones = {
137+ plain: "border-line text-muted",
138+ lavender: "border-merged/35 bg-merged/10 text-merged",
139+ mint: "border-accent/30 bg-accent/8 text-accent",
140+ warn: "border-warn/35 bg-warn/10 text-warn",
141+ danger: "border-danger/35 bg-danger/10 text-danger",
142+ info: "border-info/35 bg-info/10 text-info",
143+ };
144+ return (
145+ <span className={`inline-flex items-center rounded-full border px-2 py-px text-xs font-medium whitespace-nowrap ${tones[tone]}`}>
146+ {children}
147+ </span>
148+ );
149+}
150+
151+export function KindBadge({ kind }: { kind: PayingAccount["kind"] }) {
152+ return kind === "enterprise" ? <Badge tone="lavender">Enterprise</Badge> : <Badge>Workspace</Badge>;
153+}
154+
155+export function TermsBadge({ terms }: { terms: Terms }) {
156+ if (terms.kind === "comped") return <Badge tone="mint">Comped</Badge>;
157+ if (terms.kind === "custom") {
158+ return <Badge tone="info">Custom{terms.discountPercent > 0 ? ` −${terms.discountPercent}%` : ""}</Badge>;
159+ }
160+ return <Badge>Standard</Badge>;
161+}
162+
163+const TRUST: Record<Trust, { label: string; tone: "plain" | "lavender" | "mint" | "info" }> = {
164+ new: { label: "New", tone: "plain" },
165+ paid: { label: "Paid", tone: "info" },
166+ reviewed: { label: "Reviewed", tone: "mint" },
167+ internal: { label: "Internal", tone: "lavender" },
168+};
169+
170+export function TrustBadge({ trust }: { trust: Trust }) {
171+ const { label, tone } = TRUST[trust] ?? { label: trust, tone: "plain" };
172+ return <Badge tone={tone}>{label}</Badge>;
173+}
174+
175+const STATE = {
176+ ok: { label: "OK", fill: "var(--g1t-accent)", text: "text-accent" },
177+ warning: { label: "Warning", fill: "var(--g1t-warn)", text: "text-warn" },
178+ stopped: { label: "Stopped", fill: "var(--g1t-danger)", text: "text-danger" },
179+} as const;
180+
181+export function StateBadge({ state }: { state: Limit["state"] }) {
182+ const tone = state === "stopped" ? "danger" : state === "warning" ? "warn" : "mint";
183+ return <Badge tone={tone}>{STATE[state].label}</Badge>;
184+}
185+
186+/**
187+ * Unpaid usage this month against the ceiling, as a bar coloured by where
188+ * it stands. An account with no ceiling (g1t's own) shows the figure only.
189+ */
190+export function ExposureBar({ limit, wide = false }: { limit: Limit; wide?: boolean }) {
191+ const { exposureMicros, ceilingMicros, state } = limit;
192+ const share = ceilingMicros && ceilingMicros > 0 ? Math.min(1, Math.max(0, exposureMicros / ceilingMicros)) : 0;
193+ const percent = ceilingMicros && ceilingMicros > 0 ? Math.round((exposureMicros / ceilingMicros) * 100) : null;
194+ return (
195+ <div className={wide ? "w-full" : "w-40 max-w-full"}>
196+ <div className="flex items-baseline justify-between gap-2 text-xs">
197+ <span className="tabular font-medium text-fg-soft">
198+ {usd(exposureMicros)}
199+ <span className="font-normal text-faint"> / {ceilingMicros == null ? "no ceiling" : usd(ceilingMicros)}</span>
200+ </span>
201+ {percent != null && <span className={`tabular ${STATE[state].text}`}>{percent}%</span>}
202+ </div>
203+ <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="mt-1.5 block h-1.5 w-full" role="img" aria-label={`${STATE[state].label}: ${percent ?? 0}% of the ceiling`}>
204+ <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--g1t-raised)" />
205+ {ceilingMicros != null && share > 0 && (
206+ <rect x="0" y="0" width={Math.max(2, share * 100)} height="4" rx="2" fill={STATE[state].fill} />
207+ )}
208+ </svg>
209+ </div>
210+ );
211+}
212+
213+/** A label and a figure, for the strip of totals over a page. */
214+export function Stat({ label, value, hint, tone }: { label: string; value: ReactNode; hint?: ReactNode; tone?: "danger" | "warn" | "mint" }) {
215+ const color = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : tone === "mint" ? "text-accent" : "text-fg";
216+ return (
217+ <div className="rounded-lg border border-line bg-surface px-4 py-3">
218+ <p className="text-xs text-muted">{label}</p>
219+ <p className={`tabular mt-1 text-lg font-semibold tracking-tight ${color}`}>{value}</p>
220+ {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>}
221+ </div>
222+ );
223+}
224+
225+const DATE = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });
226+const DATE_TIME = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" });
227+
228+/** A timestamp, in UTC, as staff compare notes across time zones. */
229+export function When({ at, time = false }: { at: string | null | undefined; time?: boolean }) {
230+ if (!at) return <span className="text-faint">—</span>;
231+ const date = new Date(at);
232+ if (Number.isNaN(date.getTime())) return <span>{at}</span>;
233+ return (
234+ <time dateTime={date.toISOString()} title={date.toISOString()}>
235+ {(time ? DATE_TIME : DATE).format(date)}
236+ {time && <span className="text-faint"> UTC</span>}
237+ </time>
238+ );
239+}
240+
241+/** A letter avatar, drawn as SVG so its colour needs no inline style. */
242+export function Avatar({ name, size = 20, square = true }: { name: string; size?: number; square?: boolean }) {
243+ const hues = [82, 200, 262, 28, 330, 160];
244+ let hash = 0;
245+ for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
246+ const hue = hues[Math.abs(hash) % hues.length];
247+ return (
248+ <svg width={size} height={size} viewBox="0 0 20 20" aria-hidden="true" className="shrink-0">
249+ <rect width="20" height="20" rx={square ? 5 : 10} fill={`oklch(0.4 0.09 ${hue})`} />
250+ <text x="10" y="14.2" textAnchor="middle" fontSize="11" fontWeight="600" fontFamily="var(--g1t-font-mono)" fill={`oklch(0.93 0.08 ${hue})`}>
251+ {(name[0] ?? "?").toUpperCase()}
252+ </text>
253+ </svg>
254+ );
255+}
+25−0
1+import { renderToReadableStream } from "react-dom/server";
2+import { type EntryContext, ServerRouter } from "react-router";
3+
4+/**
5+ * Renders the whole page before sending it. sudo ships no JavaScript, so
6+ * there is nothing to stream into and no inline script to allow.
7+ */
8+export default async function handleRequest(
9+ request: Request,
10+ responseStatusCode: number,
11+ responseHeaders: Headers,
12+ routerContext: EntryContext,
13+) {
14+ let status = responseStatusCode;
15+ const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
16+ signal: request.signal,
17+ onError(error: unknown) {
18+ status = 500;
19+ console.error(error);
20+ },
21+ });
22+ await body.allReady;
23+ responseHeaders.set("content-type", "text/html; charset=utf-8");
24+ return new Response(body, { headers: responseHeaders, status });
25+}
+206−0
1+import assert from "node:assert/strict";
2+import { beforeEach, test } from "node:test";
3+
4+import {
5+ type AccessSettings,
6+ authorize,
7+ clearKeyCache,
8+ isSameOrigin,
9+ readSettings,
10+ verifyAccessJwt,
11+} from "./access.ts";
12+
13+const TEAM = "g1t.cloudflareaccess.com";
14+const AUD = "a".repeat(64);
15+const SETTINGS: AccessSettings = { teamDomain: TEAM, aud: AUD, staff: ["owner@g1t.sh"] };
16+const NOW = Date.UTC(2026, 9, 4, 12, 0, 0);
17+const NOW_SECONDS = Math.floor(NOW / 1000);
18+const RSA = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } as const;
19+
20+async function rsaKey() {
21+ return crypto.subtle.generateKey(
22+ { ...RSA, modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) },
23+ true,
24+ ["sign", "verify"],
25+ );
26+}
27+
28+const signing = await rsaKey();
29+const stranger = await rsaKey();
30+const publicJwk = { ...(await crypto.subtle.exportKey("jwk", signing.publicKey)), kid: "key-1", alg: "RS256", use: "sig" };
31+
32+function b64url(bytes: Uint8Array | string): string {
33+ const raw = typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes;
34+ let binary = "";
35+ for (const byte of raw) binary += String.fromCharCode(byte);
36+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
37+}
38+
39+async function sign(
40+ claims: Record<string, unknown>,
41+ { key = signing.privateKey, kid = "key-1", alg = "RS256" }: { key?: CryptoKey; kid?: string; alg?: string } = {},
42+): Promise<string> {
43+ const body = `${b64url(JSON.stringify({ alg, kid, typ: "JWT" }))}.${b64url(JSON.stringify(claims))}`;
44+ const signature = new Uint8Array(await crypto.subtle.sign(RSA, key, new TextEncoder().encode(body)));
45+ return `${body}.${b64url(signature)}`;
46+}
47+
48+function claims(overrides: Record<string, unknown> = {}): Record<string, unknown> {
49+ return {
50+ iss: `https://${TEAM}`,
51+ aud: [AUD],
52+ email: "owner@g1t.sh",
53+ sub: "user-1",
54+ iat: NOW_SECONDS - 60,
55+ nbf: NOW_SECONDS - 60,
56+ exp: NOW_SECONDS + 3600,
57+ ...overrides,
58+ };
59+}
60+
61+let fetches: string[] = [];
62+async function fetcher(url: string): Promise<Response> {
63+ fetches.push(url);
64+ return Response.json({ keys: [publicJwk], public_cert: { kid: "key-1", cert: "" } });
65+}
66+
67+function request(token?: string): Request {
68+ return new Request("https://sudo.g1t.sh/", {
69+ headers: token ? { "cf-access-jwt-assertion": token } : {},
70+ });
71+}
72+
73+beforeEach(() => {
74+ clearKeyCache();
75+ fetches = [];
76+});
77+
78+test("a valid token from staff is let in, by its email", async () => {
79+ const result = await authorize(request(await sign(claims())), SETTINGS, { fetcher, now: NOW });
80+ assert.deepEqual(result, { ok: true, email: "owner@g1t.sh" });
81+ assert.deepEqual(fetches, [`https://${TEAM}/cdn-cgi/access/certs`]);
82+});
83+
84+test("an audience given as a string is accepted too", async () => {
85+ const result = await verifyAccessJwt(await sign(claims({ aud: AUD })), SETTINGS, { fetcher, now: NOW });
86+ assert.equal(result.ok, true);
87+});
88+
89+test("a token for another Access application is refused", async () => {
90+ const result = await verifyAccessJwt(await sign(claims({ aud: ["b".repeat(64)] })), SETTINGS, { fetcher, now: NOW });
91+ assert.deepEqual(result, { ok: false, reason: "wrong audience" });
92+});
93+
94+test("a token from another team is refused", async () => {
95+ const result = await verifyAccessJwt(
96+ await sign(claims({ iss: "https://evil.cloudflareaccess.com" })),
97+ SETTINGS,
98+ { fetcher, now: NOW },
99+ );
100+ assert.deepEqual(result, { ok: false, reason: "wrong issuer" });
101+});
102+
103+test("an expired token is refused", async () => {
104+ const result = await verifyAccessJwt(await sign(claims({ exp: NOW_SECONDS - 3600 })), SETTINGS, { fetcher, now: NOW });
105+ assert.deepEqual(result, { ok: false, reason: "expired" });
106+});
107+
108+test("a token without an expiry is refused", async () => {
109+ const result = await verifyAccessJwt(await sign(claims({ exp: undefined })), SETTINGS, { fetcher, now: NOW });
110+ assert.deepEqual(result, { ok: false, reason: "no expiry" });
111+});
112+
113+test("a token not valid yet is refused", async () => {
114+ const result = await verifyAccessJwt(await sign(claims({ nbf: NOW_SECONDS + 3600 })), SETTINGS, { fetcher, now: NOW });
115+ assert.deepEqual(result, { ok: false, reason: "not yet valid" });
116+});
117+
118+test("a token signed by another key under a known key id is refused", async () => {
119+ const result = await verifyAccessJwt(await sign(claims(), { key: stranger.privateKey }), SETTINGS, { fetcher, now: NOW });
120+ assert.deepEqual(result, { ok: false, reason: "bad signature" });
121+});
122+
123+test("a token whose claims were changed after signing is refused", async () => {
124+ const token = await sign(claims({ email: "intern@g1t.sh" }));
125+ const [header, , signature] = token.split(".");
126+ const forged = `${header}.${b64url(JSON.stringify(claims()))}.${signature}`;
127+ const result = await authorize(request(forged), SETTINGS, { fetcher, now: NOW });
128+ assert.deepEqual(result, { ok: false, reason: "bad signature" });
129+});
130+
131+test("a token signed by a key the team does not publish is refused", async () => {
132+ const result = await verifyAccessJwt(await sign(claims(), { kid: "key-9" }), SETTINGS, { fetcher, now: NOW });
133+ assert.deepEqual(result, { ok: false, reason: "unknown signing key" });
134+});
135+
136+test("alg none and HS256 are refused before any key is fetched", async () => {
137+ for (const alg of ["none", "HS256"]) {
138+ const result = await verifyAccessJwt(await sign(claims(), { alg }), SETTINGS, { fetcher, now: NOW });
139+ assert.deepEqual(result, { ok: false, reason: "unexpected algorithm" });
140+ }
141+ assert.deepEqual(fetches, []);
142+});
143+
144+test("a valid token whose email is not staff is refused", async () => {
145+ const result = await authorize(request(await sign(claims({ email: "someone@example.com" }))), SETTINGS, {
146+ fetcher,
147+ now: NOW,
148+ });
149+ assert.deepEqual(result, { ok: false, reason: "not staff", email: "someone@example.com" });
150+});
151+
152+test("a service token, which has no email, is refused", async () => {
153+ const result = await authorize(request(await sign(claims({ email: undefined }))), SETTINGS, { fetcher, now: NOW });
154+ assert.deepEqual(result, { ok: false, reason: "token has no email" });
155+});
156+
157+test("staff emails match without regard to case", async () => {
158+ const result = await authorize(request(await sign(claims({ email: "Owner@G1T.sh" }))), SETTINGS, { fetcher, now: NOW });
159+ assert.deepEqual(result, { ok: true, email: "owner@g1t.sh" });
160+});
161+
162+test("a request without a token is refused", async () => {
163+ assert.deepEqual(await authorize(request(), SETTINGS, { fetcher, now: NOW }), { ok: false, reason: "no Access token" });
164+});
165+
166+test("garbage is refused", async () => {
167+ for (const token of ["", "a.b", "a.b.c.d", "!!.??.**", "e30.e30.e30"]) {
168+ const result = await verifyAccessJwt(token, SETTINGS, { fetcher, now: NOW });
169+ assert.equal(result.ok, false, token);
170+ }
171+});
172+
173+test("the team's keys are fetched once and kept for a few minutes", async () => {
174+ await verifyAccessJwt(await sign(claims()), SETTINGS, { fetcher, now: NOW });
175+ await verifyAccessJwt(await sign(claims()), SETTINGS, { fetcher, now: NOW + 60_000 });
176+ assert.equal(fetches.length, 1);
177+ await verifyAccessJwt(await sign(claims()), SETTINGS, { fetcher, now: NOW + 10 * 60_000 });
178+ assert.equal(fetches.length, 2);
179+});
180+
181+test("when the keys cannot be fetched, nothing is let in", async () => {
182+ const down = async () => new Response("no", { status: 503 });
183+ const result = await verifyAccessJwt(await sign(claims()), SETTINGS, { fetcher: down, now: NOW });
184+ assert.deepEqual(result, { ok: false, reason: "unknown signing key" });
185+});
186+
187+test("sudo is closed until every setting is given", () => {
188+ const full = { ACCESS_TEAM_DOMAIN: "https://g1t.cloudflareaccess.com/", ACCESS_AUD: AUD, STAFF_EMAILS: "A@g1t.sh, b@g1t.sh" };
189+ assert.deepEqual(readSettings(full), { teamDomain: TEAM, aud: AUD, staff: ["a@g1t.sh", "b@g1t.sh"] });
190+ assert.equal(readSettings({ ...full, ACCESS_AUD: "" }), null);
191+ assert.equal(readSettings({ ...full, ACCESS_TEAM_DOMAIN: "" }), null);
192+ assert.equal(readSettings({ ...full, STAFF_EMAILS: " , " }), null);
193+ assert.equal(readSettings({ ...full, ACCESS_TEAM_DOMAIN: "evil.example.com" }), null);
194+ assert.equal(readSettings({}), null);
195+});
196+
197+test("changes are only taken from sudo's own pages", () => {
198+ const post = (headers: Record<string, string>) => new Request("https://sudo.g1t.sh/x", { method: "POST", headers });
199+ assert.equal(isSameOrigin(post({ origin: "https://sudo.g1t.sh" })), true);
200+ assert.equal(isSameOrigin(post({ referer: "https://sudo.g1t.sh/accounts/x" })), true);
201+ assert.equal(isSameOrigin(post({ origin: "https://evil.example" })), false);
202+ assert.equal(isSameOrigin(post({ origin: "null" })), false);
203+ assert.equal(isSameOrigin(post({ referer: "https://sudo.g1t.sh.evil.example/" })), false);
204+ assert.equal(isSameOrigin(post({ origin: "https://sudo.g1t.sh", "sec-fetch-site": "cross-site" })), false);
205+ assert.equal(isSameOrigin(post({})), false);
206+});
+258−0
1+/**
2+ * Who is asking: sudo sits behind Cloudflare Access, and checks Access's
3+ * work itself on every request. Access signs a JWT for each request it
4+ * lets through and sends it in `Cf-Access-Jwt-Assertion`; this verifies
5+ * its RS256 signature against the team's published keys, its audience,
6+ * issuer and lifetime, and then that its email belongs to g1t staff.
7+ *
8+ * Plain Web Crypto, no dependencies and no Workers imports, so the tests
9+ * run it under Node as it runs on Workers.
10+ */
11+
12+/** Where sudo is served; the only origin a change may be posted from. */
13+export const ORIGIN = "https://sudo.g1t.sh";
14+
15+/** How far clocks may disagree, in seconds. */
16+const LEEWAY_SECONDS = 30;
17+/** How long the team's keys are trusted before they are fetched again. */
18+const JWKS_TTL_MS = 5 * 60_000;
19+/** A token signed by a key not seen yet refetches the keys, at most this often. */
20+const JWKS_REFETCH_MS = 30_000;
21+/** Access tokens are a few kilobytes; anything far larger is not one. */
22+const MAX_TOKEN_LENGTH = 16_384;
23+
24+export type AccessSettings = {
25+ /** The Zero Trust team domain, such as `g1t.cloudflareaccess.com`. */
26+ teamDomain: string;
27+ /** The Access application's Audience (AUD) tag. */
28+ aud: string;
29+ /** Lowercased staff emails. */
30+ staff: string[];
31+};
32+
33+export type AccessEnv = {
34+ ACCESS_TEAM_DOMAIN?: string;
35+ ACCESS_AUD?: string;
36+ STAFF_EMAILS?: string;
37+};
38+
39+/**
40+ * Reads the settings, or null when any is missing or malformed: sudo then
41+ * refuses everything rather than guess.
42+ */
43+export function readSettings(env: AccessEnv): AccessSettings | null {
44+ const teamDomain = normalizeTeamDomain(env.ACCESS_TEAM_DOMAIN ?? "");
45+ const aud = (env.ACCESS_AUD ?? "").trim();
46+ const staff = parseStaff(env.STAFF_EMAILS ?? "");
47+ if (!teamDomain || !/^[A-Za-z0-9]{16,128}$/.test(aud) || staff.length === 0) return null;
48+ return { teamDomain, aud, staff };
49+}
50+
51+/**
52+ * `g1t.cloudflareaccess.com`, given with or without its scheme. Only a
53+ * Cloudflare Access team domain is accepted, since that is where the
54+ * signing keys are fetched from.
55+ */
56+export function normalizeTeamDomain(raw: string): string | null {
57+ const host = raw
58+ .trim()
59+ .toLowerCase()
60+ .replace(/^https:\/\//, "")
61+ .replace(/\/+$/, "");
62+ return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.cloudflareaccess\.com$/.test(host) ? host : null;
63+}
64+
65+export function parseStaff(raw: string): string[] {
66+ return raw
67+ .split(/[,\s]+/)
68+ .map((email) => email.trim().toLowerCase())
69+ .filter((email) => /^[^@\s]+@[^@\s]+$/.test(email));
70+}
71+
72+export type AccessClaims = {
73+ iss: string;
74+ aud: string | string[];
75+ exp: number;
76+ nbf?: number;
77+ iat?: number;
78+ sub?: string;
79+ email?: string;
80+ [claim: string]: unknown;
81+};
82+
83+export type Verified = { ok: true; claims: AccessClaims } | { ok: false; reason: string };
84+
85+export type VerifyOptions = {
86+ /** Fetches the team's keys; the global `fetch` unless a test gives another. */
87+ fetcher?: (url: string) => Promise<Response>;
88+ /** Milliseconds since the epoch. */
89+ now?: number;
90+};
91+
92+type KeySet = { keys: Map<string, CryptoKey>; fetchedAt: number };
93+
94+/** The team's signing keys, by team domain, kept briefly in memory. */
95+const keySets = new Map<string, KeySet>();
96+
97+/** Forgets the cached keys. For tests. */
98+export function clearKeyCache(): void {
99+ keySets.clear();
100+}
101+
102+const RSA = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } as const;
103+
104+async function fetchKeySet(
105+ teamDomain: string,
106+ fetcher: (url: string) => Promise<Response>,
107+ now: number,
108+): Promise<KeySet> {
109+ const response = await fetcher(`https://${teamDomain}/cdn-cgi/access/certs`);
110+ if (!response.ok) throw new Error(`Access keys answered ${response.status}`);
111+ const body = (await response.json()) as { keys?: unknown };
112+ const keys = new Map<string, CryptoKey>();
113+ for (const jwk of Array.isArray(body.keys) ? body.keys : []) {
114+ if (!jwk || typeof jwk !== "object") continue;
115+ const { kid, kty, n, e, alg, use } = jwk as Record<string, unknown>;
116+ if (typeof kid !== "string" || kty !== "RSA" || typeof n !== "string" || typeof e !== "string") continue;
117+ if (alg !== undefined && alg !== "RS256") continue;
118+ if (use !== undefined && use !== "sig") continue;
119+ try {
120+ keys.set(kid, await crypto.subtle.importKey("jwk", { kty, n, e }, RSA, false, ["verify"]));
121+ } catch {
122+ // A key that does not import is skipped; tokens signed by it fail.
123+ }
124+ }
125+ return { keys, fetchedAt: now };
126+}
127+
128+/** The key a token names, fetching the team's keys when they are stale or it is new. */
129+async function keyFor(
130+ teamDomain: string,
131+ kid: string,
132+ fetcher: (url: string) => Promise<Response>,
133+ now: number,
134+): Promise<CryptoKey | null> {
135+ let set = keySets.get(teamDomain);
136+ const stale = !set || now - set.fetchedAt >= JWKS_TTL_MS;
137+ const unknown = set && !set.keys.has(kid) && now - set.fetchedAt >= JWKS_REFETCH_MS;
138+ if (stale || unknown) {
139+ try {
140+ set = await fetchKeySet(teamDomain, fetcher, now);
141+ keySets.set(teamDomain, set);
142+ } catch {
143+ // Keys that could not be refreshed are not trusted past their time.
144+ if (stale) {
145+ keySets.delete(teamDomain);
146+ return null;
147+ }
148+ }
149+ }
150+ return set?.keys.get(kid) ?? null;
151+}
152+
153+function base64UrlBytes(segment: string): Uint8Array<ArrayBuffer> {
154+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
155+ const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
156+ const bytes = new Uint8Array(binary.length);
157+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
158+ return bytes;
159+}
160+
161+function decodeJson(segment: string): Record<string, unknown> | null {
162+ try {
163+ const value: unknown = JSON.parse(new TextDecoder().decode(base64UrlBytes(segment)));
164+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
165+ } catch {
166+ return null;
167+ }
168+}
169+
170+/** Verifies an Access JWT: signature, algorithm, issuer, audience and lifetime. */
171+export async function verifyAccessJwt(
172+ token: string,
173+ settings: Pick<AccessSettings, "teamDomain" | "aud">,
174+ options: VerifyOptions = {},
175+): Promise<Verified> {
176+ const now = options.now ?? Date.now();
177+ const fetcher = options.fetcher ?? ((url: string) => fetch(url));
178+
179+ if (token.length > MAX_TOKEN_LENGTH) return { ok: false, reason: "token too long" };
180+ const parts = token.split(".");
181+ if (parts.length !== 3 || !parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part))) {
182+ return { ok: false, reason: "malformed token" };
183+ }
184+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
185+ const header = decodeJson(encodedHeader);
186+ const payload = decodeJson(encodedPayload);
187+ if (!header || !payload) return { ok: false, reason: "malformed token" };
188+ // Only RS256: never `none`, and never a symmetric algorithm keyed with a public key.
189+ if (header.alg !== "RS256") return { ok: false, reason: "unexpected algorithm" };
190+ if (typeof header.kid !== "string" || !header.kid) return { ok: false, reason: "no key id" };
191+
192+ const key = await keyFor(settings.teamDomain, header.kid, fetcher, now);
193+ if (!key) return { ok: false, reason: "unknown signing key" };
194+
195+ let signature: Uint8Array<ArrayBuffer>;
196+ try {
197+ signature = base64UrlBytes(encodedSignature);
198+ } catch {
199+ return { ok: false, reason: "malformed signature" };
200+ }
201+ const signed = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`);
202+ const valid = await crypto.subtle.verify(RSA, key, signature, signed);
203+ if (!valid) return { ok: false, reason: "bad signature" };
204+
205+ // Only now that the signature holds do the claims mean anything.
206+ if (payload.iss !== `https://${settings.teamDomain}`) return { ok: false, reason: "wrong issuer" };
207+ const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
208+ if (!audiences.includes(settings.aud)) return { ok: false, reason: "wrong audience" };
209+ const seconds = Math.floor(now / 1000);
210+ if (typeof payload.exp !== "number") return { ok: false, reason: "no expiry" };
211+ if (seconds >= payload.exp + LEEWAY_SECONDS) return { ok: false, reason: "expired" };
212+ if (payload.nbf !== undefined) {
213+ if (typeof payload.nbf !== "number") return { ok: false, reason: "malformed nbf" };
214+ if (seconds + LEEWAY_SECONDS < payload.nbf) return { ok: false, reason: "not yet valid" };
215+ }
216+ return { ok: true, claims: payload as AccessClaims };
217+}
218+
219+export type Authorized = { ok: true; email: string } | { ok: false; reason: string; email?: string };
220+
221+/**
222+ * Whether a request comes from g1t staff, through Access: a valid token
223+ * whose email is on the staff list. Service tokens carry no email and are
224+ * refused.
225+ */
226+export async function authorize(
227+ request: Request,
228+ settings: AccessSettings,
229+ options: VerifyOptions = {},
230+): Promise<Authorized> {
231+ const token = request.headers.get("cf-access-jwt-assertion");
232+ if (!token) return { ok: false, reason: "no Access token" };
233+ const verified = await verifyAccessJwt(token, settings, options);
234+ if (!verified.ok) return verified;
235+ const email = typeof verified.claims.email === "string" ? verified.claims.email.trim().toLowerCase() : "";
236+ if (!email) return { ok: false, reason: "token has no email" };
237+ if (!settings.staff.includes(email)) return { ok: false, reason: "not staff", email };
238+ return { ok: true, email };
239+}
240+
241+/**
242+ * Whether a change was posted from sudo's own pages: the browser's
243+ * `Origin`, or failing that its `Referer`, must be sudo's. A request that
244+ * says neither is refused.
245+ */
246+export function isSameOrigin(request: Request): boolean {
247+ const site = request.headers.get("sec-fetch-site");
248+ if (site !== null && site !== "same-origin") return false;
249+ const origin = request.headers.get("origin");
250+ if (origin !== null) return origin === ORIGIN;
251+ const referer = request.headers.get("referer");
252+ if (!referer) return false;
253+ try {
254+ return new URL(referer).origin === ORIGIN;
255+ } catch {
256+ return false;
257+ }
258+}
+56−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { parseCredit, parseSlugList, parseTerms } from "./forms.ts";
5+
6+const NOW = new Date("2026-10-04T12:00:00Z");
7+
8+function form(entries: Record<string, string>): FormData {
9+ const data = new FormData();
10+ for (const [name, value] of Object.entries(entries)) data.set(name, value);
11+ return data;
12+}
13+
14+test("comped terms keep a ceiling and an end date, and name who set them", () => {
15+ const result = parseTerms(form({ kind: "comped", note: "g1t's own", ceiling: "500", until: "2026-12-31", discount: "40" }), "owner@g1t.sh", NOW);
16+ assert.deepEqual(result, {
17+ ok: true,
18+ value: {
19+ kind: "comped",
20+ discountPercent: 0,
21+ ceilingMicros: 500_000_000,
22+ note: "g1t's own",
23+ until: "2026-12-31T23:59:59Z",
24+ setBy: "owner@g1t.sh",
25+ setAt: NOW.toISOString(),
26+ },
27+ });
28+});
29+
30+test("standard terms clear everything but the note", () => {
31+ const result = parseTerms(form({ kind: "standard", note: "back to normal", ceiling: "5", until: "2027-01-01" }), "a@g1t.sh", NOW);
32+ assert.ok(result.ok);
33+ assert.equal(result.value.ceilingMicros, null);
34+ assert.equal(result.value.until, null);
35+});
36+
37+test("terms are refused without a note, with a bad discount, or ending in the past", () => {
38+ assert.equal(parseTerms(form({ kind: "comped", note: "" }), "a", NOW).ok, false);
39+ assert.equal(parseTerms(form({ kind: "custom", note: "x", discount: "101" }), "a", NOW).ok, false);
40+ assert.equal(parseTerms(form({ kind: "custom", note: "x" }), "a", NOW).ok, false);
41+ assert.equal(parseTerms(form({ kind: "custom", note: "x", discount: "20", until: "2026-10-01" }), "a", NOW).ok, false);
42+ assert.equal(parseTerms(form({ kind: "custom", note: "x", discount: "20", until: "2026-02-30" }), "a", NOW).ok, false);
43+ assert.equal(parseTerms(form({ kind: "free", note: "x" }), "a", NOW).ok, false);
44+});
45+
46+test("workspace lists take commas, spaces and lines, once each", () => {
47+ assert.deepEqual(parseSlugList("acme, Acme-labs\nbeta beta"), { ok: true, value: ["acme", "acme-labs", "beta"] });
48+ assert.equal(parseSlugList("acme, bad--slug").ok, false);
49+ assert.equal(parseSlugList("../etc").ok, false);
50+});
51+
52+test("credits are positive and capped", () => {
53+ assert.deepEqual(parseCredit("25.50"), { ok: true, value: 25_500_000 });
54+ assert.equal(parseCredit("0").ok, false);
55+ assert.equal(parseCredit("10000.01").ok, false);
56+});
+118−0
1+/**
2+ * Reading sudo's forms. Everything typed is checked here before it goes
3+ * to the billing service, which checks it again.
4+ */
5+import type { Terms } from "@g1t/contracts";
6+
7+import { MICROS_PER_DOLLAR, parseDollars } from "./money.ts";
8+
9+/** A workspace slug, as identity allows them (GitHub's rules). */
10+const SLUG = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/;
11+/** An account id (`ws_<slug>`, `ent_…`) or a workspace slug. */
12+const ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/;
13+
14+/** The most one credit can be, against a slipped finger. */
15+export const MAX_CREDIT_MICROS = 10_000 * MICROS_PER_DOLLAR;
16+const MAX_NOTE = 500;
17+
18+export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
19+
20+export function text(form: FormData, name: string): string {
21+ const value = form.get(name);
22+ return typeof value === "string" ? value.trim() : "";
23+}
24+
25+/** The form's own fields, to carry through a confirmation step. */
26+export function fields(form: FormData, ...names: string[]): Record<string, string> {
27+ return Object.fromEntries(names.map((name) => [name, text(form, name)]));
28+}
29+
30+export function isSlug(value: string): boolean {
31+ return SLUG.test(value);
32+}
33+
34+export function isAccountId(value: string): boolean {
35+ return ACCOUNT_ID.test(value);
36+}
37+
38+export function parseSlug(raw: string): Parsed<string> {
39+ const slug = raw.trim().toLowerCase();
40+ return isSlug(slug) ? { ok: true, value: slug } : { ok: false, error: `“${raw}” is not a workspace slug.` };
41+}
42+
43+/** Slugs separated by commas, spaces or lines; each once. */
44+export function parseSlugList(raw: string): Parsed<string[]> {
45+ const slugs: string[] = [];
46+ for (const part of raw.split(/[\s,]+/).filter(Boolean)) {
47+ const slug = parseSlug(part);
48+ if (!slug.ok) return slug;
49+ if (!slugs.includes(slug.value)) slugs.push(slug.value);
50+ }
51+ return { ok: true, value: slugs };
52+}
53+
54+export function parseNote(raw: string): Parsed<string> {
55+ if (!raw) return { ok: false, error: "A note is required: say why, for whoever looks next." };
56+ if (raw.length > MAX_NOTE) return { ok: false, error: `Keep the note under ${MAX_NOTE} characters.` };
57+ return { ok: true, value: raw };
58+}
59+
60+/**
61+ * Terms from the terms form. Standard clears everything else; a ceiling
62+ * applies to comped and custom; a discount to custom only. An end date is
63+ * a day, and the terms last to its end, UTC.
64+ */
65+export function parseTerms(form: FormData, by: string, now = new Date()): Parsed<Terms> {
66+ const kind = text(form, "kind");
67+ if (kind !== "standard" && kind !== "comped" && kind !== "custom") return { ok: false, error: "Choose standard, comped or custom terms." };
68+ const note = parseNote(text(form, "note"));
69+ if (!note.ok) return note;
70+
71+ let discountPercent = 0;
72+ if (kind === "custom") {
73+ const raw = text(form, "discount");
74+ if (raw !== "") {
75+ if (!/^\d{1,3}$/.test(raw) || Number(raw) > 100) return { ok: false, error: "The discount is a whole percent from 0 to 100." };
76+ discountPercent = Number(raw);
77+ }
78+ }
79+
80+ let ceilingMicros: number | null = null;
81+ if (kind !== "standard") {
82+ const raw = text(form, "ceiling");
83+ if (raw !== "") {
84+ const micros = parseDollars(raw);
85+ if (micros == null) return { ok: false, error: "The ceiling is a dollar amount, such as 250 or 1,000.00." };
86+ ceilingMicros = micros;
87+ }
88+ }
89+ if (kind === "custom" && discountPercent === 0 && ceilingMicros == null) {
90+ return { ok: false, error: "Custom terms need a discount, a ceiling, or both." };
91+ }
92+
93+ let until: string | null = null;
94+ if (kind !== "standard") {
95+ const raw = text(form, "until");
96+ if (raw !== "") {
97+ const end = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T23:59:59Z`) : null;
98+ if (!end || Number.isNaN(end.getTime()) || end.toISOString().slice(0, 10) !== raw) {
99+ return { ok: false, error: "The end date is not a date." };
100+ }
101+ if (end.getTime() <= now.getTime()) return { ok: false, error: "The end date has to be in the future." };
102+ until = end.toISOString().replace(".000Z", "Z");
103+ }
104+ }
105+
106+ return {
107+ ok: true,
108+ value: { kind, discountPercent, ceilingMicros, note: note.value, until, setBy: by, setAt: now.toISOString() },
109+ };
110+}
111+
112+/** A credit's amount: more than nothing, and no more than the cap. */
113+export function parseCredit(raw: string): Parsed<number> {
114+ const micros = parseDollars(raw);
115+ if (micros == null || micros <= 0) return { ok: false, error: "The amount is dollars and cents, more than zero, such as 25 or 120.50." };
116+ if (micros > MAX_CREDIT_MICROS) return { ok: false, error: "One credit is at most $10,000. Issue more than one if it really is more." };
117+ return { ok: true, value: micros };
118+}
+85−0
1+/**
2+ * What every sudo response carries, and the page shown to whoever is
3+ * turned away. No Workers imports, so it can be tested under Node.
4+ */
5+
6+/**
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.
10+ */
11+export const CONTENT_SECURITY_POLICY = [
12+ "default-src 'none'",
13+ "script-src 'none'",
14+ "style-src 'self' https://fonts.googleapis.com",
15+ "font-src https://fonts.gstatic.com",
16+ "img-src 'self' data:",
17+ "form-action 'self'",
18+ "frame-ancestors 'none'",
19+ "base-uri 'none'",
20+ "upgrade-insecure-requests",
21+].join("; ");
22+
23+const HEADERS: Record<string, string> = {
24+ "cache-control": "no-store",
25+ "x-robots-tag": "noindex, nofollow, noarchive",
26+ "x-frame-options": "DENY",
27+ "x-content-type-options": "nosniff",
28+ // Same-origin keeps the Referer that the same-origin check falls back on.
29+ "referrer-policy": "same-origin",
30+ "strict-transport-security": "max-age=63072000; includeSubDomains",
31+ "cross-origin-opener-policy": "same-origin",
32+ "cross-origin-resource-policy": "same-origin",
33+ "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()",
34+};
35+
36+/** The response with sudo's headers; a policy it already set is kept. */
37+export function secure(response: Response): Response {
38+ const secured = new Response(response.body, response);
39+ for (const [name, value] of Object.entries(HEADERS)) secured.headers.set(name, value);
40+ if (!secured.headers.has("content-security-policy")) {
41+ secured.headers.set("content-security-policy", CONTENT_SECURITY_POLICY);
42+ }
43+ return secured;
44+}
45+
46+const DENIED_STYLE = `
47+:root{color-scheme:dark}
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}
50+main{max-width:28rem;padding:2rem 1rem;text-align:center}
51+.badge{display:inline-block;border:1px solid #b6a8ff66;color:#b6a8ff;background:#b6a8ff1a;border-radius:999px;
52+padding:.1rem .6rem;font:600 12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.02em}
53+h1{margin:1rem 0 .5rem;font-size:1.25rem;letter-spacing:-.01em}
54+p{margin:0;color:#a0a0a8}code{color:#ededef;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}`;
55+
56+let styleHash: Promise<string> | null = null;
57+
58+function hashOfStyle(): Promise<string> {
59+ styleHash ??= crypto.subtle
60+ .digest("SHA-256", new TextEncoder().encode(DENIED_STYLE))
61+ .then((digest) => btoa(String.fromCharCode(...new Uint8Array(digest))));
62+ return styleHash;
63+}
64+
65+function escapeHtml(text: string): string {
66+ return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
67+}
68+
69+/**
70+ * A self-contained page for a refusal: its one inline stylesheet is
71+ * allowed by its hash, and nothing else is.
72+ */
73+export async function denied(status: number, title: string, message: string): Promise<Response> {
74+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
75+<meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex, nofollow">
76+<title>${escapeHtml(title)} · sudo</title><style>${DENIED_STYLE}</style></head>
77+<body><main><span class="badge">sudo</span><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
78+ return new Response(html, {
79+ status,
80+ headers: {
81+ "content-type": "text/html; charset=utf-8",
82+ "content-security-policy": `default-src 'none'; style-src 'sha256-${await hashOfStyle()}'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`,
83+ },
84+ });
85+}
+22−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { dollarsField, parseDollars, usd } from "./money.ts";
5+
6+test("dollars read to the cent, and small amounts keep their fractions", () => {
7+ assert.equal(usd(1_250_500_000), "$1,250.50");
8+ assert.equal(usd(0), "$0.00");
9+ assert.equal(usd(12_345), "$0.0123");
10+ assert.equal(usd(-5_000_000), "−$5.00");
11+ assert.equal(usd(5_000_000, { signed: true }), "+$5.00");
12+ assert.equal(usd(null), "—");
13+});
14+
15+test("typed amounts become micros exactly", () => {
16+ assert.equal(parseDollars("25"), 25_000_000);
17+ assert.equal(parseDollars("$1,250.5"), 1_250_500_000);
18+ assert.equal(parseDollars("0.07"), 70_000);
19+ for (const bad of ["", "-5", "1.234", "abc", "1e3", "12345678"]) assert.equal(parseDollars(bad), null, bad);
20+ assert.equal(dollarsField(1_250_500_000), "1250.50");
21+ assert.equal(dollarsField(null), "");
22+});
+34−0
1+/** Money is held in micros: millionths of a dollar. */
2+export const MICROS_PER_DOLLAR = 1_000_000;
3+
4+const WHOLE = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2, maximumFractionDigits: 2 });
5+const SMALL = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2, maximumFractionDigits: 4 });
6+
7+/**
8+ * Dollars, to the cent; amounts under a dollar keep up to four places, so
9+ * a run that cost a fraction of a cent does not read as nothing.
10+ */
11+export function usd(micros: number | null | undefined, { signed = false }: { signed?: boolean } = {}): string {
12+ if (micros == null) return "—";
13+ const dollars = micros / MICROS_PER_DOLLAR;
14+ const text = (Math.abs(dollars) < 1 ? SMALL : WHOLE).format(Math.abs(dollars));
15+ if (dollars < 0) return `−${text}`;
16+ return signed && dollars > 0 ? `+${text}` : text;
17+}
18+
19+/**
20+ * Micros from a dollar amount as typed: `25`, `25.5`, `$1,250.00`. Null
21+ * unless it is a non-negative amount to the cent, under ten million.
22+ */
23+export function parseDollars(input: string): number | null {
24+ const text = input.trim().replace(/^\$/, "").replace(/,/g, "");
25+ const match = /^(\d{1,7})(?:\.(\d{1,2}))?$/.exec(text);
26+ if (!match) return null;
27+ const cents = Number((match[2] ?? "").padEnd(2, "0"));
28+ return Number(match[1]) * MICROS_PER_DOLLAR + cents * 10_000;
29+}
30+
31+/** A micros amount as dollars for a form field: `1250.5` → `"1250.50"`. */
32+export function dollarsField(micros: number | null | undefined): string {
33+ return micros == null ? "" : (micros / MICROS_PER_DOLLAR).toFixed(2);
34+}
+6−0
1+import { env } from "cloudflare:workers";
2+
3+import { billingAdminClient } from "@g1t/contracts";
4+
5+/** Staff-only billing, on the billing service. */
6+export const admin = billingAdminClient(env.BILLING);
+17−0
1+import { type RouterContextProvider, createContext } from "react-router";
2+
3+/** The staff member making the request, as the worker verified them. */
4+export type Staff = { email: string };
5+
6+/** Set by the worker (workers/app.ts) once the Access token checks out. */
7+export const staffContext = createContext<Staff | null>(null);
8+
9+/**
10+ * The verified staff member, or a 403. The worker refuses anyone else
11+ * before React Router runs; this is the second lock on the same door.
12+ */
13+export function requireStaff(context: Readonly<RouterContextProvider>): Staff {
14+ const staff = context.get(staffContext);
15+ if (!staff?.email) throw new Response("Forbidden", { status: 403 });
16+ return staff;
17+}
+122−0
1+import { Building2, ShieldCheck, Users } from "lucide-react";
2+import { isRouteErrorResponse, Link, Links, Meta, NavLink, Outlet, useRouteLoaderData } from "react-router";
3+
4+import type { Route } from "./+types/root";
5+import "./app.css";
6+import { Logo } from "./components/logo";
7+import { ButtonLink } from "./components/ui";
8+import { requireStaff } from "./lib/staff";
9+
10+export const links: Route.LinksFunction = () => [
11+ { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
12+ { rel: "preconnect", href: "https://fonts.googleapis.com" },
13+ { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
14+ {
15+ rel: "stylesheet",
16+ href: "https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400..700&family=JetBrains+Mono:wght@400;500;600&display=swap",
17+ },
18+];
19+
20+export const meta: Route.MetaFunction = () => [
21+ { title: "sudo · g1t" },
22+ { name: "robots", content: "noindex, nofollow" },
23+];
24+
25+export async function loader({ context }: Route.LoaderArgs) {
26+ return { email: requireStaff(context).email };
27+}
28+
29+function NavItem({ to, end, children }: { to: string; end?: boolean; children: React.ReactNode }) {
30+ return (
31+ <NavLink
32+ to={to}
33+ end={end}
34+ className={({ isActive }) =>
35+ `inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-raised hover:text-fg ${
36+ isActive ? "text-fg" : "text-muted"
37+ }`
38+ }
39+ >
40+ {children}
41+ </NavLink>
42+ );
43+}
44+
45+export function Layout({ children }: { children: React.ReactNode }) {
46+ const root = useRouteLoaderData<typeof loader>("root");
47+ return (
48+ <html lang="en">
49+ <head>
50+ <meta charSet="utf-8" />
51+ <meta name="viewport" content="width=device-width, initial-scale=1" />
52+ <meta name="theme-color" content="#0f0f11" />
53+ <Meta />
54+ <Links />
55+ </head>
56+ <body className="flex min-h-screen flex-col">
57+ <header className="sticky top-0 z-40 border-b border-line bg-surface/90 backdrop-blur">
58+ <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4">
59+ <Link to="/" aria-label="sudo home" className="mr-1 sm:mr-3">
60+ <Logo />
61+ </Link>
62+ <nav className="flex items-center gap-0.5">
63+ <NavItem to="/" end>
64+ <Users size={14} className="hidden sm:block" />
65+ Accounts
66+ </NavItem>
67+ <NavItem to="/enterprises/new">
68+ <Building2 size={14} className="hidden sm:block" />
69+ <span className="sm:hidden">New ent.</span>
70+ <span className="hidden sm:inline">New enterprise</span>
71+ </NavItem>
72+ </nav>
73+ {root?.email && (
74+ <span
75+ title="Signed in through Cloudflare Access"
76+ className="ml-auto hidden items-center gap-1.5 truncate rounded-md border border-line px-2 py-1 font-mono text-xs text-muted md:inline-flex"
77+ >
78+ <ShieldCheck size={13} className="text-merged" />
79+ {root.email}
80+ </span>
81+ )}
82+ </div>
83+ </header>
84+ <div className="grow">{children}</div>
85+ <footer className="border-t border-line">
86+ <p className="mx-auto max-w-6xl px-4 py-5 text-xs text-faint">
87+ g1t staff only. Every change is recorded with who made it.
88+ {root?.email && <span className="md:hidden"> Signed in as {root.email}.</span>}
89+ </p>
90+ </footer>
91+ {/* No <Scripts />: sudo ships no JavaScript, and its policy allows none. */}
92+ </body>
93+ </html>
94+ );
95+}
96+
97+export default function App() {
98+ return <Outlet />;
99+}
100+
101+export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
102+ let title = "Something went wrong";
103+ let details = "An unexpected error occurred.";
104+ if (isRouteErrorResponse(error)) {
105+ title = error.status === 404 ? "Not found" : `Error ${error.status}`;
106+ details = typeof error.data === "string" && error.data ? error.data : error.statusText || details;
107+ } else if (error instanceof Error) {
108+ // Staff only: the real reason helps more than a polite one.
109+ details = error.message;
110+ }
111+ return (
112+ <main className="mx-auto max-w-xl px-4 py-24 text-center">
113+ <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
114+ <p className="mt-3 break-words text-muted">{details}</p>
115+ <div className="mt-8">
116+ <ButtonLink to="/" variant="quiet">
117+ Back to accounts
118+ </ButtonLink>
119+ </div>
120+ </main>
121+ );
122+}
+7−0
1+import { type RouteConfig, index, route } from "@react-router/dev/routes";
2+
3+export default [
4+ index("routes/accounts.tsx"),
5+ route("accounts/:id", "routes/account.tsx"),
6+ route("enterprises/new", "routes/new-enterprise.tsx"),
7+] satisfies RouteConfig;
+713−0
1+import { ArrowLeft, Building2, Gift, LogOut, Plus, ScrollText, Trash2, UserRound } from "lucide-react";
2+import type { ReactNode } from "react";
3+import { data, Link, redirect, useLocation } from "react-router";
4+
5+import { type AccountDetail, type LedgerEntry, type Limit, type Terms, httpStatus } from "@g1t/contracts";
6+
7+import type { Route } from "./+types/account";
8+import {
9+ Avatar,
10+ Badge,
11+ Button,
12+ EmptyState,
13+ ExposureBar,
14+ Field,
15+ Input,
16+ KindBadge,
17+ Notice,
18+ Section,
19+ Select,
20+ StateBadge,
21+ TermsBadge,
22+ Textarea,
23+ TrustBadge,
24+ When,
25+} from "~/components/ui";
26+import { fields, isAccountId, parseCredit, parseNote, parseSlug, parseTerms, text } from "~/lib/forms";
27+import { dollarsField, usd } from "~/lib/money";
28+import { admin } from "~/lib/services.server";
29+import { requireStaff } from "~/lib/staff";
30+
31+export const meta: Route.MetaFunction = ({ loaderData }) => [
32+ { title: `${loaderData?.detail.summary.account.name ?? "Account"} · sudo` },
33+ { name: "robots", content: "noindex, nofollow" },
34+];
35+
36+const DONE: Record<string, string> = {
37+ terms: "Terms saved. They apply to charges from now on.",
38+ attach: "Workspace moved onto the enterprise.",
39+ detach: "Workspace moved back onto its own account.",
40+ credit: "Credit issued.",
41+ created: "Enterprise created.",
42+};
43+
44+async function load(id: string): Promise<AccountDetail> {
45+ if (!isAccountId(id)) throw data("That is not an account id or a workspace slug.", { status: 404 });
46+ const result = await admin.account(id);
47+ if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
48+ return result.value;
49+}
50+
51+export async function loader({ request, params, context }: Route.LoaderArgs) {
52+ requireStaff(context);
53+ const detail = await load(params.id);
54+ const { account } = detail.summary;
55+ // A workspace's page offers the enterprises it could move onto.
56+ const enterprises =
57+ account.kind === "workspace"
58+ ? (await admin.accounts())
59+ .filter((row) => row.account.kind === "enterprise")
60+ .map((row) => ({ id: row.account.id, name: row.account.name }))
61+ : [];
62+ const done = new URL(request.url).searchParams.get("done");
63+ return { detail, enterprises, done: done && DONE[done] ? DONE[done] : null };
64+}
65+
66+type Review =
67+ | { intent: "terms"; before: Terms; after: Terms; fields: Record<string, string> }
68+ | { intent: "attach"; workspace: string; target: string; targetName: string; fields: Record<string, string> }
69+ | { intent: "detach"; workspace: string; from: string; fields: Record<string, string> };
70+
71+type ActionData = { error: string; section: string; values?: Record<string, string> } | { review: Review };
72+
73+function failed(section: string, error: string, values?: Record<string, string>) {
74+ return data<ActionData>({ error, section, values }, { status: 422 });
75+}
76+
77+/** The workspace an account's page acts for when it is a workspace's own. */
78+function ownWorkspace(detail: AccountDetail): string {
79+ return detail.summary.limit.workspace;
80+}
81+
82+export async function action({ request, params, context }: Route.ActionArgs) {
83+ const staff = requireStaff(context);
84+ // What is acted on comes from the billing service, not from the form.
85+ const detail = await load(params.id);
86+ const { account } = detail.summary;
87+ const form = await request.formData();
88+ const intent = text(form, "intent");
89+ const confirmed = text(form, "confirm") === "yes";
90+ const back = (done: string) => redirect(`/accounts/${encodeURIComponent(params.id)}?done=${done}#top`);
91+
92+ if (intent === "terms") {
93+ const values = fields(form, "kind", "discount", "ceiling", "note", "until");
94+ const terms = parseTerms(form, staff.email);
95+ if (!terms.ok) return failed("terms", terms.error, values);
96+ if (!confirmed) return { review: { intent, before: account.terms, after: terms.value, fields: values } } satisfies ActionData;
97+ const result = await admin.setTerms(account.id, terms.value, staff.email);
98+ if (!result.ok) return failed("terms", result.error.message, values);
99+ return back("terms");
100+ }
101+
102+ if (intent === "attach") {
103+ const values = fields(form, "workspace", "target");
104+ const slug = parseSlug(account.kind === "enterprise" ? values.workspace : ownWorkspace(detail));
105+ if (!slug.ok) return failed("members", slug.error, values);
106+ let target = account.id;
107+ let targetName = account.name;
108+ if (account.kind === "workspace") {
109+ const enterprise = (await admin.accounts()).find((row) => row.account.kind === "enterprise" && row.account.id === values.target);
110+ if (!enterprise) return failed("enterprise", "Choose an enterprise to move onto.", values);
111+ target = enterprise.account.id;
112+ targetName = enterprise.account.name;
113+ } else if (account.workspaces.includes(slug.value)) {
114+ return failed("members", `${slug.value} is already on this enterprise.`, values);
115+ }
116+ if (!confirmed) {
117+ return { review: { intent, workspace: slug.value, target, targetName, fields: values } } satisfies ActionData;
118+ }
119+ const result = await admin.attach(slug.value, target, staff.email);
120+ if (!result.ok) return failed(account.kind === "enterprise" ? "members" : "enterprise", result.error.message, values);
121+ return back("attach");
122+ }
123+
124+ if (intent === "detach") {
125+ const values = fields(form, "workspace");
126+ const workspace = account.kind === "enterprise" ? values.workspace : ownWorkspace(detail);
127+ const onIt = account.kind === "enterprise" ? account.workspaces.includes(workspace) : detail.summary.limit.account !== account.id;
128+ if (!onIt) return failed(account.kind === "enterprise" ? "members" : "enterprise", `${workspace} is not on an enterprise here.`);
129+ const from = account.kind === "enterprise" ? account.name : detail.summary.limit.accountName;
130+ if (!confirmed) return { review: { intent, workspace, from, fields: values } } satisfies ActionData;
131+ const result = await admin.attach(workspace, null, staff.email);
132+ if (!result.ok) return failed(account.kind === "enterprise" ? "members" : "enterprise", result.error.message);
133+ return back("detach");
134+ }
135+
136+ if (intent === "credit") {
137+ const values = fields(form, "workspace", "amount", "note", "confirmation");
138+ const workspace = account.kind === "enterprise" ? values.workspace : ownWorkspace(detail);
139+ if (account.kind === "enterprise" && !account.workspaces.includes(workspace)) {
140+ return failed("credit", "Choose one of this account's workspaces.", values);
141+ }
142+ const amount = parseCredit(values.amount);
143+ if (!amount.ok) return failed("credit", amount.error, values);
144+ const note = parseNote(values.note);
145+ if (!note.ok) return failed("credit", note.error, values);
146+ if (values.confirmation !== workspace) {
147+ return failed("credit", `Type the workspace's slug, ${workspace}, exactly, to issue the credit.`, { ...values, confirmation: "" });
148+ }
149+ const result = await admin.credit(workspace, amount.value, note.value, staff.email);
150+ if (!result.ok) return failed("credit", result.error.message, values);
151+ return back("credit");
152+ }
153+
154+ return failed("top", "Unknown action.");
155+}
156+
157+export default function Account({ loaderData, actionData }: Route.ComponentProps) {
158+ const { detail, enterprises, done } = loaderData;
159+ const { summary } = detail;
160+ const { account, limit } = summary;
161+ const { pathname } = useLocation();
162+ const result = actionData as ActionData | undefined;
163+ const review = result && "review" in result ? result.review : null;
164+ const error = (section: string) => (result && "error" in result && result.section === section ? result : null);
165+ const isEnterprise = account.kind === "enterprise";
166+ const billedElsewhere = !isEnterprise && limit.account !== account.id;
167+
168+ return (
169+ <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
170+ <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
171+ <ArrowLeft size={14} />
172+ Accounts
173+ </Link>
174+
175+ {/* Header */}
176+ <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
177+ <div className="flex min-w-0 items-start gap-3">
178+ <Avatar name={account.name} size={40} />
179+ <div className="min-w-0">
180+ <h1 className="truncate text-2xl font-semibold tracking-tight">{account.name}</h1>
181+ <p className="mt-0.5 font-mono text-xs break-all text-faint">{account.id}</p>
182+ <div className="mt-2 flex flex-wrap gap-1.5">
183+ <KindBadge kind={account.kind} />
184+ <TermsBadge terms={account.terms} />
185+ <TrustBadge trust={limit.trust} />
186+ <StateBadge state={limit.state} />
187+ {billedElsewhere && <Badge tone="lavender">Billed through {limit.accountName}</Badge>}
188+ </div>
189+ </div>
190+ </div>
191+ <p className="text-xs text-faint">
192+ Account since <When at={account.createdAt} />
193+ </p>
194+ </div>
195+
196+ <div className="mt-6 space-y-3">
197+ {done && <Notice tone="ok">{done}</Notice>}
198+ {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
199+ {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
200+ {review && <ReviewPanel review={review} pathname={pathname} />}
201+ </div>
202+
203+ {/* This month */}
204+ <div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
205+ <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
206+ <p className="mb-2 text-xs text-muted">Unpaid exposure this month</p>
207+ <ExposureBar limit={limit} wide />
208+ <p className="mt-2 text-xs text-faint">
209+ Trust ceiling {usd(limit.trustCeilingMicros)} · owner's spend limit {usd(limit.spendLimitMicros)}
210+ {account.terms.ceilingMicros != null && <> · custom ceiling {usd(account.terms.ceilingMicros)}</>}
211+ </p>
212+ </div>
213+ <Figure label="Charged this month" value={usd(summary.chargedMicros)} hint={`Cost to g1t ${usd(summary.costMicros)}`} />
214+ <Figure label="Paid ever" value={usd(summary.paidMicros)} hint={`Margin this month ${usd(summary.chargedMicros - summary.costMicros)}`} />
215+ </div>
216+
217+ <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
218+ <div className="space-y-6">
219+ <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
220+
221+ {isEnterprise ? (
222+ <MembersSection detail={detail} pathname={pathname} error={error("members")} />
223+ ) : (
224+ <EnterpriseSection
225+ billedElsewhere={billedElsewhere}
226+ limit={limit}
227+ enterprises={enterprises}
228+ pathname={pathname}
229+ error={error("enterprise")}
230+ />
231+ )}
232+
233+ <LedgerSection ledger={detail.ledger} />
234+ </div>
235+
236+ <div className="space-y-6">
237+ <CreditForm
238+ workspaces={isEnterprise ? account.workspaces : [limit.workspace]}
239+ pathname={pathname}
240+ error={error("credit")}
241+ />
242+ <AuditSection audit={detail.audit} />
243+ </div>
244+ </div>
245+ </main>
246+ );
247+}
248+
249+function Figure({ label, value, hint }: { label: string; value: string; hint?: string }) {
250+ return (
251+ <div className="rounded-lg border border-line bg-surface px-4 py-3">
252+ <p className="text-xs text-muted">{label}</p>
253+ <p className="tabular mt-1 text-lg font-semibold tracking-tight">{value}</p>
254+ {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>}
255+ </div>
256+ );
257+}
258+
259+function Hidden({ values }: { values: Record<string, string> }) {
260+ return (
261+ <>
262+ {Object.entries(values).map(([name, value]) => (
263+ <input key={name} type="hidden" name={name} value={value} />
264+ ))}
265+ </>
266+ );
267+}
268+
269+type SectionError = { error: string; values?: Record<string, string> } | null;
270+
271+// --- Confirmation ------------------------------------------------------------
272+
273+function describeTerms(terms: Terms): [string, string][] {
274+ return [
275+ ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"],
276+ ["Discount", terms.kind === "custom" ? `${terms.discountPercent}%` : "—"],
277+ ["Ceiling", terms.ceilingMicros == null ? "By trust" : usd(terms.ceilingMicros)],
278+ ["Until", terms.until ? terms.until.slice(0, 10) : "No end"],
279+ ["Note", terms.note || "—"],
280+ ];
281+}
282+
283+function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) {
284+ let title: string;
285+ let body: ReactNode;
286+ let danger = false;
287+ if (review.intent === "terms") {
288+ const before = describeTerms(review.before);
289+ const after = describeTerms(review.after);
290+ title = "Confirm the new terms";
291+ danger = review.after.kind === "comped";
292+ body = (
293+ <>
294+ <div className="overflow-x-auto">
295+ <table className="w-full text-sm">
296+ <thead>
297+ <tr className="text-left text-xs text-muted">
298+ <th className="py-1.5 pr-4 font-medium" />
299+ <th className="py-1.5 pr-4 font-medium">Now</th>
300+ <th className="py-1.5 font-medium">After</th>
301+ </tr>
302+ </thead>
303+ <tbody>
304+ {after.map(([label, value], index) => (
305+ <tr key={label} className="border-t border-line align-top">
306+ <td className="py-1.5 pr-4 text-muted">{label}</td>
307+ <td className="py-1.5 pr-4 break-words text-faint">{before[index][1]}</td>
308+ <td className={`py-1.5 break-words ${value !== before[index][1] ? "font-medium text-fg" : "text-muted"}`}>{value}</td>
309+ </tr>
310+ ))}
311+ </tbody>
312+ </table>
313+ </div>
314+ {review.after.kind === "comped" && (
315+ <p className="mt-3 text-sm text-warn">Comped: nothing this account uses will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. Usage is still recorded at cost.</p>
316+ )}
317+ </>
318+ );
319+ } else if (review.intent === "attach") {
320+ title = `Move ${review.workspace} onto ${review.targetName}?`;
321+ body = (
322+ <p className="text-sm text-muted">
323+ From now on <span className="font-mono text-fg">{review.workspace}</span>'s usage is billed to{" "}
324+ <span className="text-fg">{review.targetName}</span> and counts against its limit and terms, not its own.
325+ </p>
326+ );
327+ } else {
328+ title = `Move ${review.workspace} off ${review.from}?`;
329+ danger = true;
330+ body = (
331+ <p className="text-sm text-muted">
332+ <span className="font-mono text-fg">{review.workspace}</span> goes back to paying for itself, under its own terms and the
333+ ceiling its trust gives it. It may stop at once if its own ceiling is lower than its exposure.
334+ </p>
335+ );
336+ }
337+ return (
338+ <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${danger ? "border-warn/40 bg-warn/5" : "border-merged/40 bg-merged/5"}`}>
339+ <h2 className="font-semibold tracking-tight">{title}</h2>
340+ <div className="mt-3">{body}</div>
341+ <form method="post" action={`${pathname}#top`} className="mt-4 flex flex-wrap items-center gap-2">
342+ <Hidden values={review.fields} />
343+ <input type="hidden" name="intent" value={review.intent} />
344+ <input type="hidden" name="confirm" value="yes" />
345+ <Button type="submit" variant={danger ? "danger" : "lavender"}>
346+ Confirm
347+ </Button>
348+ <Link to={pathname} className="px-2 text-sm text-muted hover:text-fg">
349+ Cancel
350+ </Link>
351+ </form>
352+ </section>
353+ );
354+}
355+
356+// --- Terms -------------------------------------------------------------------
357+
358+const KINDS: { value: Terms["kind"]; title: string; text: string }[] = [
359+ { value: "standard", title: "Standard", text: "Published prices; the ceiling comes from trust." },
360+ { value: "comped", title: "Comped", text: "Nothing charged. Usage still recorded at cost." },
361+ { value: "custom", title: "Custom", text: "A discount, a custom ceiling, or both." },
362+];
363+
364+function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) {
365+ const values = error?.values;
366+ const kind = values?.kind ?? terms.kind;
367+ return (
368+ <Section
369+ id="terms"
370+ title="Terms"
371+ description={
372+ terms.setBy ? (
373+ <>
374+ Set by <span className="font-mono">{terms.setBy}</span> on <When at={terms.setAt} />
375+ {terms.note && <> · “{terms.note}”</>}
376+ </>
377+ ) : (
378+ "Standard terms, as every account starts."
379+ )
380+ }
381+ >
382+ <form method="post" action={`${pathname}#review`} className="space-y-4">
383+ <input type="hidden" name="intent" value="terms" />
384+ {error && <Notice tone="error">{error.error}</Notice>}
385+ <fieldset>
386+ <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend>
387+ <div className="grid gap-2 sm:grid-cols-3">
388+ {KINDS.map((option) => (
389+ <label
390+ key={option.value}
391+ className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg p-3 transition-colors hover:border-line-strong has-checked:border-merged/60 has-checked:bg-merged/8"
392+ >
393+ <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required />
394+ <span>
395+ <span className="block text-sm font-medium">{option.title}</span>
396+ <span className="mt-0.5 block text-xs text-muted">{option.text}</span>
397+ </span>
398+ </label>
399+ ))}
400+ </div>
401+ </fieldset>
402+ <div className="grid gap-4 sm:grid-cols-3">
403+ <Field label="Discount %" hint="Custom only.">
404+ <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} />
405+ </Field>
406+ <Field label="Ceiling $" hint="Blank: trust decides.">
407+ <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} />
408+ </Field>
409+ <Field label="Until" hint="Blank: no end. UTC.">
410+ <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} />
411+ </Field>
412+ </div>
413+ <Field label="Note" hint="Required. Why, for whoever looks next.">
414+ <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner through launch" />
415+ </Field>
416+ <div className="flex justify-end">
417+ <Button type="submit">Review terms</Button>
418+ </div>
419+ </form>
420+ </Section>
421+ );
422+}
423+
424+// --- Enterprise membership ----------------------------------------------------
425+
426+function MembersSection({ detail, pathname, error }: { detail: AccountDetail; pathname: string; error: SectionError }) {
427+ const members: Limit[] = detail.workspaces;
428+ const listed = new Set(members.map((member) => member.workspace));
429+ // Any workspace the account names but no limit came back for.
430+ const missing = detail.summary.account.workspaces.filter((slug) => !listed.has(slug));
431+ return (
432+ <Section id="members" title="Workspaces" description="Billed together: one bill, one limit, one set of terms.">
433+ {error && (
434+ <div className="mb-4">
435+ <Notice tone="error">{error.error}</Notice>
436+ </div>
437+ )}
438+ {members.length + missing.length === 0 ? (
439+ <EmptyState title="No workspaces yet">Add one below to bill it through this enterprise.</EmptyState>
440+ ) : (
441+ <ul className="divide-y divide-line rounded-md border border-line">
442+ {members.map((member) => (
443+ <li key={member.workspace} className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center">
444+ <div className="flex min-w-0 grow items-center gap-2.5">
445+ <Avatar name={member.workspace} size={22} />
446+ <div className="min-w-0">
447+ <Link to={`/accounts/${encodeURIComponent(member.workspace)}`} className="font-mono text-sm hover:underline hover:underline-offset-4">
448+ {member.workspace}
449+ </Link>
450+ <div className="mt-1 flex flex-wrap gap-1.5">
451+ <TrustBadge trust={member.trust} />
452+ <StateBadge state={member.state} />
453+ </div>
454+ {member.message && <p className="mt-1 text-xs text-muted">{member.message}</p>}
455+ </div>
456+ </div>
457+ <div className="flex items-center gap-3 sm:w-64">
458+ <ExposureBar limit={member} wide />
459+ <DetachButton workspace={member.workspace} pathname={pathname} />
460+ </div>
461+ </li>
462+ ))}
463+ {missing.map((slug) => (
464+ <li key={slug} className="flex items-center gap-3 p-3">
465+ <span className="grow font-mono text-sm">{slug}</span>
466+ <DetachButton workspace={slug} pathname={pathname} />
467+ </li>
468+ ))}
469+ </ul>
470+ )}
471+ <form method="post" action={`${pathname}#review`} className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end">
472+ <input type="hidden" name="intent" value="attach" />
473+ <Field label="Add a workspace" className="grow">
474+ <Input name="workspace" required placeholder="workspace-slug" defaultValue={error?.values?.workspace ?? ""} className="font-mono" />
475+ </Field>
476+ <Button type="submit" variant="quiet">
477+ <Plus size={14} />
478+ Add
479+ </Button>
480+ </form>
481+ </Section>
482+ );
483+}
484+
485+function DetachButton({ workspace, pathname }: { workspace: string; pathname: string }) {
486+ return (
487+ <form method="post" action={`${pathname}#review`} className="shrink-0">
488+ <input type="hidden" name="intent" value="detach" />
489+ <input type="hidden" name="workspace" value={workspace} />
490+ <button
491+ type="submit"
492+ aria-label={`Remove ${workspace}`}
493+ title={`Remove ${workspace}`}
494+ className="rounded-md border border-line p-2 text-muted transition-colors hover:border-danger/50 hover:text-danger"
495+ >
496+ <Trash2 size={14} />
497+ </button>
498+ </form>
499+ );
500+}
501+
502+function EnterpriseSection({
503+ billedElsewhere,
504+ limit,
505+ enterprises,
506+ pathname,
507+ error,
508+}: {
509+ billedElsewhere: boolean;
510+ limit: Limit;
511+ enterprises: { id: string; name: string }[];
512+ pathname: string;
513+ error: SectionError;
514+}) {
515+ return (
516+ <Section id="enterprise" title="Enterprise" description="Whether another account pays for this workspace.">
517+ {error && (
518+ <div className="mb-4">
519+ <Notice tone="error">{error.error}</Notice>
520+ </div>
521+ )}
522+ {billedElsewhere ? (
523+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
524+ <p className="text-sm text-muted">
525+ Billed through{" "}
526+ <Link to={`/accounts/${encodeURIComponent(limit.account)}`} className="text-fg hover:underline hover:underline-offset-4">
527+ {limit.accountName}
528+ </Link>
529+ , under its limit and terms.
530+ </p>
531+ <form method="post" action={`${pathname}#review`}>
532+ <input type="hidden" name="intent" value="detach" />
533+ <Button type="submit" variant="danger">
534+ <LogOut size={14} />
535+ Move off
536+ </Button>
537+ </form>
538+ </div>
539+ ) : enterprises.length === 0 ? (
540+ <p className="text-sm text-muted">
541+ Pays for itself. There are no enterprises to move it onto yet;{" "}
542+ <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4">
543+ create one
544+ </Link>
545+ .
546+ </p>
547+ ) : (
548+ <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
549+ <input type="hidden" name="intent" value="attach" />
550+ <Field label="Pays for itself. Move onto" className="grow">
551+ <Select name="target" required defaultValue={error?.values?.target ?? ""}>
552+ <option value="" disabled>
553+ Choose an enterprise
554+ </option>
555+ {enterprises.map((enterprise) => (
556+ <option key={enterprise.id} value={enterprise.id}>
557+ {enterprise.name} ({enterprise.id})
558+ </option>
559+ ))}
560+ </Select>
561+ </Field>
562+ <Button type="submit" variant="quiet">
563+ <Building2 size={14} />
564+ Move
565+ </Button>
566+ </form>
567+ )}
568+ </Section>
569+ );
570+}
571+
572+// --- Credit -------------------------------------------------------------------
573+
574+function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) {
575+ const values = error?.values;
576+ const single = workspaces.length === 1 ? workspaces[0] : null;
577+ return (
578+ <Section id="credit" title="Issue credit" description="A refund or goodwill. Added to the workspace's balance at once.">
579+ {workspaces.length === 0 ? (
580+ <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p>
581+ ) : (
582+ <form method="post" action={`${pathname}#credit`} className="space-y-4">
583+ <input type="hidden" name="intent" value="credit" />
584+ {error && <Notice tone="error">{error.error}</Notice>}
585+ {single ? (
586+ <input type="hidden" name="workspace" value={single} />
587+ ) : (
588+ <Field label="Workspace">
589+ <Select name="workspace" required defaultValue={values?.workspace ?? ""}>
590+ <option value="" disabled>
591+ Choose a workspace
592+ </option>
593+ {workspaces.map((slug) => (
594+ <option key={slug} value={slug}>
595+ {slug}
596+ </option>
597+ ))}
598+ </Select>
599+ </Field>
600+ )}
601+ <Field label="Amount $" hint="Up to $10,000 at a time.">
602+ <Input name="amount" inputMode="decimal" required placeholder="25.00" defaultValue={values?.amount ?? ""} />
603+ </Field>
604+ <Field label="Note" hint="Required. Shown on the workspace's statement.">
605+ <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Refund for the failed runs on Oct 2" defaultValue={values?.note ?? ""} />
606+ </Field>
607+ <Field
608+ label="Confirm"
609+ hint={
610+ <>
611+ Type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>} to issue it.
612+ </>
613+ }
614+ >
615+ <Input name="confirmation" required placeholder={single ?? "workspace-slug"} className="font-mono" />
616+ </Field>
617+ <div className="flex justify-end">
618+ <Button type="submit" variant="lavender">
619+ <Gift size={14} />
620+ Issue credit
621+ </Button>
622+ </div>
623+ </form>
624+ )}
625+ </Section>
626+ );
627+}
628+
629+// --- Ledger and audit ---------------------------------------------------------
630+
631+const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" };
632+
633+function LedgerSection({ ledger }: { ledger: LedgerEntry[] }) {
634+ return (
635+ <Section title="Ledger" description="Recent lines of the statement, newest first.">
636+ {ledger.length === 0 ? (
637+ <EmptyState title="Nothing yet" />
638+ ) : (
639+ <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
640+ <table className="w-full min-w-[36rem] text-sm">
641+ <thead>
642+ <tr className="border-b border-line text-left text-xs text-muted">
643+ <th className="px-4 py-2 font-medium sm:pl-5">When</th>
644+ <th className="px-4 py-2 font-medium">What</th>
645+ <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th>
646+ </tr>
647+ </thead>
648+ <tbody>
649+ {ledger.map((entry) => (
650+ <tr key={entry.id} className="border-b border-line align-top last:border-0">
651+ <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5">
652+ <When at={entry.createdAt} time />
653+ </td>
654+ <td className="px-4 py-2.5">
655+ <div className="flex flex-wrap items-center gap-1.5">
656+ <Badge tone={entry.amountMicros > 0 ? "mint" : "plain"}>{ENTRY_KIND[entry.kind] ?? entry.kind}</Badge>
657+ <span className="break-words">{entry.description}</span>
658+ </div>
659+ <p className="mt-0.5 font-mono text-xs text-faint">
660+ {[
661+ entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo),
662+ entry.task,
663+ entry.model,
664+ entry.billedTo === "workspace" ? "own provider" : null,
665+ entry.createdBy && `by ${entry.createdBy}`,
666+ ]
667+ .filter(Boolean)
668+ .join(" · ")}
669+ </p>
670+ </td>
671+ <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-accent" : "text-fg-soft"}`}>
672+ {usd(entry.amountMicros, { signed: true })}
673+ </td>
674+ </tr>
675+ ))}
676+ </tbody>
677+ </table>
678+ </div>
679+ )}
680+ </Section>
681+ );
682+}
683+
684+function AuditSection({ audit }: { audit: AccountDetail["audit"] }) {
685+ return (
686+ <Section title="Audit log" description="Every change made in sudo, and by whom.">
687+ {audit.length === 0 ? (
688+ <p className="flex items-center gap-2 text-sm text-muted">
689+ <ScrollText size={14} />
690+ No changes yet.
691+ </p>
692+ ) : (
693+ <ol className="space-y-3">
694+ {audit.map((entry) => (
695+ <li key={entry.id} className="border-l-2 border-merged/40 pl-3">
696+ <p className="flex flex-wrap items-center gap-x-2 text-sm">
697+ <span className="font-mono font-medium text-merged">{entry.action}</span>
698+ <span className="text-xs text-faint">
699+ <When at={entry.createdAt} time />
700+ </span>
701+ </p>
702+ {entry.detail && <p className="mt-0.5 text-sm break-words text-fg-soft">{entry.detail}</p>}
703+ <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint">
704+ <UserRound size={11} />
705+ {entry.by}
706+ </p>
707+ </li>
708+ ))}
709+ </ol>
710+ )}
711+ </Section>
712+ );
713+}
+252−0
1+import { Building2, ChevronRight, Search } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { AccountSummary } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/accounts";
7+import {
8+ Avatar,
9+ Button,
10+ ButtonLink,
11+ EmptyState,
12+ ExposureBar,
13+ KindBadge,
14+ Stat,
15+ TermsBadge,
16+ TrustBadge,
17+} from "~/components/ui";
18+import { usd } from "~/lib/money";
19+import { admin } from "~/lib/services.server";
20+import { requireStaff } from "~/lib/staff";
21+
22+export const meta: Route.MetaFunction = () => [{ title: "Accounts · sudo" }, { name: "robots", content: "noindex, nofollow" }];
23+
24+const FILTERS = {
25+ attention: { label: "Stopped or warning", test: (row: AccountSummary) => row.limit.state !== "ok" },
26+ terms: { label: "Comped or custom", test: (row: AccountSummary) => row.account.terms.kind !== "standard" },
27+ enterprise: { label: "Enterprises", test: (row: AccountSummary) => row.account.kind === "enterprise" },
28+} as const;
29+
30+type Filter = keyof typeof FILTERS;
31+
32+const SEVERITY = { stopped: 0, warning: 1, ok: 2 } as const;
33+
34+export async function loader({ request, context }: Route.LoaderArgs) {
35+ requireStaff(context);
36+ const url = new URL(request.url);
37+ const q = (url.searchParams.get("q") ?? "").trim().slice(0, 100);
38+ const show = url.searchParams.getAll("show").filter((value): value is Filter => value in FILTERS);
39+
40+ const all = await admin.accounts(q || undefined);
41+ const rows = all
42+ .filter((row) => show.every((filter) => FILTERS[filter].test(row)))
43+ .sort(
44+ (a, b) =>
45+ SEVERITY[a.limit.state] - SEVERITY[b.limit.state] ||
46+ b.limit.exposureMicros - a.limit.exposureMicros ||
47+ a.account.name.localeCompare(b.account.name),
48+ );
49+
50+ const sum = (pick: (row: AccountSummary) => number) => all.reduce((total, row) => total + pick(row), 0);
51+ return {
52+ q,
53+ show,
54+ rows,
55+ total: all.length,
56+ totals: {
57+ charged: sum((row) => row.chargedMicros),
58+ cost: sum((row) => row.costMicros),
59+ paid: sum((row) => row.paidMicros),
60+ exposure: sum((row) => row.limit.exposureMicros),
61+ stopped: all.filter((row) => row.limit.state === "stopped").length,
62+ warning: all.filter((row) => row.limit.state === "warning").length,
63+ },
64+ };
65+}
66+
67+function accountHref(row: AccountSummary) {
68+ return `/accounts/${encodeURIComponent(row.account.id)}`;
69+}
70+
71+export default function Accounts({ loaderData }: Route.ComponentProps) {
72+ const { q, show, rows, total, totals } = loaderData;
73+ const margin = totals.charged - totals.cost;
74+ const filtered = q !== "" || show.length > 0;
75+
76+ return (
77+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
78+ <div className="flex flex-wrap items-end justify-between gap-4">
79+ <div>
80+ <h1 className="text-2xl font-semibold tracking-tight">Accounts</h1>
81+ <p className="mt-1 text-sm text-muted">Every account that pays, and where it stands this month.</p>
82+ </div>
83+ <ButtonLink to="/enterprises/new" variant="lavender">
84+ <Building2 size={15} />
85+ New enterprise
86+ </ButtonLink>
87+ </div>
88+
89+ <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
90+ <Stat label="Charged this month" value={usd(totals.charged)} hint={`${total} account${total === 1 ? "" : "s"}`} />
91+ <Stat label="Cost to g1t" value={usd(totals.cost)} hint={`Margin ${usd(margin)}`} tone={margin < 0 ? "danger" : undefined} />
92+ <Stat label="Unpaid exposure" value={usd(totals.exposure)} hint={`Paid ever ${usd(totals.paid)}`} />
93+ <Stat
94+ label="Needs attention"
95+ value={`${totals.stopped} stopped`}
96+ hint={`${totals.warning} near the ceiling`}
97+ tone={totals.stopped > 0 ? "danger" : totals.warning > 0 ? "warn" : "mint"}
98+ />
99+ </div>
100+
101+ <form method="get" action="/" role="search" className="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center">
102+ <div className="relative grow">
103+ <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
104+ <input
105+ type="search"
106+ name="q"
107+ defaultValue={q}
108+ placeholder="Search by workspace, account id or enterprise"
109+ aria-label="Search accounts"
110+ autoComplete="off"
111+ data-1p-ignore
112+ className="w-full rounded-md border border-line bg-bg py-2 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-merged/60"
113+ />
114+ </div>
115+ <fieldset className="flex flex-wrap items-center gap-2">
116+ <legend className="sr-only">Show only</legend>
117+ {(Object.keys(FILTERS) as Filter[]).map((key) => (
118+ <label
119+ key={key}
120+ className="inline-flex cursor-pointer items-center gap-2 rounded-full border border-line px-3 py-1.5 text-xs text-muted transition-colors select-none hover:border-line-strong has-checked:border-merged/50 has-checked:bg-merged/10 has-checked:text-merged"
121+ >
122+ <input type="checkbox" name="show" value={key} defaultChecked={show.includes(key)} className="size-3.5" />
123+ {FILTERS[key].label}
124+ </label>
125+ ))}
126+ <Button type="submit" variant="quiet" className="py-1.5">
127+ Apply
128+ </Button>
129+ {filtered && (
130+ <Link to="/" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
131+ Clear
132+ </Link>
133+ )}
134+ </fieldset>
135+ </form>
136+
137+ <p className="mt-4 text-xs text-faint">
138+ {rows.length === total ? `${total} accounts` : `${rows.length} of ${total} accounts`}
139+ {q && (
140+ <>
141+ {" "}
142+ matching <span className="font-mono text-muted">{q}</span>
143+ </>
144+ )}
145+ . Stopped and warning first, then by exposure.
146+ </p>
147+
148+ {rows.length === 0 ? (
149+ <div className="mt-3">
150+ <EmptyState title={filtered ? "No accounts match" : "No accounts yet"}>
151+ {filtered ? "Try another search, or clear the filters." : "Accounts appear once a workspace exists."}
152+ </EmptyState>
153+ </div>
154+ ) : (
155+ <>
156+ {/* Phones: one card per account. */}
157+ <ul className="mt-3 space-y-2 md:hidden">
158+ {rows.map((row) => (
159+ <li key={row.account.id}>
160+ <Link to={accountHref(row)} className="block rounded-lg border border-line bg-surface p-4 transition-colors hover:border-line-strong">
161+ <div className="flex items-start justify-between gap-3">
162+ <AccountName row={row} />
163+ <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint" />
164+ </div>
165+ <div className="mt-3">
166+ <ExposureBar limit={row.limit} wide />
167+ </div>
168+ <dl className="tabular mt-3 grid grid-cols-3 gap-2 text-xs">
169+ <Figure label="Charged" value={usd(row.chargedMicros)} />
170+ <Figure label="Cost" value={usd(row.costMicros)} />
171+ <Figure label="Paid ever" value={usd(row.paidMicros)} />
172+ </dl>
173+ </Link>
174+ </li>
175+ ))}
176+ </ul>
177+
178+ {/* Wider screens: a table. */}
179+ <div className="mt-3 hidden overflow-x-auto rounded-lg border border-line md:block">
180+ <table className="w-full text-sm">
181+ <thead>
182+ <tr className="border-b border-line bg-surface text-left text-xs text-muted">
183+ <th className="px-4 py-2.5 font-medium">Account</th>
184+ <th className="px-4 py-2.5 font-medium">Trust</th>
185+ <th className="px-4 py-2.5 font-medium">Exposure / ceiling</th>
186+ <th className="px-4 py-2.5 text-right font-medium">Charged</th>
187+ <th className="px-4 py-2.5 text-right font-medium">Cost to g1t</th>
188+ <th className="px-4 py-2.5 text-right font-medium">Paid ever</th>
189+ </tr>
190+ </thead>
191+ <tbody>
192+ {rows.map((row) => (
193+ <tr key={row.account.id} className="border-b border-line last:border-0 hover:bg-surface/60">
194+ <td className="px-4 py-3">
195+ <Link to={accountHref(row)} className="group block">
196+ <AccountName row={row} />
197+ </Link>
198+ </td>
199+ <td className="px-4 py-3">
200+ <TrustBadge trust={row.limit.trust} />
201+ </td>
202+ <td className="px-4 py-3">
203+ <ExposureBar limit={row.limit} />
204+ </td>
205+ <td className="tabular px-4 py-3 text-right">{usd(row.chargedMicros)}</td>
206+ <td className="tabular px-4 py-3 text-right text-muted">{usd(row.costMicros)}</td>
207+ <td className="tabular px-4 py-3 text-right text-muted">{usd(row.paidMicros)}</td>
208+ </tr>
209+ ))}
210+ </tbody>
211+ </table>
212+ </div>
213+ </>
214+ )}
215+ </main>
216+ );
217+}
218+
219+function AccountName({ row }: { row: AccountSummary }) {
220+ const { account } = row;
221+ const members = account.workspaces.length;
222+ return (
223+ <div className="flex min-w-0 items-start gap-2.5">
224+ <span className="mt-0.5">
225+ <Avatar name={account.name} size={22} />
226+ </span>
227+ <div className="min-w-0">
228+ <p className="truncate font-medium group-hover:underline group-hover:underline-offset-4">{account.name}</p>
229+ <p className="truncate font-mono text-xs text-faint">
230+ {account.id}
231+ {account.kind === "enterprise" && ` · ${members} workspace${members === 1 ? "" : "s"}`}
232+ </p>
233+ <div className="mt-1.5 flex flex-wrap gap-1.5">
234+ <KindBadge kind={account.kind} />
235+ <TermsBadge terms={account.terms} />
236+ <span className="md:hidden">
237+ <TrustBadge trust={row.limit.trust} />
238+ </span>
239+ </div>
240+ </div>
241+ </div>
242+ );
243+}
244+
245+function Figure({ label, value }: { label: string; value: string }) {
246+ return (
247+ <div>
248+ <dt className="text-faint">{label}</dt>
249+ <dd className="mt-0.5 text-fg-soft">{value}</dd>
250+ </div>
251+ );
252+}
+107−0
1+import { ArrowLeft, Building2 } from "lucide-react";
2+import { data, Link, redirect } from "react-router";
3+
4+import type { Route } from "./+types/new-enterprise";
5+import { Avatar, Button, Field, Input, Notice, Section, Textarea } from "~/components/ui";
6+import { fields, parseSlugList, text } from "~/lib/forms";
7+import { admin } from "~/lib/services.server";
8+import { requireStaff } from "~/lib/staff";
9+
10+export const meta: Route.MetaFunction = () => [{ title: "New enterprise · sudo" }, { name: "robots", content: "noindex, nofollow" }];
11+
12+export async function loader({ context }: Route.LoaderArgs) {
13+ requireStaff(context);
14+ return null;
15+}
16+
17+type ActionData =
18+ | { error: string; values: Record<string, string> }
19+ | { review: { name: string; workspaces: string[]; values: Record<string, string> } };
20+
21+export async function action({ request, context }: Route.ActionArgs) {
22+ const staff = requireStaff(context);
23+ const form = await request.formData();
24+ const values = fields(form, "name", "workspaces");
25+ const fail = (error: string) => data<ActionData>({ error, values }, { status: 422 });
26+
27+ const name = values.name.replace(/\s+/g, " ");
28+ if (!name) return fail("Give the enterprise a name.");
29+ if (name.length > 100) return fail("Keep the name under 100 characters.");
30+ const workspaces = parseSlugList(values.workspaces);
31+ if (!workspaces.ok) return fail(workspaces.error);
32+ if (workspaces.value.length === 0) return fail("Name at least one workspace for it to pay for.");
33+ if (workspaces.value.length > 100) return fail("At most 100 workspaces at once.");
34+
35+ // Moving workspaces onto it changes who pays for them: confirm first.
36+ if (text(form, "confirm") !== "yes") {
37+ return { review: { name, workspaces: workspaces.value, values } } satisfies ActionData;
38+ }
39+ const result = await admin.createEnterprise(name, workspaces.value, staff.email);
40+ if (!result.ok) return fail(result.error.message);
41+ return redirect(`/accounts/${encodeURIComponent(result.value.id)}?done=created#top`);
42+}
43+
44+export default function NewEnterprise({ actionData }: Route.ComponentProps) {
45+ const result = actionData as ActionData | undefined;
46+ const review = result && "review" in result ? result.review : null;
47+ const error = result && "error" in result ? result : null;
48+ const values = review?.values ?? error?.values;
49+
50+ return (
51+ <main className="mx-auto max-w-2xl px-4 py-8 sm:py-10">
52+ <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
53+ <ArrowLeft size={14} />
54+ Accounts
55+ </Link>
56+ <h1 className="mt-4 text-2xl font-semibold tracking-tight">New enterprise</h1>
57+ <p className="mt-1 text-sm text-muted">
58+ One account that pays for several workspaces, as GitHub Enterprise does: one bill, one limit, one set of terms. Set its terms
59+ once it exists.
60+ </p>
61+
62+ {review && (
63+ <section id="review" className="mt-6 scroll-mt-20 rounded-lg border border-merged/40 bg-merged/5 p-4 sm:p-5">
64+ <h2 className="font-semibold tracking-tight">Create {review.name}?</h2>
65+ <p className="mt-1 text-sm text-muted">
66+ These workspaces will be billed through it from now on, under its limit and terms instead of their own:
67+ </p>
68+ <ul className="mt-3 flex flex-wrap gap-2">
69+ {review.workspaces.map((slug) => (
70+ <li key={slug} className="inline-flex items-center gap-1.5 rounded-md border border-line bg-bg px-2 py-1 font-mono text-xs">
71+ <Avatar name={slug} size={14} />
72+ {slug}
73+ </li>
74+ ))}
75+ </ul>
76+ <form method="post" action="/enterprises/new" className="mt-4 flex flex-wrap items-center gap-2">
77+ <input type="hidden" name="name" value={review.values.name} />
78+ <input type="hidden" name="workspaces" value={review.values.workspaces} />
79+ <input type="hidden" name="confirm" value="yes" />
80+ <Button type="submit" variant="lavender">
81+ <Building2 size={14} />
82+ Create enterprise
83+ </Button>
84+ <Link to="/enterprises/new" className="px-2 text-sm text-muted hover:text-fg">
85+ Cancel
86+ </Link>
87+ </form>
88+ </section>
89+ )}
90+
91+ <Section title="Enterprise" className="mt-6">
92+ <form method="post" action="/enterprises/new#review" className="space-y-4">
93+ {error && <Notice tone="error">{error.error}</Notice>}
94+ <Field label="Name" hint="As it should appear on the bill.">
95+ <Input name="name" required maxLength={100} placeholder="Acme Corporation" defaultValue={values?.name ?? ""} />
96+ </Field>
97+ <Field label="Workspaces" hint="Slugs, separated by commas or one per line.">
98+ <Textarea name="workspaces" required rows={4} placeholder={"acme\nacme-labs"} defaultValue={values?.workspaces ?? ""} className="font-mono" />
99+ </Field>
100+ <div className="flex justify-end">
101+ <Button type="submit">Review</Button>
102+ </div>
103+ </form>
104+ </Section>
105+ </main>
106+ );
107+}
+35−0
1+{
2+ "name": "@g1t/sudo",
3+ "private": true,
4+ "type": "module",
5+ "scripts": {
6+ "build": "react-router build",
7+ "test": "node --test app/**/*.test.ts",
8+ "dev": "react-router dev",
9+ "typecheck": "wrangler types --include-env=false && react-router typegen && tsc -b --force",
10+ "deploy": "npm run build && wrangler deploy",
11+ "cf-typegen": "wrangler types --include-env=false",
12+ "postinstall": "wrangler types --include-env=false"
13+ },
14+ "dependencies": {
15+ "@g1t/contracts": "*",
16+ "@g1t/theme": "*",
17+ "lucide-react": "^1.49.0",
18+ "react": "^19.2.8",
19+ "react-dom": "^19.2.8",
20+ "react-router": "^8.4.0"
21+ },
22+ "devDependencies": {
23+ "@cloudflare/vite-plugin": "^1.62.4",
24+ "@react-router/dev": "^8.4.0",
25+ "@tailwindcss/vite": "^4.2.2",
26+ "@types/node": "^22.20.5",
27+ "@types/react": "^19.2.18",
28+ "@types/react-dom": "^19.2.7",
29+ "tailwindcss": "^4.2.2",
30+ "typescript": "^5.9.3",
31+ "vite": "^8.0.3",
32+ "wrangler": "^4.146.0"
33+ },
34+ "license": "MIT"
35+}
+1−0
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>
+8−0
1+import type { Config } from "@react-router/dev/config";
2+
3+export default {
4+ // Rendered on the server only: the pages ship no JavaScript at all (root
5+ // leaves out <Scripts />), so the content security policy can forbid
6+ // every script. Forms are plain HTML forms posting to route actions.
7+ ssr: true,
8+} satisfies Config;
+27−0
1+{
2+ "extends": "./tsconfig.json",
3+ "include": [
4+ ".react-router/types/**/*",
5+ "app/**/*",
6+ "workers/**/*",
7+ "worker-configuration.d.ts"
8+ ],
9+ "exclude": ["app/**/*.test.ts"],
10+ "compilerOptions": {
11+ "composite": true,
12+ "strict": true,
13+ "lib": ["DOM", "DOM.Iterable", "ES2022", "ESNext.Disposable"],
14+ "types": ["vite/client"],
15+ "target": "ES2022",
16+ "module": "ES2022",
17+ "moduleResolution": "bundler",
18+ "jsx": "react-jsx",
19+ "rootDirs": [".", "./.react-router/types"],
20+ "paths": {
21+ "~/*": ["./app/*"]
22+ },
23+ "esModuleInterop": true,
24+ "allowImportingTsExtensions": true,
25+ "resolveJsonModule": true
26+ }
27+}
+14−0
1+{
2+ "files": [],
3+ "references": [
4+ { "path": "./tsconfig.node.json" },
5+ { "path": "./tsconfig.cloudflare.json" }
6+ ],
7+ "compilerOptions": {
8+ "checkJs": true,
9+ "verbatimModuleSyntax": true,
10+ "skipLibCheck": true,
11+ "strict": true,
12+ "noEmit": true
13+ }
14+}
+13−0
1+{
2+ "extends": "./tsconfig.json",
3+ "include": ["vite.config.ts", "react-router.config.ts"],
4+ "compilerOptions": {
5+ "composite": true,
6+ "strict": true,
7+ "types": ["node"],
8+ "lib": ["ES2022"],
9+ "target": "ES2022",
10+ "module": "ES2022",
11+ "moduleResolution": "bundler"
12+ }
13+}
+15−0
1+import { reactRouter } from "@react-router/dev/vite";
2+import { cloudflare } from "@cloudflare/vite-plugin";
3+import tailwindcss from "@tailwindcss/vite";
4+import { defineConfig } from "vite";
5+
6+export default defineConfig({
7+ plugins: [
8+ cloudflare({ viteEnvironment: { name: "ssr" } }),
9+ tailwindcss(),
10+ reactRouter(),
11+ ],
12+ resolve: {
13+ tsconfigPaths: true,
14+ },
15+});
+68−0
1+import { RouterContextProvider, createRequestHandler } from "react-router";
2+
3+import { authorize, isSameOrigin, readSettings } from "../app/lib/access";
4+import { denied, secure } from "../app/lib/guard";
5+import { staffContext } from "../app/lib/staff";
6+
7+const requestHandler = createRequestHandler(
8+ () => import("virtual:react-router/server-build"),
9+ import.meta.env.MODE,
10+);
11+
12+/** Files the build emits for the pages; still behind the same check. */
13+const ASSET = /^\/(?:assets\/[\w.-]+|favicon\.svg)$/;
14+
15+/**
16+ * Every request, assets included, passes the same gate before anything
17+ * is served:
18+ *
19+ * 1. sudo is configured, or nothing is served at all;
20+ * 2. Cloudflare Access's token verifies (signature, audience, issuer, time);
21+ * 3. its email is on the staff list;
22+ * 4. a change is a POST from sudo's own pages.
23+ */
24+async function handle(request: Request, env: Env): Promise<Response> {
25+ const settings = readSettings(env);
26+ if (!settings) {
27+ return denied(
28+ 403,
29+ "sudo is not configured",
30+ "ACCESS_TEAM_DOMAIN, ACCESS_AUD and STAFF_EMAILS must all be set before sudo will answer. See apps/sudo/README.md.",
31+ );
32+ }
33+
34+ const auth = await authorize(request, settings);
35+ if (!auth.ok) {
36+ console.warn(JSON.stringify({ event: "sudo.denied", reason: auth.reason, email: auth.email ?? null, path: new URL(request.url).pathname }));
37+ return auth.reason === "not staff"
38+ ? denied(403, "Not staff", `${auth.email} is signed in, but is not on sudo's staff list.`)
39+ : denied(403, "Not allowed", "sudo is for g1t staff, signed in through Cloudflare Access.");
40+ }
41+
42+ const { method } = request;
43+ if (method !== "GET" && method !== "HEAD" && method !== "POST") {
44+ return denied(405, "Method not allowed", "sudo takes GET and POST only.");
45+ }
46+ if (method === "POST" && !isSameOrigin(request)) {
47+ console.warn(JSON.stringify({ event: "sudo.cross_site", email: auth.email, origin: request.headers.get("origin") }));
48+ return denied(403, "Refused", "Changes are only accepted from sudo's own pages.");
49+ }
50+
51+ const { pathname } = new URL(request.url);
52+ if (method !== "POST" && ASSET.test(pathname)) {
53+ return env.ASSETS.fetch(request);
54+ }
55+
56+ if (method === "POST") {
57+ console.log(JSON.stringify({ event: "sudo.change", email: auth.email, path: pathname }));
58+ }
59+ const context = new RouterContextProvider();
60+ context.set(staffContext, { email: auth.email });
61+ return requestHandler(request, context);
62+}
63+
64+export default {
65+ async fetch(request, env) {
66+ return secure(await handle(request, env));
67+ },
68+} satisfies ExportedHandler<Env>;
+14−0
1+import type { ServiceBinding } from "@g1t/contracts";
2+
3+declare global {
4+ namespace Cloudflare {
5+ interface Env {
6+ BILLING: ServiceBinding;
7+ ASSETS: Fetcher;
8+ ACCESS_TEAM_DOMAIN: string;
9+ ACCESS_AUD: string;
10+ STAFF_EMAILS: string;
11+ }
12+ }
13+ interface Env extends Cloudflare.Env {}
14+}
+28−0
1+{
2+ "$schema": "../../node_modules/wrangler/config-schema.json",
3+ "name": "g1t-sudo",
4+ "account_id": "1e6f2cffa3f445920836e8ebe446bb58",
5+ "compatibility_date": "2026-09-26",
6+ "main": "./workers/app.ts",
7+ // Staff only: reachable at sudo.g1t.sh, behind Cloudflare Access, and
8+ // nowhere else. No workers.dev address and no preview URLs, so there is
9+ // no way round Access to the worker.
10+ "routes": [{ "pattern": "sudo.g1t.sh", "custom_domain": true }],
11+ "workers_dev": false,
12+ "preview_urls": false,
13+ // Even the stylesheet goes through the worker, which checks the Access
14+ // token on every request before anything is served.
15+ "assets": { "binding": "ASSETS", "run_worker_first": true },
16+ "services": [{ "binding": "BILLING", "service": "g1t-billing" }],
17+ "vars": {
18+ // The Zero Trust team domain, such as `g1t.cloudflareaccess.com`.
19+ "ACCESS_TEAM_DOMAIN": "",
20+ // The Access application's Audience (AUD) tag.
21+ "ACCESS_AUD": "",
22+ // Who may use sudo, comma separated. Access lets them in; this
23+ // decides again, in case the Access policy is ever widened.
24+ "STAFF_EMAILS": "syntaqx@gmail.com"
25+ },
26+ "observability": { "enabled": true },
27+ "upload_source_maps": true
28+}
+29−14
7575 issues: number;
7676 pulls: number;
7777 } | null;
78− /** The workspace's agent credit, if billing is on and they may see it. */
79− creditMicros: number | null;
78+ /** Where the workspace stands against its usage limit, if billing is on. */
79+ limit: {
80+ exposureMicros: number;
81+ ceilingMicros: number | null;
82+ state: "ok" | "warning" | "stopped";
83+ comped: boolean;
84+ } | null;
8085 /** Whether g1t charges nothing for now, while it is being built out. */
8186 free?: boolean;
8287 /** What its agents have cost since the start of the month. */
211216 );
212217 }
213218
214−/** This month's spend against what is left, as Vercel shows a plan's usage. */
219+/**
220+ * This month's usage, and how close the workspace is to its usage limit,
221+ * as Vercel shows a plan's usage.
222+ */
215223 function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
216224 if (shell.monthUsageMicros == null) return null;
217225 const spent = shell.monthUsageMicros;
218− const left = shell.creditMicros;
219− const share = left != null && spent + left > 0 ? Math.min(1, spent / (spent + Math.max(left, 0))) : 0;
226+ const limit = shell.limit;
227+ const ceiling = limit?.ceilingMicros ?? null;
228+ const share = limit && ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0;
229+ const tone = limit?.state === "stopped" ? "text-danger" : limit?.state === "warning" ? "text-warn" : "text-faint";
230+ const bar = limit?.state === "stopped" ? "bg-danger" : limit?.state === "warning" ? "bg-warn" : "bg-accent";
220231 return (
221232 <Link
222233 to={`/${slug}/-/usage`}
230241 <span className="font-mono text-sm tabular-nums">${(spent / MICROS_PER_DOLLAR).toFixed(2)}</span>
231242 {shell.free ? (
232243 <span className="text-xs text-accent">Free for now</span>
244+ ) : limit?.comped ? (
245+ <span className="text-xs text-accent">Comped</span>
233246 ) : (
234− left != null && (
235− <span className={`text-xs ${left <= 0 ? "text-warn" : "text-faint"}`}>
236− ${(left / MICROS_PER_DOLLAR).toFixed(2)} left
247+ ceiling != null && (
248+ <span className={`text-xs ${tone}`}>
249+ {limit?.state === "stopped" ? "Limit reached" : `$${(ceiling / MICROS_PER_DOLLAR).toFixed(2)} limit`}
237250 </span>
238251 )
239252 )}
240253 </span>
241− <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
242− <span
243− className={`block h-full rounded-full ${left != null && left <= 0 ? "bg-warn" : "bg-accent"}`}
244− style={{ width: `${Math.max(share * 100, spent > 0 ? 3 : 0)}%` }}
245− />
246− </span>
254+ {ceiling != null && !limit?.comped && (
255+ <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
256+ <span
257+ className={`block h-full rounded-full ${bar}`}
258+ style={{ width: `${Math.max(share * 100, share > 0 ? 3 : 0)}%` }}
259+ />
260+ </span>
261+ )}
247262 </Link>
248263 );
249264 }
+12−4
9292 const path = params.owner && params.repo ? { namespace: params.owner, name: params.repo } : null;
9393 const now = new Date();
9494 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
95− const [listed, counts, account, usage] = await Promise.all([
95+ const [listed, counts, account, usage, limit] = await Promise.all([
9696 workspace ? projects.list(workspace.slug, user) : Promise.resolve(null),
9797 path ? work.counts(path, user) : Promise.resolve(null),
9898 workspace ? billing.account(workspace.slug, user) : Promise.resolve(null),
9999 workspace ? billing.usage(workspace.slug, user, monthStart) : Promise.resolve(null),
100+ workspace ? billing.limit(workspace.slug, user).catch(() => null) : Promise.resolve(null),
100101 ]);
101102 return {
102103 workspace,
118119 pulls: counts.value.pulls,
119120 }
120121 : null,
121− // While g1t is being built out nothing is charged, so no credit is shown.
122− creditMicros:
123− account?.ok && account.value.status.enabled && !account.value.status.free ? account.value.balanceMicros : null,
122+ // Where the workspace stands against its usage limit, once billing is on.
123+ limit:
124+ account?.ok && account.value.status.enabled && limit?.ok
125+ ? {
126+ exposureMicros: limit.value.exposureMicros,
127+ ceilingMicros: limit.value.ceilingMicros,
128+ state: limit.value.state,
129+ comped: limit.value.trust === "internal",
130+ }
131+ : null,
124132 free: account?.ok ? Boolean(account.value.status.free) : false,
125133 // While g1t is free every charge is zero, so usage is shown at cost.
126134 monthUsageMicros: usage?.ok ? (usage.value.free ? usage.value.usedMicros : usage.value.spentMicros) : null,
+5−5
300300 }
301301 : canRunAgents
302302 ? {
303− // Nothing to pay while g1t is being built out.
304− done: Boolean(shell?.free) || shell?.creditMicros == null || shell.creditMicros > 0,
305− title: "Add agent credit",
306− about: "g1t's agents are paid for from the workspace's credit, at what the model costs plus 20%.",
303+ // Done unless the workspace is near or at its usage limit.
304+ done: Boolean(shell?.free) || shell?.limit == null || shell.limit.comped || shell.limit.state === "ok",
305+ title: "Keep work running",
306+ about: "Usage is charged after it runs, at what it costs g1t plus a markup. Add a card under Billing, so g1t charges it as you near your limit instead of stopping work.",
307307 to: workspace ? `/${workspace}/-/billing` : null,
308− action: "Add credit",
308+ action: "Billing",
309309 }
310310 : {
311311 done: false,
+16−10
1717 }
1818
1919 export async function loader() {
20− const book = await billing.prices().catch(() => null);
21− return { book };
20+ const [book, status] = await Promise.all([billing.prices().catch(() => null), billing.status().catch(() => null)]);
21+ return { book, free: status?.free ?? false };
2222 }
2323
2424 /** A price in dollars, with as many digits as it needs to say anything. */
4949 },
5050 {
5151 title: "Limits that protect both of us",
52− body: "Usage not yet paid for can only go so far: $3 for a new workspace, growing with what it pays. At the limit, work stops instead of running up a bill. Owners can set a lower one.",
52+ body: "Usage not yet paid for can only go so far: $3 for a new workspace, growing with what it pays. With a card on file, g1t charges it as you near the limit, so work that is paid for never stops. Owners can set a lower limit of their own.",
5353 },
54+ {
55+ title: "Enterprise billing",
56+ body: "One bill, one limit and one set of terms for several workspaces, as GitHub Enterprise does. Write to us to set one up.",
57+ },
5458 ];
5559
5660 export default function Pricing({ loaderData }: Route.ComponentProps) {
57− const { book } = loaderData;
61+ const { book, free } = loaderData;
5862 const checked = book?.prices.map((p) => p.checkedAt).filter((at): at is string => !!at).sort().at(-1);
5963 return (
6064 <main className="mx-auto max-w-4xl px-4 py-12">
6468 g1t runs on Cloudflare and model providers, and passes those costs through. The numbers on this page are the
6569 live price book g1t charges from.
6670 </p>
67− <div className="mt-5 rounded-xl border border-accent/30 bg-accent/5 px-4 py-3 text-sm">
68− <span className="font-medium">Free while g1t is being built out.</span>{" "}
69− <span className="text-muted">
70− Usage is recorded at these prices but not charged for now. Paid features, such as Deployments, are charged.
71− </span>
72− </div>
71+ {free && (
72+ <div className="mt-5 rounded-xl border border-accent/30 bg-accent/5 px-4 py-3 text-sm">
73+ <span className="font-medium">Free while g1t is being built out.</span>{" "}
74+ <span className="text-muted">
75+ Usage is recorded at these prices but not charged for now. Paid features, such as Deployments, are charged.
76+ </span>
77+ </div>
78+ )}
7379
7480 <div className="mt-10 grid gap-4 sm:grid-cols-2">
7581 {HOW.map((item) => (
+13−7
162162 <h2 className="mt-12 font-medium">Agent credit</h2>
163163 <p className="mt-1 max-w-2xl text-sm text-muted">
164164 g1t agents that work on this workspace's repositories are paid for from
165− its credit: what the model cost, plus {account.marginPercent}%. Every sandbox, for agents, checks, the merge
166− queue and workflows, is metered by the second past 500 free minutes a month. With no credit, agents do not
167− start.
165+ its account: what the model cost, plus {account.marginPercent}%. Every sandbox, for agents, checks, the merge
166+ queue and workflows, is metered by the second past 500 free minutes a month. Credit added here pays usage in
167+ advance; the usage limit above decides whether work starts.
168168 </p>
169169
170170 <div
442442 },
443443 paid: { label: "Paid", detail: "Twice what the workspace has paid g1t, from $25 up to $1,000." },
444444 reviewed: { label: "Reviewed", detail: "Set by g1t for this workspace." },
445− internal: { label: "g1t", detail: "One of g1t's own workspaces: no limit." },
445+ internal: { label: "Comped", detail: "g1t covers this workspace's usage: nothing is charged, and there is no limit." },
446446 };
447447
448448 /**
463463 <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{trust.label}</span>
464464 </div>
465465 <p className="mt-1 max-w-2xl text-sm text-muted">
466− What this month's usage cost g1t, or is charged, whichever is more, less what was paid this month. At the limit,
467− new sandboxes and builds stop and apps pause until the workspace pays or the month turns. Work already running
468− finishes.
466+ What this month's usage cost g1t, or is charged, whichever is more, less what was paid this month. With a card on
467+ file, g1t charges it as the workspace nears the limit, so its work does not stop. Without one, at the limit new
468+ sandboxes and builds stop and apps pause until it pays or the month turns. Work already running finishes.
469469 </p>
470470 <p className="mt-4 text-2xl font-semibold tabular-nums tracking-tight">
471471 {dollars(limit.exposureMicros)}
476476 <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} />
477477 </div>
478478 )}
479+ {limit.account.startsWith("ent_") && (
480+ <p className="mt-3 text-sm text-muted">
481+ Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are
482+ for all of its workspaces together.
483+ </p>
484+ )}
479485 {limit.message && <p className="mt-3 text-sm">{limit.message}</p>}
480486 <p className="mt-3 text-xs text-faint">
481487 {trust.detail}
+179−0
376376 #[serde(rename_all = "camelCase")]
377377 pub struct Limit {
378378 pub workspace: String,
379+ /// The account that pays, whose usage and payments the limit counts:
380+ /// the workspace's own, or its enterprise's.
381+ #[serde(default)]
382+ pub account: String,
383+ #[serde(default)]
384+ pub account_name: String,
379385 pub trust: Trust,
380386 /// Usage this month (UTC) less what was paid this month.
381387 pub exposure_micros: i64,
480486 pub model_margin_percent: u32,
481487 }
482488
489+/// Who pays: a billing account. Every workspace has one; by default its
490+/// own. An enterprise account pays for several workspaces at once, as
491+/// GitHub Enterprise does: one bill, one limit, one set of terms.
492+#[derive(Clone, Debug, Serialize, Deserialize)]
493+#[serde(rename_all = "camelCase")]
494+pub struct BillingAccount {
495+ /// `ws_<slug>` for a workspace's own account; `ent_…` for an enterprise.
496+ pub id: String,
497+ pub kind: AccountKind,
498+ pub name: String,
499+ pub terms: Terms,
500+ /// The workspaces it pays for.
501+ pub workspaces: Vec<String>,
502+ pub created_at: String,
503+}
504+
505+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
506+#[serde(rename_all = "snake_case")]
507+pub enum AccountKind {
508+ Workspace,
509+ Enterprise,
510+}
511+
512+/// How an account is charged. Standard unless g1t set otherwise in sudo.
513+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
514+#[serde(rename_all = "camelCase")]
515+pub struct Terms {
516+ pub kind: TermsKind,
517+ /// Off every usage charge, in percent. Custom terms only.
518+ #[serde(default)]
519+ pub discount_percent: u32,
520+ /// A ceiling on unpaid usage that replaces the one trust would give.
521+ #[serde(default)]
522+ pub ceiling_micros: Option<i64>,
523+ /// Why, for whoever looks next.
524+ #[serde(default)]
525+ pub note: String,
526+ /// When the terms end and the account goes back to standard.
527+ #[serde(default)]
528+ pub until: Option<String>,
529+ #[serde(default)]
530+ pub set_by: Option<String>,
531+ #[serde(default)]
532+ pub set_at: Option<String>,
533+}
534+
535+impl Terms {
536+ pub fn standard() -> Self {
537+ Terms {
538+ kind: TermsKind::Standard,
539+ discount_percent: 0,
540+ ceiling_micros: None,
541+ note: String::new(),
542+ until: None,
543+ set_by: None,
544+ set_at: None,
545+ }
546+ }
547+
548+ /// What a charge becomes under these terms.
549+ pub fn apply(&self, charge_micros: i64) -> i64 {
550+ match self.kind {
551+ TermsKind::Comped => 0,
552+ TermsKind::Custom => charge_micros * i64::from(100 - self.discount_percent.min(100)) / 100,
553+ TermsKind::Standard => charge_micros,
554+ }
555+ }
556+}
557+
558+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
559+#[serde(rename_all = "snake_case")]
560+pub enum TermsKind {
561+ /// Prices as published, limits by trust.
562+ Standard,
563+ /// Nothing charged; usage still recorded with its cost. Paid features
564+ /// are on without a plan. For g1t's own workspaces, partners, and the
565+ /// like.
566+ Comped,
567+ /// A discount, a ceiling, or both.
568+ Custom,
569+}
570+
571+// --- Staff (sudo.g1t.sh) ------------------------------------------------------
572+//
573+// Called only by the sudo app, which only g1t staff can reach (behind
574+// Cloudflare Access). Each change names who made it, and is kept in the
575+// audit log.
576+
577+/// `admin_accounts`: every billing account, with where each stands this
578+/// month. Returns `Vec<AccountSummary>`.
579+#[derive(Debug, Default, Serialize, Deserialize)]
580+pub struct AdminAccountsArgs {
581+ #[serde(default)]
582+ pub query: Option<String>,
583+}
584+
585+#[derive(Clone, Debug, Serialize, Deserialize)]
586+#[serde(rename_all = "camelCase")]
587+pub struct AccountSummary {
588+ pub account: BillingAccount,
589+ pub limit: Limit,
590+ /// Charged this month, after terms.
591+ pub charged_micros: i64,
592+ /// What this month's usage cost g1t.
593+ pub cost_micros: i64,
594+ /// Paid, ever.
595+ pub paid_micros: i64,
596+}
597+
598+/// `admin_account`: one account in full. Returns `Outcome<AccountDetail>`.
599+#[derive(Debug, Serialize, Deserialize)]
600+pub struct AdminAccountArgs {
601+ /// An account id, or a workspace slug.
602+ pub id: String,
603+}
604+
605+#[derive(Clone, Debug, Serialize, Deserialize)]
606+#[serde(rename_all = "camelCase")]
607+pub struct AccountDetail {
608+ pub summary: AccountSummary,
609+ /// Each workspace's limit, for an enterprise.
610+ pub workspaces: Vec<Limit>,
611+ pub ledger: Vec<LedgerEntry>,
612+ pub audit: Vec<AdminAction>,
613+}
614+
615+/// `admin_set_terms`. Returns `Outcome<BillingAccount>`.
616+#[derive(Debug, Serialize, Deserialize)]
617+pub struct AdminSetTermsArgs {
618+ pub id: String,
619+ pub terms: Terms,
620+ pub by: String,
621+}
622+
623+/// `admin_create_enterprise`. Returns `Outcome<BillingAccount>`.
624+#[derive(Debug, Serialize, Deserialize)]
625+pub struct AdminCreateEnterpriseArgs {
626+ pub name: String,
627+ pub workspaces: Vec<String>,
628+ pub by: String,
629+}
630+
631+/// `admin_attach`: moves a workspace onto an enterprise account, or back
632+/// onto its own with `account: None`. Returns `Outcome<BillingAccount>`.
633+#[derive(Debug, Serialize, Deserialize)]
634+pub struct AdminAttachArgs {
635+ pub workspace: String,
636+ pub account: Option<String>,
637+ pub by: String,
638+}
639+
640+/// `admin_credit`: money g1t gives a workspace, such as a refund or a
641+/// goodwill credit. Returns `Outcome<LedgerEntry>`.
642+#[derive(Debug, Serialize, Deserialize)]
643+pub struct AdminCreditArgs {
644+ pub workspace: String,
645+ pub amount_micros: i64,
646+ pub note: String,
647+ pub by: String,
648+}
649+
650+/// One change made in sudo.
651+#[derive(Clone, Debug, Serialize, Deserialize)]
652+#[serde(rename_all = "camelCase")]
653+pub struct AdminAction {
654+ pub id: String,
655+ pub account: String,
656+ pub action: String,
657+ pub detail: String,
658+ pub by: String,
659+ pub created_at: String,
660+}
661+
483662 /// What a feature's plan costs and includes.
484663 #[derive(Clone, Debug, Serialize, Deserialize)]
485664 #[serde(rename_all = "camelCase")]
+37−1
856856 limits and their warnings; prepaid credit retired.
857857 4. Per-workspace allow-lists of projects for each feature, and per-project
858858 opt-out; "nothing to deploy" detection.
859−5. Turn off FREE_WHILE_BUILDING when the user says so.
859+5. Turn off FREE_WHILE_BUILDING when the user says so. **Done 2026-10-05.**
860+
861+Shipped by 2026-10-05: sandbox seconds for every sandbox; the price book
862+and its keeper (runs settled to AI Gateway's price every 15 minutes;
863+Container and Workers costs checked against Cloudflare's billable usage
864+and container analytics daily, with a public change log on
865+g1t.sh/pricing); usage limits by trust with automatic payment near the
866+limit; app traffic counted toward limits as it happens; billing accounts,
867+terms and enterprises; free mode off, syntaqx comped.
868+
869+Still to build, in order: Stripe card-on-file without a payment (setup
870+mode) and webhooks; month-end invoices for postpaid usage (and one
871+invoice per enterprise); the subscription with activations as items;
872+storage and git-operation meters; limit warnings by email at 50/80/100%;
873+self-serve enterprise management for enterprise owners.
874+
875+### Accounts, terms and enterprises
876+
877+Every workspace is paid for by a billing account: its own (`ws_<slug>`)
878+or an enterprise's (`ent_…`), which pays for several workspaces with one
879+limit, one set of terms and, once invoices exist, one bill, as GitHub
880+Enterprise does. Terms are standard, comped (nothing charged, usage still
881+recorded at cost, paid features on) or custom (a discount, its own
882+ceiling, an end date). g1t staff manage them in **sudo.g1t.sh**, a
883+separate Worker behind Cloudflare Access that also verifies the Access
884+token itself and allows only listed staff emails; every change is kept
885+with who made it and why.
886+
887+### Limits: stop non-payers, never payers
888+
889+The limit is on usage not yet paid for, counted at cost to g1t or charge,
890+whichever is more: $3 before any live payment, then twice what has been
891+paid ($25 to $1,000), or what staff set. A workspace with a card on file
892+is charged automatically near its limit, which both pays what it owes and
893+raises the limit, so paying users are never stopped. A declined card
894+stops work until paid. The owner's own spend limit always means stop.
895+Test-mode payments never lower exposure or raise trust.
860896
861897 ## Agents and models
862898
+29−0
2626 "sharp": "^0.35.3"
2727 }
2828 },
29+ "apps/sudo": {
30+ "name": "@g1t/sudo",
31+ "hasInstallScript": true,
32+ "license": "MIT",
33+ "dependencies": {
34+ "@g1t/contracts": "*",
35+ "@g1t/theme": "*",
36+ "lucide-react": "^1.49.0",
37+ "react": "^19.2.8",
38+ "react-dom": "^19.2.8",
39+ "react-router": "^8.4.0"
40+ },
41+ "devDependencies": {
42+ "@cloudflare/vite-plugin": "^1.62.4",
43+ "@react-router/dev": "^8.4.0",
44+ "@tailwindcss/vite": "^4.2.2",
45+ "@types/node": "^22.20.5",
46+ "@types/react": "^19.2.18",
47+ "@types/react-dom": "^19.2.7",
48+ "tailwindcss": "^4.2.2",
49+ "typescript": "^5.9.3",
50+ "vite": "^8.0.3",
51+ "wrangler": "^4.146.0"
52+ }
53+ },
2954 "apps/web": {
3055 "name": "@g1t/web",
3156 "hasInstallScript": true,
16041629 "resolved": "services/runner",
16051630 "link": true
16061631 },
1632+ "node_modules/@g1t/sudo": {
1633+ "resolved": "apps/sudo",
1634+ "link": true
1635+ },
16071636 "node_modules/@g1t/theme": {
16081637 "resolved": "packages/theme",
16091638 "link": true
+54−0
7272 * open to them: a few dollars of model cost each, out of one pool, until a
7373 * date. Mirrors `Trial` in `crates/contracts/src/billing.rs`.
7474 */
75+/** How an account is charged. Standard unless g1t set otherwise in sudo. */
76+export type Terms = {
77+ kind: "standard" | "comped" | "custom";
78+ discountPercent: number;
79+ ceilingMicros: number | null;
80+ note: string;
81+ until: string | null;
82+ setBy: string | null;
83+ setAt: string | null;
84+};
85+
86+/**
87+ * Who pays: a workspace's own account, or an enterprise's, which pays for
88+ * several workspaces with one bill and one limit.
89+ */
90+export type PayingAccount = {
91+ id: string;
92+ kind: "workspace" | "enterprise";
93+ name: string;
94+ terms: Terms;
95+ workspaces: string[];
96+ createdAt: string;
97+};
98+
99+export type AccountSummary = {
100+ account: PayingAccount;
101+ limit: Limit;
102+ chargedMicros: number;
103+ costMicros: number;
104+ paidMicros: number;
105+};
106+
107+export type AdminAction = { id: string; account: string; action: string; detail: string; by: string; createdAt: string };
108+
109+export type AccountDetail = {
110+ summary: AccountSummary;
111+ workspaces: Limit[];
112+ ledger: LedgerEntry[];
113+ audit: AdminAction[];
114+};
115+
116+/** Staff-only billing, for sudo.g1t.sh. Every change names who made it. */
117+export interface BillingAdminApi {
118+ accounts(query?: string): Promise<AccountSummary[]>;
119+ account(id: string): Promise<Result<AccountDetail>>;
120+ setTerms(id: string, terms: Terms, by: string): Promise<Result<PayingAccount>>;
121+ createEnterprise(name: string, workspaces: string[], by: string): Promise<Result<PayingAccount>>;
122+ attach(workspace: string, account: string | null, by: string): Promise<Result<PayingAccount>>;
123+ credit(workspace: string, amountMicros: number, note: string, by: string): Promise<Result<LedgerEntry>>;
124+}
125+
75126 /** How much a workspace has earned g1t's trust with money. */
76127 export type Trust = "new" | "paid" | "reviewed" | "internal";
77128
83134 */
84135 export type Limit = {
85136 workspace: string;
137+ /** The account that pays: the workspace's own (`ws_<slug>`), or its enterprise's. */
138+ account: string;
139+ accountName: string;
86140 trust: Trust;
87141 exposureMicros: number;
88142 /** The lower of g1t's ceiling and the owner's spend limit; null for g1t's own. */
+13−1
11 import type { ActionsApi } from "./actions";
2−import type { BillingApi } from "./billing";
2+import type { BillingAdminApi, BillingApi } from "./billing";
33 import type { DeploymentsApi } from "./deployments";
44 import type { ProjectsApi } from "./projects";
55 import type { EventsApi } from "./events";
209209 };
210210 }
211211
212+export function billingAdminClient(service: ServiceBinding): BillingAdminApi {
213+ const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
214+ return {
215+ accounts: (query) => call("admin_accounts", { query: query ?? null }),
216+ account: (id) => call("admin_account", { id }),
217+ setTerms: (id, terms, by) => call("admin_set_terms", { id, terms, by }),
218+ createEnterprise: (name, workspaces, by) => call("admin_create_enterprise", { name, workspaces, by }),
219+ attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }),
220+ credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }),
221+ };
222+}
223+
212224 export function eventsClient(service: ServiceBinding): EventsApi {
213225 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
214226 return {
+1−0
3030 apps/api
3131 services/pages
3232 apps/web
33+ apps/sudo
3334 apps/docs
3435 )
3536
+58−0
1+-- Who pays for a workspace, and on what terms. See src/accounts.rs.
2+
3+-- A billing account: a workspace's own (`ws_<slug>`, a row only once its
4+-- terms differ from standard), or an enterprise (`ent_…`) paying for
5+-- several workspaces.
6+CREATE TABLE billing_accounts (
7+ id TEXT PRIMARY KEY,
8+ -- workspace or enterprise.
9+ kind TEXT NOT NULL,
10+ name TEXT NOT NULL,
11+ -- standard, comped or custom.
12+ terms_kind TEXT NOT NULL DEFAULT 'standard',
13+ discount_percent INTEGER NOT NULL DEFAULT 0,
14+ -- Replaces the ceiling trust would give, when set.
15+ ceiling_micros INTEGER,
16+ note TEXT NOT NULL DEFAULT '',
17+ -- When the terms end; standard after.
18+ terms_until TEXT,
19+ terms_set_by TEXT,
20+ terms_set_at TEXT,
21+ -- The payment provider's customer, for an enterprise's one bill.
22+ customer_id TEXT,
23+ created_by TEXT NOT NULL,
24+ created_at TEXT NOT NULL
25+);
26+
27+-- Workspaces an enterprise pays for. A workspace not here pays for itself.
28+CREATE TABLE account_members (
29+ workspace TEXT PRIMARY KEY,
30+ account_id TEXT NOT NULL,
31+ added_by TEXT NOT NULL,
32+ added_at TEXT NOT NULL
33+);
34+CREATE INDEX account_members_by_account ON account_members (account_id);
35+
36+-- Every change made in sudo.g1t.sh, and who made it.
37+CREATE TABLE admin_actions (
38+ id TEXT PRIMARY KEY,
39+ account TEXT NOT NULL,
40+ -- terms, create, attach, detach, credit.
41+ action TEXT NOT NULL,
42+ detail TEXT NOT NULL,
43+ by TEXT NOT NULL,
44+ created_at TEXT NOT NULL
45+);
46+CREATE INDEX admin_actions_by_account ON admin_actions (account, created_at);
47+
48+-- g1t's own workspace runs comped (it was LIMIT_EXEMPT).
49+INSERT INTO billing_accounts (id, kind, name, terms_kind, note, terms_set_by, terms_set_at, created_by, created_at)
50+VALUES ('ws_syntaqx', 'workspace', 'syntaqx', 'comped', 'g1t''s own workspace', 'migration', '2026-10-05T00:00:00Z', 'migration', '2026-10-05T00:00:00Z');
51+INSERT INTO admin_actions (id, account, action, detail, by, created_at)
52+VALUES ('adm_migration_syntaqx', 'ws_syntaqx', 'terms', 'standard → comped: g1t''s own workspace', 'migration', '2026-10-05T00:00:00Z');
53+
54+-- Ceilings set by hand before accounts existed become custom terms.
55+INSERT INTO billing_accounts (id, kind, name, terms_kind, ceiling_micros, note, terms_set_by, terms_set_at, created_by, created_at)
56+SELECT 'ws_' || workspace, 'workspace', workspace, 'custom', ceiling_micros, 'Ceiling set before accounts', 'migration',
57+ '2026-10-05T00:00:00Z', 'migration', '2026-10-05T00:00:00Z'
58+FROM limits WHERE ceiling_micros IS NOT NULL AND workspace <> 'syntaqx';
+6−0
1+-- Paying automatically at the limit: when a workspace with a card on file
2+-- nears its ceiling, g1t charges the card for what is owed instead of
3+-- stopping its work. A declined card stops work until it is paid. See
4+-- `autopay` in src/limits.rs.
5+ALTER TABLE limits ADD COLUMN autopay_failed_at TEXT;
6+ALTER TABLE limits ADD COLUMN autopay_error TEXT;
+537−0
1+//! Who pays for a workspace, and on what terms.
2+//!
3+//! Every workspace is paid for by a billing account. By default that is
4+//! its own (`ws_<slug>`), on standard terms, and needs no row. g1t staff
5+//! can change that in sudo.g1t.sh:
6+//!
7+//! - **Terms.** Comped (nothing charged, usage still recorded with its
8+//! cost; for g1t's own workspaces and partners), or custom (a discount,
9+//! a ceiling of its own, or both), optionally until a date.
10+//! - **Enterprises.** One account paying for several workspaces, as GitHub
11+//! Enterprise does: their usage and payments count together against one
12+//! limit, on one set of terms.
13+//! - **Credits**, such as refunds.
14+//!
15+//! Every change names who made it and is kept in `admin_actions`.
16+
17+use g1t_contracts::billing::{
18+ AccountDetail, AccountKind, AccountSummary, AdminAccountArgs, AdminAccountsArgs, AdminAction, AdminAttachArgs,
19+ AdminCreateEnterpriseArgs, AdminCreditArgs, AdminSetTermsArgs, BillingAccount, EntryKind, LedgerEntry, Terms,
20+ TermsKind,
21+};
22+use g1t_contracts::time::rfc3339;
23+use g1t_contracts::{FailureCode, Outcome, new_id};
24+use g1t_kit::now_ms;
25+use serde::Deserialize;
26+use worker::Result;
27+use worker::wasm_bindgen::JsValue;
28+
29+use crate::{Billing, LedgerRow, optional};
30+
31+#[derive(Deserialize)]
32+struct AccountRow {
33+ id: String,
34+ kind: String,
35+ name: String,
36+ terms_kind: String,
37+ discount_percent: u32,
38+ ceiling_micros: Option<i64>,
39+ note: String,
40+ terms_until: Option<String>,
41+ terms_set_by: Option<String>,
42+ terms_set_at: Option<String>,
43+ created_at: String,
44+}
45+
46+impl AccountRow {
47+ fn terms(&self) -> Terms {
48+ let expired = self.terms_until.as_deref().is_some_and(|until| until < rfc3339(now_ms()).as_str());
49+ if expired {
50+ return Terms::standard();
51+ }
52+ Terms {
53+ kind: match self.terms_kind.as_str() {
54+ "comped" => TermsKind::Comped,
55+ "custom" => TermsKind::Custom,
56+ _ => TermsKind::Standard,
57+ },
58+ discount_percent: self.discount_percent,
59+ ceiling_micros: self.ceiling_micros,
60+ note: self.note.clone(),
61+ until: self.terms_until.clone(),
62+ set_by: self.terms_set_by.clone(),
63+ set_at: self.terms_set_at.clone(),
64+ }
65+ }
66+}
67+
68+#[derive(Deserialize)]
69+struct Member {
70+ workspace: String,
71+}
72+
73+#[derive(Deserialize)]
74+struct ActionRow {
75+ id: String,
76+ account: String,
77+ action: String,
78+ detail: String,
79+ by: String,
80+ created_at: String,
81+}
82+
83+/// `ws_<slug>`: a workspace's own account.
84+pub(crate) fn own_account(workspace: &str) -> String {
85+ format!("ws_{}", workspace.to_lowercase())
86+}
87+
88+fn kind_text(kind: TermsKind) -> &'static str {
89+ match kind {
90+ TermsKind::Standard => "standard",
91+ TermsKind::Comped => "comped",
92+ TermsKind::Custom => "custom",
93+ }
94+}
95+
96+fn describe(terms: &Terms) -> String {
97+ let mut text = match terms.kind {
98+ TermsKind::Standard => "standard".to_owned(),
99+ TermsKind::Comped => "comped".to_owned(),
100+ TermsKind::Custom => {
101+ let mut parts = vec![];
102+ if terms.discount_percent > 0 {
103+ parts.push(format!("{}% off", terms.discount_percent));
104+ }
105+ if let Some(ceiling) = terms.ceiling_micros {
106+ parts.push(format!("ceiling {}", crate::features::dollars(ceiling)));
107+ }
108+ format!("custom ({})", if parts.is_empty() { "no changes".to_owned() } else { parts.join(", ") })
109+ }
110+ };
111+ if let Some(until) = &terms.until {
112+ text.push_str(&format!(" until {}", &until[..until.len().min(10)]));
113+ }
114+ if !terms.note.is_empty() {
115+ text.push_str(&format!(": {}", terms.note));
116+ }
117+ text
118+}
119+
120+impl Billing {
121+ async fn account_row(&self, id: &str) -> Result<Option<AccountRow>> {
122+ self.db
123+ .prepare("SELECT * FROM billing_accounts WHERE id = ?")
124+ .bind(&[id.into()])?
125+ .first::<AccountRow>(None)
126+ .await
127+ }
128+
129+ async fn members(&self, account: &str) -> Result<Vec<String>> {
130+ Ok(self
131+ .db
132+ .prepare("SELECT workspace FROM account_members WHERE account_id = ? ORDER BY workspace")
133+ .bind(&[account.into()])?
134+ .all()
135+ .await?
136+ .results::<Member>()?
137+ .into_iter()
138+ .map(|m| m.workspace)
139+ .collect())
140+ }
141+
142+ fn to_account(&self, row: &AccountRow, workspaces: Vec<String>) -> BillingAccount {
143+ BillingAccount {
144+ id: row.id.clone(),
145+ kind: if row.kind == "enterprise" { AccountKind::Enterprise } else { AccountKind::Workspace },
146+ name: row.name.clone(),
147+ terms: row.terms(),
148+ workspaces,
149+ created_at: row.created_at.clone(),
150+ }
151+ }
152+
153+ /// The account that pays for a workspace.
154+ pub(crate) async fn account_of(&self, workspace: &str) -> Result<BillingAccount> {
155+ let workspace = workspace.to_lowercase();
156+ #[derive(Deserialize)]
157+ struct Link {
158+ account_id: String,
159+ }
160+ let linked = self
161+ .db
162+ .prepare("SELECT account_id FROM account_members WHERE workspace = ?")
163+ .bind(&[workspace.as_str().into()])?
164+ .first::<Link>(None)
165+ .await?;
166+ if let Some(link) = linked {
167+ if let Some(row) = self.account_row(&link.account_id).await? {
168+ let members = self.members(&row.id).await?;
169+ return Ok(self.to_account(&row, members));
170+ }
171+ }
172+ let id = own_account(&workspace);
173+ Ok(match self.account_row(&id).await? {
174+ Some(row) => self.to_account(&row, vec![workspace]),
175+ None => BillingAccount {
176+ id,
177+ kind: AccountKind::Workspace,
178+ name: workspace.clone(),
179+ terms: Terms::standard(),
180+ workspaces: vec![workspace],
181+ created_at: String::new(),
182+ },
183+ })
184+ }
185+
186+ /// The terms a workspace is charged on.
187+ pub(crate) async fn terms_of(&self, workspace: &str) -> Result<Terms> {
188+ Ok(self.account_of(workspace).await?.terms)
189+ }
190+
191+ /// An account by id, or the account of a workspace by its slug.
192+ async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> {
193+ let id = id.trim().to_lowercase();
194+ if id.starts_with("ent_") {
195+ return Ok(match self.account_row(&id).await? {
196+ Some(row) => {
197+ let members = self.members(&row.id).await?;
198+ Some(self.to_account(&row, members))
199+ }
200+ None => None,
201+ });
202+ }
203+ let slug = id.strip_prefix("ws_").unwrap_or(&id);
204+ if slug.is_empty() {
205+ return Ok(None);
206+ }
207+ Ok(Some(self.account_of(slug).await?))
208+ }
209+
210+ async fn audit(&self, account: &str, action: &str, detail: &str, by: &str) -> Result<()> {
211+ let now = now_ms();
212+ self.db
213+ .prepare("INSERT INTO admin_actions (id, account, action, detail, by, created_at) VALUES (?, ?, ?, ?, ?, ?)")
214+ .bind(&[
215+ new_id("adm", now).into(),
216+ account.into(),
217+ action.into(),
218+ detail.into(),
219+ by.into(),
220+ rfc3339(now).into(),
221+ ])?
222+ .run()
223+ .await?;
224+ Ok(())
225+ }
226+
227+ /// Where an account stands this month.
228+ async fn summary(&self, account: BillingAccount) -> Result<AccountSummary> {
229+ let first = account.workspaces.first().cloned().unwrap_or_else(|| account.name.clone());
230+ let limit = self.limit_of(&first).await?;
231+ #[derive(Deserialize)]
232+ struct Totals {
233+ charged: Option<i64>,
234+ cost: Option<i64>,
235+ }
236+ #[derive(Deserialize)]
237+ struct Paid {
238+ paid: Option<i64>,
239+ }
240+ let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
241+ let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
242+ if values.is_empty() {
243+ values.push(JsValue::from(""));
244+ }
245+ let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
246+ let mut with_month = values.clone();
247+ with_month.push(month_start.as_str().into());
248+ let totals = self
249+ .db
250+ .prepare(format!(
251+ "SELECT -SUM(amount_micros) AS charged, SUM(cost_micros) AS cost FROM ledger
252+ WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ?"
253+ ))
254+ .bind(&with_month)?
255+ .first::<Totals>(None)
256+ .await?;
257+ let paid = self
258+ .db
259+ .prepare(format!("SELECT SUM(amount_micros) AS paid FROM ledger WHERE kind = 'top_up' AND workspace IN ({marks})"))
260+ .bind(&values)?
261+ .first::<Paid>(None)
262+ .await?;
263+ Ok(AccountSummary {
264+ account,
265+ limit,
266+ charged_micros: totals.as_ref().and_then(|t| t.charged).unwrap_or(0),
267+ cost_micros: totals.and_then(|t| t.cost).unwrap_or(0),
268+ paid_micros: paid.and_then(|p| p.paid).unwrap_or(0),
269+ })
270+ }
271+
272+ // --- Staff ------------------------------------------------------------
273+
274+ pub(crate) async fn admin_accounts(&self, a: AdminAccountsArgs) -> Result<Vec<AccountSummary>> {
275+ // Every workspace that has used or paid for anything, and every
276+ // account with terms of its own.
277+ #[derive(Deserialize)]
278+ struct Slug {
279+ workspace: String,
280+ }
281+ let mut slugs: Vec<String> = self
282+ .db
283+ .prepare(
284+ "SELECT DISTINCT workspace FROM ledger
285+ UNION SELECT workspace FROM accounts
286+ UNION SELECT substr(id, 4) FROM billing_accounts WHERE kind = 'workspace'",
287+ )
288+ .all()
289+ .await?
290+ .results::<Slug>()?
291+ .into_iter()
292+ .map(|s| s.workspace)
293+ .collect();
294+ if let Some(query) = a.query.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
295+ let query = query.to_lowercase();
296+ slugs.retain(|slug| slug.contains(&query));
297+ }
298+ let mut seen = std::collections::HashSet::new();
299+ let mut summaries = vec![];
300+ for slug in slugs.into_iter().take(200) {
301+ let account = self.account_of(&slug).await?;
302+ if !seen.insert(account.id.clone()) {
303+ continue;
304+ }
305+ summaries.push(self.summary(account).await?);
306+ }
307+ // Enterprises with no usage yet.
308+ #[derive(Deserialize)]
309+ struct Id {
310+ id: String,
311+ }
312+ let enterprises = self
313+ .db
314+ .prepare("SELECT id FROM billing_accounts WHERE kind = 'enterprise'")
315+ .all()
316+ .await?
317+ .results::<Id>()?;
318+ for Id { id } in enterprises {
319+ if seen.contains(&id) {
320+ continue;
321+ }
322+ if let Some(account) = self.find_account(&id).await? {
323+ if a.query.as_deref().is_none_or(|q| account.name.to_lowercase().contains(&q.to_lowercase())) {
324+ seen.insert(id);
325+ summaries.push(self.summary(account).await?);
326+ }
327+ }
328+ }
329+ summaries.sort_by(|x, y| y.limit.exposure_micros.cmp(&x.limit.exposure_micros));
330+ Ok(summaries)
331+ }
332+
333+ pub(crate) async fn admin_account(&self, a: AdminAccountArgs) -> Result<Outcome<AccountDetail>> {
334+ let Some(account) = self.find_account(&a.id).await? else {
335+ return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
336+ };
337+ let mut workspaces = vec![];
338+ for workspace in &account.workspaces {
339+ workspaces.push(self.limit_of(workspace).await?);
340+ }
341+ let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
342+ let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
343+ if values.is_empty() {
344+ values.push(JsValue::from(""));
345+ }
346+ let ledger = self
347+ .db
348+ .prepare(format!("SELECT * FROM ledger WHERE workspace IN ({marks}) ORDER BY id DESC LIMIT 100"))
349+ .bind(&values)?
350+ .all()
351+ .await?
352+ .results::<LedgerRow>()?
353+ .into_iter()
354+ .map(LedgerEntry::from)
355+ .collect();
356+ let audit = self
357+ .db
358+ .prepare("SELECT * FROM admin_actions WHERE account = ? ORDER BY created_at DESC LIMIT 50")
359+ .bind(&[account.id.as_str().into()])?
360+ .all()
361+ .await?
362+ .results::<ActionRow>()?
363+ .into_iter()
364+ .map(|row| AdminAction {
365+ id: row.id,
366+ account: row.account,
367+ action: row.action,
368+ detail: row.detail,
369+ by: row.by,
370+ created_at: row.created_at,
371+ })
372+ .collect();
373+ Ok(Outcome::Ok(AccountDetail { summary: self.summary(account).await?, workspaces, ledger, audit }))
374+ }
375+
376+ pub(crate) async fn admin_set_terms(&self, a: AdminSetTermsArgs) -> Result<Outcome<BillingAccount>> {
377+ if a.by.trim().is_empty() {
378+ return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change."));
379+ }
380+ if a.terms.kind != TermsKind::Standard && a.terms.note.trim().is_empty() {
381+ return Ok(Outcome::fail(FailureCode::Invalid, "Say why, in the note."));
382+ }
383+ if a.terms.discount_percent > 100 || a.terms.ceiling_micros.is_some_and(|c| c < 0) {
384+ return Ok(Outcome::fail(FailureCode::Invalid, "A discount is 0 to 100%, and a ceiling is not negative."));
385+ }
386+ let Some(account) = self.find_account(&a.id).await? else {
387+ return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
388+ };
389+ let now = rfc3339(now_ms());
390+ // A workspace's own account gets a row the first time its terms change.
391+ self.db
392+ .prepare(
393+ "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, ceiling_micros, note,
394+ terms_until, terms_set_by, terms_set_at, created_by, created_at)
395+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?9, ?10)
396+ ON CONFLICT (id) DO UPDATE SET terms_kind = ?4, discount_percent = ?5, ceiling_micros = ?6,
397+ note = ?7, terms_until = ?8, terms_set_by = ?9, terms_set_at = ?10",
398+ )
399+ .bind(&[
400+ account.id.as_str().into(),
401+ if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
402+ account.name.as_str().into(),
403+ kind_text(a.terms.kind).into(),
404+ a.terms.discount_percent.into(),
405+ a.terms.ceiling_micros.map_or(JsValue::NULL, |c| (c as f64).into()),
406+ a.terms.note.trim().into(),
407+ optional(a.terms.until.as_deref()),
408+ a.by.as_str().into(),
409+ now.as_str().into(),
410+ ])?
411+ .run()
412+ .await?;
413+ self.audit(&account.id, "terms", &format!("{} → {}", describe(&account.terms), describe(&a.terms)), &a.by)
414+ .await?;
415+ Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
416+ }
417+
418+ pub(crate) async fn admin_create_enterprise(&self, a: AdminCreateEnterpriseArgs) -> Result<Outcome<BillingAccount>> {
419+ let name = a.name.trim();
420+ if name.is_empty() || a.by.trim().is_empty() {
421+ return Ok(Outcome::fail(FailureCode::Invalid, "An enterprise needs a name, and who is making it."));
422+ }
423+ let now = now_ms();
424+ let id = new_id("ent", now).to_lowercase();
425+ self.db
426+ .prepare(
427+ "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at)
428+ VALUES (?, 'enterprise', ?, 'standard', 0, '', ?, ?)",
429+ )
430+ .bind(&[id.as_str().into(), name.into(), a.by.as_str().into(), rfc3339(now).into()])?
431+ .run()
432+ .await?;
433+ self.audit(&id, "create", &format!("Enterprise {name}"), &a.by).await?;
434+ for workspace in &a.workspaces {
435+ let workspace = workspace.trim().to_lowercase();
436+ if !workspace.is_empty() {
437+ self.attach(&workspace, Some(&id), &a.by).await?;
438+ }
439+ }
440+ Ok(match self.find_account(&id).await? {
441+ Some(account) => Outcome::Ok(account),
442+ None => Outcome::fail(FailureCode::NotFound, "The enterprise was not saved."),
443+ })
444+ }
445+
446+ async fn attach(&self, workspace: &str, account: Option<&str>, by: &str) -> Result<()> {
447+ let before = self.account_of(workspace).await?;
448+ match account {
449+ Some(account) => {
450+ self.db
451+ .prepare(
452+ "INSERT INTO account_members (workspace, account_id, added_by, added_at) VALUES (?1, ?2, ?3, ?4)
453+ ON CONFLICT (workspace) DO UPDATE SET account_id = ?2, added_by = ?3, added_at = ?4",
454+ )
455+ .bind(&[workspace.into(), account.into(), by.into(), rfc3339(now_ms()).into()])?
456+ .run()
457+ .await?;
458+ self.audit(account, "attach", &format!("{workspace} joined, from {}", before.name), by).await?;
459+ }
460+ None => {
461+ self.db
462+ .prepare("DELETE FROM account_members WHERE workspace = ?")
463+ .bind(&[workspace.into()])?
464+ .run()
465+ .await?;
466+ self.audit(&before.id, "detach", &format!("{workspace} left, back to paying for itself"), by).await?;
467+ }
468+ }
469+ Ok(())
470+ }
471+
472+ pub(crate) async fn admin_attach(&self, a: AdminAttachArgs) -> Result<Outcome<BillingAccount>> {
473+ let workspace = a.workspace.trim().to_lowercase();
474+ if workspace.is_empty() || a.by.trim().is_empty() {
475+ return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is making the change."));
476+ }
477+ if let Some(account) = &a.account {
478+ match self.account_row(account).await? {
479+ Some(row) if row.kind == "enterprise" => {}
480+ _ => return Ok(Outcome::fail(FailureCode::NotFound, "Workspaces can only join an enterprise.")),
481+ }
482+ }
483+ self.attach(&workspace, a.account.as_deref(), &a.by).await?;
484+ Ok(Outcome::Ok(self.account_of(&workspace).await?))
485+ }
486+
487+ pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
488+ let workspace = a.workspace.trim().to_lowercase();
489+ if workspace.is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() {
490+ return Ok(Outcome::fail(FailureCode::Invalid, "A credit needs a workspace, a note and who gave it."));
491+ }
492+ if a.amount_micros <= 0 || a.amount_micros > 10_000 * g1t_contracts::billing::MICROS_PER_DOLLAR {
493+ return Ok(Outcome::fail(FailureCode::Invalid, "A credit is more than $0 and at most $10,000."));
494+ }
495+ let reference = new_id("crd", now_ms());
496+ let description = format!("Credit from g1t: {}", a.note.trim());
497+ self.enter(&workspace, EntryKind::TopUp, a.amount_micros, &description, &reference, None, None, Some(&a.by), None)
498+ .await?;
499+ let account = self.account_of(&workspace).await?;
500+ self.audit(&account.id, "credit", &format!("{} to {workspace}: {}", crate::features::dollars(a.amount_micros), a.note.trim()), &a.by)
501+ .await?;
502+ let row = self
503+ .db
504+ .prepare("SELECT * FROM ledger WHERE reference = ?")
505+ .bind(&[reference.as_str().into()])?
506+ .first::<LedgerRow>(None)
507+ .await?;
508+ Ok(match row {
509+ Some(row) => Outcome::Ok(LedgerEntry::from(row)),
510+ None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."),
511+ })
512+ }
513+}
514+
515+#[cfg(test)]
516+mod tests {
517+ use super::*;
518+
519+ fn terms(kind: TermsKind, discount: u32) -> Terms {
520+ Terms { kind, discount_percent: discount, ..Terms::standard() }
521+ }
522+
523+ #[test]
524+ fn terms_shape_every_charge() {
525+ assert_eq!(terms(TermsKind::Standard, 0).apply(1_000), 1_000);
526+ assert_eq!(terms(TermsKind::Comped, 0).apply(1_000), 0);
527+ assert_eq!(terms(TermsKind::Custom, 25).apply(1_000), 750);
528+ assert_eq!(terms(TermsKind::Custom, 250).apply(1_000), 0);
529+ }
530+
531+ #[test]
532+ fn terms_read_plainly_in_the_audit_log() {
533+ let custom = Terms { ceiling_micros: Some(50_000_000), note: "Design partner".into(), ..terms(TermsKind::Custom, 20) };
534+ assert_eq!(describe(&custom), "custom (20% off, ceiling $50.00): Design partner");
535+ assert_eq!(describe(&Terms::standard()), "standard");
536+ }
537+}
+7−2
368368
369369 pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> {
370370 let workspace = a.workspace.to_lowercase();
371+ // Comped accounts have every feature without a plan.
372+ if self.terms_of(&workspace).await?.kind == g1t_contracts::billing::TermsKind::Comped {
373+ return Ok(Outcome::Ok(true));
374+ }
371375 if self.state(&workspace, a.feature).await?.on {
372376 return Ok(Outcome::Ok(true));
373377 }
395399 return Ok(Outcome::Ok(false));
396400 }
397401 let cost = a.cost_micros as f64 / MICROS_PER_DOLLAR as f64;
398− // Never free: the margin applies whatever FREE_WHILE_BUILDING says.
399− let charge = crate::charge_micros(cost, self.margin_percent);
402+ // Never free: the margin applies whatever FREE_WHILE_BUILDING says,
403+ // and only the account's terms change it.
404+ let charge = self.terms_of(&workspace).await?.apply(crate::charge_micros(cost, self.margin_percent));
400405 let now = now_ms();
401406 let timestamp = rfc3339(now);
402407 self.db
+2−1
422422 .bind(&[run.id.as_str().into()])?
423423 .first::<Charged>(None)
424424 .await?;
425+ let terms = self.terms_of(&run.workspace).await?;
425426 let charge_for = |micros: i64| {
426427 if self.free {
427428 0
428429 } else {
429− charge_micros(micros as f64 / MICROS_PER_DOLLAR as f64, self.margin_percent)
430+ terms.apply(charge_micros(micros as f64 / MICROS_PER_DOLLAR as f64, self.margin_percent))
430431 }
431432 };
432433 let settled_at = rfc3339(now_ms());
+67−15
1616 //! Reached only through service bindings; see `g1t_contracts::billing` for
1717 //! the methods and their arguments.
1818
19+mod accounts;
1920 mod features;
2021 mod keeper;
2122 mod limits;
2425 use g1t_contracts::billing::*;
2526 use g1t_contracts::time::rfc3339;
2627 use g1t_contracts::{FailureCode, Outcome, Role, new_id};
28+use g1t_contracts::billing::TermsKind;
2729 use g1t_kit::{args, now_ms, reply, rpc_method};
2830 use serde::Deserialize;
2931 use sha2::{Digest, Sha256};
138140 deployments_monthly_cents: u32,
139141 /// How far unpaid usage may go; see `limits`.
140142 ceilings: limits::Ceilings,
143+ /// `PREPAID_ONLY`: the old rule, that agents need credit first.
144+ prepaid_only: bool,
141145 }
142146
143147 /// `TRIAL_WORKSPACE_MICROS`, `TRIAL_TOTAL_MICROS` and `TRIAL_UNTIL`.
247251 ])?,
248252 ])
249253 .await?;
254+ // Money in clears a card declined at the limit.
255+ if kind == "top_up" {
256+ self.db
257+ .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?")
258+ .bind(&[workspace.into()])?
259+ .run()
260+ .await?;
261+ }
250262 Ok(())
251263 }
252264
489501
490502 /// A refusal if the workspace has no credit to start an agent with.
491503 async fn out_of_credit<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
492− // While g1t is being built out, no one needs credit.
493− if self.free {
504+ // Billing is postpaid: usage limits decide whether work starts
505+ // (see `limits`), and credit is a prepayment that lowers what is
506+ // owed. A balance no longer has to be positive to start.
507+ if self.free || !self.prepaid_only {
494508 return Ok(None);
495509 }
496510 let balance = self
509523
510524 /// A workspace's free allowance on g1t's hosted models: what its runs
511525 /// there have cost against its share, and the pool everyone draws on.
526+ /// How much of a hosted model run's cost the workspace's free allowance
527+ /// covers, if it is still open.
528+ async fn trial_covers(&self, workspace: &str, cost_micros: i64) -> Result<i64> {
529+ let trial = self.trial(TrialArgs { workspace: workspace.to_owned(), exempt: vec![] }).await?;
530+ if !trial.open {
531+ return Ok(0);
532+ }
533+ Ok(cost_micros.min((trial.limit_micros - trial.used_micros).max(0)))
534+ }
535+
512536 async fn trial(&self, a: TrialArgs) -> Result<Trial> {
513537 let workspace = a.workspace.to_lowercase();
514538 let Some(config) = &self.trial else {
524548 .db
525549 .prepare(
526550 "SELECT SUM(cost_micros) AS micros FROM ledger
527− WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND workspace = ?",
551+ WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND COALESCE(task, '') NOT IN ('sandbox', 'deployments') AND workspace = ?",
528552 )
529553 .bind(&[workspace.as_str().into()])?
530554 .first::<Sum>(None)
542566 .db
543567 .prepare(format!(
544568 "SELECT SUM(cost_micros) AS micros FROM ledger
545− WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND workspace NOT IN ({marks})"
569+ WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND COALESCE(task, '') NOT IN ('sandbox', 'deployments') AND workspace NOT IN ({marks})"
546570 ))
547571 .bind(&values)?
548572 .first::<Sum>(None)
648672 }
649673 // On the workspace's own provider, the model was paid for there:
650674 // g1t charges its fee, and keeps the provider's cost to show.
651− let charge = if self.free {
652− // Recorded, with what it cost, but not charged.
653− 0
654− } else if run.own_provider() {
675+ let base = if run.own_provider() {
655676 self.orchestration_fee_micros
656677 } else {
657− charge_micros(a.cost_usd, self.margin_percent)
678+ // The free allowance on g1t's models covers what it can.
679+ let cost = charge_micros(a.cost_usd, 0);
680+ let covered = self.trial_covers(&run.workspace, cost).await?;
681+ charge_micros((cost - covered) as f64 / MICROS_PER_DOLLAR as f64, self.margin_percent)
658682 };
683+ let (charge, terms_note) = self.charged(&run.workspace, base).await?;
659684 let mut description = match run.task.as_str() {
660685 "plan" => format!("Planning for {}", run.repo),
661686 "review" => format!("Review of {}#{}", run.repo, run.number),
665690 if run.own_provider() {
666691 description.push_str(", on your own model provider");
667692 }
668− if self.free {
669− description.push_str(" (free while g1t is being built out)");
670− }
693+ description.push_str(&terms_note);
671694 self.enter(
672695 &run.workspace,
673696 EntryKind::Usage,
726749 sandbox_allowance::COST_MICROS_PER_SECOND as f64,
727750 sandbox_allowance::MICROS_PER_SECOND as f64,
728751 ));
729− let charge = if self.free { 0 } else { (billable as f64 * price_per_second).ceil() as i64 };
752+ let (charge, terms_note) = self.charged(&workspace, (billable as f64 * price_per_second).ceil() as i64).await?;
730753 let mut description = format!("{}: {} of sandbox time", a.description, duration(seconds));
731754 if billable < seconds {
732755 description.push_str(if billable == 0 {
735758 ", partly within the month's free minutes"
736759 });
737760 }
738− if self.free && billable > 0 {
739− description.push_str(" (free while g1t is being built out)");
761+ if billable > 0 {
762+ description.push_str(&terms_note);
740763 }
741764 self.db
742765 .batch(vec![
793816 }
794817 }
795818
819+impl Billing {
820+ /// What a workspace is charged for something that would be `base`:
821+ /// nothing while g1t is free, or as its account's terms say. With a
822+ /// note for the statement when it differs.
823+ pub(crate) async fn charged(&self, workspace: &str, base: i64) -> Result<(i64, String)> {
824+ if self.free {
825+ return Ok((0, " (free while g1t is being built out)".to_owned()));
826+ }
827+ let terms = self.terms_of(workspace).await?;
828+ let charge = terms.apply(base);
829+ let note = match terms.kind {
830+ TermsKind::Comped => " (comped)".to_owned(),
831+ TermsKind::Custom if terms.discount_percent > 0 && base > 0 => format!(" ({}% off)", terms.discount_percent),
832+ _ => String::new(),
833+ };
834+ Ok((charge, note))
835+ }
836+}
837+
796838 fn members_only<T>() -> Outcome<T> {
797839 Outcome::fail(
798840 FailureCode::Forbidden,
822864 .unwrap_or(100_000),
823865 free: env.var("FREE_WHILE_BUILDING").is_ok_and(|v| v.to_string() == "true"),
824866 ceilings: limits::Ceilings::from_env(&env),
867+ prepaid_only: env.var("PREPAID_ONLY").is_ok_and(|v| v.to_string() == "true"),
825868 deployments_monthly_cents: env
826869 .var("DEPLOYMENTS_MONTHLY_CENTS")
827870 .ok()
859902 if let Err(error) = billing.settle_runs(&keeper).await {
860903 worker::console_error!("settling runs failed: {error}");
861904 }
905+ if let Err(error) = billing.autopay().await {
906+ worker::console_error!("paying at the limit failed: {error}");
907+ }
862908 // Once a day, and at once if the costs were never checked: check every
863909 // cost against what Cloudflare billed.
864910 if event.cron() == keeper::DAILY || billing.never_checked().await.unwrap_or(false) {
898944 "set_spend_limit" => reply(&billing.set_spend_limit(args(body)?).await?),
899945 "prices" => reply(&billing.prices().await?),
900946 "note_pending" => reply(&billing.note_pending(args(body)?).await?),
947+ "admin_accounts" => reply(&billing.admin_accounts(args(body)?).await?),
948+ "admin_account" => reply(&billing.admin_account(args(body)?).await?),
949+ "admin_set_terms" => reply(&billing.admin_set_terms(args(body)?).await?),
950+ "admin_create_enterprise" => reply(&billing.admin_create_enterprise(args(body)?).await?),
951+ "admin_attach" => reply(&billing.admin_attach(args(body)?).await?),
952+ "admin_credit" => reply(&billing.admin_credit(args(body)?).await?),
901953 _ => Response::error("Unknown method", 404),
902954 }
903955 }
+147−39
2121 //! exemption from the ceiling. Test-mode payments are not money, so they
2222 //! do not raise trust.
2323
24−use g1t_contracts::billing::{CheckLimitArgs, Limit, LimitArgs, NotePendingArgs, LimitState, SetSpendLimitArgs, Trust};
24+use g1t_contracts::billing::{CheckLimitArgs, Limit, LimitArgs, NotePendingArgs, TermsKind, LimitState, SetSpendLimitArgs, Trust};
2525 use g1t_contracts::time::rfc3339;
2626 use g1t_contracts::{FailureCode, Outcome, Role};
2727 use g1t_kit::now_ms;
3939 /// `LIMIT_PAID_MIN_MICROS` and `LIMIT_PAID_MAX_MICROS`.
4040 pub paid_min: i64,
4141 pub paid_max: i64,
42− /// `LIMIT_EXEMPT`: g1t's own workspaces, comma-separated.
43− pub exempt: Vec<String>,
4442 }
4543
4644 impl Ceilings {
5250 new: number("LIMIT_NEW_MICROS", 3_000_000),
5351 paid_min: number("LIMIT_PAID_MIN_MICROS", 25_000_000),
5452 paid_max: number("LIMIT_PAID_MAX_MICROS", 1_000_000_000),
55− exempt: env
56− .var("LIMIT_EXEMPT")
57− .map(|v| v.to_string())
58− .unwrap_or_default()
59− .split(',')
60− .map(|name| name.trim().to_lowercase())
61− .filter(|name| !name.is_empty())
62− .collect(),
6353 }
6454 }
6555
7868 }
7969 }
8070
71+/// Never charged automatically for less.
72+const AUTOPAY_MIN_CENTS: i64 = 500;
73+
8174 #[derive(Deserialize)]
8275 struct LimitRow {
83− ceiling_micros: Option<i64>,
8476 spend_limit_micros: Option<i64>,
77+ autopay_failed_at: Option<String>,
78+ autopay_error: Option<String>,
8579 }
8680
8781 #[derive(Deserialize)]
9690 }
9791
9892 impl Billing {
99− /// The workspace's limit, worked out from its ledger.
93+ /// The workspace's limit, worked out from the ledger of the account
94+ /// that pays for it: its own, or its enterprise's, whose workspaces'
95+ /// usage and payments count together.
10096 pub(crate) async fn limit_of(&self, workspace: &str) -> Result<Limit> {
10197 let workspace = workspace.to_lowercase();
98+ let account = self.account_of(&workspace).await?;
10299 let row = self
103100 .db
104− .prepare("SELECT ceiling_micros, spend_limit_micros FROM limits WHERE workspace = ?")
101+ .prepare("SELECT spend_limit_micros, autopay_failed_at, autopay_error FROM limits WHERE workspace = ?")
105102 .bind(&[workspace.as_str().into()])?
106103 .first::<LimitRow>(None)
107104 .await?;
108105 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
106+ let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
107+ let members: Vec<JsValue> = if account.workspaces.is_empty() {
108+ vec![JsValue::from(workspace.as_str())]
109+ } else {
110+ account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect()
111+ };
112+ let mut with_month = members.clone();
113+ with_month.push(month_start.as_str().into());
109114 // Each usage entry at its cost to g1t or its charge, whichever is
110115 // more; on the workspace's own provider, only g1t's fee is g1t's.
111116 let month = self
112117 .db
113− .prepare(
118+ .prepare(format!(
114119 "SELECT
115120 SUM(CASE WHEN kind = 'usage' THEN
116121 CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t'
118123 ELSE -amount_micros END
119124 END) AS used,
120125 SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid
121− FROM ledger WHERE workspace = ?1 AND created_at >= ?2",
122− )
123− .bind(&[workspace.as_str().into(), month_start.as_str().into()])?
126+ FROM ledger WHERE workspace IN ({marks}) AND created_at >= ?"
127+ ))
128+ .bind(&with_month)?
124129 .first::<Month>(None)
125130 .await?;
126131 let (used, paid_month) = month.map_or((0, 0), |m| (m.used.unwrap_or(0), m.paid.unwrap_or(0)));
127132 // And what is metered but not charged until the month closes.
133+ let mut pending_args = members.clone();
134+ pending_args.push(month_start[..7].into());
128135 let pending = self
129136 .db
130− .prepare("SELECT SUM(charge_micros) AS paid FROM pending_usage WHERE workspace = ? AND month = ?")
131− .bind(&[workspace.as_str().into(), month_start[..7].into()])?
137+ .prepare(format!(
138+ "SELECT SUM(charge_micros) AS paid FROM pending_usage WHERE workspace IN ({marks}) AND month = ?"
139+ ))
140+ .bind(&pending_args)?
132141 .first::<Paid>(None)
133142 .await?
134143 .and_then(|row| row.paid)
138147 let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live);
139148 let exposure = (used - if live { paid_month } else { 0 }).max(0);
140149
141− let (trust, trust_ceiling) = if self.ceilings.exempt.iter().any(|name| *name == workspace) {
142− (Trust::Internal, None)
143− } else if let Some(ceiling) = row.as_ref().and_then(|row| row.ceiling_micros) {
144− (Trust::Reviewed, Some(ceiling))
145− } else {
146− let paid = self.live_paid(&workspace).await?;
147− if paid > 0 {
148− (Trust::Paid, Some(self.ceilings.for_paid(paid)))
149− } else {
150− (Trust::New, Some(self.ceilings.new))
150+ let (trust, trust_ceiling) = match account.terms.kind {
151+ TermsKind::Comped => (Trust::Internal, None),
152+ _ if account.terms.ceiling_micros.is_some() => (Trust::Reviewed, account.terms.ceiling_micros),
153+ _ => {
154+ let paid = self.live_paid(&members).await?;
155+ if paid > 0 {
156+ (Trust::Paid, Some(self.ceilings.for_paid(paid)))
157+ } else {
158+ (Trust::New, Some(self.ceilings.new))
159+ }
151160 }
152161 };
153− let spend_limit = row.and_then(|row| row.spend_limit_micros);
162+ let spend_limit = row.as_ref().and_then(|row| row.spend_limit_micros);
163+ // A card declined when g1t charged it at the limit stops work until
164+ // it is paid; any payment clears it.
165+ let declined = row.as_ref().and_then(|row| row.autopay_failed_at.clone().map(|at| (at, row.autopay_error.clone())));
154166 let ceiling = match (trust_ceiling, spend_limit) {
155167 (Some(ceiling), Some(own)) => Some(ceiling.min(own)),
156168 (None, Some(own)) => Some(own),
157169 (ceiling, None) => ceiling,
158170 };
159− let state = state(exposure, ceiling);
171+ let state = if declined.is_some() && exposure > 0 { LimitState::Stopped } else { state(exposure, ceiling) };
172+ let who = if account.kind == g1t_contracts::billing::AccountKind::Enterprise {
173+ format!("The {} enterprise, which pays for {workspace},", account.name)
174+ } else {
175+ format!("The {workspace} workspace")
176+ };
160177 let message = match state {
161178 LimitState::Ok => None,
162179 LimitState::Warning => Some(format!(
163− "The {workspace} workspace has used {} of its {} limit this month. At the limit, its sandboxes, builds and apps stop until it pays or the month turns.",
180+ "{who} has used {} of its {} limit this month. At the limit, its sandboxes, builds and apps stop until it pays or the month turns.",
164181 dollars_plain(exposure),
165182 dollars_plain(ceiling.unwrap_or_default()),
166183 )),
184+ LimitState::Stopped if declined.is_some() => Some(format!(
185+ "{who} could not be charged for its usage ({}), so its sandboxes, builds and apps are stopped. An owner can pay under Billing with another card.",
186+ declined.as_ref().and_then(|(_, error)| error.clone()).unwrap_or_else(|| "the card was declined".to_owned()),
187+ )),
167188 LimitState::Stopped => Some(if spend_limit.is_some() && ceiling == spend_limit {
168189 format!(
169190 "The {workspace} workspace reached the {} spend limit its owners set for this month, so its sandboxes, builds and apps are stopped. An owner can raise it under Billing.",
171192 )
172193 } else {
173194 format!(
174− "The {workspace} workspace reached its {} limit for usage not yet paid for, so its sandboxes, builds and apps are stopped. The limit grows as a workspace pays g1t; an owner can pay under Billing, or write to support to have it raised.",
195+ "{who} reached its {} limit for usage not yet paid for, so its sandboxes, builds and apps are stopped. The limit grows as a workspace pays g1t; an owner can pay under Billing, or write to support to have it raised.",
175196 dollars_plain(ceiling.unwrap_or_default()),
176197 )
177198 }),
178199 };
179200 Ok(Limit {
180201 workspace,
202+ account: account.id,
203+ account_name: account.name,
181204 trust,
182205 exposure_micros: exposure,
183206 ceiling_micros: ceiling,
188211 })
189212 }
190213
191− /// Real money the workspace has paid g1t. Nothing in test mode.
192− async fn live_paid(&self, workspace: &str) -> Result<i64> {
214+ /// Real money the workspaces have paid g1t. Nothing in test mode, and
215+ /// credits g1t gave are not payments.
216+ async fn live_paid(&self, members: &[JsValue]) -> Result<i64> {
193217 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
194218 return Ok(0);
195219 }
220+ let marks = vec!["?"; members.len().max(1)].join(", ");
196221 Ok(self
197222 .db
198− .prepare("SELECT SUM(amount_micros) AS paid FROM ledger WHERE workspace = ? AND kind = 'top_up'")
199− .bind(&[workspace.into()])?
223+ .prepare(format!(
224+ "SELECT SUM(amount_micros) AS paid FROM ledger
225+ WHERE workspace IN ({marks}) AND kind = 'top_up' AND reference NOT LIKE 'crd%'"
226+ ))
227+ .bind(members)?
200228 .first::<Paid>(None)
201229 .await?
202230 .and_then(|row| row.paid)
246274 Ok(true)
247275 }
248276
277+ /// Charges the saved card of each workspace nearing its limit, for what
278+ /// it owes, so that a workspace that pays never has its work stopped.
279+ /// Only with live payments: test-mode payments are not money and lower
280+ /// nothing. Not for a workspace's own spend limit, which means stop, nor
281+ /// for enterprises, which are invoiced.
282+ pub(crate) async fn autopay(&self) -> Result<()> {
283+ let Some(stripe) = self.stripe.as_ref().filter(|stripe| stripe.live()) else {
284+ return Ok(());
285+ };
286+ #[derive(Deserialize)]
287+ struct Candidate {
288+ workspace: String,
289+ customer_id: Option<String>,
290+ }
291+ let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
292+ let candidates = self
293+ .db
294+ .prepare(
295+ "SELECT DISTINCT ledger.workspace AS workspace, accounts.customer_id AS customer_id
296+ FROM ledger JOIN accounts ON accounts.workspace = ledger.workspace
297+ WHERE ledger.kind = 'usage' AND ledger.created_at >= ? AND accounts.customer_id IS NOT NULL",
298+ )
299+ .bind(&[month_start.as_str().into()])?
300+ .all()
301+ .await?
302+ .results::<Candidate>()?;
303+ for candidate in candidates {
304+ let Some(customer) = candidate.customer_id else { continue };
305+ let limit = self.limit_of(&candidate.workspace).await?;
306+ let own_limit = limit.spend_limit_micros.is_some() && limit.ceiling_micros == limit.spend_limit_micros;
307+ if limit.state == LimitState::Ok
308+ || own_limit
309+ || limit.trust == Trust::Internal
310+ || limit.account.starts_with("ent_")
311+ {
312+ continue;
313+ }
314+ let cents = ((limit.exposure_micros + 9_999) / 10_000).max(AUTOPAY_MIN_CENTS);
315+ let key = format!("autopay/{}/{}/{}", candidate.workspace, &month_start[..7], limit.exposure_micros / 1_000_000);
316+ let description = format!("g1t usage for {}, paid automatically near its limit", candidate.workspace);
317+ let now = rfc3339(now_ms());
318+ match stripe.charge_saved_card(&customer, cents, &description, &key).await {
319+ Ok(payment) if payment.status == "succeeded" => {
320+ self.enter(
321+ &candidate.workspace,
322+ g1t_contracts::billing::EntryKind::TopUp,
323+ payment.amount_received.max(cents) * 10_000,
324+ &format!("Paid automatically by card, near the {} limit", dollars_plain(limit.ceiling_micros.unwrap_or_default())),
325+ &payment.id,
326+ None,
327+ None,
328+ None,
329+ Some(&customer),
330+ )
331+ .await?;
332+ self.db
333+ .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?")
334+ .bind(&[candidate.workspace.as_str().into()])?
335+ .run()
336+ .await?;
337+ }
338+ outcome => {
339+ let error = match outcome {
340+ Ok(payment) => format!("the payment is {}", payment.status.replace('_', " ")),
341+ Err(error) => error.to_string().chars().take(200).collect(),
342+ };
343+ self.db
344+ .prepare(
345+ "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
346+ ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
347+ )
348+ .bind(&[candidate.workspace.as_str().into(), now.as_str().into(), error.as_str().into()])?
349+ .run()
350+ .await?;
351+ }
352+ }
353+ }
354+ Ok(())
355+ }
356+
249357 pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> {
250358 Ok(Outcome::Ok(self.limit_of(&a.workspace).await?))
251359 }
279387 use super::*;
280388
281389 fn ceilings() -> Ceilings {
282− Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000, exempt: vec![] }
390+ Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000 }
283391 }
284392
285393 #[test]
+58−0
8080 }
8181
8282 /// `name=value` pairs as a form body.
83+/// A payment made with no one there.
84+#[derive(Debug, Deserialize)]
85+pub struct PaymentIntent {
86+ pub id: String,
87+ /// `succeeded`, or anything else when it did not go through.
88+ pub status: String,
89+ #[serde(default)]
90+ pub amount_received: i64,
91+}
92+
8393 pub(crate) fn form(fields: &[(&str, String)]) -> String {
8494 fields
8595 .iter()
104114 path: &str,
105115 body: Option<String>,
106116 ) -> Result<T> {
117+ self.send(method, path, body, None).await
118+ }
119+
120+ async fn send<T: for<'a> Deserialize<'a>>(
121+ &self,
122+ method: Method,
123+ path: &str,
124+ body: Option<String>,
125+ idempotency_key: Option<&str>,
126+ ) -> Result<T> {
107127 let headers = Headers::new();
108128 headers.set("authorization", &format!("Bearer {}", self.key))?;
129+ if let Some(key) = idempotency_key {
130+ headers.set("idempotency-key", key)?;
131+ }
109132 if body.is_some() {
110133 headers.set("content-type", "application/x-www-form-urlencoded")?;
111134 }
126149 response.json().await
127150 }
128151
152+ /// Charges the customer's saved card, with no one there: the automatic
153+ /// payment at a workspace's limit. `key` makes a retry the same charge.
154+ pub async fn charge_saved_card(
155+ &self,
156+ customer: &str,
157+ amount_cents: i64,
158+ description: &str,
159+ key: &str,
160+ ) -> Result<PaymentIntent> {
161+ #[derive(Deserialize)]
162+ struct Methods {
163+ data: Vec<Method_>,
164+ }
165+ #[derive(Deserialize)]
166+ struct Method_ {
167+ id: String,
168+ }
169+ let methods: Methods = self
170+ .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
171+ .await?;
172+ let Some(card) = methods.data.first() else {
173+ return Err(Error::RustError("no card on file".into()));
174+ };
175+ let fields = [
176+ ("amount", amount_cents.to_string()),
177+ ("currency", "usd".to_owned()),
178+ ("customer", customer.to_owned()),
179+ ("payment_method", card.id.clone()),
180+ ("off_session", "true".to_owned()),
181+ ("confirm", "true".to_owned()),
182+ ("description", description.to_owned()),
183+ ];
184+ self.send(Method::Post, "/payment_intents", Some(form(&fields)), Some(key)).await
185+ }
186+
129187 /// Starts a page on which `amount_cents` of credit is paid for by card.
130188 /// The card is kept for the workspace, so that topping up again, by
131189 /// hand or automatically, needs no retyping.
+5−3
2929 // While g1t is being built out, workspaces pay nothing: runs are
3030 // recorded with what they cost, and nothing is charged. Set to
3131 // "false" when pricing starts.
32− "FREE_WHILE_BUILDING": "true",
32+ // Off: workspaces are charged as their account's terms say. g1t's own
33+ // (syntaqx) is comped in sudo.g1t.sh, not by this switch.
34+ "FREE_WHILE_BUILDING": "false",
3335 // How far a workspace's unpaid usage may go before its work stops
3436 // (see src/limits.rs): $3 before any live payment, then twice what
35− // it has paid, between $25 and $1,000. LIMIT_EXEMPT is g1t's own.
37+ // it has paid, between $25 and $1,000. Comped and custom terms are set
38+ // per account in sudo.g1t.sh.
3639 "LIMIT_NEW_MICROS": "3000000",
3740 "LIMIT_PAID_MIN_MICROS": "25000000",
3841 "LIMIT_PAID_MAX_MICROS": "1000000000",
39− "LIMIT_EXEMPT": "syntaqx",
4042 // A free allowance on g1t's hosted models, so people can try g1t's
4143 // agents without a key of their own: each workspace may use this
4244 // much model cost ($1), out of one pool for everyone ($40), until