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

`GET /forges/name/:name` says whether the home page's Start my forge would take that name: free, taken, or why not #11315

Merged opened this

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

Merged

mason/forge:mason/11311-get-forges-name-name-says-whether-the-ho → main 9 commits fork 2 threads · 2 unresolved

  • Merged. mason/11311-get-forges-name-name-says-whether-the-ho was merged into main as ccd4e50.

Review

Sign in to review this change.
12 files changed +220 −1
docs/features/forge-name.md added +17
@@ -0,0 +1,17 @@
1 # Is a forge name free?
2
3 `GET /forges/name/<name>` says whether the home page's Start my forge would
4 take that name, as `{"verdict":"free"|"taken"|"invalid"|"reserved","why":"…"}`.
5
6 - **Verdicts.** `invalid`: not 1-39 characters of a-z, 0-9 and `-`, or starts or
7 ends with `-`. `reserved`: one of the forge's own words (`admin`, …).
8 `taken`: a forge of that name exists or is being made. Otherwise `free`.
9 - **One rule.** The door (`POST /forges/start`) refuses by the same function
10 (`src/spawn/name_verdict.ts`), so `free` is never a lie.
11 - **Try:** `curl https://<forge>/forges/name/some-new-name`.
12 - **Limits.** More than 60 asks a minute from one address is a `429` with
13 `retry-after`. Every answer is `no-store`.
14 - **In the terminal:** signed out, open the palette (ctrl-k), "Is a forge name
15 free?", type a name, Enter; a toast gives the reason. It uses the same
16 `forgeNameVerdict` (`src/actions/spawn.ts`) as the route.
17 - Tests: `test/forge-name.test.ts`.
src/actions/spawn.ts +12
@@ -12,6 +12,7 @@
1212 * idempotent — and a finished one is answered, not made twice.
1313 */
1414
15 import { nameShapeVerdict, nameVerdict, type NameVerdict } from "../spawn/name_verdict.ts";
1516 import { readdirSync, readFileSync } from "node:fs";
1617 import { mkdir, readdir, rename } from "node:fs/promises";
1718 import { join } from "node:path";
@@ -152,6 +153,8 @@ const SAFE_ID = /^[A-Za-z0-9_-]{1,80}$/;
152153 /** The name the child's sealed model key goes by. */
153154 export const STARTER_KEY = "starter";
154155 const inFlight = new Set<string>();
156 /** Whether a child of this name is being made right now. */
157 export const beingMade = (name: string): boolean => inFlight.has(name);
155158
156159 /** A failure the boards count: the step that broke (a fixed few), never its words. */
157160 function failed(app: App, deps: SpawnDeps, step: "place" | "copy" | "health", message: string) {
@@ -776,3 +779,12 @@ export function listChildrenNow(app: App): Child[] {
776779 }
777780 return out;
778781 }
782
783 /**
784 * Whether Start my forge would take `name`: the web route's answer and the
785 * terminal's. Only a name that passes the shape is looked up in the ledger.
786 */
787 export async function forgeNameVerdict(app: App, name: string): Promise<NameVerdict> {
788 const exists = nameShapeVerdict(name) === undefined && (beingMade(name) || (await childNamed(app, name)) !== undefined);
789 return nameVerdict(name, exists);
790 }
src/core/reserved.ts +3
@@ -227,6 +227,9 @@ export function mixedScript(name: string): boolean {
227227
228228 const SYSTEM = new Set(SYSTEM_NAMES.map(skeleton));
229229
230 /** Whether `name` is one of the forge's own words, by skeleton. */
231 export const isSystemName = (name: string): boolean => SYSTEM.has(skeleton(name));
232
230233 /** Why a name is reserved: a system word, the crew's, or the owner's. */
231234 export type Reservation = "system" | "agent" | "owner" | "mixed";
232235
src/http/server.ts +2
@@ -274,6 +274,7 @@ import {
274274 tellSubmit,
275275 } from "../web/studio/actions.ts";
276276 import { forgeReady } from "../web/pages/forge-ready.ts";
277 import { forgeName } from "../web/pages/forge-name.ts";
277278 import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim.ts";
278279 import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts";
279280 import { familyPage } from "../web/pages/family.ts";
@@ -850,6 +851,7 @@ export function buildRouter(): Router<Handler> {
850851 // "+ New forge": the spawn door as a page, any signed-in person (spawn/door.ts).
851852 router.add("GET", "/forges", forgesPage);
852853 router.add("GET", "/family", familyPage);
854 router.add("GET", "/forges/name/:name", forgeName);
853855 router.add("POST", "/forges/:name/claim-link", forgeLinkSubmit);
854856 router.add("POST", "/forges/:name/revoke-link", forgeLinkRevoke);
855857 router.add("POST", "/forges/:name/undo-link", forgeLinkUndo);
src/spawn/door.ts +7
@@ -6,6 +6,7 @@
66 * the caller says `false` — returns the plan and changes nothing.
77 */
88
9 import { nameShapeVerdict } from "./name_verdict.ts";
910 import { linkRequestVisitor } from "../perf/visitor_link.ts";
1011 import { startWatch, probeOf, type Probe } from "./live.ts";
1112 import { firstProof, reachWords, settleChild } from "./reach.ts";
@@ -289,6 +290,12 @@ export async function spawnDoor(
289290 planKey = chosen.key;
290291 size = chosen.size;
291292 }
293 // The same word the name field asks about, so a name it calls free is one the door takes.
294 // A bad shape is refused by the plan below, in the same words.
295 if (typeof raw["name"] === "string") {
296 const said = nameShapeVerdict(raw["name"]);
297 if (said?.verdict === "reserved") return invalid("name", said.why);
298 }
292299 const input = {
293300 name: raw["name"],
294301 org: raw["name"],
src/spawn/name_verdict.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * Whether the home page's Start my forge would take a name: free, taken,
3 * invalid (not a name's shape) or reserved (one of the forge's own words).
4 * One answer for `GET /forges/name/<name>` and for `spawnDoor`, so "free" is
5 * never a lie. Plain data in, plain data out.
6 */
7 import { isSystemName } from "../core/reserved.ts";
8 import { NAME } from "./plan.ts";
9
10 export type NameVerdict = {
11 readonly verdict: "free" | "taken" | "invalid" | "reserved";
12 readonly why: string;
13 };
14
15 /** What the name alone says: invalid or reserved, or undefined when it passes both. */
16 export function nameShapeVerdict(name: string): NameVerdict | undefined {
17 if (!NAME.test(name))
18 return { verdict: "invalid", why: 'name must be 1-39 characters of a-z, 0-9 and "-", not starting or ending with "-"' };
19 if (isSystemName(name)) return { verdict: "reserved", why: `${name} is one of the forge's own words` };
20 return undefined;
21 }
22
23 /** The verdict for `name`; `exists` says whether a child of that name is in the ledger or being made. */
24 export function nameVerdict(name: string, exists: boolean): NameVerdict {
25 const shape = nameShapeVerdict(name);
26 if (shape !== undefined) return shape;
27 if (exists) return { verdict: "taken", why: `a forge named ${name} already exists` };
28 return { verdict: "free", why: `${name} is free` };
29 }
src/spawn/plan.ts +2 −1
@@ -34,7 +34,8 @@ 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 /** The shape of a forge's name, as the door takes it. */
38 export const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
3839 const PLAN_KEY = /^[a-z][a-z0-9-]{0,19}$/;
3940 const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
4041
src/tui/forge.ts +2
@@ -20,6 +20,7 @@ import { dataCommand } from "./screens/data.ts";
2020 import { packsCommand } from "./screens/packs.ts";
2121 import { getForgeCommand } from "./screens/getforge.ts";
2222 import { startForgeCommand } from "./screens/startforge.ts";
23 import { forgeNameCommand } from "./screens/forgename.ts";
2324 import { workCommand } from "./screens/work.ts";
2425 import { shellsScreen } from "./screens/shells.ts";
2526 import { forkTreeScreen } from "./screens/fork_tree.ts";
@@ -245,6 +246,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
245246 packsCommand(session, ctx),
246247 getForgeCommand(session, ctx),
247248 ...startForgeCommand(session, ctx),
249 ...forgeNameCommand(session, ctx),
248250 ...dataCommand(session, ctx),
249251 fleetCommand(session, ctx),
250252 {
src/tui/screens/forgename.ts added +32
@@ -0,0 +1,32 @@
1 /**
2 * "Is a forge name free?" in the terminal: type a name and be told whether
3 * Start my forge would take it, by the same `forgeNameVerdict` as
4 * `GET /forges/name/<name>`. Offered to a guest, beside Start my forge.
5 */
6
7 import { forgeNameVerdict } from "../../actions/spawn.ts";
8 import type { Ctx } from "../app/app.ts";
9 import type { Session } from "../session.ts";
10 import { blank, promptScreen } from "./common.ts";
11
12 export const forgeNameCommand = (session: Session, ctx: Ctx) =>
13 session.signedIn
14 ? []
15 : [
16 {
17 id: "forge-name",
18 title: "Is a forge name free?",
19 hint: "ask whether Start my forge would take a name",
20 run: () =>
21 ctx.push(
22 promptScreen(ctx, blank("Is a forge name free?"), {
23 title: "Is a forge name free?",
24 label: "Forge name",
25 onSubmit: async (raw) => {
26 const v = await forgeNameVerdict(session.app, raw.trim().toLowerCase());
27 ctx.toast(v.why, v.verdict === "free" ? "success" : "danger");
28 },
29 }),
30 ),
31 },
32 ];
src/web/pages/forge-name.ts added +32
@@ -0,0 +1,32 @@
1 /**
2 * `GET /forges/name/<name>`: whether Start my forge would take that name, as
3 * `{"verdict":"free"|"taken"|"invalid"|"reserved","why":"…"}`. The rules are
4 * `spawn/name_verdict.ts`, the ones the door applies. Strangers are counted
5 * per address, as `forge-ready.ts` counts them.
6 */
7 import { RateLimiter } from "../../core/ratelimit.ts";
8 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
9 import type { App } from "../../app.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { clientBucket } from "../../http/peer.ts";
12 import { json } from "../../http/respond.ts";
13 import { forgeNameVerdict } from "../../actions/spawn.ts";
14
15 /** Asks per minute from one address: a field checking as someone types fits. */
16 export const NAME_PER_MINUTE = 60;
17
18 const limiters = new WeakMap<App, RateLimiter>();
19 const NO_STORE = { "cache-control": "no-store" };
20
21 export async function forgeName(ctx: Ctx): Promise<Response> {
22 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
outdated

Both doors: the terminal has no way to ask whether a name is free. Add a src/tui screen (as forgeready.ts does) with its entry per AGENTS.md, using nameVerdict, plus a test, and write docs/features/forge-name.md.

23 let limiter = limiters.get(ctx.app);
24 if (limiter === undefined) {
25 limiter = new RateLimiter({ perMinute: NAME_PER_MINUTE, adminPerMinute: NAME_PER_MINUTE, enabled: true });
26 limiters.set(ctx.app, limiter);
27 }
28 const taken = limiter.take(clientBucket(ctx.request), "ro");
29 if (!taken.allowed)
30 return json({ error: "rate_limited" }, 429, { ...NO_STORE, "retry-after": String(Math.max(1, taken.retryAfter)) });
31 return json(await forgeNameVerdict(ctx.app, ctx.params.name ?? ""), 200, NO_STORE);
32 }
test/forge-name.test.ts added +81
@@ -0,0 +1,81 @@
1 /** GET /forges/name/<name>: free, taken, invalid or reserved, as the door would say it. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp } from "../src/app.ts";
7 import { serve } from "../src/http/server.ts";
8 import { NAME_PER_MINUTE } from "../src/web/pages/forge-name.ts";
9
10 let dir = "";
11 let app: ReturnType<typeof createApp>;
12 let servers: Awaited<ReturnType<typeof serve>>;
13
14 beforeAll(async () => {
15 dir = await mkdtemp(join(tmpdir(), "forge-name-"));
16 app = createApp({ dataDir: dir, port: 0 });
17 await mkdir(join(dir, "spawn"), { recursive: true });
18 await writeFile(join(dir, "spawn", "acme.json"), JSON.stringify({ name: "acme", bodyId: "b1", done: true }));
19 servers = await serve(app);
20 });
21
22 afterAll(async () => {
23 servers.stop();
24 app.close();
25 await rm(dir, { recursive: true, force: true });
26 });
27
28 const ask = async (name: string) => {
29 const res = await fetch(`${servers.url}/forges/name/${encodeURIComponent(name)}`);
30 return { res, body: (await res.json()) as { verdict?: string; why?: string } };
31 };
32
33 test("free, taken, invalid and reserved", async () => {
34 expect((await ask("fresh-one")).body.verdict).toBe("free");
35 expect((await ask("acme")).body.verdict).toBe("taken");
36 for (const bad of ["Bad Name!", "x".repeat(41), "-a"]) expect((await ask(bad)).body.verdict).toBe("invalid");
37 const admin = await ask("admin");
38 expect(admin.body.verdict).toBe("reserved");
39 expect(admin.body.why).toBeTruthy();
40 });
41
42 test("the answer is not cached and keeps the security floor", async () => {
43 const { res } = await ask("fresh-two");
44 expect(res.headers.get("cache-control")).toBe("no-store");
45 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
46 });
47
48 test("the door refuses what the verdict calls reserved or invalid", async () => {
49 const { spawnDoor } = await import("../src/spawn/door.ts");
50 const ctx = { app, request: new Request(`${servers.url}/`), params: {}, auth: {} } as never;
51 for (const name of ["admin", "Bad Name!"]) {
52 const door = await spawnDoor(ctx, undefined, { name, dryRun: true });
53 expect(door.status).toBe(422);
54 expect(String(door.body["message"])).toContain("name");
55 expect(door.body["error"]).toBe("invalid");
56 }
57 });
58
59 test("past the per-minute count an address is answered 429 with retry-after", async () => {
60 let last = new Response();
61 for (let i = 0; i < NAME_PER_MINUTE + 2; i++) last = await fetch(`${servers.url}/forges/name/spam`);
62 expect(last.status).toBe(429);
63 expect(Number(last.headers.get("retry-after"))).toBeGreaterThanOrEqual(1);
64 });
65
66 test("the terminal asks the same question: offered to a guest only, and says what the route says", async () => {
67 const { forgeNameCommand } = await import("../src/tui/screens/forgename.ts");
68 const { forgeNameVerdict } = await import("../src/actions/spawn.ts");
69 const toasts: string[] = [];
70 let pushed: { key: (e: unknown) => boolean } | undefined;
71 const ctx = { push: (s: never) => (pushed = s), toast: (m: string) => toasts.push(m), invalidate: () => {} } as never;
72 expect(forgeNameCommand({ app, signedIn: true } as never, ctx)).toEqual([]);
73 const [cmd] = forgeNameCommand({ app, signedIn: false } as never, ctx);
74 expect(cmd?.id).toBe("forge-name");
75 cmd?.run();
76 expect(pushed).toBeDefined();
77 for (const name of ["admin", "Bad Name!", "acme", "some-new-name"]) {
78 const v = await forgeNameVerdict(app, name.toLowerCase());
79 expect(v.verdict).toBe({ admin: "reserved", "Bad Name!": "invalid", acme: "taken", "some-new-name": "free" }[name] as never);
80 }
81 });
test/palette-roster.test.ts +1
@@ -370,6 +370,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
370370 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
371371 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
372372 ["start-forge", "Start my forge", "a free forge of your own; you choose a username and password next"],
373 ["forge-name", "Is a forge name free?", "ask whether Start my forge would take a name"],
373374 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
374375 ["profile", "Open a profile", "username or org"],
375376 ["repo", "Open a repository", "owner/name"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs