Skip to content

Commit 6b6c277

Browse files
ralyodioclaude
andcommitted
pit: let a pasted line carry its own price and target
The paste area applied one price and one target to everything in it, so a list where three endings are worth more than the rest meant claiming the list and then editing three rows. A line can now say what that ending is worth and what it points at: .toplevel .redirect $2.00USD .yeah $5USD oranges, pears, plums Anything on a line beats the form; the form is the default for the whole paste. Overrides go up as well as down — $5 on a line is honoured even though the default is $2. Prices are read in the shapes people type: $5, $5USD, 5.00, USD5, $1.50. Order on the line does not matter, because the two fields cannot be confused: a price is the only one that starts with `$` or is all digits, and an all-numeric ending is rejected anyway. Anything that is not a price is read as the ending this one points at — the safe misreading, since a stray token then fails loudly against an ending you do not own rather than silently setting a price. Zero and negatives are not prices. Behaviour change: whitespace inside a line now separates fields, not endings. `a b` on one line used to be two endings and is now one pointed at the other. Commas and newlines are the separators, which is what the placeholder shows. The default price moves 1.99 -> 2. PRD 0005 R3 caps a child name at $1.99, but that arrives with terms and renewals and nothing enforces a ceiling yet, so this is an operator's asking price and a line may exceed it. 11 more tests. 256 across the pwa suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1d2f1e1 commit 6b6c277

5 files changed

Lines changed: 181 additions & 29 deletions

File tree

apps/pwa/node_modules

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/home/anthony/src/moshcoder/moshcode/.claude/worktrees/bulk-tld/apps/pwa/node_modules

apps/pwa/src/lib/moshpit-name.mjs

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,17 @@ export const MAX_CHILD_PRICE_USD = 1.99;
135135
/**
136136
* What names under a newly claimed ending cost unless you say otherwise.
137137
*
138-
* The cap, not a round number below it. A default rather than a blank because
139-
* an unpriced ending is invisible to every buyer, and "I claimed forty and
140-
* nobody could buy a name under any of them" is the failure that costs
141-
* something. Clearing the field still means not for sale — the default is an
142-
* opinion, not a floor.
138+
* A default rather than a blank because an unpriced ending is invisible to
139+
* every buyer, and "I claimed forty and nobody could buy a name under any of
140+
* them" is the failure that costs something.
141+
*
142+
* $2 rather than MAX_CHILD_PRICE_USD: the cap in PRD 0005 R3 arrives with
143+
* terms and renewals, and until that lands this is an operator's asking price
144+
* with nothing enforcing a ceiling. A per-line price overrides this, upwards
145+
* or downwards, and clearing the field still means not for sale — the default
146+
* is an opinion, not a floor and not a limit.
143147
*/
144-
export const DEFAULT_TLD_PRICE_USD = MAX_CHILD_PRICE_USD;
148+
export const DEFAULT_TLD_PRICE_USD = 2;
145149

146150
/**
147151
* Pull a list of endings out of whatever someone pasted.
@@ -159,27 +163,64 @@ export const DEFAULT_TLD_PRICE_USD = MAX_CHILD_PRICE_USD;
159163
* against yourself.
160164
*/
161165
export function parseTldList(input, limit = MAX_BULK_TLDS) {
162-
const tokens = String(input ?? "")
166+
// Records split on newlines, commas and semicolons; fields inside a record
167+
// split on whitespace. That keeps `eggs, yeah, oranges` meaning three
168+
// endings while letting one line carry settings for the ending it names.
169+
const records = String(input ?? "")
163170
.split("\n")
164171
.map((line) => line.replace(/#.*$/, ""))
165172
.join("\n")
166-
.split(/[\s,;]+/)
167-
.map((t) => t.trim().toLowerCase().replace(/^\.+/, ""))
173+
.split(/[\n,;]+/)
174+
.map((r) => r.trim())
168175
.filter(Boolean);
169176

170177
const seen = new Set();
171-
const tlds = [];
178+
const entries = [];
172179
let skipped = 0;
173180

174-
for (const token of tokens) {
175-
if (seen.has(token)) continue;
176-
seen.add(token);
181+
for (const record of records) {
182+
const fields = record.split(/\s+/).filter(Boolean);
183+
const tld = normalizeToken(fields[0]);
184+
if (!tld || seen.has(tld)) continue;
185+
seen.add(tld);
186+
177187
// Counted rather than silently dropped: "I pasted 300 and got 200" needs to
178188
// be visible, or the missing hundred look like they failed for some other
179189
// reason.
180-
if (tlds.length >= limit) { skipped++; continue; }
181-
tlds.push(token);
190+
if (entries.length >= limit) { skipped++; continue; }
191+
192+
let priceUsd = null;
193+
let aliasOf = null;
194+
for (const field of fields.slice(1)) {
195+
const price = parsePriceToken(field);
196+
// A price is unambiguous — it is the only field that can start with `$`
197+
// or be all digits, and an all-numeric ending is rejected anyway. So
198+
// anything that is not a price is the ending this one points at.
199+
if (price !== null) priceUsd = price;
200+
else aliasOf = normalizeToken(field);
201+
}
202+
entries.push({ tld, aliasOf, priceUsd });
182203
}
183204

184-
return { tlds, skipped };
205+
// `tlds` alongside `entries` because most callers only want the names, and
206+
// making every one of them map over the records would be noise.
207+
return { entries, tlds: entries.map((e) => e.tld), skipped };
208+
}
209+
210+
function normalizeToken(value) {
211+
return String(value ?? "").trim().toLowerCase().replace(/^\.+/, "") || null;
212+
}
213+
214+
/**
215+
* `$2`, `$2.00USD`, `2.00`, `USD 2` — a price if it reads as one, else null.
216+
*
217+
* Forgiving because it is typed by hand in a textarea next to a dollar sign,
218+
* and strict about the shape because the alternative reading of a stray token
219+
* is "the ending this one points at", which would silently mis-route a name.
220+
*/
221+
function parsePriceToken(value) {
222+
const raw = String(value ?? "").trim().toLowerCase().replace(/^usd/, "").replace(/usd$/, "").replace(/^\$/, "").trim();
223+
if (!raw || !/^\d+(\.\d{1,2})?$/.test(raw)) return null;
224+
const price = Number(raw);
225+
return Number.isFinite(price) && price > 0 ? price : null;
185226
}

apps/pwa/src/moshpit.mjs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -579,23 +579,32 @@ export async function removePin({ tld: tldInput, label: labelInput, pin, userId
579579
export async function registerTlds({
580580
input, userId, ownerEmail = null, limit = MAX_BULK_TLDS, priceUsd = null, aliasOf = null,
581581
}) {
582-
const { tlds, skipped } = parseTldList(input, limit);
582+
const { entries, skipped } = parseTldList(input, limit);
583583

584584
const claimed = [];
585585
const mine = [];
586586
const taken = [];
587587
const rejected = [];
588588
const settingsFailed = [];
589589

590-
for (const tld of tlds) {
590+
for (const entry of entries) {
591+
const tld = entry.tld;
591592
const result = await registerTld({ tld, userId, ownerEmail });
592593
if (result.ok) {
593594
claimed.push(result.tld.tld);
594595
// Settings are applied per ending, and a failure here is reported rather
595596
// than thrown: the ending is already claimed and keeping it is the point.
596597
// Losing a whole batch because one alias target was wrong would be worse
597598
// than landing forty endings with no price on them.
598-
const failure = await applyTldDefaults({ tld: result.tld.tld, userId, priceUsd, aliasOf });
599+
// A value written on the line wins over the form's, in either direction:
600+
// the form is the default for the whole paste, the line is what this one
601+
// ending is actually worth.
602+
const failure = await applyTldDefaults({
603+
tld: result.tld.tld,
604+
userId,
605+
priceUsd: entry.priceUsd ?? priceUsd,
606+
aliasOf: entry.aliasOf ?? aliasOf,
607+
});
599608
if (failure) settingsFailed.push({ tld: result.tld.tld, error: failure });
600609
continue;
601610
}
@@ -610,7 +619,7 @@ export async function registerTlds({
610619
rejected.push({ tld, error: result.error });
611620
}
612621

613-
return { claimed, mine, taken, rejected, settingsFailed, skipped, attempted: tlds.length };
622+
return { claimed, mine, taken, rejected, settingsFailed, skipped, attempted: entries.length };
614623
}
615624

616625
/**

apps/pwa/src/routes/moshpit.mjs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -420,13 +420,16 @@ const bulkClaimForm = (req) => `
420420
<textarea name="tlds" rows="8" spellcheck="false" autocomplete="off" required
421421
aria-label="endings to claim, one per line"
422422
placeholder=".eggs
423-
.yeah
424-
oranges
425-
# dots optional · commas or newlines · # comments ignored"></textarea>
423+
.yeah $5USD
424+
.toplevel .redirect $2.00USD
425+
oranges, pears, plums
426+
# one per line · dots optional · add a price and/or an ending to point at
427+
# anything on a line beats the defaults below · # comments ignored"></textarea>
426428
${claimDefaults(req)}
427429
<p class="mono faint" style="font-size:.7rem;margin:8px 0 10px">
428430
Up to ${MAX_BULK_TLDS} at a time. Ones already taken are reported, not fatal —
429-
the rest still land. Price and target apply to every ending that lands.
431+
the rest still land. The price and target below apply to every ending that
432+
lands, unless a line says otherwise.
430433
</p>
431434
<button class="btn acid" type="submit">Claim them all</button>
432435
</form>

apps/pwa/test/moshpit-bulk-claim.test.mjs

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,18 @@ test("parsing a pasted list", async (t) => {
3030
assert.deepEqual(parseTldList(".eggs\nyeah\n.oranges").tlds, ["eggs", "yeah", "oranges"]);
3131
});
3232

33-
await t.test("commas, semicolons and stray whitespace all separate", () => {
33+
await t.test("commas and semicolons separate endings", () => {
3434
// People paste spreadsheet columns and CSV exports; neither should need
3535
// reformatting first.
36-
assert.deepEqual(parseTldList("eggs, yeah ;oranges\t\tmosh").tlds,
37-
["eggs", "yeah", "oranges", "mosh"]);
36+
assert.deepEqual(parseTldList("eggs, yeah ;oranges").tlds, ["eggs", "yeah", "oranges"]);
37+
});
38+
39+
await t.test("whitespace inside a line is fields, not more endings", () => {
40+
// The cost of per-line settings: `a b` on one line used to be two endings
41+
// and is now one ending pointed at another. Commas and newlines are the
42+
// separators, which is what the placeholder shows.
43+
assert.deepEqual(parseTldList("oranges\t\tmosh").entries,
44+
[{ tld: "oranges", aliasOf: "mosh", priceUsd: null }]);
3845
});
3946

4047
await t.test("# comments to end of line are dropped", () => {
@@ -216,10 +223,12 @@ test("the default price", { skip: installed ? false : "pwa dependencies not inst
216223
const m = await import("../src/moshpit.mjs");
217224
const uniq = () => `d${randomBytes(4).toString("hex")}`;
218225

219-
await t.test("is the cap PRD 0005 R3 sets, not a round number below it", async () => {
226+
await t.test("is $2, and nothing enforces a ceiling on an override", async () => {
220227
const { MAX_CHILD_PRICE_USD } = await import("../src/lib/moshpit-name.mjs");
228+
assert.equal(DEFAULT_TLD_PRICE_USD, 2);
229+
// PRD 0005 R3 caps a child name at $1.99, but that arrives with terms and
230+
// renewals; until then this is an asking price and a line may exceed it.
221231
assert.equal(MAX_CHILD_PRICE_USD, 1.99);
222-
assert.equal(DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD);
223232
});
224233

225234
await t.test("an explicit price still wins over it", async () => {
@@ -242,3 +251,92 @@ test("the default price", { skip: installed ? false : "pwa dependencies not inst
242251
assert.equal((await m.getTld(a)).price_usd, null);
243252
});
244253
});
254+
255+
test("a line can carry its own price and target", async (t) => {
256+
const { parseTldList } = await import("../src/lib/moshpit-name.mjs");
257+
258+
await t.test("reads tld, target and price off one line", () => {
259+
assert.deepEqual(parseTldList(".toplevel .redirect $2.00USD").entries,
260+
[{ tld: "toplevel", aliasOf: "redirect", priceUsd: 2 }]);
261+
});
262+
263+
await t.test("accepts the shapes a person actually types", () => {
264+
for (const [text, price] of [
265+
[".a $5", 5], [".b $5USD", 5], [".c 5.00", 5], [".d USD5", 5], [".e $1.50", 1.5],
266+
]) {
267+
assert.equal(parseTldList(text).entries[0].priceUsd, price, text);
268+
}
269+
});
270+
271+
await t.test("order on the line does not matter", () => {
272+
assert.deepEqual(parseTldList(".a $5 .b").entries, [{ tld: "a", aliasOf: "b", priceUsd: 5 }]);
273+
assert.deepEqual(parseTldList(".a .b $5").entries, [{ tld: "a", aliasOf: "b", priceUsd: 5 }]);
274+
});
275+
276+
await t.test("a bare list still means one ending per entry", () => {
277+
// Commas separate records, so this must not read as tld+target+price.
278+
assert.deepEqual(parseTldList("eggs, yeah, oranges").tlds, ["eggs", "yeah", "oranges"]);
279+
assert.deepEqual(parseTldList("eggs\nyeah").entries.map((e) => e.aliasOf), [null, null]);
280+
});
281+
282+
await t.test("a line with nothing extra inherits the form's settings", () => {
283+
const [entry] = parseTldList("plain").entries;
284+
assert.equal(entry.priceUsd, null, "null means 'use the default', not 'free'");
285+
assert.equal(entry.aliasOf, null);
286+
});
287+
288+
await t.test("junk on a line is read as a target, never as a price", () => {
289+
// The safe misreading: a stray token becomes an alias, which fails loudly
290+
// against an ending you do not own, rather than silently setting a price.
291+
assert.equal(parseTldList(".a hunter2").entries[0].priceUsd, null);
292+
assert.equal(parseTldList(".a hunter2").entries[0].aliasOf, "hunter2");
293+
assert.equal(parseTldList(".a $0").entries[0].priceUsd, null, "zero is not a price");
294+
assert.equal(parseTldList(".a $-5").entries[0].priceUsd, null);
295+
});
296+
297+
await t.test("the limit still counts endings, not fields", () => {
298+
const many = Array.from({ length: 5 }, (_, i) => `t${i} .hub $3`).join("\n");
299+
const { entries, skipped } = parseTldList(many, 3);
300+
assert.equal(entries.length, 3);
301+
assert.equal(skipped, 2);
302+
});
303+
});
304+
305+
test("per-line settings beat the form", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => {
306+
const { migrate } = await import("../src/migrate.mjs");
307+
await migrate();
308+
const { run } = await import("../src/db.mjs");
309+
await run(`INSERT OR IGNORE INTO users (id,email,created_at) VALUES (?,?,?)`, [ALICE, "alice@example.com", Date.now()]);
310+
const m = await import("../src/moshpit.mjs");
311+
const uniq = () => `o${randomBytes(4).toString("hex")}`;
312+
313+
await t.test("a line's price overrides the form's, upwards", async () => {
314+
const cheap = uniq(), dear = uniq();
315+
await m.registerTlds({ input: `${cheap}\n${dear} $5USD`, userId: ALICE, priceUsd: "2" });
316+
317+
assert.equal((await m.getTld(cheap)).price_usd, 2, "no line price -> the form's");
318+
assert.equal((await m.getTld(dear)).price_usd, 5, "a line price wins, even above the default");
319+
});
320+
321+
await t.test("a line's target overrides the form's", async () => {
322+
const hub = uniq(), other = uniq();
323+
await m.registerTld({ tld: hub, userId: ALICE });
324+
await m.registerTld({ tld: other, userId: ALICE });
325+
const a = uniq(), b = uniq();
326+
327+
await m.registerTlds({ input: `${a}\n${b} .${other}`, userId: ALICE, aliasOf: hub });
328+
assert.equal((await m.getTld(a)).alias_of, hub);
329+
assert.equal((await m.getTld(b)).alias_of, other);
330+
});
331+
332+
await t.test("a line works with no form defaults at all", async () => {
333+
const hub = uniq();
334+
await m.registerTld({ tld: hub, userId: ALICE });
335+
const a = uniq();
336+
337+
await m.registerTlds({ input: `${a} .${hub} $3.50`, userId: ALICE });
338+
const row = await m.getTld(a);
339+
assert.equal(row.price_usd, 3.5);
340+
assert.equal(row.alias_of, hub);
341+
});
342+
});

0 commit comments

Comments
 (0)