Public: anyone can read this; the crew can change it.

Plans are a setting: free, small, medium and large, each with a size, a monthly price, a monthly crew credit and a forge count #11152

Merged opened this in answer to

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

Merged

mason/forge:mason/11135-plans-are-a-setting-free-small-medium-an → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/11135-plans-are-a-setting-free-small-medium-an was merged into main as 109e9c3.

Review

Sign in to review this change.
27 files changed +938 −13
docs/MCP.md +2 −2
@@ -133,7 +133,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
133133 | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 |
134134 | `lift` | — | Lift an issue out from behind the Later gate | 214 |
135135 | `limb` | `defer`, `resume`, `close`, `raise`, `lower`, `replan`, `takeback`, `undo` | Act on a limb by `action` | 532 |
136 | `limits` | — | Every limit the owner can hit, value and bounds | 898 |
136 | `limits` | — | Every limit the owner can hit, value and bounds | 949 |
137137 | `maintenance` | `status`, `prepare`, `resume` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 256 |
138138 | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 |
139139 | `map` | — | An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first | 375 |
@@ -197,7 +197,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
197197 | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 291 |
198198 | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 |
199199 | `snapshot_now` | — | Take a snapshot now, as Backups does (admin) | 133 |
200 | `spawn` | — | Spawn a child forge | 824 |
200 | `spawn` | — | Spawn a child forge | 910 |
201201 | `spend` | — | Report what your session spent outside forge runs: who, model, tokens or dollars, issue | 470 |
202202 | `ssh_keys` | `list`, `add`, `remove` | Your SSH keys; add: key, remove: id | 250 |
203203 | `steer` | — | Say a word to one crew run by its id | 224 |
docs/features/plans.md added +25
@@ -0,0 +1,25 @@
1 # Plans
2
3 Free, small, medium and large are a setting, never a constant (`src/settings/plans.ts`). Each plan has a key, a name, one line of words, the forge size (`src/spawn/sizes.ts`), a price in USD a month (0 is free), a crew credit in USD a month and how many forges one person may hold on it.
4
5 | plan | size | price / month | crew credit / month |
6 |---|---|---|---|
7 | free | small | $0 | $0 (bring your own model key) |
8 | small | small | $19 | $5 |
9 | medium | medium | $49 | $15 |
10 | large | large | $149 | $50 |
11
12 These are the defaults. A forge admin who is a person edits them:
13
14 - on `/admin/limits`, the Plans form;
15 - `PUT /api/v1/admin/plans` with `{ "plans": [...] }` (admin scope);
16 - the MCP `limits` tool with `plans` (dry run first, validated the same as the real change);
17 - the terminal: the Plans screen, `e` on a plan, `<field> <value>`, then a confirm.
18
19 `GET /api/v1/plans` is public and answers the plans, no secrets. It is revalidated by ETag, so an edit shows at once.
20
21 ## What a spawn gets
22
23 `POST /api/v1/forges` (and the MCP `spawn`) takes `plan`. The forge's size comes from the plan (a `size` that disagrees is refused), and its credit cap is the plan's `creditUsd`, lent as a monthly cap through `src/spawn/credit.ts`: the spend is counted again each UTC calendar month. Plans are read at spawn time, so an edit governs the next spawn; forges already made keep the credit they were given.
24
25 The free plan keeps today's visitor and per-person limits (`src/spawn/rate.ts`, `forges.per_person`, `forges.storage_mb`); its `forges` figure is shown for the page and the API, and `forges.per_person` is what is enforced. A paid plan is held only to its own `forges` count, counted among the person's forges on that plan. A stranger and any person who is not a forge admin may take the free plan only: a paid plan lends crew credit from the forge's own model credential and no payment is taken yet, so the door, the API and the MCP all refuse it with one sentence until billing exists. A forge admin is never limited.
src/actions/plans.ts added +32
@@ -0,0 +1,32 @@
1 /**
2 * The forge's plans (`settings/plans.ts`): read by anyone as the public list,
3 * changed only by a forge admin who is a person (a crew run that could set a
4 * price or a credit would decide its own money). Validation lives in
5 * `settings/plans.ts`, once, for the page, the API and the MCP alike.
6 */
7
8 import type { App } from "../app.ts";
9 import type { User } from "../core/users.ts";
10 import { err, ok, type Result } from "../kernel/result.ts";
11 import { getPlans, parsePlans, publicPlans, setPlans, type Plan } from "../settings/plans.ts";
12
13 export type PlansError = "not_found" | { readonly invalid: string };
14
15 export function readPlans(app: App): Plan[] {
16 return publicPlans(getPlans(app.db));
17 }
18
19 /** Replace the plans; `not_found` for anyone but a person admin, `invalid` with the sentence for a bad list. */
20 export function changePlans(app: App, actor: User | undefined, input: unknown): Result<Plan[], PlansError> {
21 if (actor?.isAdmin !== true || actor.kind === "agent") return err("not_found");
22 const parsed = parsePlans(input);
23 if (!parsed.ok) return err({ invalid: parsed.error });
24 setPlans(app.db, parsed.value, actor.id);
25 return ok(readPlans(app));
26 }
27
28 /** Some fields of some plans edited in place, the rest kept: what the form sends. */
29 export function editPlans(app: App, actor: User | undefined, edits: Record<string, Record<string, unknown>>): Result<Plan[], PlansError> {
30 const next = getPlans(app.db).map((p) => ({ ...p, ...(edits[p.key] ?? {}) }));
31 return changePlans(app, actor, next);
32 }
src/actions/spawn.ts +37 −2
@@ -58,7 +58,10 @@ import { loadOrCreateHostKey } from "../ssh/keys.ts";
5858 import { claimLink, hasAdmin, mintClaim, spawnedOrg } from "../spawn/claim.ts";
5959 import { childPublicUrl, parentOrigin, type Placement, type PlacementKind } from "../spawn/placement.ts";
6060 import { quotaRefusal } from "../spawn/quota.ts";
61 import { personRefusal } from "../spawn/sleep.ts";
61 import { personRefusal, planRefusal } from "../spawn/sleep.ts";
62 import { FREE, getPlan, PAID_PLAN_ADMIN_ONLY } from "../settings/plans.ts";
63 import { setCap } from "../spawn/credit.ts";
64 import { fundingOf, keepFunding } from "../spawn/funding.ts";
6265 import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts";
6366 import { DEFAULT_SIZE, isSize, type Size } from "../spawn/sizes.ts";
6467 import { DEFAULT_MODE } from "../spawn/chain.ts";
@@ -123,6 +126,8 @@ interface Ledger {
123126 readonly size?: Size;
124127 /** The template chosen (`spawn/templates.ts`); absent means `blank`. */
125128 readonly template?: string;
129 /** The plan chosen (`settings/plans.ts`); absent means the free plan. */
130 readonly plan?: string;
126131 /** Absent on a ledger written before cells; reads as hosted. */
127132 readonly placement?: PlacementKind;
128133 /** Why a child that wanted a cell is hosted instead (`spawn/host.ts`). */
@@ -274,7 +279,16 @@ async function make(
274279 // A retry of an unfinished child is a new attempt: its failure has a start.
275280 app.bus.append({ kind: "forge.spawn.started", payload: { ...(deps.drill === true ? { drill: 1 } : {}) } });
276281 } else {
277 if (owner !== undefined) {
282 // A paid plan is held to its own count, never the free one; the free plan keeps today's per-person limits.
283 const chosen = plan.plan === undefined ? undefined : getPlan(app.db, plan.plan);
284 if (plan.plan !== undefined && chosen === undefined)
285 return err({ kind: "invalid", field: "plan", reason: `${plan.plan} is not a plan this forge offers` });
286 if (chosen !== undefined && chosen.key !== FREE) {

High: a paid plan is open to every signed-in person and grants credit in grantPlanCredit with no payment. Restrict to admins until payment exists, and test it.

287 if (owner === undefined) return err({ kind: "refused", reason: "plan", message: `the ${chosen.name} plan is for a signed-in person; sign in first` });
288 if (!owner.isAdmin) return err({ kind: "refused", reason: "plan", message: PAID_PLAN_ADMIN_ONLY(chosen.name) });
289 const many = planRefusal(app.config.dataDir, owner, chosen, FREE);
290 if (many !== undefined) return err({ kind: "refused", reason: "quota", message: many });
291 } else if (owner !== undefined) {
278292 const many = personRefusal(app.db, app.config.dataDir, owner);
279293 if (many !== undefined) return err({ kind: "refused", reason: "quota", message: many });
280294 }
@@ -298,6 +312,7 @@ async function make(
298312 ...(owner === undefined ? {} : { ownerId: owner.id }),
299313 size: plan.size ?? DEFAULT_SIZE,
300314 ...(plan.template === undefined ? {} : { template: plan.template }),
315 ...(plan.plan === undefined ? {} : { plan: plan.plan }),
301316 placement: placed.value.kind ?? "hosted",
302317 statusToken: secret(),
303318 beatKey: secret(),
@@ -310,6 +325,7 @@ async function make(
310325 if (!copied.ok) return failed(app, deps, "copy", copied.error);
311326 const claimUrl = claimLink(liveChildUrl(app, plan.name), copied.value);
312327
328 grantPlanCredit(app, plan, ledger.bodyId);
313329 ledger.done = true;
314330 await writeLedger(app, ledger);
315331 app.bus.append({
@@ -328,6 +344,25 @@ async function make(
328344 });
329345 }
330346
347 /**
348 * The crew credit the child's plan carries, read now (so an edit on the plans
349 * page is what the next spawn gets): lent as a monthly cap, the spend counted
350 * again each calendar month (`spawn/credit.ts`). A plan with no credit lends
351 * none. A hand-off that cannot reach the child leaves it unfunded, and an
352 * admin can lend by hand (`PUT /api/v1/forges/:name/credit`).
353 */
354 export function grantPlanCredit(app: App, plan: SpawnPlan, bodyId: string): void {
355 const chosen = plan.plan === undefined ? undefined : getPlan(app.db, plan.plan);
356 if (chosen === undefined || !(chosen.creditUsd > 0)) return;
357 const dir = childDirOf(app, bodyId);
358 const set = setCap(app, plan.name, chosen.creditUsd, fundingOf(dir) !== undefined, (token, credit) =>
359 keepFunding(dir, { token, capUsd: credit.capUsd, spentUsd: credit.spentUsd }),
360 true,
361 );
362 if (set === undefined || set === "unreachable") app.log.warn(`plan credit not lent to ${plan.name}`);
363 else app.bus.append({ kind: "forge.credit_set", payload: { name: plan.name, usd: set.capUsd, plan: chosen.key } });
364 }
365
331366 /**
332367 * Every step skips what is already there, so a resumed child is finished,
333368 * not doubled. Answers the child's claim token, minted afresh each time.
src/contract/openapi/routes/getApiV1Plans.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/plans", summary: "The plans (free, small, medium, large by default): key, name, says, size, priceUsd a month, creditUsd a month, forges one person may hold; public, revalidated by ETag, no secrets", auth: false, role: "anonymous" };
src/contract/openapi/routes/index.ts +2
@@ -109,6 +109,7 @@ export { route as getApiV1OrgsOrgTeamsTeamMembers } from "./getApiV1OrgsOrgTeams
109109 export { route as getApiV1Packs } from "./getApiV1Packs.ts";
110110 export { route as getApiV1PacksName } from "./getApiV1PacksName.ts";
111111 export { route as getApiV1PassportHealth } from "./getApiV1PassportHealth.ts";
112 export { route as getApiV1Plans } from "./getApiV1Plans.ts";
112113 export { route as getApiV1Releases } from "./getApiV1Releases.ts";
113114 export { route as getApiV1Repos } from "./getApiV1Repos.ts";
114115 export { route as getApiV1ReposOwnerName } from "./getApiV1ReposOwnerName.ts";
@@ -374,6 +375,7 @@ export { route as postMcp } from "./postMcp.ts";
374375 export { route as putApiV1AdminCapacityKey } from "./putApiV1AdminCapacityKey.ts";
375376 export { route as putApiV1AdminLimitsKey } from "./putApiV1AdminLimitsKey.ts";
376377 export { route as putApiV1AdminMachinesMachineDisksDevice } from "./putApiV1AdminMachinesMachineDisksDevice.ts";
378 export { route as putApiV1AdminPlans } from "./putApiV1AdminPlans.ts";
377379 export { route as putApiV1AdminQuotasKey } from "./putApiV1AdminQuotasKey.ts";
378380 export { route as putApiV1AdminShield } from "./putApiV1AdminShield.ts";
379381 export { route as putApiV1AdminTeamsSlug } from "./putApiV1AdminTeamsSlug.ts";
src/contract/openapi/routes/postApiV1Forges.ts +1
@@ -16,6 +16,7 @@ export const route: Route = {
1616 direction: "string",
1717 keepAccess: "boolean",
1818 size: "string",
19 plan: "string (a key from GET /api/v1/plans; gives the size and a monthly crew credit; a paid plan is a forge admin's until payment exists)",
1920 for: "string",
2021 fresh: "boolean",
2122 followMode: "string",
src/contract/openapi/routes/putApiV1AdminPlans.ts added +14
@@ -0,0 +1,14 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "put",
5 path: "/api/v1/admin/plans",
6 summary: "Replace the plans live (a person admin's token, admin scope); the next spawn and GET /api/v1/plans use them at once; answers { items }",
7 auth: true,
8 role: "admin",
9 body: { plans: "list of { key, name, says, size, priceUsd, creditUsd, forges }; one to eight, `free` among them at $0" },
10 refusals: {
11 not_found: "the token is not a person who administers the forge (404)",
12 invalid_plans: "a plan is not valid, named in the message (422)",
13 },
14 };
src/contract/tags.ts +1
@@ -59,6 +59,7 @@ const ALIAS: Readonly<Record<string, string>> = {
5959 front: "feed",
6060 search: "feed",
6161 family: "forges",
62 plans: "forges",
6263 start: "forges",
6364 cloud: "forges",
6465 migrate: "forges",
src/http/api/admin.ts +12
@@ -17,6 +17,7 @@ import { DERIVED_WORDS, REASON_WORDS, changeCapacity, edgeOffDryRun, listCapacit
1717 import { changeShield, lastShieldChange, shieldState } from "../../actions/shield.ts";
1818 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts";
1919 import { changeLimit, listLimits } from "../../actions/limits.ts";
20 import { changePlans } from "../../actions/plans.ts";
2021 import { setAccountCeilings, setAccountGrant } from "../../actions/model_grant.ts";
2122 import { checkPinned, deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts";
2223 import { TARGETS } from "../../cloud/targets.ts";
@@ -255,6 +256,17 @@ export function adminRoutes(router: Router<Handler>): void {
255256 );
256257 });
257258
259 // The plans (`settings/plans.ts`): the whole list is replaced; GET is the public /api/v1/plans.
260 router.add("PUT", "/api/v1/admin/plans", async (ctx) => {
261 const denied = accountAdmin(ctx, "admin");
262 if (denied) return denied;
263 const input = await body(ctx.request);
264 const changed = changePlans(ctx.app, ctx.auth.user, input["plans"]);
265 if (changed.ok) return json({ items: changed.value });
266 if (changed.error === "not_found") return notFound();
267 return apiError("invalid_plans", 422, changed.error.invalid);
268 });
269
258270 // Per-account quotas (`settings/quotas.ts`), key by key: the REST twin of MCP `quotas`.
259271 router.add("GET", "/api/v1/admin/quotas", (ctx) => {
260272 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
src/http/api/forges.ts +12
@@ -5,6 +5,7 @@
55 */
66
77 import { lendCredit } from "../../actions/child_credit.ts";
8 import { readPlans } from "../../actions/plans.ts";
89 import { listChildren } from "../../actions/spawn.ts";
910 import { usdWords } from "../../spawn/credit.ts";
1011 import { fundingOf, fundingWords, refreshFunding } from "../../spawn/funding.ts";
@@ -30,7 +31,18 @@ function publicTable(ctx: Ctx, phase: string, view: () => unknown): Response {
3031 });
3132 }
3233
34 /** The plans (`settings/plans.ts`), public. Revalidated on every read, so an edit shows at once. */
35 function plansAnswer(ctx: Ctx): Response {
36 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
37 const text = timePhase(ctx.request, "plans", () => JSON.stringify({ items: readPlans(ctx.app) }));
38 const etag = weakEtag(text);
39 const headers = { etag, "cache-control": "public, no-cache" };
40 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
41 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
42 }
43
3344 export function forgeRoutes(router: Router<Handler>): void {
45 router.add("GET", "/api/v1/plans", plansAnswer);
3446 // What small, medium and large mean, and which templates a child may
3547 // start from: public, and cacheable by ETag.
3648 router.add("GET", "/api/v1/forges/sizes", (ctx) => publicTable(ctx, "sizes", sizesView));
src/http/mcp/tools/limits.ts +16 −2
@@ -1,11 +1,13 @@
11 import { changeLimit, listLimits } from "../../../actions/limits.ts";
2 import { changePlans, readPlans } from "../../../actions/plans.ts";
3 import { MAX_PLANS, parsePlans } from "../../../settings/plans.ts";
24 import { LIMIT_KEYS } from "../../../settings/limits.ts";
35 import { scopeAllows } from "../../../core/tokens.ts";
46 import { good, bad, type Tool } from "../shared.ts";
57
68 export const tool: Tool = {
79 name: "limits",
8 doc: "`{ key?, value?, dryRun? }` (forge admin): every limit the forge's owner can hit, with its value, default and bounds; with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); a value out of bounds is refused, applying needs an admin-scope token",
10 doc: "`{ key?, value?, plans?, dryRun? }` (forge admin): every limit the forge's owner can hit, with its value, default and bounds; with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); a value out of bounds is refused, applying needs an admin-scope token; the answer lists the plans; `plans`, a list of `{ key, name, says, size, priceUsd, creditUsd, forges }`, replaces them",
911 description:
1012 "Every limit the owner can hit, value and bounds. `key`, `value` (\"\" restores) plan.",
1113 inputSchema: {
@@ -13,6 +15,7 @@ export const tool: Tool = {
1315 properties: {
1416 key: { type: "string", enum: [...LIMIT_KEYS] },
1517 value: { type: "string", maxLength: 16 },
18 plans: { type: "array", maxItems: MAX_PLANS, items: { type: "object" } },
1619 dryRun: { type: "boolean" },
1720 },
1821 },
@@ -21,8 +24,19 @@ export const tool: Tool = {
2124 run(ctx, user, args) {
2225 const listed = listLimits(ctx.app, user);
2326 if (!listed.ok) return bad("not_found", "no such tool here");
outdated

Dry run returns the raw input without parsePlans; validate it so the dry run reports the same refusal the real change would. Add maxItems to the schema.

27 if (args["plans"] !== undefined) {
28 if (args["dryRun"] !== false) {
29 const parsed = parsePlans(args["plans"]);
30 return parsed.ok
31 ? good({ says: "Would replace the plans. Send dryRun: false to change them.", plans: parsed.value })
32 : bad("invalid_plans", parsed.error);
33 }
34 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "changing the plans needs an admin-scope token");
35 const set = changePlans(ctx.app, user, args["plans"]);
36 return set.ok ? good({ says: "Plans saved; the next spawn uses them.", plans: set.value }) : bad("invalid_plans", set.error === "not_found" ? "no such tool here" : set.error.invalid);
37 }
2438 const key = typeof args["key"] === "string" ? args["key"] : undefined;
25 if (key === undefined) return good(listed.value);
39 if (key === undefined) return good({ ...listed.value, plans: readPlans(ctx.app) });
2640 const value = typeof args["value"] === "string" ? args["value"] : "";
2741 const now = listed.value.limits.find((l) => l.key === key);
2842 if (now === undefined) return bad("no_such_limit", `no limit ${key}`);
src/http/mcp/tools/spawn.ts +5 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool, APP_ARG, REVEAL_ARG, LINK_KEPT, LINK_REVEALED } f
44
55 export const tool: Tool = {
66 name: "spawn",
7 doc: "`{ name, apps?, crew?, direction?, fresh?, followMode?, size?, template?, keepAccess?, dryRun?, reveal? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make; `reveal: true` shows the claim link",
7 doc: "`{ name, apps?, crew?, direction?, fresh?, followMode?, size?, plan?, template?, keepAccess?, dryRun?, reveal? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make; `reveal: true` shows the claim link",
88 description:
99 "Spawn a child forge. Plans first.",
1010 inputSchema: {
@@ -38,6 +38,10 @@ export const tool: Tool = {
3838 enum: ["small", "medium", "large"],
3939 description: "Recorded only.",
4040 },
41 plan: {
42 type: "string",
43 description: "Plan key: size and credit. Paid: admin only.",
44 },
4145 template: {
4246 type: "string",
4347 description: "An id from `templates`.",
src/http/server.ts +2
@@ -33,6 +33,7 @@ import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts";
3333 import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts";
3434 import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts";
3535 import { agentStartSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
36 import { plansSet } from "../web/settings/plans.ts";
3637 import { allFlowsPage } from "../web/pages/flows-all.ts";
3738 import { measureSubmit } from "./measure.ts";
3839 import { cspReportSubmit } from "./csp_report.ts";
@@ -1056,6 +1057,7 @@ export function buildRouter(): Router<Handler> {
10561057 router.add("GET", "/admin/flows", allFlowsPage);
10571058 router.add("POST", "/admin/limits", limitsSet);
10581059 router.add("POST", "/admin/quotas", quotasSet);
1060 router.add("POST", "/admin/plans", plansSet);
10591061 router.add("POST", "/admin/signup", signupSet);
10601062 router.add("POST", "/admin/agent-start", agentStartSet);
10611063 router.add("POST", "/admin/invites", inviteMint);
src/settings/plans.ts added +135
@@ -0,0 +1,135 @@
1 /**
2 * Plans: free, small, medium and large as a setting, never a constant. Each
3 * has a key, a name, one line of words, a forge size (`spawn/sizes.ts`), a
4 * price in USD a month (0 is free), a crew credit in USD a month (the cap
5 * lent to the child's crew, `spawn/credit.ts`) and how many forges one person
6 * may hold on it. Kept as one JSON value in `forge_setting` under `plans`, so
7 * a change governs the next read and needs no restart; each change is kept in
8 * `forge_setting_change`. A stored value that does not parse reads as the
9 * defaults. Pure data; authorization is in `actions/plans.ts`.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import { err, ok, type Result } from "../kernel/result.ts";
14 import { isSize, SIZES, type Size } from "../spawn/sizes.ts";
15 import { writeSetting } from "./limits.ts";
16
17 export interface Plan {
18 readonly key: string;
19 readonly name: string;
20 readonly says: string;
21 readonly size: Size;
22 /** US dollars a month; 0 is free. */
23 readonly priceUsd: number;
24 /** US dollars of the forge's model credential a month, lent to the child's crew; 0 lends none. */
25 readonly creditUsd: number;
26 /** How many forges one person may hold on this plan. */
27 readonly forges: number;
28 }
29
30 /** The plan every forge has: its price is always 0 and its limits are the visitor and per-person limits. */
31 export const FREE = "free";
32 /** Why a paid plan is a forge admin's for now: it lends crew credit from the forge's own model credential, and no payment is taken yet. */
33 export const PAID_PLAN_ADMIN_ONLY = (name: string): string =>
34 `the ${name} plan lends crew credit and no payment is taken yet, so only a forge admin may take it; use the free plan`;
35 export const PLANS_KEY = "plans";
36 export const MAX_PLANS = 8;
37 export const MAX_USD = 100_000;
38 export const MAX_FORGES = 1000;
39
40 export const DEFAULT_PLANS: readonly Plan[] = [
41 { key: "free", name: "Free", says: "A small forge that sleeps when idle. Bring your own model key.", size: "small", priceUsd: 0, creditUsd: 0, forges: 3 },
42 { key: "small", name: "Small", says: "A small forge, awake, with a crew credit each month.", size: "small", priceUsd: 19, creditUsd: 5, forges: 3 },
43 { key: "medium", name: "Medium", says: "A medium forge for a busy app, with more crew credit.", size: "medium", priceUsd: 49, creditUsd: 15, forges: 3 },
44 { key: "large", name: "Large", says: "A large forge for a working crew, with the most credit.", size: "large", priceUsd: 149, creditUsd: 50, forges: 3 },
45 ];
46
47 const KEY = /^[a-z][a-z0-9-]{0,19}$/;
48 const cents = (n: number): number => Math.round(n * 100) / 100;
49
50 function usd(v: unknown): number | undefined {
51 const n = typeof v === "string" && /^\d{1,6}(\.\d{1,2})?$/.test(v.trim()) ? Number(v) : v;
52 if (typeof n !== "number" || !Number.isFinite(n) || n < 0 || n > MAX_USD) return undefined;
53 return Math.abs(n * 100 - Math.round(n * 100)) > 1e-6 ? undefined : cents(n);
54 }
55
56 function count(v: unknown): number | undefined {
57 const n = typeof v === "string" && /^\d{1,4}$/.test(v.trim()) ? Number(v) : v;
58 return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= MAX_FORGES ? n : undefined;
59 }
60
61 function line(v: unknown, max: number): string | undefined {
62 if (typeof v !== "string") return undefined;
63 const s = v.trim();
64 return s !== "" && s.length <= max && !/[\u0000-\u001f\u007f]/.test(s) ? s : undefined;
65 }
66
67 /** One plan from untrusted input, or the sentence that says what is wrong with it. */
68 export function parsePlan(raw: unknown): Result<Plan, string> {
69 if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return err("a plan is an object");
70 const p = raw as Record<string, unknown>;
71 const key = typeof p["key"] === "string" && KEY.test(p["key"]) ? p["key"] : undefined;
72 if (key === undefined) return err('a plan key is 1-20 characters of a-z, 0-9 and "-", starting with a letter');
73 const name = line(p["name"], 40);
74 if (name === undefined) return err(`${key}: name is one line of at most 40 characters`);
75 const says = line(p["says"], 160);
76 if (says === undefined) return err(`${key}: says is one line of at most 160 characters`);
77 const size = p["size"];
78 if (!isSize(size)) return err(`${key}: size must be ${Object.keys(SIZES).join(", ")}`);
79 const priceUsd = usd(p["priceUsd"]);
80 if (priceUsd === undefined) return err(`${key}: priceUsd is dollars from 0 to ${MAX_USD}, at most two decimals`);
81 const creditUsd = usd(p["creditUsd"]);
82 if (creditUsd === undefined) return err(`${key}: creditUsd is dollars from 0 to ${MAX_USD}, at most two decimals`);
83 const forges = count(p["forges"]);
84 if (forges === undefined) return err(`${key}: forges is a whole number from 1 to ${MAX_FORGES}`);
85 if (key === FREE && priceUsd !== 0) return err("the free plan costs $0");
86 return ok({ key, name, says, size, priceUsd, creditUsd, forges });
87 }
88
89 /** A whole list of plans from untrusted input: one to eight, unique keys, `free` among them. */
90 export function parsePlans(raw: unknown): Result<Plan[], string> {
91 if (!Array.isArray(raw) || raw.length < 1 || raw.length > MAX_PLANS) return err(`plans is a list of 1 to ${MAX_PLANS} plans`);
92 const plans: Plan[] = [];
93 for (const r of raw) {
94 const p = parsePlan(r);
95 if (!p.ok) return p;
96 if (plans.some((q) => q.key === p.value.key)) return err(`${p.value.key} is listed twice`);
97 plans.push(p.value);
98 }
99 if (!plans.some((p) => p.key === FREE)) return err("the free plan cannot be removed");
100 return ok(plans);
101 }
102
103 /** The plans in force now, read on every use. */
104 export function getPlans(db: Database): Plan[] {
105 const row = db.query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1").get(PLANS_KEY);
106 if (row !== null) {
107 try {
108 const parsed = parsePlans(JSON.parse(row.value));
109 if (parsed.ok) return parsed.value;
110 } catch {
111 // a hand-edited value reads as the defaults
112 }
113 }
114 return DEFAULT_PLANS.map((p) => ({ ...p }));
115 }
116
117 export function getPlan(db: Database, key: string): Plan | undefined {
118 return getPlans(db).find((p) => p.key === key);
119 }
120
121 const words = (plans: readonly Plan[]): string =>
122 plans.map((p) => `${p.key} ${p.size} $${p.priceUsd}/$${p.creditUsd}×${p.forges}`).join(", ");
123
124 /** Replace the plans (validated by the caller) and keep the change. */
125 export function setPlans(db: Database, plans: readonly Plan[], actorId: string): void {
126 const before = getPlans(db);
127 const next = JSON.stringify(plans);
128 if (next === JSON.stringify(before)) return;
129 writeSetting(db, PLANS_KEY, next, words(before), words(plans), actorId);
130 }
131
132 /** The plans as the public sees them: only the named fields leave. */
133 export function publicPlans(plans: readonly Plan[]): Plan[] {
134 return plans.map(({ key, name, says, size, priceUsd, creditUsd, forges }) => ({ key, name, says, size, priceUsd, creditUsd, forges }));
135 }
src/spawn/credit.ts +12 −4
@@ -19,6 +19,8 @@ export interface Credit {
1919 readonly capUsd: number;
2020 readonly spentUsd: number;
2121 readonly tokenHash: string;
22 /** A plan's monthly credit: the UTC month (`YYYY-MM`) the spend is counted in; it starts again at 0 each month. Absent for a cap that does not reset. */
23 readonly period?: string;
2224 }
2325
2426 export const CREDIT_MAX_USD = 100_000;
@@ -27,12 +29,17 @@ const sha = (s: string): string => createHash("sha256").update(s).digest("hex");
2729 const path = (app: App, name: string): string => join(app.config.dataDir, "credit", `${name}.json`);
2830 const cents = (n: number): number => Math.round(n * 100) / 100;
2931
30 export function readCredit(app: App, name: string): Credit | undefined {
32 /** The UTC calendar month a credit period is counted in. */
33 export const periodOf = (at: Date = new Date()): string => at.toISOString().slice(0, 7);
34
35 export function readCredit(app: App, name: string, now: Date = new Date()): Credit | undefined {
3136 if (!isChildName(name)) return undefined;
3237 try {
3338 const d = JSON.parse(readFileSync(path(app, name), "utf8")) as Record<string, unknown>;
34 const { capUsd, spentUsd, tokenHash } = d;
39 const { capUsd, spentUsd, tokenHash, period } = d;
3540 if (typeof capUsd !== "number" || typeof spentUsd !== "number" || typeof tokenHash !== "string") return undefined;
41 // A new month starts the spend again, without anything having to run at midnight.
42 if (typeof period === "string") return { name, capUsd, spentUsd: period === periodOf(now) ? spentUsd : 0, tokenHash, period: periodOf(now) };
3643 return { name, capUsd, spentUsd, tokenHash };
3744 } catch {
3845 return undefined;
@@ -59,16 +66,17 @@ export function setCap(
5966 usd: number,
6067 childHolds: boolean,
6168 handOff: (token: string, credit: Credit) => boolean,
69 monthly = false,
6270 ): Credit | "unreachable" | undefined {
6371 if (!isChildName(name) || !(Number.isFinite(usd) && usd > 0 && usd <= CREDIT_MAX_USD)) return undefined;
6472 const before = readCredit(app, name);
6573 if (before !== undefined && childHolds) {
66 const credit = { ...before, capUsd: cents(usd) };
74 const credit = { ...before, capUsd: cents(usd), ...(monthly ? { period: periodOf() } : {}) };
6775 keep(app, credit);
6876 return credit;
6977 }
7078 const token = `${name}.${randomBytes(24).toString("base64url")}`;
71 const credit = { name, capUsd: cents(usd), spentUsd: before?.spentUsd ?? 0, tokenHash: sha(token) };
79 const credit = { name, capUsd: cents(usd), spentUsd: before?.spentUsd ?? 0, tokenHash: sha(token), ...(monthly ? { period: periodOf() } : {}) };
7280 if (!handOff(token, credit)) return "unreachable";
7381 keep(app, credit);
7482 return credit;
src/spawn/door.ts +16 −1
@@ -36,6 +36,7 @@ import { ANON_SPAWNS_PER_DAY, checkAnonSpawn, noteAnonSpawn } from "./rate.ts";
3636 import { readdirSync, readFileSync } from "node:fs";
3737 import { readdir, stat } from "node:fs/promises";
3838 import { join } from "node:path";
39 import { FREE, getPlan, getPlans, PAID_PLAN_ADMIN_ONLY } from "../settings/plans.ts";
3940
4041 export interface Door {
4142 readonly status: number;
@@ -256,6 +257,19 @@ export async function spawnDoor(
256257 }
257258 }
258259 const source: SpawnSource = { catalog, briefs, subscriptions: [] };
260 // A plan (`settings/plans.ts`) gives the child its size; read now, so an edit governs this spawn.
261 let planKey: string | undefined;
262 let size = raw["size"];
263 if (raw["plan"] !== undefined && raw["plan"] !== null && raw["plan"] !== "") {
264 const chosen = typeof raw["plan"] === "string" ? getPlan(db, raw["plan"]) : undefined;
265 if (chosen === undefined) return invalid("plan", `must be one of ${getPlans(db).map((p) => p.key).join(", ")}`);
266 if (anon && chosen.key !== FREE) return invalid("plan", `the ${chosen.name} plan is for a signed-in person; sign in first`);
267 if (chosen.key !== FREE && user?.isAdmin !== true) return invalid("plan", PAID_PLAN_ADMIN_ONLY(chosen.name));
268 if (size !== undefined && size !== null && size !== "" && size !== chosen.size)
269 return invalid("size", `the ${chosen.name} plan is a ${chosen.size} forge; leave size out or send ${chosen.size}`);
270 planKey = chosen.key;
271 size = chosen.size;
272 }
259273 const input = {
260274 name: raw["name"],
261275 org: raw["name"],
@@ -269,8 +283,9 @@ export async function spawnDoor(
269283 budgetUsd: raw["budgetUsd"],
270284 fresh: raw["fresh"],
271285 followMode: raw["followMode"],
272 size: raw["size"],
286 size,
273287 template: raw["template"],
288 ...(planKey === undefined ? {} : { plan: planKey }),
274289 ...(anon ? {} : { for: raw["for"] }),
275290 };
276291
src/spawn/plan.ts +13
@@ -35,6 +35,7 @@ export const MAX_BUDGET_USD = 1000;
3535 /** One line saying what a fresh app is. */
3636 export const MAX_FRESH_CHARS = 200;
3737 const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
38 const PLAN_KEY = /^[a-z][a-z0-9-]{0,19}$/;
3839 const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
3940
4041 /** An app the viewer can see, as the caller found it. */
@@ -75,6 +76,8 @@ export interface SpawnInput {
7576 readonly size?: unknown;
7677 /** Which template the child's apps start from (`spawn/templates.ts`); optional. */
7778 readonly template?: unknown;
79 /** The plan (`settings/plans.ts`) the child is on; optional. The door has already set `size` from it. */
80 readonly plan?: unknown;
7881 }
7982
8083 /** What the parent has to offer. Extra fields are ignored, never copied. */
@@ -119,6 +122,8 @@ export interface SpawnPlan {
119122 readonly size?: Size;
120123 /** The template the child starts from, present only when chosen and not `blank`. */
121124 readonly template?: string;
125 /** The key of the plan the child is on, present only when one was chosen; its credit is read when the child is made. */
126 readonly plan?: string;
122127 }
123128
124129 export interface DryRun {
@@ -276,6 +281,13 @@ export function planSpawn(
276281 if (parsed.value !== DEFAULT_TEMPLATE) template = parsed.value;
277282 }
278283
284 // Last of all, so every older field's refusal comes first.
285 let plan: string | undefined;
286 if (input.plan !== undefined && input.plan !== null && input.plan !== "") {
287 if (typeof input.plan !== "string" || !PLAN_KEY.test(input.plan)) return refuse("plan", "a plan key is 1-20 characters of a-z, 0-9 and \"-\"");
288 plan = input.plan;
289 }
290
279291 // `forWhom` only when said, so a plan without it hashes as it always did.
280292 return ok({
281293 name,
@@ -291,6 +303,7 @@ export function planSpawn(
291303 ...(followMode === undefined ? {} : { followMode }),
292304 ...(size === undefined ? {} : { size }),
293305 ...(template === undefined ? {} : { template }),
306 ...(plan === undefined ? {} : { plan }),
294307 });
295308 }
296309
src/spawn/sleep.ts +34
@@ -134,6 +134,40 @@ export function heldByPerson(dataDir: string, ownerId: string): number {
134134
135135 const MB = 1_000_000;
136136
137 /** How many forges this person holds on plan `key`; a ledger that names no plan is on the free one. */
138 export function ownedOnPlan(dataDir: string, ownerId: string, key: string, free: string): number {
139 let n = 0;
140 let files: string[] = [];
141 try {
142 files = readdirSync(join(dataDir, "spawn"));
143 } catch {
144 return 0;
145 }
146 for (const f of files) {
147 if (!f.endsWith(".json")) continue;
148 try {
149 const l = JSON.parse(readFileSync(join(dataDir, "spawn", f), "utf8")) as { ownerId?: unknown; plan?: unknown };
150 if (l.ownerId === ownerId && (typeof l.plan === "string" ? l.plan : free) === key) n++;
151 } catch {
152 // a damaged ledger counts for no one
153 }
154 }
155 return n;
156 }
157
158 /**
159 * Why this person may not make another forge on a paid plan, or undefined:
160 * the plan's own count, not the free one (`personRefusal`). Site admins are
161 * not limited.
162 */
163 export function planRefusal(dataDir: string, owner: { id: string; isAdmin: boolean }, plan: { key: string; name: string; forges: number }, free: string): string | undefined {
164 if (owner.isAdmin) return undefined;
165 const has = ownedOnPlan(dataDir, owner.id, plan.key, free);
166 return has >= plan.forges
167 ? `you already have ${has} ${has === 1 ? "forge" : "forges"} on the ${plan.name} plan, the most one person may have (${plan.forges}); remove one first`
168 : undefined;
169 }
170
137171 /**
138172 * Why this person may not make another forge, or undefined: one sentence
139173 * naming the limit (set in admin limits) and what they use. Site admins are
src/tui/screens/entries.ts +2
@@ -46,6 +46,7 @@ import { entry as homePins } from "./home_pins.ts";
4646 import { entry as claimStart } from "./claim_start.ts";
4747 import { entry as invites } from "./invites.ts";
4848 import { entry as limits } from "./limits.ts";
49 import { entry as plans } from "./plans.ts";
4950 import { entry as economics } from "./economics.ts";
5051 import { entry as money } from "./money.ts";
5152 import { entry as loopStalls } from "./loopstalls.ts";
@@ -129,6 +130,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
129130 people,
130131 quotas,
131132 limits,
133 plans,
132134 economics,
133135 money,
134136 appTemplates,
src/tui/screens/plans.ts added +122
@@ -0,0 +1,122 @@
1 /**
2 * The forge's plans in the terminal, a forge admin's: every plan with its
3 * size, price, monthly crew credit and forge count, as /admin/limits and MCP
4 * `limits` show them. `e` asks for `<field> <value>` (price, credit, forges,
5 * size, name or words), shows the change and applies it only after a confirm,
6 * through `editPlans`, so the words of a refusal are the page's and the MCP's.
7 * The gate is the action's: an admin who is a person; anyone else sees nothing.
8 */
9
10 import { editPlans, readPlans } from "../../actions/plans.ts";
11 import type { Plan } from "../../settings/plans.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import { style } from "../term/style.ts";
15 import { truncate } from "../term/width.ts";
16 import type { Session } from "../session.ts";
17 import { fill, len, split } from "../ui/layout.ts";
18 import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts";
19 import type { PaletteEntry } from "./entries.ts";
20
21 const FIELDS: Readonly<Record<string, keyof Plan>> = {
22 name: "name",
23 words: "says",
24 size: "size",
25 price: "priceUsd",
26 credit: "creditUsd",
27 forges: "forges",
28 };
29 const NUMERIC = new Set<keyof Plan>(["priceUsd", "creditUsd", "forges"]);
30 const ASK = `${Object.keys(FIELDS).join(", ")} then a value, e.g. credit 6`;
31
32 /** `credit 6` as the plan field and the value the form would send, or the sentence that says what is wrong. */
33 function parseEdit(raw: string): { field: keyof Plan; value: string | number } | string {
34 const m = /^(\S+)\s+(.+)$/.exec(raw.trim());
35 const field = m === null ? undefined : FIELDS[m[1]!.toLowerCase()];
36 if (m === null || field === undefined) return ASK;
37 const text = m[2]!.trim();
38 if (!NUMERIC.has(field)) return { field, value: text };
39 return /^\d{1,7}(\.\d{1,2})?$/.test(text) ? { field, value: Number(text) } : `${m[1]} is a number`;
40 }
41
42 export function plansScreen(f: Session, ctx: Ctx): Screen {
43 const state = new ListState();
44 let note = "";
45 const all = (): Plan[] => (f.user?.isAdmin === true && f.user.kind !== "agent" ? readPlans(f.app) : []);
46
47 const apply = (plan: Plan, field: keyof Plan, value: string | number): void => {
48 const made = editPlans(f.app, f.user, { [plan.key]: { [field]: value } });
49 note = made.ok
50 ? `${plan.key}: ${field} is now ${value}, from the next spawn on.`
51 : made.error === "not_found"
52 ? "You cannot change plans."
53 : `${made.error.invalid}.`;
54 ctx.invalidate();
55 };
56
57 const ask = (plan: Plan): void => {
58 ctx.push(
59 promptScreen(ctx, screen, {
60 title: plan.key,
61 label: ASK,
62 hint: `now ${plan.size} · $${plan.priceUsd}/mo · $${plan.creditUsd} crew credit · ${plan.forges} forges`,
63 onSubmit: (raw) => {
64 const edit = parseEdit(raw);
65 if (typeof edit === "string") return edit;
66 return Promise.resolve().then(() => {
67 ctx.push(
68 confirmScreen(ctx, screen, {
69 title: "Change this plan",
70 body: [`${plan.key}: ${edit.field} ${String(plan[edit.field])} → ${edit.value}`],
71 actions: [{ label: "Keep", variant: "ghost" }, { label: "Change" }],
72 onChoose: (i) => {
73 if (i === 1) apply(plan, edit.field, edit.value);
74 },
75 }),
76 );
77 });
78 },
79 }),
80 );
81 };
82
83 const rows = (plans: readonly Plan[]): ListRow[] =>
84 plans.map((p) => ({
85 title: `${p.key} · ${p.name}`,
86 secondary: `${p.size} · $${p.priceUsd}/mo · $${p.creditUsd} crew credit · ${p.forges} forges`,
87 meta: p.says,
88 }));
89
90 const screen: Screen = {
91 title: "Plans",
92 keys: [binding(["j", "k"], "move cursor"), binding(["e"], "change")],
93 render(area, s) {
94 const c = ctx.theme.c;
95 const [head, list] = split(area, "v", [len(1), fill(1)]);
96 const plans = all();
97 const text = note !== "" ? note : plans.length > 0 ? "What a spawn on each plan gets. e changes one." : "Plans are set by a forge admin.";
98 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
99 drawList(s, list!, c, state, rows(plans));
100 },
101 key(e) {
102 const plans = all();
103 if (state.key(e, plans.length)) return true;
104 if (e.ctrl || e.alt || e.key !== "e") return false;
105 const plan = plans[state.index];
106 if (plan === undefined) return false;
107 ask(plan);
108 return true;
109 },
110 };
111 return screen;
112 }
113
114 /** The forge's plans, as /admin/limits has them: an admin who is not an agent. */
115 export const entry: PaletteEntry = {
116 id: "plans",
117 title: "Plans",
118 hint: "free, small, medium and large: size, price, crew credit, forges; change one",
119 after: ["limits"],
120 offered: (session: Session) => session.user?.isAdmin === true && session.user.kind !== "agent",
121 run: (session, ctx) => ctx.push(plansScreen(session, ctx)),
122 };
src/web/pages/admin-limits.ts +2 −1
@@ -22,6 +22,7 @@ import { field, readForm } from "../form.ts";
2222 import { html, when, type Html } from "../html.ts";
2323 import { flashTo, page } from "../layout.ts";
2424 import { btn, confirmAction, icon, row, time } from "../ui.ts";
25 import { plansRow } from "../settings/plans.ts";
2526 import { accountSections } from "./origins.ts";
2627
2728 // GET /admin/limits
@@ -196,7 +197,7 @@ export function limitsPage(ctx: Ctx): Response {
196197 const body = html`<p class="small muted mb">
197198 Every limit you can hit, and why. A change applies to the next request that meets it; nothing needs a restart.
198199 </p>
199 <dl class="status-rows">${signupRow}${agentStartRow}${inviteRow}${quotaRow}</dl>
200 <dl class="status-rows">${signupRow}${agentStartRow}${inviteRow}${quotaRow}${plansRow(ctx)}</dl>
200201 ${historyBlock("Invite links made", inviteRows)}
201202 <section class="settings-block mt">
202203 <h2>Limits</h2>
src/web/settings/plans.ts added +69
@@ -0,0 +1,69 @@
1 /**
2 * The plans card on /admin/limits, for a forge admin: every plan's name,
3 * words, size, price, crew credit and forge count as one form
4 * (`settings/plans.ts`). POST /admin/plans saves it; the next spawn and
5 * GET /api/v1/plans use it at once.
6 */
7
8 import { editPlans, readPlans } from "../../actions/plans.ts";
9 import type { Ctx } from "../../http/context.ts";
10 import { SIZES } from "../../spawn/sizes.ts";
11 import { notFoundPage } from "../chrome.ts";
12 import { field, readForm } from "../form.ts";
13 import { html, type Html } from "../html.ts";
14 import { flashTo } from "../layout.ts";
15 import { btn, row } from "../ui.ts";
16
17 const FIELDS = ["name", "says", "size", "priceUsd", "creditUsd", "forges"] as const;
18
19 export function plansRow(ctx: Ctx): Html {
20 const rows = readPlans(ctx.app).map((p) =>
21 row({
22 title: p.key,
23 facts: [
24 { label: "Name", value: html`<input class="input" name="${p.key}.name" value="${p.name}" maxlength="40" aria-label="${p.key} name" />` },
25 { label: "Words", value: html`<input class="input" name="${p.key}.says" value="${p.says}" maxlength="160" aria-label="${p.key} words" />` },
26 {
27 label: "Size",
28 value: html`<select class="input mono" name="${p.key}.size" aria-label="${p.key} size">${Object.keys(SIZES).map((s) => html`<option value="${s}" ${p.size === s ? "selected" : ""}>${s}</option>`)}</select>`,
29 },
30 { label: "Price $/mo", value: html`<input class="input mono" name="${p.key}.priceUsd" inputmode="decimal" value="${String(p.priceUsd)}" ${p.key === "free" ? "readonly" : ""} aria-label="${p.key} price a month in dollars" />` },
31 { label: "Crew credit $/mo", value: html`<input class="input mono" name="${p.key}.creditUsd" inputmode="decimal" value="${String(p.creditUsd)}" aria-label="${p.key} crew credit a month in dollars" />` },
32 { label: "Forges", value: html`<input class="input mono" name="${p.key}.forges" type="number" min="1" max="1000" step="1" value="${String(p.forges)}" aria-label="${p.key} forges one person may hold" />` },
33 ],
34 }),
35 );
36 return html`<div class="status-row">
37 <dt>Plans</dt>
38 <dd>
39 <form method="post" action="/admin/plans">
40 <ul class="list">${rows}</ul>
41 ${btn("Save", { size: "sm", variant: "primary", label: "Save plans" })}
42 </form>
43 <span class="muted small">The next spawn takes what is saved; until payment exists only a forge admin may take a paid plan.</span>
44 </dd>
45 </div>`;
46 }
47
48 // POST /admin/plans
49 export async function plansSet(ctx: Ctx): Promise<Response> {
outdated

Principles 1 and 7: this paragraph explains the form a second time and the plans are a table of inputs, not rows. One sentence; plan rows with a dl of facts.

50 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx);
51 const form = await readForm(ctx, "admin");
52 if (form instanceof Response) return form;
53 const edits: Record<string, Record<string, unknown>> = {};
54 for (const p of readPlans(ctx.app)) {
55 const mine: Record<string, unknown> = {};
56 for (const f of FIELDS) {
57 const v = field(form, `${p.key}.${f}`);
58 if (v !== "") mine[f] = v;
59 }
60 edits[p.key] = mine;
61 }
62 const changed = editPlans(ctx.app, ctx.auth.user, edits);
63 if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx);
64 return flashTo(
65 ctx,
66 "/admin/limits",
67 changed.ok ? { message: "Plans saved." } : { tone: "danger", message: `${typeof changed.error === "object" ? changed.error.invalid : "Plans were not saved"}.` },
68 );
69 }
test/mcp-limits.test.ts +30
@@ -147,4 +147,34 @@ describe("MCP limits", () => {
147147 }
148148 expect(now()).toBe(before);
149149 });
150
151 const PLANS = [
152 { key: "free", name: "Free", says: "to try it", size: "small", priceUsd: 0, creditUsd: 0, forges: 1 },
153 { key: "pro", name: "Pro", says: "for work", size: "medium", priceUsd: 9, creditUsd: 3, forges: 2 },
154 ];
155
156 test("plans: a dry run validates, applying needs admin scope, a non-admin has no tool", async () => {
157 const dry = await call(adaAdmin, { plans: PLANS });
158 expect(dry.error).toBe(false);
159 expect(JSON.parse(dry.text).plans.map((p: any) => p.key)).toEqual(["free", "pro"]);
160 expect(JSON.parse((await call(adaAdmin, {})).text).plans.map((p: any) => p.key)).toEqual(["free", "small", "medium", "large"]);
161 // A dry run tells the truth: a bad list is refused as the real change would refuse it.
162 const bad = await call(adaAdmin, { plans: PLANS.slice(1) });
163 expect(bad.error).toBe(true);
164 expect(bad.text).toContain("the free plan cannot be removed");
165 expect((await call(adaAdmin, { plans: [{ ...PLANS[0], size: "huge" }] })).error).toBe(true);
166 expect((await call(adaAdmin, { plans: [] })).error).toBe(true);
167 // Below admin scope may plan but not apply.
168 const ro = await call(adaRw, { plans: PLANS, dryRun: false });
169 expect(ro.error).toBe(true);
170 expect(ro.text).toContain("admin-scope");
171 expect(JSON.parse((await call(adaAdmin, {})).text).plans).toHaveLength(4);
172 // A non-admin and an agent are told there is no such tool.
173 expect((await call(cyToken, { plans: PLANS, dryRun: false })).error).toBe(true);
174 expect((await call(botToken, { plans: PLANS, dryRun: false })).error).toBe(true);
175 expect(JSON.parse((await call(adaAdmin, {})).text).plans).toHaveLength(4);
176 const applied = await call(adaAdmin, { plans: PLANS, dryRun: false });
177 expect(applied.error).toBe(false);
178 expect(JSON.parse((await call(adaAdmin, {})).text).plans.map((p: any) => p.key)).toEqual(["free", "pro"]);
179 });
150180 });
test/palette-roster.test.ts +1
@@ -145,6 +145,7 @@ test("a site-admin person is offered the whole roster", () => {
145145 ["people", "People", "who is a guest, a member or an admin; promote or demote"],
146146 ["quotas", "Account quotas", "what one account may own, and who changed it"],
147147 ["limits", "Limits", "what the forge caps, with default and bounds; change one"],
148 ["plans", "Plans", "free, small, medium and large: size, price, crew credit, forges; change one"],
148149 ["economics", "Economics", "the profit slider's position, the margin, the owned-node label and the prices; change one"],
149150 ["flows-all", "Flows across the forge", "every repository's flow lines, by kind and stale"],
150151 ["teams", "Crew teams", "each team's charter, dial and members"],
test/plans.test.ts added +215
@@ -0,0 +1,215 @@
1 /**
2 * Plans are a setting (#11135): free, small, medium and large, each with a
3 * size, a monthly price, a monthly crew credit and a forge count. An admin's
4 * edit changes what GET /api/v1/plans answers and what the next spawn gets,
5 * with no restart; a spawn on each plan takes its size and credit.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, readFile, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { hostedPlacement, memoryBodies } from "../src/spawn/placement.ts";
16 import { usePlacement, useProbe } from "../src/spawn/door.ts";
17 import { addSpend, periodOf, readCredit } from "../src/spawn/credit.ts";
18 import { DEFAULT_PLANS, getPlans, parsePlans } from "../src/settings/plans.ts";
19 import { planRefusal } from "../src/spawn/sleep.ts";
20 import { grantPlanCredit } from "../src/actions/spawn.ts";
21 import { setLimit } from "../src/settings/limits.ts";
22
23 const PASSWORD = "a-long-new-forge-passphrase";
24
25 describe("plans", () => {
26 let dir = "";
27 let app: App;
28 let servers: Servers;
29 let bob = "";
30 let admin = "";
31 let adminId = "";
32 const url = (p: string) => `${servers.url}${p}`;
33 const plans = async () => ((await (await fetch(url("/api/v1/plans"))).json()) as { items: any[] }).items;
34 const spawn = (tok: string | undefined, name: string, plan?: string, extra: Record<string, unknown> = {}) =>
35 fetch(url("/api/v1/forges"), {
36 method: "POST",
37 headers: { "content-type": "application/json", ...(tok === undefined ? { origin: "https://forge.test" } : { authorization: `Bearer ${tok}` }) },
38 body: JSON.stringify({ name, apps: [], dryRun: false, ...(plan === undefined ? {} : { plan }), ...extra }),
39 });
40 const put = (tok: string, list: unknown) =>
41 fetch(url("/api/v1/admin/plans"), {
42 method: "PUT",
43 headers: { "content-type": "application/json", authorization: `Bearer ${tok}` },
44 body: JSON.stringify({ plans: list }),
45 });
46 const ledger = async (name: string) => JSON.parse(await readFile(join(dir, "spawn", `${name}.json`), "utf8")) as { size: string; plan?: string };
47
48 beforeAll(async () => {
49 dir = await mkdtemp(join(tmpdir(), "forge-plans-"));
50 app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
51 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: PASSWORD, isAdmin: true });
52 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: PASSWORD });
53 if (!a.ok || !b.ok) throw new Error("users");
54 adminId = a.value.id;
55 admin = createToken(app.db, a.value.id, "admin", "admin").token;
56 bob = createToken(app.db, b.value.id, "rw", "rw").token;
57 usePlacement(app, hostedPlacement({ host: "https://forge.test", store: memoryBodies(), maxChildren: 50 }));
58 useProbe(app, () => ({ live: async () => true }) as never);
59 servers = await serve(app);
60 }, 30_000);
61
62 afterAll(async () => {
63 servers.stop();
64 app.close();
65 await rm(dir, { recursive: true, force: true });
66 });
67
68 test("the defaults: free, small, medium and large, public and without secrets", async () => {
69 const items = await plans();
70 expect(items.map((p) => [p.key, p.size, p.priceUsd, p.creditUsd])).toEqual([
71 ["free", "small", 0, 0],
72 ["small", "small", 19, 5],
73 ["medium", "medium", 49, 15],
74 ["large", "large", 149, 50],
75 ]);
76 for (const p of items) expect(Object.keys(p).sort()).toEqual(["creditUsd", "forges", "key", "name", "priceUsd", "says", "size"]);
77 expect(getPlans(app.db)).toEqual([...DEFAULT_PLANS]);
78 });
79
80 test("only a person admin edits them; a bad list is refused in a sentence", async () => {
81 expect([403, 404]).toContain((await put(bob, DEFAULT_PLANS)).status);
82 const noFree = await put(admin, DEFAULT_PLANS.filter((p) => p.key !== "free"));
83 expect(noFree.status).toBe(422);
84 expect(((await noFree.json()) as any).message).toBe("the free plan cannot be removed");
85 const dear = await put(admin, DEFAULT_PLANS.map((p) => (p.key === "free" ? { ...p, priceUsd: 1 } : p)));
86 expect(((await dear.json()) as any).message).toBe("the free plan costs $0");
87 expect(parsePlans([{ ...DEFAULT_PLANS[0], size: "huge" }]).ok).toBe(false);
88 expect(parsePlans([{ ...DEFAULT_PLANS[0], creditUsd: 1.234 }]).ok).toBe(false);
89 expect((await plans())[1].priceUsd).toBe(19);
90 });
91
92 test("a spawn on each plan takes its size and its credit", async () => {
93 for (const [plan, size, credit] of [["free", "small", undefined], ["small", "small", 5], ["medium", "medium", 15], ["large", "large", 50]] as const) {
94 const name = `kid-${plan}`;
95 const res = await spawn(plan === "free" ? bob : admin, name, plan);
96 expect(res.status).toBe(201);
97 const l = await ledger(name);
98 expect([l.size, l.plan]).toEqual([size, plan]);
99 expect(readCredit(app, name)?.capUsd).toBe(credit);
100 }
101 // A plan and a size that disagree are refused.
102 const clash = await spawn(admin, "kid-clash", "medium", { size: "small" });
103 expect(clash.status).toBe(422);
104 expect((await spawn(admin, "kid-nope", "gold")).status).toBe(422);
105 });
106
107 test("an edit changes the answer and the next spawn, with no restart", async () => {
108 const edited = DEFAULT_PLANS.map((p) => (p.key === "medium" ? { ...p, priceUsd: 59.5, creditUsd: 20 } : p));
109 const res = await put(admin, edited);
110 expect(res.status).toBe(200);
111 const medium = (await plans()).find((p) => p.key === "medium");
112 expect([medium.priceUsd, medium.creditUsd]).toEqual([59.5, 20]);
113 expect((await spawn(admin, "kid-medium-2", "medium")).status).toBe(201);
114 expect(readCredit(app, "kid-medium-2")?.capUsd).toBe(20);
115 // An edit is kept: who, when, from what to what.
116 const kept = app.db.query("SELECT actor_id FROM forge_setting_change WHERE key = 'plans'").all() as { actor_id: string }[];
117 expect(kept.map((r) => r.actor_id)).toEqual([adminId]);
118 });
119
120 test("a paid plan is held to its own count, never the free one; the free plan keeps the free limit", async () => {
121 setLimit(app.db, "forges.per_person", 1, adminId);
122 // bob holds a free forge: the free plan is full; the admin holds paid ones and a plan with room is not.
123 expect((await spawn(bob, "kid-free-2", "free")).status).toBe(409);
124 expect((await spawn(admin, "kid-small-2", "small")).status).toBe(201);
125 const tight = DEFAULT_PLANS.map((p) => (p.key === "large" ? { ...p, forges: 1 } : p));
126 expect((await put(admin, tight)).status).toBe(200);
127 // A forge admin is never held to a count; the same person as a plain one is, by the plan's own.
128 expect((await spawn(admin, "kid-large-2", "large")).status).toBe(201);
129 const plain = planRefusal(dir, { id: adminId, isAdmin: false }, { key: "large", name: "Large", forges: 1 }, "free");
130 expect(plain).toBe("you already have 2 forges on the Large plan, the most one person may have (1); remove one first");
131 expect(planRefusal(dir, { id: adminId, isAdmin: true }, { key: "large", name: "Large", forges: 1 }, "free")).toBeUndefined();
132 });
133
134 test("a plain signed-in person is refused a paid plan, and no credit is lent", async () => {
135 for (const plan of ["small", "medium", "large"]) {
136 const res = await spawn(bob, `bob-${plan}`, plan);
137 expect(res.status).toBe(422);
138 expect(((await res.json()) as any).message).toContain("only a forge admin may take it");
139 expect(readCredit(app, `bob-${plan}`)).toBeUndefined();
140 }
141 // The plan alone is refused too, before anything is made.
142 expect((await spawn(bob, "bob-dry", "small", { dryRun: true })).status).toBe(422);
143 });
144
145 test("a failed credit hand-off lends nothing and says nothing was set", () => {
146 const seen: string[] = [];
147 const off = app.bus.subscribe((e) => seen.push(e.kind));
148 grantPlanCredit(app, { name: "kid-lost", plan: "small" } as never, "no-such-body");
149 expect(readCredit(app, "kid-lost")).toBeUndefined();
150 expect(seen).not.toContain("forge.credit_set");
151 off();
152 });
153
154 test("a stranger may take the free plan, never a paid one", async () => {
155 const paid = await spawn(undefined, "stranger-paid", "small");
156 expect(paid.status).toBe(422);
157 });
158
159 test("a plan's credit starts again each calendar month", async () => {
160 const now = new Date();
161 const spendStart = readCredit(app, "kid-small")!;
162 expect(spendStart.period).toBe(periodOf(now));
163 const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 15));
164 expect(readCredit(app, "kid-small", next)?.period).toBe(periodOf(next));
165 // Spend counted this month is gone next month; a cap that never resets keeps its spend.
166 addSpend(app, "kid-small", 2);
167 expect(readCredit(app, "kid-small")?.spentUsd).toBe(2);
168 expect(readCredit(app, "kid-small", next)?.spentUsd).toBe(0);
169 });
170
171 test("the plans card is on the limits page and its form saves", async () => {
172 const signin = await fetch(url("/signin"), { method: "POST", body: new URLSearchParams({ username: "alice", password: PASSWORD }), redirect: "manual" });
173 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
174 const page = await (await fetch(url("/admin/limits"), { headers: { cookie } })).text();
175 expect(page).toContain('name="small.creditUsd"');
176 expect(page).toContain('name="small.size"');
177 const bad = await fetch(url("/admin/plans"), { method: "POST", headers: { cookie, origin: "https://forge.test" }, body: new URLSearchParams({ "small.creditUsd": "lots" }), redirect: "manual" });
178 expect(bad.status).toBe(303);
179 expect((await plans()).find((p) => p.key === "small").creditUsd).toBe(5);
180 const flash = (bad.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
181 const flashed = await (await fetch(url("/admin/limits"), { headers: { cookie: `${cookie}; ${flash}` } })).text();
182 expect(flashed).toContain("small: creditUsd is dollars");
183 const form = new URLSearchParams({ "small.priceUsd": "21", "small.creditUsd": "6" });
184 const saved = await fetch(url("/admin/plans"), { method: "POST", headers: { cookie, origin: "https://forge.test" }, body: form, redirect: "manual" });
185 expect(saved.status).toBe(303);
186 const small = (await plans()).find((p) => p.key === "small");
187 expect([small.priceUsd, small.creditUsd]).toEqual([21, 6]);
188 });
189
190 test("a person who is not an admin cannot POST /admin/plans", async () => {
191 const signin = await fetch(url("/signin"), { method: "POST", body: new URLSearchParams({ username: "bob", password: PASSWORD }), redirect: "manual" });
192 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
193 const res = await fetch(url("/admin/plans"), { method: "POST", headers: { cookie, origin: "https://forge.test" }, body: new URLSearchParams({ "small.priceUsd": "1" }), redirect: "manual" });
194 expect([403, 404]).toContain(res.status);
195 expect((await plans()).find((p) => p.key === "small").priceUsd).toBe(21);
196 });
197
198 test("the MCP spawn tool takes a plan: an admin's is made with its size and credit, a plain person's is refused", async () => {
199 const mcp = async (tok: string, args: Record<string, unknown>) => {
200 const res = await fetch(url("/mcp"), {
201 method: "POST",
202 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok}` },
203 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "spawn", arguments: args } }),
204 });
205 const body = (await res.json()) as any;
206 return { text: (body.error?.message ?? body.result.content[0].text) as string, error: body.error !== undefined || body.result.isError === true };
207 };
208 const planned = await mcp(admin, { name: "mcp-kid", plan: "large", dryRun: false });
209 expect(planned.error).toBe(false);
210 expect([(await ledger("mcp-kid")).size, (await ledger("mcp-kid")).plan]).toEqual(["large", "large"]);
211 const refused = await mcp(bob, { name: "mcp-bob", plan: "large", dryRun: false });
212 expect(refused.error).toBe(true);
213 expect(refused.text).toContain("only a forge admin may take it");
214 });
215 });
test/tui-plans.test.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * The forge's plans in the terminal (#11135): an admin who is a person lists
3 * the plans, changes a field after a confirm and is refused a bad value in the
4 * page's words; anyone else has no command and, driving the screen directly,
5 * sees and changes nothing.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App as Forge } from "../src/app.ts";
12 import { getPlan } from "../src/settings/plans.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { type TuiIdentity } from "../src/tui/session.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 describe("plans in the terminal", () => {
23 let dataDir: string;
24 let forge: Forge;
25
26 function boot(username: string) {
27 const terminal = headlessTerminal(140, 32);
28 const user = getUserByName(forge.db, username)!;
29 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
30 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
31 app.start();
32 const key = (k: string) =>
33 app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
34 return {
35 text: () => app.screen.toText(),
36 press: async (...keys: string[]) => {
37 keys.forEach(key);
38 await settleApp(app);
39 },
40 ctrl: async (k: string) => {
41 app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false });
42 await settleApp(app);
43 },
44 type: async (t: string) => {
45 for (const ch of t) key(ch);
46 await settleApp(app);
47 },
48 stop: () => app.stop(),
49 };
50 }
51
52 beforeAll(async () => {
53 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-plans-"));
54 forge = createApp({ dataDir, port: 0 });
55 for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) {
56 const made = await createUser(forge.db, {
57 origin: "seed",
58 trusted: true,
59 username,
60 email: `${username}@forge.local`,
61 password: "a-good-passphrase",
62 adminIfFirst,
63 });
64 if (!made.ok) throw new Error(made.error);
65 }
66 forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run();
67 });
68 afterAll(async () => {
69 forge.close();
70 await rm(dataDir, { recursive: true, force: true });
71 });
72
73 const open = async (h: ReturnType<typeof boot>) => {
74 await h.ctrl("k");
75 await h.type("Plans");
76 await h.press("enter");
77 };
78 const credit = () => getPlan(forge.db, "small")!.creditUsd;
79
80 test("lists each plan with size, price, credit and forges", async () => {
81 const h = boot("alice");
82 await open(h);
83 expect(h.text()).toContain("small · $19/mo · $5 crew credit");
84 expect(h.text()).toContain("large");
85 h.stop();
86 });
87
88 test("a change applies only after the confirm; a bad value is refused in the page's words", async () => {
89 const h = boot("alice");
90 await open(h);
91 await h.press("j", "e");
92 await h.type("credit 6");
93 await h.press("enter");
94 expect(h.text()).toContain("creditUsd 5 → 6");
95 expect(credit()).toBe(5);
96 await h.press("right", "enter");
97 expect(credit()).toBe(6);
98 expect(h.text()).toContain("small: creditUsd is now 6");
99
100 await h.press("e");
101 await h.type("credit lots");
102 await h.press("enter");
103 expect(h.text()).toContain("credit is a number");
104 await h.press("esc");
105 await h.press("e");
106 await h.type("size huge");
107 await h.press("enter");
108 await h.press("right", "enter");
109 expect(h.text()).toContain("small: size must be");
110 expect(credit()).toBe(6);
111 h.stop();
112 });
113
114 test("a plain person and an agent admin have no command", async () => {
115 for (const who of ["bob", "carol"]) {
116 const h = boot(who);
117 await h.ctrl("k");
118 await h.type("Plans");
119 expect(h.text()).not.toContain("free, small, medium and large");
120 h.stop();
121 }
122 });
123 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs