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… #11156

Parked opened this in answer to
Closed without merging

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

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
17 files changed +491 −33
README.md +4
@@ -52,3 +52,7 @@ One dependency is vendored for browsers: [ghostty-web](https://github.com/coder/
5252 0.4.0 (MIT, `src/web/vendor/ghostty-web`), the terminal on a machine's shell page —
5353 about 75 KB of script and a 423 KB WebAssembly file, both cached immutable and
5454 loaded on that page only. See [NOTICE](NOTICE).
55
56 ## Start a forge from the home page
57
58 A visitor types a name on `/` and presses "Start my forge" (`src/web/pages/start.ts`): the name is checked live at `GET /start/check`, `POST /start` goes through the same spawn door as `/forges/new` (free plan, visitor rate limits), and the person lands on the new forge's claim page to choose a username and password. No dependency added.
docs/design/audit.md +2
@@ -132,6 +132,8 @@ Machine routes are not pages and have no entry: `/audit.csv`,
132132 - `/login` — `http/server.ts` (`signinAlias`) · the address people type to sign in · it is `/signin`, keeping a same-origin `next` · none (308, no page of its own).
133133 - `/sign-in` — `http/server.ts` (`signinAlias`) · the same, hyphenated · it is `/signin` · none (308, no page of its own).
134134 - `/signup` — `pages/site.ts` · make an account · `/signup` passkey flow (`signup-passkey.ts`) · colours 3 (file).
135 - `/start/check` — `pages/start.ts` · live check of the name typed on the front door · `/` · none (a fragment).
136 - `/start/ready` — `pages/start.ts` · whether the new forge is awake yet · `/claim` · none (a fragment).
135137 - `/signup/requested` — `pages/site.ts` · what a stranger whose request waits is told · `/signin` · colours 3 (file).
136138 - `/verify` — `pages/site.ts` · confirm an email address · none · colours 3 (file).
137139 - `/recover` — `pages/passkeys.ts` · get back in without a password · `/settings/passkeys` · none seen.
src/actions/spawn.ts +1 −1
@@ -159,7 +159,7 @@ const fail = (message: string) => err<SpawnError>({ kind: "failed", message });
159159
160160 export const childDirOf = (app: App, bodyId: string): string =>
161161 join(app.config.dataDir, "children", bodyId);
162 const ledgerPath = (app: App, name: string): string =>
162 export const ledgerPath = (app: App, name: string): string =>
163163 join(app.config.dataDir, "spawn", `${name}.json`);
164164
165165 /** Live child URL from name + the parent's origin (ignores stale ledger.url), so old subdomain ledgers still resolve. */
src/core/reserved.ts +1
@@ -113,6 +113,7 @@ export const SYSTEM_NAMES: readonly string[] = [
113113 "plugins",
114114 "signin",
115115 "signup",
116 "start",
116117 "sitemap.xml",
117118 "releases",
118119 "releases.json",
src/http/server.ts +5
@@ -274,6 +274,7 @@ import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/page
274274 import { familyPage } from "../web/pages/family.ts";
275275 import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, forgesPage } from "../web/pages/forges.ts";
276276 import { forgeItSubmit } from "../web/pages/forge-it.ts";
277 import { startCheck, startReady, startSubmit } from "../web/pages/start.ts";
277278 import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts";
278279 import { askedPage } from "../web/studio/asked_page.ts";
279280 import { notesOverrule, notesSave, notesView } from "../web/pages/notes.ts";
@@ -314,6 +315,7 @@ import {
314315 liveHomeFragment,
315316 liveOnYouFragment,
316317 studioDoor,
318
317319 } from "../web/studio/home.ts";
318320 import { appAnswersFragment } from "../web/studio/answers.ts";
319321 import { appNowFragment } from "../web/studio/now.ts";
@@ -844,6 +846,9 @@ export function buildRouter(): Router<Handler> {
844846 router.add("POST", "/forges/:name/stop", forgeStopSubmit);
845847 router.add("GET", "/forges/:name/remove", forgeRemoveConfirm);
846848 router.add("POST", "/forges/:name/remove", forgeRemoveSubmit);
849 router.add("GET", "/start/check", startCheck);
850 router.add("GET", "/start/ready", startReady);
851 router.add("POST", "/start", (ctx) => startSubmit(ctx, studioDoor));
847852 router.add("GET", "/forges/new", newForgePage);
848853 router.add("POST", "/forges/new", newForgeSubmit);
849854 // The front page as a stranger sees it, for anyone; a site admin there
src/spawn/claim.ts +17
@@ -18,6 +18,7 @@
1818 */
1919
2020 import type { Database } from "bun:sqlite";
21 import { readFileSync } from "node:fs";
2122 import { join } from "node:path";
2223 import type { App } from "../app.ts";
2324 import {
@@ -68,6 +69,22 @@ export interface Spawned {
6869 readonly statusHash: string | undefined;
6970 }
7071
72 /**
73 * The forge that spawned this one, from `parent.json`, when this forge was
74 * spawned and the file names an http(s) address. Read synchronously and only
75 * on the rare page that needs it (a crew with no credit says where plans are).
76 */
77 export function parentUpstream(app: App): string | undefined {
78 try {
79 const parsed = JSON.parse(readFileSync(join(app.config.dataDir, "parent.json"), "utf8")) as { upstream?: unknown };
80 return typeof parsed.upstream === "string" && /^https?:\/\/[^\s"<>]+$/.test(parsed.upstream)
81 ? parsed.upstream.replace(/\/+$/, "")
82 : undefined;
83 } catch {
84 return undefined;
85 }
86 }
87
7188 /** The organisation this forge was spawned as, or undefined if it was not spawned. */
7289 export async function spawnedOrg(app: App): Promise<Spawned | undefined> {
7390 const file = Bun.file(join(app.config.dataDir, "parent.json"));
src/spawn/door.ts +3
@@ -52,6 +52,9 @@ export function useProbe(app: App, probe: (url: string) => Probe): void {
5252 probes.set(app, probe);
5353 }
5454
55 /** The probe of a child's public address: the test's stand-in when one is set. */
56 export const probeFor = (app: App, url: string): Probe => (probes.get(app) ?? probeOf)(url);
57
5558 /** Tests (and an external placement, one day) swap where children live. */
5659 export function usePlacement(app: App, placement: Placement): void {
5760 placements.set(app, placement);
src/spawn/plan.ts +1 −1
@@ -34,7 +34,7 @@ export const MAX_BUDGET_USD = 1000;
3434
3535 /** One line saying what a fresh app is. */
3636 export const MAX_FRESH_CHARS = 200;
37 const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
37 export const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
3838 const PLAN_KEY = /^[a-z][a-z0-9-]{0,19}$/;
3939 const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
4040
src/web/pages/agents.ts +1 −1
@@ -70,7 +70,7 @@ export function agentsPage(ctx: Ctx): Response {
7070 description:
7171 "What this forge is and how an AI assistant can help you use it: build one of its public apps, report a problem, or get a forge of your own.",
7272 body: html`${pageHead("For AI assistants", {
73 sub: html`What this forge is. Plain text: <a href="/llms.txt">/llms.txt</a>`,
73 sub: html`<a href="/llms.txt">/llms.txt</a>. People: <a href="/#start">start here</a>.`,
7474 })}
7575 <section class="stack" aria-labelledby="agent-forge-h">
7676 <h2 id="agent-forge-h">Get a forge of your own</h2>
src/web/pages/start.ts added +204
@@ -0,0 +1,204 @@
1 /**
2 * "Start my forge" (#11153): the home page's one field and one button for a
3 * stranger. The name is checked as it is typed (`GET /start/check`), the post
4 * goes through the same door as `/forges/new` and the API (`spawnDoor`: the
5 * free plan, the visitor's rate limits), and the answer is one hop to the new
6 * forge's claim page, where the person chooses a username and a password and
7 * is signed in. The claim code rides the link's fragment, so nobody copies it.
8 *
9 * The hop is a page with a link and one line of script, not a 303: the page's
10 * `form-action 'self'` stops a browser following a form post to another origin.
11 * A refusal is said on the home page itself, above the same form.
12 */
13
14 import { ledgerPath } from "../../actions/spawn.ts";
15 import { reservedName } from "../../core/reserved.ts";
16 import { childUrlOf, noRoom, probeFor, spawnDoor } from "../../spawn/door.ts";
17 import { NAME } from "../../spawn/plan.ts";
18 import type { Ctx } from "../../http/context.ts";
19 import { json } from "../../http/respond.ts";
20 import { field, readForm } from "../form.ts";
21 import { html, type Html } from "../html.ts";
22 import { page, redirect } from "../layout.ts";
23 import { btn, field as formField } from "../ui.ts";
24 import { pageHead } from "../chrome.ts";
25
26 /** What a name is, for the live line under the field. */
27 export interface NameState {
28 readonly free: boolean;
29 readonly message: string;
30 }
31
32 const cleaned = (raw: string): string => raw.trim().toLowerCase();
33
34 /** Whether a name is held: an existing forge, or a word the forge keeps (its own, the crew's, the owner's). */
35 async function held(ctx: Ctx, name: string): Promise<boolean> {
36 return reservedName(ctx.app.db, name) !== undefined || (await Bun.file(ledgerPath(ctx.app, name)).exists());
37 }
38
39 /** The first near name that is free, for "taken" to offer. */
40 async function suggestion(ctx: Ctx, name: string): Promise<string | undefined> {
41 for (const tail of ["-forge", "-2", "-3", "-app"]) {
42 const next = `${name.slice(0, 39 - tail.length)}${tail}`;
43 if (NAME.test(next) && !(await held(ctx, next))) return next;
44 }
45 return undefined;
46 }
47
48 export async function nameState(ctx: Ctx, raw: string): Promise<NameState & { suggest?: string }> {
49 const name = cleaned(raw.slice(0, 80));
50 if (name === "") return { free: false, message: "" };
51 if (!NAME.test(name))
52 return { free: false, message: "Use 1 to 39 lowercase letters, digits and dashes, not starting or ending with a dash." };
53 if (await held(ctx, name)) {
54 const suggest = await suggestion(ctx, name);
55 return {
56 free: false,
57 message: `${name} is taken.${suggest === undefined ? " Pick another name." : ` Try ${suggest}.`}`,
58 ...(suggest === undefined ? {} : { suggest }),
59 };
60 }
61 return { free: true, message: `${name} is free.` };
62 }
63
64 // GET /start/ready?name= — whether the new forge's health answers. Only a
65 // name this forge made is asked about, at the address it derives itself.
66 export async function startReady(ctx: Ctx): Promise<Response> {
67 const name = cleaned((ctx.url.searchParams.get("name") ?? "").slice(0, 80));
68 const known = NAME.test(name) && (await Bun.file(ledgerPath(ctx.app, name)).exists());
69 const ready = known && (await probeFor(ctx.app, childUrlOf(ctx.app, name))());
70 return json({ ready }, 200, { "cache-control": "no-store" });
71 }
72
73 // GET /start/check?name= — a stat call, never a spawn.
74 export async function startCheck(ctx: Ctx): Promise<Response> {
75 const state = await nameState(ctx, ctx.url.searchParams.get("name") ?? "");
76 return json(state, 200, { "cache-control": "no-store" });
77 }
78
79 /** The field, its live line and the one button. `error` is a refusal to say again. */
80 export function startForm(ctx: Ctx, values: { name?: string; error?: string } = {}): Html {
81 return html`<form method="post" action="/start" class="stack-tight door-start" id="start" data-address="${childUrlOf(ctx.app, "NAME")}">
82 ${formField({
83 label: "Name your forge",
84 name: "name",
85 id: "start-name",
86 value: values.name ?? "",
87 required: true,
88 spellcheck: false,
89 hint: `Its address: ${childUrlOf(ctx.app, "your-name")}`,
90 })}
91 <p class="small" id="start-status" role="status" aria-live="polite"></p>
92 ${values.error === undefined ? null : html`<p class="error" id="start-error" role="alert">${values.error}</p>`}
93 ${btn("Start my forge", { variant: "primary", size: "lg" })}
94 </form>`;
95 }
96
97 /** The live check: waits for a pause in typing, then says free or taken. */
98 export const startJs = /* js */ `
99 (function () {
100 var input = document.getElementById('start-name');
101 var out = document.getElementById('start-status');
102 if (!input || !out || !window.fetch) return;
103 var timer = 0, seq = 0;
104 var hint = document.getElementById('start-name-hint');
105 var address = (document.getElementById('start') || {}).dataset.address || '';
106 input.setAttribute('autocomplete', 'off');
107 input.setAttribute('autocapitalize', 'none');
108 input.addEventListener('input', function () {
109 clearTimeout(timer);
110 var at = input.selectionStart;
111 var lower = input.value.toLowerCase();
112 if (lower !== input.value) { input.value = lower; try { input.setSelectionRange(at, at); } catch (e) {} }
113 if (hint && address) hint.textContent = 'Its address: ' + address.replace('NAME', lower.trim() || 'your-name');
114 var mine = ++seq, value = input.value;
115 if (!value.trim()) { out.textContent = ''; return; }
116 timer = setTimeout(function () {
117 fetch('/start/check?name=' + encodeURIComponent(value), { credentials: 'omit' })
118 .then(function (r) { return r.json(); })
119 .then(function (s) {
120 if (mine !== seq) return;
121 out.textContent = s.message;
122 out.className = 'small ' + (s.free ? 'muted' : 'error');
123 })
124 .catch(function () {});
125 }, 250);
126 });
127 })();
128 `;
129
130 /** Holds the person here until the new forge answers, then goes: bounded, and honest when it never does. */
131 export const waitJs = /* js */ `
132 (function () {
133 var box = document.getElementById('setup');
134 if (!box || !window.fetch) return;
135 var name = box.dataset.name, claim = box.dataset.claim;
136 var until = Date.now() + 120000, wait = 1000;
137 function late() {
138 document.getElementById('setup-status').textContent = 'Setting up ' + name + ' has not finished.';
139 document.getElementById('setup-late').hidden = false;
140 }
141 function ask() {
142 if (Date.now() > until) return late();
143 fetch('/start/ready?name=' + encodeURIComponent(name), { credentials: 'omit' })
144 .then(function (r) { return r.json(); })
145 .then(function (s) { if (s.ready) location.replace(claim); else setTimeout(ask, wait = Math.min(wait + 500, 3000)); })
146 .catch(function () { setTimeout(ask, 3000); });
147 }
148 ask();
149 })();
150 `;
151
152 /** What to do next, per refusal: never a bare error. */
153 function refusal(status: number, message: string): string {
154 if (status === 429)
155 return `${message}. Wait a little and press the button again, or run your own forge from the steps further down.`;
156 if (status === 422) return `${message.replace(/^(name|org): /, "")}. Pick another name.`;
157 if (status === 409 && /exists|already/.test(message)) return `That name is taken. Pick another name.`;
158 if (status === 409) return `${message}. Try again in a little while, or run your own forge from the steps further down.`;
159 return "Your forge could not be made just now. Try again in a minute, or run your own forge from the steps further down.";
160 }
161
162 /** The home page again, as the refusal's status: 429 stays, everything else is the person's to fix (422). */
163 const refused = (res: Response, status: number): Response =>
164 new Response(res.body, { status: status === 429 ? 429 : 422, headers: res.headers });
165
166 // POST /start
167 export async function startSubmit(
168 ctx: Ctx,
169 /** The home page again, with the refusal above its form. */
170 again: (ctx: Ctx, start: { name: string; error: string }) => Response,
171 ): Promise<Response> {
172 const form = await readForm(ctx, undefined);
173 if (form instanceof Response) return form;
174 const name = cleaned(field(form, "name"));
175 // A signed-in person has the full form, with their own claim link to send.
176 if (ctx.auth.user !== undefined)
177 return redirect(`/forges/new?name=${encodeURIComponent(name.slice(0, 38))}`, { url: ctx.url, forgeUrl: ctx.app.config.url });
178 const full = noRoom(ctx.app);
179 if (full !== undefined) return refused(again(ctx, { name, error: refusal(409, `This forge has no room for another forge right now (${full})`) }), 409);
180 const state = await nameState(ctx, name);
181 if (!state.free && state.message !== "" && /is taken/.test(state.message))
182 return refused(again(ctx, { name, error: state.message }), 422);
183 const door = await spawnDoor(ctx, undefined, { name, dryRun: false });
184 const claim = door.body["claimUrl"];
185 if (door.status < 400 && typeof claim === "string") {
186 const response = page({
187 ctx,
188 title: `Setting up ${name}`,
189 body: html`<div class="narrow stack" id="setup" data-name="${name}" data-claim="${claim}">
190 ${pageHead(`Setting up ${name}`)}
191 <p role="status" aria-live="polite" id="setup-status">Setting up ${name}…</p>
192 <p id="setup-late" hidden>${name} is taking longer than it should. It may still come up:
193 ${btn("Open it anyway", { href: claim, variant: "primary", size: "lg" })}</p>
194 <noscript><p>${btn("Open my forge", { href: claim, variant: "primary", size: "lg" })}</p></noscript>
195 </div>`,
196 scriptJs: waitJs,
197 });
198 response.headers.set("cache-control", "no-store");
199 return response;
200 }
201 const status = door.status < 400 ? 409 : door.status;
202 const message = door.status < 400 ? "exists: already" : String(door.body["message"] ?? "");
203 return refused(again(ctx, { name, error: refusal(status, message) }), status);
204 }
src/web/pages/waking.ts +17 −3
@@ -5,6 +5,8 @@
55 * client or an API call gets a sentence (`spawn/sleep.ts` `wakingText`).
66 */
77
8 import { readFileSync } from "node:fs";
9 import { ledgerPath, parseLedger } from "../../actions/spawn.ts";
810 import type { App } from "../../app.ts";
911 import type { Ctx } from "../../http/context.ts";
1012 import { wakingText } from "../../spawn/sleep.ts";
@@ -12,21 +14,33 @@ import { btn, empty } from "../ui.ts";
1214 import { html } from "../html.ts";
1315 import { page } from "../layout.ts";
1416
17 /** A forge made in the last ten minutes was never asleep: it is being set up. */
18 const NEW_MS = 10 * 60_000;
19 function isNew(app: App, name: string): boolean {
20 try {
21 const made = parseLedger(readFileSync(ledgerPath(app, name), "utf8"))?.createdAt;
22 return made !== undefined && Date.now() - Date.parse(made) < NEW_MS;
23 } catch {
24 return false;
25 }
26 }
27
1528 /** Waking (`why` undefined, 503) or refused (`why` a sentence, 429), as a page when the visitor asks for HTML. */
1629 export function wakingResponse(app: App, request: Request, name: string, why?: string, retryS = 5): Response {
1730 if (!(request.headers.get("accept") ?? "").includes("text/html")) return wakingText(name, why, retryS);
1831 const url = new URL(request.url);
32 const fresh = why === undefined && isNew(app, name);
1933 const ctx: Ctx = { app, request, url, params: {}, auth: { user: undefined, scope: undefined, via: "none" }, theme: undefined };
2034 const response = page(
2135 {
2236 ctx,
2337 bare: true,
24 title: why === undefined ? "Waking up" : "Not now",
38 title: why !== undefined ? "Not now" : fresh ? "Setting up" : "Waking up",
2539 body: html`<div class="narrow-wide">
2640 ${empty({
2741 icon: "history",
28 title: why === undefined ? "Waking up" : "Not now",
29 note: why ?? `${name} was asleep. It is waking up and this page tries again by itself in a few seconds.`,
42 title: why !== undefined ? "Not now" : fresh ? "Setting up" : "Waking up",
43 note: why ?? (fresh ? `${name} is being set up and this page tries again by itself in a few seconds.` : `${name} was asleep. It is waking up and this page tries again by itself in a few seconds.`),
3044 action: btn("Try again", { href: url.pathname, variant: "primary" }),
3145 })}
3246 </div>`,
src/web/studio/crew.ts +7 −3
@@ -30,6 +30,7 @@ import { listCrewRoles, type Agent, type CrewRole } from "../../core/agents.ts";
3030 import { getAppByRepo } from "../../core/apps.ts";
3131 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
3232 import { seatsOf, type Seat } from "../../crew/seats.ts";
33 import { parentUpstream } from "../../spawn/claim.ts";
3334 import { crewHasCredential } from "../../core/subscriptions.ts";
3435 import { autonomyStatus } from "../../crew/autonomy.ts";
3536 import { governorSeen } from "../../crew/governed.ts";
@@ -423,10 +424,13 @@ export function settingsLead(ctx: Ctx, repo: Repo): Html {
423424 */
424425 export function crewKeyNote(ctx: Ctx): Html | null {
425426 if (crewHasCredential(ctx.app.db)) return null;
427 const plans = parentUpstream(ctx.app);
426428 return html`<p class="small muted" id="crew-key-note" role="status">
427 ${icon("alert")} The crew cannot start work yet: it needs a subscription.
429 ${icon("alert")} The crew cannot start work yet: no credit.
428430 ${ctx.auth.user?.isAdmin
429 ? html`<a href="/settings/subscriptions">Add one in Subscriptions</a>.`
430 : "Ask this forge's admin to add one."}
431 ? html`<a href="/settings/subscriptions">Add your own credit</a>${plans === undefined
432 ? ""
433 : html`, or <a href="${plans}/pricing">pick a paid plan</a>`}.`
434 : "Ask this forge's admin to add credit or a plan."}
431435 </p>`;
432436 }
src/web/studio/home.ts +31 −21
@@ -88,6 +88,7 @@ import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts";
8888 import { spawnedOrg } from "../../spawn/claim.ts";
8989 import { clientApps } from "./client_home.ts";
9090 import { passkeyJs } from "../passkey-client.ts";
91 import { startForm, startJs } from "../pages/start.ts";
9192 import { SITE_LINE } from "../site_line.ts";
9293
9394 /** The events that can change what is on you. */
@@ -98,6 +99,12 @@ const APPS_LIVE = "app.serving,app.stopped,run.finished,fleet.operated";
9899 /** How many of your apps the rail names before "All apps". */
99100 const RAIL_APPS = 8;
100101
102 /** A new forge's first screen says the next step in one line (#11153). */
103 function nextLine(): Html {
104 return html`<p id="next-line"><b>Next:</b> <a href="/apps/new">make an app</a> or
105 <a href="/migrate">bring your code</a>. The crew builds it with credit you add or a paid plan.</p>`;
106 }
107
101108 /**
102109 * Your apps, one step away (#599): a narrow column on a desk — each app's
103110 * name and how production is — and the verbs that were the shelf's head.
@@ -319,7 +326,7 @@ export async function studioHome(
319326 ) {
320327 const apps = await clientApps(ctx, user);
321328 const lean = html`<h1 class="sr-only">Home</h1>
322 ${firstSteps} ${address} ${crewKeyNote(ctx)} ${heldLine(ctx, user)} ${apps}
329 ${spawned === undefined ? null : nextLine()} ${firstSteps} ${address} ${crewKeyNote(ctx)} ${heldLine(ctx, user)} ${apps}
323330 <div class="stack">
324331 ${composer(ctx, user)}
325332 <p class="small muted">
@@ -750,7 +757,7 @@ function agentPrompt(origin: string): string {
750757 * `/front` is the same page for anyone; a site admin there also gets the
751758 * controls that decide it.
752759 */
753 export function studioDoor(ctx: Ctx): Response {
760 export function studioDoor(ctx: Ctx, start?: { name: string; error: string }): Response {
754761 // The door draws what is in memory; a pack pushed since is picked up for the next visit.
755762 void refreshCatalogQuietly(ctx.app, 5_000);
756763 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -775,7 +782,8 @@ export function studioDoor(ctx: Ctx): Response {
775782 const back = ctx.url.pathname === "/" ? "/" + ctx.url.search : "/";
776783 const nextQuery = back === "/" ? "" : `?next=${encodeURIComponent(back)}`;
777784 const ssh = sshCommand(ctx.app);
778 const signup = offersSignup(ctx) && accounts;
785 const offers = offersSignup(ctx);
786 const signup = offers && accounts;
779787 // Closed is a door with nothing behind it; invite still lands an invite link's holder.
780788 // The plans setting is read only when a link is set: no link, no extra statement on the home.
781789 const priced = BILLING_SIZES.some((z) => links[z] !== undefined) ? getPlans(ctx.app.db).filter((p) => p.priceUsd > 0) : [];
@@ -789,27 +797,29 @@ export function studioDoor(ctx: Ctx): Response {
789797 <h1>Software for what you're building.<br /><span class="muted">Yours to keep.</span></h1>
790798 <div class="door-under">
791799 <div class="door-lead">
792 <p class="door-support">Say what you need. An AI crew builds it, for a business or a game studio.</p>
793 <div class="bar">
794 ${btn("Get your own forge", { href: "#yours", variant: "primary", size: "lg" })}
795 ${when(made.length > 0, () => btn("See what it built", { href: "#made", size: "lg" }))}
796 </div>
797 <div class="door-agent">
798 <p>
799 Have an AI assistant? Give it this:
800 <a href="https://chatgpt.com/?q=${asked}" rel="noopener">Open in ChatGPT</a>
801 <a href="https://grok.com/?q=${asked}" rel="noopener">Open in Grok</a>
802 <a href="https://claude.ai/new?q=${asked}" rel="noopener">Open in Claude</a>
803 <span class="muted">(after you sign in)</span>
804 </p>
805 ${copyField("door-prompt", prompt, "A message for any AI assistant", "Copy the message")}
806 </div>
800 <p class="door-support">Your own forge, free to try: say what you need and an AI crew builds it.</p>
801 ${offers
802 ? startForm(ctx, start)
803 : btn("Get your own forge", { href: "#yours", variant: "primary", size: "lg" })}
804 ${when(made.length > 0, () => html`<p class="small"><a href="#made">See what it built</a></p>`)}
807805 </div>
808806 ${doorLive(front)}
809807 </div>
810808 ${doorPulse(front.pulse.days)}
811809 </section>
812810
811 <section class="door-section door-agent" id="assistant">
812 <p>Have an AI assistant? Give it this:</p>
813 <div class="bar">
814 ${btn("Open in ChatGPT", { href: `https://chatgpt.com/?q=${asked}`, size: "lg" })}
815 ${btn("Open in Grok", { href: `https://grok.com/?q=${asked}`, size: "lg" })}
816 ${btn("Open in Claude", { href: `https://claude.ai/new?q=${asked}`, size: "lg" })}
817 </div>
818 <p class="muted small">After you sign in.</p>
819 ${copyField("door-prompt", prompt, "A message for any AI assistant", "Copy the message")}
820 ${signup ? html`<p>Rather have an account first? ${btn("Create an account", { href: `/signup${nextQuery}`, size: "lg" })}</p>` : null}
821 </section>
822
813823 <section class="door-section" id="made">
814824 <h2>Made here</h2>
815825 <p class="door-support">
@@ -926,16 +936,16 @@ bun run src/main.ts</code></pre>
926936 title: "forge: software for what you're building, yours to keep",
927937 description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.",
928938 body,
929 scriptJs: `${liveClientJs}\n${liveRegionsJs}`,
939 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${startJs}`,
930940 jsonLd: siteStructuredData(origin, running().version),
931941 ...(visitor
932942 ? {
933943 tools: html`<a class="small door-wide" href="#made">Made here</a>
934944 <a class="small door-wide" href="${FORGE_REPO}">Code</a>
935945 ${signup
936 ? html`<a class="small" href="/signin${nextQuery}">Sign in</a>
937 ${btn("Create an account", { href: `/signup${nextQuery}` })}`
946 ? html`<a class="small" href="/signin${nextQuery}">Sign in</a>`
938947 : html`${when(mode !== "closed", () => html`<a class="small door-wide" href="/signup">Create an account</a>`)}
948 ${btn("Start my forge", { href: "#start" })}
939949 ${btn("Sign in", { href: activeEdgeToken(ctx.app) === undefined ? `/signin${nextQuery}` : edgeLoginUrl(back) })}`}`,
940950 foot: html`<span class="mark"></span>
941951 <span>Open Platform: two open source projects, AGPL-3.0</span>
src/web/style/content.ts +2 −2
@@ -260,8 +260,8 @@ form.inline { display: inline; margin-left: auto; }
260260 .door-under { display: grid; gap: 56px; margin-top: 28px; }
261261 .door-lead { display: flex; flex-direction: column; align-items: flex-start; gap: 24px; min-width: 0; }
262262 .door-lead .bar { gap: 8px; flex-wrap: wrap; }
263 .door-agent { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 520px; font-size: 13px; color: var(--muted-fg); }
264 .door-agent a { margin-left: 8px; color: var(--fg); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--border-strong); }
263 .door-agent { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; max-width: 520px; font-size: 14px; color: var(--muted-fg); }
264 .door-agent .btn-icon { min-width: 44px; min-height: 44px; }
265265 /* On the input's own tinted ground the muted color falls short of AA; the words are the thing to copy. */
266266 .door-agent .input { color: var(--fg); }
267267 .door-live { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
test/forge-looks.test.ts +1
@@ -47,6 +47,7 @@ const NOT_A_PAGE: Record<string, string> = {
4747 "/first-admin": "the setup form for a forge with no admin yet",
4848 "/signin": "a sign-in form, not a page of the forge",
4949 "/signup": "a sign-up form, not a page of the forge",
50 "/start": "the start-a-forge form, a POST door; covered by test/start-forge.test.ts",
5051 "/recover": "an account-recovery form, not a page of the forge",
5152 "/verify": "answers 400 without the emailed code",
5253 "/logout": "a mutation, answers 404 to a GET",
test/forge-sleep.test.ts +1 −1
@@ -165,7 +165,7 @@ describe("a hosted forge scales to zero", () => {
165165 expect(page.status).toBe(503);
166166 expect(page.headers.get("refresh")).toBe("3");
167167 const text = await page.text();
168 expect(text).toContain("Waking up");
168 expect(text).toContain("Setting up"); // made moments ago in this test, so never asleep
169169 expect(text).not.toContain("style=");
170170 expect(wakeGate(parent, new Request("https://forge.test/", { headers: { host: CHILD_HOST } }))).toBeUndefined();
171171 });
test/start-forge.test.ts added +193
@@ -0,0 +1,193 @@
1 /**
2 * "Start my forge" (#11153): the home page of a stranger carries the one
3 * field and the one button; the name is checked live; the post makes a forge
4 * through the spawn door and hands the person to its claim page; a refusal is
5 * said on the home page. A new forge with no crew credit says its two ways forward.
6 */
7 import { afterAll, beforeAll, 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 } from "../src/app.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { serve } from "../src/http/server.ts";
15 import { usePlacement, useProbe } from "../src/spawn/door.ts";
16 import { bootHostedChild } from "../src/spawn/host.ts";
17 import { ok } from "../src/kernel/result.ts";
18 import { crewKeyNote } from "../src/web/studio/crew.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 let dir = "";
24 let app: ReturnType<typeof createApp>;
25 let servers: Awaited<ReturnType<typeof serve>>;
26 let roomLeft = true;
27 const spawned: string[] = [];
28
29 beforeAll(async () => {
30 dir = await mkdtemp(join(tmpdir(), "forge-start-"));
31 app = createApp({ dataDir: dir, port: 0 });
32 useProbe(app, () => async () => true);
33 usePlacement(app, {
34 place: async (plan: { name: string }) => ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${plan.name}` }),
35 retire: async () => ok(),
36 room: () => (roomLeft ? undefined : { message: "the test host is full" }),
37 } as never);
38 servers = await serve(app);
39 });
40 afterAll(async () => {
41 for (const name of spawned) await bootHostedChild(app, name);
42 servers.stop();
43 app.close();
44 await rm(dir, { recursive: true, force: true });
45 });
46
47 const post = (name: string, headers: Record<string, string> = {}) =>
48 fetch(servers.url + "/start", {
49 method: "POST",
50 redirect: "manual",
51 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, ...headers },
52 body: new URLSearchParams({ name }),
53 });
54
55 test("a stranger's home has a labelled field and one Start my forge button", async () => {
56 const home = await (await fetch(servers.url + "/")).text();
57 expect(home).toContain('action="/start"');
58 expect(home).toMatch(/<label[^>]*for="start-name"[^>]*>\s*Name your forge/);
59 expect(home).toContain("Start my forge");
60 expect(home).toContain("free to try");
61 // The form asks for the name only.
62 const form = home.slice(home.indexOf('id="start"'), home.indexOf("</form>", home.indexOf('id="start"')));
63 expect(form.match(/<input/g)?.length).toBe(1);
64 });
65
66 test("the live check says free, taken, and what a bad name needs", async () => {
67 const say = async (n: string) => (await (await fetch(`${servers.url}/start/check?name=${encodeURIComponent(n)}`)).json()) as { free: boolean; message: string };
68 expect(await say("Fresh-One")).toMatchObject({ free: true, message: "fresh-one is free." });
69 expect((await say("-bad")).free).toBe(false);
70 expect((await say("")).message).toBe("");
71 const made = await post("taken-one");
72 expect(made.status).toBe(200);
73 spawned.push("taken-one");
74 expect(await say("taken-one")).toMatchObject({ free: false });
75 });
76
77 test("the post makes the forge and hands over its claim link, once, uncached", async () => {
78 const res = await post("hello-forge");
79 spawned.push("hello-forge");
80 expect(res.status).toBe(200);
81 expect(res.headers.get("cache-control")).toBe("no-store");
82 const page = await res.text();
83 expect(page).toMatch(/href="https?:\/\/hello-forge--[^"]*\/claim#[A-Za-z0-9_-]{43}"/);
84 // The person waits here until the new forge's health answers, then goes.
85 expect(page).toContain("Setting up hello-forge…");
86 expect(page).toContain('id="setup" data-name="hello-forge"');
87 expect(page).not.toContain("is ready");
88 const ready = (await (await fetch(`${servers.url}/start/ready?name=hello-forge`)).json()) as { ready: boolean };
89 expect(ready.ready).toBe(true);
90 // Only a forge this one made is asked about.
91 expect(((await (await fetch(`${servers.url}/start/ready?name=nobody-here`)).json()) as { ready: boolean }).ready).toBe(false);
92 });
93
94 test("a forge that does not answer yet is not ready", async () => {
95 useProbe(app, () => async () => false);
96 const said = (await (await fetch(`${servers.url}/start/ready?name=hello-forge`)).json()) as { ready: boolean };
97 useProbe(app, () => async () => true);
98 expect(said.ready).toBe(false);
99 });
100
101 test("reserved and existing names read taken with one suggestion", async () => {
102 const say = async (n: string) => (await (await fetch(`${servers.url}/start/check?name=${n}`)).json()) as { free: boolean; message: string };
103 const forge = await say("forge");
104 expect(forge.free).toBe(false);
105 expect(forge.message).toMatch(/^forge is taken\. Try [a-z0-9-]+\.$/);
106 const refused = await post("forge");
107 expect(refused.status).toBe(422);
108 expect(await refused.text()).toContain("forge is taken.");
109 expect(await Bun.file(join(dir, "spawn", "forge.json")).exists()).toBe(false);
110 });
111
112 test("the first view is the sentence, the field and one button; the rest is below", async () => {
113 const home = await (await fetch(servers.url + "/")).text();
114 const hero = home.slice(home.indexOf('class="door-hero"'), home.indexOf('id="assistant"'));
115 expect(hero).not.toContain("Open in ChatGPT");
116 expect(hero).not.toContain("Create an account");
117 expect(home).toContain("Open in ChatGPT");
118 expect(home).toContain("Copy the message");
119 // The address hint is a pattern the page fills as the name is typed, never a literal <name>.
120 expect(home).not.toContain("&lt;name&gt;");
121 expect(home).toContain('data-address="');
122 expect(home).toContain("Its address: ");
123 });
124
125 test("a forge made minutes ago is being set up, not waking", async () => {
126 const { wakingResponse } = await import("../src/web/pages/waking.ts");
127 const html = (name: string) =>
128 wakingResponse(app, new Request(servers.url + "/", { headers: { accept: "text/html" } }), name).text();
129 const fresh = await html("hello-forge");
130 expect(fresh).toContain("being set up");
131 expect(fresh).not.toContain("was asleep");
132 expect(await html("never-made")).toContain("was asleep");
133 });
134
135 test("a taken name is said on the home page with what to do", async () => {
136 const res = await post("taken-one");
137 expect(res.status).toBe(422);
138 const page = await res.text();
139 expect(page).toContain('id="start-error"');
140 expect(page).toContain("taken-one is taken. Try ");
141 expect(page).toContain('value="taken-one"');
142 expect(page).not.toContain("/claim#");
143 });
144
145 test("a bad name and no room are said in words, never a dead end", async () => {
146 expect(await (await post("Bad_Name!")).text()).toContain("Pick another name.");
147 roomLeft = false;
148 const full = await post("no-room-yet");
149 roomLeft = true;
150 expect(full.status).toBe(422);
151 expect(await full.text()).toContain("run your own forge");
152 });
153
154 test("a post from another origin is refused", async () => {
155 const res = await post("evil-one", { origin: "https://evil.example" });
156 expect(res.status).toBeGreaterThanOrEqual(400);
157 expect(await Bun.file(join(dir, "spawn", "evil-one.json")).exists()).toBe(false);
158 });
159
160 test("the strangers' rate limit shows as a refusal on the home page", async () => {
161 let last: Response | undefined;
162 for (let i = 0; i < 30; i++) {
163 last = await post(`rate-${i}`);
164 spawned.push(`rate-${i}`);
165 if (last.status === 429) break;
166 }
167 expect(last?.status).toBe(429);
168 expect(await last!.text()).toContain("Wait a little");
169 });
170
171 test("a signed-in person is sent to the full form with the name", async () => {
172 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
173 if (!made.ok) throw new Error(made.error);
174 const session = createSession(app.db, made.value.id);
175 const res = await post("mine-now", { cookie: `forge_session=${typeof session === "string" ? session : (session as { token: string }).token}` });
176 expect([200, 303]).toContain(res.status);
177 });
178
179 test("with no crew credit the note names both ways forward", () => {
180 const note = crewKeyNote({ app, auth: { user: { isAdmin: true } } } as never)?.toString() ?? "";
181 expect(note).toContain("Add your own credit");
182 expect(note).toContain("/settings/subscriptions");
183 });
184
185 test("a spawned forge's note also names the parent's plans", async () => {
186 await Bun.write(join(dir, "parent.json"), JSON.stringify({ upstream: "https://forge.example/" }));
187 const note = crewKeyNote({ app, auth: { user: { isAdmin: true } } } as never)?.toString() ?? "";
188 expect(note).toContain('href="https://forge.example/pricing"');
189 expect(note).toContain("pick a paid plan");
190 const reader = crewKeyNote({ app, auth: { user: { isAdmin: false } } } as never)?.toString() ?? "";
191 expect(reader).toContain("admin to add credit or a plan");
192 await rm(join(dir, "parent.json"));
193 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs