From 6782624b67320b656c1aff498caf2e89f6f9ff83 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Fri, 28 Aug 2026 20:28:29 +0000
Subject: [PATCH 1/2] Backfill a Moshpit name with a clearnet twin
A pit name cannot be reached from outside the pit and cannot hold a
certificate, because no CA will issue for an ending ICANN does not
delegate. That is the ceiling on the namespace: people take the clean
name and hand out an ugly domain anyway, because the ugly one works.
A twin is a real registered domain a name publishes as its way in --
`financial.advisors` backfilled by `financial-advisors.net`. The pit
name stays the identity; the domain is only transport.
The transform is deterministic in both directions, because a pit label
may not contain a hyphen. So a twin has exactly one hyphen in its stem
and splits back into exactly one name with no lookup, which is what
lets a client holding only the domain name the pit name it belongs to.
Four things the design holds to:
- A twin never touches `prefer`. It is a domain that already answers in
the legacy root, so folding it into precedence would have the pit
outrank DNS for names DNS handed it -- indistinguishable from the
hijack the clearnet-wins default exists to prevent. Covered by a test
that compares a backfilled name against a bare one.
- Ownership is proven before a twin is served, with one TXT record that
does two jobs: publishing it proves control of the domain, and the
same record is the reverse pointer that lets someone arriving at the
domain discover the name. Two records would have allowed a domain to
prove itself and never advertise the name, which is the state where
nobody learns the clean name exists.
- A lapsed domain does not fail closed, it fails into whoever catches
the drop. So the link is dropped on our clock, a week ahead of the
registrar's, and it is read at query time rather than swept -- a
sweep that has not run yet is a window serving a link already known
to be dead.
- A domain whose stem reads as a different name is refused. Letting
`red-eggs.net` back `blue.eggs` would make the cheap computation and
the published proof disagree, and any client trusting the former gets
sent somewhere its owner never pointed it.
Releasing a name takes its twin with it, deleted explicitly like the
pins and records above it, since foreign keys are not enforced here. It
matters more than either: an inherited twin would point the next
holder's visitors at a stranger's site under their own name.
Not built yet: the checkout. TWIN_PRICE_USD is settled and quoted by
the API, but nothing charges it, and buying the domain on a customer's
behalf needs a registrar integration that is its own unit of work.
51 new tests; 626 pass in apps/pwa.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01GWhPX5Uzd29whRg5WPAYM7
---
apps/pwa/src/lib/moshpit-twin.mjs | 255 ++++++++++++++
apps/pwa/src/migrations/015_moshpit_twins.sql | 59 ++++
apps/pwa/src/moshpit.mjs | 297 ++++++++++++++++
apps/pwa/src/routes/moshpit.mjs | 154 +++++++++
apps/pwa/test/moshpit-twin-name.test.mjs | 185 ++++++++++
apps/pwa/test/moshpit-twin-route.test.mjs | 187 ++++++++++
apps/pwa/test/moshpit-twins.test.mjs | 327 ++++++++++++++++++
7 files changed, 1464 insertions(+)
create mode 100644 apps/pwa/src/lib/moshpit-twin.mjs
create mode 100644 apps/pwa/src/migrations/015_moshpit_twins.sql
create mode 100644 apps/pwa/test/moshpit-twin-name.test.mjs
create mode 100644 apps/pwa/test/moshpit-twin-route.test.mjs
create mode 100644 apps/pwa/test/moshpit-twins.test.mjs
diff --git a/apps/pwa/src/lib/moshpit-twin.mjs b/apps/pwa/src/lib/moshpit-twin.mjs
new file mode 100644
index 00000000..74b87052
--- /dev/null
+++ b/apps/pwa/src/lib/moshpit-twin.mjs
@@ -0,0 +1,255 @@
+// The clearnet twin: what a Moshpit name looks like on the legacy internet.
+//
+// `financial.advisors` has no answer in the public root and never will. No CA
+// will issue for an ending ICANN does not delegate, so the name cannot carry a
+// certificate and cannot be reached by anyone who has not installed a resolver.
+// That is the whole ceiling on the namespace: people like the clean name and
+// then hand out an ugly one anyway, because the ugly one is the one that works.
+//
+// A twin is the way out. `financial-advisors.net` can be registered, certified
+// and reached by anybody, and the pit name is the identity it publishes under.
+// The pit name stays canonical; the twin is transport.
+//
+// Deliberately free of any database import, for the same reason moshpit-name is:
+// a client -- the tronbrowser.dev extension, the DNS bridge -- needs these rules
+// too, and none of them have a libSQL connection. src/moshpit.mjs owns storage.
+import { normalizeLabel, normalizeTld, parseMoshpitName } from "./moshpit-name.mjs";
+
+/**
+ * The endings a twin is offered under, in the order people want them.
+ *
+ * All three are unclaimed as Moshpit endings and reserved in RESERVED_TLDS, so
+ * a twin can never collide with an ending somebody holds. That is not luck --
+ * `com`, `net` and `org` were reserved precisely because they collide with the
+ * legacy internet in ways that would only ever confuse, and this is the one
+ * place where that collision is the point.
+ */
+export const TWIN_TLDS = ["com", "net", "org"];
+
+/** A hostname label on the legacy internet, where -- unlike in the pit -- dashes are allowed. */
+const DOMAIN_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
+
+/**
+ * Normalise a clearnet domain, or null when it could never be one.
+ *
+ * Forgiving about what arrives because the field is typed by hand and people
+ * paste a URL with a path still on it. Strict in one place beyond DNS: the last
+ * label must be alphabetic and at least two characters, which refuses
+ * `1.2.3.4`. An address is a well-formed sequence of labels, and accepting one
+ * here would mean recording a "domain" that has no registrar to expire at.
+ */
+export function normalizeDomain(input) {
+ const raw = String(input ?? "").trim().toLowerCase()
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//, "") // a pasted URL
+ .replace(/[/?#].*$/, "") // ...with a path on it
+ .replace(/^\.+/, "")
+ .replace(/\.+$/, ""); // and a root dot, sometimes
+ if (!raw || raw.length > 253) return null;
+ const labels = raw.split(".");
+ if (labels.length < 2) return null;
+ if (!labels.every((l) => DOMAIN_LABEL.test(l))) return null;
+ if (!/^[a-z]{2,}$/.test(labels[labels.length - 1])) return null;
+ return raw;
+}
+
+/**
+ * `blue.eggs` + `net` -> `blue-eggs.net`, or null when it will not fit.
+ *
+ * A dot collapsing into a hyphen, and it is deterministic in BOTH directions
+ * for one reason: a Moshpit label may not contain a hyphen. That rule exists to
+ * stop look-alike squatting (see LABEL in moshpit-name.mjs) and this inherits it
+ * for free -- a twin has exactly one hyphen in its stem, so it splits back into
+ * exactly one `.` with no lookup and no ambiguity.
+ *
+ * The length check is not pedantry. A DNS label is capped at 63 characters and
+ * the stem is both halves of the pit name plus a hyphen, so a name well inside
+ * Moshpit's own limits can have no representable twin at all. Better to say so
+ * than to offer a domain no registrar will accept.
+ */
+export function clearnetTwin(input, tld = TWIN_TLDS[0]) {
+ const parsed = parseMoshpitName(input);
+ const suffix = normalizeTld(tld);
+ if (!parsed || !suffix) return null;
+ const stem = `${parsed.label}-${parsed.tld}`;
+ if (stem.length > 63) return null;
+ return normalizeDomain(`${stem}.${suffix}`);
+}
+
+/** Every twin worth offering for a name. Empty when the name is too long to have one. */
+export function clearnetTwins(input, tlds = TWIN_TLDS) {
+ return tlds.map((tld) => clearnetTwin(input, tld)).filter(Boolean);
+}
+
+/**
+ * The other direction: `blue-eggs.net` -> `blue.eggs`.
+ *
+ * This is what lets someone who arrived at the twin discover the name it stands
+ * for, without asking the registry anything.
+ *
+ * Only the registrable stem is read, so `www.blue-eggs.net` is the same twin
+ * wearing a hostname. Exactly one hyphen and both halves valid Moshpit labels,
+ * or null: a domain that merely happens to contain a dash is not a twin, and
+ * guessing otherwise would name a pit name on behalf of someone who never asked
+ * for one.
+ *
+ * A multi-label public suffix (`blue-eggs.co.uk`) reads the wrong stem here and
+ * comes back null or wrong. Doing it properly needs the Public Suffix List,
+ * which is a dependency this file exists to avoid -- so TWIN_TLDS is one label
+ * only, and that is the constraint that keeps this honest rather than an
+ * oversight to fix later.
+ */
+export function moshpitNameForTwin(input) {
+ const domain = normalizeDomain(input);
+ if (!domain) return null;
+ const labels = domain.split(".");
+ const stem = labels[labels.length - 2];
+ const parts = stem.split("-");
+ if (parts.length !== 2) return null;
+ const label = normalizeLabel(parts[0]);
+ const tld = normalizeTld(parts[1]);
+ if (!label || !tld) return null;
+ const name = `${label}.${tld}`;
+ return parseMoshpitName(name) ? name : null;
+}
+
+/* ---- proving the twin is yours ---- */
+
+/**
+ * Where the proof lives: `_moshpit.blue-eggs.net TXT "v=moshpit1 ..."`.
+ *
+ * Underscore-prefixed so it can never collide with a host somebody wants to
+ * serve, which is the convention every other TXT-based challenge settled on for
+ * the same reason.
+ */
+export const TWIN_PROOF_HOST = "_moshpit";
+
+/** The name to query for a domain's proof record. */
+export function twinProofName(domain) {
+ const d = normalizeDomain(domain);
+ return d ? `${TWIN_PROOF_HOST}.${d}` : null;
+}
+
+/** A challenge token: 16 random bytes as hex, checked so a malformed one cannot half-match. */
+export function normalizeTwinToken(input) {
+ const raw = String(input ?? "").trim().toLowerCase();
+ return /^[0-9a-f]{32}$/.test(raw) ? raw : null;
+}
+
+/**
+ * The TXT record a domain publishes to be backfilled onto a name.
+ *
+ * One record doing two jobs, deliberately. Publishing it proves control of the
+ * domain, because only its holder can put a record there -- and the same record
+ * IS the reverse pointer, the thing that lets a client arriving at
+ * `blue-eggs.net` learn it is `blue.eggs` in the pit. Two separate records
+ * would have allowed a domain to prove itself and then never advertise the
+ * name, and that is precisely the state in which nobody ever finds out the
+ * clean name exists. Adoption is the point; a proof nobody can read is half a
+ * feature.
+ *
+ * The token binds the pair in the direction the name's owner cannot fake.
+ * Without it, publishing `name=someone.else` would assert a link to a name you
+ * do not hold; with it, the assertion is only good against the challenge the
+ * registry issued to that name's actual owner.
+ */
+export function twinProof({ name, token }) {
+ const parsed = parseMoshpitName(name);
+ const t = normalizeTwinToken(token);
+ return parsed && t ? `v=moshpit1 name=${parsed.label}.${parsed.tld} token=${t}` : null;
+}
+
+/**
+ * Read a proof record back, or null when it is not one.
+ *
+ * Fields are read by key rather than by position: a TXT record gets edited by
+ * hand in a registrar's web form, and order is the first thing to change.
+ * Unknown fields are ignored so the format can grow one without every
+ * already-published record turning invalid on the day it does.
+ */
+export function parseTwinProof(txt) {
+ const fields = new Map(
+ String(txt ?? "").trim().split(/\s+/)
+ .map((f) => {
+ const eq = f.indexOf("=");
+ return eq > 0 ? [f.slice(0, eq).toLowerCase(), f.slice(eq + 1)] : null;
+ })
+ .filter(Boolean),
+ );
+ if (fields.get("v") !== "moshpit1") return null;
+ const parsed = parseMoshpitName(fields.get("name"));
+ const token = normalizeTwinToken(fields.get("token"));
+ if (!parsed || !token) return null;
+ return { name: `${parsed.label}.${parsed.tld}`, token };
+}
+
+/**
+ * Does any of a domain's TXT records prove this name?
+ *
+ * Takes the whole set because that is what a resolver returns, and because a
+ * domain in real use carries several: an SPF record, somebody else's challenge,
+ * a previous proof left behind after a rotation. One match among them is the
+ * answer. Requiring the set to contain nothing else would fail on every domain
+ * that is actually being used for anything.
+ *
+ * Compared plainly rather than in constant time, and that is considered: a
+ * challenge token is not a secret we hold and they guess. It is a value we hand
+ * to the name's owner and then read back out of public DNS, where anyone can
+ * already see it.
+ */
+export function twinProofMatches(txtRecords, { name, token }) {
+ const want = twinProof({ name, token });
+ if (!want) return false;
+ const expected = parseTwinProof(want);
+ for (const record of txtRecords ?? []) {
+ // A TXT record longer than 255 bytes arrives from DNS split into chunks and
+ // resolvers hand those back as an array per record. Joining is what
+ // reassembles the value the operator actually typed.
+ const value = Array.isArray(record) ? record.join("") : record;
+ const proof = parseTwinProof(value);
+ if (proof && proof.name === expected.name && proof.token === expected.token) return true;
+ }
+ return false;
+}
+
+/* ---- what a backfill costs, and when it lapses ---- */
+
+/**
+ * What backfilling a name costs per year, on top of the name itself.
+ *
+ * $12, which is roughly a `.com` at cost. This is not a margin business: the
+ * reason to sell it is that a pit name nobody outside the pit can reach is a
+ * name people admire and do not buy, and the twin is what turns the namespace
+ * from a curiosity into something you would put on a business card.
+ *
+ * Quoted as one number covering the registration rather than a fee plus a
+ * pass-through, so the buyer is told the thing they actually pay.
+ */
+export const TWIN_PRICE_USD = 12;
+
+/**
+ * How long before the registrar's expiry a twin stops being served.
+ *
+ * A lapsed domain does not fail closed. It fails into whoever catches the drop,
+ * and it fails invisibly: the pit goes on handing out a name that now resolves
+ * to a stranger, under a proof record that stranger may delete at their
+ * leisure. So the link is dropped on our clock, ahead of theirs.
+ *
+ * A week, because a renewal in flight should not be punished for being slow,
+ * and because the alternative failure -- a twin that goes dark while its owner
+ * still holds the domain -- is one an owner can see and fix, where the other
+ * one is not.
+ */
+export const TWIN_UNLINK_LEAD_MS = 7 * 24 * 60 * 60 * 1000;
+
+/**
+ * Is a verified twin still good at this instant?
+ *
+ * Read at query time rather than swept by a job. A sweep that has not run yet
+ * is a window in which the registry serves a link it has already decided is
+ * dead, and the whole point of the lead time is that there is no such window.
+ */
+export function twinIsLive(twin, now = Date.now()) {
+ if (!twin || twin.status !== "verified") return false;
+ if (twin.expires_at === null || twin.expires_at === undefined) return true;
+ return now < twin.expires_at - TWIN_UNLINK_LEAD_MS;
+}
diff --git a/apps/pwa/src/migrations/015_moshpit_twins.sql b/apps/pwa/src/migrations/015_moshpit_twins.sql
new file mode 100644
index 00000000..18c26b0e
--- /dev/null
+++ b/apps/pwa/src/migrations/015_moshpit_twins.sql
@@ -0,0 +1,59 @@
+-- The clearnet twin of a name: `blue.eggs` backfilled by `blue-eggs.net`.
+--
+-- A Moshpit name cannot be reached from outside the pit and cannot hold a
+-- certificate, because no CA will issue for an ending ICANN does not delegate.
+-- That is the ceiling on the whole namespace: people take the clean name and
+-- then hand out an ugly domain anyway, because the ugly one is the one that
+-- works. A twin is a real registered domain that the name publishes as its way
+-- in, so the pit name stays the identity and the domain is only transport.
+--
+-- Its own table rather than a column on moshpit_names for the ordinary reason:
+-- a twin has a lifecycle the name does not. It is claimed, then proven, then
+-- eventually lapses at a registrar on a date the pit does not control, and each
+-- of those is a field. Four nullable columns on `moshpit_names` would leave
+-- every name that never buys one carrying them.
+CREATE TABLE IF NOT EXISTS moshpit_twins (
+ tld TEXT NOT NULL,
+ label TEXT NOT NULL,
+ -- Normalised: lowercased, no scheme, no trailing dot. See normalizeDomain.
+ domain TEXT NOT NULL,
+ -- pending -> verified. There is no third state: a twin that fails
+ -- verification stays pending and can be retried, because the usual cause is
+ -- a TXT record that has not propagated yet rather than a wrong answer, and
+ -- recording that as a failure would mean re-issuing a challenge to fix a
+ -- delay that fixes itself.
+ status TEXT NOT NULL CHECK (status IN ('pending','verified')),
+ -- The challenge this claim is good against, and half of the TXT record the
+ -- domain publishes. Kept after verification rather than cleared: the record
+ -- stays in DNS as the reverse pointer, so the value that must still be found
+ -- there is not scratch state to discard.
+ token TEXT NOT NULL,
+ -- When the registration lapses at the registrar, or null when it was never
+ -- recorded. Null means "serve it indefinitely", which is the right default
+ -- for a domain the holder brought themselves and manages elsewhere -- the pit
+ -- has no way to learn that date and inventing one would drop a live twin.
+ expires_at INTEGER,
+ verified_at INTEGER,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at INTEGER NOT NULL,
+ -- One twin per name. Not several, and this is the load-bearing constraint:
+ -- the twin's entire job is to be the single answer to "where do I send
+ -- someone who is not on the pit". A name with two of them has no canonical
+ -- outside form, which is the problem it was bought to solve.
+ PRIMARY KEY (tld, label)
+);
+
+-- ...and one name per domain, among the ones actually being served.
+--
+-- Without this, `blue-eggs.net` could back both `blue.eggs` and `red.eggs`, and
+-- the reverse pointer -- the TXT record naming which pit name the domain stands
+-- for -- would be a claim the registry contradicts. Partial, so an abandoned
+-- pending claim on a domain never blocks the person who actually holds it from
+-- proving it. Two people may both be trying; only one can finish.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_moshpit_twins_domain
+ ON moshpit_twins(domain) WHERE status = 'verified';
+
+CREATE INDEX IF NOT EXISTS idx_moshpit_twins_user ON moshpit_twins(user_id);
+-- Lapse sweeps and the renewal nag both read "verified, expiring before X".
+CREATE INDEX IF NOT EXISTS idx_moshpit_twins_expiry ON moshpit_twins(expires_at)
+ WHERE status = 'verified';
diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs
index b7df1c60..725fdd07 100644
--- a/apps/pwa/src/moshpit.mjs
+++ b/apps/pwa/src/moshpit.mjs
@@ -10,6 +10,8 @@
// without a mirror being able to forge or seize a name, because the order is
// checkable rather than trusted.
+import { randomBytes } from "node:crypto";
+
import { db, get, all, run } from "./db.mjs";
import { normalizeFeedKind, normalizeFeedUrl } from "./lib/feed.mjs";
import { contentOut, MAX_ITEMS_PER_NAME, normalizeContent, normalizeSlug } from "./lib/moshpit-content.mjs";
@@ -32,6 +34,16 @@ import {
normalizeRecordType,
recordConflict,
} from "./lib/moshpit-records.mjs";
+import {
+ TWIN_UNLINK_LEAD_MS,
+ clearnetTwins,
+ moshpitNameForTwin,
+ normalizeDomain,
+ twinIsLive,
+ twinProof,
+ twinProofMatches,
+ twinProofName,
+} from "./lib/moshpit-twin.mjs";
export {
RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, BULK_CHUNK, BULK_TIME_BUDGET_MS, shortCount, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, CHILD_PRICE_USD, ENDING_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName,
@@ -48,6 +60,12 @@ export {
navFor, normalizeContent, normalizeSlug, postsFor,
} from "./lib/moshpit-content.mjs";
+export {
+ TWIN_PRICE_USD, TWIN_PROOF_HOST, TWIN_TLDS, TWIN_UNLINK_LEAD_MS,
+ clearnetTwin, clearnetTwins, moshpitNameForTwin, normalizeDomain, normalizeTwinToken,
+ parseTwinProof, twinIsLive, twinProof, twinProofMatches, twinProofName,
+} from "./lib/moshpit-twin.mjs";
+
/**
* The largest number this column will accept.
*
@@ -450,6 +468,11 @@ export async function releaseName({ tld: tldInput, label: labelInput, userId })
// Records go with the name for the same reason, and it matters more: an
// inherited MX would route the next holder's mail to the last one's server.
await run(`DELETE FROM moshpit_records WHERE tld = ? AND label = ?`, [owned.tld, owned.label]);
+ // And the twin, which matters most of the three. It names a domain the
+ // departing holder registered and still controls, so inheriting one would
+ // point the next holder's visitors at a stranger's website under their own
+ // name — and hand that stranger a proof record they can revoke at will.
+ await run(`DELETE FROM moshpit_twins WHERE tld = ? AND label = ?`, [owned.tld, owned.label]);
await run(`DELETE FROM moshpit_names WHERE tld = ? AND label = ?`, [owned.tld, owned.label]);
await logAction(owned.tld, userId, `unname:${owned.label}`);
return { ok: true };
@@ -1547,3 +1570,277 @@ export async function listTldPurchases(userId, limit = 50) {
export function isExpired(tld, now = Date.now()) {
return Boolean(tld?.expires_at) && tld.expires_at <= now;
}
+
+/* ---- the clearnet twin ---- */
+
+const TWIN_COLS = `tld, label, domain, status, token, expires_at, verified_at, user_id, created_at`;
+
+/**
+ * The longest term a twin's expiry may be set to.
+ *
+ * ICANN caps a domain registration at ten years, so a date beyond that is not a
+ * long registration, it is a typo or a client sending milliseconds where it
+ * meant seconds. Accepting it would park a twin that never lapses on our clock
+ * and defeat the lead time entirely.
+ */
+const MAX_TWIN_TERM_MS = 11 * 365 * 24 * 60 * 60 * 1000;
+
+/** Read TXT records for a name. Replaceable, so verification is testable without DNS. */
+async function resolveTxtRecords(hostname) {
+ const { resolveTxt } = await import("node:dns/promises");
+ return resolveTxt(hostname);
+}
+
+export async function getTwin(tldInput, labelInput) {
+ const tld = normalizeTld(tldInput);
+ const label = normalizeLabel(labelInput);
+ if (!tld || !label) return null;
+ return get(`SELECT ${TWIN_COLS} FROM moshpit_twins WHERE tld = ? AND label = ?`, [tld, label]);
+}
+
+/**
+ * The clearnet domain a client should send someone to for `scrambled.eggs`.
+ *
+ * Aliases are followed first, for the same reason pins follow them: when
+ * `.agentic` points at `.agent`, whatever serves `foo.agent` is what a visitor
+ * actually reaches, so its twin is the one that leads somewhere. Answering with
+ * the typed name's own twin would hand out a domain nobody is serving.
+ *
+ * Only a live twin is returned -- verified, and not inside the lead time before
+ * its registration lapses. A pending claim is a domain the registry has no
+ * evidence anyone controls, and handing that out would be worse than handing
+ * out nothing: the caller cannot tell an unproven answer from a proven one.
+ */
+export async function twinForName(input, now = Date.now()) {
+ const resolution = await resolveMoshpitName(input);
+ if (!resolution || !resolution.registered) return null;
+ const parsed = parseMoshpitName(resolution.resolved);
+ if (!parsed) return null;
+
+ const twin = await getTwin(parsed.tld, parsed.label);
+ return twinIsLive(twin, now) ? twin : null;
+}
+
+/**
+ * Why this domain cannot stand for this name, or null when it can.
+ *
+ * The interesting rule is the last one. A twin's stem is computable in both
+ * directions without a lookup -- that is the property the whole design leans
+ * on, because it lets a client holding only `blue-eggs.net` name the pit name
+ * it belongs to for free. Letting `red-eggs.net` back `blue.eggs` would break
+ * exactly that: the computation says `red.eggs`, the published proof says
+ * `blue.eggs`, and any client trusting the cheap answer is sent somewhere its
+ * owner never pointed it.
+ *
+ * A domain whose stem is not a twin shape at all is fine, and deliberately so.
+ * Somebody who already owns `financialadvisors.com` should be able to back
+ * `financial.advisors` with it; there is no computation to contradict.
+ */
+function twinDomainRejection(domain, name) {
+ if (!domain) return "not a valid domain";
+ const computed = moshpitNameForTwin(domain);
+ if (computed && computed !== name) {
+ return `${domain} reads as the twin of ${computed}, not ${name} — a client computing the name from the domain would be sent to the wrong one`;
+ }
+ return null;
+}
+
+/**
+ * Start backfilling a name: record the domain and issue the challenge.
+ *
+ * Does not verify. Verification is a second, separate call because the record
+ * has to be published between the two, and an API that made you guess how long
+ * to wait before retrying a single combined call would be a worse version of
+ * the same two steps.
+ *
+ * Replacing a twin that is already live is refused unless asked for
+ * explicitly. One twin per name is the rule that makes a twin worth anything,
+ * so pointing a name at a new domain necessarily takes it off the clearnet
+ * until the new one proves itself -- brief, recoverable, and not something to
+ * do by accident on the way to fixing a typo.
+ */
+export async function claimTwin({ tld: tldInput, label: labelInput, domain: domainInput, userId, expiresAt = null, replace = false, now = Date.now() }) {
+ const owned = await ownedName(tldInput, labelInput, userId);
+ if (!owned.ok) return owned;
+ const name = `${owned.label}.${owned.tld}`;
+
+ const domain = normalizeDomain(domainInput);
+ const rejection = twinDomainRejection(domain, name);
+ if (rejection) return { ok: false, error: rejection };
+
+ const expiry = normalizeTwinExpiry(expiresAt, now);
+ if (expiry.error) return { ok: false, error: expiry.error };
+
+ const current = await getTwin(owned.tld, owned.label);
+ if (current && twinIsLive(current, now) && current.domain !== domain && !replace) {
+ return {
+ ok: false,
+ error: `${name} is already backfilled by ${current.domain} — replacing it takes the name off the clearnet until the new domain verifies`,
+ replaceable: true,
+ current: current.domain,
+ };
+ }
+
+ // Checked before the challenge is issued rather than left to the unique index
+ // at verify time. Both stop it, but only this one stops it before the buyer
+ // has published a TXT record that was never going to be accepted.
+ const taken = await get(
+ `SELECT tld, label FROM moshpit_twins WHERE domain = ? AND status = 'verified' AND NOT (tld = ? AND label = ?)`,
+ [domain, owned.tld, owned.label],
+ );
+ if (taken) return { ok: false, error: `${domain} already backfills ${taken.label}.${taken.tld}`, taken: true };
+
+ // A fresh token per claim, including a re-claim of the same domain. Reusing
+ // the old one would let a proof published for a claim that was since given up
+ // silently satisfy a new one.
+ const token = randomBytes(16).toString("hex");
+ await run(
+ `INSERT INTO moshpit_twins (${TWIN_COLS}) VALUES (?,?,?,?,?,?,?,?,?)
+ ON CONFLICT (tld, label) DO UPDATE SET
+ domain = excluded.domain, status = 'pending', token = excluded.token,
+ expires_at = excluded.expires_at, verified_at = NULL, user_id = excluded.user_id`,
+ [owned.tld, owned.label, domain, "pending", token, expiry.value, null, userId, now],
+ );
+ await logAction(owned.tld, userId, `twin:claim:${owned.label}`);
+
+ return {
+ ok: true,
+ name,
+ domain,
+ token,
+ // Everything the owner needs to publish, rather than the pieces to assemble.
+ // The record is one string typed into one registrar form, and handing back
+ // its parts is how it gets typed in wrong.
+ proof: { host: twinProofName(domain), type: "TXT", value: twinProof({ name, token }) },
+ };
+}
+
+/**
+ * Check the proof and, if it is there, start serving the twin.
+ *
+ * The DNS lookup is injectable because the alternative is a test suite that
+ * either talks to the real internet or does not cover the only part of this
+ * worth covering.
+ *
+ * A lookup that fails is reported as a lookup that failed, separately from a
+ * lookup that succeeded and found nothing. They call for opposite responses --
+ * wait and retry, versus go and fix your record -- and collapsing them into one
+ * message is how somebody spends an afternoon re-typing a record that was
+ * always correct.
+ */
+export async function verifyTwin({ tld: tldInput, label: labelInput, userId, resolveTxt = resolveTxtRecords, now = Date.now() }) {
+ const owned = await ownedName(tldInput, labelInput, userId);
+ if (!owned.ok) return owned;
+ const name = `${owned.label}.${owned.tld}`;
+
+ const twin = await getTwin(owned.tld, owned.label);
+ if (!twin) return { ok: false, error: `${name} has no twin claimed` };
+
+ const host = twinProofName(twin.domain);
+ let records;
+ try {
+ records = await resolveTxt(host);
+ } catch (e) {
+ // ENODATA/ENOTFOUND mean the lookup worked and there is nothing there,
+ // which is a missing record rather than a broken resolver.
+ if (e?.code === "ENODATA" || e?.code === "ENOTFOUND" || e?.code === "NXDOMAIN") records = [];
+ else return { ok: false, error: `could not read TXT for ${host}: ${e?.code || e?.message || "lookup failed"}`, retryable: true };
+ }
+
+ if (!twinProofMatches(records, { name, token: twin.token })) {
+ return {
+ ok: false,
+ error: `no matching proof at ${host} — DNS changes can take a few minutes to publish`,
+ retryable: true,
+ proof: { host, type: "TXT", value: twinProof({ name, token: twin.token }) },
+ };
+ }
+
+ try {
+ await run(
+ `UPDATE moshpit_twins SET status = 'verified', verified_at = ? WHERE tld = ? AND label = ?`,
+ [now, owned.tld, owned.label],
+ );
+ } catch {
+ // The partial unique index on verified domains. Someone else proved this
+ // domain between the claim and now, which is a race with a real answer
+ // rather than an internal error to log and swallow.
+ return { ok: false, error: `${twin.domain} was verified against another name first`, taken: true };
+ }
+
+ await logAction(owned.tld, userId, `twin:verify:${owned.label}`);
+ return { ok: true, name, domain: twin.domain, verified_at: now, expires_at: twin.expires_at };
+}
+
+/**
+ * Record when the registration lapses, so the link can be dropped ahead of it.
+ *
+ * Null clears it, meaning "serve indefinitely". That is the honest default for
+ * a domain its holder brought and renews elsewhere: the pit cannot learn the
+ * date, and inventing one would take a live twin down on a guess.
+ */
+export async function setTwinExpiry({ tld: tldInput, label: labelInput, userId, expiresAt, now = Date.now() }) {
+ const owned = await ownedName(tldInput, labelInput, userId);
+ if (!owned.ok) return owned;
+
+ const expiry = normalizeTwinExpiry(expiresAt, now);
+ if (expiry.error) return { ok: false, error: expiry.error };
+
+ const result = await run(
+ `UPDATE moshpit_twins SET expires_at = ? WHERE tld = ? AND label = ?`,
+ [expiry.value, owned.tld, owned.label],
+ );
+ if (!result.rowsAffected) return { ok: false, error: `${owned.label}.${owned.tld} has no twin claimed` };
+ return { ok: true, expires_at: expiry.value };
+}
+
+function normalizeTwinExpiry(input, now) {
+ if (input === null || input === undefined || input === "") return { value: null };
+ const at = typeof input === "number" ? input : Date.parse(String(input));
+ if (!Number.isFinite(at)) return { error: "expiry must be a timestamp" };
+ if (at <= now) return { error: "expiry is in the past" };
+ if (at > now + MAX_TWIN_TERM_MS) return { error: "expiry is further out than a domain registration can run" };
+ return { value: Math.round(at) };
+}
+
+/** Stop backfilling a name. */
+export async function removeTwin({ tld: tldInput, label: labelInput, userId }) {
+ const owned = await ownedName(tldInput, labelInput, userId);
+ if (!owned.ok) return owned;
+ const result = await run(`DELETE FROM moshpit_twins WHERE tld = ? AND label = ?`, [owned.tld, owned.label]);
+ if (!result.rowsAffected) return { ok: false, error: `${owned.label}.${owned.tld} has no twin claimed` };
+ await logAction(owned.tld, userId, `twin:remove:${owned.label}`);
+ return { ok: true };
+}
+
+export async function listTwinsForUser(userId) {
+ return all(`SELECT ${TWIN_COLS} FROM moshpit_twins WHERE user_id = ? ORDER BY created_at DESC`, [userId]);
+}
+
+/**
+ * Verified twins that are about to stop being served, soonest first.
+ *
+ * What a renewal nag reads. The window is measured against the moment the pit
+ * drops the link, not against the registrar's date, because that is when the
+ * owner's name actually goes dark and it is the deadline they need told.
+ */
+export async function expiringTwins({ within = 30 * 24 * 60 * 60 * 1000, now = Date.now(), limit = 500 } = {}) {
+ return all(
+ `SELECT ${TWIN_COLS} FROM moshpit_twins
+ WHERE status = 'verified' AND expires_at IS NOT NULL AND expires_at - ? <= ?
+ ORDER BY expires_at ASC LIMIT ?`,
+ [TWIN_UNLINK_LEAD_MS, now + within, limit],
+ );
+}
+
+/** The twins worth offering for a name that has none, minus any already spoken for. */
+export async function availableTwins(input) {
+ const candidates = clearnetTwins(input);
+ if (!candidates.length) return [];
+ const rows = await all(
+ `SELECT domain FROM moshpit_twins WHERE status = 'verified' AND domain IN (${candidates.map(() => "?").join(",")})`,
+ candidates,
+ );
+ const taken = new Set(rows.map((r) => r.domain));
+ return candidates.filter((d) => !taken.has(d));
+}
diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs
index b5e5e274..8c410e9e 100644
--- a/apps/pwa/src/routes/moshpit.mjs
+++ b/apps/pwa/src/routes/moshpit.mjs
@@ -12,6 +12,12 @@
// 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 /api/moshpit/records?name= the records a name publishes, no auth
+// GET /api/moshpit/twin?name= the clearnet domain backfilling a name, no auth
+// GET /api/moshpit/tlds/:tld/twin the same, by tld + ?label=
+// POST /api/moshpit/tlds/:tld/twin claim a domain for a name you hold — issues the challenge
+// POST /api/moshpit/tlds/:tld/twin/verify check the TXT proof and start serving it
+// PUT /api/moshpit/tlds/:tld/twin/expiry when the registration lapses, so we drop it first
+// DELETE /api/moshpit/tlds/:tld/twin stop backfilling
// GET /api/moshpit/tlds/:tld/records the same, by tld + ?label=
// POST /api/moshpit/tlds/:tld/records publish a record on a name you hold
// DELETE /api/moshpit/tlds/:tld/records withdraw one
@@ -116,6 +122,16 @@ import {
tldLog,
tldRejection,
zoneLine,
+ TWIN_PRICE_USD,
+ availableTwins,
+ claimTwin,
+ clearnetTwins,
+ getTwin,
+ removeTwin,
+ setTwinExpiry,
+ twinForName,
+ twinProofName,
+ verifyTwin,
} from "../moshpit.mjs";
import { config } from "../config.mjs";
@@ -726,6 +742,127 @@ moshpitRouter.delete("/api/moshpit/tlds/:tld/records", async (req, res) => {
res.json({ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label), removed: true });
});
+/* ---- the clearnet twin ---- */
+
+/**
+ * GET /api/moshpit/twin?name=scrambled.eggs — public.
+ *
+ * Where to send somebody who is not on the pit. Public for the same reason the
+ * records are: the answer exists so that strangers can act on it, and a twin
+ * only a signed-in caller can read is a twin that does not do its job.
+ *
+ * Answers the resolved name, aliases followed, because that is the name a
+ * visitor actually reaches. The challenge token is not in here even for a
+ * pending claim — publishing it would say nothing an attacker can use, since
+ * only the domain's holder can act on it, but it would advertise which domain
+ * somebody is midway through claiming, and that is not this endpoint's business.
+ *
+ * `available` is offered when there is no twin: the candidates are computable
+ * without asking, but knowing which of them somebody else has already proven is
+ * not, and that is the half worth a round trip.
+ */
+moshpitRouter.get("/api/moshpit/twin", async (req, res) => {
+ const resolution = await resolveMoshpitName(req.query.name);
+ if (!resolution || !resolution.registered) {
+ return res.status(404).json({ error: "not a Moshpit name", twin: null });
+ }
+
+ const twin = await twinForName(resolution.resolved);
+ return res.json({
+ name: resolution.name,
+ resolved: resolution.resolved,
+ name_registered: resolution.name_registered,
+ twin: twin?.domain ?? null,
+ verified_at: twin?.verified_at ?? null,
+ expires_at: twin?.expires_at ?? null,
+ // Where the reverse pointer lives, so a client can read the link from the
+ // domain's side rather than taking our word for it.
+ proof: twin ? { host: twinProofName(twin.domain), type: "TXT" } : null,
+ ...(twin ? {} : { available: await availableTwins(resolution.resolved), price_usd: TWIN_PRICE_USD }),
+ });
+});
+
+/** GET /api/moshpit/tlds/:tld/twin?label=blue — public, unresolved and exact. */
+moshpitRouter.get("/api/moshpit/tlds/:tld/twin", async (req, res) => {
+ const tld = normalizeTld(req.params.tld);
+ const label = normalizeLabel(req.query.label);
+ if (!tld) return bad(res, "not a valid TLD");
+ if (!label) return bad(res, "which name? pass ?label=");
+ const twin = await getTwin(tld, label);
+ if (!twin) return res.status(404).json({ tld, label, twin: null, available: await availableTwins(`${label}.${tld}`) });
+ // Status and expiry, but never the token: this is the public view of a claim
+ // that may still be pending.
+ res.json({
+ tld, label, twin: twin.domain, status: twin.status,
+ verified_at: twin.verified_at, expires_at: twin.expires_at,
+ });
+});
+
+/**
+ * POST /api/moshpit/tlds/:tld/twin { label, domain, expires_at?, replace? }
+ *
+ * Issues the challenge. 409 rather than 400 when the domain is spoken for or a
+ * live twin would be replaced: the request was well-formed and the state
+ * refused it, and telling a script to fix input that was never wrong sends it
+ * round a loop it cannot exit.
+ */
+moshpitRouter.post("/api/moshpit/tlds/:tld/twin", async (req, res) => {
+ if (!req.user) return unauthorized(res);
+ const result = await claimTwin({
+ tld: req.params.tld, label: req.body?.label, userId: req.user.id,
+ domain: req.body?.domain, expiresAt: req.body?.expires_at ?? null,
+ replace: Boolean(req.body?.replace),
+ });
+ if (!result.ok) {
+ const conflict = result.taken || result.replaceable;
+ return res.status(conflict ? 409 : 400).json({
+ error: result.error || "could not claim that domain",
+ ...(result.replaceable ? { replaceable: true, current: result.current } : {}),
+ });
+ }
+ res.status(201).json({
+ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label),
+ domain: result.domain, status: "pending", proof: result.proof,
+ });
+});
+
+/**
+ * POST /api/moshpit/tlds/:tld/twin/verify { label }
+ *
+ * 202 rather than 200 on a retryable miss. The claim is accepted and unfinished
+ * — a TXT record that has not propagated is the ordinary case, not an error the
+ * caller can do anything about except wait — and a 4xx here would have every
+ * client treat "try again in a minute" as "you got it wrong".
+ */
+moshpitRouter.post("/api/moshpit/tlds/:tld/twin/verify", async (req, res) => {
+ if (!req.user) return unauthorized(res);
+ const result = await verifyTwin({ tld: req.params.tld, label: req.body?.label, userId: req.user.id });
+ if (!result.ok) {
+ if (result.retryable) return res.status(202).json({ verified: false, error: result.error, proof: result.proof });
+ return bad(res, result.error || "could not verify that domain", result.taken ? 409 : 400);
+ }
+ res.json({ verified: true, name: result.name, domain: result.domain, verified_at: result.verified_at, expires_at: result.expires_at });
+});
+
+/** PUT /api/moshpit/tlds/:tld/twin/expiry { label, expires_at } — null serves it indefinitely. */
+moshpitRouter.put("/api/moshpit/tlds/:tld/twin/expiry", async (req, res) => {
+ if (!req.user) return unauthorized(res);
+ const result = await setTwinExpiry({
+ tld: req.params.tld, label: req.body?.label, userId: req.user.id,
+ expiresAt: req.body?.expires_at ?? null,
+ });
+ if (!result.ok) return bad(res, result.error || "could not set that expiry");
+ res.json({ expires_at: result.expires_at });
+});
+
+/** DELETE /api/moshpit/tlds/:tld/twin { label } */
+moshpitRouter.delete("/api/moshpit/tlds/:tld/twin", async (req, res) => {
+ if (!req.user) return unauthorized(res);
+ const result = await removeTwin({ tld: req.params.tld, label: req.body?.label, userId: req.user.id });
+ if (!result.ok) return bad(res, result.error || "could not remove that twin", 404);
+ res.json({ removed: true });
+});
+
/* ---- serving a name over the clearnet ---- */
/**
@@ -1573,9 +1710,26 @@ moshpitRouter.get("/api/moshpit/resolve", async (req, res) => {
? await listRecords(resolved.tld, resolved.label)
: null;
+ // `?twin=1`, gated for the same reason records are: a second table, and the
+ // bridge and the DoH server only ever want an address.
+ const twin = req.query.twin && resolution.name_registered && resolved
+ ? await twinForName(resolution.resolved)
+ : null;
+
res.json({
...resolution,
...(records ? { records: records.map((r) => ({ type: r.type, value: r.value, ttl: r.ttl, ...(r.priority === null ? {} : { priority: r.priority }) })) } : {}),
+ // A place to reach this name from the legacy internet, and nothing more.
+ //
+ // Deliberately NOT an input to `prefer`, and this is the line to hold. A
+ // twin is a domain that already answers in the legacy root, so folding it
+ // into precedence would have the pit start outranking DNS for names it was
+ // handed by DNS in the first place — which is indistinguishable from the
+ // hijack the clearnet-wins default exists to prevent. The client decides
+ // what to do with a twin; `prefer` still answers only "may the pit outrank
+ // real DNS for this name", and the answer does not change because somebody
+ // bought a domain.
+ ...(twin ? { twin: twin.domain, twin_expires_at: twin.expires_at } : {}),
mode,
prefer: resolutionPreference({ registered: resolution.registered, mode }),
});
diff --git a/apps/pwa/test/moshpit-twin-name.test.mjs b/apps/pwa/test/moshpit-twin-name.test.mjs
new file mode 100644
index 00000000..e5e51b59
--- /dev/null
+++ b/apps/pwa/test/moshpit-twin-name.test.mjs
@@ -0,0 +1,185 @@
+// The twin rules, with no database and no network.
+//
+// These are the parts a client reimplements -- the browser extension computing
+// a twin, a resolver reading a proof record -- so they are the parts that have
+// to be exactly specified rather than merely working here.
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ TWIN_PRICE_USD,
+ TWIN_TLDS,
+ TWIN_UNLINK_LEAD_MS,
+ clearnetTwin,
+ clearnetTwins,
+ moshpitNameForTwin,
+ normalizeDomain,
+ normalizeTwinToken,
+ parseTwinProof,
+ twinIsLive,
+ twinProof,
+ twinProofMatches,
+ twinProofName,
+} from "../src/lib/moshpit-twin.mjs";
+
+const TOKEN = "a".repeat(32);
+const OTHER = "b".repeat(32);
+
+test("the clearnet twin", async (t) => {
+ await t.test("collapses the dot into a hyphen", () => {
+ assert.equal(clearnetTwin("financial.advisors"), "financial-advisors.com");
+ assert.equal(clearnetTwin("financial.advisors", "net"), "financial-advisors.net");
+ assert.equal(clearnetTwin("blue.eggs", "org"), "blue-eggs.org");
+ // People type the dot, and the ending is normalised the same way everywhere.
+ assert.equal(clearnetTwin(".blue.eggs", ".NET"), "blue-eggs.net");
+ });
+
+ await t.test("refuses a name that has no representable twin", () => {
+ assert.equal(clearnetTwin("a.b.c"), null, "not a moshpit name");
+ assert.equal(clearnetTwin(""), null);
+ // 63 is the DNS label ceiling and the stem is both halves plus a hyphen, so
+ // a name well inside Moshpit's own limits can have no twin at all.
+ const long = `${"a".repeat(40)}.${"b".repeat(40)}`;
+ assert.equal(clearnetTwin(long), null, "stem would be 81 characters");
+ const justFits = `${"a".repeat(31)}.${"b".repeat(31)}`;
+ assert.equal(clearnetTwin(justFits), `${"a".repeat(31)}-${"b".repeat(31)}.com`);
+ });
+
+ await t.test("offers every ending, and none when there is no room", () => {
+ assert.deepEqual(clearnetTwins("blue.eggs"), ["blue-eggs.com", "blue-eggs.net", "blue-eggs.org"]);
+ assert.deepEqual(clearnetTwins("blue.eggs", ["net"]), ["blue-eggs.net"]);
+ assert.deepEqual(clearnetTwins(`${"a".repeat(40)}.${"b".repeat(40)}`), []);
+ // The endings offered are the ones reserved as pit endings, so a twin can
+ // never collide with a namespace somebody holds.
+ assert.deepEqual(TWIN_TLDS, ["com", "net", "org"]);
+ });
+
+ await t.test("round-trips, because a pit label may not contain a hyphen", () => {
+ for (const name of ["blue.eggs", "financial.advisors", "420.blue", "x.yz"]) {
+ const twin = clearnetTwin(name);
+ assert.equal(moshpitNameForTwin(twin), name, name);
+ }
+ });
+
+ await t.test("reads the stem out of a hostname, not the hostname", () => {
+ assert.equal(moshpitNameForTwin("www.blue-eggs.net"), "blue.eggs");
+ assert.equal(moshpitNameForTwin("https://blue-eggs.net/some/path?q=1"), "blue.eggs");
+ assert.equal(moshpitNameForTwin("BLUE-EGGS.NET."), "blue.eggs");
+ });
+
+ await t.test("a domain that merely contains a dash is not a twin", () => {
+ assert.equal(moshpitNameForTwin("example.com"), null, "no dash");
+ assert.equal(moshpitNameForTwin("a-b-c.com"), null, "two dashes is not one name");
+ assert.equal(moshpitNameForTwin("-bad.com"), null);
+ assert.equal(moshpitNameForTwin("blue-.com"), null);
+ // Both halves numeric is an IPv4 literal in disguise, which parseMoshpitName
+ // refuses -- so it is not a twin either.
+ assert.equal(moshpitNameForTwin("1-420.com"), null);
+ });
+});
+
+test("clearnet domains", async (t) => {
+ await t.test("normalises what people actually paste", () => {
+ assert.equal(normalizeDomain(" HTTPS://Example.COM/path#x "), "example.com");
+ assert.equal(normalizeDomain("example.com."), "example.com");
+ assert.equal(normalizeDomain("sub.example.co.uk"), "sub.example.co.uk");
+ });
+
+ await t.test("refuses what is not a domain", () => {
+ assert.equal(normalizeDomain("localhost"), null, "one label");
+ assert.equal(normalizeDomain(""), null);
+ assert.equal(normalizeDomain("example."), null);
+ assert.equal(normalizeDomain("exa mple.com"), null);
+ assert.equal(normalizeDomain("under_score.com"), null);
+ // An address is a well-formed sequence of labels with no registrar to
+ // expire at, so it is refused rather than recorded as a domain.
+ assert.equal(normalizeDomain("1.2.3.4"), null);
+ assert.equal(normalizeDomain("example.c"), null, "one-character suffix");
+ assert.equal(normalizeDomain(`${"a".repeat(64)}.com`), null, "label over 63");
+ });
+});
+
+test("the proof record", async (t) => {
+ await t.test("is one string, at one place", () => {
+ assert.equal(twinProofName("blue-eggs.net"), "_moshpit.blue-eggs.net");
+ assert.equal(twinProof({ name: "blue.eggs", token: TOKEN }),
+ `v=moshpit1 name=blue.eggs token=${TOKEN}`);
+ });
+
+ await t.test("refuses to render against a bad name or token", () => {
+ assert.equal(twinProof({ name: "a.b.c", token: TOKEN }), null);
+ assert.equal(twinProof({ name: "blue.eggs", token: "hunter2" }), null);
+ assert.equal(twinProof({ name: "blue.eggs", token: TOKEN.toUpperCase() }),
+ `v=moshpit1 name=blue.eggs token=${TOKEN}`, "hex is case-insensitive");
+ assert.equal(normalizeTwinToken("z".repeat(32)), null, "not hex");
+ assert.equal(normalizeTwinToken("a".repeat(31)), null, "too short");
+ });
+
+ await t.test("parses by key, because registrar forms reorder fields", () => {
+ assert.deepEqual(parseTwinProof(`token=${TOKEN} v=moshpit1 name=blue.eggs`),
+ { name: "blue.eggs", token: TOKEN });
+ // Unknown fields are ignored so the format can grow one without every
+ // already-published record turning invalid that day.
+ assert.deepEqual(parseTwinProof(`v=moshpit1 name=blue.eggs token=${TOKEN} future=yes`),
+ { name: "blue.eggs", token: TOKEN });
+ });
+
+ await t.test("is not fooled by something that merely looks like one", () => {
+ assert.equal(parseTwinProof(""), null);
+ assert.equal(parseTwinProof("v=spf1 include:example.com ~all"), null);
+ assert.equal(parseTwinProof(`v=moshpit2 name=blue.eggs token=${TOKEN}`), null, "wrong version");
+ assert.equal(parseTwinProof("v=moshpit1 name=blue.eggs"), null, "no token");
+ assert.equal(parseTwinProof(`v=moshpit1 token=${TOKEN}`), null, "no name");
+ assert.equal(parseTwinProof(`v=moshpit1 name=a.b.c token=${TOKEN}`), null, "not a pit name");
+ });
+
+ await t.test("matches one record among the several a real domain carries", () => {
+ const want = { name: "blue.eggs", token: TOKEN };
+ const spf = "v=spf1 include:_spf.google.com ~all";
+ const stale = `v=moshpit1 name=blue.eggs token=${OTHER}`;
+ assert.equal(twinProofMatches([spf, stale, twinProof(want)], want), true);
+ assert.equal(twinProofMatches([spf, stale], want), false, "only a superseded token");
+ assert.equal(twinProofMatches([], want), false);
+ assert.equal(twinProofMatches(null, want), false);
+ });
+
+ await t.test("reassembles a record DNS split into chunks", () => {
+ const want = { name: "blue.eggs", token: TOKEN };
+ const value = twinProof(want);
+ const chunked = [value.slice(0, 20), value.slice(20)];
+ assert.equal(twinProofMatches([chunked], want), true);
+ });
+
+ await t.test("will not accept a proof issued for another name", () => {
+ const theirs = twinProof({ name: "red.eggs", token: TOKEN });
+ assert.equal(twinProofMatches([theirs], { name: "blue.eggs", token: TOKEN }), false);
+ });
+});
+
+test("a twin lapses on our clock, ahead of the registrar's", async (t) => {
+ const now = 1_700_000_000_000;
+ const verified = (expires_at) => ({ status: "verified", expires_at });
+
+ await t.test("only a verified twin is ever live", () => {
+ assert.equal(twinIsLive(verified(null), now), true);
+ assert.equal(twinIsLive({ status: "pending", expires_at: null }, now), false);
+ assert.equal(twinIsLive(null, now), false);
+ assert.equal(twinIsLive(undefined, now), false);
+ });
+
+ await t.test("goes dark a lead time before it expires", () => {
+ const lead = TWIN_UNLINK_LEAD_MS;
+ assert.equal(twinIsLive(verified(now + lead + 1), now), true, "just outside the window");
+ assert.equal(twinIsLive(verified(now + lead), now), false, "at the boundary");
+ assert.equal(twinIsLive(verified(now + lead - 1), now), false, "inside the window");
+ assert.equal(twinIsLive(verified(now - 1), now), false, "already expired");
+ // The point of the lead: the domain is still registered, and we have
+ // already stopped handing it out.
+ assert.equal(twinIsLive(verified(now + 1), now), false, "expires tomorrow, dropped today");
+ });
+});
+
+test("a twin is priced as one number", () => {
+ assert.equal(TWIN_PRICE_USD, 12);
+ assert.equal(Number.isInteger(TWIN_PRICE_USD), true, "a price a person can hold in their head");
+});
diff --git a/apps/pwa/test/moshpit-twin-route.test.mjs b/apps/pwa/test/moshpit-twin-route.test.mjs
new file mode 100644
index 00000000..68eef45c
--- /dev/null
+++ b/apps/pwa/test/moshpit-twin-route.test.mjs
@@ -0,0 +1,187 @@
+// The twin endpoints, over a real socket.
+//
+// The model has its own tests; these are about the contract a client sees, and
+// one of them cannot be checked anywhere else: that publishing a twin does not
+// move `prefer`. That is the whole safety property of the feature -- a twin is
+// a domain which already answers in the legacy root, so if it fed into
+// precedence the pit would start outranking DNS for names DNS gave it -- and it
+// lives in the shape of the resolve response rather than in any one function.
+import assert from "node:assert/strict";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { createRequire } from "node:module";
+import test from "node:test";
+
+const require = createRequire(import.meta.url);
+let deps = null;
+try {
+ deps = { express: require("express"), cookieParser: require("cookie-parser") };
+} catch {
+ deps = null;
+}
+
+const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-twin-route-test-"));
+process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
+process.env.SESSION_SECRET = "test-secret";
+
+async function boot() {
+ const { migrate } = await import("../src/migrate.mjs");
+ await migrate();
+ const { run } = await import("../src/db.mjs");
+ const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs");
+ const { moshpitRouter } = await import("../src/routes/moshpit.mjs");
+ const { createApiKey } = await import("../src/lib/apikey.mjs");
+ const moshpit = await import("../src/moshpit.mjs");
+
+ await run(`INSERT OR REPLACE INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','one',1)`);
+ await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,created_at) VALUES ('eggs','u1','a@b.c',1)`);
+ await run(`INSERT INTO moshpit_names (tld,label,user_id,created_at) VALUES ('eggs','blue','u1',1)`);
+ await run(`INSERT INTO moshpit_names (tld,label,user_id,created_at) VALUES ('eggs','bare','u1',1)`);
+ const key = (await createApiKey("u1", "cli one")).plaintext;
+
+ const app = deps.express();
+ app.use(deps.express.json());
+ app.use(deps.express.urlencoded({ extended: false }));
+ app.use(deps.cookieParser());
+ app.use(sessionMiddleware);
+ app.use(csrfGuard);
+ app.use(moshpitRouter);
+
+ const server = await new Promise((resolve) => {
+ const s = app.listen(0, "127.0.0.1", () => resolve(s));
+ });
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const call = async (method, url, { body, token = key } = {}) => {
+ const res = await fetch(`${base}${url}`, {
+ method,
+ headers: {
+ ...(body ? { "content-type": "application/json" } : {}),
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
+ },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ });
+ let json = null;
+ try { json = await res.json(); } catch { json = null; }
+ return { status: res.status, body: json };
+ };
+
+ return { call, server, moshpit };
+}
+
+test("the twin endpoints", { skip: deps ? false : "express not installed" }, async (t) => {
+ const { call, server, moshpit } = await boot();
+ t.after(() => server.close());
+
+ await t.test("an unclaimed name says so, and offers what is available", async () => {
+ const r = await call("GET", "/api/moshpit/twin?name=bare.eggs");
+ assert.equal(r.status, 200);
+ assert.equal(r.body.twin, null);
+ assert.deepEqual(r.body.available, ["bare-eggs.com", "bare-eggs.net", "bare-eggs.org"]);
+ assert.equal(r.body.price_usd, moshpit.TWIN_PRICE_USD);
+ });
+
+ await t.test("a name this registry does not answer for is a 404", async () => {
+ const r = await call("GET", "/api/moshpit/twin?name=example.com");
+ assert.equal(r.status, 404);
+ assert.equal(r.body.twin, null);
+ });
+
+ await t.test("claiming needs the name's holder", async () => {
+ const anon = await call("POST", "/api/moshpit/tlds/eggs/twin", { body: { label: "blue", domain: "blueeggs.com" }, token: null });
+ assert.equal(anon.status, 401);
+ });
+
+ await t.test("a claim comes back with the record to publish", async () => {
+ const r = await call("POST", "/api/moshpit/tlds/eggs/twin", { body: { label: "blue", domain: "blueeggs.com" } });
+ assert.equal(r.status, 201, JSON.stringify(r.body));
+ assert.equal(r.body.status, "pending");
+ assert.equal(r.body.proof.host, "_moshpit.blueeggs.com");
+ assert.equal(r.body.proof.type, "TXT");
+ assert.match(r.body.proof.value, /^v=moshpit1 name=blue\.eggs token=[0-9a-f]{32}$/);
+ // The public view shows the claim but never the challenge.
+ const pub = await call("GET", "/api/moshpit/tlds/eggs/twin?label=blue");
+ assert.equal(pub.body.status, "pending");
+ assert.equal(pub.body.token, undefined);
+ // Pending is not served.
+ assert.equal((await call("GET", "/api/moshpit/twin?name=blue.eggs")).body.twin, null);
+ });
+
+ await t.test("a domain reading as another name's twin is refused", async () => {
+ const r = await call("POST", "/api/moshpit/tlds/eggs/twin", { body: { label: "blue", domain: "red-eggs.net" } });
+ assert.equal(r.status, 400);
+ assert.match(r.body.error, /reads as the twin of red\.eggs/);
+ });
+
+ await t.test("an unpublished record is a 202, not a refusal", async () => {
+ // `.invalid` never resolves (RFC 2606), so this exercises the real resolver
+ // without depending on anything outside the machine. Whether the lookup
+ // NXDOMAINs or the resolver is unreachable, both are "wait and retry" and
+ // both must read as the same accepted-and-unfinished answer.
+ await call("POST", "/api/moshpit/tlds/eggs/twin", { body: { label: "blue", domain: "twintest.invalid", replace: true } });
+ const r = await call("POST", "/api/moshpit/tlds/eggs/twin/verify", { body: { label: "blue" } });
+ assert.equal(r.status, 202, JSON.stringify(r.body));
+ assert.equal(r.body.verified, false);
+ assert.ok(r.body.proof?.value, "told again what to publish");
+ });
+
+ await t.test("a verified twin is served, and replacing it is a 409", async () => {
+ // Verified through the model with a stubbed resolver: the HTTP layer has no
+ // seam for DNS, and inventing one only for a test would be a worse design
+ // than the one it was checking.
+ const claim = await moshpit.claimTwin({ tld: "eggs", label: "blue", userId: "u1", domain: "blueeggs.com", replace: true });
+ const proof = moshpit.twinProof({ name: "blue.eggs", token: claim.token });
+ const ok = await moshpit.verifyTwin({
+ tld: "eggs", label: "blue", userId: "u1",
+ resolveTxt: async () => [[proof]],
+ });
+ assert.equal(ok.ok, true, ok.error);
+
+ const r = await call("GET", "/api/moshpit/twin?name=blue.eggs");
+ assert.equal(r.body.twin, "blueeggs.com");
+ assert.equal(r.body.proof.host, "_moshpit.blueeggs.com");
+ assert.ok(r.body.verified_at);
+ // No suggestions once it has one.
+ assert.equal(r.body.available, undefined);
+
+ const clash = await call("POST", "/api/moshpit/tlds/eggs/twin", { body: { label: "blue", domain: "somethingelse.com" } });
+ assert.equal(clash.status, 409);
+ assert.equal(clash.body.replaceable, true);
+ assert.equal(clash.body.current, "blueeggs.com");
+ });
+
+ await t.test("resolve carries the twin only when asked", async () => {
+ const bare = await call("GET", "/api/moshpit/resolve?name=blue.eggs");
+ assert.equal(bare.body.twin, undefined, "every DNS query lands here; it does not pay for a second table");
+
+ const asked = await call("GET", "/api/moshpit/resolve?name=blue.eggs&twin=1");
+ assert.equal(asked.body.twin, "blueeggs.com");
+ });
+
+ await t.test("a twin does not move `prefer`", async () => {
+ // The line to hold. A twin already answers in the legacy root, so letting it
+ // touch precedence would have the pit outrank DNS for names DNS handed it.
+ const backfilled = await call("GET", "/api/moshpit/resolve?name=blue.eggs&twin=1");
+ const plain = await call("GET", "/api/moshpit/resolve?name=bare.eggs&twin=1");
+ assert.equal(backfilled.body.twin, "blueeggs.com");
+ assert.equal(plain.body.twin, undefined, "no twin on this one");
+ assert.equal(backfilled.body.prefer, "fallback");
+ assert.equal(plain.body.prefer, backfilled.body.prefer,
+ "the backfilled name and the bare one prefer the same thing");
+
+ // ...in either mode, and the opt-in is still the only thing that changes it.
+ const opted = await call("GET", "/api/moshpit/resolve?name=blue.eggs&twin=1&mode=moshpit");
+ assert.equal(opted.body.prefer, "moshpit");
+ const optedPlain = await call("GET", "/api/moshpit/resolve?name=bare.eggs&mode=moshpit");
+ assert.equal(optedPlain.body.prefer, "moshpit");
+ });
+
+ await t.test("removing a twin stops it being served", async () => {
+ const r = await call("DELETE", "/api/moshpit/tlds/eggs/twin", { body: { label: "blue" } });
+ assert.equal(r.status, 200, JSON.stringify(r.body));
+ assert.equal((await call("GET", "/api/moshpit/twin?name=blue.eggs")).body.twin, null);
+ // And saying so twice is a 404, not a second success.
+ assert.equal((await call("DELETE", "/api/moshpit/tlds/eggs/twin", { body: { label: "blue" } })).status, 404);
+ });
+});
diff --git a/apps/pwa/test/moshpit-twins.test.mjs b/apps/pwa/test/moshpit-twins.test.mjs
new file mode 100644
index 00000000..2f2acad3
--- /dev/null
+++ b/apps/pwa/test/moshpit-twins.test.mjs
@@ -0,0 +1,327 @@
+// Backfilling a name with a clearnet domain, against a real (throwaway) libSQL
+// database.
+//
+// The behaviour worth checking is in the ownership rules, the uniqueness
+// constraints and the lapse arithmetic, and none of that survives being mocked.
+// DNS is the one thing stubbed: verification takes its resolver as an argument
+// precisely so this can cover the only part of it worth covering.
+//
+// Skips cleanly when the PWA dependencies are not installed.
+import assert from "node:assert/strict";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { createRequire } from "node:module";
+import { randomBytes } from "node:crypto";
+import test from "node:test";
+
+const require = createRequire(import.meta.url);
+let installed = true;
+try { require("@libsql/client"); } catch { installed = false; }
+
+const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-twins-test-"));
+process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
+process.env.SESSION_SECRET = "test-secret";
+
+const ALICE = "user-alice";
+const BOB = "user-bob";
+const DAY = 24 * 60 * 60 * 1000;
+
+async function boot() {
+ const { migrate } = await import("../src/migrate.mjs");
+ await migrate();
+ const { run } = await import("../src/db.mjs");
+ for (const [id, email] of [[ALICE, "alice@example.com"], [BOB, "bob@example.com"]]) {
+ await run(`INSERT OR IGNORE INTO users (id, email, created_at) VALUES (?,?,?)`, [id, email, Date.now()]);
+ }
+ return { moshpit: await import("../src/moshpit.mjs"), run };
+}
+
+/** A resolver that answers with exactly these TXT records, in DNS's chunked shape. */
+const dnsWith = (...values) => async () => values.map((v) => [v]);
+/** A resolver that answers the way DNS answers when there is nothing there. */
+const dnsEmpty = async () => { const e = new Error("no data"); e.code = "ENODATA"; throw e; };
+/** A resolver that is simply broken, which is a different thing. */
+const dnsBroken = async () => { const e = new Error("servfail"); e.code = "ESERVFAIL"; throw e; };
+
+test("moshpit twins", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => {
+ const { moshpit: m } = await boot();
+ let n = 0;
+
+ /** A fresh ending with one name under it, held by `userId`. */
+ const freshName = async (userId = ALICE, label = "blue") => {
+ const tld = `t${n++}${randomBytes(3).toString("hex")}`;
+ const claimed = await m.registerTld({ tld, userId, ownerEmail: null });
+ assert.ok(claimed.ok, `could not claim .${tld}: ${claimed.error}`);
+ const named = await m.registerName({ tld, label, userId });
+ assert.ok(named.ok, `could not mint ${label}.${tld}: ${named.error}`);
+ return { tld, label, name: `${label}.${tld}` };
+ };
+
+ /** Claim and prove a twin in one step, for tests about what happens afterwards. */
+ const backfilled = async ({ tld, label, name }, domain, opts = {}) => {
+ const claim = await m.claimTwin({ tld, label, userId: ALICE, domain, ...opts });
+ assert.ok(claim.ok, `claim failed: ${claim.error}`);
+ const verified = await m.verifyTwin({
+ tld, label, userId: ALICE,
+ resolveTxt: dnsWith(m.twinProof({ name, token: claim.token })),
+ ...(opts.now ? { now: opts.now } : {}),
+ });
+ assert.ok(verified.ok, `verify failed: ${verified.error}`);
+ return { claim, verified };
+ };
+
+ await t.test("only the name's holder may back it with a domain", async () => {
+ const { tld, label } = await freshName(ALICE);
+ const theirs = await m.claimTwin({ tld, label, userId: BOB, domain: "example.com" });
+ assert.equal(theirs.ok, false);
+ assert.match(theirs.error, /do not own/);
+ // ...and an unminted name has nobody to authorise it at all.
+ const orphan = await m.claimTwin({ tld, label: "nobody", userId: ALICE, domain: "example.com" });
+ assert.equal(orphan.ok, false);
+ assert.match(orphan.error, /not registered/);
+ });
+
+ await t.test("a claim issues a challenge and the record to publish", async () => {
+ const name = await freshName();
+ const claim = await m.claimTwin({ ...name, userId: ALICE, domain: "HTTPS://Example.COM/path" });
+ assert.equal(claim.ok, true);
+ assert.equal(claim.domain, "example.com", "normalised on the way in");
+ assert.match(claim.token, /^[0-9a-f]{32}$/);
+ // One string, at one place. Handing back the pieces is how it gets typed in
+ // wrong.
+ assert.deepEqual(claim.proof, {
+ host: "_moshpit.example.com",
+ type: "TXT",
+ value: `v=moshpit1 name=${name.name} token=${claim.token}`,
+ });
+ // Claimed is not served.
+ assert.equal(await m.twinForName(name.name), null);
+ });
+
+ await t.test("a domain that reads as another name's twin is refused", async () => {
+ const name = await freshName(ALICE, "blue");
+ const wrong = await m.claimTwin({ ...name, userId: ALICE, domain: "red-eggs.net" });
+ assert.equal(wrong.ok, false);
+ assert.match(wrong.error, /reads as the twin of red\.eggs/);
+
+ // Its own twin is fine, and so is a domain that is not twin-shaped at all --
+ // somebody who already owns `financialadvisors.com` may use it.
+ const own = await m.claimTwin({ ...name, userId: ALICE, domain: `blue-${name.tld}.net` });
+ assert.equal(own.ok, true, own.error);
+ const unshaped = await m.claimTwin({ ...name, userId: ALICE, domain: "financialadvisors.com" });
+ assert.equal(unshaped.ok, true, unshaped.error);
+ });
+
+ await t.test("verification distinguishes no record from no answer", async () => {
+ const name = await freshName();
+ const claim = await m.claimTwin({ ...name, userId: ALICE, domain: "proofme.com" });
+
+ const missing = await m.verifyTwin({ ...name, userId: ALICE, resolveTxt: dnsEmpty });
+ assert.equal(missing.ok, false);
+ assert.match(missing.error, /no matching proof/);
+ assert.equal(missing.retryable, true);
+ assert.equal(missing.proof.value, `v=moshpit1 name=${name.name} token=${claim.token}`,
+ "told again what to publish");
+
+ // A resolver that is broken calls for waiting, not for re-typing a record
+ // that was always correct -- so it says something different.
+ const broken = await m.verifyTwin({ ...name, userId: ALICE, resolveTxt: dnsBroken });
+ assert.equal(broken.ok, false);
+ assert.match(broken.error, /could not read TXT/);
+ assert.equal(broken.retryable, true);
+
+ // A proof for somebody else's name is not this name's proof.
+ const theirs = await m.verifyTwin({
+ ...name, userId: ALICE,
+ resolveTxt: dnsWith(m.twinProof({ name: "red.eggs", token: claim.token })),
+ });
+ assert.equal(theirs.ok, false);
+ });
+
+ await t.test("a published proof starts the twin serving", async () => {
+ const name = await freshName();
+ const claim = await m.claimTwin({ ...name, userId: ALICE, domain: "serveme.com" });
+ const ok = await m.verifyTwin({
+ ...name, userId: ALICE,
+ // Alongside the records a domain in real use actually carries.
+ resolveTxt: dnsWith("v=spf1 include:example.com ~all", m.twinProof({ name: name.name, token: claim.token })),
+ });
+ assert.equal(ok.ok, true, ok.error);
+ assert.equal(ok.domain, "serveme.com");
+
+ const live = await m.twinForName(name.name);
+ assert.equal(live.domain, "serveme.com");
+ assert.equal(live.status, "verified");
+ });
+
+ await t.test("one domain backfills one name", async () => {
+ const first = await freshName();
+ await backfilled(first, "contested.com");
+ const second = await freshName();
+ const clash = await m.claimTwin({ ...second, userId: ALICE, domain: "contested.com" });
+ assert.equal(clash.ok, false);
+ assert.equal(clash.taken, true);
+ assert.match(clash.error, new RegExp(`already backfills ${first.name.replace(".", "\\.")}`));
+ });
+
+ await t.test("a pending claim does not reserve a domain against the person who holds it", async () => {
+ // Two people may both be trying; only one can finish. An abandoned pending
+ // claim must not be what stops the real holder proving it.
+ const squatter = await freshName();
+ await m.claimTwin({ ...squatter, userId: ALICE, domain: "unproven.com" });
+
+ const real = await freshName();
+ const claim = await m.claimTwin({ ...real, userId: ALICE, domain: "unproven.com" });
+ assert.equal(claim.ok, true, claim.error);
+ const ok = await m.verifyTwin({
+ ...real, userId: ALICE,
+ resolveTxt: dnsWith(m.twinProof({ name: real.name, token: claim.token })),
+ });
+ assert.equal(ok.ok, true, ok.error);
+ });
+
+ await t.test("replacing a live twin is a deliberate act", async () => {
+ const name = await freshName();
+ await backfilled(name, "firstchoice.com");
+
+ const accidental = await m.claimTwin({ ...name, userId: ALICE, domain: "secondchoice.com" });
+ assert.equal(accidental.ok, false);
+ assert.equal(accidental.replaceable, true);
+ assert.equal(accidental.current, "firstchoice.com");
+ // Refused, and the old one is still serving.
+ assert.equal((await m.twinForName(name.name)).domain, "firstchoice.com");
+
+ const deliberate = await m.claimTwin({ ...name, userId: ALICE, domain: "secondchoice.com", replace: true });
+ assert.equal(deliberate.ok, true, deliberate.error);
+ // Which takes the name off the clearnet until the new domain proves itself.
+ assert.equal(await m.twinForName(name.name), null);
+ });
+
+ await t.test("re-claiming issues a fresh token", async () => {
+ const name = await freshName();
+ const first = await m.claimTwin({ ...name, userId: ALICE, domain: "rotate.com" });
+ const second = await m.claimTwin({ ...name, userId: ALICE, domain: "rotate.com" });
+ assert.notEqual(first.token, second.token);
+ // A proof published for the abandoned claim must not satisfy the new one.
+ const stale = await m.verifyTwin({
+ ...name, userId: ALICE,
+ resolveTxt: dnsWith(m.twinProof({ name: name.name, token: first.token })),
+ });
+ assert.equal(stale.ok, false);
+ });
+
+ await t.test("the twin follows the ending's alias", async () => {
+ // `.agentic` points at `.agent`, so what serves foo.agent is what a visitor
+ // reaches -- and its twin is the one that leads anywhere.
+ const target = await freshName(ALICE, "foo");
+ await backfilled(target, "aliasedtarget.com");
+
+ const from = `a${n++}${randomBytes(3).toString("hex")}`;
+ await m.registerTld({ tld: from, userId: ALICE, ownerEmail: null });
+ const aliased = await m.setAlias({ from, to: target.tld, userId: ALICE });
+ assert.ok(aliased.ok, aliased.error);
+
+ const twin = await m.twinForName(`foo.${from}`);
+ assert.equal(twin?.domain, "aliasedtarget.com");
+ });
+
+ await t.test("an expiry has to be a date a registration could actually have", async () => {
+ const name = await freshName();
+ await backfilled(name, "expiring.com");
+ const now = Date.now();
+
+ for (const [bad, why] of [[now - DAY, /in the past/], ["not a date", /must be a timestamp/], [now + 20 * 365 * DAY, /further out/]]) {
+ const r = await m.setTwinExpiry({ ...name, userId: ALICE, expiresAt: bad, now });
+ assert.equal(r.ok, false, String(bad));
+ assert.match(r.error, why);
+ }
+
+ const ok = await m.setTwinExpiry({ ...name, userId: ALICE, expiresAt: now + 365 * DAY, now });
+ assert.equal(ok.ok, true, ok.error);
+ // Null clears it: a domain its holder renews elsewhere has no date we can learn.
+ const cleared = await m.setTwinExpiry({ ...name, userId: ALICE, expiresAt: null, now });
+ assert.equal(cleared.ok, true);
+ assert.equal(cleared.expires_at, null);
+ });
+
+ await t.test("a twin goes dark before its domain drops", async () => {
+ const name = await freshName();
+ await backfilled(name, "lapsing.com");
+ const now = Date.now();
+ const lead = m.TWIN_UNLINK_LEAD_MS;
+
+ await m.setTwinExpiry({ ...name, userId: ALICE, expiresAt: now + lead + 2 * DAY, now });
+ assert.ok(await m.twinForName(name.name, now), "still outside the window");
+
+ // The domain is still registered for another six days, and the pit has
+ // already stopped handing it out. That is the whole point.
+ await m.setTwinExpiry({ ...name, userId: ALICE, expiresAt: now + lead - 2 * DAY, now });
+ assert.equal(await m.twinForName(name.name, now), null);
+ });
+
+ await t.test("the renewal nag reads the date the name goes dark", async () => {
+ const name = await freshName();
+ await backfilled(name, "nagme.com");
+ const now = Date.now();
+ const lead = m.TWIN_UNLINK_LEAD_MS;
+
+ await m.setTwinExpiry({ ...name, userId: ALICE, expiresAt: now + lead + 3 * DAY, now });
+ const soon = await m.expiringTwins({ within: 5 * DAY, now });
+ assert.ok(soon.some((r) => r.domain === "nagme.com"), "drops in 3 days, inside a 5 day window");
+
+ const notYet = await m.expiringTwins({ within: 1 * DAY, now });
+ assert.equal(notYet.some((r) => r.domain === "nagme.com"), false);
+ });
+
+ await t.test("releasing the name takes the twin with it", async () => {
+ const name = await freshName();
+ await backfilled(name, "handedback.com");
+
+ const released = await m.releaseName({ ...name, userId: ALICE });
+ assert.equal(released.ok, true, released.error);
+ assert.equal(await m.getTwin(name.tld, name.label), null,
+ "an inherited twin would point the next holder's visitors at a stranger's site");
+
+ // And the domain is free for whoever takes the name next. Re-minted by the
+ // same account here because only an ending's owner may mint under it -- a
+ // real change of hands goes through settleNamePurchase -- but what is being
+ // checked is the name, not the person: the row it inherits must be empty.
+ const retaken = await m.registerName({ tld: name.tld, label: name.label, userId: ALICE });
+ assert.ok(retaken.ok, retaken.error);
+ assert.equal(await m.twinForName(name.name), null, "re-minted with no twin");
+ const reclaimed = await m.claimTwin({ ...name, userId: ALICE, domain: "handedback.com" });
+ assert.equal(reclaimed.ok, true, reclaimed.error);
+ });
+
+ await t.test("removing a twin stops it being served", async () => {
+ const name = await freshName();
+ await backfilled(name, "removeme.com");
+ assert.ok(await m.twinForName(name.name));
+
+ const gone = await m.removeTwin({ ...name, userId: ALICE });
+ assert.equal(gone.ok, true);
+ assert.equal(await m.twinForName(name.name), null);
+ // Removing one that is not there says so rather than reporting success.
+ const again = await m.removeTwin({ ...name, userId: ALICE });
+ assert.equal(again.ok, false);
+ });
+
+ await t.test("suggests the twins that are actually available", async () => {
+ const taken = await freshName(ALICE, "picked");
+ const all = m.clearnetTwins(taken.name);
+ assert.equal(all.length, 3);
+
+ await backfilled(taken, all[0]);
+ const left = await m.availableTwins(taken.name);
+ assert.deepEqual(left, all.slice(1), "the .com is spoken for");
+ });
+
+ await t.test("a twin is listed to the person who bought it", async () => {
+ const name = await freshName();
+ await backfilled(name, "mine-to-see.com");
+ const mine = await m.listTwinsForUser(ALICE);
+ assert.ok(mine.some((r) => r.domain === "mine-to-see.com"));
+ assert.equal((await m.listTwinsForUser(BOB)).some((r) => r.domain === "mine-to-see.com"), false);
+ });
+});
From 68b6de3405f9f31ee45a3ae99478c7a4b7818523 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Fri, 28 Aug 2026 20:41:08 +0000
Subject: [PATCH 2/2] Moshpit is lifetime pricing: $5 an ending, $2 a name,
paid once
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Migration 010 gave endings a one-year term with renewals, per PRD 0005
§5. That is withdrawn. The prices are unchanged; what changed is that
they are not charged again.
The reason is what the namespace is for. A name that lapses is a name
somebody else can catch, and the whole pitch is that you can finally
hold the clean name instead of the hyphenated one you settled for. An
annual invoice with a drop date attached is the thing people are
trying to get away from, and selling it back to them undoes the pitch.
Names were already sold this way and only the comments said otherwise:
`moshpit_names` has never had an expiry column and nothing has ever
renewed a name. The PRD called it a yearly fee, the schema sold it
outright, and the doc comment described the PRD. It now describes the
code.
Endings really did have the machinery, but it never shipped -- nothing
in the app could open an ending checkout, so `quoteTld` and
`quoteRenewal` were unreachable and only the webhook settler was wired
up. No ending was ever charged a renewal and no row in the wild has an
expiry this takes away, which is why migration 016 is a plain drop
rather than a grandfathering policy: §21.8 asks for one before putting
endings INTO a lifecycle, and taking them back out of one nobody was
in needs no such thing.
- `expires_at` and `term_started_at` are dropped from `moshpit_tlds`,
columns and index. Gone rather than left NULL, because a nullable
expiry is an annual term waiting to be switched back on.
- `moshpit_tld_purchases` KEEPS `kind` and `years`. That is a financial
record of what was sold at the time, and a ledger is not something to
rewrite once the product changes.
- A 'renew' row can no longer be created, but one may still settle
late. It is honoured rather than refused -- the buyer keeps the
ending, which now keeps itself. Refusing it would take money for
nothing.
- `isExpired()` stays and always answers no, so callers asking a fair
question get a permanent answer instead of an import that fails.
The pit page leads with it, since it is the one claim here worth making
above the fold and the whole reason to prefer this over a registrar.
Note for whoever writes the next migration: nothing may follow the last
statement in a .sql file but whitespace. migrate.mjs splits on
semicolons and hands each piece to libSQL, and a trailing comment-only
piece comes back as `SQLITE_OK: not an error`, which is as unhelpful as
it sounds.
591 pass in apps/pwa.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01GWhPX5Uzd29whRg5WPAYM7
---
apps/pwa/src/lib/moshpit-name.mjs | 23 +++-
.../src/migrations/016_moshpit_lifetime.sql | 37 ++++++
apps/pwa/src/moshpit.mjs | 111 +++++++++---------
apps/pwa/src/routes/moshpit.mjs | 17 ++-
apps/pwa/test/moshpit-terms.test.mjs | 111 ++++++++++--------
5 files changed, 189 insertions(+), 110 deletions(-)
create mode 100644 apps/pwa/src/migrations/016_moshpit_lifetime.sql
diff --git a/apps/pwa/src/lib/moshpit-name.mjs b/apps/pwa/src/lib/moshpit-name.mjs
index f7be06cd..19d19523 100644
--- a/apps/pwa/src/lib/moshpit-name.mjs
+++ b/apps/pwa/src/lib/moshpit-name.mjs
@@ -226,20 +226,25 @@ export function shortCount(n) {
}
/**
- * The most a child name should cost per year.
+ * The most a child name should cost. Once, for good.
*
* This is the `me.whatever` price — what a buyer pays to mint a name under an
* ending someone else holds. It is not the price of `.whatever` itself, which
- * is a separate thing the registry does not charge for yet.
+ * is a separate thing.
*
* $2 flat. PRD 0005 R3 wrote this as $1.99; the extra cent buys nothing but a
* price tag that looks like a supermarket shelf, and every number a person has
* to reason about here — a default, a cap, a per-line override — reads better
* round. The PRD number is superseded by this one.
*
- * The ceiling is on the annual registration/renewal price only. A one-time
- * Buy Now resale transfers ownership rather than starting a term, and §10.2.4
- * puts no ceiling on that.
+ * Not an annual price, and never was in practice: `moshpit_names` has never had
+ * an expiry column and nothing has ever renewed a name. The PRD called it a
+ * yearly fee, the schema sold it outright, and this comment used to describe
+ * the PRD. It now describes what the code does, which is the thing buyers were
+ * actually getting.
+ *
+ * A one-time Buy Now resale transfers ownership rather than starting anything,
+ * and §10.2.4 puts no ceiling on that.
*/
export const MAX_CHILD_PRICE_USD = 2;
@@ -247,7 +252,7 @@ export const MAX_CHILD_PRICE_USD = 2;
export const CHILD_PRICE_USD = MAX_CHILD_PRICE_USD;
/**
- * What a direct ending costs per year: `.whatever` itself.
+ * What a direct ending costs, once: `.whatever` itself.
*
* Nothing charges this yet — `registerTld` inserts a row and claiming is free.
* It lives here anyway so the two prices sit together and the number is settled
@@ -257,6 +262,12 @@ export const CHILD_PRICE_USD = MAX_CHILD_PRICE_USD;
* $5 flat, for the same reason the child price is $2: PRD 0005 §10.1 wrote
* these as $4.99 and $1.99, and the trailing cents buy nothing but a price tag
* shaped like a supermarket shelf.
+ *
+ * Paid once and held for good. §5's one-year term with renewals is withdrawn
+ * (migration 016). A name that lapses is a name somebody else can catch, and
+ * the whole reason to be here is to stop settling for the hyphenated version of
+ * the name you wanted — an annual invoice with a drop date attached is the
+ * thing people are leaving, not something to sell them again.
*/
export const ENDING_PRICE_USD = 5;
diff --git a/apps/pwa/src/migrations/016_moshpit_lifetime.sql b/apps/pwa/src/migrations/016_moshpit_lifetime.sql
new file mode 100644
index 00000000..790e1568
--- /dev/null
+++ b/apps/pwa/src/migrations/016_moshpit_lifetime.sql
@@ -0,0 +1,37 @@
+-- Endings are sold once and held for good. The term is withdrawn.
+--
+-- Migration 010 gave endings a one-year term with renewals, per PRD 0005 §5.
+-- That is reversed here: $5 buys `.eggs` outright and $2 buys a name under one,
+-- both paid once. The prices have not changed -- what changed is that they are
+-- not charged again.
+--
+-- The reason is what the namespace is for rather than generosity. A name that
+-- lapses is a name somebody else can catch, and the pitch is that you can
+-- finally hold the clean name instead of the hyphenated one you settled for. An
+-- annual invoice with a drop date on it is the thing people are trying to get
+-- away from; selling it back to them undoes the pitch.
+--
+-- Worth recording that the term never actually shipped: nothing in the app
+-- could open an ending checkout, so `quoteTld` and `quoteRenewal` were
+-- unreachable and only the webhook settler was wired up. No ending was ever
+-- charged a renewal, and no row in the wild has an expiry that this drop takes
+-- away from somebody. That is why this is a plain drop rather than a
+-- grandfathering policy -- §21.8 asks for one before putting endings INTO a
+-- lifecycle, and taking them back out of one nobody was in needs no such thing.
+--
+-- `moshpit_tld_purchases` keeps its `kind` and `years` columns, deliberately.
+-- They are a financial record of what was sold at the time, and a ledger is not
+-- something to rewrite once the product changes. New rows are written
+-- 'register' and 1 for good; a 'renew' row that settles late is honoured rather
+-- than refused, because the buyer is owed what they were promised.
+--
+-- (Nothing may follow the last statement here but whitespace: migrate.mjs
+-- splits on semicolons and hands each piece to libSQL, and a trailing
+-- comment-only piece comes back as the opaque `SQLITE_OK: not an error`.)
+
+-- The index goes first: SQLite refuses to drop a column an index reads.
+DROP INDEX IF EXISTS idx_moshpit_tlds_expires;
+
+ALTER TABLE moshpit_tlds DROP COLUMN expires_at;
+
+ALTER TABLE moshpit_tlds DROP COLUMN term_started_at;
diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs
index e347b0fd..727f0d1e 100644
--- a/apps/pwa/src/moshpit.mjs
+++ b/apps/pwa/src/moshpit.mjs
@@ -1403,13 +1403,27 @@ export function summarizeBulkClaim(result, limit = MAX_BULK_TLDS) {
return parts.length ? parts.join(". ") + "." : "nothing to claim — paste one ending per line.";
}
-/* ---- buying and renewing an ending ---- */
+/* ---- buying an ending ---- */
-/** A term is a year. Ten is the ceiling PRD 0005 R6 puts on one checkout. */
-export const TERM_MS = 365 * 24 * 60 * 60 * 1000;
-export const MAX_TERM_YEARS = 10;
-
-const TLD_COLS_FULL = `tld, user_id, owner_email, alias_of, price_usd, term_started_at, expires_at, created_at`;
+/**
+ * Endings are sold once and held for good.
+ *
+ * They used to carry a one-year term with renewals, per PRD 0005 §5. That is
+ * withdrawn: $5 buys `.eggs` outright, $2 buys a name under one, and neither
+ * ever comes up for renewal. The prices are unchanged -- what changed is that
+ * they are paid once.
+ *
+ * The reason is not generosity, it is what the namespace is for. A name that
+ * lapses is a name somebody else can catch, and the whole pitch here is that
+ * you can finally have the clean name instead of the hyphenated one you settled
+ * for. An annual invoice with a drop date attached is the thing people are
+ * trying to get away from, and selling it back to them undoes the pitch.
+ *
+ * The term columns are gone (migration 016). The purchase ledger keeps its
+ * `kind` and `years` columns, because those record what was actually sold at
+ * the time and a financial record is not something to rewrite after the fact.
+ */
+const TLD_COLS_FULL = `tld, user_id, owner_email, alias_of, price_usd, created_at`;
export async function getTldWithTerm(tld) {
return get(`SELECT ${TLD_COLS_FULL} FROM moshpit_tlds WHERE tld = ?`, [tld]);
@@ -1423,18 +1437,13 @@ export async function getTldWithTerm(tld) {
* reserved, already held, and "you already own it" are three different answers
* and a single "unavailable" would be none of them.
*/
-export async function quoteTld({ tld: tldInput, buyerId, years = 1, now = Date.now() }) {
+export async function quoteTld({ tld: tldInput, buyerId, now = Date.now() }) {
const tld = normalizeTld(tldInput);
if (!tld) return { ok: false, error: "not a valid TLD — letters, digits and dashes only, no dots" };
const why = tldRejection(tld);
if (why) return { ok: false, error: why };
- const term = Number(years);
- if (!Number.isInteger(term) || term < 1 || term > MAX_TERM_YEARS) {
- return { ok: false, error: `a term is 1 to ${MAX_TERM_YEARS} years` };
- }
-
const owner = await getTldWithTerm(tld);
if (owner) {
if (owner.user_id === buyerId) return { ok: false, error: `.${tld} is already yours`, taken: true };
@@ -1450,46 +1459,40 @@ export async function quoteTld({ tld: tldInput, buyerId, years = 1, now = Date.n
);
if (held) return { ok: false, error: `.${tld} is in someone's checkout right now — try again shortly`, taken: true };
- return { ok: true, tld, years: term, priceUsd: Math.round(ENDING_PRICE_USD * term * 100) / 100 };
+ // Not multiplied by anything. There is one price and one purchase, and a
+ // quote that still carried a term would be an offer the checkout cannot make.
+ return { ok: true, tld, priceUsd: ENDING_PRICE_USD };
}
-/** What it costs to keep one you hold. */
-export async function quoteRenewal({ tld: tldInput, userId, years = 1 }) {
- const tld = normalizeTld(tldInput);
- if (!tld) return { ok: false, error: "not a valid TLD" };
-
- const term = Number(years);
- if (!Number.isInteger(term) || term < 1 || term > MAX_TERM_YEARS) {
- return { ok: false, error: `a term is 1 to ${MAX_TERM_YEARS} years` };
- }
-
- const owner = await getTldWithTerm(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, years: term, priceUsd: Math.round(ENDING_PRICE_USD * term * 100) / 100, expiresAt: owner.expires_at };
-}
-
-export async function openTldPurchase({ paymentId, tld, userId, amountUsd, years = 1, kind = "register", now = Date.now() }) {
+/**
+ * Open a checkout for an ending.
+ *
+ * `kind` and `years` are written as the constants they now always are rather
+ * than dropped from the INSERT: the columns are the ledger's, and a row that
+ * left them NULL would be indistinguishable from one written before they
+ * existed. Every ending sold from here on is one registration, held for good.
+ */
+export async function openTldPurchase({ paymentId, tld, userId, amountUsd, now = Date.now() }) {
await run(
`INSERT INTO moshpit_tld_purchases (id, tld, user_id, amount_usd, kind, status, years, created_at, reserved_until)
- VALUES (?,?,?,?,?, 'pending', ?,?,?)`,
- [paymentId, tld, userId, amountUsd, kind, years, now, now + RESERVATION_MS],
+ VALUES (?,?,?,?, 'register', 'pending', 1, ?,?)`,
+ [paymentId, tld, userId, amountUsd, now, now + RESERVATION_MS],
);
}
/**
- * Hand over a paid-for ending, or extend one. Idempotent on the payment id.
+ * Hand over a paid-for ending. Idempotent on the payment id.
*
* The claim is a conditional UPDATE for the same reason every other settlement
* here uses one: CoinPay retries a webhook it never got an ack for, so two
* deliveries can be in flight at once and both read 'pending' before either
* write lands.
*
- * A renewal never shortens a term. It extends from whichever is later — the
- * current expiry or now — so renewing early adds to what is left rather than
- * throwing it away, and renewing late does not backdate the new term into the
- * past. PRD 0005 R7.
+ * A 'renew' row can no longer be created, but one may still arrive here: a
+ * checkout opened before endings went lifetime can settle after. It is honoured
+ * as what the buyer was actually promised -- they hold the ending, and it now
+ * holds for good -- rather than refused for naming a kind this code no longer
+ * sells. Refusing it would take money for nothing.
*/
export async function settleTldPurchase(paymentId, now = Date.now()) {
const p = await get(`SELECT * FROM moshpit_tld_purchases WHERE id = ? AND status = 'pending'`, [paymentId]);
@@ -1499,8 +1502,6 @@ export async function settleTldPurchase(paymentId, now = Date.now()) {
`UPDATE moshpit_tld_purchases SET status = 'settling' WHERE id = ? AND status = 'pending'`, [paymentId]);
if (!claimed.rowsAffected) return { ok: false, error: "already settled" };
- const span = TERM_MS * (p.years || 1);
-
if (p.kind === "renew") {
const owner = await getTldWithTerm(p.tld);
if (!owner || owner.user_id !== p.user_id) {
@@ -1508,19 +1509,17 @@ export async function settleTldPurchase(paymentId, now = Date.now()) {
console.error(`[moshpit] .${p.tld} left ${p.user_id} before renewal ${paymentId} settled — refund due`);
return { ok: false, error: "ending changed hands before the renewal settled", refundDue: true };
}
- const from = Math.max(owner.expires_at || 0, now);
- await run(`UPDATE moshpit_tlds SET expires_at = ? WHERE tld = ? AND user_id = ?`,
- [from + span, p.tld, p.user_id]);
+ // Nothing to extend any more. The ending is already theirs for good.
await run(`UPDATE moshpit_tld_purchases SET status = 'cleared' WHERE id = ?`, [paymentId]);
- await logAction(p.tld, p.user_id, `renew:${p.years}y`);
- return { ok: true, tld: p.tld, userId: p.user_id, expiresAt: from + span, renewed: true };
+ await logAction(p.tld, p.user_id, `renew:lifetime`);
+ return { ok: true, tld: p.tld, userId: p.user_id, renewed: true, lifetime: true };
}
try {
await run(
- `INSERT INTO moshpit_tlds (tld, user_id, owner_email, owner_key, created_at, term_started_at, expires_at)
- VALUES (?,?,?,?,?,?,?)`,
- [p.tld, p.user_id, null, null, now, now, now + span],
+ `INSERT INTO moshpit_tlds (tld, user_id, owner_email, owner_key, created_at)
+ VALUES (?,?,?,?,?)`,
+ [p.tld, p.user_id, null, null, now],
);
} catch {
// Claimed by someone else between checkout and confirmation. Real money
@@ -1532,7 +1531,7 @@ export async function settleTldPurchase(paymentId, now = Date.now()) {
await run(`UPDATE moshpit_tld_purchases SET status = 'cleared' WHERE id = ?`, [paymentId]);
await logAction(p.tld, p.user_id, `bought:.${p.tld}`);
- return { ok: true, tld: p.tld, userId: p.user_id, expiresAt: now + span };
+ return { ok: true, tld: p.tld, userId: p.user_id, lifetime: true };
}
export async function listTldPurchases(userId, limit = 50) {
@@ -1544,14 +1543,16 @@ export async function listTldPurchases(userId, limit = 50) {
}
/**
- * Is this ending inside its term?
+ * Kept, and it always answers no.
*
- * A NULL expiry is not expired. Every ending claimed before terms existed has
- * one, and treating "no term recorded" as "term ended" would expire a few
- * hundred namespaces that nobody agreed to put on a clock.
+ * Endings do not expire any more. This stays as a named answer rather than
+ * being deleted because "does this ending still belong to its holder" is a
+ * question callers are entitled to keep asking -- the CLI, the DNS bridge and
+ * the resolvers all reasonably might -- and the honest reply is now a permanent
+ * no rather than a missing export that fails at import time.
*/
-export function isExpired(tld, now = Date.now()) {
- return Boolean(tld?.expires_at) && tld.expires_at <= now;
+export function isExpired() {
+ return false;
}
/* ---- short links: /f/ ---- */
diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs
index 600c98c3..094bf383 100644
--- a/apps/pwa/src/routes/moshpit.mjs
+++ b/apps/pwa/src/routes/moshpit.mjs
@@ -64,7 +64,9 @@ import {
countSearchTlds,
countTldsNotOwnedBy,
createLink,
+ CHILD_PRICE_USD,
DEFAULT_TLD_PRICE_USD,
+ ENDING_PRICE_USD,
deleteContent,
deleteLink,
getContent,
@@ -1868,6 +1870,12 @@ const PIT_CSS = `
.pit-forsale{border-color:color-mix(in srgb,var(--acid) 35%,var(--line))}
.pit-tab .count{font-size:.68rem;color:var(--faint);margin-left:6px}
.pit-tab.on .count{color:var(--acid)}
+/* The one claim worth making above the fold, so it reads as a fact about the
+ namespace rather than as a banner. Bordered on one side only — a full box
+ here would sit next to the error and success boxes and be mistaken for one. */
+.pit-forever{border-left:2px solid var(--acid);padding:2px 0 2px 14px;margin:18px 0 0;max-width:62ch}
+.pit-forever b{color:var(--acid);font-weight:600}
+.pit-forever .mono{color:var(--acid)}
.pit-msg{border-radius:8px;padding:10px 14px;margin:14px 0;font-family:var(--mono);font-size:.84rem}
.pit-msg.err{border:1px solid var(--danger);color:var(--danger)}
.pit-msg.ok{border:1px solid var(--acid);color:var(--acid)}
@@ -2457,6 +2465,12 @@ moshpitRouter.get("/pit", async (req, res) => {
foo.agentic resolve to foo.agent — while any name
you exempt stays exactly where it is.
+
+ Bought once. Yours for good.
+ $${ENDING_PRICE_USD} an ending, $${CHILD_PRICE_USD} a name —
+ paid one time, not every year. Nothing here renews, nothing lapses, and no name you hold can drop
+ because an invoice went to an address you stopped reading.
+
${landingCard(req, landing)}
${msg}
${req.user ? claimForm(req) + bulkClaimForm(req) : ""}
@@ -2486,7 +2500,8 @@ moshpitRouter.get("/pit", async (req, res) => {
Endings somebody else holds. Where the operator has set a price you can buy a name under it —
foo.whatever without owning .whatever . Paid in crypto
- through CoinPay; the name lands the moment the payment confirms.
+ through CoinPay; the name lands the moment the payment confirms, and it is yours from then on —
+ there is no renewal and no expiry date.
${theirsHtml}
${pager({
diff --git a/apps/pwa/test/moshpit-terms.test.mjs b/apps/pwa/test/moshpit-terms.test.mjs
index b6f45ddf..708b849a 100644
--- a/apps/pwa/test/moshpit-terms.test.mjs
+++ b/apps/pwa/test/moshpit-terms.test.mjs
@@ -32,18 +32,24 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed
const uniq = () => `e${randomBytes(4).toString("hex")}`;
const pay = () => `pay-${randomBytes(6).toString("hex")}`;
- await t.test("an unclaimed ending quotes at the ending price", async () => {
+ await t.test("an unclaimed ending quotes at the ending price, once", async () => {
const q = await m.quoteTld({ tld: uniq(), buyerId: ALICE });
assert.equal(q.ok, true);
assert.equal(q.priceUsd, 5, "PRD 0005 §10.1, rounded to whole dollars");
- assert.equal(q.years, 1);
+ assert.equal(q.years, undefined, "there is no term to quote");
});
- await t.test("multiple years multiply, up to the cap", async () => {
- assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 3 })).priceUsd, 15);
- assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 11 })).ok, false);
- assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 0 })).ok, false);
- assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 1.5 })).ok, false);
+ await t.test("there is no term to buy more of", async () => {
+ // A quantity used to multiply the price. Passing one now is not an error
+ // that needs naming, it is a field nothing reads -- what matters is that it
+ // cannot quietly produce a different price.
+ assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 3 })).priceUsd, 5);
+ assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 11 })).priceUsd, 5);
+ // And renewing is gone entirely rather than left as a no-op somebody could
+ // still wire a checkout to.
+ assert.equal(typeof m.quoteRenewal, "undefined");
+ assert.equal(typeof m.MAX_TERM_YEARS, "undefined");
+ assert.equal(typeof m.TERM_MS, "undefined");
});
await t.test("every refusal names itself", async () => {
@@ -72,7 +78,7 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed
assert.equal((await m.quoteTld({ tld, buyerId: BOB })).ok, true);
});
- await t.test("settling hands it over with a term", async () => {
+ await t.test("settling hands it over for good", async () => {
const tld = uniq();
const id = pay();
const now = Date.now();
@@ -80,11 +86,26 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed
const result = await m.settleTldPurchase(id, now);
assert.equal(result.ok, true);
+ assert.equal(result.lifetime, true);
const row = await m.getTldWithTerm(tld);
assert.equal(row.user_id, ALICE);
- assert.equal(row.term_started_at, now);
- assert.equal(row.expires_at, now + m.TERM_MS, "one year");
+ // The columns are gone, not merely unset: a NULL expiry somebody could
+ // later populate is an annual term waiting to be switched back on.
+ assert.equal("expires_at" in row, false);
+ assert.equal("term_started_at" in row, false);
+ });
+
+ await t.test("the ledger still records what was sold", async () => {
+ const tld = uniq();
+ const id = pay();
+ await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5 });
+ await m.settleTldPurchase(id);
+
+ const row = (await m.listTldPurchases(ALICE, 50)).find((r) => r.id === id);
+ assert.equal(row.kind, "register");
+ assert.equal(row.years, 1);
+ assert.equal(row.status, "cleared");
});
await t.test("a redelivered webhook does not settle twice", async () => {
@@ -113,56 +134,50 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed
assert.ok(row, "the purchase is still on Alice's record");
});
- await t.test("renewing extends, and never shortens", async () => {
+ await t.test("a renewal opened before the change is honoured, not refunded", async () => {
+ // Nothing can create one of these any more, but a checkout opened before
+ // endings went lifetime may still settle afterwards. The buyer is owed what
+ // they were promised -- they keep the ending, and it now keeps itself.
const tld = uniq();
- const now = Date.now();
- const first = pay();
- await m.openTldPurchase({ paymentId: first, tld, userId: ALICE, amountUsd: 5, now });
- await m.settleTldPurchase(first, now);
-
- // Renewing early adds to what is left rather than throwing it away.
- const second = pay();
- await m.openTldPurchase({ paymentId: second, tld, userId: ALICE, amountUsd: 5, kind: "renew", now });
- await m.settleTldPurchase(second, now + 1000);
+ await m.registerTld({ tld, userId: ALICE });
+ const id = pay();
+ await run(
+ `INSERT INTO moshpit_tld_purchases (id,tld,user_id,amount_usd,kind,status,years,created_at,reserved_until)
+ VALUES (?,?,?,?, 'renew', 'pending', 1, ?, ?)`,
+ [id, tld, ALICE, 5, Date.now(), Date.now() + 60_000],
+ );
- assert.equal((await m.getTldWithTerm(tld)).expires_at, now + m.TERM_MS * 2);
+ const result = await m.settleTldPurchase(id);
+ assert.equal(result.ok, true, result.error);
+ assert.equal(result.renewed, true);
+ assert.equal(result.lifetime, true);
+ assert.equal((await m.getTldWithTerm(tld)).user_id, ALICE);
+ assert.equal((await m.listTldPurchases(ALICE, 50)).find((r) => r.id === id).status, "cleared");
});
- await t.test("renewing a lapsed term runs from now, not from the past", async () => {
+ await t.test("a renewal for an ending that changed hands is still a refund", async () => {
const tld = uniq();
- const past = Date.now() - m.TERM_MS * 2;
+ await m.registerTld({ tld, userId: BOB });
+ const id = pay();
await run(
- `INSERT INTO moshpit_tlds (tld,user_id,created_at,term_started_at,expires_at) VALUES (?,?,?,?,?)`,
- [tld, ALICE, past, past, past + m.TERM_MS],
+ `INSERT INTO moshpit_tld_purchases (id,tld,user_id,amount_usd,kind,status,years,created_at,reserved_until)
+ VALUES (?,?,?,?, 'renew', 'pending', 1, ?, ?)`,
+ [id, tld, ALICE, 5, Date.now(), Date.now() + 60_000],
);
- const id = pay();
- const now = Date.now();
- await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5, kind: "renew", now });
- await m.settleTldPurchase(id, now);
-
- assert.equal((await m.getTldWithTerm(tld)).expires_at, now + m.TERM_MS, "not backdated");
- });
-
- await t.test("only the holder may renew", async () => {
- const tld = uniq();
- await m.registerTld({ tld, userId: ALICE });
- assert.match((await m.quoteRenewal({ tld, userId: BOB })).error, /do not own/);
- assert.equal((await m.quoteRenewal({ tld, userId: ALICE })).ok, true);
+ const result = await m.settleTldPurchase(id);
+ assert.equal(result.ok, false);
+ assert.equal(result.refundDue, true);
});
- await t.test("an ending with no term recorded is not expired", async () => {
- // Every ending claimed before terms existed has a NULL expiry. Treating
- // that as expired would put a few hundred namespaces on a clock nobody
- // agreed to.
+ await t.test("nothing expires any more", async () => {
const tld = uniq();
await m.registerTld({ tld, userId: ALICE });
- const row = await m.getTldWithTerm(tld);
-
- assert.equal(row.expires_at, null);
- assert.equal(m.isExpired(row), false);
- assert.equal(m.isExpired({ expires_at: Date.now() - 1 }), true);
- assert.equal(m.isExpired({ expires_at: Date.now() + 1000 }), false);
+ assert.equal(m.isExpired(await m.getTldWithTerm(tld)), false);
+ // Including for a row that still carries a date from somewhere. The answer
+ // is a permanent no, not a comparison against a column that no longer runs.
+ assert.equal(m.isExpired({ expires_at: Date.now() - 1 }), false);
+ assert.equal(m.isExpired(), false);
});
});