From 9eb57868427e5f2dcc0dd50945bd1b1e4923757c Mon Sep 17 00:00:00 2001 From: Anthony Date: Sat, 1 Aug 2026 04:21:08 +0000 Subject: [PATCH] feat(parking): send Moshpit names to the Pit instead of a parked card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name in our own network was landing on this app's generic parked-domain page — "Scrambled IS COMING" about a name that is one click from being yours in the Pit. That is a dead end, and it is not the network we are selling. /parking?name= now 308s to /n/. Permanent, not temporary, so crawlers follow it and index the Pit's page for the name rather than holding on to a moshcoding.com URL that only ever bounces. The Pit base is PIT_BASE_URL (default https://pit.moshcode.sh), and the host is fixed in code — safeDomain() validates the name before it is appended, so nothing that survives it can redirect off-site. No usable name falls back to the Pit's front door rather than a 404. Host-header parking for real clearnet domains is untouched: those still render the tenant page at /. Co-Authored-By: Claude Opus 5 (1M context) --- app/parking/page.tsx | 29 ++++++++------------- lib/parking.ts | 41 +++++++++++++++++------------- tests/parking-params.test.mjs | 48 ----------------------------------- tests/parking-target.test.mjs | 48 +++++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 84 deletions(-) delete mode 100644 tests/parking-params.test.mjs create mode 100644 tests/parking-target.test.mjs diff --git a/app/parking/page.tsx b/app/parking/page.tsx index f238b25..1c15bd8 100644 --- a/app/parking/page.tsx +++ b/app/parking/page.tsx @@ -1,6 +1,5 @@ -import type { Metadata } from "next"; -import { toTenantParams } from "@/lib/parking"; -import TenantPage, { generateMetadata as tenantMetadata } from "../page"; +import { permanentRedirect } from "next/navigation"; +import { parkingTarget } from "@/lib/parking"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -8,27 +7,19 @@ export const dynamic = "force-dynamic"; type SearchParams = Record; /** - * /parking?name= — the URL registrar parking pages and forwarding rules - * point at. It renders the tenant page in place rather than redirecting to - * /?dn=, so the link a registrar already holds keeps working and masked - * forwarding never sees a hop. All other params (?ref, ?brand, ?social_*, …) - * pass straight through to the same renderer as /. + * /parking?name= — where a Moshpit name lands when it has not been + * pointed anywhere yet. + * + * It sends the visitor to the Pit's page for that name rather than rendering a + * parked-domain card here. A 308 (not a 307) because the name's home really is + * `/n/` and always will be: crawlers follow it and index the Pit page, + * instead of holding on to a moshcoding.com URL that only ever bounces. */ -export async function generateMetadata({ - searchParams, -}: { - searchParams: Promise; -}): Promise { - const sp = (await searchParams) || {}; - return tenantMetadata({ searchParams: Promise.resolve(toTenantParams(sp)) }); -} - export default async function Parking({ searchParams, }: { searchParams: Promise; }) { const sp = (await searchParams) || {}; - // No usable name → the tenant renderer falls back to , not a 404. - return TenantPage({ searchParams: Promise.resolve(toTenantParams(sp)) }); + permanentRedirect(parkingTarget(sp)); } diff --git a/lib/parking.ts b/lib/parking.ts index 0994119..5d86f25 100644 --- a/lib/parking.ts +++ b/lib/parking.ts @@ -1,24 +1,31 @@ +import { safeDomain } from "./config"; + export type ParkingParams = Record; +/** The Pit that owns the Moshpit namespace — the registry, not this app. */ +export const PIT_BASE_URL = (process.env.PIT_BASE_URL || "https://pit.moshcode.sh").replace( + /\/+$/, + "", +); + /** - * Registrar parking/forwarding links point at /parking?name=, while the - * tenant renderer at / keys off ?dn=. Map name → dn so both URLs drive one - * implementation instead of two that drift. + * Where /parking?name= belongs. + * + * A Moshpit name that hasn't been pointed anywhere is a name in *our* network, + * so it gets the Pit's own page for it — `/n/`, where you can see who + * holds it and take it if nobody does. Rendering this app's generic + * parked-domain card instead was a dead end: it said "IS COMING" about a name + * that is one click from being yours, and it is not the network we are selling. * * Porkbun's param forwarding can glue the visitor's query onto the value - * ("scrambled.eggs?ref=abc"), and safeDomain() strips that back to a bare - * domain — so lift any glued ?ref= into its own param before it's lost. An - * explicit ?ref= already on the URL wins, matching the first-touch rule. + * ("scrambled.eggs?ref=abc"); safeDomain() strips that back to a bare name, + * which is also what keeps this from being an open redirect — the host is + * fixed here and only a validated name is ever appended. */ -export function toTenantParams(sp: ParkingParams): ParkingParams { - const { name, ...rest } = sp; - const raw = typeof name === "string" && name.trim() ? name : rest.dn; - if (typeof raw !== "string" || !raw.trim()) return rest; - - const out: ParkingParams = { ...rest, dn: raw }; - if (!out.ref) { - const glued = raw.match(/[?&]ref=([A-Za-z0-9_-]+)/)?.[1]; - if (glued) out.ref = glued; - } - return out; +export function parkingTarget(sp: ParkingParams): string { + const name = safeDomain(sp.name ?? sp.dn); + // 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 `${PIT_BASE_URL}/n/${encodeURIComponent(name)}`; } diff --git a/tests/parking-params.test.mjs b/tests/parking-params.test.mjs deleted file mode 100644 index 816db90..0000000 --- a/tests/parking-params.test.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { toTenantParams } from "../lib/parking.ts"; -import { safeDomain } from "../lib/config.ts"; - -test("toTenantParams maps ?name= onto the ?dn= the tenant renderer reads", () => { - assert.deepEqual(toTenantParams({ name: "scrambled.eggs" }), { dn: "scrambled.eggs" }); - assert.equal(safeDomain(toTenantParams({ name: "scrambled.eggs" }).dn), "scrambled.eggs"); -}); - -test("toTenantParams passes every other param through untouched", () => { - const out = toTenantParams({ - name: "nonhuman.aliens", - brand: "Nonhuman", - ref: "abc123", - social_bluesky: "@x", - link_1: "https://example.com", - }); - assert.equal(out.dn, "nonhuman.aliens"); - assert.equal(out.brand, "Nonhuman"); - assert.equal(out.ref, "abc123"); - assert.equal(out.social_bluesky, "@x"); - assert.equal(out.link_1, "https://example.com"); - assert.equal(out.name, undefined); -}); - -test("toTenantParams recovers a Porkbun-glued ref off the name value", () => { - const out = toTenantParams({ name: "scrambled.eggs?ref=abc123" }); - assert.equal(out.ref, "abc123"); - // safeDomain still resolves the tenant from the glued value. - assert.equal(safeDomain(out.dn), "scrambled.eggs"); -}); - -test("an explicit ref wins over a glued one (first-touch stays predictable)", () => { - const out = toTenantParams({ name: "scrambled.eggs?ref=glued", ref: "explicit" }); - assert.equal(out.ref, "explicit"); -}); - -test("toTenantParams leaves an existing ?dn= alone when no name is given", () => { - assert.deepEqual(toTenantParams({ dn: "moshcode.sh" }), { dn: "moshcode.sh" }); -}); - -test("a blank or missing name yields no dn, so / falls back to the landing page", () => { - assert.equal(toTenantParams({}).dn, undefined); - assert.equal(toTenantParams({ name: " " }).dn, undefined); - assert.equal(toTenantParams({ name: "" }).dn, undefined); -}); diff --git a/tests/parking-target.test.mjs b/tests/parking-target.test.mjs new file mode 100644 index 0000000..c10ce9d --- /dev/null +++ b/tests/parking-target.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parkingTarget, PIT_BASE_URL } from "../lib/parking.ts"; + +test("a Moshpit name goes to the Pit's page for it, not a parked card here", () => { + assert.equal(parkingTarget({ name: "hawaiian.chicken" }), `${PIT_BASE_URL}/n/hawaiian.chicken`); + assert.equal(parkingTarget({ name: "scrambled.eggs" }), `${PIT_BASE_URL}/n/scrambled.eggs`); +}); + +test("the name is normalized before it becomes a path", () => { + assert.equal(parkingTarget({ name: " Scrambled.EGGS " }), `${PIT_BASE_URL}/n/scrambled.eggs`); +}); + +test("a Porkbun-glued query is stripped back to the bare name", () => { + // "scrambled.eggs?ref=abc" must not leak into the path. + assert.equal( + parkingTarget({ name: "scrambled.eggs?ref=abc123" }), + `${PIT_BASE_URL}/n/scrambled.eggs`, + ); +}); + +test("?dn= still works, so the older link shape does not break", () => { + assert.equal(parkingTarget({ dn: "moshcode.sh" }), `${PIT_BASE_URL}/n/moshcode.sh`); +}); + +test("no usable name falls back to the Pit itself rather than a 404", () => { + for (const sp of [{}, { name: " " }, { name: "" }, { name: "notaname" }]) { + assert.equal(parkingTarget(sp), `${PIT_BASE_URL}/pit`, `for ${JSON.stringify(sp)}`); + } +}); + +test("the target host is fixed, so a hostile name cannot redirect off-site", () => { + // safeDomain() rejects or strips these; the assertion is that nothing which + // survives it can change the host we send people to. + for (const name of [ + "evil.com/../../out", + "https://evil.com", + "evil.com#@attacker.test", + "//evil.com", + ]) { + const target = parkingTarget({ name }); + assert.ok( + target.startsWith(`${PIT_BASE_URL}/`), + `${JSON.stringify(name)} escaped to ${target}`, + ); + } +});