diff --git a/apps/pwa/src/lib/moshpit-name.mjs b/apps/pwa/src/lib/moshpit-name.mjs
index f7be06c..19d1952 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/lib/moshpit-twin.mjs b/apps/pwa/src/lib/moshpit-twin.mjs
new file mode 100644
index 0000000..74b8705
--- /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 `
+
+ 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.
+
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 b6f45dd..708b849 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);
});
});
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 0000000..e5e51b5
--- /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 0000000..68eef45
--- /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 0000000..2f2acad
--- /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);
+ });
+});