Public: anyone can read this; the crew can change it.
Billing is a seam with two providers, none and stripe; with none, every forge is free within its plan #11150
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11134-billing-is-a-seam-with-two-providers-non → main 5 commits fork 4 threads · 4 unresolved
26 files
changed +732
−8
docs/MCP.md +1 −1
| @@ -112,7 +112,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 112 | 112 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | |
| 113 | 113 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | |
| 114 | 114 | | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 | |
| 115 | | `forges` | — | Forges sent from here (admin): name, address, apps, claimed, stage, resend, spendTodayUsd, ceilingUsd, size, use | 231 | | |
| 115 | | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 257 | | |
| 116 | 116 | | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 | |
| 117 | 117 | | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 397 | |
| 118 | 118 | | `forks` | — | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 345 | |
docs/PROFIT.md +11
| @@ -114,3 +114,14 @@ forge's cost is the apps' costs plus the box's unowned share. How to read it: `d | ||
| 114 | 114 | constant. |
| 115 | 115 | - Billing produces a statement, with no payment processor. A person can |
| 116 | 116 | check every line by hand. |
| 117 | ||
| 118 | ## Billing | |
| 119 | ||
| 120 | Billing records each forge's plan, `src/billing/`, with no SDK and no | |
| 121 | dependency: a store with one row per forge (plan, status, period end, payer, | |
| 122 | provider, the provider's ids) whose every change is kept. No payment | |
| 123 | processor is written; every forge is free within its plan and an admin grants | |
| 124 | plans by hand (`PUT /api/v1/forges/{name}/plan`, the Billing page, the | |
| 125 | terminal, the MCP `forges` tool). A forge with no row is active on the plan | |
| 126 | its size names. Nothing reads a plan to limit a forge yet. How to use it: | |
| 127 | `docs/features/billing.md`. |
docs/design/attention-budget.json +2 −2
| @@ -109,12 +109,12 @@ | ||
| 109 | 109 | }, |
| 110 | 110 | "settings": { |
| 111 | 111 | "390": { |
| 112 | "words": 140, | |
| 112 | "words": 141, | |
| 113 | 113 | "blocks": 32, |
| 114 | 114 | "colours": 1 |
| 115 | 115 | }, |
| 116 | 116 | "1280": { |
| 117 | "words": 140, | |
| 117 | "words": 141, | |
| 118 | 118 | "blocks": 32, |
| 119 | 119 | "colours": 1 |
| 120 | 120 | } |
docs/design/audit.md +1
| @@ -105,6 +105,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 105 | 105 | - `/admin/limits` — `pages/admin-limits.ts` · every limit with value, default and reason · `/settings/capacity` (also a numbers-you-can-change page) · none seen. |
| 106 | 106 | - `/admin/flows` — `pages/flows-all.ts` · every repository's flow lines with state and last moved, by kind and stale · `/:owner/:name/flows` · none seen. |
| 107 | 107 | - `/admin/data` — `pages/data.ts` · the forge's own database: tables, rows, read-only SQL, secrets masked · `/data` (the Forge data button) · none seen; shares its body (`pages/data_view.ts`) with `/data` and `/:owner/:name/data`. |
| 108 | - `/settings/billing` — `pages/billing.ts` · each forge's plan, granted by hand · `/settings/economics` · none seen. | |
| 108 | 109 | - `/settings/economics` — `pages/economics.ts` · the profit slider's position and the margin, with the policy in words · `/admin/limits`, `/settings/capacity` · none seen. |
| 109 | 110 | - `/settings/capacity` — `pages/capacity.ts` · how many runs and checks go at once · `/admin/limits`, `/settings/usage` · none seen. |
| 110 | 111 | - `/settings/usage` — `pages/usage.ts` · route patterns by use, unused first · `/settings/telemetry`, `/board/returns` · none seen. Not about cost, despite the name. |
docs/features/billing.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # Billing | |
| 2 | ||
| 3 | Billing records the plan each forge is on (`src/billing/`): a store of entitlements, every change kept. There is no payment processor and nothing reads a plan to limit a forge yet; a grant is a record until a limit reads it. | |
| 4 | ||
| 5 | - **Plans:** small, medium or large, the sizes. A forge never granted a plan reads as `active` on its size, source `granted`. | |
| 6 | - **Grant by hand (admin, a person):** the Billing page, the terminal screen, `PUT /api/v1/forges/{name}/plan` with `{ "plan": "large" }` (`GET` reads it), or the MCP `forges` tool with `{ forge, plan }` (planned first; `dryRun: false` grants). | |
| 7 | - **Audit:** every grant is a row in `entitlement_change` and a `billing.plan` event. | |
| 8 | - **Terminal:** the palette's *Billing* (admins), `src/tui/screens/billing.ts`: `j`/`k` pick a forge, `s`, `m` or `l` grants that plan after a yes/no. |
src/actions/billing.ts added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | /** | |
| 2 | * Billing as a forge admin reaches it (#11134): every forge's plan, and the | |
| 3 | * hand grant that is the only way a plan changes (no payment processor). Never an agent: a crew that could | |
| 4 | * grant itself a plan would decide its own limits. Each grant is kept in | |
| 5 | * `entitlement_change` and put on the event outbox (`billing.plan`), so it | |
| 6 | * shows in the audit trail. Anyone else is told `not_found`. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { changesOf, entitlementOf, putEntitlement, type EntitlementChange } from "../billing/store.ts"; | |
| 11 | import type { Entitlement } from "../billing/types.ts"; | |
| 12 | import type { User } from "../core/users.ts"; | |
| 13 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 14 | import { listChildrenNow } from "./spawn.ts"; | |
| 15 | import { parseSize } from "../spawn/sizes.ts"; | |
| 16 | ||
| 17 | export type BillingError = "not_found" | "no_such_forge" | "bad_plan"; | |
| 18 | ||
| 19 | export const BILLING_WORDS: Record<Exclude<BillingError, "not_found">, string> = { | |
| 20 | no_such_forge: "No forge by that name was sent from here", | |
| 21 | bad_plan: "A plan is small, medium or large", | |
| 22 | }; | |
| 23 | ||
| 24 | export interface BillingView { | |
| 25 | readonly forges: readonly (Entitlement & { size: string })[]; | |
| 26 | } | |
| 27 | ||
| 28 | const admin = (a: User | undefined): Result<User, "not_found"> => (a?.isAdmin === true && a.kind !== "agent" ? ok(a) : err("not_found")); | |
| 29 | ||
| 30 | export function readBilling(app: App, actor: User | undefined): Result<BillingView, "not_found"> { | |
| 31 | const who = admin(actor); | |
| 32 | if (!who.ok) return who; | |
| 33 | return ok({ | |
| 34 | forges: listChildrenNow(app).map((c) => ({ ...entitlementOf(app.db, c.name, c.size), size: c.size })), | |
| 35 | }); | |
| 36 | } | |
| 37 | ||
| 38 | /** The forge's entitlement and its last changes. */ | |
| 39 | export function readPlan(app: App, actor: User | undefined, forge: string): Result<{ entitlement: Entitlement; changes: EntitlementChange[] }, BillingError> { | |
| 40 | const who = admin(actor); | |
| 41 | if (!who.ok) return who; | |
| 42 | const child = listChildrenNow(app).find((c) => c.name === forge); | |
| 43 | if (!child) return err("no_such_forge"); | |
| 44 | return ok({ entitlement: entitlementOf(app.db, forge, child.size), changes: changesOf(app.db, forge) }); | |
| 45 | } | |
| 46 | ||
| 47 | /** The validation of a grant without the write: the plan it would make, or why not. */ | |
| 48 | export function planGrant(app: App, actor: User | undefined, forge: unknown, plan: unknown): Result<{ from: Entitlement; to: Entitlement }, BillingError> { | |
| 49 | const who = admin(actor); | |
| 50 | if (!who.ok) return who; | |
| 51 | const child = typeof forge === "string" ? listChildrenNow(app).find((c) => c.name === forge) : undefined; | |
| 52 | if (!child) return err("no_such_forge"); | |
| 53 | const size = parseSize(plan); | |
| 54 | if (!size.ok) return err("bad_plan"); | |
| 55 | const from = entitlementOf(app.db, child.name, child.size); | |
| 56 | return ok({ from, to: { forge: child.name, plan: size.value, status: "active", source: "granted" } }); | |
| 57 | } | |
| 58 | ||
| 59 | /** An admin grants or changes a forge's plan by hand: active, source `granted`. */ | |
| 60 | export function grantPlan(app: App, actor: User | undefined, forge: unknown, plan: unknown): Result<{ entitlement: Entitlement; changes: EntitlementChange[] }, BillingError> { | |
| 61 | const planned = planGrant(app, actor, forge, plan); | |
| 62 | if (!planned.ok) return planned; | |
| 63 | const who = actor as User; | |
| 64 | const { from, to } = planned.value; | |
| 65 | const size = listChildrenNow(app).find((c) => c.name === to.forge)?.size ?? from.plan; | |
| 66 | const entitlement = putEntitlement(app.db, { forge: to.forge, plan: to.plan, status: "active", source: "granted", provider: "none" }, size, who.id); | |
| 67 | app.bus.append({ kind: "billing.plan", actorId: who.id, payload: { forge: to.forge, from: from.plan, to: to.plan, source: "granted" } }); | |
| 68 | return ok({ entitlement, changes: changesOf(app.db, to.forge) }); | |
| 69 | } |
src/billing/store.ts added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | /** | |
| 2 | * The store of entitlements: one row per forge (plan, status, period end, | |
| 3 | * payer, provider, the provider's customer and subscription ids) and every | |
| 4 | * change kept in `entitlement_change` — who, when, from what to what. A forge | |
| 5 | * with no row is `active` on the plan matching its size, source `granted` | |
| 6 | * (so every forge that existed before billing is free within its plan). | |
| 7 | * Pure data; authorization is in `actions/billing.ts`. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import type { Entitlement, EntitlementStatus } from "./types.ts"; | |
| 12 | ||
| 13 | export interface Stored extends Entitlement { | |
| 14 | payer: string; | |
| 15 | provider: string; | |
| 16 | customerId: string; | |
| 17 | subscriptionId: string; | |
| 18 | updatedAt: string; | |
| 19 | } | |
| 20 | ||
| 21 | export interface EntitlementChange { | |
| 22 | readonly forge: string; | |
| 23 | readonly fromPlan: string; | |
| 24 | readonly toPlan: string; | |
| 25 | readonly fromStatus: string; | |
| 26 | readonly toStatus: string; | |
| 27 | readonly source: string; | |
| 28 | readonly actorId: string; | |
| 29 | readonly at: string; | |
| 30 | } | |
| 31 | ||
| 32 | interface Row { | |
| 33 | forge: string; plan: string; status: EntitlementStatus; period_end: number | null; payer: string; | |
| 34 | provider: string; customer_id: string; subscription_id: string; source: string; updated_at: string; | |
| 35 | } | |
| 36 | ||
| 37 | const fromRow = (r: Row): Stored => ({ | |
| 38 | forge: r.forge, | |
| 39 | plan: r.plan, | |
| 40 | status: r.status, | |
| 41 | ...(r.period_end === null ? {} : { periodEnd: r.period_end }), | |
| 42 | source: r.source, | |
| 43 | payer: r.payer, | |
| 44 | provider: r.provider, | |
| 45 | customerId: r.customer_id, | |
| 46 | subscriptionId: r.subscription_id, | |
| 47 | updatedAt: r.updated_at, | |
| 48 | }); | |
| 49 | ||
| 50 | export function getStored(db: Database, forge: string): Stored | undefined { | |
| 51 | const r = db.query<Row, [string]>("SELECT * FROM entitlement WHERE forge = ?1").get(forge); | |
| 52 | return r ? fromRow(r) : undefined; | |
| 53 | } | |
| 54 | ||
| 55 | /** What a forge is entitled to: its row, or `active` on the plan its size names. */ | |
| 56 | export function entitlementOf(db: Database, forge: string, size: string): Entitlement { | |
| 57 | const s = getStored(db, forge); | |
| 58 | if (!s) return { forge, plan: size, status: "active", source: "granted" }; | |
| 59 | const { forge: f, plan, status, periodEnd, source } = s; | |
| 60 | return { forge: f, plan, status, ...(periodEnd === undefined ? {} : { periodEnd }), source }; | |
| 61 | } | |
| 62 | ||
| 63 | export interface Put { | |
| 64 | forge: string; | |
| 65 | plan: string; | |
| 66 | status: EntitlementStatus; | |
| 67 | periodEnd?: number; | |
| 68 | payer?: string; | |
| 69 | provider?: string; | |
| 70 | customerId?: string; | |
| 71 | subscriptionId?: string; | |
| 72 | source: string; | |
| 73 | } | |
| 74 | ||
| 75 | /** Write the forge's one row and keep the change, in one transaction. `size` is the plan a row-less forge already had. */ | |
| 76 | export function putEntitlement(db: Database, p: Put, size: string, actorId: string): Entitlement { | |
| 77 | const before = getStored(db, p.forge); | |
| 78 | const at = new Date().toISOString(); | |
| 79 | db.transaction(() => { | |
| 80 | db.query( | |
| 81 | `INSERT INTO entitlement (forge, plan, status, period_end, payer, provider, customer_id, subscription_id, source, updated_at) | |
| 82 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) | |
| 83 | ON CONFLICT (forge) DO UPDATE SET plan = excluded.plan, status = excluded.status, period_end = excluded.period_end, | |
| 84 | payer = excluded.payer, provider = excluded.provider, customer_id = excluded.customer_id, | |
| 85 | subscription_id = excluded.subscription_id, source = excluded.source, updated_at = excluded.updated_at`, | |
| 86 | ).run(p.forge, p.plan, p.status, p.periodEnd ?? null, p.payer ?? before?.payer ?? "", p.provider ?? before?.provider ?? "none", | |
| 87 | p.customerId ?? before?.customerId ?? "", p.subscriptionId ?? before?.subscriptionId ?? "", p.source, at); | |
| 88 | db.query( | |
| 89 | `INSERT INTO entitlement_change (forge, from_plan, to_plan, from_status, to_status, source, actor_id, at) | |
| 90 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`, | |
| 91 | ).run(p.forge, before?.plan ?? size, p.plan, before?.status ?? "active", p.status, p.source, actorId, at); | |
| 92 | })(); | |
| 93 | return entitlementOf(db, p.forge, size); | |
| 94 | } | |
| 95 | ||
| 96 | export function changesOf(db: Database, forge: string, limit = 20): EntitlementChange[] { | |
| 97 | return db | |
| 98 | .query<{ forge: string; from_plan: string; to_plan: string; from_status: string; to_status: string; source: string; actor_id: string; at: string }, [string, number]>( | |
| 99 | "SELECT * FROM entitlement_change WHERE forge = ?1 ORDER BY id DESC LIMIT ?2", | |
| 100 | ) | |
| 101 | .all(forge, limit) | |
| 102 | .map((r) => ({ forge: r.forge, fromPlan: r.from_plan, toPlan: r.to_plan, fromStatus: r.from_status, toStatus: r.to_status, source: r.source, actorId: r.actor_id, at: r.at })); | |
| 103 | } |
src/billing/types.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | /** | |
| 2 | * What a forge is entitled to (#11134): plain data. There is no payment | |
| 3 | * processor; an admin's hand grant is the only thing that writes one. | |
| 4 | */ | |
| 5 | ||
| 6 | export type EntitlementStatus = "active" | "grace" | "none"; | |
| 7 | ||
| 8 | export type Entitlement = { | |
| 9 | forge: string; | |
| 10 | plan: string; | |
| 11 | status: EntitlementStatus; | |
| 12 | periodEnd?: number; | |
| 13 | source: string; | |
| 14 | }; |
src/contract/openapi/routes/getApiV1ForgesNamePlan.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/forges/{name}/plan", | |
| 6 | summary: "A child forge's entitlement ({ forge, plan, status, periodEnd?, source }; a forge never granted a plan is active on the plan its size names) and its last changes (a person admin's token)", | |
| 7 | auth: true, | |
| 8 | role: "admin", | |
| 9 | refusals: { not_found: "the token is not a person who administers the forge (404)", no_such_forge: "no child forge has this name (404)" }, | |
| 10 | }; |
src/contract/openapi/routes/index.ts +2
| @@ -93,6 +93,7 @@ export { route as getApiV1Forge } from "./getApiV1Forge.ts"; | ||
| 93 | 93 | export { route as getApiV1ForgeEconomics } from "./getApiV1ForgeEconomics.ts"; |
| 94 | 94 | export { route as getApiV1ForgeView } from "./getApiV1ForgeView.ts"; |
| 95 | 95 | export { route as getApiV1Forges } from "./getApiV1Forges.ts"; |
| 96 | export { route as getApiV1ForgesNamePlan } from "./getApiV1ForgesNamePlan.ts"; | |
| 96 | 97 | export { route as getApiV1ForgesSizes } from "./getApiV1ForgesSizes.ts"; |
| 97 | 98 | export { route as getApiV1ForgesTemplates } from "./getApiV1ForgesTemplates.ts"; |
| 98 | 99 | export { route as getApiV1Front } from "./getApiV1Front.ts"; |
| @@ -381,6 +382,7 @@ export { route as putApiV1BoardsSlug } from "./putApiV1BoardsSlug.ts"; | ||
| 381 | 382 | export { route as putApiV1FeaturesSlug } from "./putApiV1FeaturesSlug.ts"; |
| 382 | 383 | export { route as putApiV1ForgeEconomics } from "./putApiV1ForgeEconomics.ts"; |
| 383 | 384 | export { route as putApiV1ForgesNameCredit } from "./putApiV1ForgesNameCredit.ts"; |
| 385 | export { route as putApiV1ForgesNamePlan } from "./putApiV1ForgesNamePlan.ts"; | |
| 384 | 386 | export { route as putApiV1Front } from "./putApiV1Front.ts"; |
| 385 | 387 | export { route as putApiV1PacksName } from "./putApiV1PacksName.ts"; |
| 386 | 388 | export { route as putApiV1ReposOwnerNameAppEnvVar } from "./putApiV1ReposOwnerNameAppEnvVar.ts"; |
src/contract/openapi/routes/putApiV1ForgesNamePlan.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "put", | |
| 5 | path: "/api/v1/forges/{name}/plan", | |
| 6 | summary: "Grant or change a child forge's plan by hand (a person admin's token, admin scope): active, source granted, kept in the audit trail. Answers { entitlement, changes }", | |
| 7 | auth: true, | |
| 8 | role: "admin", | |
| 9 | body: { plan: "small, medium or large" }, | |
| 10 | refusals: { not_found: "the token is not a person who administers the forge (404)", no_such_forge: "no child forge has this name (404)", bad_plan: "plan is not small, medium or large (422)" }, | |
| 11 | }; |
src/core/event_audience.ts +2
| @@ -236,6 +236,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 236 | 236 | kind.startsWith("pinned.") || |
| 237 | 237 | // Which of a person's connected accounts a mirror syncs through is too. |
| 238 | 238 | kind.startsWith("origin.") || |
| 239 | // A forge's plan and the billing provider are the admins' books (#11134). | |
| 240 | kind.startsWith("billing.") || | |
| 239 | 241 | // A declared LAN device names the owner's own network (#10567). |
| 240 | 242 | kind.startsWith("device.") || |
| 241 | 243 | kind === "repo.settings_updated" || |
src/core/migration-pins/20261110200000-billing-entitlements.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 675c45d4fe69424f2d83aea54d896c6c9f17d8696ef4409d2e3e52a564fa01a8 |
src/core/migrations/20261110200000-billing-entitlements.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | // What each forge is entitled to (#11134), one row per forge, every change | |
| 2 | // kept. Additive. A forge with no row reads as | |
| 3 | // active on the plan matching its size (src/billing/store.ts). | |
| 4 | export default ` | |
| 5 | CREATE TABLE entitlement ( | |
| 6 | forge TEXT PRIMARY KEY, | |
| 7 | plan TEXT NOT NULL, | |
| 8 | status TEXT NOT NULL CHECK (status IN ('active','grace','none')), | |
| 9 | period_end INTEGER, | |
| 10 | payer TEXT NOT NULL DEFAULT '', | |
| 11 | provider TEXT NOT NULL DEFAULT 'none', | |
| 12 | customer_id TEXT NOT NULL DEFAULT '', | |
| 13 | subscription_id TEXT NOT NULL DEFAULT '', | |
| 14 | source TEXT NOT NULL, | |
| 15 | updated_at TEXT NOT NULL | |
| 16 | ); | |
| 17 | CREATE TABLE entitlement_change ( | |
| 18 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 19 | forge TEXT NOT NULL, | |
| 20 | from_plan TEXT NOT NULL, | |
| 21 | to_plan TEXT NOT NULL, | |
| 22 | from_status TEXT NOT NULL, | |
| 23 | to_status TEXT NOT NULL, | |
| 24 | source TEXT NOT NULL, | |
| 25 | actor_id TEXT NOT NULL, | |
| 26 | at TEXT NOT NULL | |
| 27 | ); | |
| 28 | CREATE INDEX entitlement_change_forge ON entitlement_change (forge, id); | |
| 29 | `; |
src/crew/conversation_confirm.ts +1
| @@ -109,6 +109,7 @@ export const ASKING: Readonly<Record<string, Asking>> = { | ||
| 109 | 109 | app_deploy: { does: "redeploy or stop an app's production or preview" }, |
| 110 | 110 | stop: { does: "stop a crew run" }, |
| 111 | 111 | converge: { does: "have a fork's crew merge the parent's updates" }, |
| 112 | forges: { does: "grant a forge a plan by hand", asks: (a) => a["plan"] !== undefined && a["dryRun"] === false }, | |
| 112 | 113 | origins: { does: "sync an origin or replace the internal hosts one may live on", asks: not("action", "list", "repos") }, |
| 113 | 114 | cloud_account: { does: "change a cloud account" }, |
| 114 | 115 | pinned_forge: { does: "deploy, update or roll back a pinned forge on the owner's cloud", asks: not("verb", "list") }, |
src/http/api.ts +2
| @@ -350,6 +350,7 @@ import { printRoutes } from "./api/print.ts"; | ||
| 350 | 350 | import { forgeRoutes } from "./api/forges.ts"; |
| 351 | 351 | import { packRoutes } from "./api/packs.ts"; |
| 352 | 352 | import { forgeViewRoutes } from "./api/forge_view.ts"; |
| 353 | import { billingRoutes } from "./api/billing.ts"; | |
| 353 | 354 | import { economicsRoutes } from "./api/economics.ts"; |
| 354 | 355 | import { releasesRoutes } from "./api/releases.ts"; |
| 355 | 356 | import { hookRoutes } from "./api/hooks.ts"; |
| @@ -3585,6 +3586,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3585 | 3586 | packRoutes(router); |
| 3586 | 3587 | forgeViewRoutes(router); |
| 3587 | 3588 | economicsRoutes(router); |
| 3589 | billingRoutes(router); | |
| 3588 | 3590 | releasesRoutes(router); |
| 3589 | 3591 | |
| 3590 | 3592 | router.add("GET", "/api/v1/apps", (ctx) => { |
src/http/api/billing.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * GET/PUT /api/v1/forges/{name}/plan: a forge's entitlement and the admin's | |
| 3 | * hand grant (`actions/billing.ts`), the twin of Settings → Billing. A | |
| 4 | * forge admin who is a person; anyone else is told 404. Same-origin checked. | |
| 5 | */ | |
| 6 | ||
| 7 | import { BILLING_WORDS, grantPlan, readPlan } from "../../actions/billing.ts"; | |
| 8 | import { crossOrigin, type Handler } from "../context.ts"; | |
| 9 | import { apiError, body, forbidden, json, notFound, requireScope } from "../respond.ts"; | |
| 10 | import type { Router } from "../router.ts"; | |
| 11 | ||
| 12 | export function billingRoutes(router: Router<Handler>): void { | |
| 13 | router.add("GET", "/api/v1/forges/:name/plan", (ctx) => { | |
| 14 | if (ctx.auth.user?.isAdmin !== true) return notFound(); | |
| 15 | const denied = requireScope(ctx, "ro"); | |
| 16 | if (denied) return denied; | |
| 17 | const read = readPlan(ctx.app, ctx.auth.user, ctx.params["name"] ?? ""); | |
| 18 | if (read.ok) return json(read.value, 200, { "cache-control": "private, no-cache" }); | |
| 19 | return read.error === "not_found" ? notFound() : apiError(read.error, 404, BILLING_WORDS[read.error]); | |
| 20 | }); | |
| 21 | ||
| 22 | router.add("PUT", "/api/v1/forges/:name/plan", async (ctx) => { | |
| 23 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden(); | |
| 24 | if (ctx.auth.user?.isAdmin !== true) return notFound(); | |
| 25 | const denied = requireScope(ctx, "admin"); | |
| 26 | if (denied) return denied; | |
| 27 | const input = await body(ctx.request); | |
| 28 | const set = grantPlan(ctx.app, ctx.auth.user, ctx.params["name"] ?? "", input["plan"]); | |
| 29 | if (set.ok) return json(set.value); | |
| 30 | if (set.error === "not_found") return notFound(); | |
| 31 | return set.error === "no_such_forge" ? apiError(set.error, 404, BILLING_WORDS[set.error]) : apiError(set.error, 422, BILLING_WORDS[set.error]); | |
| 32 | }); | |
| 33 | } |
src/http/mcp/tools/forges.ts +23 −5
| @@ -1,16 +1,34 @@ | ||
| 1 | import { BILLING_WORDS, grantPlan, planGrant, readPlan } from "../../../actions/billing.ts"; | |
| 2 | import { scopeAllows } from "../../../core/tokens.ts"; | |
| 1 | 3 | import { clientRows } from "../../../spawn/clients.ts"; |
| 2 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| 3 | 5 | |
| 4 | 6 | export const tool: Tool = { |
| 5 | 7 | name: "forges", |
| 6 | doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed, stage (sent/opened/signed in/last tell; resend true when unopened 3 days: claim_link), last activity, reachable, size, use", | |
| 8 | doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed, stage (sent/opened/signed in/last tell; resend true when unopened 3 days: claim_link), last activity, reachable, size, use; `{ forge, plan?, dryRun? }` reads a forge's plan (small|medium|large, status, source, changes) or, with `plan`, grants it by hand, planned unless `dryRun` is false", | |
| 7 | 9 | description: |
| 8 | "Forges sent from here (admin): name, address, apps, claimed, stage, resend, spendTodayUsd, ceilingUsd, size, use.", | |
| 9 | inputSchema: { type: "object", properties: {} }, | |
| 10 | writes: false, | |
| 11 | async run(ctx, user) { | |
| 10 | "Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant.", | |
| 11 | inputSchema: { type: "object", properties: { forge: { type: "string" }, plan: { type: "string" }, dryRun: { type: "boolean" } } }, | |
| 12 | writes: true, | |
| 13 | dryByDefault: true, | |
| 14 | readsWithout: ["forge", "plan"], | |
| 15 | async run(ctx, user, args) { | |
| 12 | 16 | if (!user.isAdmin) |
| 13 | 17 | return bad("forbidden", "only an admin of this forge sees the forges it sent"); |
| 18 | if (args["forge"] !== undefined || args["plan"] !== undefined) { | |
| 19 | const refuse = (e: string) => (e === "not_found" ? bad("not_found", "Not found.") : bad("invalid", BILLING_WORDS[e as keyof typeof BILLING_WORDS] ?? e)); | |
| 20 | if (args["plan"] === undefined) { | |
| 21 | const read = readPlan(ctx.app, user, String(args["forge"])); | |
| 22 | return read.ok ? good(read.value) : refuse(read.error); | |
| 23 | } | |
| 24 | if (args["dryRun"] !== false) { | |
| 25 | const planned = planGrant(ctx.app, user, args["forge"], args["plan"]); | |
| 26 | return planned.ok ? good({ ...planned.value, says: "This is the plan; nothing changed. Send dryRun: false to grant it." }) : refuse(planned.error); | |
| 27 | } | |
| 28 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "granting a plan needs an admin token"); | |
| 29 | const set = grantPlan(ctx.app, user, args["forge"], args["plan"]); | |
| 30 | return set.ok ? good(set.value) : refuse(set.error); | |
| 31 | } | |
| 14 | 32 | const forges = await clientRows(ctx.app); |
| 15 | 33 | return good({ |
| 16 | 34 | says: |
src/http/settings_routes.ts +3
| @@ -17,6 +17,7 @@ import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts | ||
| 17 | 17 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| 18 | 18 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 19 | 19 | import { appTemplatesPage, appTemplatesSet } from "../web/pages/app_templates.ts"; |
| 20 | import { billingPage, billingGrant } from "../web/pages/billing.ts"; | |
| 20 | 21 | import { economicsPage, economicsSet } from "../web/pages/economics.ts"; |
| 21 | 22 | import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts"; |
| 22 | 23 | import { usagePage } from "../web/pages/usage.ts"; |
| @@ -205,6 +206,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 205 | 206 | router.add("POST", "/settings/capacity", capacitySet); |
| 206 | 207 | router.add("GET", "/settings/economics", economicsPage); |
| 207 | 208 | router.add("POST", "/settings/economics", economicsSet); |
| 209 | router.add("GET", "/settings/billing", billingPage); | |
| 210 | router.add("POST", "/settings/billing/grant", billingGrant); | |
| 208 | 211 | router.add("GET", "/settings/flags", flagsPage); |
| 209 | 212 | // Apps absorbed as the forge's own features, and their settings. |
| 210 | 213 | router.add("GET", "/settings/captain", captainPage); |
src/tui/screens/billing.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** | |
| 2 | * Billing in the terminal, a forge admin's who is a person: each forge's plan | |
| 3 | * and the hand grant, through the same `readBilling` and `grantPlan` the page | |
| 4 | * and API use. `s`, `m` or `l` grants the forge under the cursor that plan | |
| 5 | * after a yes/no; the change is kept in the audit trail like any other. | |
| 6 | */ | |
| 7 | ||
| 8 | import { grantPlan, readBilling, BILLING_WORDS } from "../../actions/billing.ts"; | |
| 9 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 10 | import { binding } from "../app/keys.ts"; | |
| 11 | import { fill, len, split } from "../ui/layout.ts"; | |
| 12 | import { style } from "../term/style.ts"; | |
| 13 | import { truncate } from "../term/width.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | import { emptyState } from "../ui/widgets.ts"; | |
| 16 | import { ListState, confirmScreen, drawList, yesNo, type ListRow } from "./common.ts"; | |
| 17 | import type { PaletteEntry } from "./entries.ts"; | |
| 18 | ||
| 19 | const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent"; | |
| 20 | const PLANS: Record<string, string> = { s: "small", m: "medium", l: "large" }; | |
| 21 | ||
| 22 | export function billingRows(f: Session): ListRow[] { | |
| 23 | const read = readBilling(f.app, f.user); | |
| 24 | if (!read.ok) return []; | |
| 25 | return read.value.forges.map((e) => ({ title: e.forge, secondary: e.plan, meta: `${e.status} · ${e.source}` })); | |
| 26 | } | |
| 27 | ||
| 28 | export function billingScreen(f: Session, ctx: Ctx): Screen { | |
| 29 | const state = new ListState(); | |
| 30 | let note = ""; | |
| 31 | const screen: Screen = { | |
| 32 | title: "Billing", | |
| 33 | keys: [binding(["j", "k"], "move cursor"), binding(["s", "m", "l"], "grant small, medium, large"), binding(["esc"], "back")], | |
| 34 | render(area, s) { | |
| 35 | const c = ctx.theme.c; | |
| 36 | const rows = billingRows(f); | |
| 37 | state.clamp(rows.length); | |
| 38 | if (rows.length === 0) { | |
| 39 | emptyState(s, area, c, { title: "No forge has been sent from here.", body: "Billing has nothing to grant yet." }); | |
| 40 | return; | |
| 41 | } | |
| 42 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 43 | const line = note === "" ? "Every forge is free within its plan; a grant is recorded by hand" : note; | |
| 44 | s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 45 | drawList(s, body!, c, state, rows); | |
| 46 | }, | |
| 47 | key(e) { | |
| 48 | const plan = PLANS[e.key]; | |
| 49 | const rows = billingRows(f); | |
| 50 | const target = rows[state.index]; | |
| 51 | if (plan !== undefined && target !== undefined) { | |
| 52 | const grant = (i: number): void => { | |
| 53 | if (i !== 0) return; | |
| 54 | const set = grantPlan(f.app, f.user, target.title, plan); | |
| 55 | note = set.ok ? `${target.title} is on ${plan}.` : set.error === "not_found" ? "Not found." : `${BILLING_WORDS[set.error]}.`; | |
| 56 | ctx.invalidate(); | |
| 57 | }; | |
| 58 | const ask = confirmScreen(ctx, screen, { | |
| 59 | title: "Grant a plan", | |
| 60 | body: [`Put ${target.title} on ${plan}?`], | |
| 61 | actions: [{ label: "Grant" }, { label: "Cancel" }], | |
| 62 | onChoose: grant, | |
| 63 | }); | |
| 64 | ctx.push(yesNo(ctx, ask, grant)); | |
| 65 | return true; | |
| 66 | } | |
| 67 | return state.key(e, rows.length); | |
| 68 | }, | |
| 69 | }; | |
| 70 | return screen; | |
| 71 | } | |
| 72 | ||
| 73 | /** Each forge's plan, and the grant. */ | |
| 74 | export const entry: PaletteEntry = { | |
| 75 | id: "billing", | |
| 76 | title: "Billing", | |
| 77 | hint: "each forge's plan, and grant one by hand", | |
| 78 | offered: isAdmin, | |
| 79 | run: (session, ctx) => ctx.push(billingScreen(session, ctx)), | |
| 80 | }; |
src/tui/screens/entries.ts +2
| @@ -17,6 +17,7 @@ import { entry as appTemplates } from "./app_templates.ts"; | ||
| 17 | 17 | import { entry as dotfiles } from "./dotfiles.ts"; |
| 18 | 18 | import { entry as devices } from "./devices.ts"; |
| 19 | 19 | import { entry as bets } from "./bets.ts"; |
| 20 | import { entry as billing } from "./billing.ts"; | |
| 20 | 21 | import { entry as boards } from "./boards.ts"; |
| 21 | 22 | import { entry as drift } from "./drift.ts"; |
| 22 | 23 | import { entry as captain } from "./captain.ts"; |
| @@ -131,6 +132,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 131 | 132 | limits, |
| 132 | 133 | economics, |
| 133 | 134 | money, |
| 135 | billing, | |
| 134 | 136 | appTemplates, |
| 135 | 137 | devices, |
| 136 | 138 | flowsAll, |
src/web/pages/billing.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * Settings → Billing, for a forge admin who is a person (#11134): each | |
| 3 | * forge's plan with the hand grant that changes it, each grant kept in the | |
| 4 | * audit trail. Server-rendered; the forms are same-origin POSTs. | |
| 5 | * Anyone else gets the not-found page. | |
| 6 | */ | |
| 7 | ||
| 8 | import { BILLING_WORDS, grantPlan, readBilling, type BillingError } from "../../actions/billing.ts"; | |
| 9 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 10 | import type { Ctx } from "../../http/context.ts"; | |
| 11 | import { SIZES } from "../../spawn/sizes.ts"; | |
| 12 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | |
| 13 | import { field, readForm } from "../form.ts"; | |
| 14 | import { html } from "../html.ts"; | |
| 15 | import { flashTo, page } from "../layout.ts"; | |
| 16 | import { btn } from "../ui.ts"; | |
| 17 | import { accountSections } from "./origins.ts"; | |
| 18 | ||
| 19 | const PATH = "/settings/billing"; | |
| 20 | const words = (e: BillingError) => (e === "not_found" ? "Not found" : BILLING_WORDS[e]); | |
| 21 | ||
| 22 | // GET /settings/billing | |
| 23 | export function billingPage(ctx: Ctx): Response { | |
| 24 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 25 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 26 | const read = readBilling(ctx.app, ctx.auth.user); | |
| 27 | if (!read.ok) return notFoundPage(ctx); | |
| 28 | const b = read.value; | |
| 29 | const rows = b.forges.map( | |
| 30 | (f) => html`<li class="row" data-forge="${f.forge}"> | |
| 31 | <form class="row-fields" method="post" action="${PATH}/grant"> | |
| 32 | <input type="hidden" name="forge" value="${f.forge}" /> | |
| 33 | <span class="mono">${f.forge}</span> | |
| 34 | <span class="muted small">${f.status} · ${f.source}</span> | |
| 35 | <select class="input" name="plan" aria-label="Plan for ${f.forge}"> | |
| 36 | ${Object.keys(SIZES).map((p) => html`<option value="${p}" ${p === f.plan ? html`selected` : null}>${p}</option>`)} | |
| 37 | </select> | |
| 38 | ${btn("Grant")} | |
| 39 | </form> | |
| 40 | </li>`, | |
| 41 | ); | |
| 42 | const rendering = performance.now(); | |
| 43 | const body = html`<section class="settings-block"> | |
| 44 | <h2>Plans</h2> | |
| 45 | <p class="muted small">Choose the plan each forge is on.</p> | |
| 46 | ${rows.length === 0 | |
| 47 | ? html`<p class="muted">No forge has been sent from here.</p>` | |
| 48 | : html`<ul class="list setting-list">${rows}</ul>`} | |
| 49 | </section>`; | |
| 50 | const response = page({ | |
| 51 | ctx, | |
| 52 | title: "Billing", | |
| 53 | body: html`<div class="page-head"><h1>Billing</h1></div> | |
| 54 | ${settingsLayout(accountSections("billing", { admin: true }), body, "Account settings")}`, | |
| 55 | }); | |
outdated
Principle 1 and 8: 'Stripe is named by the seam and not written yet' explains itself twice and offers an option the form then refuses. Offer only the providers that work, and drop this line. |
||
| 56 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 57 | return response; | |
| 58 | } | |
| 59 | ||
| 60 | // POST /settings/billing/grant | |
| 61 | export async function billingGrant(ctx: Ctx): Promise<Response> { | |
| 62 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx); | |
| 63 | const form = await readForm(ctx, "admin"); | |
| 64 | if (form instanceof Response) return form; | |
| 65 | const set = grantPlan(ctx.app, ctx.auth.user, field(form, "forge"), field(form, "plan")); | |
| 66 | if (!set.ok && set.error === "not_found") return notFoundPage(ctx); | |
| 67 | return flashTo(ctx, PATH, set.ok ? { message: `${set.value.entitlement.forge} is on ${set.value.entitlement.plan}.` } : { tone: "danger", message: `${words(set.error)}.` }); | |
| 68 | } |
src/web/pages/origins.ts +2
| @@ -87,6 +87,7 @@ import { | ||
| 87 | 87 | } from "../ui.ts"; |
| 88 | 88 | |
| 89 | 89 | export type AccountSection = |
| 90 | | "billing" | |
| 90 | 91 | | "adminflows" |
| 91 | 92 | | "security" |
| 92 | 93 | | "profile" |
| @@ -165,6 +166,7 @@ const SECTION_ROWS: readonly (readonly [ | ||
| 165 | 166 | ["This forge", "People", "/settings/people", "users", (c) => c === "people", true], |
| 166 | 167 | ["This forge", "Limits", "/admin/limits", "activity", (c) => c === "limits", true], |
| 167 | 168 | ["This forge", "Economics", "/settings/economics", "activity", (c) => c === "economics", true], |
| 169 | ["This forge", "Billing", "/settings/billing", "activity", (c) => c === "billing", true], | |
| 168 | 170 | ["This forge", "Flows", "/admin/flows", "activity", (c) => c === "adminflows", true], |
| 169 | 171 | ["This forge", "Flags", "/settings/flags", "activity", (c) => c === "flags", true], |
| 170 | 172 | ["This forge", "Templates", "/settings/app-templates", "rocket", (c) => c === "templates", true], |
test/billing-tui.test.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** Billing in the terminal (#11134): the admin sees each forge's plan and grants one; no one else is offered it. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { entitlementOf } from "../src/billing/store.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { ok } from "../src/kernel/result.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 13 | import { bootHostedChild } from "../src/spawn/host.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { billingScreen, entry } from "../src/tui/screens/billing.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dir: string; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let alice: User; | |
| 26 | let bob: User; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | dir = await mkdtemp(join(tmpdir(), "forge-billing-tui-")); | |
| 30 | app = createApp({ dataDir: dir, port: 0, forgeRepo: "alice/forge-code" }); | |
| 31 | servers = await serve(app); | |
| 32 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 33 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 34 | if (!a.ok || !b.ok) throw new Error("user"); | |
| 35 | alice = a.value; | |
| 36 | bob = b.value; | |
| 37 | const code = await createRepo(app, alice, { name: "forge-code", visibility: "public" }); | |
| 38 | if (!code.ok) throw new Error("repo"); | |
| 39 | await commitToBranch(app, alice, code.value, { message: "v1", files: [{ path: "src/main.ts", content: "1" }] }); | |
| 40 | useProbe(app, () => async () => true); | |
| 41 | usePlacement(app, { place: async (p) => ok({ url: `https://${p.name}.fake.test`, bodyId: `fake-${p.name}` }), retire: async () => ok() }); | |
| 42 | const res = await fetch(`${servers.url}/api/v1/forges`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "kid", dryRun: false }) }); | |
| 43 | expect(res.status).toBe(201); | |
| 44 | }, 30_000); | |
| 45 | afterAll(async () => { | |
| 46 | await bootHostedChild(app, "kid"); | |
| 47 | servers.stop(); | |
| 48 | app.close(); | |
| 49 | await rm(dir, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | const key = (k: string) => ({ type: "key" as const, key: k, ctrl: false, alt: false, shift: false }); | |
| 53 | function open(user: User) { | |
| 54 | const { app: tui, session } = createForgeApp( | |
| 55 | app, | |
| 56 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 57 | { terminal: headlessTerminal(120, 40), exitHooks: false }, | |
| 58 | ); | |
| 59 | tui.start(); | |
| 60 | return { tui, session }; | |
| 61 | } | |
| 62 | ||
| 63 | test("the entry is offered to an admin only", () => { | |
| 64 | expect(entry.offered(open(alice).session)).toBe(true); | |
| 65 | expect(entry.offered(open(bob).session)).toBe(false); | |
| 66 | }); | |
| 67 | ||
| 68 | test("the admin sees the forge's plan and grants another after a yes", async () => { | |
| 69 | const { tui, session } = open(alice); | |
| 70 | const screen = billingScreen(session, tui.ctx); | |
| 71 | tui.ctx.push(screen); | |
| 72 | await settleApp(tui); | |
| 73 | expect(tui.screen.toText()).toContain("kid"); | |
| 74 | expect(tui.screen.toText()).toContain("small"); | |
| 75 | tui.dispatch(key("l")); | |
| 76 | await settleApp(tui); | |
| 77 | expect(tui.screen.toText()).toContain("Put kid on large?"); | |
| 78 | expect(entitlementOf(app.db, "kid", "small").plan).toBe("small"); | |
| 79 | tui.dispatch(key("y")); | |
| 80 | await settleApp(tui); | |
| 81 | expect(entitlementOf(app.db, "kid", "small").plan).toBe("large"); | |
| 82 | expect(tui.screen.toText()).toContain("kid is on large."); | |
| 83 | tui.stop?.(); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a no changes nothing", async () => { | |
| 87 | const { tui, session } = open(alice); | |
| 88 | const screen = billingScreen(session, tui.ctx); | |
| 89 | tui.ctx.push(screen); | |
| 90 | await settleApp(tui); | |
| 91 | tui.dispatch(key("s")); | |
| 92 | await settleApp(tui); | |
| 93 | tui.dispatch(key("n")); | |
| 94 | await settleApp(tui); | |
| 95 | expect(entitlementOf(app.db, "kid", "small").plan).toBe("large"); | |
| 96 | tui.stop?.(); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a non-admin who reaches the screen sees no forge and cannot grant", async () => { | |
| 100 | const { tui, session } = open(bob); | |
| 101 | const screen = billingScreen(session, tui.ctx); | |
| 102 | tui.ctx.push(screen); | |
| 103 | await settleApp(tui); | |
| 104 | expect(tui.screen.toText()).not.toContain("kid"); | |
| 105 | tui.dispatch(key("s")); | |
| 106 | await settleApp(tui); | |
| 107 | expect(entitlementOf(app.db, "kid", "small").plan).toBe("large"); | |
| 108 | tui.stop?.(); | |
| 109 | }); |
test/billing.test.ts added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | /** | |
| 2 | * What each forge is entitled to (#11134): the entitlement store, an admin's hand | |
| 3 | * grant through the API, the page and the MCP, its audit trail, and the spawn | |
| 4 | * path a visitor takes, unchanged. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { changesOf, entitlementOf, getStored, putEntitlement } from "../src/billing/store.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { ok } from "../src/kernel/result.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { bootHostedChild } from "../src/spawn/host.ts"; | |
| 18 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | describe("billing seam", () => { | |
| 24 | let dataDir: string; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let admin = ""; | |
| 28 | let adminRo = ""; | |
| 29 | let bob = ""; | |
| 30 | let n = 0; | |
| 31 | ||
| 32 | const send = (method: string, path: string, token: string | undefined, payload?: unknown, origin = true) => | |
| 33 | fetch(`${servers.url}${path}`, { | |
| 34 | method, | |
| 35 | headers: { | |
| 36 | "content-type": "application/json", | |
| 37 | ...(token ? { authorization: `Bearer ${token}` } : {}), | |
| 38 | ...(origin ? {} : { origin: "https://evil.example" }), | |
| 39 | }, | |
| 40 | ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), | |
| 41 | }); | |
| 42 | const mcp = async (token: string, args: unknown) => { | |
| 43 | const res = await fetch(`${servers.url}/mcp`, { | |
| 44 | method: "POST", | |
| 45 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 46 | body: JSON.stringify({ jsonrpc: "2.0", id: ++n, method: "tools/call", params: { name: "forges", arguments: args } }), | |
| 47 | }); | |
| 48 | const text = await res.text(); | |
| 49 | const line = text.startsWith("event") ? text.split("\n").find((l) => l.startsWith("data:"))!.slice(5) : text; | |
| 50 | return JSON.parse(line).result; | |
| 51 | }; | |
| 52 | ||
| 53 | beforeAll(async () => { | |
| 54 | dataDir = await mkdtemp(join(tmpdir(), "forge-billing-")); | |
| 55 | app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge-code" }); | |
| 56 | servers = await serve(app); | |
| 57 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 58 | if (!a.ok) throw new Error("user"); | |
| 59 | admin = createToken(app.db, a.value.id, "admin", "admin").token; | |
| 60 | adminRo = createToken(app.db, a.value.id, "ro", "ro").token; | |
| 61 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 62 | if (!b.ok) throw new Error("user"); | |
| 63 | bob = createToken(app.db, b.value.id, "admin", "admin").token; | |
| 64 | const code = await createRepo(app, a.value, { name: "forge-code", visibility: "public" }); | |
| 65 | if (!code.ok) throw new Error("repo"); | |
| 66 | await commitToBranch(app, a.value, code.value, { message: "v1", files: [{ path: "src/main.ts", content: "1" }] }); | |
| 67 | useProbe(app, () => async () => true); | |
| 68 | usePlacement(app, { place: async (p) => ok({ url: `https://${p.name}.fake.test`, bodyId: `fake-${p.name}` }), retire: async () => ok() }); | |
| 69 | }, 30_000); | |
| 70 | ||
| 71 | afterAll(async () => { | |
| 72 | await bootHostedChild(app, "kid"); | |
| 73 | servers.stop(); | |
| 74 | app.close(); | |
| 75 | await rm(dataDir, { recursive: true, force: true }); | |
| 76 | }); | |
| 77 | ||
| 78 | test("the store keeps one row per forge and every change", () => { | |
| 79 | expect(entitlementOf(app.db, "x", "medium")).toEqual({ forge: "x", plan: "medium", status: "active", source: "granted" }); | |
| 80 | expect(getStored(app.db, "x")).toBeUndefined(); | |
| 81 | putEntitlement(app.db, { forge: "x", plan: "large", status: "grace", periodEnd: 5, source: "granted" }, "medium", "u1"); | |
| 82 | putEntitlement(app.db, { forge: "x", plan: "small", status: "active", source: "granted", payer: "[email protected]" }, "medium", "u1"); | |
| 83 | expect(app.db.query("SELECT COUNT(*) AS n FROM entitlement WHERE forge = 'x'").get()).toEqual({ n: 1 }); | |
| 84 | expect(getStored(app.db, "x")).toMatchObject({ plan: "small", payer: "[email protected]", provider: "none" }); | |
| 85 | const c = changesOf(app.db, "x"); | |
| 86 | expect(c.map((r) => [r.fromPlan, r.toPlan, r.toStatus])).toEqual([["large", "small", "active"], ["medium", "large", "grace"]]); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a status outside active, grace and none is refused by the table", () => { | |
| 90 | expect(() => app.db.query("INSERT INTO entitlement (forge, plan, status, source, updated_at) VALUES ('bad','small','paid','x','')").run()).toThrow(); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a visitor's spawn is unchanged: no row is written and the forge reads active on its size", async () => { | |
| 94 | const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM entitlement").get()!.n; | |
| 95 | const res = await send("POST", "/api/v1/forges", undefined, { name: "kid", dryRun: false }); | |
| 96 | expect(res.status).toBe(201); | |
| 97 | expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM entitlement").get()!.n).toBe(before); | |
| 98 | const plan = (await (await send("GET", "/api/v1/forges/kid/plan", admin)).json()) as any; | |
| 99 | expect(plan.entitlement).toEqual({ forge: "kid", plan: "small", status: "active", source: "granted" }); | |
| 100 | }); | |
| 101 | ||
| 102 | test("an admin grant changes the plan and the audit trail shows it", async () => { | |
| 103 | const res = await send("PUT", "/api/v1/forges/kid/plan", admin, { plan: "large" }); | |
| 104 | expect(res.status).toBe(200); | |
| 105 | const body = (await res.json()) as any; | |
| 106 | expect(body.entitlement).toEqual({ forge: "kid", plan: "large", status: "active", source: "granted" }); | |
| 107 | expect(body.changes[0]).toMatchObject({ fromPlan: "small", toPlan: "large", source: "granted" }); | |
| 108 | const ev = app.db.query<{ payload: string }, []>("SELECT payload FROM event WHERE kind = 'billing.plan'").all(); | |
| 109 | expect(ev.map((e) => JSON.parse(e.payload))).toEqual([{ forge: "kid", from: "small", to: "large", source: "granted" }]); | |
| 110 | }); | |
| 111 | ||
| 112 | test("the grant is validated and guarded", async () => { | |
| 113 | expect((await send("PUT", "/api/v1/forges/kid/plan", admin, { plan: "huge" })).status).toBe(422); | |
| 114 | expect((await send("PUT", "/api/v1/forges/kid/plan", admin, {})).status).toBe(422); | |
| 115 | expect((await send("PUT", "/api/v1/forges/nope/plan", admin, { plan: "small" })).status).toBe(404); | |
| 116 | expect((await send("PUT", "/api/v1/forges/kid/plan", bob, { plan: "small" })).status).toBe(404); | |
| 117 | expect((await send("PUT", "/api/v1/forges/kid/plan", undefined, { plan: "small" })).status).toBeGreaterThanOrEqual(401); | |
| 118 | expect((await send("PUT", "/api/v1/forges/kid/plan", adminRo, { plan: "small" })).status).toBeGreaterThanOrEqual(403); | |
| 119 | expect((await send("PUT", "/api/v1/forges/kid/plan", admin, { plan: "small" }, false)).status).toBe(403); | |
| 120 | expect(entitlementOf(app.db, "kid", "small").plan).toBe("large"); | |
| 121 | }); | |
| 122 | ||
| 123 | test("MCP forges plans first, then grants with dryRun false", async () => { | |
| 124 | const plan = await mcp(admin, { forge: "kid", plan: "medium" }); | |
| 125 | expect(JSON.stringify(plan)).toContain("nothing changed"); | |
| 126 | expect(entitlementOf(app.db, "kid", "small").plan).toBe("large"); | |
| 127 | await mcp(admin, { forge: "kid", plan: "medium", dryRun: false }); | |
| 128 | expect(entitlementOf(app.db, "kid", "small").plan).toBe("medium"); | |
| 129 | const read = await mcp(admin, { forge: "kid" }); | |
| 130 | expect(JSON.stringify(read)).toContain("medium"); | |
| 131 | expect(JSON.stringify(await mcp(bob, { forge: "kid", plan: "small", dryRun: false }))).not.toContain("granted"); | |
| 132 | expect(entitlementOf(app.db, "kid", "small").plan).toBe("medium"); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the Billing page lists forges and grants; only an admin sees it", async () => { | |
| 136 | const page = await send("GET", "/settings/billing", admin); | |
| 137 | expect(page.status).toBe(200); | |
| 138 | const text = await page.text(); | |
| 139 | expect(text).toContain("Choose the plan each forge is on."); | |
| 140 | expect(text).not.toContain("Stripe"); | |
| 141 | expect(text).toContain('data-forge="kid"'); | |
| 142 | expect((await send("GET", "/settings/billing", bob)).status).toBe(404); | |
| 143 | }); | |
| 144 | }); |
test/palette-roster.test.ts +1
| @@ -161,6 +161,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 161 | 161 | ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"], |
| 162 | 162 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 163 | 163 | ["security-controls", "Security controls", "each safeguard, and when it was last tested"], |
| 164 | ["billing", "Billing", "each forge's plan, and grant one by hand"], | |
| 164 | 165 | ["archived", "Archived repositories", "put away, not gone: open one or restore it"], |
| 165 | 166 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 166 | 167 | ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"], |
Principles 1 and 8: the page does not say what it is for in one sentence. This copy explains the provider and its default, and the line below repeats that Stripe is missing. Use one plain sentence, and drop the word 'seam', which is jargon, here and in the Stripe note.