From ca98212ca0c309eb15c667bb82cf2b4cf74f8c76 Mon Sep 17 00:00:00 2001 From: Anthony Date: Sat, 1 Aug 2026 04:15:14 +0000 Subject: [PATCH] feat(pit): accept a whole name in the claim box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim box was built for an ending (`eggs`) and hands its value to registerTlds(), which rejects a dotted token. So `scrambled.eggs` — the thing people actually type — came back as "not a valid TLD", even though holding it is just two ordinary steps in order. POST /pit/claim now forks when the value parses as a Moshpit name: claim the ending if it is free, then mint the name under it, and report the name that was asked for rather than the ending it had to take first. Someone else's ending is the one case this cannot finish — minting under it is not ours to do. Whether that name is for sale, taken, or simply unlisted is a question landingFor() already answers, so hand over that card instead of growing a second, thinner copy of the same rules here. A bare ending still goes down the existing list path untouched, and a token that is not a name (`a.b.c`) is still refused rather than being coerced into one. Co-Authored-By: Claude Opus 5 (1M context) --- apps/pwa/src/routes/moshpit.mjs | 42 ++++- .../pwa/test/moshpit-claim-full-name.test.mjs | 144 ++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 apps/pwa/test/moshpit-claim-full-name.test.mjs diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index 5baf93f..940a70a 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -670,7 +670,8 @@ const claimForm = (req, prefill = "") => `
${csrfInput(req)} ${claimDefaults(req)} @@ -1452,7 +1453,46 @@ moshpitRouter.post("/pit/claim-bulk", requireAuth, async (req, res) => { return back(res, result.claimed.length ? { ok: summary } : { err: summary }); }); +/** + * `scrambled.eggs` typed into the claim box, rather than the bare `eggs` it + * was built for. + * + * Someone who wants a name should not have to know that holding it is two + * steps — claim the ending, then mint the name under it. Do both, in that + * order, and report the name they actually asked for. + * + * Someone else's ending is the one case this cannot finish: minting under it + * is not ours to do, and whether it is for sale, taken, or simply unlisted is + * a question `landingFor` already answers. Hand them that card instead of + * inventing a second, thinner version of it here. + */ +async function claimFullName(req, res, { label, tld }) { + const owner = await getTld(tld); + if (owner && owner.user_id !== req.user.id) { + return res.redirect(`/pit?${new URLSearchParams({ name: `${label}.${tld}` })}`); + } + + if (!owner) { + const claim = await registerTlds({ + input: tld, userId: req.user.id, ownerEmail: req.user.email ?? null, + priceUsd: req.body?.price_usd, aliasOf: req.body?.alias_of, + }); + // Lost the ending to a race, or it was reserved/malformed — either way the + // name underneath it cannot follow. + if (!claim.claimed.length) return back(res, { err: summarizeBulkClaim(claim).slice(0, 500) }); + } + + const minted = await registerName({ tld, label, userId: req.user.id, target: null }); + if (!minted.ok) return back(res, { err: minted.error || "could not register that name" }); + back(res, { ok: `${label}.${tld} is yours.` }); +} + moshpitRouter.post("/pit/claim", requireAuth, async (req, res) => { + // A whole name reaches registerTlds() as a dotted token it can only reject, + // so it forks off before the list path rather than failing as a bad ending. + const asked = parseMoshpitName(req.body?.tld); + if (asked) return claimFullName(req, res, asked); + // One ending goes through the same path as a list of one, so the settings // behave identically either way rather than being a bulk-only feature. const result = await registerTlds({ diff --git a/apps/pwa/test/moshpit-claim-full-name.test.mjs b/apps/pwa/test/moshpit-claim-full-name.test.mjs new file mode 100644 index 0000000..fca80a0 --- /dev/null +++ b/apps/pwa/test/moshpit-claim-full-name.test.mjs @@ -0,0 +1,144 @@ +// Typing a whole name into the claim box. +// +// The box was built for an ending (`eggs`) and reached registerTlds(), which +// rejects a dotted token — so `scrambled.eggs`, the thing people actually want, +// came back as "not a valid TLD". Holding a name is really two steps, and the +// form should do both rather than teaching the order to the visitor. +// +// Same harness as moshpit-pit-page.test.mjs: the real router against a +// throwaway libsql file, skipped cleanly when the PWA deps are not installed. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { randomBytes } from "node:crypto"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let deps = null; +try { + deps = { express: require("express") }; +} catch { + deps = null; +} + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-claim-name-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; + +const ME = "u1"; +const THEM = "u2"; + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, db } = await import("../src/db.mjs"); + const { moshpitRouter } = await import("../src/routes/moshpit.mjs"); + const m = await import("../src/moshpit.mjs"); + + await run(`INSERT OR REPLACE INTO users (id,email,display_name,created_at) VALUES ('u1','a@b.c','one',1)`); + await run(`INSERT OR REPLACE INTO users (id,email,display_name,created_at) VALUES ('u2','x@y.z','two',1)`); + + const app = deps.express(); + app.use(deps.express.urlencoded({ extended: false })); + app.use((req, _res, next) => { + req.csrfToken = () => "csrf"; + req.user = { id: ME, email: "a@b.c" }; + next(); + }); + app.use(moshpitRouter); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + + /** POST the claim form and hand back where it sent us, without following. */ + const claim = async (tld) => { + const res = await fetch(`${base}/pit/claim`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ tld }), + redirect: "manual", + }); + return { status: res.status, location: res.headers.get("location") || "" }; + }; + + return { server, db, m, claim }; +} + +let booted = null; +const app = () => (booted ||= boot()); +const uniq = () => `t${randomBytes(4).toString("hex")}`; + +test.after(() => { + if (!booted) return; + booted.then(({ server, db }) => { server.close(); db.close?.(); }) + .finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } }); +}); + +const skip = { skip: !deps && "apps/pwa deps not installed" }; + +test("claim: a whole name under a free ending takes both", skip, async () => { + const { claim, m } = await app(); + const tld = uniq(); + + const { location } = await claim(`scrambled.${tld}`); + + // The flash names what they asked for, not the ending it had to take first. + assert.match(location, /^\/pit\?/); + const q = new URLSearchParams(location.slice("/pit?".length)); + assert.equal(q.get("ok"), `scrambled.${tld} is yours.`); + assert.equal(q.get("tab"), "yours"); + + assert.equal((await m.getTld(tld))?.user_id, ME, "the ending is claimed"); + assert.equal((await m.getName(tld, "scrambled"))?.user_id, ME, "the name is minted"); +}); + +test("claim: a name under an ending you already hold just mints", skip, async () => { + const { claim, m } = await app(); + const tld = uniq(); + await m.registerTld({ tld, userId: ME }); + + const { location } = await claim(`poached.${tld}`); + + const q = new URLSearchParams(location.slice("/pit?".length)); + assert.equal(q.get("ok"), `poached.${tld} is yours.`); + assert.equal((await m.getName(tld, "poached"))?.user_id, ME); +}); + +test("claim: someone else's ending goes to the card, and takes nothing", skip, async () => { + const { claim, m } = await app(); + const tld = uniq(); + await m.registerTld({ tld, userId: THEM }); + + const { location } = await claim(`fried.${tld}`); + + // landingFor() already decides whether that name is for sale, taken, or + // simply unlisted — this must not answer that question a second time. + assert.equal(location, `/pit?name=${encodeURIComponent(`fried.${tld}`)}`); + assert.equal((await m.getTld(tld)).user_id, THEM, "not stolen"); + assert.equal(await m.getName(tld, "fried"), null, "no name minted under someone else's ending"); +}); + +test("claim: a bare ending still behaves exactly as before", skip, async () => { + const { claim, m } = await app(); + const tld = uniq(); + + const { location } = await claim(tld); + + const q = new URLSearchParams(location.slice("/pit?".length)); + assert.ok(q.get("ok"), `expected a success flash, got ${location}`); + assert.equal((await m.getTld(tld))?.user_id, ME); + assert.deepEqual(await m.listNames(tld), [], "an ending on its own mints no names"); +}); + +test("claim: a dotted token that is not a name is still refused", skip, async () => { + const { claim } = await app(); + + // Three labels is not a Moshpit name, so it must not silently become one. + const { location } = await claim("a.b.c"); + const q = new URLSearchParams(location.slice("/pit?".length)); + assert.ok(q.get("err"), `expected a refusal, got ${location}`); +});