diff --git a/app/page.tsx b/app/page.tsx
index bd96d14..0759c5f 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
+import { redirect } from "next/navigation";
import { configFor, safeDomain } from "@/lib/config";
+import { isMoshpitName } from "@/lib/moshpit-tlds";
+import { pitNameUrl } from "@/lib/parking";
import { getTenantConfig } from "@/lib/db";
import Landing from "@/components/Landing";
import Tenant from "@/components/Tenant";
@@ -69,6 +72,13 @@ export default async function Page({
const dn = safeDomain(sp.dn) || (await hostTenantDn());
if (!dn) return ;
+ // A Moshpit name that DNS parked here belongs to the Pit, not to this app's
+ // parked-domain card. Temporary, not permanent: the owner can point the name
+ // at a real target at any moment, and a 308 cached in a browser would keep
+ // sending them here long after this app stopped being the answer. Ordinary
+ // parked clearnet domains do not match and render exactly as before.
+ if (await isMoshpitName(dn)) redirect(pitNameUrl(dn));
+
// A paid/provisioned domain has a tenants row that overrides the defaults.
const tenantOverride = await getTenantConfig(dn).catch(() => null);
diff --git a/lib/moshpit-tlds.ts b/lib/moshpit-tlds.ts
new file mode 100644
index 0000000..8b4479f
--- /dev/null
+++ b/lib/moshpit-tlds.ts
@@ -0,0 +1,63 @@
+import { PIT_BASE_URL } from "./parking";
+
+/**
+ * Is this host a name on the Moshpit network, or an ordinary parked domain?
+ *
+ * Asked of the Pit per name rather than by pulling its ending list: that list
+ * is capped (200 at the time of writing) and `.eggs` already falls outside it,
+ * so matching against it would quietly answer "no" for real names. `resolve`
+ * has no such ceiling — its `registered` flag is exactly "an ending the pit
+ * holds", which is the question here.
+ *
+ * This app keeps its own `moshpit_tlds` rows, but they are a cache of a
+ * registry that lives elsewhere; a name claimed minutes ago has to work.
+ */
+
+/** Long enough not to hit the Pit per request, short enough for a new ending to show up. */
+const TTL_MS = 5 * 60_000;
+const TIMEOUT_MS = 2_000;
+/** Host is attacker-controlled, so the memo cannot be allowed to grow without end. */
+const MAX_ENTRIES = 2_000;
+
+const cache = new Map();
+
+function remember(name: string, ours: boolean): void {
+ // Whole-map eviction rather than LRU bookkeeping: this is a small memo in
+ // front of a 5-minute TTL, and the cost of a cold start is one request.
+ if (cache.size >= MAX_ENTRIES) cache.clear();
+ cache.set(name, { at: Date.now(), ours });
+}
+
+/**
+ * Fails closed: an unreachable Pit answers "no", so a registry outage leaves
+ * every parked domain rendering exactly as it does today rather than bouncing
+ * the whole internet at `/n/`. A previously cached answer beats that fallback.
+ */
+export async function isMoshpitName(dn: string): Promise {
+ const name = String(dn || "").toLowerCase();
+ const parts = name.split(".");
+ // One label and one ending — the same shape the registry can hold.
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return false;
+
+ const hit = cache.get(name);
+ if (hit && Date.now() - hit.at < TTL_MS) return hit.ours;
+
+ try {
+ const res = await fetch(
+ `${PIT_BASE_URL}/api/moshpit/resolve?name=${encodeURIComponent(name)}`,
+ { signal: AbortSignal.timeout(TIMEOUT_MS), cache: "no-store" },
+ );
+ if (!res.ok) return hit?.ours ?? false;
+ const json = (await res.json()) as { registered?: boolean };
+ const ours = json?.registered === true;
+ remember(name, ours);
+ return ours;
+ } catch {
+ return hit?.ours ?? false;
+ }
+}
+
+/** Test seam: drop the memoised answers. */
+export function resetMoshpitTldCache(): void {
+ cache.clear();
+}
diff --git a/lib/parking.ts b/lib/parking.ts
index 5d86f25..dbb8d0c 100644
--- a/lib/parking.ts
+++ b/lib/parking.ts
@@ -27,5 +27,10 @@ export function parkingTarget(sp: ParkingParams): string {
// Nothing usable to look up — the Pit's front door beats a 404 for someone
// who just typed a name at us.
if (!name) return `${PIT_BASE_URL}/pit`;
+ return pitNameUrl(name);
+}
+
+/** The Pit's page for a name. Callers pass something safeDomain() has cleared. */
+export function pitNameUrl(name: string): string {
return `${PIT_BASE_URL}/n/${encodeURIComponent(name)}`;
}
diff --git a/tests/moshpit-tlds.test.mjs b/tests/moshpit-tlds.test.mjs
new file mode 100644
index 0000000..69e6ee1
--- /dev/null
+++ b/tests/moshpit-tlds.test.mjs
@@ -0,0 +1,110 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { isMoshpitName, resetMoshpitTldCache } from "../lib/moshpit-tlds.ts";
+
+const realFetch = globalThis.fetch;
+
+/** Stand in for the Pit's resolve endpoint. */
+function pitSays(registeredEndings, { ok = true } = {}) {
+ const seen = [];
+ globalThis.fetch = async (url) => {
+ seen.push(String(url));
+ const name = new URL(String(url)).searchParams.get("name") || "";
+ const ending = name.split(".").pop();
+ return { ok, json: async () => ({ name, registered: registeredEndings.includes(ending) }) };
+ };
+ return seen;
+}
+
+function pitIsDown() {
+ globalThis.fetch = async () => {
+ throw new Error("registry unreachable");
+ };
+}
+
+test.afterEach(() => {
+ globalThis.fetch = realFetch;
+ resetMoshpitTldCache();
+});
+
+test("a name under a Moshpit ending is ours", async () => {
+ resetMoshpitTldCache();
+ pitSays(["eggs", "chicken"]);
+
+ assert.equal(await isMoshpitName("scrambled.eggs"), true);
+ assert.equal(await isMoshpitName("hawaiian.chicken"), true);
+});
+
+test("an ordinary parked domain is not ours, and keeps its tenant page", async () => {
+ resetMoshpitTldCache();
+ pitSays(["eggs"]);
+
+ // `.sh` is a real TLD nobody claimed in the pit — it must not be hijacked.
+ assert.equal(await isMoshpitName("moshcode.sh"), false);
+ assert.equal(await isMoshpitName("example.com"), false);
+});
+
+test("only one label and one ending counts, and it never calls out for the rest", async () => {
+ resetMoshpitTldCache();
+ const seen = pitSays(["eggs"]);
+
+ assert.equal(await isMoshpitName("a.b.eggs"), false);
+ assert.equal(await isMoshpitName("eggs"), false);
+ assert.equal(await isMoshpitName(""), false);
+ assert.equal(seen.length, 0, "a shape the registry cannot hold needs no request");
+});
+
+test("the answer is memoised per name rather than fetched per request", async () => {
+ resetMoshpitTldCache();
+ const seen = pitSays(["eggs"]);
+
+ await isMoshpitName("scrambled.eggs");
+ await isMoshpitName("scrambled.eggs");
+ await isMoshpitName("scrambled.eggs");
+
+ assert.equal(seen.length, 1, "the Pit should be hit once per name");
+});
+
+test("the name is normalized, so case is not a cache miss", async () => {
+ resetMoshpitTldCache();
+ const seen = pitSays(["eggs"]);
+
+ assert.equal(await isMoshpitName("Scrambled.EGGS"), true);
+ assert.equal(await isMoshpitName("scrambled.eggs"), true);
+ assert.equal(seen.length, 1);
+});
+
+test("an unreachable Pit fails closed rather than bouncing everything", async () => {
+ resetMoshpitTldCache();
+ pitIsDown();
+
+ // The whole internet parked here must not start redirecting to /n/ because
+ // the registry blipped — today's behaviour is the safe answer.
+ assert.equal(await isMoshpitName("scrambled.eggs"), false);
+});
+
+test("a non-200 from the Pit is treated as unreachable", async () => {
+ resetMoshpitTldCache();
+ pitSays(["eggs"], { ok: false });
+
+ assert.equal(await isMoshpitName("scrambled.eggs"), false);
+});
+
+test("a cached answer beats the fail-closed fallback when the Pit goes down", async () => {
+ resetMoshpitTldCache();
+ pitSays(["eggs"]);
+ assert.equal(await isMoshpitName("scrambled.eggs"), true);
+
+ pitIsDown();
+ assert.equal(await isMoshpitName("scrambled.eggs"), true, "warm cache should hold");
+});
+
+test("the lookup asks the Pit for the exact name", async () => {
+ resetMoshpitTldCache();
+ const seen = pitSays(["eggs"]);
+
+ await isMoshpitName("scrambled.eggs");
+
+ assert.match(seen[0], /\/api\/moshpit\/resolve\?name=scrambled\.eggs$/);
+});