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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11134-billing-is-a-seam-with-two-providers-non → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/11134-billing-is-a-seam-with-two-providers-non was merged into main as 7e309d2.

Review

Sign in to review this change.
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`;
112112 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
113113 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
114114 | `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 |
116116 | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 |
117117 | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 397 |
118118 | `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
114114 constant.
115115 - Billing produces a statement, with no payment processor. A person can
116116 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 @@
109109 },
110110 "settings": {
111111 "390": {
112 "words": 140,
112 "words": 141,
113113 "blocks": 32,
114114 "colours": 1
115115 },
116116 "1280": {
117 "words": 140,
117 "words": 141,
118118 "blocks": 32,
119119 "colours": 1
120120 }
docs/design/audit.md +1
@@ -105,6 +105,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
105105 - `/admin/limits` — `pages/admin-limits.ts` · every limit with value, default and reason · `/settings/capacity` (also a numbers-you-can-change page) · none seen.
106106 - `/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.
107107 - `/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.
108109 - `/settings/economics` — `pages/economics.ts` · the profit slider's position and the margin, with the policy in words · `/admin/limits`, `/settings/capacity` · none seen.
109110 - `/settings/capacity` — `pages/capacity.ts` · how many runs and checks go at once · `/admin/limits`, `/settings/usage` · none seen.
110111 - `/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";
9393 export { route as getApiV1ForgeEconomics } from "./getApiV1ForgeEconomics.ts";
9494 export { route as getApiV1ForgeView } from "./getApiV1ForgeView.ts";
9595 export { route as getApiV1Forges } from "./getApiV1Forges.ts";
96 export { route as getApiV1ForgesNamePlan } from "./getApiV1ForgesNamePlan.ts";
9697 export { route as getApiV1ForgesSizes } from "./getApiV1ForgesSizes.ts";
9798 export { route as getApiV1ForgesTemplates } from "./getApiV1ForgesTemplates.ts";
9899 export { route as getApiV1Front } from "./getApiV1Front.ts";
@@ -381,6 +382,7 @@ export { route as putApiV1BoardsSlug } from "./putApiV1BoardsSlug.ts";
381382 export { route as putApiV1FeaturesSlug } from "./putApiV1FeaturesSlug.ts";
382383 export { route as putApiV1ForgeEconomics } from "./putApiV1ForgeEconomics.ts";
383384 export { route as putApiV1ForgesNameCredit } from "./putApiV1ForgesNameCredit.ts";
385 export { route as putApiV1ForgesNamePlan } from "./putApiV1ForgesNamePlan.ts";
384386 export { route as putApiV1Front } from "./putApiV1Front.ts";
385387 export { route as putApiV1PacksName } from "./putApiV1PacksName.ts";
386388 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 {
236236 kind.startsWith("pinned.") ||
237237 // Which of a person's connected accounts a mirror syncs through is too.
238238 kind.startsWith("origin.") ||
239 // A forge's plan and the billing provider are the admins' books (#11134).
240 kind.startsWith("billing.") ||
239241 // A declared LAN device names the owner's own network (#10567).
240242 kind.startsWith("device.") ||
241243 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>> = {
109109 app_deploy: { does: "redeploy or stop an app's production or preview" },
110110 stop: { does: "stop a crew run" },
111111 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 },
112113 origins: { does: "sync an origin or replace the internal hosts one may live on", asks: not("action", "list", "repos") },
113114 cloud_account: { does: "change a cloud account" },
114115 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";
350350 import { forgeRoutes } from "./api/forges.ts";
351351 import { packRoutes } from "./api/packs.ts";
352352 import { forgeViewRoutes } from "./api/forge_view.ts";
353 import { billingRoutes } from "./api/billing.ts";
353354 import { economicsRoutes } from "./api/economics.ts";
354355 import { releasesRoutes } from "./api/releases.ts";
355356 import { hookRoutes } from "./api/hooks.ts";
@@ -3585,6 +3586,7 @@ export function apiRoutes(router: Router<Handler>): void {
35853586 packRoutes(router);
35863587 forgeViewRoutes(router);
35873588 economicsRoutes(router);
3589 billingRoutes(router);
35883590 releasesRoutes(router);
35893591
35903592 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";
13 import { clientRows } from "../../../spawn/clients.ts";
24 import { good, bad, type Tool } from "../shared.ts";
35
46 export const tool: Tool = {
57 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",
79 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) {
1216 if (!user.isAdmin)
1317 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 }
1432 const forges = await clientRows(ctx.app);
1533 return good({
1634 says:
src/http/settings_routes.ts +3
@@ -17,6 +17,7 @@ import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts
1717 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
1818 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
1919 import { appTemplatesPage, appTemplatesSet } from "../web/pages/app_templates.ts";
20 import { billingPage, billingGrant } from "../web/pages/billing.ts";
2021 import { economicsPage, economicsSet } from "../web/pages/economics.ts";
2122 import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts";
2223 import { usagePage } from "../web/pages/usage.ts";
@@ -205,6 +206,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
205206 router.add("POST", "/settings/capacity", capacitySet);
206207 router.add("GET", "/settings/economics", economicsPage);
207208 router.add("POST", "/settings/economics", economicsSet);
209 router.add("GET", "/settings/billing", billingPage);
210 router.add("POST", "/settings/billing/grant", billingGrant);
208211 router.add("GET", "/settings/flags", flagsPage);
209212 // Apps absorbed as the forge's own features, and their settings.
210213 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";
1717 import { entry as dotfiles } from "./dotfiles.ts";
1818 import { entry as devices } from "./devices.ts";
1919 import { entry as bets } from "./bets.ts";
20 import { entry as billing } from "./billing.ts";
2021 import { entry as boards } from "./boards.ts";
2122 import { entry as drift } from "./drift.ts";
2223 import { entry as captain } from "./captain.ts";
@@ -131,6 +132,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
131132 limits,
132133 economics,
133134 money,
135 billing,
134136 appTemplates,
135137 devices,
136138 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>`
outdated

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.

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 {
8787 } from "../ui.ts";
8888
8989 export type AccountSection =
90 | "billing"
9091 | "adminflows"
9192 | "security"
9293 | "profile"
@@ -165,6 +166,7 @@ const SECTION_ROWS: readonly (readonly [
165166 ["This forge", "People", "/settings/people", "users", (c) => c === "people", true],
166167 ["This forge", "Limits", "/admin/limits", "activity", (c) => c === "limits", true],
167168 ["This forge", "Economics", "/settings/economics", "activity", (c) => c === "economics", true],
169 ["This forge", "Billing", "/settings/billing", "activity", (c) => c === "billing", true],
168170 ["This forge", "Flows", "/admin/flows", "activity", (c) => c === "adminflows", true],
169171 ["This forge", "Flags", "/settings/flags", "activity", (c) => c === "flags", true],
170172 ["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", () => {
161161 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
162162 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
163163 ["security-controls", "Security controls", "each safeguard, and when it was last tested"],
164 ["billing", "Billing", "each forge's plan, and grant one by hand"],
164165 ["archived", "Archived repositories", "put away, not gone: open one or restore it"],
165166 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
166167 ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs