diff --git a/app/parking/page.tsx b/app/parking/page.tsx new file mode 100644 index 0000000..f238b25 --- /dev/null +++ b/app/parking/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { toTenantParams } from "@/lib/parking"; +import TenantPage, { generateMetadata as tenantMetadata } from "../page"; + +export const runtime = "nodejs"; +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 /. + */ +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)) }); +} diff --git a/lib/parking.ts b/lib/parking.ts new file mode 100644 index 0000000..0994119 --- /dev/null +++ b/lib/parking.ts @@ -0,0 +1,24 @@ +export type ParkingParams = Record; + +/** + * 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. + * + * 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. + */ +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; +} diff --git a/middleware.ts b/middleware.ts index fc30339..8d57bc2 100644 --- a/middleware.ts +++ b/middleware.ts @@ -14,7 +14,8 @@ function frameAncestors(req: NextRequest): string { // its own pages. New parked domains work without hand-editing FRAME_ANCESTORS. const isDomain = (d: string) => d.length <= 253 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/.test(d); - for (const key of ["dn", "bid"]) { + // "name" is the same domain arriving via /parking?name=. + for (const key of ["dn", "bid", "name"]) { const d = (req.nextUrl.searchParams.get(key) || "").trim().toLowerCase(); if (isDomain(d)) allow.push(`https://${d}`, `https://www.${d}`); } @@ -39,9 +40,11 @@ export function middleware(req: NextRequest) { // — the first ref a visitor arrives with sticks). Signup/waitlist read it so // the referral is credited whenever they convert within the window. // Normally ?ref= is its own param. But Porkbun param-forwarding can glue - // it onto the ?dn= value ("dn=moshscript.com?ref=abc"), so recover it there too. + // it onto the ?dn= value ("dn=moshscript.com?ref=abc"), so recover it there too + // — and from ?name=, which carries the same domain on /parking. const sp = req.nextUrl.searchParams; - const rawRef = sp.get("ref") ?? sp.get("dn")?.match(/[?&]ref=([A-Za-z0-9_-]+)/)?.[1] ?? null; + const glued = (key: string) => sp.get(key)?.match(/[?&]ref=([A-Za-z0-9_-]+)/)?.[1]; + const rawRef = sp.get("ref") ?? glued("dn") ?? glued("name") ?? null; if (rawRef && !req.cookies.get("mc_ref")?.value) { const ref = rawRef.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64); if (ref) { diff --git a/tests/parking-params.test.mjs b/tests/parking-params.test.mjs new file mode 100644 index 0000000..816db90 --- /dev/null +++ b/tests/parking-params.test.mjs @@ -0,0 +1,48 @@ +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); +});