From 66703503010fdc4bff398013aa5729594022e6d1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 02:52:47 +0000 Subject: [PATCH] feat(pit): bring the Moshpit namespace into app.moshcode.sh The TLD registry lived in the moshcoding app, which meant claiming .eggs used a moshcoding account while the CLI authenticated against a moshcode one. Two identities for one network, and no way to reach the registry from the dashboard you actually sign into. Ported here so a TLD is owned by a `users` row -- the same account `moshcode login` establishes. src/lib/moshpit-name.mjs validation + policy, no db import src/moshpit.mjs storage: claim, alias, exempt, resolve src/routes/moshpit.mjs the API and /pit src/migrations/006 moshpit_tlds, the append-only log, exemptions scripts/seed-moshpit-tld assigns .moshpit to the operator, idempotent Also adds the resolution-precedence option tronbrowser.dev needs: GET /api/moshpit/resolve?name=&mode= returns `prefer`, one of clearnet / fallback / moshpit. Default mode is clearnet, where a registered name only fills a gap DNS leaves ("fallback"); mode=moshpit is the opt-in that lets a name in the pit outrank a squatted clearnet domain. Whether clearnet actually answers is deliberately not decided here. The extension is the thing holding the DNS result, and an ICANN TLD list baked into the server would be stale within the week -- so the server states the rule and the client applies it. Overriding DNS is never the default: a resolver that silently outranked real DNS the first time it was switched on would hijack names its operator never meant to touch. Co-Authored-By: Claude Opus 5 --- apps/pwa/scripts/seed-moshpit-tld.mjs | 44 ++++ apps/pwa/src/lib/moshpit-name.mjs | 109 ++++++++++ apps/pwa/src/migrations/006_moshpit.sql | 41 ++++ apps/pwa/src/moshpit.mjs | 211 +++++++++++++++++++ apps/pwa/src/routes/moshpit.mjs | 262 ++++++++++++++++++++++++ apps/pwa/src/server.mjs | 2 + apps/pwa/test/moshpit-name.test.mjs | 87 ++++++++ apps/pwa/test/moshpit-registry.test.mjs | 139 +++++++++++++ 8 files changed, 895 insertions(+) create mode 100644 apps/pwa/scripts/seed-moshpit-tld.mjs create mode 100644 apps/pwa/src/lib/moshpit-name.mjs create mode 100644 apps/pwa/src/migrations/006_moshpit.sql create mode 100644 apps/pwa/src/moshpit.mjs create mode 100644 apps/pwa/src/routes/moshpit.mjs create mode 100644 apps/pwa/test/moshpit-name.test.mjs create mode 100644 apps/pwa/test/moshpit-registry.test.mjs diff --git a/apps/pwa/scripts/seed-moshpit-tld.mjs b/apps/pwa/scripts/seed-moshpit-tld.mjs new file mode 100644 index 0000000..95dc1a4 --- /dev/null +++ b/apps/pwa/scripts/seed-moshpit-tld.mjs @@ -0,0 +1,44 @@ +// Seed the network's own TLD: `.moshpit`, owned by the operator. +// +// node scripts/seed-moshpit-tld.mjs +// +// Idempotent -- running it twice is a no-op, so it is safe on every deploy. +// `.moshpit` is on the reserved list, which is what stops anyone else claiming +// it; assigning it to us is the one case that bypasses the list on purpose. +import { migrate } from "../src/migrate.mjs"; +import { get, run } from "../src/db.mjs"; +import { id } from "../src/lib/crypto.mjs"; +import { getTld, registerTld } from "../src/moshpit.mjs"; + +const OWNER_EMAIL = process.env.MOSHPIT_OWNER_EMAIL || "anthony@profullstack.com"; +const TLD = process.env.MOSHPIT_SEED_TLD || "moshpit"; + +await migrate(); + +const existing = await getTld(TLD); +if (existing) { + console.log(`.${TLD} already registered to ${existing.owner_email ?? existing.user_id}`); + process.exit(0); +} + +// The operator may not have signed in on this deployment yet, so the account +// they will sign into is created here rather than assumed. Matching on email +// means a later email/passkey/CoinPay sign-in lands on this same row. +let user = await get(`SELECT id, email FROM users WHERE email = ?`, [OWNER_EMAIL]); +if (!user) { + const uid = id(); + await run(`INSERT INTO users (id, email, created_at) VALUES (?,?,?)`, [uid, OWNER_EMAIL, Date.now()]); + user = { id: uid, email: OWNER_EMAIL }; + console.log(`created account for ${OWNER_EMAIL}`); +} + +const result = await registerTld({ + tld: TLD, userId: user.id, ownerEmail: OWNER_EMAIL, allowReserved: true, +}); + +if (!result.ok) { + console.error(`could not register .${TLD}: ${result.error}`); + process.exit(1); +} +console.log(`registered .${result.tld.tld} -> ${OWNER_EMAIL}`); +process.exit(0); diff --git a/apps/pwa/src/lib/moshpit-name.mjs b/apps/pwa/src/lib/moshpit-name.mjs new file mode 100644 index 0000000..7da87a8 --- /dev/null +++ b/apps/pwa/src/lib/moshpit-name.mjs @@ -0,0 +1,109 @@ +// Validation, policy and resolution precedence for Moshpit names. +// +// 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])?$/; + +/** + * 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(/^\.+/, ""); + if (!raw || raw.length > 63) return null; + // A dot means they gave a domain, not a TLD. Say so rather than silently + // registering the wrong thing. + if (raw.includes(".")) return null; + if (!LABEL.test(raw)) return null; + // All-numeric would be ambiguous against an IPv4 literal in a hostname. + if (/^\d+$/.test(raw)) return null; + return raw; +} + +/** 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; + // Both halves are hostname labels, and normalizeTld already encodes exactly + // that rule -- so reuse it rather than keeping a second copy that can drift. + if (!normalizeTld(label) || !normalizeTld(tld)) return null; + return { label, tld }; +} + +/* ---- 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"; +} diff --git a/apps/pwa/src/migrations/006_moshpit.sql b/apps/pwa/src/migrations/006_moshpit.sql new file mode 100644 index 0000000..b007311 --- /dev/null +++ b/apps/pwa/src/migrations/006_moshpit.sql @@ -0,0 +1,41 @@ +-- The Moshpit TLD namespace: `.moshpit`, `.eggs`, `.whatever`. +-- +-- Ported from the moshcoding app so that a TLD is owned by a moshcode account +-- (`users`) -- the same identity `moshcode login` establishes -- rather than by +-- a second, unrelated account table. + +-- The directory. This is a cache; moshpit_tld_log below is the record. +CREATE TABLE IF NOT EXISTS moshpit_tlds ( + tld TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + owner_email TEXT, + owner_key TEXT, + -- The TLD this one points at, or null when it stands on its own. + alias_of TEXT, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_moshpit_tlds_user ON moshpit_tlds(user_id); +CREATE INDEX IF NOT EXISTS idx_moshpit_tlds_alias ON moshpit_tlds(alias_of); + +-- Append-only allocation log: the answer to "who claimed it first". Allocating +-- a unique name is an ordering problem, and an ordered log is checkable rather +-- than trusted, so the directory can be mirrored without a mirror being able to +-- forge or seize a name. +CREATE TABLE IF NOT EXISTS moshpit_tld_log ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + tld TEXT NOT NULL, + user_id TEXT NOT NULL, + action TEXT NOT NULL, + at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_moshpit_log_tld ON moshpit_tld_log(tld); + +-- Names held back from their TLD's alias, so `tonyrobbins.financewizards` +-- survives `.financewizards` being pointed at `.financialadvice`. +CREATE TABLE IF NOT EXISTS moshpit_alias_exempt ( + tld TEXT NOT NULL, + label TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (tld, label) +); diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs new file mode 100644 index 0000000..0c17f9b --- /dev/null +++ b/apps/pwa/src/moshpit.mjs @@ -0,0 +1,211 @@ +// The Moshpit TLD namespace -- `.moshpit`, `.eggs`, `.whatever`. +// +// Anyone can claim a TLD nobody holds; the operator of that TLD then owns +// everything under it. Ownership hangs off `users`, the same account +// `moshcode login` establishes. +// +// On authority: the `moshpit_tlds` row is a cache. `moshpit_tld_log` is the +// record. Allocating a unique name is an ordering problem, and ordering is what +// the log provides -- so the directory can be mirrored and served by anyone +// without a mirror being able to forge or seize a name, because the order is +// checkable rather than trusted. + +import { get, all, run } from "./db.mjs"; +import { normalizeTld, parseMoshpitName, tldRejection } from "./lib/moshpit-name.mjs"; + +export { + RESERVED_TLDS, RESOLVE_MODES, normalizeTld, parseMoshpitName, tldRejection, + normalizeMode, resolutionPreference, +} from "./lib/moshpit-name.mjs"; + +const COLS = `tld, user_id, owner_email, alias_of, created_at`; + +export async function getTld(tld) { + return get(`SELECT ${COLS} FROM moshpit_tlds WHERE tld = ?`, [tld]); +} + +export async function listTlds(limit = 200) { + return all(`SELECT ${COLS} FROM moshpit_tlds ORDER BY created_at DESC LIMIT ?`, [limit]); +} + +export async function listTldsForUser(userId) { + return all(`SELECT ${COLS} FROM moshpit_tlds WHERE user_id = ? ORDER BY created_at DESC`, [userId]); +} + +/** + * Claim a TLD. First writer wins. + * + * The PRIMARY KEY on `tld` is what actually decides a race -- checking "is it + * free?" and then inserting would let two simultaneous claims both pass the + * check. So the insert is the check, and a constraint violation is read as + * "someone got there first" rather than as an error. + * + * `allowReserved` registers a name on the reserved list. Only for assigning one + * of our own names to us; it is never reachable from the HTTP API, because the + * reserved list exists precisely to stop that route. + */ +export async function registerTld({ tld: input, userId, ownerEmail = null, ownerKey = null, allowReserved = false }) { + const tld = normalizeTld(input); + if (!tld) return { ok: false, error: "not a valid TLD — letters, digits and dashes only, no dots" }; + + const rejected = tldRejection(tld); + if (rejected && !allowReserved) return { ok: false, error: rejected }; + + try { + await run( + `INSERT INTO moshpit_tlds (tld, user_id, owner_email, owner_key, created_at) VALUES (?,?,?,?,?)`, + [tld, userId, ownerEmail, ownerKey, Date.now()], + ); + } catch { + const existing = await getTld(tld); + if (existing) return { ok: false, error: `.${tld} is already registered`, taken: true }; + return { ok: false, error: "could not register that TLD" }; + } + + // Written after the row lands, so the log never claims an allocation that did + // not happen. + await logAction(tld, userId, "register"); + + const created = await getTld(tld); + return created ? { ok: true, tld: created } : { ok: false, error: "registered but could not be read back" }; +} + +const logAction = (tld, userId, action) => + run(`INSERT INTO moshpit_tld_log (tld, user_id, action, at) VALUES (?,?,?,?)`, [tld, userId, action, Date.now()]); + +/** The append-only allocation log -- the answer to "who claimed it first". */ +export async function tldLog(limit = 500) { + return all(`SELECT seq, tld, user_id, action, at FROM moshpit_tld_log ORDER BY seq ASC LIMIT ?`, [limit]); +} + +/* ---- aliases ---- */ + +/** + * Point one TLD at another: `.agentic` -> `.agent`, so `foo.agentic` resolves + * to `foo.agent`. + * + * Both must be held by the same user. Aliasing a name you do not own would turn + * this into a land-grab -- claim `.agent`, then absorb forty related words + * without registering any of them -- and first-come-first-served would stop + * meaning anything. + * + * Chains are rejected rather than followed. A TLD is either a target or an + * alias, never both, which makes resolution a single hop and makes a cycle + * impossible to construct in the first place, instead of something to detect at + * read time forever after. + */ +export async function setAlias({ from: fromInput, to: toInput, userId }) { + const from = normalizeTld(fromInput); + const to = normalizeTld(toInput); + if (!from || !to) return { ok: false, error: "not a valid TLD" }; + if (from === to) return { ok: false, error: "a TLD cannot point at itself" }; + + const [source, target] = await Promise.all([getTld(from), getTld(to)]); + if (!source) return { ok: false, error: `.${from} is not registered` }; + if (!target) return { ok: false, error: `.${to} is not registered` }; + if (source.user_id !== userId) return { ok: false, error: `you do not own .${from}` }; + if (target.user_id !== userId) return { ok: false, error: `you do not own .${to}` }; + if (target.alias_of) { + return { ok: false, error: `.${to} already points at .${target.alias_of} — point at the destination instead` }; + } + + const pointedHere = await get(`SELECT tld FROM moshpit_tlds WHERE alias_of = ? LIMIT 1`, [from]); + if (pointedHere) { + return { ok: false, error: `.${pointedHere.tld} already points at .${from}, so it cannot point elsewhere itself` }; + } + + await run(`UPDATE moshpit_tlds SET alias_of = ? WHERE tld = ?`, [to, from]); + await logAction(from, userId, `alias:${to}`); + return { ok: true }; +} + +/** Stop pointing `.from` anywhere. */ +export async function clearAlias(fromInput, userId) { + const tld = normalizeTld(fromInput); + if (!tld) return { ok: false, error: "not a valid TLD" }; + const existing = await getTld(tld); + if (!existing) return { ok: false, error: `.${tld} is not registered` }; + if (existing.user_id !== userId) return { ok: false, error: `you do not own .${tld}` }; + + await run(`UPDATE moshpit_tlds SET alias_of = NULL WHERE tld = ?`, [tld]); + await logAction(tld, userId, "unalias"); + return { ok: true }; +} + +/* ---- exemptions ---- */ + +/** Is this name held back from its TLD's alias? */ +export async function isExempt(tld, label) { + const row = await get(`SELECT 1 AS hit FROM moshpit_alias_exempt WHERE tld = ? AND label = ? LIMIT 1`, [tld, label]); + return Boolean(row); +} + +export async function listExempt(tld) { + const rows = await all(`SELECT label FROM moshpit_alias_exempt WHERE tld = ? ORDER BY label`, [tld]); + return rows.map((r) => String(r.label)); +} + +/** + * Hold `label.tld` back from `.tld`'s alias, so it keeps resolving to itself. + * + * Allowed even when no alias is set yet: an operator should be able to carve + * out the names they intend to keep BEFORE pointing the TLD somewhere, rather + * than having to redirect everyone first and repair it afterwards. + */ +export async function setExempt({ tld: tldInput, label: labelInput, userId }) { + const owned = await ownedTldAndLabel(tldInput, labelInput, userId); + if (!owned.ok) return owned; + + await run(`INSERT OR IGNORE INTO moshpit_alias_exempt (tld, label, user_id, created_at) VALUES (?,?,?,?)`, + [owned.tld, owned.label, userId, Date.now()]); + await logAction(owned.tld, userId, `exempt:${owned.label}`); + return { ok: true }; +} + +/** Let `label.tld` follow the alias again. */ +export async function clearExempt({ tld: tldInput, label: labelInput, userId }) { + const owned = await ownedTldAndLabel(tldInput, labelInput, userId); + if (!owned.ok) return owned; + + await run(`DELETE FROM moshpit_alias_exempt WHERE tld = ? AND label = ?`, [owned.tld, owned.label]); + await logAction(owned.tld, userId, `unexempt:${owned.label}`); + return { ok: true }; +} + +async function ownedTldAndLabel(tldInput, labelInput, userId) { + const tld = normalizeTld(tldInput); + const label = normalizeTld(labelInput); + if (!tld || !label) return { ok: false, error: "not a valid name" }; + const owner = await getTld(tld); + if (!owner) return { ok: false, error: `.${tld} is not registered` }; + if (owner.user_id !== userId) return { ok: false, error: `you do not own .${tld}` }; + return { ok: true, tld, label }; +} + +/* ---- resolution ---- */ + +/** + * Resolve `foo.agentic` to `foo.agent`. + * + * The label is carried across rather than dropped: an alias redirects the + * namespace, not the name. `.agentic` pointing at `.agent` means every name + * under it keeps its own identity on the other side. + */ +export async function resolveMoshpitName(input) { + const parsed = parseMoshpitName(input); + if (!parsed) return null; + const { label, tld } = parsed; + const name = `${label}.${tld}`; + + const owner = await getTld(tld); + if (!owner) return { name, resolved: name, aliased: false, registered: false }; + if (!owner.alias_of) return { name, resolved: name, aliased: false, registered: true }; + + // An exempt name outranks the alias. Checked here rather than at write time + // because the exemption has to survive the alias being repointed later. + if (await isExempt(tld, label)) { + return { name, resolved: name, aliased: false, registered: true, exempt: true }; + } + + return { name, resolved: `${label}.${owner.alias_of}`, aliased: true, registered: true }; +} diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs new file mode 100644 index 0000000..9b836dc --- /dev/null +++ b/apps/pwa/src/routes/moshpit.mjs @@ -0,0 +1,262 @@ +// The Moshpit namespace: claim `.`, alias it, exempt names from the +// alias, and resolve. +// +// GET /api/moshpit/tlds the public registry (`?mine=1` for yours) +// POST /api/moshpit/tlds claim `.` +// GET /api/moshpit/tlds/:tld availability lookup, no auth +// PUT /api/moshpit/tlds/:tld/alias point .tld at another TLD you own +// DELETE /api/moshpit/tlds/:tld/alias stop pointing it anywhere +// GET /api/moshpit/tlds/:tld/exempt names held back from the alias +// POST /api/moshpit/tlds/:tld/exempt hold one back +// DELETE /api/moshpit/tlds/:tld/exempt let it follow the alias again +// GET /api/moshpit/resolve?name=&mode= resolve + precedence for a client resolver +// GET /pit the human page +import { Router } from "express"; +import { page, footer, appBar, esc } from "../lib/html.mjs"; +import { requireAuth, csrfInput } from "../lib/session.mjs"; +import { balance } from "../lib/credits.mjs"; +import { + getTld, listTlds, listTldsForUser, registerTld, + setAlias, clearAlias, listExempt, setExempt, clearExempt, + resolveMoshpitName, normalizeTld, tldRejection, + normalizeMode, resolutionPreference, +} from "../moshpit.mjs"; + +export const moshpitRouter = Router(); + +const bad = (res, error, status = 400) => res.status(status).json({ error }); +const unauthorized = (res) => res.status(401).json({ error: "sign in first" }); + +/* ---------- API ---------- */ + +moshpitRouter.get("/api/moshpit/tlds", async (req, res) => { + if (req.query.mine) { + if (!req.user) return unauthorized(res); + return res.json({ tlds: await listTldsForUser(req.user.id) }); + } + res.json({ tlds: await listTlds() }); +}); + +moshpitRouter.post("/api/moshpit/tlds", async (req, res) => { + if (!req.user) return unauthorized(res); + const result = await registerTld({ + tld: req.body?.tld, + userId: req.user.id, + ownerEmail: req.user.email ?? null, + ownerKey: typeof req.body?.owner_key === "string" ? req.body.owner_key : null, + }); + // 409 rather than 400 when the name is gone: the request was well formed, + // someone else simply got there first, and a client should be able to tell + // those apart without parsing the message. + if (!result.ok) return bad(res, result.error || "could not register that TLD", result.taken ? 409 : 400); + res.status(201).json({ tld: result.tld }); +}); + +/** + * Availability lookup. Answers for every case rather than 404ing on "not + * registered" -- unregistered is a legitimate answer here, and this is what a + * registration page calls as you type. + */ +moshpitRouter.get("/api/moshpit/tlds/:tld", async (req, res) => { + const tld = normalizeTld(req.params.tld); + if (!tld) { + return res.status(400).json({ + tld: req.params.tld, available: false, + reason: "not a valid TLD — letters, digits and dashes only, no dots", + }); + } + const reserved = tldRejection(tld); + if (reserved) return res.json({ tld, available: false, reason: reserved }); + + const owned = await getTld(tld); + if (owned) { + // Deliberately not the owning user id -- ownership is public, the account + // behind it is not. + return res.json({ tld, available: false, reason: "already registered", registered_at: owned.created_at }); + } + res.json({ tld, available: true }); +}); + +moshpitRouter.put("/api/moshpit/tlds/:tld/alias", async (req, res) => { + if (!req.user) return unauthorized(res); + const result = await setAlias({ from: req.params.tld, to: req.body?.to, userId: req.user.id }); + if (!result.ok) return bad(res, result.error || "could not set that alias"); + res.json({ from: normalizeTld(req.params.tld), to: normalizeTld(req.body?.to) }); +}); + +moshpitRouter.delete("/api/moshpit/tlds/:tld/alias", async (req, res) => { + if (!req.user) return unauthorized(res); + const result = await clearAlias(req.params.tld, req.user.id); + if (!result.ok) return bad(res, result.error || "could not clear that alias"); + res.json({ from: normalizeTld(req.params.tld), to: null }); +}); + +moshpitRouter.get("/api/moshpit/tlds/:tld/exempt", async (req, res) => { + const tld = normalizeTld(req.params.tld); + if (!tld) return bad(res, "not a valid TLD"); + res.json({ tld, exempt: await listExempt(tld) }); +}); + +moshpitRouter.post("/api/moshpit/tlds/:tld/exempt", async (req, res) => { + if (!req.user) return unauthorized(res); + const result = await setExempt({ tld: req.params.tld, label: req.body?.label, userId: req.user.id }); + if (!result.ok) return bad(res, result.error || "could not exempt that name"); + res.status(201).json({ tld: normalizeTld(req.params.tld), label: normalizeTld(req.body?.label), exempt: true }); +}); + +moshpitRouter.delete("/api/moshpit/tlds/:tld/exempt", async (req, res) => { + if (!req.user) return unauthorized(res); + const result = await clearExempt({ tld: req.params.tld, label: req.body?.label, userId: req.user.id }); + if (!result.ok) return bad(res, result.error || "could not clear that exemption"); + res.json({ tld: normalizeTld(req.params.tld), label: normalizeTld(req.body?.label), exempt: false }); +}); + +/** + * Resolve a name, and say what a resolver should DO with the answer. + * + * `mode` is the tronbrowser.dev setting: "clearnet" (default) keeps legacy DNS + * authoritative and uses the pit only to fill gaps; "moshpit" lets a registered + * name outrank DNS, which is how a squatted `profullstack.ai` gets backfilled + * by the pit's own `profullstack.ai`. + * + * We do not check whether clearnet answers -- the extension is the thing + * holding the DNS result, and an ICANN TLD list baked in here would be stale + * within the week. The server states the rule; the client applies it. + */ +moshpitRouter.get("/api/moshpit/resolve", async (req, res) => { + const mode = normalizeMode(req.query.mode); + const resolution = await resolveMoshpitName(req.query.name); + if (!resolution) { + return res.status(400).json({ + error: "not a valid moshpit name — expected