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
39 changes: 39 additions & 0 deletions app/api/moshpit/tlds/[tld]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { getTld, normalizeTld, tldRejection } from "@/lib/moshpit";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* GET /api/moshpit/tlds/:tld — availability lookup, no auth.
*
* This is what a registration page calls as you type, and what a resolver calls
* to find out who owns a name, so it answers for every case rather than 404ing
* on "not registered" — unregistered is a legitimate answer here.
*/
export async function GET(_req: NextRequest, ctx: { params: Promise<{ tld: string }> }) {
const { tld: raw } = await ctx.params;
const tld = normalizeTld(raw);
if (!tld) {
return NextResponse.json(
{ tld: raw, available: false, reason: "not a valid TLD — letters, digits and dashes only, no dots" },
{ status: 400 },
);
}

const reserved = tldRejection(tld);
if (reserved) return NextResponse.json({ tld, available: false, reason: reserved });

const owned = await getTld(tld);
if (owned) {
// Deliberately not the owning account id — ownership is public, the
// account behind it is not.
return NextResponse.json({
tld,
available: false,
reason: "already registered",
registered_at: owned.created_at,
});
}
return NextResponse.json({ tld, available: true });
}
39 changes: 39 additions & 0 deletions app/api/moshpit/tlds/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { resolveAccountId, bad, unauthorized } from "@/lib/api";
import { getAccountById } from "@/lib/db";
import { listTlds, listTldsForAccount, registerTld } from "@/lib/moshpit";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/** GET /api/moshpit/tlds — the public registry, or `?mine=1` for yours. */
export async function GET(req: NextRequest) {
if (req.nextUrl.searchParams.get("mine")) {
const accountId = await resolveAccountId(req);
if (!accountId) return unauthorized();
return NextResponse.json({ tlds: await listTldsForAccount(accountId) });
}
return NextResponse.json({ tlds: await listTlds() });
}

