diff --git a/apps/pwa/package-lock.json b/apps/pwa/package-lock.json index 8095617..d9b678e 100644 --- a/apps/pwa/package-lock.json +++ b/apps/pwa/package-lock.json @@ -18,7 +18,7 @@ "web-push": "^3.6.7" }, "devDependencies": { - "@moshcoder/moshpit-name": "^0.1.1" + "@moshcoder/moshpit-name": "^0.2.0" }, "engines": { "node": ">=20" @@ -181,9 +181,9 @@ ] }, "node_modules/@moshcoder/moshpit-name": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@moshcoder/moshpit-name/-/moshpit-name-0.1.1.tgz", - "integrity": "sha512-ltZbKeUljHSQSjFeOvxZBT8AWEnRkj+W0QpjBkADSgHp9T4IidK1cpuRAsGRVY+vt7gltSFh2531jhGHhRG5RA==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@moshcoder/moshpit-name/-/moshpit-name-0.2.0.tgz", + "integrity": "sha512-FcIv8TkonMfBwVD/gBiQKI34jSK38qtr6rKmjFX2grPtIyNqrKqv15eSpKsgja1/uasUbFC9Y0pck4jbOsBLiw==", "dev": true, "license": "MIT", "bin": { diff --git a/apps/pwa/package.json b/apps/pwa/package.json index 7ad046d..bf46eee 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -24,6 +24,6 @@ "web-push": "^3.6.7" }, "devDependencies": { - "@moshcoder/moshpit-name": "^0.1.1" + "@moshcoder/moshpit-name": "^0.2.0" } } diff --git a/apps/pwa/src/lib/moshpit-name.mjs b/apps/pwa/src/lib/moshpit-name.mjs index d044e50..8395773 100644 --- a/apps/pwa/src/lib/moshpit-name.mjs +++ b/apps/pwa/src/lib/moshpit-name.mjs @@ -224,6 +224,14 @@ export const DEFAULT_TLD_PRICE_USD = MAX_CHILD_PRICE_USD; * Deduplicated on the normalised form, so `.Eggs`, `eggs` and `EGGS` in one * paste are one claim rather than one claim and two "already taken" errors * against yourself. + * + * Both halves of the namespace come through the same field. `eggs` and `.eggs` + * are the ending; `blue.eggs` and `.blue.eggs` are a name under it. The leading + * dot is decoration in either case — people type the shape they saw written + * down, and refusing one spelling of the thing they already own would be a + * distinction without a difference. Which one a line meant is recorded on the + * entry rather than resolved here, because parsing is not the place that knows + * who holds `.eggs`. */ export function parseTldList(input, limit = MAX_BULK_TLDS) { // Records split on newlines, commas and semicolons; fields inside a record @@ -243,9 +251,13 @@ export function parseTldList(input, limit = MAX_BULK_TLDS) { for (const record of records) { const fields = record.split(/\s+/).filter(Boolean); - const tld = normalizeToken(fields[0]); - if (!tld || seen.has(tld)) continue; - seen.add(tld); + const target = parseListToken(fields[0]); + if (!target) continue; + // Keyed on the whole thing, so `.eggs` and `blue.eggs` in one paste are two + // entries rather than one swallowing the other. + const key = target.label ? `${target.label}.${target.tld}` : target.tld; + if (seen.has(key)) continue; + seen.add(key); // Counted rather than silently dropped: "I pasted 300 and got 200" needs to // be visible, or the missing hundred look like they failed for some other @@ -262,12 +274,35 @@ export function parseTldList(input, limit = MAX_BULK_TLDS) { if (price !== null) priceUsd = price; else aliasOf = normalizeToken(field); } - entries.push({ tld, aliasOf, priceUsd }); + entries.push({ tld: target.tld, label: target.label, aliasOf, priceUsd }); } // `tlds` alongside `entries` because most callers only want the names, and - // making every one of them map over the records would be noise. - return { entries, tlds: entries.map((e) => e.tld), skipped }; + // making every one of them map over the records would be noise. `names` is + // the same courtesy for the other half, and the two are disjoint: an entry is + // an ending or a name under one, never both. + return { + entries, + tlds: entries.filter((e) => !e.label).map((e) => e.tld), + names: entries.filter((e) => e.label).map((e) => ({ tld: e.tld, label: e.label })), + skipped, + }; +} + +/** + * One pasted token, as the ending or the name it was written as. + * + * A token that reads as neither comes back as an ending carrying its own bad + * text — `a.b.c` is not silently dropped, it is handed on so the caller can + * reject it by name and say why. Losing it here would turn "you pasted + * something wrong" into a line that never appears in the report at all. + */ +function parseListToken(value) { + const raw = normalizeToken(value); + if (!raw) return null; + if (!raw.includes(".")) return { tld: raw, label: null }; + const name = parseMoshpitName(raw); + return name ? { tld: name.tld, label: name.label } : { tld: raw, label: null }; } function normalizeToken(value) { diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index 5a728f0..3da438a 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -703,19 +703,51 @@ export async function registerTlds({ const taken = []; const rejected = []; const settingsFailed = []; + const names = []; + const namesMine = []; + const namesTaken = []; + const namesRejected = []; // Validation first, in memory. A reserved or malformed ending never needs a // round trip to be refused, and filtering here keeps the batches below to // things that can actually land. const candidates = []; + const nameCandidates = []; for (const entry of entries) { const tld = normalizeTld(entry.tld); if (!tld) { rejected.push({ tld: entry.tld, error: "not a valid TLD — letters, digits and dashes only, no dots" }); continue; } const why = tldRejection(tld); if (why) { rejected.push({ tld, error: why }); continue; } + + if (entry.label) { + const label = normalizeLabel(entry.label); + if (!label) { + namesRejected.push({ tld: `${entry.label}.${tld}`, error: "not a valid name — letters, digits and dashes only" }); + continue; + } + nameCandidates.push({ tld, label }); + continue; + } + candidates.push({ ...entry, tld }); } + // A name has nowhere to live until its ending exists, so the endings the + // pasted names imply are claimed alongside the ones pasted outright. Pasting + // `blue.eggs` when you hold nothing means you wanted `.eggs` too — the + // alternative is refusing the line and making the operator paste the halves + // in two passes, in the right order, to get the same result. + // + // Implied or explicit, a claim is a claim: these land in `claimed` and are + // reported like any other, because quietly acquiring an ending someone did + // not read themselves asking for is the one outcome worth being loud about. + const wanted = new Set(candidates.map((c) => c.tld)); + for (const { tld } of nameCandidates) { + if (wanted.has(tld)) continue; + wanted.add(tld); + candidates.push({ tld, aliasOf: null, priceUsd: null }); + } + const at = Date.now(); for (const chunk of chunksOf(candidates, chunkSize)) { @@ -775,7 +807,28 @@ export async function registerTlds({ if (updates.length || logs.length) await db.batch([...updates, ...logs], "write"); } - return { claimed, mine, taken, rejected, settingsFailed, skipped, remaining: [], attempted: entries.length }; + // Names last, once every ending above has settled. `registerName` re-checks + // ownership rather than trusting the loop: an ending in this batch may have + // gone to someone else a moment ago, and "you do not own .agent" is the + // honest report for a name under it — not the "no dots allowed" that the + // parser used to give, which described the paste rather than the problem. + for (const { tld, label } of nameCandidates) { + const full = `${label}.${tld}`; + const result = await registerName({ tld, label, userId }); + if (result.ok) { names.push(full); continue; } + if (result.taken) { + const existing = await getName(tld, label); + (existing && existing.user_id === userId ? namesMine : namesTaken).push(full); + continue; + } + namesRejected.push({ tld: full, error: result.error }); + } + + return { + claimed, mine, taken, rejected, settingsFailed, + names, namesMine, namesTaken, namesRejected, + skipped, remaining: [], attempted: entries.length, + }; } function* chunksOf(list, size) { @@ -798,14 +851,35 @@ export function summarizeBulkClaim(result, limit = MAX_BULK_TLDS) { // The single-ending case says the plain thing. "claimed 1 — .eggs." is what a // batch report looks like, and the commonest path through this page is one // ending typed into one box. - const onlyClaimed = result.claimed.length === 1 && !result.mine.length && !result.taken.length - && !result.rejected.length && !result.settingsFailed?.length && !result.skipped - && !result.remaining?.length; + // A name is shown as written — `blue.eggs`, not `.blue.eggs` — because the + // leading dot belongs to an ending and putting one on a name would spell it + // as something you cannot claim. + const showNames = (list, n = 6) => + list.slice(0, n).join(", ") + (list.length > n ? ` +${list.length - n} more` : ""); + + const nameCount = result.names?.length || 0; + const quiet = !result.mine.length && !result.taken.length && !result.rejected.length + && !result.settingsFailed?.length && !result.skipped && !result.remaining?.length + && !result.namesMine?.length && !result.namesTaken?.length && !result.namesRejected?.length; + + const onlyClaimed = quiet && result.claimed.length === 1 && !nameCount; if (onlyClaimed) return `.${result.claimed[0]} is yours.`; + // The same plain sentence for the other half. Claiming `.eggs` on the way to + // `blue.eggs` is one intention, so it reads as one result rather than as a + // claim report with a name bolted to it. + if (quiet && nameCount === 1 && result.claimed.length <= 1) return `${result.names[0]} is yours.`; + const parts = []; if (result.claimed.length) parts.push(`claimed ${result.claimed.length} — ${show(result.claimed)}`); + if (nameCount) parts.push(`registered ${nameCount} — ${showNames(result.names)}`); if (result.mine.length) parts.push(`${result.mine.length} already yours`); + if (result.namesMine?.length) parts.push(`${result.namesMine.length} name${result.namesMine.length > 1 ? "s" : ""} already yours`); + if (result.namesTaken?.length) parts.push(`${result.namesTaken.length} name${result.namesTaken.length > 1 ? "s" : ""} taken by someone else (${showNames(result.namesTaken)})`); + if (result.namesRejected?.length) { + const reasons = result.namesRejected.slice(0, 3).map((r) => `${r.tld} — ${r.error}`).join("; "); + parts.push(`${result.namesRejected.length} name${result.namesRejected.length > 1 ? "s" : ""} rejected (${reasons}${result.namesRejected.length > 3 ? "; …" : ""})`); + } if (result.taken.length) parts.push(`${result.taken.length} taken by someone else (${show(result.taken)})`); if (result.rejected.length) { // The reason matters more than the count here: "reserved" and "too short" diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index 5baf93f..8b2a420 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -1440,6 +1440,10 @@ const back = (res, params, tab = "yours") => * Reported as `ok` when anything landed at all, even alongside collisions — * a list where 38 of 40 were claimed succeeded, and colouring it as an error * because two were taken would misread the normal case as a failure. + * + * A name counts as landing. Pasting `blue.eggs` under an ending you already + * hold claims no ending at all, and judging that paste by the ending count + * alone would flash red over a registration that worked. */ moshpitRouter.post("/pit/claim-bulk", requireAuth, async (req, res) => { const result = await registerTlds({ @@ -1449,7 +1453,8 @@ moshpitRouter.post("/pit/claim-bulk", requireAuth, async (req, res) => { // The flash rides back in the query string, so it has to stay short enough // to survive a URL. const summary = summarizeBulkClaim(result).slice(0, 500); - return back(res, result.claimed.length ? { ok: summary } : { err: summary }); + const landed = result.claimed.length || result.names.length; + return back(res, landed ? { ok: summary } : { err: summary }); }); moshpitRouter.post("/pit/claim", requireAuth, async (req, res) => { @@ -1459,7 +1464,9 @@ moshpitRouter.post("/pit/claim", requireAuth, async (req, res) => { input: req.body?.tld, userId: req.user.id, ownerEmail: req.user.email ?? null, priceUsd: req.body?.price_usd, aliasOf: req.body?.alias_of, }); - if (!result.claimed.length) return back(res, { err: summarizeBulkClaim(result).slice(0, 500) }); + if (!result.claimed.length && !result.names.length) { + return back(res, { err: summarizeBulkClaim(result).slice(0, 500) }); + } back(res, { ok: summarizeBulkClaim(result).slice(0, 500) }); }); diff --git a/apps/pwa/test/moshpit-bulk-claim.test.mjs b/apps/pwa/test/moshpit-bulk-claim.test.mjs index 5acf799..86c4c85 100644 --- a/apps/pwa/test/moshpit-bulk-claim.test.mjs +++ b/apps/pwa/test/moshpit-bulk-claim.test.mjs @@ -41,7 +41,7 @@ test("parsing a pasted list", async (t) => { // and is now one ending pointed at another. Commas and newlines are the // separators, which is what the placeholder shows. assert.deepEqual(parseTldList("oranges\t\tmosh").entries, - [{ tld: "oranges", aliasOf: "mosh", priceUsd: null }]); + [{ tld: "oranges", label: null, aliasOf: "mosh", priceUsd: null }]); }); await t.test("# comments to end of line are dropped", () => { @@ -53,6 +53,42 @@ test("parsing a pasted list", async (t) => { assert.deepEqual(parseTldList(".Eggs\nEGGS\neggs").tlds, ["eggs"]); }); + await t.test("all four shapes of a pasted line are understood", () => { + // `foo`, `bar.foo`, `.whatever` and `.foo.whatever` — the ending and the + // name under it, each with and without the decorative leading dot. Anything + // with a dot used to be refused outright as "not a valid TLD", which + // described the field rather than the mistake. + const { tlds, names } = parseTldList("foo\nbar.foo\n.whatever\n.foo.whatever"); + assert.deepEqual(tlds, ["foo", "whatever"]); + assert.deepEqual(names, [ + { tld: "foo", label: "bar" }, + { tld: "whatever", label: "foo" }, + ]); + }); + + await t.test("an ending and a name under it are two entries, not one", () => { + // Deduplication keys on the whole thing. `.eggs` and `blue.eggs` share a + // TLD and nothing else, and collapsing them would drop whichever came second. + const { tlds, names } = parseTldList(".eggs\nblue.eggs\neggs\nblue.eggs"); + assert.deepEqual(tlds, ["eggs"]); + assert.deepEqual(names, [{ tld: "eggs", label: "blue" }]); + }); + + await t.test("a name carries per-line settings the same way an ending does", () => { + assert.deepEqual(parseTldList("blue.eggs $5 mosh").entries, + [{ tld: "eggs", label: "blue", aliasOf: "mosh", priceUsd: 5 }]); + }); + + await t.test("something that is neither is kept, to be rejected by name", () => { + // `a.b.c` is not a name and not an ending. Dropping it in the parser would + // leave it out of the report entirely, so it survives as its own bad text + // for registerTlds to refuse and say why. + const { entries, tlds, names } = parseTldList("a.b.c"); + assert.deepEqual(names, []); + assert.deepEqual(tlds, ["a.b.c"]); + assert.equal(entries[0].label, null); + }); + await t.test("blank input yields nothing rather than a phantom entry", () => { for (const input of ["", " \n\n ", null, undefined, "# only a comment"]) { assert.deepEqual(parseTldList(input).tlds, [], `for ${JSON.stringify(input)}`); @@ -117,13 +153,56 @@ test("claiming a pasted list", { skip: installed ? false : "pwa dependencies not await t.test("reserved and malformed endings are rejected with their reason", async () => { const good = uniq(); - const result = await m.registerTlds({ input: `bank\na\n${good}\nfoo.bar`, userId: ALICE }); + const result = await m.registerTlds({ input: `bank\na\n${good}\na.b.c`, userId: ALICE }); assert.deepEqual(result.claimed, [good], "one bad entry must not sink the list"); const rejected = Object.fromEntries(result.rejected.map((r) => [r.tld, r.error])); assert.match(rejected.bank, /reserved/); assert.match(rejected.a, /at least 2/); - assert.ok("foo.bar" in rejected, "a domain is not an ending"); + // Two labels is a name and one is an ending; three is neither, and it is + // refused by name rather than dropped out of the report. + assert.ok("a.b.c" in rejected, "three labels is neither an ending nor a name"); + }); + + await t.test("a pasted name claims the ending it needs, then registers under it", async () => { + const ending = uniq(); + const result = await m.registerTlds({ input: `blue.${ending}`, userId: ALICE }); + + assert.deepEqual(result.claimed, [ending], "the ending a name needs is claimed for you"); + assert.deepEqual(result.names, [`blue.${ending}`]); + assert.equal(result.rejected.length, 0); + assert.equal((await m.getName(ending, "blue")).user_id, ALICE); + }); + + await t.test("the ending is claimed once when both halves are pasted", async () => { + const ending = uniq(); + const result = await m.registerTlds({ input: `.${ending}\nblue.${ending}\ngreen.${ending}`, userId: ALICE }); + + assert.deepEqual(result.claimed, [ending]); + assert.deepEqual(result.names.sort(), [`blue.${ending}`, `green.${ending}`].sort()); + }); + + await t.test("a name under someone else's ending says who owns it, not 'no dots'", async () => { + // The report this replaces said "not a valid TLD — letters, digits and + // dashes only, no dots", which sent you off to fix a paste that was fine. + const theirs = uniq(); + await m.registerTld({ tld: theirs, userId: BOB }); + + const result = await m.registerTlds({ input: `blue.${theirs}`, userId: ALICE }); + assert.deepEqual(result.names, []); + assert.equal(result.namesRejected.length, 1); + assert.match(result.namesRejected[0].error, /do not own/); + assert.equal((await m.getTld(theirs)).user_id, BOB, "not stolen on the way past"); + }); + + await t.test("re-pasting a name you hold reads as already yours", async () => { + const ending = uniq(); + await m.registerTlds({ input: `blue.${ending}`, userId: ALICE }); + + const again = await m.registerTlds({ input: `blue.${ending}`, userId: ALICE }); + assert.deepEqual(again.namesMine, [`blue.${ending}`]); + assert.equal(again.names.length, 0); + assert.equal(again.namesTaken.length, 0); }); await t.test("the summary names what happened", async () => { @@ -266,7 +345,7 @@ test("a line can carry its own price and target", async (t) => { await t.test("reads tld, target and price off one line", () => { assert.deepEqual(parseTldList(".toplevel .redirect $2.00USD").entries, - [{ tld: "toplevel", aliasOf: "redirect", priceUsd: 2 }]); + [{ tld: "toplevel", label: null, aliasOf: "redirect", priceUsd: 2 }]); }); await t.test("accepts the shapes a person actually types", () => { @@ -278,8 +357,8 @@ test("a line can carry its own price and target", async (t) => { }); await t.test("order on the line does not matter", () => { - assert.deepEqual(parseTldList(".a $5 .b").entries, [{ tld: "a", aliasOf: "b", priceUsd: 5 }]); - assert.deepEqual(parseTldList(".a .b $5").entries, [{ tld: "a", aliasOf: "b", priceUsd: 5 }]); + assert.deepEqual(parseTldList(".a $5 .b").entries, [{ tld: "a", label: null, aliasOf: "b", priceUsd: 5 }]); + assert.deepEqual(parseTldList(".a .b $5").entries, [{ tld: "a", label: null, aliasOf: "b", priceUsd: 5 }]); }); await t.test("a bare list still means one ending per entry", () => { diff --git a/apps/pwa/test/moshpit-name-drift.test.mjs b/apps/pwa/test/moshpit-name-drift.test.mjs index aa7082a..a8a50de 100644 --- a/apps/pwa/test/moshpit-name-drift.test.mjs +++ b/apps/pwa/test/moshpit-name-drift.test.mjs @@ -65,6 +65,15 @@ test("vendored namespace rules match the published package", { } }); + await t.test("both copies read a name under an ending the same way", () => { + // The half of a paste that is not an ending. This drifting is how + // `blue.eggs` becomes a name in one copy and an unregistrable string in + // the other, which is the bug the two of them were changed to fix. + for (const paste of ["blue.eggs", ".me.whatever\nfoo\nbar.foo", "a.b.c", "1.420", ".eggs\nblue.eggs"]) { + assert.deepEqual(vendored.parseTldList(paste), published.parseTldList(paste), JSON.stringify(paste)); + } + }); + await t.test("resolution precedence agrees across the whole input space", () => { for (const registered of [true, false]) { for (const mode of ["clearnet", "moshpit", "nonsense"]) {