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.
| 125 | 125 | Workflows run in every workspace that can use g1t's agents: one with its | |
| 126 | 126 | own [model provider](/guides/models/) connected, or one on | |
| 127 | 127 | [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 | |
| 129 | 131 | recorded with its jobs failed and the reason, and the Actions page says so | |
| 130 | 132 | before the first run. | |
| 131 | 133 |
| 205 | 205 | ||
| 206 | 206 | ## What it costs | |
| 207 | 207 | ||
| 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. | |
| 213 | 213 | The workspace's **Usage** page shows what its agents have cost, by day, | |
| 214 | 214 | kind of work, repository, model and pull request. See | |
| 215 | 215 | [usage and billing](/guides/usage-and-billing/). |
| 85 | 85 | ||
| 86 | 86 | ## What it costs | |
| 87 | 87 | ||
| 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 | |
| 92 | 91 | change, a review, a revision, a catch-up and a plan are each a run. The | |
| 93 | 92 | statement marks these runs "on your own model provider" and names the | |
| 94 | 93 | model and provider; the Usage page shows what they cost at the provider, |
| 1 | 1 | --- | |
| 2 | 2 | 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. | |
| 4 | 4 | --- | |
| 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. | |
| 12 | 5 | ||
| 13 | 6 | 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. | |
| 17 | 11 | ||
| 18 | 12 | Some features are paid for with a monthly plan the workspace turns on, and | |
| 19 | 13 | are never free, including while the rest of g1t is. See | |
| 79 | 73 | it, plus 20%. A small change costs a few cents. | |
| 80 | 74 | ||
| 81 | 75 | 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). | |
| 85 | 78 | ||
| 86 | 79 | The charge goes to the workspace that owns the repository, whoever | |
| 87 | 80 | assigned the issue. That is why only members of a workspace can put g1t | |
| 134 | 127 | | | | | |
| 135 | 128 | | --- | --- | | |
| 136 | 129 | | 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) | | |
| 139 | 132 | ||
| 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). | |
| 141 | 136 | ||
| 142 | 137 | Deploy builds are not counted here: [Deployments](/guides/deployments/) | |
| 143 | 138 | charges them by the second on its own plan. | |
| 144 | 139 | ||
| 145 | 140 | Each sandbox is one line on the [statement](#the-statement), such as | |
| 146 | 141 | *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. | |
| 149 | 143 | ||
| 150 | 144 | ## Usage limits | |
| 151 | 145 | ||
| 164 | 158 | again, g1t rebuilds each one from the commit it was serving, by itself. | |
| 165 | 159 | ||
| 166 | 160 | 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. | |
| 170 | 164 | ||
| 171 | 165 | | Workspace | Limit | | |
| 172 | 166 | | --- | --- | | |
| 173 | 167 | | **New**: has not paid g1t yet | $3: the free allowances and a little more | | |
| 174 | 168 | | **Paid**: has paid g1t | twice what it has paid, from $25 up to $1,000 | | |
| 175 | 169 | | **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. | |
| 176 | 185 | ||
| 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 | |
| 179 | 192 | ||
| 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: | |
| 183 | 194 | ||
| 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 | + | ||
| 184 | 208 | ## Add credit | |
| 185 | 209 | ||
| 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. | |
| 187 | 213 | ||
| 188 | 214 | 1. Open the workspace's **Settings → Billing**, `g1t.sh/<workspace>/-/billing`. | |
| 189 | 215 | 2. Under **Add credit by card**, choose an amount: $10, $25, $50 or $100. | |
| 197 | 223 | test card `4242 4242 4242 4242` with any future date and any code. The | |
| 198 | 224 | Billing page says when payments are in test mode. | |
| 199 | 225 | ||
| 200 | − | ## When credit runs out | |
| 226 | + | ## When work is stopped | |
| 201 | 227 | ||
| 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: | |
| 205 | 231 | ||
| 206 | 232 | ```json | |
| 207 | 233 | { | |
| 208 | 234 | "error": { | |
| 209 | 235 | "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." | |
| 211 | 237 | } | |
| 212 | 238 | } | |
| 213 | 239 | ``` | |
| 214 | 240 | ||
| 215 | 241 | A step g1t would take by itself, such as a revision or a review, stops | |
| 216 | 242 | 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. | |
| 218 | 244 | ||
| 219 | 245 | ## The Usage page | |
| 220 | 246 |
| 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 |
| 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. |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 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); |
| 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 | + | } |
| 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 | + | } |
| 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; |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.f{fill:#161618}.f.s{fill:none;stroke:#161618}.m{fill:#6b56e8}@media (prefers-color-scheme: dark){.f{fill:#ededef}.f.s{fill:none;stroke:#ededef}.m{fill:#b6a8ff}}</style><mask id="cut" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><path d="M10.5 3.5 8.5 5.4" stroke="#000" stroke-width="3.6" stroke-linecap="round"/></mask><g mask="url(#cut)"><rect class="m" x="3" y="5.5" width="2" height="8.5" rx="1" fill-opacity="0.5"/><rect class="m" x="6" y="4" width="2" height="10" rx="1" fill-opacity="0.85"/></g><rect class="f" x="9" y="2" width="3" height="12" rx="1.5"/><path class="f s" d="M10.5 3.5 8.5 5.4" stroke-width="2.2" stroke-linecap="round"/></svg> |
| 1 | + | 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; |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | }); |
| 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>; |
| 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 | + | } |
| 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 | + | } |
| 75 | 75 | issues: number; | |
| 76 | 76 | pulls: number; | |
| 77 | 77 | } | 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; | |
| 80 | 85 | /** Whether g1t charges nothing for now, while it is being built out. */ | |
| 81 | 86 | free?: boolean; | |
| 82 | 87 | /** What its agents have cost since the start of the month. */ | |
| 211 | 216 | ); | |
| 212 | 217 | } | |
| 213 | 218 | ||
| 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 | + | */ | |
| 215 | 223 | function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) { | |
| 216 | 224 | if (shell.monthUsageMicros == null) return null; | |
| 217 | 225 | 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"; | |
| 220 | 231 | return ( | |
| 221 | 232 | <Link | |
| 222 | 233 | to={`/${slug}/-/usage`} | |
| 230 | 241 | <span className="font-mono text-sm tabular-nums">${(spent / MICROS_PER_DOLLAR).toFixed(2)}</span> | |
| 231 | 242 | {shell.free ? ( | |
| 232 | 243 | <span className="text-xs text-accent">Free for now</span> | |
| 244 | + | ) : limit?.comped ? ( | |
| 245 | + | <span className="text-xs text-accent">Comped</span> | |
| 233 | 246 | ) : ( | |
| 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`} | |
| 237 | 250 | </span> | |
| 238 | 251 | ) | |
| 239 | 252 | )} | |
| 240 | 253 | </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 | + | )} | |
| 247 | 262 | </Link> | |
| 248 | 263 | ); | |
| 249 | 264 | } |
| 92 | 92 | const path = params.owner && params.repo ? { namespace: params.owner, name: params.repo } : null; | |
| 93 | 93 | const now = new Date(); | |
| 94 | 94 | 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([ | |
| 96 | 96 | workspace ? projects.list(workspace.slug, user) : Promise.resolve(null), | |
| 97 | 97 | path ? work.counts(path, user) : Promise.resolve(null), | |
| 98 | 98 | workspace ? billing.account(workspace.slug, user) : Promise.resolve(null), | |
| 99 | 99 | workspace ? billing.usage(workspace.slug, user, monthStart) : Promise.resolve(null), | |
| 100 | + | workspace ? billing.limit(workspace.slug, user).catch(() => null) : Promise.resolve(null), | |
| 100 | 101 | ]); | |
| 101 | 102 | return { | |
| 102 | 103 | workspace, | |
| 118 | 119 | pulls: counts.value.pulls, | |
| 119 | 120 | } | |
| 120 | 121 | : 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, | |
| 124 | 132 | free: account?.ok ? Boolean(account.value.status.free) : false, | |
| 125 | 133 | // While g1t is free every charge is zero, so usage is shown at cost. | |
| 126 | 134 | monthUsageMicros: usage?.ok ? (usage.value.free ? usage.value.usedMicros : usage.value.spentMicros) : null, |
| 300 | 300 | } | |
| 301 | 301 | : canRunAgents | |
| 302 | 302 | ? { | |
| 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.", | |
| 307 | 307 | to: workspace ? `/${workspace}/-/billing` : null, | |
| 308 | − | action: "Add credit", | |
| 308 | + | action: "Billing", | |
| 309 | 309 | } | |
| 310 | 310 | : { | |
| 311 | 311 | done: false, |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | 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 }; | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | 24 | /** A price in dollars, with as many digits as it needs to say anything. */ | |
| 49 | 49 | }, | |
| 50 | 50 | { | |
| 51 | 51 | 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.", | |
| 53 | 53 | }, | |
| 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 | + | }, | |
| 54 | 58 | ]; | |
| 55 | 59 | ||
| 56 | 60 | export default function Pricing({ loaderData }: Route.ComponentProps) { | |
| 57 | − | const { book } = loaderData; | |
| 61 | + | const { book, free } = loaderData; | |
| 58 | 62 | const checked = book?.prices.map((p) => p.checkedAt).filter((at): at is string => !!at).sort().at(-1); | |
| 59 | 63 | return ( | |
| 60 | 64 | <main className="mx-auto max-w-4xl px-4 py-12"> | |
| 64 | 68 | g1t runs on Cloudflare and model providers, and passes those costs through. The numbers on this page are the | |
| 65 | 69 | live price book g1t charges from. | |
| 66 | 70 | </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 | + | )} | |
| 73 | 79 | ||
| 74 | 80 | <div className="mt-10 grid gap-4 sm:grid-cols-2"> | |
| 75 | 81 | {HOW.map((item) => ( |
| 162 | 162 | <h2 className="mt-12 font-medium">Agent credit</h2> | |
| 163 | 163 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 164 | 164 | 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. | |
| 168 | 168 | </p> | |
| 169 | 169 | ||
| 170 | 170 | <div | |
| 442 | 442 | }, | |
| 443 | 443 | paid: { label: "Paid", detail: "Twice what the workspace has paid g1t, from $25 up to $1,000." }, | |
| 444 | 444 | 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." }, | |
| 446 | 446 | }; | |
| 447 | 447 | ||
| 448 | 448 | /** | |
| 463 | 463 | <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{trust.label}</span> | |
| 464 | 464 | </div> | |
| 465 | 465 | <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. | |
| 469 | 469 | </p> | |
| 470 | 470 | <p className="mt-4 text-2xl font-semibold tabular-nums tracking-tight"> | |
| 471 | 471 | {dollars(limit.exposureMicros)} | |
| 476 | 476 | <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} /> | |
| 477 | 477 | </div> | |
| 478 | 478 | )} | |
| 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 | + | )} | |
| 479 | 485 | {limit.message && <p className="mt-3 text-sm">{limit.message}</p>} | |
| 480 | 486 | <p className="mt-3 text-xs text-faint"> | |
| 481 | 487 | {trust.detail} |
| 376 | 376 | #[serde(rename_all = "camelCase")] | |
| 377 | 377 | pub struct Limit { | |
| 378 | 378 | 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, | |
| 379 | 385 | pub trust: Trust, | |
| 380 | 386 | /// Usage this month (UTC) less what was paid this month. | |
| 381 | 387 | pub exposure_micros: i64, | |
| 480 | 486 | pub model_margin_percent: u32, | |
| 481 | 487 | } | |
| 482 | 488 | ||
| 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 | + | ||
| 483 | 662 | /// What a feature's plan costs and includes. | |
| 484 | 663 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 485 | 664 | #[serde(rename_all = "camelCase")] |
| 856 | 856 | limits and their warnings; prepaid credit retired. | |
| 857 | 857 | 4. Per-workspace allow-lists of projects for each feature, and per-project | |
| 858 | 858 | 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. | |
| 860 | 896 | ||
| 861 | 897 | ## Agents and models | |
| 862 | 898 |
| 26 | 26 | "sharp": "^0.35.3" | |
| 27 | 27 | } | |
| 28 | 28 | }, | |
| 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 | + | }, | |
| 29 | 54 | "apps/web": { | |
| 30 | 55 | "name": "@g1t/web", | |
| 31 | 56 | "hasInstallScript": true, | |
| 1604 | 1629 | "resolved": "services/runner", | |
| 1605 | 1630 | "link": true | |
| 1606 | 1631 | }, | |
| 1632 | + | "node_modules/@g1t/sudo": { | |
| 1633 | + | "resolved": "apps/sudo", | |
| 1634 | + | "link": true | |
| 1635 | + | }, | |
| 1607 | 1636 | "node_modules/@g1t/theme": { | |
| 1608 | 1637 | "resolved": "packages/theme", | |
| 1609 | 1638 | "link": true |
| 72 | 72 | * open to them: a few dollars of model cost each, out of one pool, until a | |
| 73 | 73 | * date. Mirrors `Trial` in `crates/contracts/src/billing.rs`. | |
| 74 | 74 | */ | |
| 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 | + | ||
| 75 | 126 | /** How much a workspace has earned g1t's trust with money. */ | |
| 76 | 127 | export type Trust = "new" | "paid" | "reviewed" | "internal"; | |
| 77 | 128 | ||
| 83 | 134 | */ | |
| 84 | 135 | export type Limit = { | |
| 85 | 136 | workspace: string; | |
| 137 | + | /** The account that pays: the workspace's own (`ws_<slug>`), or its enterprise's. */ | |
| 138 | + | account: string; | |
| 139 | + | accountName: string; | |
| 86 | 140 | trust: Trust; | |
| 87 | 141 | exposureMicros: number; | |
| 88 | 142 | /** The lower of g1t's ceiling and the owner's spend limit; null for g1t's own. */ |
| 1 | 1 | import type { ActionsApi } from "./actions"; | |
| 2 | − | import type { BillingApi } from "./billing"; | |
| 2 | + | import type { BillingAdminApi, BillingApi } from "./billing"; | |
| 3 | 3 | import type { DeploymentsApi } from "./deployments"; | |
| 4 | 4 | import type { ProjectsApi } from "./projects"; | |
| 5 | 5 | import type { EventsApi } from "./events"; | |
| 209 | 209 | }; | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 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 | + | ||
| 212 | 224 | export function eventsClient(service: ServiceBinding): EventsApi { | |
| 213 | 225 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 214 | 226 | return { |
| 30 | 30 | apps/api | |
| 31 | 31 | services/pages | |
| 32 | 32 | apps/web | |
| 33 | + | apps/sudo | |
| 33 | 34 | apps/docs | |
| 34 | 35 | ) | |
| 35 | 36 |
| 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'; |
| 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; |
| 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 | + | } |
| 368 | 368 | ||
| 369 | 369 | pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> { | |
| 370 | 370 | 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 | + | } | |
| 371 | 375 | if self.state(&workspace, a.feature).await?.on { | |
| 372 | 376 | return Ok(Outcome::Ok(true)); | |
| 373 | 377 | } | |
| 395 | 399 | return Ok(Outcome::Ok(false)); | |
| 396 | 400 | } | |
| 397 | 401 | 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)); | |
| 400 | 405 | let now = now_ms(); | |
| 401 | 406 | let timestamp = rfc3339(now); | |
| 402 | 407 | self.db |
| 422 | 422 | .bind(&[run.id.as_str().into()])? | |
| 423 | 423 | .first::<Charged>(None) | |
| 424 | 424 | .await?; | |
| 425 | + | let terms = self.terms_of(&run.workspace).await?; | |
| 425 | 426 | let charge_for = |micros: i64| { | |
| 426 | 427 | if self.free { | |
| 427 | 428 | 0 | |
| 428 | 429 | } 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)) | |
| 430 | 431 | } | |
| 431 | 432 | }; | |
| 432 | 433 | let settled_at = rfc3339(now_ms()); |
| 16 | 16 | //! Reached only through service bindings; see `g1t_contracts::billing` for | |
| 17 | 17 | //! the methods and their arguments. | |
| 18 | 18 | ||
| 19 | + | mod accounts; | |
| 19 | 20 | mod features; | |
| 20 | 21 | mod keeper; | |
| 21 | 22 | mod limits; | |
| 24 | 25 | use g1t_contracts::billing::*; | |
| 25 | 26 | use g1t_contracts::time::rfc3339; | |
| 26 | 27 | use g1t_contracts::{FailureCode, Outcome, Role, new_id}; | |
| 28 | + | use g1t_contracts::billing::TermsKind; | |
| 27 | 29 | use g1t_kit::{args, now_ms, reply, rpc_method}; | |
| 28 | 30 | use serde::Deserialize; | |
| 29 | 31 | use sha2::{Digest, Sha256}; | |
| 138 | 140 | deployments_monthly_cents: u32, | |
| 139 | 141 | /// How far unpaid usage may go; see `limits`. | |
| 140 | 142 | ceilings: limits::Ceilings, | |
| 143 | + | /// `PREPAID_ONLY`: the old rule, that agents need credit first. | |
| 144 | + | prepaid_only: bool, | |
| 141 | 145 | } | |
| 142 | 146 | ||
| 143 | 147 | /// `TRIAL_WORKSPACE_MICROS`, `TRIAL_TOTAL_MICROS` and `TRIAL_UNTIL`. | |
| 247 | 251 | ])?, | |
| 248 | 252 | ]) | |
| 249 | 253 | .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 | + | } | |
| 250 | 262 | Ok(()) | |
| 251 | 263 | } | |
| 252 | 264 | ||
| 489 | 501 | ||
| 490 | 502 | /// A refusal if the workspace has no credit to start an agent with. | |
| 491 | 503 | 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 { | |
| 494 | 508 | return Ok(None); | |
| 495 | 509 | } | |
| 496 | 510 | let balance = self | |
| 509 | 523 | ||
| 510 | 524 | /// A workspace's free allowance on g1t's hosted models: what its runs | |
| 511 | 525 | /// 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 | + | ||
| 512 | 536 | async fn trial(&self, a: TrialArgs) -> Result<Trial> { | |
| 513 | 537 | let workspace = a.workspace.to_lowercase(); | |
| 514 | 538 | let Some(config) = &self.trial else { | |
| 524 | 548 | .db | |
| 525 | 549 | .prepare( | |
| 526 | 550 | "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 = ?", | |
| 528 | 552 | ) | |
| 529 | 553 | .bind(&[workspace.as_str().into()])? | |
| 530 | 554 | .first::<Sum>(None) | |
| 542 | 566 | .db | |
| 543 | 567 | .prepare(format!( | |
| 544 | 568 | "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})" | |
| 546 | 570 | )) | |
| 547 | 571 | .bind(&values)? | |
| 548 | 572 | .first::<Sum>(None) | |
| 648 | 672 | } | |
| 649 | 673 | // On the workspace's own provider, the model was paid for there: | |
| 650 | 674 | // 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() { | |
| 655 | 676 | self.orchestration_fee_micros | |
| 656 | 677 | } 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) | |
| 658 | 682 | }; | |
| 683 | + | let (charge, terms_note) = self.charged(&run.workspace, base).await?; | |
| 659 | 684 | let mut description = match run.task.as_str() { | |
| 660 | 685 | "plan" => format!("Planning for {}", run.repo), | |
| 661 | 686 | "review" => format!("Review of {}#{}", run.repo, run.number), | |
| 665 | 690 | if run.own_provider() { | |
| 666 | 691 | description.push_str(", on your own model provider"); | |
| 667 | 692 | } | |
| 668 | − | if self.free { | |
| 669 | − | description.push_str(" (free while g1t is being built out)"); | |
| 670 | − | } | |
| 693 | + | description.push_str(&terms_note); | |
| 671 | 694 | self.enter( | |
| 672 | 695 | &run.workspace, | |
| 673 | 696 | EntryKind::Usage, | |
| 726 | 749 | sandbox_allowance::COST_MICROS_PER_SECOND as f64, | |
| 727 | 750 | sandbox_allowance::MICROS_PER_SECOND as f64, | |
| 728 | 751 | )); | |
| 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?; | |
| 730 | 753 | let mut description = format!("{}: {} of sandbox time", a.description, duration(seconds)); | |
| 731 | 754 | if billable < seconds { | |
| 732 | 755 | description.push_str(if billable == 0 { | |
| 735 | 758 | ", partly within the month's free minutes" | |
| 736 | 759 | }); | |
| 737 | 760 | } | |
| 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); | |
| 740 | 763 | } | |
| 741 | 764 | self.db | |
| 742 | 765 | .batch(vec![ | |
| 793 | 816 | } | |
| 794 | 817 | } | |
| 795 | 818 | ||
| 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 | + | ||
| 796 | 838 | fn members_only<T>() -> Outcome<T> { | |
| 797 | 839 | Outcome::fail( | |
| 798 | 840 | FailureCode::Forbidden, | |
| 822 | 864 | .unwrap_or(100_000), | |
| 823 | 865 | free: env.var("FREE_WHILE_BUILDING").is_ok_and(|v| v.to_string() == "true"), | |
| 824 | 866 | ceilings: limits::Ceilings::from_env(&env), | |
| 867 | + | prepaid_only: env.var("PREPAID_ONLY").is_ok_and(|v| v.to_string() == "true"), | |
| 825 | 868 | deployments_monthly_cents: env | |
| 826 | 869 | .var("DEPLOYMENTS_MONTHLY_CENTS") | |
| 827 | 870 | .ok() | |
| 859 | 902 | if let Err(error) = billing.settle_runs(&keeper).await { | |
| 860 | 903 | worker::console_error!("settling runs failed: {error}"); | |
| 861 | 904 | } | |
| 905 | + | if let Err(error) = billing.autopay().await { | |
| 906 | + | worker::console_error!("paying at the limit failed: {error}"); | |
| 907 | + | } | |
| 862 | 908 | // Once a day, and at once if the costs were never checked: check every | |
| 863 | 909 | // cost against what Cloudflare billed. | |
| 864 | 910 | if event.cron() == keeper::DAILY || billing.never_checked().await.unwrap_or(false) { | |
| 898 | 944 | "set_spend_limit" => reply(&billing.set_spend_limit(args(body)?).await?), | |
| 899 | 945 | "prices" => reply(&billing.prices().await?), | |
| 900 | 946 | "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?), | |
| 901 | 953 | _ => Response::error("Unknown method", 404), | |
| 902 | 954 | } | |
| 903 | 955 | } |
| 21 | 21 | //! exemption from the ceiling. Test-mode payments are not money, so they | |
| 22 | 22 | //! do not raise trust. | |
| 23 | 23 | ||
| 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}; | |
| 25 | 25 | use g1t_contracts::time::rfc3339; | |
| 26 | 26 | use g1t_contracts::{FailureCode, Outcome, Role}; | |
| 27 | 27 | use g1t_kit::now_ms; | |
| 39 | 39 | /// `LIMIT_PAID_MIN_MICROS` and `LIMIT_PAID_MAX_MICROS`. | |
| 40 | 40 | pub paid_min: i64, | |
| 41 | 41 | pub paid_max: i64, | |
| 42 | − | /// `LIMIT_EXEMPT`: g1t's own workspaces, comma-separated. | |
| 43 | − | pub exempt: Vec<String>, | |
| 44 | 42 | } | |
| 45 | 43 | ||
| 46 | 44 | impl Ceilings { | |
| 52 | 50 | new: number("LIMIT_NEW_MICROS", 3_000_000), | |
| 53 | 51 | paid_min: number("LIMIT_PAID_MIN_MICROS", 25_000_000), | |
| 54 | 52 | 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(), | |
| 63 | 53 | } | |
| 64 | 54 | } | |
| 65 | 55 | ||
| 78 | 68 | } | |
| 79 | 69 | } | |
| 80 | 70 | ||
| 71 | + | /// Never charged automatically for less. | |
| 72 | + | const AUTOPAY_MIN_CENTS: i64 = 500; | |
| 73 | + | ||
| 81 | 74 | #[derive(Deserialize)] | |
| 82 | 75 | struct LimitRow { | |
| 83 | − | ceiling_micros: Option<i64>, | |
| 84 | 76 | spend_limit_micros: Option<i64>, | |
| 77 | + | autopay_failed_at: Option<String>, | |
| 78 | + | autopay_error: Option<String>, | |
| 85 | 79 | } | |
| 86 | 80 | ||
| 87 | 81 | #[derive(Deserialize)] | |
| 96 | 90 | } | |
| 97 | 91 | ||
| 98 | 92 | 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. | |
| 100 | 96 | pub(crate) async fn limit_of(&self, workspace: &str) -> Result<Limit> { | |
| 101 | 97 | let workspace = workspace.to_lowercase(); | |
| 98 | + | let account = self.account_of(&workspace).await?; | |
| 102 | 99 | let row = self | |
| 103 | 100 | .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 = ?") | |
| 105 | 102 | .bind(&[workspace.as_str().into()])? | |
| 106 | 103 | .first::<LimitRow>(None) | |
| 107 | 104 | .await?; | |
| 108 | 105 | 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()); | |
| 109 | 114 | // Each usage entry at its cost to g1t or its charge, whichever is | |
| 110 | 115 | // more; on the workspace's own provider, only g1t's fee is g1t's. | |
| 111 | 116 | let month = self | |
| 112 | 117 | .db | |
| 113 | − | .prepare( | |
| 118 | + | .prepare(format!( | |
| 114 | 119 | "SELECT | |
| 115 | 120 | SUM(CASE WHEN kind = 'usage' THEN | |
| 116 | 121 | CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t' | |
| 118 | 123 | ELSE -amount_micros END | |
| 119 | 124 | END) AS used, | |
| 120 | 125 | 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)? | |
| 124 | 129 | .first::<Month>(None) | |
| 125 | 130 | .await?; | |
| 126 | 131 | let (used, paid_month) = month.map_or((0, 0), |m| (m.used.unwrap_or(0), m.paid.unwrap_or(0))); | |
| 127 | 132 | // 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()); | |
| 128 | 135 | let pending = self | |
| 129 | 136 | .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)? | |
| 132 | 141 | .first::<Paid>(None) | |
| 133 | 142 | .await? | |
| 134 | 143 | .and_then(|row| row.paid) | |
| 138 | 147 | let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live); | |
| 139 | 148 | let exposure = (used - if live { paid_month } else { 0 }).max(0); | |
| 140 | 149 | ||
| 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 | + | } | |
| 151 | 160 | } | |
| 152 | 161 | }; | |
| 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()))); | |
| 154 | 166 | let ceiling = match (trust_ceiling, spend_limit) { | |
| 155 | 167 | (Some(ceiling), Some(own)) => Some(ceiling.min(own)), | |
| 156 | 168 | (None, Some(own)) => Some(own), | |
| 157 | 169 | (ceiling, None) => ceiling, | |
| 158 | 170 | }; | |
| 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 | + | }; | |
| 160 | 177 | let message = match state { | |
| 161 | 178 | LimitState::Ok => None, | |
| 162 | 179 | 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.", | |
| 164 | 181 | dollars_plain(exposure), | |
| 165 | 182 | dollars_plain(ceiling.unwrap_or_default()), | |
| 166 | 183 | )), | |
| 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 | + | )), | |
| 167 | 188 | LimitState::Stopped => Some(if spend_limit.is_some() && ceiling == spend_limit { | |
| 168 | 189 | format!( | |
| 169 | 190 | "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.", | |
| 171 | 192 | ) | |
| 172 | 193 | } else { | |
| 173 | 194 | 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.", | |
| 175 | 196 | dollars_plain(ceiling.unwrap_or_default()), | |
| 176 | 197 | ) | |
| 177 | 198 | }), | |
| 178 | 199 | }; | |
| 179 | 200 | Ok(Limit { | |
| 180 | 201 | workspace, | |
| 202 | + | account: account.id, | |
| 203 | + | account_name: account.name, | |
| 181 | 204 | trust, | |
| 182 | 205 | exposure_micros: exposure, | |
| 183 | 206 | ceiling_micros: ceiling, | |
| 188 | 211 | }) | |
| 189 | 212 | } | |
| 190 | 213 | ||
| 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> { | |
| 193 | 217 | if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) { | |
| 194 | 218 | return Ok(0); | |
| 195 | 219 | } | |
| 220 | + | let marks = vec!["?"; members.len().max(1)].join(", "); | |
| 196 | 221 | Ok(self | |
| 197 | 222 | .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)? | |
| 200 | 228 | .first::<Paid>(None) | |
| 201 | 229 | .await? | |
| 202 | 230 | .and_then(|row| row.paid) | |
| 246 | 274 | Ok(true) | |
| 247 | 275 | } | |
| 248 | 276 | ||
| 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 | + | ||
| 249 | 357 | pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> { | |
| 250 | 358 | Ok(Outcome::Ok(self.limit_of(&a.workspace).await?)) | |
| 251 | 359 | } | |
| 279 | 387 | use super::*; | |
| 280 | 388 | ||
| 281 | 389 | 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 } | |
| 283 | 391 | } | |
| 284 | 392 | ||
| 285 | 393 | #[test] |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | 82 | /// `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 | + | ||
| 83 | 93 | pub(crate) fn form(fields: &[(&str, String)]) -> String { | |
| 84 | 94 | fields | |
| 85 | 95 | .iter() | |
| 104 | 114 | path: &str, | |
| 105 | 115 | body: Option<String>, | |
| 106 | 116 | ) -> 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> { | |
| 107 | 127 | let headers = Headers::new(); | |
| 108 | 128 | headers.set("authorization", &format!("Bearer {}", self.key))?; | |
| 129 | + | if let Some(key) = idempotency_key { | |
| 130 | + | headers.set("idempotency-key", key)?; | |
| 131 | + | } | |
| 109 | 132 | if body.is_some() { | |
| 110 | 133 | headers.set("content-type", "application/x-www-form-urlencoded")?; | |
| 111 | 134 | } | |
| 126 | 149 | response.json().await | |
| 127 | 150 | } | |
| 128 | 151 | ||
| 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 | + | ||
| 129 | 187 | /// Starts a page on which `amount_cents` of credit is paid for by card. | |
| 130 | 188 | /// The card is kept for the workspace, so that topping up again, by | |
| 131 | 189 | /// hand or automatically, needs no retyping. |
| 29 | 29 | // While g1t is being built out, workspaces pay nothing: runs are | |
| 30 | 30 | // recorded with what they cost, and nothing is charged. Set to | |
| 31 | 31 | // "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", | |
| 33 | 35 | // How far a workspace's unpaid usage may go before its work stops | |
| 34 | 36 | // (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. | |
| 36 | 39 | "LIMIT_NEW_MICROS": "3000000", | |
| 37 | 40 | "LIMIT_PAID_MIN_MICROS": "25000000", | |
| 38 | 41 | "LIMIT_PAID_MAX_MICROS": "1000000000", | |
| 39 | − | "LIMIT_EXEMPT": "syntaqx", | |
| 40 | 42 | // A free allowance on g1t's hosted models, so people can try g1t's | |
| 41 | 43 | // agents without a key of their own: each workspace may use this | |
| 42 | 44 | // much model cost ($1), out of one pool for everyone ($40), until |