Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions apps/pwa/src/lib/moshpit-name.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -226,28 +226,33 @@ 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;

/** Alias, for code that reads better naming the thing than the ceiling. */
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
Expand All@@ -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;

Expand Down
255 changes: 255 additions & 0 deletions apps/pwa/src/lib/moshpit-twin.mjs
Original file line numberDiff line numberDiff line change
@@ -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 `<label>.<tld>` 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;
}
59 changes: 59 additions & 0 deletions apps/pwa/src/migrations/015_moshpit_twins.sql
Original file line numberDiff line numberDiff line change
@@ -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';
Loading
Loading