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

Merged opened this in answer to

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

Merged

torque/forge:torque/11149-the-home-page-sells-forges-today-three-p → main 8 commits fork 2 threads · 2 unresolved

  • Merged. torque/11149-the-home-page-sells-forges-today-three-p was merged into main as 8011647.

Review

Sign in to review this change.
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`;
194194 | `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 |
195195 | `sever` | — | Unlink a spawned forge from its parent (owner of its `forge` repo only) | 214 |
196196 | `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 |
198198 | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 |
199199 | `snapshot_now` | — | Take a snapshot now, as Backups does (admin) | 133 |
200200 | `spawn` | — | Spawn a child forge | 910 |
src/actions/signup.ts +21
@@ -14,6 +14,7 @@ import {
1414 } from "../settings/signup.ts";
1515 import { getAgentStart, parseAgentStart, setAgentStart, type AgentStartMode } from "../settings/agent_start.ts";
1616 import { forgeSource, parseForgeSource, setForgeSource, storedForgeSource } from "../settings/forge_source.ts";
17 import { getBillingLinks, parseBillingLinks, setBillingLinks, type BillingLinks } from "../settings/billing.ts";
1718 import type { User } from "../core/users.ts";
1819 import { err, ok, type Result } from "../kernel/result.ts";
1920
@@ -89,3 +90,23 @@ export function changeAgentStart(
8990 setAgentStart(app.db, mode.value, who.value.id);
9091 return ok(mode.value);
9192 }
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";
1212 import { err, ok, type Result } from "../kernel/result.ts";
1313 import { admitSignin } from "./signin-window.ts";
1414 import { waitingCount, WAITING_CAP } from "./requests.ts";
15 import { getSignup } from "../settings/signup.ts";
15 import { getSignup, type SignupMode } from "../settings/signup.ts";
1616 import { inviteOpen, redeemInvite, reopenInvite } from "./invites.ts";
1717 import type { SignupSlot } from "../actions/signup-window.ts";
1818
@@ -179,10 +179,7 @@ export type CreateUserInput = CreateUserFields & AccountOrigin;
179179 * (`inviteNeeded` says when it must be spent).
180180 */
181181 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);
186183 }
187184
188185 /**
@@ -194,6 +191,15 @@ function inviteNeeded(db: Database): boolean {
194191 return mode === "invite" || mode === "closed";
195192 }
196193
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
197203 interface CreateUserFields {
198204 username: string;
199205 email: string;
src/http/api/admin.ts +14 −6
@@ -25,7 +25,8 @@ import { changeQuotas, readQuotas } from "../../actions/quotas.ts";
2525 import { allowRequest, declineRequest, listRequests } from "../../actions/requests.ts";
2626 import { setRole } from "../../actions/roles.ts";
2727 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";
2930 import { SPEND_WORDS } from "../../actions/spend_words.ts";
3031 import { TEAM_SAYS, changeTeam, readTeams } from "../../actions/teams.ts";
3132 import { SIGNUP_WORDS } from "../../core/arg_words.ts";
@@ -327,14 +328,15 @@ export function adminRoutes(router: Router<Handler>): void {
327328 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
328329 const denied = accountAdmin(ctx, "ro");
329330 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),
331333 read: readQuotas(ctx.app, ctx.auth.user),
332334 signup: readSignup(ctx.app, ctx.auth.user),
333335 source: readForgeSource(ctx.app, ctx.auth.user),
334336 }));
335337 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 })
338340 : notFound());
339341 });
340342
@@ -342,8 +344,14 @@ export function adminRoutes(router: Router<Handler>): void {
342344 const denied = accountAdmin(ctx, "admin");
343345 if (denied) return denied;
344346 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 }
347355 if (input.forgeRepo !== undefined) {
348356 const set = changeForgeSource(ctx.app, ctx.auth.user, input.forgeRepo);
349357 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";
22 import { SIGNUP_MODES } from "../../../settings/signup.ts";
33 import { scopeAllows } from "../../../core/tokens.ts";
44 import { good, bad, type Tool } from "../shared.ts";
5 import { BILLING_WORDS } from "../../../settings/billing.ts";
56 import { SIGNUP_WORDS } from "../../../core/arg_words.ts";
67
78 export const tool: Tool = {
89 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",
1011 description:
1112 "Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account",
1213 inputSchema: {
1314 type: "object",
1415 properties: {
1516 mode: { type: "string", enum: [...SIGNUP_MODES] },
17 billingLinks: { type: "object" },
1618 dryRun: { type: "boolean" },
1719 },
1820 },
@@ -21,7 +23,18 @@ export const tool: Tool = {
2123 run(ctx, user, args) {
2224 const read = readSignup(ctx.app, user);
2325 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 }
2538 if (!(SIGNUP_MODES as readonly unknown[]).includes(args["mode"]))
2639 return bad("invalid_signup", SIGNUP_WORDS);
2740 if (args["dryRun"] !== false)
src/http/server.ts +2 −1
@@ -32,7 +32,7 @@ import { adminDataPage, appDataPage, dataPage } from "../web/pages/data.ts";
3232 import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts";
3333 import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts";
3434 import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts";
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";
3636 import { plansSet } from "../web/settings/plans.ts";
3737 import { allFlowsPage } from "../web/pages/flows-all.ts";
3838 import { measureSubmit } from "./measure.ts";
@@ -1060,6 +1060,7 @@ export function buildRouter(): Router<Handler> {
10601060 router.add("POST", "/admin/plans", plansSet);
10611061 router.add("POST", "/admin/signup", signupSet);
10621062 router.add("POST", "/admin/agent-start", agentStartSet);
1063 router.add("POST", "/admin/billing-links", billingLinksSet);
10631064 router.add("POST", "/admin/invites", inviteMint);
10641065 router.add("POST", "/admin/invites/:id/revoke", inviteRevoke);
10651066
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";
outdated

Plan prices and sizeWords are presentation, not the setting. Move them to their own module so #11135 swaps one file.

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/entries.ts +2
@@ -45,6 +45,7 @@ import { entry as forges } from "./forges.ts";
4545 import { entry as homePins } from "./home_pins.ts";
4646 import { entry as claimStart } from "./claim_start.ts";
4747 import { entry as invites } from "./invites.ts";
48 import { entry as billingLinks } from "./billinglinks.ts";
4849 import { entry as limits } from "./limits.ts";
4950 import { entry as plans } from "./plans.ts";
5051 import { entry as economics } from "./economics.ts";
@@ -144,6 +145,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
144145 agents,
145146 archived,
146147 invites,
148 billingLinks,
147149 claimStart,
148150 requests,
149151 bulk,
src/web/pages/admin-limits.ts +38 −2
@@ -11,7 +11,8 @@ import { changeQuotas, readQuotas } from "../../actions/quotas.ts";
1111 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts";
1212 import { INVITE_NOT_OPEN } from "../../actions/sever_invite_words.ts";
1313 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";
1516 import { AGENT_START_MODES } from "../../settings/agent_start.ts";
1617 import { SIGNUP_MEANS, SIGNUP_MODES } from "../../settings/signup.ts";
1718 import { QUOTA_KEYS } from "../../settings/quotas.ts";
@@ -33,6 +34,7 @@ export function limitsPage(ctx: Ctx): Response {
3334 const quotas = timePhase(ctx.request, "quotas", () => readQuotas(ctx.app, ctx.auth.user));
3435 const signup = timePhase(ctx.request, "signup", () => readSignup(ctx.app, ctx.auth.user));
3536 const agentStart = readAgentStart(ctx.app, ctx.auth.user);
37 const billing = readBillingLinks(ctx.app, ctx.auth.user);
3638 const invites = timePhase(ctx.request, "invites", () => listInviteLinks(ctx.app, ctx.auth.user));
3739 const rendering = performance.now();
3840 const { limits, changes } = listed.value;
@@ -155,6 +157,20 @@ export function limitsPage(ctx: Ctx): Response {
155157 </dd>
156158 <dd class="status-act"></dd>
157159 </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>`;
158174 const inviteRows = (invites.ok ? invites.value : []).map((i) =>
159175 row({
160176 lead: icon("activity"),
@@ -197,7 +213,7 @@ export function limitsPage(ctx: Ctx): Response {
197213 const body = html`<p class="small muted mb">
198214 Every limit you can hit, and why. A change applies to the next request that meets it; nothing needs a restart.
199215 </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>
201217 ${historyBlock("Invite links made", inviteRows)}
202218 <section class="settings-block mt">
203219 <h2>Limits</h2>
@@ -320,6 +336,26 @@ export async function agentStartSet(ctx: Ctx): Promise<Response> {
320336 );
321337 }
322338
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
323359 // POST /admin/invites
324360 export async function inviteMint(ctx: Ctx): Promise<Response> {
325361 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
4848 import { forgeDocument } from "../core/standards_index.ts";
4949 import { TAGS } from "../contract/served.ts";
5050 import { running } from "../kernel/version.ts";
51 import { getBillingLinks } from "../settings/billing.ts";
5152 import { timePhase, timePhaseAsync } from "../core/route_timing.ts";
5253 import { shippedReleases, type Shipped } from "../release/shipped.ts";
5354
@@ -398,10 +399,11 @@ export function llmsBody(ctx: Ctx): { body: string; etag: string } {
398399 const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db));
399400 // The tools section depends on who asks, so the signed-in and the anonymous body are kept apart.
400401 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}`;
402404 let kept = llmsBodies.get(key);
403405 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));
405407 const etag = timePhase(ctx.request, "etag", () => `"${new Bun.CryptoHasher("sha256").update(body).digest("hex").slice(0, 32)}"`);
406408 kept = { signedIn, repos, running, tell, plugins, body, etag };
407409 // 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[] {
421423 return plugins === undefined ? [...lines, "- Claude Code plugin: not offered here (no public source)"] : lines;
422424 }
423425
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 {
425427 const mcp = mcpPointers(o);
426428 return (
427429 [
@@ -437,7 +439,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
437439 "",
438440 `- 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.`,
439441 `- 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.`,
441443 `- 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`,
442444 `- Connect an assistant that uses tools: custom connector URL ${mcp.url} (OAuth); start and report need no account.`,
443445 "",
src/web/studio/home.ts +23 −6
@@ -20,10 +20,12 @@ import {
2020 } from "../../actions/front.ts";
2121 import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts";
2222 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";
2425 import { markTold, untoldAnswer } from "../../core/requests.ts";
2526 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";
2729 import { appsForRepos } from "../../core/apps.ts";
2830 import { WEIGHT_MAX, WEIGHT_NAMES } from "../../core/front.ts";
2931 import { LOOK_SIZES } from "../../core/looks.ts";
@@ -757,7 +759,8 @@ export function studioDoor(ctx: Ctx): Response {
757759 const featured = front.featured;
758760 const all = [...(featured === undefined ? [] : [featured]), ...front.shelf];
759761 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);
761764 timePhase(ctx.request, "tiles", () => {
762765 tiles = made.map((e, at) =>
763766 tile(e, at, accounts, e === featured ? html`role="region" aria-label="Featured"` : undefined),
@@ -774,7 +777,10 @@ export function studioDoor(ctx: Ctx): Response {
774777 const ssh = sshCommand(ctx.app);
775778 const signup = offersSignup(ctx) && accounts;
776779 // 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)!;
778784 const origin = (ctx.app.config.url ?? ctx.url.origin).replace(/\/$/, "");
779785 const prompt = agentPrompt(origin);
780786 const asked = encodeURIComponent(prompt);
@@ -878,9 +884,20 @@ export function studioDoor(ctx: Ctx): Response {
878884 <div class="stack">
879885 <h2>Get your own forge</h2>
880886 <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."}
883888 </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>`)}
884901 <div class="bar">${btn("Read the step-by-step guide", { href: `${FORGE_REPO}`, size: "lg" })}</div>
885902 </div>
886903 <div class="stack">
test/palette-roster.test.ts +1
@@ -149,6 +149,7 @@ test("a site-admin person is offered the whole roster", () => {
149149 ["economics", "Economics", "the profit slider's position, the margin, the owned-node label and the prices; change one"],
150150 ["flows-all", "Flows across the forge", "every repository's flow lines, by kind and stale"],
151151 ["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"],
152153 ["agents", "Crew agents", "each agent's model, budget and brief"],
153154 ["invites", "Invite links", "make a single-use link, see which are open, revoke one"],
154155 ["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", () => {
5252 [await timeRoute(f, "map member", "/map", peer, 20), SQL.mapMember],
5353 [await timeRoute(f, "home owner", "/", owner, 20), SQL.homeOwner],
5454 [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],
5656 [await timeRoute(f, "map owner cold", "/map", owner, 20, touch), COLD_SQL.mapOwner],
5757 [await timeRoute(f, "home owner cold", "/", owner, 20, touch), COLD_SQL.homeOwner],
5858 ] as const;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs