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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11135-plans-are-a-setting-free-small-medium-an → main 4 commits fork 3 threads · 3 unresolved
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`; | ||
| 133 | 133 | | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 | |
| 134 | 134 | | `lift` | — | Lift an issue out from behind the Later gate | 214 | |
| 135 | 135 | | `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 | | |
| 137 | 137 | | `maintenance` | `status`, `prepare`, `resume` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 256 | |
| 138 | 138 | | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 | |
| 139 | 139 | | `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`; | ||
| 197 | 197 | | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 291 | |
| 198 | 198 | | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 | |
| 199 | 199 | | `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 | | |
| 201 | 201 | | `spend` | — | Report what your session spent outside forge runs: who, model, tokens or dollars, issue | 470 | |
| 202 | 202 | | `ssh_keys` | `list`, `add`, `remove` | Your SSH keys; add: key, remove: id | 250 | |
| 203 | 203 | | `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"; | ||
| 58 | 58 | import { claimLink, hasAdmin, mintClaim, spawnedOrg } from "../spawn/claim.ts"; |
| 59 | 59 | import { childPublicUrl, parentOrigin, type Placement, type PlacementKind } from "../spawn/placement.ts"; |
| 60 | 60 | 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"; | |
| 62 | 65 | import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts"; |
| 63 | 66 | import { DEFAULT_SIZE, isSize, type Size } from "../spawn/sizes.ts"; |
| 64 | 67 | import { DEFAULT_MODE } from "../spawn/chain.ts"; |
| @@ -123,6 +126,8 @@ interface Ledger { | ||
| 123 | 126 | readonly size?: Size; |
| 124 | 127 | /** The template chosen (`spawn/templates.ts`); absent means `blank`. */ |
| 125 | 128 | readonly template?: string; |
| 129 | /** The plan chosen (`settings/plans.ts`); absent means the free plan. */ | |
| 130 | readonly plan?: string; | |
| 126 | 131 | /** Absent on a ledger written before cells; reads as hosted. */ |
| 127 | 132 | readonly placement?: PlacementKind; |
| 128 | 133 | /** Why a child that wanted a cell is hosted instead (`spawn/host.ts`). */ |
| @@ -274,7 +279,16 @@ async function make( | ||
| 274 | 279 | // A retry of an unfinished child is a new attempt: its failure has a start. |
| 275 | 280 | app.bus.append({ kind: "forge.spawn.started", payload: { ...(deps.drill === true ? { drill: 1 } : {}) } }); |
| 276 | 281 | } 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) { | |
| 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) { | |
| 278 | 292 | const many = personRefusal(app.db, app.config.dataDir, owner); |
| 279 | 293 | if (many !== undefined) return err({ kind: "refused", reason: "quota", message: many }); |
| 280 | 294 | } |
| @@ -298,6 +312,7 @@ async function make( | ||
| 298 | 312 | ...(owner === undefined ? {} : { ownerId: owner.id }), |
| 299 | 313 | size: plan.size ?? DEFAULT_SIZE, |
| 300 | 314 | ...(plan.template === undefined ? {} : { template: plan.template }), |
| 315 | ...(plan.plan === undefined ? {} : { plan: plan.plan }), | |
| 301 | 316 | placement: placed.value.kind ?? "hosted", |
| 302 | 317 | statusToken: secret(), |
| 303 | 318 | beatKey: secret(), |
| @@ -310,6 +325,7 @@ async function make( | ||
| 310 | 325 | if (!copied.ok) return failed(app, deps, "copy", copied.error); |
| 311 | 326 | const claimUrl = claimLink(liveChildUrl(app, plan.name), copied.value); |
| 312 | 327 | |
| 328 | grantPlanCredit(app, plan, ledger.bodyId); | |
| 313 | 329 | ledger.done = true; |
| 314 | 330 | await writeLedger(app, ledger); |
| 315 | 331 | app.bus.append({ |
| @@ -328,6 +344,25 @@ async function make( | ||
| 328 | 344 | }); |
| 329 | 345 | } |
| 330 | 346 | |
| 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 | ||
| 331 | 366 | /** |
| 332 | 367 | * Every step skips what is already there, so a resumed child is finished, |
| 333 | 368 | * 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 | ||
| 109 | 109 | export { route as getApiV1Packs } from "./getApiV1Packs.ts"; |
| 110 | 110 | export { route as getApiV1PacksName } from "./getApiV1PacksName.ts"; |
| 111 | 111 | export { route as getApiV1PassportHealth } from "./getApiV1PassportHealth.ts"; |
| 112 | export { route as getApiV1Plans } from "./getApiV1Plans.ts"; | |
| 112 | 113 | export { route as getApiV1Releases } from "./getApiV1Releases.ts"; |
| 113 | 114 | export { route as getApiV1Repos } from "./getApiV1Repos.ts"; |
| 114 | 115 | export { route as getApiV1ReposOwnerName } from "./getApiV1ReposOwnerName.ts"; |
| @@ -374,6 +375,7 @@ export { route as postMcp } from "./postMcp.ts"; | ||
| 374 | 375 | export { route as putApiV1AdminCapacityKey } from "./putApiV1AdminCapacityKey.ts"; |
| 375 | 376 | export { route as putApiV1AdminLimitsKey } from "./putApiV1AdminLimitsKey.ts"; |
| 376 | 377 | export { route as putApiV1AdminMachinesMachineDisksDevice } from "./putApiV1AdminMachinesMachineDisksDevice.ts"; |
| 378 | export { route as putApiV1AdminPlans } from "./putApiV1AdminPlans.ts"; | |
| 377 | 379 | export { route as putApiV1AdminQuotasKey } from "./putApiV1AdminQuotasKey.ts"; |
| 378 | 380 | export { route as putApiV1AdminShield } from "./putApiV1AdminShield.ts"; |
| 379 | 381 | export { route as putApiV1AdminTeamsSlug } from "./putApiV1AdminTeamsSlug.ts"; |
src/contract/openapi/routes/postApiV1Forges.ts +1
| @@ -16,6 +16,7 @@ export const route: Route = { | ||
| 16 | 16 | direction: "string", |
| 17 | 17 | keepAccess: "boolean", |
| 18 | 18 | 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)", | |
| 19 | 20 | for: "string", |
| 20 | 21 | fresh: "boolean", |
| 21 | 22 | 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>> = { | ||
| 59 | 59 | front: "feed", |
| 60 | 60 | search: "feed", |
| 61 | 61 | family: "forges", |
| 62 | plans: "forges", | |
| 62 | 63 | start: "forges", |
| 63 | 64 | cloud: "forges", |
| 64 | 65 | migrate: "forges", |
src/http/api/admin.ts +12
| @@ -17,6 +17,7 @@ import { DERIVED_WORDS, REASON_WORDS, changeCapacity, edgeOffDryRun, listCapacit | ||
| 17 | 17 | import { changeShield, lastShieldChange, shieldState } from "../../actions/shield.ts"; |
| 18 | 18 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts"; |
| 19 | 19 | import { changeLimit, listLimits } from "../../actions/limits.ts"; |
| 20 | import { changePlans } from "../../actions/plans.ts"; | |
| 20 | 21 | import { setAccountCeilings, setAccountGrant } from "../../actions/model_grant.ts"; |
| 21 | 22 | import { checkPinned, deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts"; |
| 22 | 23 | import { TARGETS } from "../../cloud/targets.ts"; |
| @@ -255,6 +256,17 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 255 | 256 | ); |
| 256 | 257 | }); |
| 257 | 258 | |
| 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 | ||
| 258 | 270 | // Per-account quotas (`settings/quotas.ts`), key by key: the REST twin of MCP `quotas`. |
| 259 | 271 | router.add("GET", "/api/v1/admin/quotas", (ctx) => { |
| 260 | 272 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
src/http/api/forges.ts +12
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { lendCredit } from "../../actions/child_credit.ts"; |
| 8 | import { readPlans } from "../../actions/plans.ts"; | |
| 8 | 9 | import { listChildren } from "../../actions/spawn.ts"; |
| 9 | 10 | import { usdWords } from "../../spawn/credit.ts"; |
| 10 | 11 | import { fundingOf, fundingWords, refreshFunding } from "../../spawn/funding.ts"; |
| @@ -30,7 +31,18 @@ function publicTable(ctx: Ctx, phase: string, view: () => unknown): Response { | ||
| 30 | 31 | }); |
| 31 | 32 | } |
| 32 | 33 | |
| 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 | ||
| 33 | 44 | export function forgeRoutes(router: Router<Handler>): void { |
| 45 | router.add("GET", "/api/v1/plans", plansAnswer); | |
| 34 | 46 | // What small, medium and large mean, and which templates a child may |
| 35 | 47 | // start from: public, and cacheable by ETag. |
| 36 | 48 | router.add("GET", "/api/v1/forges/sizes", (ctx) => publicTable(ctx, "sizes", sizesView)); |
src/http/mcp/tools/limits.ts +16 −2
| @@ -1,11 +1,13 @@ | ||
| 1 | 1 | import { changeLimit, listLimits } from "../../../actions/limits.ts"; |
| 2 | import { changePlans, readPlans } from "../../../actions/plans.ts"; | |
| 3 | import { MAX_PLANS, parsePlans } from "../../../settings/plans.ts"; | |
| 2 | 4 | import { LIMIT_KEYS } from "../../../settings/limits.ts"; |
| 3 | 5 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 4 | 6 | import { good, bad, type Tool } from "../shared.ts"; |
| 5 | 7 | |
| 6 | 8 | export const tool: Tool = { |
| 7 | 9 | 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", | |
| 9 | 11 | description: |
| 10 | 12 | "Every limit the owner can hit, value and bounds. `key`, `value` (\"\" restores) plan.", |
| 11 | 13 | inputSchema: { |
| @@ -13,6 +15,7 @@ export const tool: Tool = { | ||
| 13 | 15 | properties: { |
| 14 | 16 | key: { type: "string", enum: [...LIMIT_KEYS] }, |
| 15 | 17 | value: { type: "string", maxLength: 16 }, |
| 18 | plans: { type: "array", maxItems: MAX_PLANS, items: { type: "object" } }, | |
| 16 | 19 | dryRun: { type: "boolean" }, |
| 17 | 20 | }, |
| 18 | 21 | }, |
| @@ -21,8 +24,19 @@ export const tool: Tool = { | ||
| 21 | 24 | run(ctx, user, args) { |
| 22 | 25 | const listed = listLimits(ctx.app, user); |
| 23 | 26 | 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 | } | |
| 24 | 38 | 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) }); | |
| 26 | 40 | const value = typeof args["value"] === "string" ? args["value"] : ""; |
| 27 | 41 | const now = listed.value.limits.find((l) => l.key === key); |
| 28 | 42 | 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 | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | 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", | |
| 8 | 8 | description: |
| 9 | 9 | "Spawn a child forge. Plans first.", |
| 10 | 10 | inputSchema: { |
| @@ -38,6 +38,10 @@ export const tool: Tool = { | ||
| 38 | 38 | enum: ["small", "medium", "large"], |
| 39 | 39 | description: "Recorded only.", |
| 40 | 40 | }, |
| 41 | plan: { | |
| 42 | type: "string", | |
| 43 | description: "Plan key: size and credit. Paid: admin only.", | |
| 44 | }, | |
| 41 | 45 | template: { |
| 42 | 46 | type: "string", |
| 43 | 47 | description: "An id from `templates`.", |
src/http/server.ts +2
| @@ -33,6 +33,7 @@ import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts"; | ||
| 33 | 33 | import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts"; |
| 34 | 34 | import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts"; |
| 35 | 35 | import { agentStartSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 36 | import { plansSet } from "../web/settings/plans.ts"; | |
| 36 | 37 | import { allFlowsPage } from "../web/pages/flows-all.ts"; |
| 37 | 38 | import { measureSubmit } from "./measure.ts"; |
| 38 | 39 | import { cspReportSubmit } from "./csp_report.ts"; |
| @@ -1056,6 +1057,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1056 | 1057 | router.add("GET", "/admin/flows", allFlowsPage); |
| 1057 | 1058 | router.add("POST", "/admin/limits", limitsSet); |
| 1058 | 1059 | router.add("POST", "/admin/quotas", quotasSet); |
| 1060 | router.add("POST", "/admin/plans", plansSet); | |
| 1059 | 1061 | router.add("POST", "/admin/signup", signupSet); |
| 1060 | 1062 | router.add("POST", "/admin/agent-start", agentStartSet); |
| 1061 | 1063 | 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 { | ||
| 19 | 19 | readonly capUsd: number; |
| 20 | 20 | readonly spentUsd: number; |
| 21 | 21 | 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; | |
| 22 | 24 | } |
| 23 | 25 | |
| 24 | 26 | export const CREDIT_MAX_USD = 100_000; |
| @@ -27,12 +29,17 @@ const sha = (s: string): string => createHash("sha256").update(s).digest("hex"); | ||
| 27 | 29 | const path = (app: App, name: string): string => join(app.config.dataDir, "credit", `${name}.json`); |
| 28 | 30 | const cents = (n: number): number => Math.round(n * 100) / 100; |
| 29 | 31 | |
| 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 { | |
| 31 | 36 | if (!isChildName(name)) return undefined; |
| 32 | 37 | try { |
| 33 | 38 | 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; | |
| 35 | 40 | 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) }; | |
| 36 | 43 | return { name, capUsd, spentUsd, tokenHash }; |
| 37 | 44 | } catch { |
| 38 | 45 | return undefined; |
| @@ -59,16 +66,17 @@ export function setCap( | ||
| 59 | 66 | usd: number, |
| 60 | 67 | childHolds: boolean, |
| 61 | 68 | handOff: (token: string, credit: Credit) => boolean, |
| 69 | monthly = false, | |
| 62 | 70 | ): Credit | "unreachable" | undefined { |
| 63 | 71 | if (!isChildName(name) || !(Number.isFinite(usd) && usd > 0 && usd <= CREDIT_MAX_USD)) return undefined; |
| 64 | 72 | const before = readCredit(app, name); |
| 65 | 73 | if (before !== undefined && childHolds) { |
| 66 | const credit = { ...before, capUsd: cents(usd) }; | |
| 74 | const credit = { ...before, capUsd: cents(usd), ...(monthly ? { period: periodOf() } : {}) }; | |
| 67 | 75 | keep(app, credit); |
| 68 | 76 | return credit; |
| 69 | 77 | } |
| 70 | 78 | 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() } : {}) }; | |
| 72 | 80 | if (!handOff(token, credit)) return "unreachable"; |
| 73 | 81 | keep(app, credit); |
| 74 | 82 | return credit; |
src/spawn/door.ts +16 −1
| @@ -36,6 +36,7 @@ import { ANON_SPAWNS_PER_DAY, checkAnonSpawn, noteAnonSpawn } from "./rate.ts"; | ||
| 36 | 36 | import { readdirSync, readFileSync } from "node:fs"; |
| 37 | 37 | import { readdir, stat } from "node:fs/promises"; |
| 38 | 38 | import { join } from "node:path"; |
| 39 | import { FREE, getPlan, getPlans, PAID_PLAN_ADMIN_ONLY } from "../settings/plans.ts"; | |
| 39 | 40 | |
| 40 | 41 | export interface Door { |
| 41 | 42 | readonly status: number; |
| @@ -256,6 +257,19 @@ export async function spawnDoor( | ||
| 256 | 257 | } |
| 257 | 258 | } |
| 258 | 259 | 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 | } | |
| 259 | 273 | const input = { |
| 260 | 274 | name: raw["name"], |
| 261 | 275 | org: raw["name"], |
| @@ -269,8 +283,9 @@ export async function spawnDoor( | ||
| 269 | 283 | budgetUsd: raw["budgetUsd"], |
| 270 | 284 | fresh: raw["fresh"], |
| 271 | 285 | followMode: raw["followMode"], |
| 272 | size: raw["size"], | |
| 286 | size, | |
| 273 | 287 | template: raw["template"], |
| 288 | ...(planKey === undefined ? {} : { plan: planKey }), | |
| 274 | 289 | ...(anon ? {} : { for: raw["for"] }), |
| 275 | 290 | }; |
| 276 | 291 |
src/spawn/plan.ts +13
| @@ -35,6 +35,7 @@ export const MAX_BUDGET_USD = 1000; | ||
| 35 | 35 | /** One line saying what a fresh app is. */ |
| 36 | 36 | export const MAX_FRESH_CHARS = 200; |
| 37 | 37 | const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/; |
| 38 | const PLAN_KEY = /^[a-z][a-z0-9-]{0,19}$/; | |
| 38 | 39 | const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/; |
| 39 | 40 | |
| 40 | 41 | /** An app the viewer can see, as the caller found it. */ |
| @@ -75,6 +76,8 @@ export interface SpawnInput { | ||
| 75 | 76 | readonly size?: unknown; |
| 76 | 77 | /** Which template the child's apps start from (`spawn/templates.ts`); optional. */ |
| 77 | 78 | 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; | |
| 78 | 81 | } |
| 79 | 82 | |
| 80 | 83 | /** What the parent has to offer. Extra fields are ignored, never copied. */ |
| @@ -119,6 +122,8 @@ export interface SpawnPlan { | ||
| 119 | 122 | readonly size?: Size; |
| 120 | 123 | /** The template the child starts from, present only when chosen and not `blank`. */ |
| 121 | 124 | 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; | |
| 122 | 127 | } |
| 123 | 128 | |
| 124 | 129 | export interface DryRun { |
| @@ -276,6 +281,13 @@ export function planSpawn( | ||
| 276 | 281 | if (parsed.value !== DEFAULT_TEMPLATE) template = parsed.value; |
| 277 | 282 | } |
| 278 | 283 | |
| 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 | ||
| 279 | 291 | // `forWhom` only when said, so a plan without it hashes as it always did. |
| 280 | 292 | return ok({ |
| 281 | 293 | name, |
| @@ -291,6 +303,7 @@ export function planSpawn( | ||
| 291 | 303 | ...(followMode === undefined ? {} : { followMode }), |
| 292 | 304 | ...(size === undefined ? {} : { size }), |
| 293 | 305 | ...(template === undefined ? {} : { template }), |
| 306 | ...(plan === undefined ? {} : { plan }), | |
| 294 | 307 | }); |
| 295 | 308 | } |
| 296 | 309 |
src/spawn/sleep.ts +34
| @@ -134,6 +134,40 @@ export function heldByPerson(dataDir: string, ownerId: string): number { | ||
| 134 | 134 | |
| 135 | 135 | const MB = 1_000_000; |
| 136 | 136 | |
| 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 | ||
| 137 | 171 | /** |
| 138 | 172 | * Why this person may not make another forge, or undefined: one sentence |
| 139 | 173 | * 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"; | ||
| 46 | 46 | import { entry as claimStart } from "./claim_start.ts"; |
| 47 | 47 | import { entry as invites } from "./invites.ts"; |
| 48 | 48 | import { entry as limits } from "./limits.ts"; |
| 49 | import { entry as plans } from "./plans.ts"; | |
| 49 | 50 | import { entry as economics } from "./economics.ts"; |
| 50 | 51 | import { entry as money } from "./money.ts"; |
| 51 | 52 | import { entry as loopStalls } from "./loopstalls.ts"; |
| @@ -129,6 +130,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 129 | 130 | people, |
| 130 | 131 | quotas, |
| 131 | 132 | limits, |
| 133 | plans, | |
| 132 | 134 | economics, |
| 133 | 135 | money, |
| 134 | 136 | 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"; | ||
| 22 | 22 | import { html, when, type Html } from "../html.ts"; |
| 23 | 23 | import { flashTo, page } from "../layout.ts"; |
| 24 | 24 | import { btn, confirmAction, icon, row, time } from "../ui.ts"; |
| 25 | import { plansRow } from "../settings/plans.ts"; | |
| 25 | 26 | import { accountSections } from "./origins.ts"; |
| 26 | 27 | |
| 27 | 28 | // GET /admin/limits |
| @@ -196,7 +197,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 196 | 197 | const body = html`<p class="small muted mb"> |
| 197 | 198 | Every limit you can hit, and why. A change applies to the next request that meets it; nothing needs a restart. |
| 198 | 199 | </p> |
| 199 | <dl class="status-rows">${signupRow}${agentStartRow}${inviteRow}${quotaRow}</dl> | |
| 200 | <dl class="status-rows">${signupRow}${agentStartRow}${inviteRow}${quotaRow}${plansRow(ctx)}</dl> | |
| 200 | 201 | ${historyBlock("Invite links made", inviteRows)} |
| 201 | 202 | <section class="settings-block mt"> |
| 202 | 203 | <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", () => { | ||
| 147 | 147 | } |
| 148 | 148 | expect(now()).toBe(before); |
| 149 | 149 | }); |
| 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 | }); | |
| 150 | 180 | }); |
test/palette-roster.test.ts +1
| @@ -145,6 +145,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 145 | 145 | ["people", "People", "who is a guest, a member or an admin; promote or demote"], |
| 146 | 146 | ["quotas", "Account quotas", "what one account may own, and who changed it"], |
| 147 | 147 | ["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"], | |
| 148 | 149 | ["economics", "Economics", "the profit slider's position, the margin, the owned-node label and the prices; change one"], |
| 149 | 150 | ["flows-all", "Flows across the forge", "every repository's flow lines, by kind and stale"], |
| 150 | 151 | ["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 | }); |
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.