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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11311-get-forges-name-name-says-whether-the-ho → main 9 commits fork 2 threads · 2 unresolved
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 @@ | ||
| 12 | 12 | * idempotent — and a finished one is answered, not made twice. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { nameShapeVerdict, nameVerdict, type NameVerdict } from "../spawn/name_verdict.ts"; | |
| 15 | 16 | import { readdirSync, readFileSync } from "node:fs"; |
| 16 | 17 | import { mkdir, readdir, rename } from "node:fs/promises"; |
| 17 | 18 | import { join } from "node:path"; |
| @@ -152,6 +153,8 @@ const SAFE_ID = /^[A-Za-z0-9_-]{1,80}$/; | ||
| 152 | 153 | /** The name the child's sealed model key goes by. */ |
| 153 | 154 | export const STARTER_KEY = "starter"; |
| 154 | 155 | 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); | |
| 155 | 158 | |
| 156 | 159 | /** A failure the boards count: the step that broke (a fixed few), never its words. */ |
| 157 | 160 | function failed(app: App, deps: SpawnDeps, step: "place" | "copy" | "health", message: string) { |
| @@ -776,3 +779,12 @@ export function listChildrenNow(app: App): Child[] { | ||
| 776 | 779 | } |
| 777 | 780 | return out; |
| 778 | 781 | } |
| 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 { | ||
| 227 | 227 | |
| 228 | 228 | const SYSTEM = new Set(SYSTEM_NAMES.map(skeleton)); |
| 229 | 229 | |
| 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 | ||
| 230 | 233 | /** Why a name is reserved: a system word, the crew's, or the owner's. */ |
| 231 | 234 | export type Reservation = "system" | "agent" | "owner" | "mixed"; |
| 232 | 235 |
src/http/server.ts +2
| @@ -274,6 +274,7 @@ import { | ||
| 274 | 274 | tellSubmit, |
| 275 | 275 | } from "../web/studio/actions.ts"; |
| 276 | 276 | import { forgeReady } from "../web/pages/forge-ready.ts"; |
| 277 | import { forgeName } from "../web/pages/forge-name.ts"; | |
| 277 | 278 | import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim.ts"; |
| 278 | 279 | import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts"; |
| 279 | 280 | import { familyPage } from "../web/pages/family.ts"; |
| @@ -850,6 +851,7 @@ export function buildRouter(): Router<Handler> { | ||
| 850 | 851 | // "+ New forge": the spawn door as a page, any signed-in person (spawn/door.ts). |
| 851 | 852 | router.add("GET", "/forges", forgesPage); |
| 852 | 853 | router.add("GET", "/family", familyPage); |
| 854 | router.add("GET", "/forges/name/:name", forgeName); | |
| 853 | 855 | router.add("POST", "/forges/:name/claim-link", forgeLinkSubmit); |
| 854 | 856 | router.add("POST", "/forges/:name/revoke-link", forgeLinkRevoke); |
| 855 | 857 | router.add("POST", "/forges/:name/undo-link", forgeLinkUndo); |
src/spawn/door.ts +7
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * the caller says `false` — returns the plan and changes nothing. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { nameShapeVerdict } from "./name_verdict.ts"; | |
| 9 | 10 | import { linkRequestVisitor } from "../perf/visitor_link.ts"; |
| 10 | 11 | import { startWatch, probeOf, type Probe } from "./live.ts"; |
| 11 | 12 | import { firstProof, reachWords, settleChild } from "./reach.ts"; |
| @@ -289,6 +290,12 @@ export async function spawnDoor( | ||
| 289 | 290 | planKey = chosen.key; |
| 290 | 291 | size = chosen.size; |
| 291 | 292 | } |
| 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 | } | |
| 292 | 299 | const input = { |
| 293 | 300 | name: raw["name"], |
| 294 | 301 | 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; | ||
| 34 | 34 | |
| 35 | 35 | /** One line saying what a fresh app is. */ |
| 36 | 36 | 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])?$/; | |
| 38 | 39 | const PLAN_KEY = /^[a-z][a-z0-9-]{0,19}$/; |
| 39 | 40 | const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/; |
| 40 | 41 |
src/tui/forge.ts +2
| @@ -20,6 +20,7 @@ import { dataCommand } from "./screens/data.ts"; | ||
| 20 | 20 | import { packsCommand } from "./screens/packs.ts"; |
| 21 | 21 | import { getForgeCommand } from "./screens/getforge.ts"; |
| 22 | 22 | import { startForgeCommand } from "./screens/startforge.ts"; |
| 23 | import { forgeNameCommand } from "./screens/forgename.ts"; | |
| 23 | 24 | import { workCommand } from "./screens/work.ts"; |
| 24 | 25 | import { shellsScreen } from "./screens/shells.ts"; |
| 25 | 26 | import { forkTreeScreen } from "./screens/fork_tree.ts"; |
| @@ -245,6 +246,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 245 | 246 | packsCommand(session, ctx), |
| 246 | 247 | getForgeCommand(session, ctx), |
| 247 | 248 | ...startForgeCommand(session, ctx), |
| 249 | ...forgeNameCommand(session, ctx), | |
| 248 | 250 | ...dataCommand(session, ctx), |
| 249 | 251 | fleetCommand(session, ctx), |
| 250 | 252 | { |
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)); | |
| 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", () => { | ||
| 370 | 370 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 371 | 371 | ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"], |
| 372 | 372 | ["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"], | |
| 373 | 374 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 374 | 375 | ["profile", "Open a profile", "username or org"], |
| 375 | 376 | ["repo", "Open a repository", "owner/name"], |
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.