Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/pwa/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/pwa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@
"web-push": "^3.6.7"
},
"devDependencies": {
"@moshcoder/moshpit-name": "^0.1.1"
"@moshcoder/moshpit-name": "^0.2.0"
}
}
47 changes: 41 additions & 6 deletions apps/pwa/src/lib/moshpit-name.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand Down
82 changes: 78 additions & 4 deletions apps/pwa/src/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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"
Expand Down
11 changes: 9 additions & 2 deletions apps/pwa/src/routes/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) => {
Expand All @@ -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) });
});

Expand Down
91 changes: 85 additions & 6 deletions apps/pwa/test/moshpit-bulk-claim.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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)}`);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
Loading
Loading