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

A person who opens the forge's link can start their own forge from the first… #11213

Parked opened this in answer to
Closed without merging

mason/forge:mason/11153-a-person-who-opens-the-forge-s-link-can → main 3 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
11 files changed +303 −10
docs/features/start-forge.md added +11
@@ -0,0 +1,11 @@
1 # Start a forge from the home page (#11153)
2
3 A person who opens the forge's link sees, in the first view, one sentence (your own forge, free to try), one field (**Name your forge**) and one button (**Start my forge**). The field says "is free" or "is taken" as they type (`GET /start/name`, one JSON line; without script the form still posts).
4
5 `POST /start` goes through `spawnDoor` as a stranger: the free plan, the visitor rate limits, the capacity checks. On success it answers 303 to the new forge's `/claim#…` page, where the person chooses a username and password and lands signed in as its admin: no link to copy. A refusal (name taken, rate limit, no capacity, a bad name) is drawn on the home page itself with what to do next; the typed name stays.
6
7 The header's account link on the door leads to the same form when signup on the top forge is closed or by invitation. `/agents` keeps its form for agents and links people to `/#start`.
8
9 A hosted child whose crew has no key and no included credit says so once (`crewKeyCard`): add a model key, or pick a paid plan (the plans row on its host's home, from `parent.json`).
10
11 Not here: the child's first screen does not yet carry the one-line "make an app or bring a repository" next step beyond what the first-login card already says (#600).
src/actions/spawn.ts +5
@@ -194,6 +194,11 @@ async function readLedger(app: App, name: string): Promise<Ledger | undefined> {
194194 }
195195 }
196196
197 /** Whether a child of this name was already started here (its ledger is kept). */
198 export async function childExists(app: App, name: string): Promise<boolean> {
199 return /^[a-z0-9-]{1,39}$/.test(name) && (await readLedger(app, name)) !== undefined;
200 }
201
197202 async function writeLedger(app: App, ledger: Ledger): Promise<void> {
198203 await mkdir(join(app.config.dataDir, "spawn"), { recursive: true });
199204 const path = ledgerPath(app, ledger.name);
src/http/server.ts +4
@@ -17,6 +17,7 @@ import {
1717 import { secureFor } from "./cookie_secure.ts";
1818 import { refusedByDatabase, shownRefusal } from "./refusal.ts";
1919 import { NOT_A_MEMBER } from "../core/roles.ts";
20 import { startName, startSubmit } from "../web/pages/start-forge.ts";
2021 import { startClaimAccountSubmit, startClaimPage, startClaimSubmit } from "../web/pages/start_claim.ts";
2122 import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
2223 import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts";
@@ -828,6 +829,9 @@ export function buildRouter(): Router<Handler> {
828829 // What a page's Content-Security-Policy blocked (http/csp_report.ts): counts only.
829830 router.add("POST", "/csp-report", cspReportSubmit);
830831 router.add("GET", "/", home);
832 // The home page's own door: a person starts a forge by naming it (pages/start-forge.ts).
833 router.add("POST", "/start", startSubmit);
834 router.add("GET", "/start/name", startName);
831835 // A spawned child's claim link: GET shows, POST takes it (spawn/claim.ts).
832836 router.add("GET", "/claim", claimFragmentPage);
833837 router.add("POST", "/claim", claimSubmit);
src/web/pages/agents.ts +1
@@ -74,6 +74,7 @@ export function agentsPage(ctx: Ctx): Response {
7474 })}
7575 <section class="stack" aria-labelledby="agent-forge-h">
7676 <h2 id="agent-forge-h">Get a forge of your own</h2>
77 <p><a href="/#start">People: start here.</a></p>
7778 <form method="post" action="/forges/new" class="stack" id="agent-forge">
7879 ${formField({ label: "Name", name: "name", required: true })}
7980 ${btn("Make my forge", { variant: "primary" })}
src/web/pages/start-forge.ts added +65
@@ -0,0 +1,65 @@
1 /**
2 * Starting a forge from the home page (#11153). `POST /start` is the home
3 * form's door: it asks the spawn door for a free forge as a stranger (the same
4 * rate limits and capacity as `/forges/new`) and sends the person straight to
5 * the new forge's claim page, so they choose a username and password and land
6 * signed in with no link to copy. A refusal is said on the home page itself.
7 * `GET /start/name` answers the form's "free / taken" line.
8 */
9
10 import { childExists } from "../../actions/spawn.ts";
11 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
12 import type { Ctx } from "../../http/context.ts";
13 import { json } from "../../http/respond.ts";
14 import { spawnDoor } from "../../spawn/door.ts";
15 import { field, readForm } from "../form.ts";
16 import { redirect } from "../layout.ts";
17 import { studioDoor } from "../studio/home.ts";
18
19 const SELF_HOST = "Or run your own: the guide is further down this page.";
20
21 /** What a refused start says, and what to do next. */
22 function refusalWords(status: number, error: unknown, message: unknown): string {
23 const said = typeof message === "string" ? message : "That did not work.";
24 if (status === 429) return `${said}. Try again later, or ${SELF_HOST.toLowerCase()}`;
25 if (error === "exists") return "That name is taken. Pick another.";
26 if (status === 409) return `${said}. Try again in a few minutes. ${SELF_HOST}`;
27 if (status >= 500) return `The forge could not be made just now. Try again in a minute. ${SELF_HOST}`;
28 return `${said}. Change the name and try again.`;
29 }
30
31 // POST /start
32 export async function startSubmit(ctx: Ctx): Promise<Response> {
33 const form = await readForm(ctx);
34 if (form instanceof Response) return form;
35 const name = field(form, "name").trim().toLowerCase();
36 const refuse = (message: string, status = 422): Response =>
37 studioDoor(ctx, { name, message }, status);
38 // A signed-in person has /forges/new, which asks what a person with an account may choose.
39 if (ctx.auth.user !== undefined) return redirect(`/forges/new?name=${encodeURIComponent(name)}`);
40 const door = await spawnDoor(ctx, undefined, { name, dryRun: false });
41 if (door.status >= 400)
42 return refuse(refusalWords(door.status, door.body["error"], door.body["message"]), door.status === 429 ? 429 : door.status === 422 ? 422 : 409);
43 const link = door.body["claimUrl"];
44 // An existing forge's link is never shown again: the name is someone's.
45 if (door.body["existed"] === true || typeof link !== "string") return refuse(refusalWords(409, "exists", ""), 409);
46 const response = redirect(link);
47 response.headers.set("cache-control", "no-store");
48 return response;
49 }
50
51 // GET /start/name?name= — one sentence, "free" or why not.
52 export async function startName(ctx: Ctx): Promise<Response> {
53 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
54 const name = (ctx.url.searchParams.get("name") ?? "").trim().toLowerCase().slice(0, 64);
55 const reply = (free: boolean, message: string): Response => {
56 const res = json({ free, message });
57 res.headers.set("cache-control", "no-store");
58 return res;
59 };
60 if (name === "") return reply(false, "");
61 const door = await spawnDoor(ctx, undefined, { name, dryRun: true });
62 if (door.status === 429) return reply(false, "Too many checks just now. Wait a moment.");
63 if (door.status >= 400) return reply(false, refusalWords(door.status, door.body["error"], door.body["message"]));
64 return (await childExists(ctx.app, name)) ? reply(false, `${name} is taken. Pick another.`) : reply(true, `${name} is free.`);
65 }
src/web/studio/crew_key.ts +16 −2
@@ -7,7 +7,7 @@
77 * echoed, never sent to the host.
88 */
99
10 import { existsSync } from "node:fs";
10 import { existsSync, readFileSync } from "node:fs";
1111 import { join } from "node:path";
1212 import type { App } from "../../app.ts";
1313 import { listAgents } from "../../core/agents.ts";
@@ -42,6 +42,17 @@ export function includedCredit(app: App): Included | undefined {
4242 /** Whether a forge is a hosted child: its host's file is in its data directory. */
4343 export const isHostedForge = (app: App): boolean => existsSync(join(app.config.dataDir, "parent.json"));
4444
45 /** The host forge's plans, for a hosted child to point at: the host's address from its `parent.json`, when that is an http(s) origin. */
46 export function plansLink(app: App): string | undefined {
47 try {
48 const upstream = (JSON.parse(readFileSync(join(app.config.dataDir, "parent.json"), "utf8")) as { upstream?: unknown }).upstream;
49 const url = typeof upstream === "string" ? new URL(upstream) : undefined;
50 return url !== undefined && (url.protocol === "https:" || url.protocol === "http:") ? `${url.origin}/#yours` : undefined;
51 } catch {
52 return undefined;
53 }
54 }
55
4556 /** Whether the crew has anything to run on: a key of the owner's, or included credit with money left. */
4657 export function crewCanRun(app: App): boolean {
4758 if (crewHasCredential(app.db)) return true;
@@ -79,8 +90,11 @@ export function crewKeyCard(ctx: Ctx, compact = false): Html | null {
7990 credit !== undefined
8091 ? "The included credit is spent. Add your own key to keep building, or wait for next month."
8192 : "The crew needs a model key to start. Paste yours.";
93 // Two ways forward, said once: a key of one's own, or a paid plan.
94 const plansAt = plansLink(ctx.app);
95 const plans = plansAt === undefined ? null : html` Or <a href="${plansAt}">pick a paid plan</a>.`;
8296 return html`<section class="settings-block" id="crew-key-note" role="status">
83 <p class="small muted">${icon("alert")} ${said}${admin ? "" : " Ask this forge's admin."}</p>
97 <p class="small muted">${icon("alert")} ${said}${admin ? "" : " Ask this forge's admin."}${plans}</p>
8498 ${admin ? keyField() : null}
8599 </section>`;
86100 }
src/web/studio/home.ts +8 −6
@@ -85,6 +85,7 @@ import { isNavigation, lastVisit, noteVisit } from "../../core/home_visit.ts";
8585 import { sinceYouWere } from "./since.ts";
8686 import { homeTabs, unshippedAsks } from "./home_tabs.ts";
8787 import { crewKeyCard } from "./crew_key.ts";
88 import { startForm, startFormJs, type StartRefusal } from "./start_form.ts";
8889 import { everydayCard } from "../pages/everyday.ts";
8990 import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts";
9091 import { spawnedOrg } from "../../spawn/claim.ts";
@@ -751,7 +752,7 @@ function agentPrompt(origin: string): string {
751752 * `/front` is the same page for anyone; a site admin there also gets the
752753 * controls that decide it.
753754 */
754 export function studioDoor(ctx: Ctx): Response {
755 export function studioDoor(ctx: Ctx, refusal?: StartRefusal, status = 200): Response {
755756 // The door draws what is in memory; a pack pushed since is picked up for the next visit.
756757 void refreshCatalogQuietly(ctx.app, 5_000);
757758 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -787,9 +788,10 @@ export function studioDoor(ctx: Ctx): Response {
787788 <div class="door-under">
788789 <div class="door-lead">
789790 <p class="door-support">Say what you need. An AI crew builds it, for a business or a game studio.</p>
791 <div id="start">${startForm(refusal)}</div>
790792 <div class="bar">
791 ${btn("Get your own forge", { href: "#yours", variant: "primary", size: "lg" })}
792 ${when(made.length > 0, () => btn("See what it built", { href: "#made", size: "lg" }))}
793 ${btn("Get your own forge", { href: "#yours", size: "sm" })}
794 ${when(made.length > 0, () => btn("See what it built", { href: "#made", size: "sm" }))}
793795 </div>
794796 <div class="door-agent">
795797 <p>
@@ -911,7 +913,7 @@ bun run src/main.ts</code></pre>
911913 title: "forge: software for what you're building, yours to keep",
912914 description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.",
913915 body,
914 scriptJs: `${liveClientJs}\n${liveRegionsJs}`,
916 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${startFormJs}`,
915917 jsonLd: siteStructuredData(origin, running().version),
916918 ...(visitor
917919 ? {
@@ -920,7 +922,7 @@ bun run src/main.ts</code></pre>
920922 ${signup
921923 ? html`<a class="small" href="/signin${nextQuery}">Sign in</a>
922924 ${btn("Create an account", { href: `/signup${nextQuery}` })}`
923 : html`${when(mode !== "closed", () => html`<a class="small door-wide" href="/signup">Create an account</a>`)}
925 : html`<a class="small door-wide" href="/#start">Start your forge</a>
924926 ${btn("Sign in", { href: activeEdgeToken(ctx.app) === undefined ? `/signin${nextQuery}` : edgeLoginUrl(back) })}`}`,
925927 foot: html`<span class="mark"></span>
926928 <span>Open Platform: two open source projects, AGPL-3.0</span>
@@ -930,7 +932,7 @@ bun run src/main.ts</code></pre>
930932 <a href="https://www.espodev.com">EspoDev</a>`,
931933 }
932934 : {}),
933 });
935 }, status);
934936 notePhaseRest(ctx.request, "render", rendering, []);
935937 return res;
936938 }
src/web/studio/start_form.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * The home page's door for a person (#11153): one sentence on what they get,
3 * one field for the forge's name with live "free / taken" feedback, one button.
4 * It posts to `POST /start` (`pages/start-forge.ts`), which goes through the
5 * same spawn door as `/forges/new` and sends the person on to their new
6 * forge's claim page, where they choose a username and password and arrive
7 * signed in. The script only asks `GET /start/name`; without it the form
8 * still posts, and the door answers the same.
9 */
10
11 import { html, type Html } from "../html.ts";
12 import { btn, field } from "../ui.ts";
13
14 /** What a refused start says on the home page: plain words and what to do next. */
15 export interface StartRefusal {
16 readonly name: string;
17 readonly message: string;
18 }
19
20 /** The form's script: asks whether the typed name is free, a moment after typing stops. */
21 export const startFormJs = `(() => {
22 const input = document.getElementById("f-name");
23 const form = document.getElementById("start-forge");
24 const status = document.getElementById("start-name-status");
25 if (!input || !form || !status) return;
26 let timer = 0, seq = 0;
27 input.addEventListener("input", () => {
28 clearTimeout(timer);
29 const name = input.value.trim().toLowerCase();
30 if (name === "") { status.textContent = ""; return; }
31 timer = setTimeout(async () => {
32 const mine = ++seq;
33 try {
34 const res = await fetch("/start/name?name=" + encodeURIComponent(name), { headers: { accept: "application/json" } });
35 const said = await res.json();
36 if (mine === seq && typeof said.message === "string") status.textContent = said.message;
37 } catch { /* the button still works: the door answers on submit */ }
38 }, 250);
39 });
40 })();`;
41
42 /** The offer and the form. `refusal` is the last try's refusal, said on this same screen. */
43 export function startForm(refusal?: StartRefusal): Html {
44 return html`<form method="post" action="/start" class="stack-tight door-start" id="start-forge">
45 <p class="door-support"><b>Your own forge, free to try.</b> Name it and an AI crew starts building for you.</p>
46 ${field({
47 label: "Name your forge",
48 name: "name",
49 required: true,
50 value: refusal?.name ?? "",
51 placeholder: "my-forge",
52 spellcheck: false,
53 autocomplete: "off",
54 maxlength: 38,
55 ...(refusal === undefined ? {} : { error: refusal.message }),
56 })}
57 <p class="small muted" id="start-name-status" role="status" aria-live="polite"></p>
58 <div>${btn("Start my forge", { variant: "primary", size: "lg" })}</div>
59 </form>`;
60 }
test/design-audit.test.ts +1 −1
@@ -15,7 +15,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8");
1515
1616 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1717 const NOT_PAGES =
18 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|explore\.atom$|docs(\/[^/]*)?$|audit\.csv$|settings\/traffic\/scrapes\/export$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1(\/|$))/;
18 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|start\/name$|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|explore\.atom$|docs(\/[^/]*)?$|audit\.csv$|settings\/traffic\/scrapes\/export$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1(\/|$))/;
1919
2020 const getRoutes = [
2121 ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g),
test/front.test.ts +1 −1
@@ -558,7 +558,7 @@ describe("the front page", () => {
558558 expect(closed).toContain('href="/owner/door-second/report"');
559559 expect(closed).toContain(">Suggest a change<");
560560 expect(closed).not.toContain(">Help build<");
561 expect(closed).toContain('href="/signup"');
561 expect(closed).toContain('href="/#start"');
562562 expect(closed).toContain("New accounts on this forge are by invitation.");
563563 changeSignup(app, admin, "open");
564564 expect((await put(adminToken, { pinned: [], hidden: [] })).status).toBe(200);
test/start-forge.test.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * The home page's door for a person (#11153): a name and one button start a
3 * forge and land the person on its claim page (a username and a password, no
4 * link to copy); the name's "free / taken" line answers; a refusal is said on
5 * the home page; the crew's missing credit names both ways forward.
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 } from "../src/app.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { bootHostedChild } from "../src/spawn/host.ts";
14 import { usePlacement, useProbe } from "../src/spawn/door.ts";
15 import { resetAnonSpawns } from "../src/spawn/rate.ts";
16 import { createUser } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { ok } from "../src/kernel/result.ts";
19 import { crewKeyCard } from "../src/web/studio/crew_key.ts";
20
21 describe("start a forge from the home page", () => {
22 let dataDir: string;
23 let app: App;
24 let servers: Servers;
25 let base: string;
26 let placed = 0;
27 const started: string[] = [];
28
29 const start = (name: string) =>
30 fetch(`${base}/start`, {
31 method: "POST",
32 redirect: "manual",
33 headers: { "content-type": "application/x-www-form-urlencoded", origin: base },
34 body: new URLSearchParams({ name }),
35 });
36
37 beforeAll(async () => {
38 dataDir = await mkdtemp(join(tmpdir(), "forge-start-"));
39 app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge-code" });
40 resetAnonSpawns(app.db);
41 servers = await serve(app);
42 base = servers.url;
43 const a = await createUser(app.db, {
44 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
45 password: "a-good-passphrase", adminIfFirst: true,
46 });
47 if (!a.ok) throw new Error("user");
48 const code = await createRepo(app, a.value, { name: "forge-code", visibility: "public" });
49 if (!code.ok) throw new Error("code repo");
50 await commitToBranch(app, a.value, code.value, {
51 message: "forge v1",
52 files: [{ path: "src/main.ts", content: "console.log('forge')" }],
53 });
54 useProbe(app, () => async () => true);
55 usePlacement(app, {
56 place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${++placed}` }),
57 retire: async () => ok(),
58 });
59 }, 30_000);
60
61 afterAll(async () => {
62 for (const n of started) await bootHostedChild(app, n);
63 servers.stop();
64 app.close();
65 await rm(dataDir, { recursive: true, force: true });
66 });
67
68 test("the first view carries the offer, one labelled field and one button", async () => {
69 const html = await (await fetch(`${base}/`)).text();
70 expect(html).toContain('id="start-forge"');
71 expect(html).toContain("Your own forge, free to try");
72 expect(html).toContain("Name your forge");
73 expect(html).toContain("Start my forge");
74 });
75
76 test("/agents sends a person to the home page", async () => {
77 const html = await (await fetch(`${base}/agents`)).text();
78 expect(html).toContain('href="/#start"');
79 });
80
81 test("a name goes straight to the new forge's claim page: no link to copy", async () => {
82 const res = await start("kid");
83 started.push("kid");
84 expect(res.status).toBe(303);
85 expect(res.headers.get("location") ?? "").toMatch(/\/claim#/);
86 expect(res.headers.get("cache-control")).toBe("no-store");
87 });
88
89 test("the name's line says free, taken, or why not", async () => {
90 const said = async (name: string) => (await (await fetch(`${base}/start/name?name=${encodeURIComponent(name)}`)).json()) as any;
91 expect((await said("fresh-one")).free).toBe(true);
92 const taken = await said("kid");
93 expect(taken.free).toBe(false);
94 expect(taken.message).toContain("taken");
95 const bad = await said("Not A Name!");
96 expect(bad.free).toBe(false);
97 expect(bad.message.length).toBeGreaterThan(0);
98 });
99
100 test("a taken name is refused on the home page, with what to do", async () => {
101 const res = await start("kid");
102 expect(res.status).toBe(409);
103 const html = await res.text();
104 expect(html).toContain("That name is taken. Pick another.");
105 expect(html).toContain('id="start-forge"');
106 expect(html).toContain('value="kid"');
107 });
108
109 test("a start from another origin is refused", async () => {
110 const res = await fetch(`${base}/start`, {
111 method: "POST", redirect: "manual",
112 headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example" },
113 body: new URLSearchParams({ name: "evil" }),
114 });
115 expect(res.status).toBe(403);
116 });
117
118 test("no crew credit says so once, with both ways forward", () => {
119 const ctx = { app, auth: { user: { isAdmin: false } } } as any;
120 const text = String(crewKeyCard(ctx));
121 expect(text).toContain("needs a model key");
122 expect(text).not.toContain("paid plan"); // no host to point at here
123 // A hosted child names its host's plans.
124 const { writeFileSync } = require("node:fs");
125 writeFileSync(join(dataDir, "parent.json"), JSON.stringify({ upstream: "https://forge.example" }));
126 const said = String(crewKeyCard(ctx));
127 expect(said).toContain("https://forge.example/#yours");
128 expect(said).toContain("pick a paid plan");
129 expect(said.match(/needs a model key/g)?.length).toBe(1);
130 });
131 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs