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
71 changes: 71 additions & 0 deletions apps/pwa/src/lib/moshpit-name.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,74 @@ export function resolutionPreference({ registered, mode }) {
if (!registered) return "clearnet";
return normalizeMode(mode) === "moshpit" ? "moshpit" : "fallback";
}

/**
* How many endings one paste may claim at a time.
*
* A cap rather than no cap because this runs one INSERT per ending against a
* remote database, and a pasted spreadsheet column is exactly the shape of
* input that turns into ten thousand of them by accident.
*/
export const MAX_BULK_TLDS = 200;

/**
* The most a child name may cost per year.
*
* PRD 0005 §5 and §10.1, requirement R3: `me.whatever` is capped at $1.99/year.
* An operator may go lower, including free. The cap is on the annual
* registration/renewal price only — a one-time Buy Now resale is a transfer of
* ownership, not a term, and §10.2.4 puts no ceiling on it.
*/
export const MAX_CHILD_PRICE_USD = 1.99;

/**
* What names under a newly claimed ending cost unless you say otherwise.
*
* The cap, not a round number below it. A default rather than a blank because
* an unpriced ending is invisible to every buyer, and "I claimed forty and
* nobody could buy a name under any of them" is the failure that costs
* something. Clearing the field still means not for sale — the default is an
* opinion, not a floor.
*/
export const DEFAULT_TLD_PRICE_USD = MAX_CHILD_PRICE_USD;

