Public: anyone can read this; the crew can change it.
The home page sells forges today: three plans, each with a button to its paym… #11151
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11149-the-home-page-sells-forges-today-three-p → main 8 commits fork 2 threads · 2 unresolved
17 files
changed +614
−29
docs/MCP.md +1 −1
| @@ -194,7 +194,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 194 | 194 | | `sessions` | `list`, `end`, `end_idle`, `signins`, `shells` | Where you are signed in: your tokens and browsers, never a secret; `username` for a person as owner; action end + id signs a browser out | 385 | |
| 195 | 195 | | `sever` | — | Unlink a spawned forge from its parent (owner of its `forge` repo only) | 214 | |
| 196 | 196 | | `share` | — | Share a feed item: no `to` tells the app's crew and owners, else followers; budgeted | 258 | |
| 197 | | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 291 | | |
| 197 | | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 324 | | |
| 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 | 200 | | `spawn` | — | Spawn a child forge | 910 | |
docs/features/billing-links.md added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | # Buy links | |
| 2 | ||
| 3 | Where a buyer pays for a hosted forge (#11149). One https link per size (small, medium, large), empty by default. | |
| 4 | ||
| 5 | ## Set them | |
| 6 | ||
| 7 | - Web: `/admin/limits`, the **Buy links** row; one address per size, empty clears it. | |
| 8 | - Terminal: the palette entry **Buy links**; enter on a size asks for its address. | |
| 9 | - API: `PATCH /api/v1/admin/settings` with `{"billingLinks": {"small": "https://…"}}` (admin token); `GET` on the same path reads them. | |
| 10 | - MCP: the `signup` tool with `billingLinks`; it plans first, send `dryRun: false` with an admin token to apply. Without `mode` or `billingLinks` it reads them. | |
| 11 | ||
| 12 | An address is https, at most 300 bytes, with no credentials, spaces or quotes. Only a forge admin who is a person may read or change them; each change is kept with who and when. | |
| 13 | ||
| 14 | ## What the home shows | |
| 15 | ||
| 16 | With no links, the signed-out home says "Hosted forges are coming." and nothing is sold. With any link set, the home's **Get your own forge** section lists one row per size that has a link: the size's CPU, memory and disk, the monthly price and crew credit, and a **Get a forge** button to the link. "Hosted forges are coming" goes. The prices come from `src/settings/plans.ts`; the buyer is told the forge is made after payment and the claim link arrives by email. | |
| 17 | ||
| 18 | ## What llms.txt changes | |
| 19 | ||
| 20 | The "Get your own forge" line says hosted forges are free within limits "while hosting is not sold" when no link is set; with any link set it says paid plans are on the home page and the other way is free within limits. |
src/actions/signup.ts +21
| @@ -14,6 +14,7 @@ import { | ||
| 14 | 14 | } from "../settings/signup.ts"; |
| 15 | 15 | import { getAgentStart, parseAgentStart, setAgentStart, type AgentStartMode } from "../settings/agent_start.ts"; |
| 16 | 16 | import { forgeSource, parseForgeSource, setForgeSource, storedForgeSource } from "../settings/forge_source.ts"; |
| 17 | import { getBillingLinks, parseBillingLinks, setBillingLinks, type BillingLinks } from "../settings/billing.ts"; | |
| 17 | 18 | import type { User } from "../core/users.ts"; |
| 18 | 19 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 19 | 20 | |
| @@ -89,3 +90,23 @@ export function changeAgentStart( | ||
| 89 | 90 | setAgentStart(app.db, mode.value, who.value.id); |
| 90 | 91 | return ok(mode.value); |
| 91 | 92 | } |
| 93 | ||
| 94 | /** The billing links (`settings/billing.ts`), for a forge admin who is a person. */ | |
| 95 | export function readBillingLinks(app: App, actor: User | undefined): Result<BillingLinks, "not_found"> { | |
| 96 | const who = admin(actor); | |
| 97 | return who.ok ? ok(getBillingLinks(app.db)) : who; | |
| 98 | } | |
| 99 | ||
| 100 | /** Replace the links: an object of sizes to https URLs; a size left out or empty has none. */ | |
| 101 | export function changeBillingLinks( | |
| 102 | app: App, | |
| 103 | actor: User | undefined, | |
| 104 | raw: unknown, | |
| 105 | ): Result<BillingLinks, "not_found" | "invalid_billing_links"> { | |
| 106 | const who = admin(actor); | |
| 107 | if (!who.ok) return who; | |
| 108 | const links = parseBillingLinks(raw); | |
| 109 | if (!links.ok) return links; | |
| 110 | setBillingLinks(app.db, links.value, who.value.id); | |
| 111 | return ok(links.value); | |
| 112 | } |
src/core/users.ts +11 −5
| @@ -12,7 +12,7 @@ import { id } from "../kernel/id.ts"; | ||
| 12 | 12 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 13 | 13 | import { admitSignin } from "./signin-window.ts"; |
| 14 | 14 | import { waitingCount, WAITING_CAP } from "./requests.ts"; |
| 15 | import { getSignup } from "../settings/signup.ts"; | |
| 15 | import { getSignup, type SignupMode } from "../settings/signup.ts"; | |
| 16 | 16 | import { inviteOpen, redeemInvite, reopenInvite } from "./invites.ts"; |
| 17 | 17 | import type { SignupSlot } from "../actions/signup-window.ts"; |
| 18 | 18 | |
| @@ -179,10 +179,7 @@ export type CreateUserInput = CreateUserFields & AccountOrigin; | ||
| 179 | 179 | * (`inviteNeeded` says when it must be spent). |
| 180 | 180 | */ |
| 181 | 181 | export function newAccountsClosed(db: Database, invite?: string): boolean { |
| 182 | // In `request` the door shuts once the Waiting list is full, for anyone an open invite does not vouch for. | |
| 183 | if (getSignup(db) === "request") return waitingCount(db) >= WAITING_CAP && !(invite !== undefined && inviteOpen(db, invite)); | |
| 184 | if (!inviteNeeded(db)) return false; | |
| 185 | return !(getSignup(db) === "invite" && invite !== undefined && inviteOpen(db, invite)); | |
| 182 | return accountsClosedIn(db, getSignup(db), invite); | |
| 186 | 183 | } |
| 187 | 184 | |
| 188 | 185 | /** |
| @@ -194,6 +191,15 @@ function inviteNeeded(db: Database): boolean { | ||
| 194 | 191 | return mode === "invite" || mode === "closed"; |
| 195 | 192 | } |
| 196 | 193 | |
| 194 | /** `newAccountsClosed` for a mode the caller has read already, so one request reads the setting once. */ | |
| 195 | export function accountsClosedIn(db: Database, mode: SignupMode, invite?: string): boolean { | |
| 196 | // In `request` the door shuts once the Waiting list is full, for anyone an open invite does not vouch for. | |
| 197 | if (mode === "request") return waitingCount(db) >= WAITING_CAP && !(invite !== undefined && inviteOpen(db, invite)); | |
| 198 | // Open lets anyone in; invite and closed need an invite, and only invite takes one. | |
| 199 | if (mode !== "invite" && mode !== "closed") return false; | |
| 200 | return !(mode === "invite" && invite !== undefined && inviteOpen(db, invite)); | |
| 201 | } | |
| 202 | ||
| 197 | 203 | interface CreateUserFields { |
| 198 | 204 | username: string; |
| 199 | 205 | email: string; |
src/http/api/admin.ts +14 −6
| @@ -25,7 +25,8 @@ import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; | ||
| 25 | 25 | import { allowRequest, declineRequest, listRequests } from "../../actions/requests.ts"; |
| 26 | 26 | import { setRole } from "../../actions/roles.ts"; |
| 27 | 27 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; |
| 28 | import { changeForgeSource, changeSignup, readForgeSource, readSignup } from "../../actions/signup.ts"; | |
| 28 | import { BILLING_WORDS } from "../../settings/billing.ts"; | |
| 29 | import { changeBillingLinks, changeForgeSource, changeSignup, readBillingLinks, readForgeSource, readSignup } from "../../actions/signup.ts"; | |
| 29 | 30 | import { SPEND_WORDS } from "../../actions/spend_words.ts"; |
| 30 | 31 | import { TEAM_SAYS, changeTeam, readTeams } from "../../actions/teams.ts"; |
| 31 | 32 | import { SIGNUP_WORDS } from "../../core/arg_words.ts"; |
| @@ -327,14 +328,15 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 327 | 328 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 328 | 329 | const denied = accountAdmin(ctx, "ro"); |
| 329 | 330 | if (denied) return denied; |
| 330 | const { read, signup, source } = timePhase(ctx.request, "settings", () => ({ | |
| 331 | const { read, signup, source, billing } = timePhase(ctx.request, "settings", () => ({ | |
| 332 | billing: readBillingLinks(ctx.app, ctx.auth.user), | |
| 331 | 333 | read: readQuotas(ctx.app, ctx.auth.user), |
| 332 | 334 | signup: readSignup(ctx.app, ctx.auth.user), |
| 333 | 335 | source: readForgeSource(ctx.app, ctx.auth.user), |
| 334 | 336 | })); |
| 335 | 337 | return timePhase(ctx.request, "render", () => |
| 336 | read.ok && signup.ok && source.ok | |
| 337 | ? json({ ...read.value, ...signup.value, ...source.value, signupChanges: signup.value.changes, changes: read.value.changes }) | |
| 338 | read.ok && signup.ok && source.ok && billing.ok | |
| 339 | ? json({ billingLinks: billing.value, ...read.value, ...signup.value, ...source.value, signupChanges: signup.value.changes, changes: read.value.changes }) | |
| 338 | 340 | : notFound()); |
| 339 | 341 | }); |
| 340 | 342 | |
| @@ -342,8 +344,14 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 342 | 344 | const denied = accountAdmin(ctx, "admin"); |
| 343 | 345 | if (denied) return denied; |
| 344 | 346 | const input = await body(ctx.request); |
| 345 | const sent = [input.signup, input.quotas, input.forgeRepo].filter((v) => v !== undefined).length; | |
| 346 | if (sent > 1) return apiError("invalid_body", 422, "send one of signup, quotas or forgeRepo"); | |
| 347 | const sent = [input.signup, input.quotas, input.forgeRepo, input.billingLinks].filter((v) => v !== undefined).length; | |
| 348 | if (sent > 1) return apiError("invalid_body", 422, "send one of signup, quotas, forgeRepo or billingLinks"); | |
| 349 | if (input.billingLinks !== undefined) { | |
| 350 | const set = changeBillingLinks(ctx.app, ctx.auth.user, input.billingLinks); | |
| 351 | if (set.ok) return json({ billingLinks: set.value }); | |
| 352 | if (set.error === "not_found") return notFound(); | |
| 353 | return apiError(set.error, 422, BILLING_WORDS); | |
| 354 | } | |
| 347 | 355 | if (input.forgeRepo !== undefined) { |
| 348 | 356 | const set = changeForgeSource(ctx.app, ctx.auth.user, input.forgeRepo); |
| 349 | 357 | if (set.ok) return json({ forgeRepo: set.value }); |
src/http/mcp/tools/signup.ts +16 −3
| @@ -1,18 +1,20 @@ | ||
| 1 | import { changeSignup, readSignup } from "../../../actions/signup.ts"; | |
| 1 | import { changeBillingLinks, changeSignup, readSignup, readBillingLinks } from "../../../actions/signup.ts"; | |
| 2 | 2 | import { SIGNUP_MODES } from "../../../settings/signup.ts"; |
| 3 | 3 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 4 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| 5 | import { BILLING_WORDS } from "../../../settings/billing.ts"; | |
| 5 | 6 | import { SIGNUP_WORDS } from "../../../core/arg_words.ts"; |
| 6 | 7 | |
| 7 | 8 | export const tool: Tool = { |
| 8 | 9 | name: "signup", |
| 9 | doc: "`{ mode?, dryRun? }`: who may sign up: `open` (default), `invite` (only a person with an open invite link) or `closed` (no new accounts); without `mode` it reads the setting, with `mode` it plans first (`dryRun` is true unless `false`), and applying needs an admin token", | |
| 10 | doc: "`{ mode?, billingLinks?, dryRun? }`: who may sign up: `open` (default), `invite` (only a person with an open invite link) or `closed` (no new accounts); without `mode` it reads the setting, with `mode` it plans first (`dryRun` is true unless `false`); `billingLinks` `{small?, medium?, large?}` (https URLs) sets the home page's buy links; applying needs an admin token", | |
| 10 | 11 | description: |
| 11 | 12 | "Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account", |
| 12 | 13 | inputSchema: { |
| 13 | 14 | type: "object", |
| 14 | 15 | properties: { |
| 15 | 16 | mode: { type: "string", enum: [...SIGNUP_MODES] }, |
| 17 | billingLinks: { type: "object" }, | |
| 16 | 18 | dryRun: { type: "boolean" }, |
| 17 | 19 | }, |
| 18 | 20 | }, |
| @@ -21,7 +23,18 @@ export const tool: Tool = { | ||
| 21 | 23 | run(ctx, user, args) { |
| 22 | 24 | const read = readSignup(ctx.app, user); |
| 23 | 25 | if (!read.ok) return bad("not_found", "no such tool here"); |
| 24 | if (args["mode"] === undefined) return good(read.value); | |
| 26 | if (args["billingLinks"] !== undefined) { | |
| 27 | if (args["dryRun"] !== false) | |
| 28 | return good({ says: "Would set the billing links. Send dryRun: false to change them." }); | |
| 29 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 30 | return bad("forbidden", "changing billing links needs an admin-scope token"); | |
| 31 | const set = changeBillingLinks(ctx.app, user, args["billingLinks"]); | |
| 32 | return set.ok ? good({ says: "Billing links saved.", billingLinks: set.value }) : bad(set.error, BILLING_WORDS); | |
| 33 | } | |
| 34 | if (args["mode"] === undefined) { | |
| 35 | const links = readBillingLinks(ctx.app, user); | |
| 36 | return links.ok ? good({ ...read.value, billingLinks: links.value }) : bad("not_found", "no such tool here"); | |
| 37 | } | |
| 25 | 38 | if (!(SIGNUP_MODES as readonly unknown[]).includes(args["mode"])) |
| 26 | 39 | return bad("invalid_signup", SIGNUP_WORDS); |
| 27 | 40 | if (args["dryRun"] !== false) |
src/http/server.ts +2 −1
| @@ -32,7 +32,7 @@ import { adminDataPage, appDataPage, dataPage } from "../web/pages/data.ts"; | ||
| 32 | 32 | 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 | import { agentStartSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; | |
| 35 | import { agentStartSet, billingLinksSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; | |
| 36 | 36 | import { plansSet } from "../web/settings/plans.ts"; |
| 37 | 37 | import { allFlowsPage } from "../web/pages/flows-all.ts"; |
| 38 | 38 | import { measureSubmit } from "./measure.ts"; |
| @@ -1060,6 +1060,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1060 | 1060 | router.add("POST", "/admin/plans", plansSet); |
| 1061 | 1061 | router.add("POST", "/admin/signup", signupSet); |
| 1062 | 1062 | router.add("POST", "/admin/agent-start", agentStartSet); |
| 1063 | router.add("POST", "/admin/billing-links", billingLinksSet); | |
| 1063 | 1064 | router.add("POST", "/admin/invites", inviteMint); |
| 1064 | 1065 | router.add("POST", "/admin/invites/:id/revoke", inviteRevoke); |
| 1065 | 1066 |
src/settings/billing.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * Where a buyer pays for a hosted forge, one link a size (#11149). Stored as | |
| 3 | * JSON in `forge_setting` under `billing.links`, every change kept with who | |
| 4 | * and when; empty by default, and a child forge's database starts without it, | |
| 5 | * so only the forge that set links shows them. A bridge until the forge's own | |
| 6 | * checkout replaces it: the buyer pays at the link, the operator makes the forge. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 11 | import { SIZES, type Size } from "../spawn/sizes.ts"; | |
| 12 | import { SIGNUP_DEFAULT, parseSignup, type SignupMode } from "./signup.ts"; | |
| 13 | import { writeSetting } from "./limits.ts"; | |
| 14 | ||
| 15 | export const BILLING_SIZES = ["small", "medium", "large"] as const satisfies readonly Size[]; | |
| 16 | export type BillingLinks = Partial<Record<Size, string>>; | |
| 17 | ||
| 18 | const STORE_KEY = "billing.links"; | |
| 19 | const MAX_URL = 300; | |
| 20 | ||
| 21 | export const BILLING_WORDS = "billing links are an object of small, medium and large, each an https URL or empty"; | |
| 22 | ||
| 23 | /** One https URL with no credentials and no spaces, or the empty string. */ | |
| 24 | function parseLink(raw: unknown): string | undefined { | |
| 25 | if (raw === null || raw === "") return ""; | |
| 26 | if (typeof raw !== "string") return undefined; | |
| 27 | const s = raw.trim(); | |
| 28 | if (s.length > MAX_URL || /[\s"'<>\\]/.test(s)) return undefined; | |
| 29 | try { | |
| 30 | const u = new URL(s); | |
| 31 | return u.protocol === "https:" && u.username === "" && u.password === "" && u.hostname !== "" ? s : undefined; | |
| 32 | } catch { | |
| 33 | return undefined; | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | /** Links from untrusted input: only the three sizes, empty ones dropped; anything else is refused. */ | |
| 38 | export function parseBillingLinks(raw: unknown): Result<BillingLinks, "invalid_billing_links"> { | |
| 39 | if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return err("invalid_billing_links"); | |
| 40 | const out: BillingLinks = {}; | |
| 41 | for (const [key, value] of Object.entries(raw)) { | |
| 42 | if (!(BILLING_SIZES as readonly string[]).includes(key)) return err("invalid_billing_links"); | |
| 43 | const link = parseLink(value); | |
| 44 | if (link === undefined) return err("invalid_billing_links"); | |
| 45 | if (link !== "") out[key as Size] = link; | |
| 46 | } | |
| 47 | return ok(out); | |
| 48 | } | |
| 49 | ||
| 50 | export function getBillingLinks(db: Database): BillingLinks { | |
| 51 | const row = db | |
| 52 | .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1") | |
| 53 | .get(STORE_KEY); | |
| 54 | return row === null ? {} : decodeLinks(row.value); | |
| 55 | } | |
| 56 | ||
| 57 | function decodeLinks(raw: string): BillingLinks { | |
| 58 | try { | |
| 59 | const parsed = parseBillingLinks(JSON.parse(raw)); | |
| 60 | return parsed.ok ? parsed.value : {}; | |
| 61 | } catch { | |
| 62 | return {}; | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | const ENCODE = (links: BillingLinks): string => | |
| 67 | JSON.stringify(Object.fromEntries(BILLING_SIZES.flatMap((s) => (links[s] === undefined ? [] : [[s, links[s]]])))); | |
| 68 | ||
| 69 | /** Replace the links and keep the change; the same links again keep nothing; none at all clears the row. */ | |
| 70 | export function setBillingLinks(db: Database, value: BillingLinks, actorId: string): void { | |
| 71 | const from = ENCODE(getBillingLinks(db)); | |
| 72 | const to = ENCODE(value); | |
| 73 | if (from === to) return; | |
| 74 | writeSetting(db, STORE_KEY, to === "{}" ? null : to, from, to, actorId); | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Who may sign up and the buy links, in one query: the home page needs both | |
| 79 | * and each read is a statement on a page held to a statement budget. | |
| 80 | */ | |
| 81 | export function getSignupAndLinks(db: Database): { mode: SignupMode; links: BillingLinks } { | |
| 82 | let mode: SignupMode = SIGNUP_DEFAULT; | |
| 83 | let links: BillingLinks = {}; | |
| 84 | const rows = db | |
| 85 | .query<{ key: string; value: string }, [string, string]>("SELECT key, value FROM forge_setting WHERE key IN (?1, ?2)") | |
| 86 | .all("signup", STORE_KEY); | |
| 87 | for (const row of rows) { | |
| 88 | if (row.key === "signup") { | |
| 89 | const m = parseSignup(row.value); | |
| 90 | if (m.ok) mode = m.value; | |
| 91 | } else links = decodeLinks(row.value); | |
| 92 | } | |
| 93 | return { mode, links }; | |
| 94 | } | |
| 95 | ||
| 96 | /** The size in plain words, from the one table of sizes: "1 CPU, 2 GB memory, 2 GB disk". */ | |
| 97 | export function sizeWords(size: Size): string { | |
| 98 | const s = SIZES[size]; | |
| 99 | const gb = (mib: number): string => `${mib / 1024} GB`; | |
| 100 | return `${s.cpu} ${s.cpu === 1 ? "CPU" : "CPUs"}, ${gb(s.memoryMiB)} memory, ${gb(s.diskMiB)} disk`; | |
| 101 | } |
src/tui/screens/billinglinks.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** | |
| 2 | * Buy links in the terminal, a forge admin's (#11149): one row a size with its | |
| 3 | * link, as Buy links on /admin/limits shows them. Enter on a row asks for an | |
| 4 | * https address (empty clears it) and saves it after the same checks as the | |
| 5 | * page. Reading and changing go through `readBillingLinks`/`changeBillingLinks`, | |
| 6 | * so the gate is theirs: an admin who is a person; anyone else sees nothing. | |
| 7 | */ | |
| 8 | ||
| 9 | import { changeBillingLinks, readBillingLinks } from "../../actions/signup.ts"; | |
| 10 | import { BILLING_SIZES } from "../../settings/billing.ts"; | |
| 11 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 12 | import { binding } from "../app/keys.ts"; | |
| 13 | import { style } from "../term/style.ts"; | |
| 14 | import { truncate } from "../term/width.ts"; | |
| 15 | import type { Session } from "../session.ts"; | |
| 16 | import { fill, len, split } from "../ui/layout.ts"; | |
| 17 | import { ListState, drawList, promptScreen, type ListRow } from "./common.ts"; | |
| 18 | import type { PaletteEntry } from "./entries.ts"; | |
| 19 | ||
| 20 | export function billingLinksScreen(f: Session, ctx: Ctx): Screen { | |
| 21 | const state = new ListState(); | |
| 22 | let note = ""; | |
| 23 | const read = () => readBillingLinks(f.app, f.user); | |
| 24 | ||
| 25 | const save = (size: (typeof BILLING_SIZES)[number], raw: string): string | undefined => { | |
| 26 | const now = read(); | |
| 27 | if (!now.ok) return "You cannot change buy links."; | |
| 28 | const next: Record<string, string> = { ...now.value, [size]: raw.trim() }; | |
| 29 | const changed = changeBillingLinks(f.app, f.user, next); | |
| 30 | if (!changed.ok) return changed.error === "not_found" ? "You cannot change buy links." : "A buy link is an https address, or empty."; | |
| 31 | note = `${size}: ${changed.value[size] ?? "no link"}.`; | |
| 32 | ctx.invalidate(); | |
| 33 | return undefined; | |
| 34 | }; | |
| 35 | ||
| 36 | const rows = (): ListRow[] => { | |
| 37 | const r = read(); | |
| 38 | return BILLING_SIZES.map((z) => ({ title: z, secondary: r.ok ? (r.value[z] ?? "no link") : "" })); | |
| 39 | }; | |
| 40 | ||
| 41 | const screen: Screen = { | |
| 42 | title: "Buy links", | |
| 43 | keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "set the link")], | |
| 44 | render(area, s) { | |
| 45 | const c = ctx.theme.c; | |
| 46 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 47 | const text = note !== "" ? note : read().ok ? "With any link set, the home page offers paid forges. Enter sets one." : "Buy links are set by a forge admin."; | |
| 48 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 49 | drawList(s, body!, c, state, rows()); | |
| 50 | }, | |
| 51 | key(e) { | |
| 52 | note = ""; | |
| 53 | if (state.key(e, BILLING_SIZES.length)) return true; | |
| 54 | if (e.ctrl || e.alt || !read().ok || e.key !== "enter") return false; | |
| 55 | const size = BILLING_SIZES[state.index]; | |
| 56 | if (size === undefined) return false; | |
| 57 | const now = read(); | |
| 58 | ctx.push( | |
| 59 | promptScreen(ctx, screen, { | |
| 60 | title: `Buy link: ${size}`, | |
| 61 | label: "https address, empty for none", | |
| 62 | value: now.ok ? (now.value[size] ?? "") : "", | |
| 63 | onSubmit: (v) => save(size, v), | |
| 64 | }), | |
| 65 | ); | |
| 66 | return true; | |
| 67 | }, | |
| 68 | }; | |
| 69 | return screen; | |
| 70 | } | |
| 71 | ||
| 72 | /** Buy links, as /admin/limits has them: an admin who is a person. */ | |
| 73 | export const entry: PaletteEntry = { | |
| 74 | id: "billing-links", | |
| 75 | title: "Buy links", | |
| 76 | hint: "where each hosted plan is paid for, shown on the home page", | |
| 77 | after: ["limits", "invites"], | |
| 78 | offered: (session) => session.user?.isAdmin === true && session.user.kind === "user", | |
| 79 | run: (session, ctx) => ctx.push(billingLinksScreen(session, ctx)), | |
| 80 | }; |
src/tui/screens/entries.ts +2
| @@ -45,6 +45,7 @@ import { entry as forges } from "./forges.ts"; | ||
| 45 | 45 | 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 | import { entry as billingLinks } from "./billinglinks.ts"; | |
| 48 | 49 | import { entry as limits } from "./limits.ts"; |
| 49 | 50 | import { entry as plans } from "./plans.ts"; |
| 50 | 51 | import { entry as economics } from "./economics.ts"; |
| @@ -144,6 +145,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 144 | 145 | agents, |
| 145 | 146 | archived, |
| 146 | 147 | invites, |
| 148 | billingLinks, | |
| 147 | 149 | claimStart, |
| 148 | 150 | requests, |
| 149 | 151 | bulk, |
src/web/pages/admin-limits.ts +38 −2
| @@ -11,7 +11,8 @@ import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; | ||
| 11 | 11 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts"; |
| 12 | 12 | import { INVITE_NOT_OPEN } from "../../actions/sever_invite_words.ts"; |
| 13 | 13 | import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.ts"; |
| 14 | import { changeAgentStart, changeSignup, readAgentStart, readSignup } from "../../actions/signup.ts"; | |
| 14 | import { changeAgentStart, changeBillingLinks, changeSignup, readAgentStart, readBillingLinks, readSignup } from "../../actions/signup.ts"; | |
| 15 | import { BILLING_SIZES } from "../../settings/billing.ts"; | |
| 15 | 16 | import { AGENT_START_MODES } from "../../settings/agent_start.ts"; |
| 16 | 17 | import { SIGNUP_MEANS, SIGNUP_MODES } from "../../settings/signup.ts"; |
| 17 | 18 | import { QUOTA_KEYS } from "../../settings/quotas.ts"; |
| @@ -33,6 +34,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 33 | 34 | const quotas = timePhase(ctx.request, "quotas", () => readQuotas(ctx.app, ctx.auth.user)); |
| 34 | 35 | const signup = timePhase(ctx.request, "signup", () => readSignup(ctx.app, ctx.auth.user)); |
| 35 | 36 | const agentStart = readAgentStart(ctx.app, ctx.auth.user); |
| 37 | const billing = readBillingLinks(ctx.app, ctx.auth.user); | |
| 36 | 38 | const invites = timePhase(ctx.request, "invites", () => listInviteLinks(ctx.app, ctx.auth.user)); |
| 37 | 39 | const rendering = performance.now(); |
| 38 | 40 | const { limits, changes } = listed.value; |
| @@ -155,6 +157,20 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 155 | 157 | </dd> |
| 156 | 158 | <dd class="status-act"></dd> |
| 157 | 159 | </div>`; |
| 160 | const billingRow = html`<div class="status-row"> | |
| 161 | <dt>Buy links</dt> | |
| 162 | <dd> | |
| 163 | <form class="row-fields" method="post" action="/admin/billing-links"> | |
| 164 | ${BILLING_SIZES.map( | |
| 165 | (z) => html`<label class="small">${z} | |
| 166 | <input class="input mono" type="url" name="${z}" maxlength="300" placeholder="https://" value="${billing.ok ? (billing.value[z] ?? "") : ""}" /></label>`, | |
| 167 | )} | |
| 168 | ${btn("Save", { size: "sm", variant: "primary", label: "Save buy links" })} | |
| 169 | </form> | |
| 170 | <span class="muted small">One payment link per size. With any set, the home page offers paid forges; empty by default.</span> | |
| 171 | </dd> | |
| 172 | <dd class="status-act"></dd> | |
| 173 | </div>`; | |
| 158 | 174 | const inviteRows = (invites.ok ? invites.value : []).map((i) => |
| 159 | 175 | row({ |
| 160 | 176 | lead: icon("activity"), |
| @@ -197,7 +213,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 197 | 213 | const body = html`<p class="small muted mb"> |
| 198 | 214 | Every limit you can hit, and why. A change applies to the next request that meets it; nothing needs a restart. |
| 199 | 215 | </p> |
| 200 | <dl class="status-rows">${signupRow}${agentStartRow}${inviteRow}${quotaRow}${plansRow(ctx)}</dl> | |
| 216 | <dl class="status-rows">${signupRow}${agentStartRow}${billingRow}${inviteRow}${quotaRow}${plansRow(ctx)}</dl> | |
| 201 | 217 | ${historyBlock("Invite links made", inviteRows)} |
| 202 | 218 | <section class="settings-block mt"> |
| 203 | 219 | <h2>Limits</h2> |
| @@ -320,6 +336,26 @@ export async function agentStartSet(ctx: Ctx): Promise<Response> { | ||
| 320 | 336 | ); |
| 321 | 337 | } |
| 322 | 338 | |
| 339 | // POST /admin/billing-links | |
| 340 | export async function billingLinksSet(ctx: Ctx): Promise<Response> { | |
| 341 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx); | |
| 342 | const form = await readForm(ctx, "admin"); | |
| 343 | if (form instanceof Response) return form; | |
| 344 | const changed = changeBillingLinks( | |
| 345 | ctx.app, | |
| 346 | ctx.auth.user, | |
| 347 | Object.fromEntries(BILLING_SIZES.map((z) => [z, field(form, z)])), | |
| 348 | ); | |
| 349 | if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx); | |
| 350 | return flashTo( | |
| 351 | ctx, | |
| 352 | "/admin/limits", | |
| 353 | changed.ok | |
| 354 | ? { message: "Buy links saved." } | |
| 355 | : { tone: "danger", message: "Each buy link is an https address, or empty." }, | |
| 356 | ); | |
| 357 | } | |
| 358 | ||
| 323 | 359 | // POST /admin/invites |
| 324 | 360 | export async function inviteMint(ctx: Ctx): Promise<Response> { |
| 325 | 361 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") |
src/web/standards.ts +6 −4
| @@ -48,6 +48,7 @@ import { apiCatalog, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPlu | ||
| 48 | 48 | import { forgeDocument } from "../core/standards_index.ts"; |
| 49 | 49 | import { TAGS } from "../contract/served.ts"; |
| 50 | 50 | import { running } from "../kernel/version.ts"; |
| 51 | import { getBillingLinks } from "../settings/billing.ts"; | |
| 51 | 52 | import { timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| 52 | 53 | import { shippedReleases, type Shipped } from "../release/shipped.ts"; |
| 53 | 54 | |
| @@ -398,10 +399,11 @@ export function llmsBody(ctx: Ctx): { body: string; etag: string } { | ||
| 398 | 399 | const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db)); |
| 399 | 400 | // The tools section depends on who asks, so the signed-in and the anonymous body are kept apart. |
| 400 | 401 | const signedIn = ctx.auth.user !== undefined; |
| 401 | const key = `${o} ${signedIn}`; | |
| 402 | const sold = Object.keys(getBillingLinks(ctx.app.db)).length > 0; | |
| 403 | const key = `${o} ${signedIn} ${sold}`; | |
| 402 | 404 | let kept = llmsBodies.get(key); |
| 403 | 405 | if (kept === undefined || kept.repos !== repos || kept.running !== running || kept.tell !== tell || kept.plugins !== plugins) { |
| 404 | const body = timePhase(ctx.request, "render", () => renderLlmsTxt(ctx, o, tell, running, repos, signedIn, plugins)); | |
| 406 | const body = timePhase(ctx.request, "render", () => renderLlmsTxt(ctx, o, tell, running, repos, signedIn, plugins, sold)); | |
| 405 | 407 | const etag = timePhase(ctx.request, "etag", () => `"${new Bun.CryptoHasher("sha256").update(body).digest("hex").slice(0, 32)}"`); |
| 406 | 408 | kept = { signedIn, repos, running, tell, plugins, body, etag }; |
| 407 | 409 | // Origins come from the Host header: keep a handful, not one per guess. |
| @@ -421,7 +423,7 @@ function pluginLines(o: string, plugins: string | undefined): string[] { | ||
| 421 | 423 | return plugins === undefined ? [...lines, "- Claude Code plugin: not offered here (no public source)"] : lines; |
| 422 | 424 | } |
| 423 | 425 | |
| 424 | function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: string, repos: LlmsListing, signedIn: boolean, plugins: string | undefined): string { | |
| 426 | function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: string, repos: LlmsListing, signedIn: boolean, plugins: string | undefined, sold: boolean): string { | |
| 425 | 427 | const mcp = mcpPointers(o); |
| 426 | 428 | return ( |
| 427 | 429 | [ |
| @@ -437,7 +439,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 437 | 439 | "", |
| 438 | 440 | `- Build now, no account: \`curl -sX POST ${o}/api/v1/start -d '{"name":"my-app"}'\` -> {user, token, repo, git, app, claim}. Clone git.url (user:token@), \`git push\` to main: app.url is live. Give claim.url to the person who takes it over. Unclaimed: asleep in 7 days, archived in 30.`, |
| 439 | 441 | `- Or with an account: sign up at ${o}/signup, then follow "Offer a change" below. By request, POST ${o}/api/v1/users gives 202 {state:"waiting"}. If /signup says 403, use "Report a problem".`, |
| 440 | `- Get your own forge: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one, free within limits while hosting is not sold: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`, | |
| 442 | `- Get your own forge: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one${sold ? " (paid plans are on the home page; this way is free within limits)" : ", free within limits while hosting is not sold"}: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`, | |
| 441 | 443 | `- Work from your own forge: connect it to this one as an origin, bring an app in, and send your work back. How: ${UPSTREAM_REPO}/raw/main/docs/ORIGINS.md`, |
| 442 | 444 | `- Connect an assistant that uses tools: custom connector URL ${mcp.url} (OAuth); start and report need no account.`, |
| 443 | 445 | "", |
src/web/studio/home.ts +23 −6
| @@ -20,10 +20,12 @@ import { | ||
| 20 | 20 | } from "../../actions/front.ts"; |
| 21 | 21 | import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts"; |
| 22 | 22 | import { appsRuntime, type EnvState } from "../../apps/runtime.ts"; |
| 23 | import { signupClosed, YOURE_IN_MESSAGE } from "../../actions/users.ts"; | |
| 23 | import { YOURE_IN_MESSAGE } from "../../actions/users.ts"; | |
| 24 | import { accountsClosedIn } from "../../core/users.ts"; | |
| 24 | 25 | import { markTold, untoldAnswer } from "../../core/requests.ts"; |
| 25 | 26 | import { requestsCard } from "../requests_card.ts"; |
| 26 | import { getSignup } from "../../settings/signup.ts"; | |
| 27 | import { getSignupAndLinks, BILLING_SIZES, sizeWords } from "../../settings/billing.ts"; | |
| 28 | import { getPlans } from "../../settings/plans.ts"; | |
| 27 | 29 | import { appsForRepos } from "../../core/apps.ts"; |
| 28 | 30 | import { WEIGHT_MAX, WEIGHT_NAMES } from "../../core/front.ts"; |
| 29 | 31 | import { LOOK_SIZES } from "../../core/looks.ts"; |
| @@ -757,7 +759,8 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 757 | 759 | const featured = front.featured; |
| 758 | 760 | const all = [...(featured === undefined ? [] : [featured]), ...front.shelf]; |
| 759 | 761 | const made = all.slice(0, Math.max(DOOR_TILES, all.filter((e) => e.pinned).length)); |
| 760 | const accounts = !signupClosed(ctx.app); | |
| 762 | const { mode, links } = getSignupAndLinks(ctx.app.db); | |
| 763 | const accounts = !accountsClosedIn(ctx.app.db, mode); | |
| 761 | 764 | timePhase(ctx.request, "tiles", () => { |
| 762 | 765 | tiles = made.map((e, at) => |
| 763 | 766 | tile(e, at, accounts, e === featured ? html`role="region" aria-label="Featured"` : undefined), |
| @@ -774,7 +777,10 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 774 | 777 | const ssh = sshCommand(ctx.app); |
| 775 | 778 | const signup = offersSignup(ctx) && accounts; |
| 776 | 779 | // Closed is a door with nothing behind it; invite still lands an invite link's holder. |
| 777 | const mode = getSignup(ctx.app.db); | |
| 780 | // The plans setting is read only when a link is set: no link, no extra statement on the home. | |
| 781 | const priced = BILLING_SIZES.some((z) => links[z] !== undefined) ? getPlans(ctx.app.db).filter((p) => p.priceUsd > 0) : []; | |
| 782 | const plans = BILLING_SIZES.filter((z) => links[z] !== undefined && priced.some((p) => p.size === z)); | |
| 783 | const planOf = (z: (typeof BILLING_SIZES)[number]) => priced.find((p) => p.size === z)!; | |
| 778 | 784 | const origin = (ctx.app.config.url ?? ctx.url.origin).replace(/\/$/, ""); |
| 779 | 785 | const prompt = agentPrompt(origin); |
| 780 | 786 | const asked = encodeURIComponent(prompt); |
| @@ -878,9 +884,20 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 878 | 884 | <div class="stack"> |
| 879 | 885 | <h2>Get your own forge</h2> |
| 880 | 886 | <p class="door-support"> |
| 881 | ${SITE_LINE} Free and open source. Run it on your own computer or server today. Hosted forges are | |
| 882 | coming.${accounts ? "" : mode === "invite" ? " New accounts on this forge are by invitation." : " This forge is not taking new accounts."} | |
| 887 | ${SITE_LINE} Free and open source. Run it on your own computer or server today.${plans.length > 0 ? "" : " Hosted forges are coming."}${accounts ? "" : mode === "invite" ? " New accounts on this forge are by invitation." : " This forge is not taking new accounts."} | |
| 883 | 888 | </p> |
| 889 | ${when(plans.length > 0, () => html`<div class="stack-tight" id="plans"> | |
| 890 | <ul class="list"> | |
| 891 | ${plans.map((z) => | |
| 892 | row({ | |
| 893 | title: html`<strong>${z[0]!.toUpperCase()}${z.slice(1)}</strong> <span class="muted small">${sizeWords(z)}</span>`, | |
| 894 | meta: html`$${String(planOf(z).priceUsd)} a month, with $${String(planOf(z).creditUsd)} of crew credit`, | |
| 895 | trail: btn("Get a forge", { href: links[z]!, external: true, size: "sm", variant: "primary" }), | |
| 896 | }), | |
| 897 | )} | |
outdated
Principle 1: this repeats 'Free and open source. Run it on your own computer or server today.' in the paragraph above. Remove it. |
||
| 898 | </ul> | |
| 899 | <p class="muted small">Your forge is made after payment; the link to claim it arrives by email, usually within a few hours.</p> | |
| 900 | </div>`)} | |
| 884 | 901 | <div class="bar">${btn("Read the step-by-step guide", { href: `${FORGE_REPO}`, size: "lg" })}</div> |
| 885 | 902 | </div> |
| 886 | 903 | <div class="stack"> |
test/billing-links.test.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
| 2 | * The bridge to paid hosting (#11149): `billing.links` is empty by default and | |
| 3 | * the anonymous home reads as it did; with links set, `#yours` shows one row | |
| 4 | * a plan, each button opening its link, and "coming" and "not sold" are gone. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.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 { getBillingLinks, parseBillingLinks, sizeWords } from "../src/settings/billing.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 18 | import { promote } from "./helpers/members.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dataDir: string; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let adminToken: string; | |
| 26 | let memberToken: string; | |
| 27 | let adminCookie: string; | |
| 28 | let memberCookie: string; | |
| 29 | ||
| 30 | const LINKS = { | |
| 31 | small: "https://buy.stripe.com/small-test", | |
| 32 | medium: "https://buy.stripe.com/medium-test", | |
| 33 | large: "https://buy.stripe.com/large-test", | |
| 34 | }; | |
| 35 | ||
| 36 | const home = async (): Promise<string> => (await fetch(`${servers.url}/`)).text(); | |
| 37 | const guide = async (): Promise<string> => (await fetch(`${servers.url}/llms.txt`)).text(); | |
| 38 | const patch = (token: string, body: unknown) => | |
| 39 | fetch(`${servers.url}/api/v1/admin/settings`, { | |
| 40 | method: "PATCH", | |
| 41 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 42 | body: JSON.stringify(body), | |
| 43 | }); | |
| 44 | ||
| 45 | beforeAll(async () => { | |
| 46 | dataDir = await mkdtemp(join(tmpdir(), "forge-billing-")); | |
| 47 | app = createApp({ dataDir, port: 0 }); | |
| 48 | servers = await serve(app); | |
| 49 | const make = async (username: string) => { | |
| 50 | const made = await createUser(app.db, { | |
| 51 | origin: "seed", | |
| 52 | trusted: true, | |
| 53 | username, | |
| 54 | email: `${username}@forge.local`, | |
| 55 | password: "a-good-passphrase", | |
| 56 | allowReserved: true, | |
| 57 | adminIfFirst: true, | |
| 58 | }); | |
| 59 | if (!made.ok) throw new Error(made.error); | |
| 60 | promote(app.db, made.value); | |
| 61 | return made.value; | |
| 62 | }; | |
| 63 | const admin = await make("owner"); | |
| 64 | const member = await make("mia"); | |
| 65 | adminToken = createToken(app.db, admin.id, "admin", "admin").token; | |
| 66 | memberToken = createToken(app.db, member.id, "member", "rw").token; | |
| 67 | adminCookie = `forge_session=${createSession(app.db, admin.id).token}`; | |
| 68 | memberCookie = `forge_session=${createSession(app.db, member.id).token}`; | |
| 69 | }); | |
| 70 | afterAll(async () => { | |
| 71 | await servers.stop(); | |
| 72 | await rm(dataDir, { recursive: true, force: true }); | |
| 73 | }); | |
| 74 | ||
| 75 | test("no links by default: the home says hosted forges are coming and shows no plan", async () => { | |
| 76 | expect(getBillingLinks(app.db)).toEqual({}); | |
| 77 | const text = await home(); | |
| 78 | expect(text).toContain("Hosted forges are"); | |
| 79 | expect(text).not.toContain("Get a forge"); | |
| 80 | expect(await guide()).toContain("while hosting is not sold"); | |
| 81 | }); | |
| 82 | ||
| 83 | test("only http(s) addresses without credentials, only the three sizes", () => { | |
| 84 | for (const bad of [ | |
| 85 | { small: "http://x.test/" }, | |
| 86 | { small: "javascript:alert(1)" }, | |
| 87 | { small: "https://u:[email protected]/" }, | |
| 88 | { small: 5 }, | |
| 89 | { small: 'https://x.test/"onclick="y' }, | |
| 90 | { huge: "https://x.test/" }, | |
| 91 | [], | |
| 92 | "https://x.test/", | |
| 93 | ]) | |
| 94 | expect(parseBillingLinks(bad).ok).toBe(false); | |
| 95 | expect(parseBillingLinks({ small: "", medium: null })).toEqual({ ok: true, value: {} }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a plain member cannot set the links", async () => { | |
| 99 | expect((await patch(memberToken, { billingLinks: LINKS })).status).toBe(403); | |
| 100 | expect(getBillingLinks(app.db)).toEqual({}); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a bad link is refused and nothing is kept", async () => { | |
| 104 | const res = await patch(adminToken, { billingLinks: { small: "http://insecure.test" } }); | |
| 105 | expect(res.status).toBe(422); | |
| 106 | expect(getBillingLinks(app.db)).toEqual({}); | |
| 107 | }); | |
| 108 | ||
| 109 | test("with three links the home shows three rows, each a button to its link", async () => { | |
| 110 | const res = await patch(adminToken, { billingLinks: LINKS }); | |
| 111 | expect(res.status).toBe(200); | |
| 112 | const read = (await (await fetch(`${servers.url}/api/v1/admin/settings`, { headers: { authorization: `Bearer ${adminToken}` } })).json()) as { billingLinks: unknown }; | |
| 113 | expect(read.billingLinks).toEqual(LINKS); | |
| 114 | const text = await home(); | |
| 115 | const section = text.slice(text.indexOf('id="plans"'), text.indexOf("Read the step-by-step guide")); | |
| 116 | const anchors = [...section.matchAll(/<a\s[^>]*>Get a forge<\/a\s*>/g)].map((m) => m[0]); | |
| 117 | expect(anchors).toHaveLength(3); | |
| 118 | for (const [i, size] of (["small", "medium", "large"] as const).entries()) { | |
| 119 | expect(anchors[i]).toContain(`href="${LINKS[size]}"`); | |
| 120 | expect(anchors[i]).toContain("noopener"); | |
| 121 | expect(section).toContain(sizeWords(size)); | |
| 122 | } | |
| 123 | expect(section).toContain("$19 a month, with $5 of crew credit"); | |
| 124 | expect(section).toContain("$49 a month, with $15 of crew credit"); | |
| 125 | expect(section).toContain("$149 a month, with $50 of crew credit"); | |
| 126 | expect(section).toContain("Your forge is made after payment; the link to claim it arrives by email, usually within a few hours."); | |
| 127 | expect(text).not.toContain("Hosted forges are"); | |
| 128 | expect(await guide()).not.toContain("while hosting is not sold"); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a size without a link has no row; clearing every link restores the page", async () => { | |
| 132 | await patch(adminToken, { billingLinks: { medium: LINKS.medium } }); | |
| 133 | const one = await home(); | |
| 134 | expect(one.match(/Get a forge/g)).toHaveLength(1); | |
| 135 | expect(one).toContain("$49 a month"); | |
| 136 | expect(one).not.toContain("$19 a month"); | |
| 137 | await patch(adminToken, { billingLinks: {} }); | |
| 138 | // three real changes so far (set, narrowed, cleared); the refused ones kept nothing | |
| 139 | const kept = app.db | |
| 140 | .query<{ n: number }, []>("SELECT count(*) AS n FROM forge_setting_change WHERE key = 'billing.links'") | |
| 141 | .get(); | |
| 142 | expect(kept?.n).toBe(3); | |
| 143 | const none = await home(); | |
| 144 | expect(none).toContain("Hosted forges are"); | |
| 145 | expect(none).not.toContain("Get a forge"); | |
| 146 | }); | |
| 147 | ||
| 148 | const form = (cookie: string, fields: Record<string, string>, origin: string = servers.url) => | |
| 149 | fetch(`${servers.url}/admin/billing-links`, { | |
| 150 | method: "POST", | |
| 151 | headers: { cookie, origin, accept: "text/html" }, | |
| 152 | body: new URLSearchParams(fields), | |
| 153 | redirect: "manual", | |
| 154 | }); | |
| 155 | const flashed = (res: Response): string => decodeURIComponent((res.headers.get("set-cookie") ?? "").replace(/\+/g, " ")); | |
| 156 | ||
| 157 | test("the form: a member is refused, a cross-origin post is refused, nothing is kept", async () => { | |
| 158 | expect([403, 404]).toContain((await form(memberCookie, { small: LINKS.small })).status); | |
| 159 | expect((await form(adminCookie, { small: LINKS.small }, "https://elsewhere.test")).status).toBe(403); | |
| 160 | expect(getBillingLinks(app.db)).toEqual({}); | |
| 161 | }); | |
| 162 | ||
| 163 | test("the form: a bad address flashes danger and keeps nothing; a good one is saved", async () => { | |
| 164 | const bad = await form(adminCookie, { small: "http://insecure.test" }); | |
| 165 | expect(bad.status).toBe(303); | |
| 166 | expect(flashed(bad)).toContain("https address"); | |
| 167 | expect(getBillingLinks(app.db)).toEqual({}); | |
| 168 | const good = await form(adminCookie, { small: LINKS.small, medium: "", large: LINKS.large }); | |
| 169 | expect(good.status).toBe(303); | |
| 170 | expect(flashed(good)).toContain("Buy links saved."); | |
| 171 | expect(getBillingLinks(app.db)).toEqual({ small: LINKS.small, large: LINKS.large }); | |
| 172 | await patch(adminToken, { billingLinks: {} }); | |
| 173 | }); | |
| 174 | ||
| 175 | const tool = async (token: string, args: unknown) => mcpTool(servers.url, token, "signup", args); | |
| 176 | ||
| 177 | test("MCP signup: billingLinks plans first, needs an admin token to apply, and a read returns them", async () => { | |
| 178 | const dry = await tool(adminToken, { billingLinks: LINKS }); | |
| 179 | expect(dry.value.says).toContain("Would set"); | |
| 180 | expect(getBillingLinks(app.db)).toEqual({}); | |
| 181 | const rw = createToken(app.db, (app.db.query("SELECT id FROM user WHERE username = 'owner'").get() as { id: string }).id, "rw", "rw").token; | |
| 182 | expect((await tool(rw, { billingLinks: LINKS, dryRun: false })).error).toBeDefined(); | |
| 183 | expect(getBillingLinks(app.db)).toEqual({}); | |
| 184 | expect((await tool(adminToken, { billingLinks: { small: "http://x.test" }, dryRun: false })).error).toBeDefined(); | |
| 185 | const set = await tool(adminToken, { billingLinks: LINKS, dryRun: false }); | |
| 186 | expect(set.value.billingLinks).toEqual(LINKS); | |
| 187 | expect((await tool(adminToken, {})).value.billingLinks).toEqual(LINKS); | |
| 188 | await patch(adminToken, { billingLinks: {} }); | |
| 189 | }); |
test/palette-roster.test.ts +1
| @@ -149,6 +149,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 149 | 149 | ["economics", "Economics", "the profit slider's position, the margin, the owned-node label and the prices; change one"], |
| 150 | 150 | ["flows-all", "Flows across the forge", "every repository's flow lines, by kind and stale"], |
| 151 | 151 | ["teams", "Crew teams", "each team's charter, dial and members"], |
| 152 | ["billing-links", "Buy links", "where each hosted plan is paid for, shown on the home page"], | |
| 152 | 153 | ["agents", "Crew agents", "each agent's model, budget and brief"], |
| 153 | 154 | ["invites", "Invite links", "make a single-use link, see which are open, revoke one"], |
| 154 | 155 | ["claim-start", "Take an agent's work", "claim the repositories an agent started, from its one-time link"], |
test/snappy.test.ts +1 −1
| @@ -52,7 +52,7 @@ describe("budgets", () => { | ||
| 52 | 52 | [await timeRoute(f, "map member", "/map", peer, 20), SQL.mapMember], |
| 53 | 53 | [await timeRoute(f, "home owner", "/", owner, 20), SQL.homeOwner], |
| 54 | 54 | [await timeRoute(f, "home member", "/", peer, 20), SQL.homeMember], |
| 55 | [await timeRoute(f, "home stranger", "/", {}, 20), 20], | |
| 55 | [await timeRoute(f, "home stranger", "/", {}, 20), 19], | |
| 56 | 56 | [await timeRoute(f, "map owner cold", "/map", owner, 20, touch), COLD_SQL.mapOwner], |
| 57 | 57 | [await timeRoute(f, "home owner cold", "/", owner, 20, touch), COLD_SQL.homeOwner], |
| 58 | 58 | ] as const; |
test/tui-billing-links.test.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | /** | |
| 2 | * Buy links in the terminal (#11149): an admin who is a person reads the three | |
| 3 | * sizes and sets one through the same action as the page; a refused address | |
| 4 | * keeps the prompt open and nothing; a member is not offered the screen. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { getBillingLinks } from "../src/settings/billing.ts"; | |
| 13 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 14 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let dir: string; | |
| 22 | let forge: Forge; | |
| 23 | ||
| 24 | function boot(username: string) { | |
| 25 | const user = getUserByName(forge.db, username)!; | |
| 26 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 27 | const { app } = createForgeApp(forge, identity, { terminal: headlessTerminal(140, 32), exitHooks: false }); | |
| 28 | app.start(); | |
| 29 | const k = (key: string, ctrl = false) => app.dispatch({ type: "key", key, ctrl, alt: false, shift: false }); | |
| 30 | return { | |
| 31 | text: () => app.screen.toText(), | |
| 32 | press: async (...keys: string[]) => { | |
| 33 | keys.forEach((x) => k(x)); | |
| 34 | await settleApp(app); | |
| 35 | }, | |
| 36 | open: async () => { | |
| 37 | k("k", true); | |
| 38 | for (const ch of "Buy links") k(ch); | |
| 39 | k("enter"); | |
| 40 | await settleApp(app); | |
| 41 | }, | |
| 42 | stop: () => app.stop(), | |
| 43 | }; | |
| 44 | } | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dir = await mkdtemp(join(tmpdir(), "forge-tui-billing-")); | |
| 48 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 49 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 50 | const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst }); | |
| 51 | if (!made.ok) throw new Error(made.error); | |
| 52 | } | |
| 53 | }); | |
| 54 | afterAll(async () => { | |
| 55 | forge.close(); | |
| 56 | await rm(dir, { recursive: true, force: true }); | |
| 57 | }); | |
| 58 | ||
| 59 | test("an admin reads the sizes, a bad address is refused, a good one is saved", async () => { | |
| 60 | const h = boot("alice"); | |
| 61 | await h.open(); | |
| 62 | expect(h.text()).toContain("small"); | |
| 63 | expect(h.text()).toContain("no link"); | |
| 64 | await h.press("enter"); | |
| 65 | for (const ch of "http://insecure.test") await h.press(ch); | |
| 66 | await h.press("enter"); | |
| 67 | expect(h.text()).toContain("https address"); | |
| 68 | expect(getBillingLinks(forge.db)).toEqual({}); | |
| 69 | await h.press("esc"); | |
| 70 | h.stop(); | |
| 71 | const again = boot("alice"); | |
| 72 | await again.open(); | |
| 73 | await again.press("j", "enter"); | |
| 74 | for (const ch of "https://buy.test/medium") await again.press(ch); | |
| 75 | await again.press("enter"); | |
| 76 | expect(getBillingLinks(forge.db)).toEqual({ medium: "https://buy.test/medium" }); | |
| 77 | expect(again.text()).toContain("https://buy.test/medium"); | |
| 78 | again.stop(); | |
| 79 | }); | |
| 80 | ||
| 81 | test("a member is not offered the screen and cannot set a link", async () => { | |
| 82 | const h = boot("bob"); | |
| 83 | await h.open(); | |
| 84 | expect(h.text()).not.toContain("no link"); | |
| 85 | await h.press("enter"); | |
| 86 | expect(getBillingLinks(forge.db)).toEqual({ medium: "https://buy.test/medium" }); | |
| 87 | h.stop(); | |
| 88 | }); |
Plan prices and
sizeWordsare presentation, not the setting. Move them to their own module so #11135 swaps one file.