/** POST /api/moshpit/tlds — claim `.<whatever>`. First writer wins. */
export async function POST(req: NextRequest) {
const accountId = await resolveAccountId(req);
if (!accountId) return unauthorized();

const body = await req.json().catch(() => ({}));
const account = await getAccountById(accountId);

const result = await registerTld({
tld: body?.tld,
accountId,
ownerEmail: account?.email ?? null,
ownerKey: typeof body?.owner_key === "string" ? 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(result.error, result.taken ? 409 : 400);
return NextResponse.json({ tld: result.tld }, { status: 201 });
}
32 changes: 32 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,38 @@ async function initSchema(): Promise<void> {
`);
await d.execute(`CREATE INDEX IF NOT EXISTS idx_parked_account ON parked_domains (account_id)`);

// ---- Moshpit TLDs: the .anything namespace (see docs/prd/0001-moshpit-namespace.md).
//
// Ownership is first-come-first-served, and the row is not the authority —
// `moshpit_tld_log` is. Allocation order is what decides a contested name, so
// it is recorded in an append-only log that can be published and audited.
// That is what lets the directory be mirrored by anyone without letting a
// mirror forge or seize a name: records are ordered here, verified anywhere.
await d.execute(`
CREATE TABLE IF NOT EXISTS moshpit_tlds (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
tld TEXT NOT NULL UNIQUE,
account_id TEXT NOT NULL,
owner_email TEXT,
owner_key TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
await d.execute(`CREATE INDEX IF NOT EXISTS idx_moshpit_tld_account ON moshpit_tlds (account_id)`);

// Append-only. No UPDATE or DELETE is ever issued against this table: "who
// claimed .eggs first" has to stay answerable after the fact, including when
// the answer is inconvenient.
await d.execute(`
CREATE TABLE IF NOT EXISTS moshpit_tld_log (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
tld TEXT NOT NULL,
account_id TEXT NOT NULL,
action TEXT NOT NULL,
at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);

// ---- domain auctions: one per domain, runs FOREVER (no expiry) — the owner
// collects bids until they accept one. Owner sets an optional reserve (hidden
// from bidders) and buy-now (a bid >= buy_now auto-wins). Managed on /dashboard.
Expand Down
52 changes: 52 additions & 0 deletions lib/moshpit-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Validation and policy for Moshpit TLD names.
//
// Deliberately free of any database import so it can be tested — and reused by
// a client — without a Turso connection. lib/moshpit.ts owns the storage.

/**
* Names nobody may claim, whatever the PRD's 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; PRD 0001
* R9 (reputation / anti-squatting) is the longer answer.
*/
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: unknown): string | null {
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: string): string | null {
if (RESERVED_TLDS.has(tld)) return "that name is reserved";
if (tld.length < 2) return "a TLD needs at least 2 characters";
return null;
}
114 changes: 114 additions & 0 deletions lib/moshpit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// The Moshpit TLD namespace — `.moshpit`, `.eggs`, `.whatever`.
//
// See docs/prd/0001-moshpit-namespace.md. Anyone can claim a TLD nobody holds;
// the operator of that TLD then owns everything under it.
//
// 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 { db, ensureSchema } from "./db";
import { normalizeTld, tldRejection } from "./moshpit-name";

export { RESERVED_TLDS, normalizeTld, tldRejection } from "./moshpit-name";

export type MoshpitTld = {
tld: string;
account_id: string;
owner_email: string | null;
created_at: string;
};

export async function getTld(tld: string): Promise<MoshpitTld | null> {
await ensureSchema();
const r = await db().execute({
sql: `SELECT tld, account_id, owner_email, created_at FROM moshpit_tlds WHERE tld = ?`,
args: [tld],
});
return (r.rows[0] as unknown as MoshpitTld) ?? null;
}

export async function listTlds(limit = 200): Promise<MoshpitTld[]> {
await ensureSchema();
const r = await db().execute({
sql: `SELECT tld, account_id, owner_email, created_at FROM moshpit_tlds
ORDER BY created_at DESC LIMIT ?`,
args: [limit],
});
return r.rows as unknown as MoshpitTld[];
}

export async function listTldsForAccount(accountId: string): Promise<MoshpitTld[]> {
await ensureSchema();
const r = await db().execute({
sql: `SELECT tld, account_id, owner_email, created_at FROM moshpit_tlds
WHERE account_id = ? ORDER BY created_at DESC`,
args: [accountId],
});
return r.rows as unknown as MoshpitTld[];
}

export type RegisterResult =
| { ok: true; tld: MoshpitTld }
| { ok: false; error: string; taken?: boolean };

/**
* Claim a TLD. First writer wins.
*
* The UNIQUE constraint 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.
*/
export async function registerTld(opts: {
tld: string;
accountId: string;
ownerEmail?: string | null;
ownerKey?: string | null;
/**
* Register a name that is on the reserved list. Only for assigning one of
* our own names to us — it is never reachable from the public API, because
* the reserved list exists precisely to stop that route.
*/
allowReserved?: boolean;
}): Promise<RegisterResult> {
await ensureSchema();
const tld = normalizeTld(opts.tld);
if (!tld) return { ok: false, error: "not a valid TLD — letters, digits and dashes only, no dots" };

const rejected = tldRejection(tld);
if (rejected && !opts.allowReserved) return { ok: false, error: rejected };

try {
await db().execute({
sql: `INSERT INTO moshpit_tlds (tld, account_id, owner_email, owner_key) VALUES (?,?,?,?)`,
args: [tld, opts.accountId, opts.ownerEmail ?? null, opts.ownerKey ?? null],
});
} 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 db().execute({
sql: `INSERT INTO moshpit_tld_log (tld, account_id, action) VALUES (?,?,'register')`,
args: [tld, opts.accountId],
});

const created = await getTld(tld);
return created ? { ok: true, tld: created } : { ok: false, error: "registered but could not be read back" };
}

/** The append-only allocation log — the answer to "who claimed it first". */
export async function tldLog(limit = 500) {
await ensureSchema();
const r = await db().execute({
sql: `SELECT seq, tld, account_id, action, at FROM moshpit_tld_log ORDER BY seq ASC LIMIT ?`,
args: [limit],
});
return r.rows;
}
32 changes: 32 additions & 0 deletions scripts/seed-moshpit-tld.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Seed the network's own TLD: `.moshpit`, owned by the operator.
//
// bun run scripts/seed-moshpit-tld.ts
//
// 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 { findOrCreateAccountByEmail } from "../lib/db";
import { getTld, registerTld } from "../lib/moshpit";

const OWNER_EMAIL = process.env.MOSHPIT_OWNER_EMAIL || "anthony@profullstack.com";
const TLD = process.env.MOSHPIT_SEED_TLD || "moshpit";

const existing = await getTld(TLD);
if (existing) {
console.log(`.${TLD} already registered to ${existing.owner_email ?? existing.account_id} (${existing.created_at})`);
process.exit(0);
}

const account = await findOrCreateAccountByEmail(OWNER_EMAIL);
const result = await registerTld({
tld: TLD,
accountId: account.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}`);
Loading
Loading