/**
* Pull a list of endings out of whatever someone pasted.
*
* Deliberately forgiving about shape, because the source is a text field and
* people paste columns, comma-separated exports, and hand-typed lines with the
* dot already on. Splitting on any run of whitespace, commas or semicolons
* covers all three without asking anyone to reformat first.
*
* `#` starts a comment to end of line, so a list can be annotated and re-pasted
* with the rejects commented out rather than deleted.
*
* 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.
*/
export function parseTldList(input, limit = MAX_BULK_TLDS) {
const tokens = String(input ?? "")
.split("\n")
.map((line) => line.replace(/#.*$/, ""))
.join("\n")
.split(/[\s,;]+/)
.map((t) => t.trim().toLowerCase().replace(/^\.+/, ""))
.filter(Boolean);

const seen = new Set();
const tlds = [];
let skipped = 0;

for (const token of tokens) {
if (seen.has(token)) continue;
seen.add(token);
// 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
// reason.
if (tlds.length >= limit) { skipped++; continue; }
tlds.push(token);
}

return { tlds, skipped };
}
118 changes: 115 additions & 3 deletions apps/pwa/src/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
// checkable rather than trusted.

import { get, all, run } from "./db.mjs";
import { normalizeLabel, normalizeTld, parseMoshpitName, tldRejection } from "./lib/moshpit-name.mjs";
import { MAX_BULK_TLDS, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, parseTldList, tldRejection } from "./lib/moshpit-name.mjs";

export {
RESERVED_TLDS, RESOLVE_MODES, normalizeLabel, normalizeTld, parseMoshpitName, tldRejection,
normalizeMode, resolutionPreference,
RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName,
parseTldList, tldRejection, normalizeMode, resolutionPreference,
} from "./lib/moshpit-name.mjs";

const COLS = `tld, user_id, owner_email, alias_of, price_usd, created_at`;
Expand Down Expand Up @@ -292,6 +292,11 @@ export async function setTldPrice({ tld: tldInput, userId, priceUsd }) {
// NaN/Infinity would be stored verbatim and then charged; a negative or
// zero price would let anyone drain the namespace for free.
if (!Number.isFinite(price) || price <= 0) return { ok: false, error: "price must be a positive number" };
// Not capped at MAX_CHILD_PRICE_USD here on purpose. PRD 0005 R3 caps the
// annual child price at $1.99, but that requirement arrives with terms,
// renewals and the ledger, and today this same column also carries prices
// set before any cap existed. The forms default to the cap and hint at it;
// enforcing it is a migration, not a validation tweak.
if (price > 1_000_000) return { ok: false, error: "price is implausibly large" };
price = Math.round(price * 100) / 100;
}
Expand Down Expand Up @@ -554,3 +559,110 @@ export async function removePin({ tld: tldInput, label: labelInput, pin, userId
await logAction(owned.tld, userId, `pin:remove:${owned.label}`);
return { ok: true };
}

/* ---- claiming a list of endings at once ---- */

/**
* Claim every ending in a pasted list.
*
* One at a time, not in parallel. `moshpit_tld_log` is the record of who
* claimed what first, and a batch that interleaves its writes makes that
* ordering meaningless for the endings inside it. Sequential is also the
* difference between one slow request and a burst that trips rate limits at
* the database.
*
* Partial success is the normal outcome, not the error case: any real list has
* a few names someone already holds. Every ending is attempted and reported on
* — the caller gets what landed and what did not, rather than a stop at the
* first collision with the rest silently unattempted.
*/
export async function registerTlds({
input, userId, ownerEmail = null, limit = MAX_BULK_TLDS, priceUsd = null, aliasOf = null,
}) {
const { tlds, skipped } = parseTldList(input, limit);

const claimed = [];
const mine = [];
const taken = [];
const rejected = [];
const settingsFailed = [];

for (const tld of tlds) {
const result = await registerTld({ tld, userId, ownerEmail });
if (result.ok) {
claimed.push(result.tld.tld);
// Settings are applied per ending, and a failure here is reported rather
// than thrown: the ending is already claimed and keeping it is the point.
// Losing a whole batch because one alias target was wrong would be worse
// than landing forty endings with no price on them.
const failure = await applyTldDefaults({ tld: result.tld.tld, userId, priceUsd, aliasOf });
if (failure) settingsFailed.push({ tld: result.tld.tld, error: failure });
continue;
}

if (result.taken) {
// Re-pasting a list you already claimed should read as "already yours",
// not as a collision with a stranger.
const owner = await getTld(tld);
(owner?.user_id === userId ? mine : taken).push(tld);
continue;
}
rejected.push({ tld, error: result.error });
}

return { claimed, mine, taken, rejected, settingsFailed, skipped, attempted: tlds.length };
}

/**
* Apply the whole-list settings to one freshly claimed ending.
*
* Returns an error string, or null when there was nothing to do or it worked.
* Aliasing an ending to itself is silently skipped rather than reported: it is
* what you get by pasting a list that happens to contain the alias target, and
* refusing the whole entry over it would be pedantic.
*/
async function applyTldDefaults({ tld, userId, priceUsd, aliasOf }) {
const wantsPrice = priceUsd !== null && priceUsd !== undefined && String(priceUsd).trim() !== "";
if (wantsPrice) {
const priced = await setTldPrice({ tld, userId, priceUsd });
if (!priced.ok) return priced.error;
}

const target = normalizeTld(aliasOf);
if (target && target !== tld) {
const aliased = await setAlias({ from: tld, to: target, userId });
if (!aliased.ok) return aliased.error;
}
return null;
}

/** One line fit for a flash message: what landed, what did not, and why. */
export function summarizeBulkClaim(result, limit = MAX_BULK_TLDS) {
const show = (list, n = 6) =>
list.slice(0, n).map((t) => `.${t}`).join(", ") + (list.length > n ? ` +${list.length - n} more` : "");

// 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;
if (onlyClaimed) return `.${result.claimed[0]} is yours.`;

const parts = [];
if (result.claimed.length) parts.push(`claimed ${result.claimed.length} — ${show(result.claimed)}`);
if (result.mine.length) parts.push(`${result.mine.length} already yours`);
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"
// need different fixes, and a bare number tells you neither.
const reasons = result.rejected.slice(0, 3).map((r) => `.${r.tld} — ${r.error}`).join("; ");
parts.push(`${result.rejected.length} rejected (${reasons}${result.rejected.length > 3 ? "; …" : ""})`);
}
if (result.settingsFailed?.length) {
const first = result.settingsFailed[0];
parts.push(`${result.settingsFailed.length} claimed but not configured (.${first.tld} — ${first.error})`);
}
if (result.skipped) parts.push(`${result.skipped} past the ${limit} limit, not attempted`);

return parts.length ? parts.join(". ") + "." : "nothing to claim — paste one ending per line.";
}
94 changes: 89 additions & 5 deletions apps/pwa/src/routes/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
addPin,
clearAlias,
clearExempt,
DEFAULT_TLD_PRICE_USD,
getName,
getTld,
getTldWithPrice,
Expand All @@ -31,6 +32,8 @@ import {
listTlds,
listTldsForUser,
listTldsNotOwnedBy,
MAX_BULK_TLDS,
MAX_CHILD_PRICE_USD,
normalizeLabel,
normalizeMode,
normalizePinKind,
Expand All @@ -42,6 +45,7 @@ import {
quoteName,
registerName,
registerTld,
registerTlds,
releaseName,
removePin,
resolutionPreference,
Expand All @@ -50,6 +54,7 @@ import {
setExempt,
setNameTarget,
setTldPrice,
summarizeBulkClaim,
tldRejection,
} from "../moshpit.mjs";
import { config } from "../config.mjs";
Expand Down Expand Up @@ -371,15 +376,62 @@ moshpitRouter.get("/api/moshpit/resolve", async (req, res) => {

/* ---------- the human page ---------- */

/**
* The settings applied to everything a single submission claims.
*
* Optional, and blank means "leave it alone" rather than "clear it" — the same
* two knobs a claimed ending already has on its own row, offered at the moment
* you claim it so a list of forty does not need forty follow-up edits.
*/
const claimDefaults = (req) => `
<div class="pit-defaults">
<label>Price each
<span class="pit-dot">$</span><input name="price_usd" type="number" min="0.01" step="0.01" max="${MAX_CHILD_PRICE_USD}"
value="${DEFAULT_TLD_PRICE_USD}" placeholder="unlisted" autocomplete="off"
aria-label="price per name, in dollars — clear it to keep them off the market"></label>
<label>Point at
<span class="pit-dot">.</span><input name="alias_of" placeholder="nothing"
autocomplete="off" spellcheck="false" aria-label="an ending you already hold"></label>
</div>`;

const claimForm = (req, prefill = "") => `
<form method="post" action="/pit/claim" class="pit-form">
${csrfInput(req)}
<label class="pit-field"><span class="pit-dot">.</span
><input name="tld" placeholder="eggs" aria-label="the TLD you want" autocomplete="off" spellcheck="false"
value="${esc(prefill)}" required></label>
${claimDefaults(req)}
<button class="btn acid" type="submit">Claim it</button>
</form>`;

/**
* The same claim, for a list.
*
* Behind a <details> because one ending at a time is the common case and a
* textarea would otherwise be the loudest thing on the page. Open, it accepts
* whatever shape the list arrived in — a pasted column, a comma-separated
* export, dots on or off.
*/
const bulkClaimForm = (req) => `
<details class="pit-bulk">
<summary>…or paste a list</summary>
<form method="post" action="/pit/claim-bulk">
${csrfInput(req)}
<textarea name="tlds" rows="8" spellcheck="false" autocomplete="off" required
aria-label="endings to claim, one per line"
placeholder=".eggs
.yeah
oranges
# dots optional · commas or newlines · # comments ignored"></textarea>
${claimDefaults(req)}
<p class="mono faint" style="font-size:.7rem;margin:8px 0 10px">
Up to ${MAX_BULK_TLDS} at a time. Ones already taken are reported, not fatal —
the rest still land. Price and target apply to every ending that lands.
</p>
<button class="btn acid" type="submit">Claim them all</button>
</form>
</details>`;

/**
* The card someone lands on after typing `mosh.whatever` somewhere.
*
Expand Down Expand Up @@ -493,6 +545,17 @@ const PIT_CSS = `
.pit-msg{border-radius:8px;padding:10px 14px;margin:14px 0;font-family:var(--mono);font-size:.84rem}
.pit-msg.err{border:1px solid var(--danger);color:var(--danger)}
.pit-msg.ok{border:1px solid var(--acid);color:var(--acid)}
.pit-defaults{display:flex;gap:12px;flex-wrap:wrap;margin:10px 0 4px}
.pit-defaults label{display:flex;align-items:center;gap:6px;font-family:var(--mono);
font-size:.72rem;letter-spacing:.06em;color:var(--dim);white-space:nowrap}
.pit-defaults input{width:11ch;padding:7px 9px;font-size:.78rem}
.pit-bulk{margin:0 0 18px;max-width:62ch}
.pit-bulk summary{font-family:var(--mono);font-size:.74rem;letter-spacing:.08em;color:var(--dim);cursor:pointer;padding:6px 0}
.pit-bulk summary:hover{color:var(--acid)}
.pit-bulk textarea{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);
border:1px solid var(--line);border-radius:6px;padding:10px 12px;font-family:var(--mono);
font-size:.8rem;line-height:1.55;resize:vertical;min-height:9em}
.pit-bulk textarea:focus{outline:none;border-color:var(--acid)}
.pit-tabs{display:flex;gap:4px;margin:22px 0 26px;border-bottom:1px solid var(--line)}
.pit-tab{font-family:var(--mono);font-size:.76rem;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);
padding:11px 15px;border-bottom:2px solid transparent;margin-bottom:-1px}
Expand Down Expand Up @@ -674,7 +737,7 @@ moshpitRouter.get("/pit", async (req, res) => {
</p>
${landingCard(req, landing)}
${msg}
${req.user ? claimForm(req) : ""}
${req.user ? claimForm(req) + bulkClaimForm(req) : ""}
${pitTabs(tab, { yours: mine.length, theirs: theirs.length, forSale: forSaleCount })}

<section class="pit-panel">
Expand Down Expand Up @@ -816,12 +879,33 @@ dig @127.0.0.1 -p 5354 +short anything.moshpit</code></pre>
const back = (res, params, tab = "yours") =>
res.redirect(`/pit?${new URLSearchParams({ ...params, tab })}`);

/**
* Claim a pasted list.
*
* 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.
*/
moshpitRouter.post("/pit/claim-bulk", requireAuth, async (req, res) => {
const result = await registerTlds({
input: req.body?.tlds, userId: req.user.id, ownerEmail: req.user.email ?? null,
priceUsd: req.body?.price_usd, aliasOf: req.body?.alias_of,
});
// 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 });
});

moshpitRouter.post("/pit/claim", requireAuth, async (req, res) => {
const result = await registerTld({
tld: req.body?.tld, userId: req.user.id, ownerEmail: req.user.email ?? null,
// 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({
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.ok) return back(res, { err: result.error || "could not register that TLD" });
back(res, { ok: `.${result.tld.tld} is yours.` });
if (!result.claimed.length) return back(res, { err: summarizeBulkClaim(result).slice(0, 500) });
back(res, { ok: summarizeBulkClaim(result).slice(0, 500) });
});

moshpitRouter.post("/pit/:tld/alias", requireAuth, async (req, res) => {
Expand Down
Loading
Loading