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
mason/forge:mason/11153-a-person-who-opens-the-forge-s-link-can → main 3 commits fork
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> { | ||
| 194 | 194 | } |
| 195 | 195 | } |
| 196 | 196 | |
| 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 | ||
| 197 | 202 | async function writeLedger(app: App, ledger: Ledger): Promise<void> { |
| 198 | 203 | await mkdir(join(app.config.dataDir, "spawn"), { recursive: true }); |
| 199 | 204 | const path = ledgerPath(app, ledger.name); |
src/http/server.ts +4
| @@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | import { secureFor } from "./cookie_secure.ts"; |
| 18 | 18 | import { refusedByDatabase, shownRefusal } from "./refusal.ts"; |
| 19 | 19 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 20 | import { startName, startSubmit } from "../web/pages/start-forge.ts"; | |
| 20 | 21 | import { startClaimAccountSubmit, startClaimPage, startClaimSubmit } from "../web/pages/start_claim.ts"; |
| 21 | 22 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; |
| 22 | 23 | import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts"; |
| @@ -828,6 +829,9 @@ export function buildRouter(): Router<Handler> { | ||
| 828 | 829 | // What a page's Content-Security-Policy blocked (http/csp_report.ts): counts only. |
| 829 | 830 | router.add("POST", "/csp-report", cspReportSubmit); |
| 830 | 831 | 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); | |
| 831 | 835 | // A spawned child's claim link: GET shows, POST takes it (spawn/claim.ts). |
| 832 | 836 | router.add("GET", "/claim", claimFragmentPage); |
| 833 | 837 | router.add("POST", "/claim", claimSubmit); |
src/web/pages/agents.ts +1
| @@ -74,6 +74,7 @@ export function agentsPage(ctx: Ctx): Response { | ||
| 74 | 74 | })} |
| 75 | 75 | <section class="stack" aria-labelledby="agent-forge-h"> |
| 76 | 76 | <h2 id="agent-forge-h">Get a forge of your own</h2> |
| 77 | <p><a href="/#start">People: start here.</a></p> | |
| 77 | 78 | <form method="post" action="/forges/new" class="stack" id="agent-forge"> |
| 78 | 79 | ${formField({ label: "Name", name: "name", required: true })} |
| 79 | 80 | ${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 @@ | ||
| 7 | 7 | * echoed, never sent to the host. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { existsSync } from "node:fs"; | |
| 10 | import { existsSync, readFileSync } from "node:fs"; | |
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import type { App } from "../../app.ts"; |
| 13 | 13 | import { listAgents } from "../../core/agents.ts"; |
| @@ -42,6 +42,17 @@ export function includedCredit(app: App): Included | undefined { | ||
| 42 | 42 | /** Whether a forge is a hosted child: its host's file is in its data directory. */ |
| 43 | 43 | export const isHostedForge = (app: App): boolean => existsSync(join(app.config.dataDir, "parent.json")); |
| 44 | 44 | |
| 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 | ||
| 45 | 56 | /** Whether the crew has anything to run on: a key of the owner's, or included credit with money left. */ |
| 46 | 57 | export function crewCanRun(app: App): boolean { |
| 47 | 58 | if (crewHasCredential(app.db)) return true; |
| @@ -79,8 +90,11 @@ export function crewKeyCard(ctx: Ctx, compact = false): Html | null { | ||
| 79 | 90 | credit !== undefined |
| 80 | 91 | ? "The included credit is spent. Add your own key to keep building, or wait for next month." |
| 81 | 92 | : "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>.`; | |
| 82 | 96 | 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> | |
| 84 | 98 | ${admin ? keyField() : null} |
| 85 | 99 | </section>`; |
| 86 | 100 | } |
src/web/studio/home.ts +8 −6
| @@ -85,6 +85,7 @@ import { isNavigation, lastVisit, noteVisit } from "../../core/home_visit.ts"; | ||
| 85 | 85 | import { sinceYouWere } from "./since.ts"; |
| 86 | 86 | import { homeTabs, unshippedAsks } from "./home_tabs.ts"; |
| 87 | 87 | import { crewKeyCard } from "./crew_key.ts"; |
| 88 | import { startForm, startFormJs, type StartRefusal } from "./start_form.ts"; | |
| 88 | 89 | import { everydayCard } from "../pages/everyday.ts"; |
| 89 | 90 | import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts"; |
| 90 | 91 | import { spawnedOrg } from "../../spawn/claim.ts"; |
| @@ -751,7 +752,7 @@ function agentPrompt(origin: string): string { | ||
| 751 | 752 | * `/front` is the same page for anyone; a site admin there also gets the |
| 752 | 753 | * controls that decide it. |
| 753 | 754 | */ |
| 754 | export function studioDoor(ctx: Ctx): Response { | |
| 755 | export function studioDoor(ctx: Ctx, refusal?: StartRefusal, status = 200): Response { | |
| 755 | 756 | // The door draws what is in memory; a pack pushed since is picked up for the next visit. |
| 756 | 757 | void refreshCatalogQuietly(ctx.app, 5_000); |
| 757 | 758 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -787,9 +788,10 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 787 | 788 | <div class="door-under"> |
| 788 | 789 | <div class="door-lead"> |
| 789 | 790 | <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> | |
| 790 | 792 | <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" }))} | |
| 793 | 795 | </div> |
| 794 | 796 | <div class="door-agent"> |
| 795 | 797 | <p> |
| @@ -911,7 +913,7 @@ bun run src/main.ts</code></pre> | ||
| 911 | 913 | title: "forge: software for what you're building, yours to keep", |
| 912 | 914 | description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.", |
| 913 | 915 | body, |
| 914 | scriptJs: `${liveClientJs}\n${liveRegionsJs}`, | |
| 916 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${startFormJs}`, | |
| 915 | 917 | jsonLd: siteStructuredData(origin, running().version), |
| 916 | 918 | ...(visitor |
| 917 | 919 | ? { |
| @@ -920,7 +922,7 @@ bun run src/main.ts</code></pre> | ||
| 920 | 922 | ${signup |
| 921 | 923 | ? html`<a class="small" href="/signin${nextQuery}">Sign in</a> |
| 922 | 924 | ${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> | |
| 924 | 926 | ${btn("Sign in", { href: activeEdgeToken(ctx.app) === undefined ? `/signin${nextQuery}` : edgeLoginUrl(back) })}`}`, |
| 925 | 927 | foot: html`<span class="mark"></span> |
| 926 | 928 | <span>Open Platform: two open source projects, AGPL-3.0</span> |
| @@ -930,7 +932,7 @@ bun run src/main.ts</code></pre> | ||
| 930 | 932 | <a href="https://www.espodev.com">EspoDev</a>`, |
| 931 | 933 | } |
| 932 | 934 | : {}), |
| 933 | }); | |
| 935 | }, status); | |
| 934 | 936 | notePhaseRest(ctx.request, "render", rendering, []); |
| 935 | 937 | return res; |
| 936 | 938 | } |
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"); | ||
| 15 | 15 | |
| 16 | 16 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 17 | 17 | 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(\/|$))/; | |
| 19 | 19 | |
| 20 | 20 | const getRoutes = [ |
| 21 | 21 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
test/front.test.ts +1 −1
| @@ -558,7 +558,7 @@ describe("the front page", () => { | ||
| 558 | 558 | expect(closed).toContain('href="/owner/door-second/report"'); |
| 559 | 559 | expect(closed).toContain(">Suggest a change<"); |
| 560 | 560 | expect(closed).not.toContain(">Help build<"); |
| 561 | expect(closed).toContain('href="/signup"'); | |
| 561 | expect(closed).toContain('href="/#start"'); | |
| 562 | 562 | expect(closed).toContain("New accounts on this forge are by invitation."); |
| 563 | 563 | changeSignup(app, admin, "open"); |
| 564 | 564 | 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 | }); |