Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
1 commit
54 files+4028−1460/54 viewed
| 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 |