From d70d6e4d7ff3ed5cab4ea56dfd40f63597e143fa Mon Sep 17 00:00:00 2001 From: anthony Date: Fri, 31 Jul 2026 20:00:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(pit):=20vendor=20the=20namespace=20rules=20?= =?UTF-8?q?again=20=E2=80=94=20main=20is=20red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #161 replaced apps/pwa/src/lib/moshpit-name.mjs with a re-export of @moshcoder/moshpit-name. That broke main: three test files now fail at import with ERR_MODULE_NOT_FOUND. I had it wrong. apps/pwa is not part of a pnpm workspace and has no workspaces field, so the repo-root `pnpm install --frozen-lockfile` that CI runs never reaches it — its dependencies are simply absent there, which is why 116 of its tests have always skipped. The claim in #161 that this was "the one consumer that does npm install" was not true; nothing installs them in CI. Worse, the failure mode changed. Those tests used to skip when a dependency was missing. A re-export at the top of a module every one of them imports fails at load instead, so the pure tests — which need nothing — went down with the ones that need a database. So the vendored copy comes back, and gets the same treatment as src/dns.mjs in #162: a dev-only dependency and a drift test that compares behaviour rather than bytes. Parsing across the whole hostname space, the reserved list, prices and limits, pasted lists with their per-line settings, and resolution precedence. Verified both ways this time. It skips cleanly with no dev dependencies, which is what CI has. It fails when ENDING_PRICE_USD is changed in the copy alone. And the full suite with apps/pwa/node_modules removed — CI, exactly — is 484 pass, 0 fail, 122 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- apps/pwa/package-lock.json | 11 +- apps/pwa/package.json | 4 +- apps/pwa/src/lib/moshpit-name.mjs | 299 +++++++++++++++++++++- apps/pwa/test/moshpit-name-drift.test.mjs | 79 ++++++ 4 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 apps/pwa/test/moshpit-name-drift.test.mjs diff --git a/apps/pwa/package-lock.json b/apps/pwa/package-lock.json index 3b676d9..8095617 100644 --- a/apps/pwa/package-lock.json +++ b/apps/pwa/package-lock.json @@ -9,7 +9,6 @@ "version": "0.1.0", "dependencies": { "@libsql/client": "^0.14.0", - "@moshcoder/moshpit-name": "^0.1.0", "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.1.0", "@xterm/addon-fit": "^0.11.0", @@ -18,6 +17,9 @@ "express": "^4.21.2", "web-push": "^3.6.7" }, + "devDependencies": { + "@moshcoder/moshpit-name": "^0.1.1" + }, "engines": { "node": ">=20" } @@ -179,9 +181,10 @@ ] }, "node_modules/@moshcoder/moshpit-name": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@moshcoder/moshpit-name/-/moshpit-name-0.1.0.tgz", - "integrity": "sha512-EW50r1sc+65vKttYV/E/vrtRBFJXy9bb1KaOXDhFIPqJQ2gozJbL7gJ6lHDe5QX5tj4S3Oc9qkgsUSWRizec0g==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@moshcoder/moshpit-name/-/moshpit-name-0.1.1.tgz", + "integrity": "sha512-ltZbKeUljHSQSjFeOvxZBT8AWEnRkj+W0QpjBkADSgHp9T4IidK1cpuRAsGRVY+vt7gltSFh2531jhGHhRG5RA==", + "dev": true, "license": "MIT", "bin": { "moshpit-name": "bin/moshpit-name.mjs" diff --git a/apps/pwa/package.json b/apps/pwa/package.json index 1570ed4..7ad046d 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -15,7 +15,6 @@ }, "dependencies": { "@libsql/client": "^0.14.0", - "@moshcoder/moshpit-name": "^0.1.0", "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.1.0", "@xterm/addon-fit": "^0.11.0", @@ -23,5 +22,8 @@ "cookie-parser": "^1.4.7", "express": "^4.21.2", "web-push": "^3.6.7" + }, + "devDependencies": { + "@moshcoder/moshpit-name": "^0.1.1" } } diff --git a/apps/pwa/src/lib/moshpit-name.mjs b/apps/pwa/src/lib/moshpit-name.mjs index 9d70aa5..d044e50 100644 --- a/apps/pwa/src/lib/moshpit-name.mjs +++ b/apps/pwa/src/lib/moshpit-name.mjs @@ -1,12 +1,289 @@ -// The Moshpit namespace rules — now @moshcoder/moshpit-name. +// Validation, policy and resolution precedence for Moshpit names. // -// These rules were written three times: here, in TronBrowser's TypeScript -// resolver, and in a hand port of that into its extension, with a test whose -// only job was asserting two of the copies agreed. They are one package now, -// and this file is the seam so the seven modules that import -// `./lib/moshpit-name.mjs` do not each have to know that. -// -// Re-exported rather than replaced by a rewrite of every import: the path is -// what those files depend on, and changing where a rule lives is not a reason -// to touch code that only uses it. -export * from "@moshcoder/moshpit-name"; +// Deliberately free of any database import so it can be tested -- and reused by +// a client, such as the tronbrowser.dev extension -- without a libSQL +// connection. src/moshpit.mjs owns the storage. + +/** + * Names nobody may claim, whatever the first-come-first-served rule says. + * + * The moment a namespace sells `.bank` or `.apple` it has a phishing and + * trademark problem, and neither is cheap to unwind after the fact. A static + * list is a blunt instrument, but it is the one that works on day one. + */ +export const RESERVED_TLDS = new Set([ + // trades on trust in money + "bank", "banking", "paypal", "visa", "mastercard", "amex", "stripe", "coinbase", + // trades on trust in a company + "apple", "google", "microsoft", "amazon", "meta", "facebook", "netflix", "openai", + "anthropic", "github", "x", "twitter", "tesla", + // trades on trust in an institution + "gov", "police", "nhs", "irs", "fbi", "army", "navy", + // ours: the network's own names are not for sale + "moshpit", "moshcode", "moshcoding", "profullstack", "logicsrc", + // collide with the legacy internet in ways that would only ever confuse + "com", "net", "org", "edu", "mil", "int", "arpa", "localhost", "local", "onion", "test", "invalid", "example", +]); + +/** A TLD label: lowercase letters, digits and dashes; no leading/trailing dash. */ +const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +/** A hostname label. Unlike a TLD, an all-numeric label is valid. */ +export function normalizeLabel(input) { + const raw = String(input ?? "").trim().toLowerCase(); + return raw && raw.length <= 63 && LABEL.test(raw) ? raw : null; +} + +/** + * Normalise user input into a bare TLD label, or null when it could never be + * one. Accepts ".eggs", "eggs", " .EGGS " -- people type the dot. + */ +export function normalizeTld(input) { + const raw = String(input ?? "").trim().toLowerCase().replace(/^\.+/, ""); + // A dot means they gave a domain, not a TLD. Say so rather than silently + // registering the wrong thing. + const label = normalizeLabel(raw); + if (!label) return null; + // All-numeric endings are fine: `.420`, `.187`, `.911` are names people want, + // and an ending on its own is never mistaken for an address. The ambiguity + // with an IPv4 literal belongs to the whole hostname — `1.420` reads as one, + // `blue.420` cannot — so parseMoshpitName rejects that case and this does not. + return label; +} + +/** Why a TLD cannot be registered, or null when it is fine. */ +export function tldRejection(tld) { + if (RESERVED_TLDS.has(tld)) return "that name is reserved"; + if (tld.length < 2) return "a TLD needs at least 2 characters"; + return null; +} + +/** + * Split "foo.agentic" into its label and TLD. + * + * Only one dot is allowed: the namespace is one level deep, so "a.b.c" is not a + * deeper name, it is a malformed one, and guessing which part was meant would + * resolve someone to a place they never asked for. + */ +export function parseMoshpitName(input) { + const raw = String(input ?? "").trim().toLowerCase().replace(/^\.+/, "").replace(/\.+$/, ""); + if (!raw) return null; + const parts = raw.split("."); + if (parts.length !== 2) return null; + const [label, tld] = parts; + const normalizedLabel = normalizeLabel(label); + const normalizedTld = normalizeTld(tld); + if (!normalizedLabel || !normalizedTld) return null; + + // `1.420` is indistinguishable from an abbreviated IPv4 literal — several + // parsers read a two-part dotted number as an address — so a name whose every + // label is numeric is refused. `blue.420` and `420.blue` are unambiguous and + // allowed; it takes both halves being numbers to create the collision. + if (/^\d+$/.test(normalizedLabel) && /^\d+$/.test(normalizedTld)) return null; + + return { label: normalizedLabel, tld: normalizedTld }; +} + +/* ---- resolution precedence (tronbrowser.dev) ---- */ + +/** The two ways a resolver can be configured to treat a moshpit answer. */ +export const RESOLVE_MODES = new Set(["clearnet", "moshpit"]); + +/** + * Which resolution mode a caller asked for. Defaults to "clearnet": a resolver + * that silently outranked real DNS the first time it was switched on would + * hijack names its operator never intended to touch, so overriding the legacy + * internet has to be something you opt into. + */ +export function normalizeMode(input) { + const raw = String(input ?? "").trim().toLowerCase(); + return RESOLVE_MODES.has(raw) ? raw : "clearnet"; +} + +/** + * What the client should do with the moshpit answer. + * + * "clearnet" -- ignore it; there is nothing registered here + * "fallback" -- use it only when clearnet DNS does not answer + * "moshpit" -- use it even when clearnet DNS does answer + * + * Whether clearnet actually answers is deliberately NOT decided here. The + * browser extension already knows -- it is the thing doing the DNS lookup -- + * and an ICANN TLD list baked into this server would be stale the week after it + * shipped. So the server states the rule and the client applies it. + * + * "fallback" is what makes the default safe: an unregistered name never + * displaces DNS, and a registered one only fills a gap. Mode "moshpit" is the + * opt-in that lets `profullstack.ai` in the pit outrank a squatted + * `profullstack.ai` in clearnet. + */ +export function resolutionPreference({ registered, mode }) { + if (!registered) return "clearnet"; + return normalizeMode(mode) === "moshpit" ? "moshpit" : "fallback"; +} + +/** + * The most endings one paste may claim. + * + * A ceiling rather than no ceiling 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. + * + * It is not the thing that usually stops a paste, though — BULK_TIME_BUDGET_MS + * is. A count cannot know how slow the database is today, and the failure it + * guards against is a request that dies halfway with no report of what landed. + */ +export const MAX_BULK_TLDS = 1000; + +/** + * How long claiming may run before it stops and reports. + * + * Stopping on the clock rather than on a count adapts to the database: a fast + * one gets through hundreds, a slow one stops early, and neither ends as a + * timed-out request whose result nobody ever sees. Whatever is left is named + * so it can be pasted again. + */ +export const BULK_TIME_BUDGET_MS = 20_000; + +/** + * How many endings go into one round trip. + * + * Claiming used to cost six or seven trips per ending — insert, log, read + * back, then read-check-write for the price — which is why a 300-ending paste + * spent its whole time budget on 54 of them. Batched, the cost is a handful of + * trips for the entire paste, so the chunk exists to bound request size rather + * than to ration anything. + */ +export const BULK_CHUNK = 100; + +/** 1000 -> "1k". A ceiling is a rough promise and should read like one. */ +export function shortCount(n) { + return n >= 1000 && n % 1000 === 0 ? `${n / 1000}k` : String(n); +} + +/** + * The most a child name should cost per year. + * + * This is the `me.whatever` price — what a buyer pays to mint a name under an + * ending someone else holds. It is not the price of `.whatever` itself, which + * is a separate thing the registry does not charge for yet. + * + * $2 flat. PRD 0005 R3 wrote this as $1.99; the extra cent buys nothing but a + * price tag that looks like a supermarket shelf, and every number a person has + * to reason about here — a default, a cap, a per-line override — reads better + * round. The PRD number is superseded by this one. + * + * The ceiling is on the annual registration/renewal price only. A one-time + * Buy Now resale transfers ownership rather than starting a term, and §10.2.4 + * puts no ceiling on that. + */ +export const MAX_CHILD_PRICE_USD = 2; + +/** Alias, for code that reads better naming the thing than the ceiling. */ +export const CHILD_PRICE_USD = MAX_CHILD_PRICE_USD; + +/** + * What a direct ending costs per year: `.whatever` itself. + * + * Nothing charges this yet — `registerTld` inserts a row and claiming is free. + * It lives here anyway so the two prices sit together and the number is settled + * before the checkout that will read it, rather than being invented at the + * point someone builds that and having to be reconciled afterwards. + * + * $5 flat, for the same reason the child price is $2: PRD 0005 §10.1 wrote + * these as $4.99 and $1.99, and the trailing cents buy nothing but a price tag + * shaped like a supermarket shelf. + */ +export const ENDING_PRICE_USD = 5; + +/** + * What names under a newly claimed ending cost unless you say otherwise. + * + * 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. + * + * The cap itself, not a number under it: $2 is already the round, memorable + * price this namespace is meant to have, so there is nothing to shade off it + * for. A per-line price overrides this in either direction, and 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) { + // Records split on newlines, commas and semicolons; fields inside a record + // split on whitespace. That keeps `eggs, yeah, oranges` meaning three + // endings while letting one line carry settings for the ending it names. + const records = String(input ?? "") + .split("\n") + .map((line) => line.replace(/#.*$/, "")) + .join("\n") + .split(/[\n,;]+/) + .map((r) => r.trim()) + .filter(Boolean); + + const seen = new Set(); + const entries = []; + let skipped = 0; + + 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); + + // 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 (entries.length >= limit) { skipped++; continue; } + + let priceUsd = null; + let aliasOf = null; + for (const field of fields.slice(1)) { + const price = parsePriceToken(field); + // A price is unambiguous — it is the only field that can start with `$` + // or be all digits, and an all-numeric ending is rejected anyway. So + // anything that is not a price is the ending this one points at. + if (price !== null) priceUsd = price; + else aliasOf = normalizeToken(field); + } + entries.push({ tld, 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 }; +} + +function normalizeToken(value) { + return String(value ?? "").trim().toLowerCase().replace(/^\.+/, "") || null; +} + +/** + * `$2`, `$2.00USD`, `2.00`, `USD 2` — a price if it reads as one, else null. + * + * Forgiving because it is typed by hand in a textarea next to a dollar sign, + * and strict about the shape because the alternative reading of a stray token + * is "the ending this one points at", which would silently mis-route a name. + */ +function parsePriceToken(value) { + const raw = String(value ?? "").trim().toLowerCase().replace(/^usd/, "").replace(/usd$/, "").replace(/^\$/, "").trim(); + if (!raw || !/^\d+(\.\d{1,2})?$/.test(raw)) return null; + const price = Number(raw); + return Number.isFinite(price) && price > 0 ? price : null; +} diff --git a/apps/pwa/test/moshpit-name-drift.test.mjs b/apps/pwa/test/moshpit-name-drift.test.mjs new file mode 100644 index 0000000..aa7082a --- /dev/null +++ b/apps/pwa/test/moshpit-name-drift.test.mjs @@ -0,0 +1,79 @@ +// The vendored namespace rules, against the published ones. +// +// src/lib/moshpit-name.mjs is a copy of @moshcoder/moshpit-name, and it stays a +// copy: apps/pwa is not part of a pnpm workspace, so the repo-root install that +// CI runs never reaches it and its dependencies are simply absent there. A +// runtime import would fail at load — which is exactly what happened when this +// was tried the other way round. +// +// So the dependency is dev-only, nothing at runtime needs it, and this compares +// the two. Behaviour rather than bytes, because the copies are allowed to carry +// different comments and must not be allowed to carry different rules. +// +// Skips when the package is absent, so a checkout without dev dependencies +// still runs everything else. +import assert from "node:assert/strict"; +import test from "node:test"; + +import * as vendored from "../src/lib/moshpit-name.mjs"; + +let published = null; +try { published = await import("@moshcoder/moshpit-name"); } catch { published = null; } + +const NAMES = [ + "blue.eggs", "a.b.c", "1.2.3.4", "localhost", "", "eggs", "blue.420", "420.blue", + "1.420", "192.168", "0.0", "-bad.eggs", "bad-.eggs", "x".repeat(64) + ".eggs", "A.EGGS.", +]; + +test("vendored namespace rules match the published package", { + skip: published ? false : "@moshcoder/moshpit-name not installed", +}, async (t) => { + await t.test("every hostname parses the same way", () => { + for (const n of NAMES) { + assert.deepEqual(vendored.parseMoshpitName(n), published.parseMoshpitName(n), n); + } + }); + + await t.test("the same endings are refused, for the same reason", () => { + for (const tld of ["bank", "apple", "gov", "eggs", "420", "a", "", "a.b", "com"]) { + assert.equal(vendored.normalizeTld(tld), published.normalizeTld(tld), `normalizeTld(${tld})`); + assert.equal(vendored.tldRejection(tld), published.tldRejection(tld), `tldRejection(${tld})`); + } + // A reserved list that drifts is a list where one copy sells a name the + // other protects. + assert.deepEqual([...vendored.RESERVED_TLDS].sort(), [...published.RESERVED_TLDS].sort()); + }); + + await t.test("prices and limits have not drifted apart", () => { + for (const key of [ + "CHILD_PRICE_USD", "ENDING_PRICE_USD", "MAX_CHILD_PRICE_USD", + "DEFAULT_TLD_PRICE_USD", "MAX_BULK_TLDS", "BULK_CHUNK", + ]) { + assert.equal(vendored[key], published[key], key); + } + }); + + await t.test("a pasted list parses identically, settings and all", () => { + const pastes = [ + ".toplevel .redirect $2.00USD\neggs, yeah\n# a comment\n.911 $5", + "oranges\t\tmosh", + ".Eggs\nEGGS\neggs", + "", + ]; + for (const paste of pastes) { + 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"]) { + assert.equal( + vendored.resolutionPreference({ registered, mode }), + published.resolutionPreference({ registered, mode }), + `${mode}/${registered}`, + ); + } + } + }); +});