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