diff --git a/apps/pwa/src/lib/moshpit-offer-mail.mjs b/apps/pwa/src/lib/moshpit-offer-mail.mjs
new file mode 100644
index 0000000..2d75fa6
--- /dev/null
+++ b/apps/pwa/src/lib/moshpit-offer-mail.mjs
@@ -0,0 +1,119 @@
+// The four mails an offer sends, and nothing else.
+//
+// An offer is a conversation between two people who have no other way to reach
+// each other, so mail is not a notification here -- it is the channel. Each of
+// these is one side learning it is their turn.
+//
+// Every send is best-effort and says so in its return value. A failed mail must
+// never lose an offer that is already recorded: the row is the fact, and the
+// holder can see it on /pit/offers whether or not the mail arrived.
+import { config } from "../config.mjs";
+import { esc } from "./html.mjs";
+import { agreedTerms, describeOffer } from "./moshpit-offer.mjs";
+
+const offerName = (offer) => (offer.label ? `${offer.label}.${offer.tld}` : `.${offer.tld}`);
+
+/**
+ * The house style, such as it is -- the same dark card the approval mails use.
+ *
+ * Plain enough to survive a client that strips the CSS, because the one thing
+ * that has to work in every reader is the link.
+ */
+const wrap = (title, lines, cta) => `
+
`;
+
+async function send(to, subject, html) {
+ if (!config.resend.apiKey) {
+ console.log(`[offer-mail:stub] β ${to}: ${subject}`);
+ return { ok: true, stubbed: true };
+ }
+ try {
+ const res = await fetch("https://api.resend.com/emails", {
+ method: "POST",
+ headers: { authorization: `Bearer ${config.resend.apiKey}`, "content-type": "application/json" },
+ body: JSON.stringify({ from: config.resend.from, to, subject, html }),
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!res.ok) {
+ console.error(`[offer-mail] resend ${res.status} for ${subject}`);
+ return { ok: false, error: `mail provider returned ${res.status}` };
+ }
+ return { ok: true };
+ } catch (e) {
+ console.error(`[offer-mail] ${e?.message ?? e}`);
+ return { ok: false, error: "could not send the mail" };
+ }
+}
+
+/**
+ * To the offerer: prove this address wanted to send that.
+ *
+ * The only mail sent before anything is verified, and the only one that goes to
+ * an address nobody has checked. It says exactly what was offered, because the
+ * other reason someone gets this mail is that a stranger typed their address
+ * into our form -- and that person needs enough to recognise it as not theirs
+ * and ignore it.
+ */
+export const sendOfferVerification = (offer, url) => send(
+ offer.offerer_email,
+ `Confirm your offer on ${offerName(offer)}`,
+ wrap("One click and the holder sees it", [
+ `You offered ${esc(describeOffer(offer))}.`,
+ "Nobody has been told yet. Confirming is what sends it to whoever holds the name.",
+ "If this was not you, nothing happens β do not click, and the offer expires on its own.",
+ ], { url, label: "Confirm the offer β" }),
+);
+
+/** To the holder: somebody wants your name. */
+export const sendOfferToHolder = (offer, holderEmail, url) => send(
+ holderEmail,
+ `Offer on ${offerName(offer)} β ${describeOffer(offer)}`,
+ wrap(`Somebody wants ${offerName(offer)}`, [
+ `${esc(describeOffer(offer))}`,
+ offer.message ? `They said: β${esc(offer.message)}β` : "They left no message.",
+ "Accept it, refuse it, or name a different number. Nothing happens until you do.",
+ ], { url, label: "Answer the offer β" }),
+);
+
+/** To the offerer: the holder has answered. */
+export function sendOfferAnswer(offer, url) {
+ const terms = agreedTerms(offer);
+ const name = offerName(offer);
+ if (offer.status === "accepted") {
+ return send(offer.offerer_email, `Your offer on ${name} was accepted`,
+ wrap(`${name} is yours to pay for`, [
+ `The holder accepted ${esc(describeOffer(offer))}.`,
+ "Nothing has moved yet. Sign in with this address and pay, and the name is transferred on confirmation.",
+ ], { url, label: "Pay and take it β" }));
+ }
+ if (offer.status === "countered") {
+ return send(offer.offerer_email, `Counter-offer on ${name}`,
+ wrap(`The holder named a different number`, [
+ `They countered at $${esc(String(terms.amountUsd))}${
+ terms.months ? ` for ${esc(String(terms.months))} months` : ""}.`,
+ "Take it or leave it β either answer ends the wait.",
+ ], { url, label: "See the counter β" }));
+ }
+ return send(offer.offerer_email, `Your offer on ${name} was not taken`,
+ wrap(`No on ${name}`, [
+ "The holder turned the offer down. Nothing was charged.",
+ "You can make another one later if the name is still there.",
+ ], { url, label: "Look at the name β" }));
+}
+
+/** To the tenant and the holder: a lease has started. */
+export const sendLeaseStarted = (offer, expiresAt, url) => send(
+ offer.offerer_email,
+ `You now hold ${offerName(offer)} until ${new Date(expiresAt).toISOString().slice(0, 10)}`,
+ wrap(`${offerName(offer)} is yours to use`, [
+ `Point it, publish under it, put records and keys on it β until ${
+ esc(new Date(expiresAt).toISOString().slice(0, 10))}.`,
+ "It reverts to its holder on that date, and what it was serving stops being served. Nothing renews.",
+ ], { url, label: "Set it up β" }),
+);
diff --git a/apps/pwa/src/lib/moshpit-offer.mjs b/apps/pwa/src/lib/moshpit-offer.mjs
new file mode 100644
index 0000000..90d66b9
--- /dev/null
+++ b/apps/pwa/src/lib/moshpit-offer.mjs
@@ -0,0 +1,198 @@
+// What a stranger may offer for a name, and what a lease term actually means.
+//
+// The parked page is the one moment someone wants a name enough to say so, and
+// until now every version of that moment ended in a sentence: "claimed but does
+// not point anywhere", ".eggs is not for sale". The holder never heard that
+// anybody asked. An offer is the missing half of that conversation.
+//
+// Deliberately free of any database, network or config import, for the same
+// reason moshpit-name, moshpit-twin and moshpit-contact are: these are the
+// rules, and the rules have to be checkable without a libSQL connection.
+// src/moshpit.mjs owns storage and the routes own the pages.
+import { randomBytes } from "node:crypto";
+
+export const OFFER_KINDS = ["buy", "lease"];
+
+/**
+ * The statuses an offer can be in, and which of them are still a conversation.
+ *
+ * `unverified` is live in the sense that it can still become something, but the
+ * holder has not been told it exists -- that is the whole point of it. Every
+ * listing the holder sees starts at `open`.
+ */
+export const OFFER_STATUSES = [
+ "unverified", "open", "countered", "accepted", "settling", "paid",
+ "refund_due", "rejected", "withdrawn", "expired",
+];
+
+/** Statuses that can still change. Everything else is history. */
+const LIVE_STATUSES = new Set(["unverified", "open", "countered", "accepted", "settling"]);
+
+/** Statuses the clock is allowed to end. An accepted offer is waiting on money, not on a reply. */
+const EXPIRABLE_STATUSES = new Set(["unverified", "open", "countered"]);
+
+/**
+ * Thirty days, then an unanswered offer stops being one.
+ *
+ * Long enough that a holder who checks the pit monthly still sees it, short
+ * enough that a page does not accumulate a decade of stale numbers somebody
+ * might act on. An accepted offer is exempt: the clock stops the moment both
+ * sides agree, because from there the only thing outstanding is a payment.
+ */
+export const OFFER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+
+/**
+ * The floor, and why there is one.
+ *
+ * Not a view about what a name is worth. A required, non-trivial number is the
+ * cheapest filter there is on a form that anybody on the internet can submit:
+ * it costs a real bidder nothing and it makes "offer $0 on all 18,000 endings"
+ * an activity with a stated price attached.
+ */
+export const MIN_OFFER_USD = 1;
+
+/** Matches MAX_LISTING_PRICE_USD in moshpit.mjs -- a bound against Infinity and 1e300, not a policy. */
+export const MAX_OFFER_USD = 1_000_000_000;
+
+/** One month to five years. Beyond that, somebody wants to buy the name. */
+export const MIN_LEASE_MONTHS = 1;
+export const MAX_LEASE_MONTHS = 60;
+
+/** How long a message may be. Enough to explain who you are, not enough to be a payload. */
+export const MAX_OFFER_MESSAGE = 1000;
+
+/**
+ * Money, or null when what arrived could never be an amount.
+ *
+ * Rounded to cents rather than accepted as typed. A float that carries more
+ * precision than money does is a number that renders as $19.989999999999998
+ * somewhere downstream, and it will be somewhere the holder is deciding whether
+ * to accept.
+ */
+export function normalizeOfferAmount(input) {
+ const raw = String(input ?? "").trim().replace(/^\$/, "").replace(/,/g, "");
+ if (!raw) return null;
+ const value = Number(raw);
+ if (!Number.isFinite(value)) return null;
+ const cents = Math.round(value * 100) / 100;
+ if (cents < MIN_OFFER_USD || cents > MAX_OFFER_USD) return null;
+ return cents;
+}
+
+/** A whole number of months inside the bounds, or null. */
+export function normalizeLeaseMonths(input) {
+ const raw = String(input ?? "").trim();
+ if (!/^\d+$/.test(raw)) return null;
+ const months = Number.parseInt(raw, 10);
+ if (months < MIN_LEASE_MONTHS || months > MAX_LEASE_MONTHS) return null;
+ return months;
+}
+
+export const normalizeOfferKind = (input) => {
+ const kind = String(input ?? "").trim().toLowerCase();
+ return OFFER_KINDS.includes(kind) ? kind : null;
+};
+
+/** Trimmed and capped. Empty becomes null, so "no message" is one value and not two. */
+export function normalizeOfferMessage(input) {
+ const text = String(input ?? "").trim().slice(0, MAX_OFFER_MESSAGE);
+ return text || null;
+}
+
+/**
+ * Ids and tokens.
+ *
+ * The verify token is the only one that is a secret: it arrives by mail and
+ * clicking it is what turns an address into a verified one, so it is sized
+ * against guessing rather than against collision.
+ */
+export const mintOfferId = () => `ofr_${randomBytes(9).toString("hex")}`;
+export const mintVerifyToken = () => randomBytes(32).toString("base64url");
+
+/**
+ * When a term that starts now runs out.
+ *
+ * Calendar months, not thirty-day blocks, because "three months" is what the
+ * two of them agreed and a tenant counting days off a calendar should reach the
+ * same date we did. The clamp is the awkward case every month-arithmetic
+ * implementation has to pick an answer for: one month from 31 January is 28
+ * February, because the alternative is silently rolling into March and giving
+ * away a day of somebody's term.
+ */
+export function leaseEndsAt(startsAt, months) {
+ const start = new Date(startsAt);
+ const target = new Date(start.getTime());
+ target.setUTCDate(1);
+ target.setUTCMonth(target.getUTCMonth() + months);
+ const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
+ target.setUTCDate(Math.min(start.getUTCDate(), lastDay));
+ target.setUTCHours(start.getUTCHours(), start.getUTCMinutes(), start.getUTCSeconds(), start.getUTCMilliseconds());
+ return target.getTime();
+}
+
+/**
+ * What the two of them have actually agreed to, counter included.
+ *
+ * The counter is held beside the original rather than replacing it, so every
+ * reader has to know which one is operative. This is that knowledge, in one
+ * place -- a caller that reads `amount_usd` directly will charge the wrong
+ * number the first time a holder counters.
+ */
+export function agreedTerms(offer) {
+ if (!offer) return null;
+ return {
+ amountUsd: offer.counter_amount_usd ?? offer.amount_usd,
+ months: offer.kind === "lease" ? (offer.counter_months ?? offer.lease_months) : null,
+ countered: offer.counter_amount_usd !== null && offer.counter_amount_usd !== undefined,
+ };
+}
+
+/**
+ * The status an offer really has, which is not always the one in the column.
+ *
+ * Expiry is a date passing rather than a write happening, so a row can be
+ * `open` and long dead. Every reader goes through here, and the sweep that
+ * writes `expired` is a tidy-up rather than the thing that makes it true --
+ * otherwise an offer would be live exactly as long as the sweep was broken.
+ */
+export function effectiveStatus(offer, now = Date.now()) {
+ if (!offer) return null;
+ if (EXPIRABLE_STATUSES.has(offer.status) && offer.expires_at <= now) return "expired";
+ return offer.status;
+}
+
+/** Whether this offer is still something either side can act on. */
+export const offerIsLive = (offer, now = Date.now()) => LIVE_STATUSES.has(effectiveStatus(offer, now));
+
+/** Whether the holder is the one being waited on. */
+export const awaitingHolder = (offer, now = Date.now()) => effectiveStatus(offer, now) === "open";
+
+/** Whether the offerer is the one being waited on -- a counter to answer, or a bill to pay. */
+export const awaitingOfferer = (offer, now = Date.now()) =>
+ ["countered", "accepted"].includes(effectiveStatus(offer, now));
+
+/**
+ * Whether a lease is running right now.
+ *
+ * Read-time, like everything else here. A lease that ended an hour ago must
+ * stop granting control the moment it ends, not the next time a sweep runs --
+ * the alternative is a former tenant still able to repoint a name they no
+ * longer rent.
+ */
+export const leaseIsActive = (lease, now = Date.now()) =>
+ Boolean(lease) && lease.starts_at <= now && lease.expires_at > now;
+
+/**
+ * How an offer reads in one line, for a subject line or a list row.
+ *
+ * Money first, because that is what the holder is deciding about.
+ */
+export function describeOffer(offer) {
+ const terms = agreedTerms(offer);
+ if (!terms) return "";
+ const name = offer.label ? `${offer.label}.${offer.tld}` : `.${offer.tld}`;
+ const money = `$${terms.amountUsd.toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
+ return offer.kind === "lease"
+ ? `${money} to lease ${name} for ${terms.months} month${terms.months === 1 ? "" : "s"}`
+ : `${money} to buy ${name}`;
+}
diff --git a/apps/pwa/src/migrations/018_moshpit_offers.sql b/apps/pwa/src/migrations/018_moshpit_offers.sql
new file mode 100644
index 0000000..37d1365
--- /dev/null
+++ b/apps/pwa/src/migrations/018_moshpit_offers.sql
@@ -0,0 +1,166 @@
+-- Offers on a parked name, and the leases some of them turn into.
+--
+-- Everything the registry could say to someone who wanted a name it could not
+-- sell them was a dead end. A name somebody holds answered "claimed but does
+-- not point anywhere"; a name under an ending with no price answered ".eggs is
+-- not for sale". Both are the exact moment a visitor is most interested, and
+-- both ended the conversation -- while the holder, who might well have sold,
+-- never heard that anyone asked.
+--
+-- An offer is that conversation. It is private between the two of them: only
+-- the holder sees what was offered, and they accept, reject or counter. A
+-- public bid board would tell every later bidder what the last one offered and
+-- tell the holder's next buyer exactly where their floor is.
+CREATE TABLE IF NOT EXISTS moshpit_offers (
+ id TEXT PRIMARY KEY,
+ tld TEXT NOT NULL,
+ -- The name being offered on, or '' for the ending itself -- the same
+ -- convention moshpit_contacts uses, and for the same reason: the whole
+ -- lifecycle below is identical for a name and an ending.
+ label TEXT NOT NULL DEFAULT '',
+
+ -- buy | lease.
+ --
+ -- Leases are names only, never endings, and that is a deliberate limit
+ -- rather than an oversight. Leasing an ending would have to mean the lessee
+ -- can mint names under it, and a name minted during a lease outlives the
+ -- lease -- so a six-month tenancy would permanently carve up a namespace its
+ -- holder never sold. Until there is an answer to that, an ending can be
+ -- bought and not rented.
+ kind TEXT NOT NULL CHECK (kind IN ('buy','lease')),
+
+ -- What is being offered, in whole dollars and cents. For a lease this is the
+ -- total for the whole term, paid once, not a monthly rate -- see
+ -- moshpit_leases on why there is no recurring billing here.
+ amount_usd REAL NOT NULL,
+ -- How long the lease runs. NULL for a purchase, and required for a lease.
+ lease_months INTEGER,
+
+ -- Who is asking. An offer can come from someone with no account at all,
+ -- because requiring one first is asking a stranger to sign up before they
+ -- may say what they would pay, on a page whose entire job is to convert that
+ -- stranger. The address is the identity until there is a user id, which
+ -- appears when they sign in to pay.
+ offerer_email TEXT NOT NULL,
+ offerer_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
+ message TEXT,
+
+ -- Who it was addressed to when it was made. Kept as a fact about the offer
+ -- rather than looked up later: names change hands, and an offer must stay
+ -- attached to the person it was actually put to. Acceptance re-checks who
+ -- holds the name now, so a stale row can never sell something twice.
+ holder_user_id TEXT NOT NULL,
+
+ -- unverified -- made, but the address has not proved it wants to be here.
+ -- The holder is not told. This is where spam stops.
+ -- open -- verified and waiting on the holder.
+ -- countered -- the holder named a different number, waiting on the offerer.
+ -- accepted -- agreed by both, waiting on payment.
+ -- settling -- a confirmed payment is being turned into a transfer. Held
+ -- for one write, and it is the atomic claim that stops a
+ -- redelivered webhook moving the name twice.
+ -- paid -- settled, and the name or lease has moved.
+ -- refund_due -- the money arrived and the name could not be given. Someone
+ -- else took it between acceptance and confirmation, which is
+ -- real money against something the buyer cannot have, so it
+ -- is recorded rather than swallowed.
+ -- rejected / withdrawn / expired -- over, by each of the three people who
+ -- can end it: the holder, the offerer, and the clock.
+ status TEXT NOT NULL
+ CHECK (status IN ('unverified','open','countered','accepted','settling','paid','refund_due','rejected','withdrawn','expired')),
+
+ -- Proves the address wants the mail before any is sent to the holder.
+ -- Without this the form is a way to mail every holder in the registry from
+ -- our own domain, one name at a time.
+ verify_token TEXT NOT NULL UNIQUE,
+ verified_at INTEGER,
+
+ -- The holder's counter, held beside the original rather than overwriting it.
+ -- What was first offered is the fact the offerer will be comparing against,
+ -- and a negotiation that silently rewrites its own history is one neither
+ -- side can check.
+ counter_amount_usd REAL,
+ counter_months INTEGER,
+ countered_at INTEGER,
+
+ -- The CoinPay checkout, once there is something agreed to pay for. UNIQUE so
+ -- a redelivered webhook settles the same offer rather than a second one --
+ -- the same reasoning moshpit_name_purchases uses for its primary key.
+ payment_id TEXT UNIQUE,
+
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ -- An offer nobody answered stops being one. Checked at read time as well as
+ -- swept, so an offer is never live merely because the sweep has not run.
+ expires_at INTEGER NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_moshpit_offers_holder ON moshpit_offers(holder_user_id, status);
+CREATE INDEX IF NOT EXISTS idx_moshpit_offers_name ON moshpit_offers(tld, label, status);
+CREATE INDEX IF NOT EXISTS idx_moshpit_offers_offerer ON moshpit_offers(offerer_email, created_at);
+-- The sweep reads "still live, past its date", which is a small slice.
+CREATE INDEX IF NOT EXISTS idx_moshpit_offers_expiry ON moshpit_offers(expires_at)
+ WHERE status IN ('unverified','open','countered');
+
+-- A name rented rather than sold.
+--
+-- The holder keeps the name. The lessee gets to point it, publish under it and
+-- present keys for it until the term runs out, and then it reverts with no
+-- action needed from either of them.
+--
+-- Paid once, upfront, for the whole term. Not because a monthly rate would be
+-- wrong -- it is how leasing actually works -- but because renewing one needs
+-- subscription billing, a grace period, and a story for what happens to a live
+-- site when a card fails, and none of those exist here yet. A term that is
+-- fully paid before it starts cannot lapse halfway through, which makes this
+-- the version that can be built correctly today.
+CREATE TABLE IF NOT EXISTS moshpit_leases (
+ tld TEXT NOT NULL,
+ label TEXT NOT NULL,
+ lessee_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ -- Who it reverts to. Stored rather than read from moshpit_names, because
+ -- that row is what a lease exists to make ambiguous, and the answer to
+ -- "whose name is this really" must not depend on the thing being leased.
+ holder_user_id TEXT NOT NULL,
+ offer_id TEXT NOT NULL REFERENCES moshpit_offers(id),
+ months INTEGER NOT NULL,
+ amount_usd REAL NOT NULL,
+ starts_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ created_at INTEGER NOT NULL,
+
+ -- One lease per name. Two people holding overlapping tenancies on one name
+ -- is not a state anything downstream could resolve -- whose target does it
+ -- serve? -- so the key refuses it rather than the application remembering to.
+ -- A finished lease stays as the row until a new one replaces it, which is
+ -- what makes "who had this last" answerable.
+ PRIMARY KEY (tld, label)
+);
+
+CREATE INDEX IF NOT EXISTS idx_moshpit_leases_lessee ON moshpit_leases(lessee_user_id);
+CREATE INDEX IF NOT EXISTS idx_moshpit_leases_expiry ON moshpit_leases(expires_at);
+
+-- The lease, denormalised onto the name it is on.
+--
+-- moshpit_leases above is the record. These two are the cache, and the split is
+-- the same one moshpit_tlds and moshpit_tld_log already make in this schema.
+--
+-- The reason is resolution. Every name lookup in the pit goes through
+-- resolveMoshpitName -- the DNS bridge, the browser extension, every /n/ page
+-- -- and each one has to know whether the name is currently being served by a
+-- tenant or by a tenancy that has quietly run out. Asking a second table on
+-- that path would put an extra SELECT on the hottest query in the registry to
+-- answer a question that is null for almost every name. Read from the row that
+-- was already fetched, it is free.
+--
+-- NULL means what it has always meant: nobody is renting this.
+ALTER TABLE moshpit_names ADD COLUMN leased_to TEXT;
+ALTER TABLE moshpit_names ADD COLUMN leased_until INTEGER;
+
+-- Reverted, so the sweep can tell a lease it has already cleaned up from one it
+-- has not. Without it the sweep either has to re-clear every expired lease
+-- forever, or track its own high-water mark somewhere else.
+ALTER TABLE moshpit_leases ADD COLUMN reverted_at INTEGER;
+
+CREATE INDEX IF NOT EXISTS idx_moshpit_leases_unreverted ON moshpit_leases(expires_at)
+ WHERE reverted_at IS NULL;
diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs
index fc07a5e..32f933c 100644
--- a/apps/pwa/src/moshpit.mjs
+++ b/apps/pwa/src/moshpit.mjs
@@ -49,6 +49,25 @@ import {
parseTldList,
tldRejection,
} from "./lib/moshpit-name.mjs";
+import {
+ agreedTerms,
+ effectiveStatus,
+ leaseEndsAt,
+ leaseIsActive,
+ MAX_LEASE_MONTHS,
+ MAX_OFFER_USD,
+ MIN_LEASE_MONTHS,
+ MIN_OFFER_USD,
+ mintOfferId,
+ mintVerifyToken,
+ normalizeLeaseMonths,
+ normalizeOfferAmount,
+ normalizeOfferKind,
+ normalizeOfferMessage,
+ OFFER_KINDS,
+ OFFER_TTL_MS,
+ offerIsLive,
+} from "./lib/moshpit-offer.mjs";
import {
effectiveTarget,
normalizeRecord,
@@ -92,6 +111,13 @@ export {
guardAddress, isGuardToken, mintGuardToken, normalizeContactEmail, normalizeVisibility, publishedContact,
} from "./lib/moshpit-contact.mjs";
+export {
+ MAX_LEASE_MONTHS, MAX_OFFER_MESSAGE, MAX_OFFER_USD, MIN_LEASE_MONTHS, MIN_OFFER_USD,
+ OFFER_KINDS, OFFER_STATUSES, OFFER_TTL_MS,
+ agreedTerms, awaitingHolder, awaitingOfferer, describeOffer, effectiveStatus, leaseEndsAt, leaseIsActive,
+ normalizeLeaseMonths, normalizeOfferAmount, normalizeOfferKind, normalizeOfferMessage, offerIsLive,
+} from "./lib/moshpit-offer.mjs";
+
/**
* The largest number this column will accept.
*
@@ -333,7 +359,7 @@ async function ownedTldAndLabel(tldInput, labelInput, userId) {
/* ---- names under a TLD ---- */
-const NAME_COLS = `tld, label, user_id, target, feed_url, feed_kind, created_at`;
+const NAME_COLS = `tld, label, user_id, target, feed_url, feed_kind, leased_to, leased_until, created_at`;
export async function getName(tld, label) {
return get(`SELECT ${NAME_COLS} FROM moshpit_names WHERE tld = ? AND label = ?`, [tld, label]);
@@ -442,7 +468,7 @@ export async function registerName({ tld: tldInput, label: labelInput, userId, t
/** Point an existing name somewhere else. */
export async function setNameTarget({ tld: tldInput, label: labelInput, userId, target }) {
- const owned = await ownedName(tldInput, labelInput, userId);
+ const owned = await controlledName(tldInput, labelInput, userId);
if (!owned.ok) return owned;
const dest = normalizeTarget(target);
if (!dest.ok) return { ok: false, error: dest.error };
@@ -467,7 +493,7 @@ export async function setNameTarget({ tld: tldInput, label: labelInput, userId,
* longer exists, and it would silently apply to whatever feed came next.
*/
export async function setNameFeed({ tld: tldInput, label: labelInput, userId, feed, kind = null }) {
- const owned = await ownedName(tldInput, labelInput, userId);
+ const owned = await controlledName(tldInput, labelInput, userId);
if (!owned.ok) return owned;
const stream = normalizeFeedUrl(feed);
@@ -482,9 +508,20 @@ export async function setNameFeed({ tld: tldInput, label: labelInput, userId, fe
}
/** Give the name back. */
-export async function releaseName({ tld: tldInput, label: labelInput, userId }) {
+export async function releaseName({ tld: tldInput, label: labelInput, userId, now = Date.now() }) {
const owned = await ownedName(tldInput, labelInput, userId);
if (!owned.ok) return owned;
+
+ // Not while somebody is renting it. Releasing would drop the name back into
+ // the pool for anyone to mint, mid-tenancy, taking a paid-for term with it --
+ // and the tenant would find out when their site stopped answering.
+ const lease = await activeLease(owned.tld, owned.label, now);
+ if (lease) {
+ return {
+ ok: false,
+ error: `${owned.label}.${owned.tld} is leased until ${new Date(lease.expires_at).toISOString().slice(0, 10)} β you cannot give it up mid-term`,
+ };
+ }
// Keys go with the name. Deleted explicitly rather than left to the foreign
// key, because SQLite only enforces those with `PRAGMA foreign_keys = ON`
// and nothing here sets it β so a cascade that looks declared would not fire,
@@ -758,6 +795,16 @@ export async function resolveMoshpitName(input) {
// legitimately come from.
const entry = await getName(resolvedTld, label);
+ // A tenancy that has run out but not yet been swept up. The row still carries
+ // the tenant's target because nothing has been round to clear it, and serving
+ // it would keep a former tenant's site answering under a name they no longer
+ // rent -- for however long the sweep is behind, which on a registry that has
+ // just restarted is "since the restart".
+ //
+ // Read-time, so the lease ends when it ends. endExpiredLeases() makes the row
+ // agree afterwards; it is not what makes this true.
+ const lapsed = Boolean(entry?.leased_until) && entry.leased_until <= Date.now();
+
return {
name,
resolved,
@@ -768,12 +815,17 @@ export async function resolveMoshpitName(input) {
registered: true,
...(Boolean(owner.alias_of) && !aliased ? { exempt: true } : {}),
name_registered: Boolean(entry),
- target: entry?.target ?? null,
+ target: lapsed ? null : (entry?.target ?? null),
// Carried alongside the target rather than folded into it. A resolver
// answering AAAA has no use for a feed and ignores these; /n/ is the caller
// that turns them into a page, and it needs both to decide which it serves.
- feed: entry?.feed_url ?? null,
- feed_kind: entry?.feed_kind ?? null,
+ feed: lapsed ? null : (entry?.feed_url ?? null),
+ feed_kind: lapsed ? null : (entry?.feed_kind ?? null),
+ // Who is renting it, and until when. Null for almost every name. A resolver
+ // ignores both; the name's own page uses them to say why a name that is
+ // plainly somebody's is not answering as theirs.
+ leased_to: lapsed ? null : (entry?.leased_to ?? null),
+ leased_until: lapsed ? null : (entry?.leased_until ?? null),
};
}
@@ -842,7 +894,7 @@ export async function pinsForName(input, kind = null) {
/** Publish a key for a name you hold. */
export async function addPin({ tld: tldInput, label: labelInput, pin, kind: kindInput, note = null, userId }) {
- const owned = await ownedName(tldInput, labelInput, userId);
+ const owned = await controlledName(tldInput, labelInput, userId);
if (!owned.ok) return owned;
if (!isPin(pin)) {
@@ -881,7 +933,7 @@ export async function addPin({ tld: tldInput, label: labelInput, pin, kind: kind
* grounds that it breaks connections would be refusing the point.
*/
export async function removePin({ tld: tldInput, label: labelInput, pin, userId }) {
- const owned = await ownedName(tldInput, labelInput, userId);
+ const owned = await controlledName(tldInput, labelInput, userId);
if (!owned.ok) return owned;
const result = await run(
@@ -957,7 +1009,7 @@ export async function recordsForName(input) {
* what they wanted.
*/
export async function addRecord({ tld: tldInput, label: labelInput, type, value, ttl, priority, userId }) {
- const owned = await ownedName(tldInput, labelInput, userId);
+ const owned = await controlledName(tldInput, labelInput, userId);
if (!owned.ok) return owned;
const name = `${owned.label}.${owned.tld}`;
@@ -1009,7 +1061,7 @@ export async function addRecord({ tld: tldInput, label: labelInput, type, value,
* that already knows exactly which record it means.
*/
export async function removeRecord({ tld: tldInput, label: labelInput, type, value, userId }) {
- const owned = await ownedName(tldInput, labelInput, userId);
+ const owned = await controlledName(tldInput, labelInput, userId);
if (!owned.ok) return owned;
const wanted = normalizeRecordType(type);
@@ -1164,7 +1216,7 @@ export async function contentForName(input) {
* which is not something a later edit gets to rewrite.
*/
export async function putContent({ tld: tldInput, label: labelInput, userId, item: input, now = Date.now() }) {
- const owned = await ownedName(tldInput, labelInput, userId);
+ const owned = await controlledName(tldInput, labelInput, userId);
if (!owned.ok) return owned;
const normalized = normalizeContent(input, { now });
@@ -1207,7 +1259,7 @@ export async function putContent({ tld: tldInput, label: labelInput, userId, ite
/** Take one item back down. */
export async function deleteContent({ tld: tldInput, label: labelInput, userId, slug: slugInput }) {
- const owned = await ownedName(tldInput, labelInput, userId);
+ const owned = await controlledName(tldInput, labelInput, userId);
if (!owned.ok) return owned;
const slug = normalizeSlug(slugInput);
if (!slug) return { ok: false, error: "not a valid slug" };
@@ -1674,7 +1726,7 @@ export async function createLink({
if (nameInput) {
const parsed = parseMoshpitName(nameInput);
if (!parsed) return { ok: false, error: `not a moshpit name: ${nameInput}` };
- const owned = await ownedName(parsed.tld, parsed.label, userId);
+ const owned = await controlledName(parsed.tld, parsed.label, userId);
if (!owned.ok) return owned;
name = `${owned.label}.${owned.tld}`;
}
@@ -2252,3 +2304,577 @@ export async function unsyncedContacts(limit = 200) {
[limit],
);
}
+
+/* ---- offers, and the leases some of them become ---- */
+
+const OFFER_COLS = `id, tld, label, kind, amount_usd, lease_months, offerer_email, offerer_user_id, message,
+ holder_user_id, status, verify_token, verified_at, counter_amount_usd, counter_months, countered_at,
+ payment_id, created_at, updated_at, expires_at`;
+
+const LEASE_COLS = `tld, label, lessee_user_id, holder_user_id, offer_id, months, amount_usd, starts_at, expires_at, created_at`;
+
+/**
+ * How many offers one address may make in a day, across the whole registry.
+ *
+ * The verification step is what stops the form being a way to mail strangers;
+ * this is what stops it being a way to mail one holder two hundred times. Both
+ * are needed -- a verified address can still be a determined nuisance.
+ */
+export const MAX_OFFERS_PER_DAY = 20;
+
+/** And one live offer per address per name. A second is a counter, not a new offer. */
+const OFFERS_PER_NAME = 1;
+
+export async function getOffer(id) {
+ return get(`SELECT ${OFFER_COLS} FROM moshpit_offers WHERE id = ?`, [String(id ?? "")]);
+}
+
+export async function offerByVerifyToken(token) {
+ const raw = String(token ?? "");
+ if (!raw) return null;
+ return get(`SELECT ${OFFER_COLS} FROM moshpit_offers WHERE verify_token = ?`, [raw]);
+}
+
+export async function getLease(tld, label) {
+ return get(`SELECT ${LEASE_COLS} FROM moshpit_leases WHERE tld = ? AND label = ?`, [tld, label]);
+}
+
+/** The lease running right now, or null. Read-time, so it ends exactly when it ends. */
+export async function activeLease(tld, label, now = Date.now()) {
+ const lease = await getLease(tld, label);
+ return leaseIsActive(lease, now) ? lease : null;
+}
+
+export async function listLeasesForUser(userId) {
+ return all(`SELECT ${LEASE_COLS} FROM moshpit_leases WHERE lessee_user_id = ? ORDER BY expires_at DESC`, [userId]);
+}
+
+/**
+ * May this account act on the name -- as its holder, or as the tenant renting it?
+ *
+ * The distinction the whole lease feature turns on. A lessee can point the
+ * name, publish under it, put records and keys on it: everything that makes the
+ * name usable for the term they paid for. What they cannot do is anything that
+ * outlives the lease -- give the name up, sell it, bind a clearnet twin to it,
+ * or change who is contacted about buying it. Those stay with the holder, and
+ * they stay on `ownedName`.
+ */
+async function controlledName(tldInput, labelInput, userId, now = Date.now()) {
+ const tld = normalizeTld(tldInput);
+ const label = normalizeLabel(labelInput);
+ if (!tld || !label) return { ok: false, error: "not a valid name" };
+
+ const existing = await getName(tld, label);
+ if (!existing) return { ok: false, error: `${label}.${tld} is not registered` };
+
+ // The holder, unless somebody is renting it. A holder who has let the name
+ // out does not get to keep pointing it somewhere else for the term -- that
+ // is what the tenant paid for, and two people editing one name is the state
+ // a lease has to rule out rather than race on.
+ const tenanted = existing.leased_to && existing.leased_until > now;
+ if (tenanted) {
+ return existing.leased_to === userId
+ ? { ok: true, tld, label, leased: true }
+ : { ok: false, error: `${label}.${tld} is leased until ${new Date(existing.leased_until).toISOString().slice(0, 10)}` };
+ }
+
+ // The same wording ownedName uses, deliberately. Somebody refused here owns
+ // nothing and rents nothing, so ownership is still the honest reason -- and
+ // the one case where control and ownership genuinely differ, an active lease,
+ // has its own message above that says so with the date.
+ if (existing.user_id !== userId) return { ok: false, error: `you do not own ${label}.${tld}` };
+ return { ok: true, tld, label };
+}
+
+/**
+ * Who an offer for this name or ending would be put to.
+ *
+ * An unregistered name under an ending somebody holds is a legitimate subject:
+ * the operator can mint it and sell it, so they are the one to ask. That is the
+ * case the old page turned away with ".eggs is not for sale", which was true
+ * and unhelpful -- not for sale at a fixed price is not the same as not for
+ * sale.
+ */
+export async function offerTarget(tldInput, labelInput) {
+ const tld = normalizeTld(tldInput);
+ if (!tld) return { ok: false, error: "not a valid ending" };
+
+ const ending = await getTld(tld);
+ // Nobody holds the ending, so there is nobody to offer to -- and no need.
+ if (!ending) return { ok: false, error: `nobody holds .${tld} β claim it instead`, claimable: true };
+
+ const raw = String(labelInput ?? "").trim();
+ if (!raw) return { ok: true, tld, label: "", holderId: ending.user_id, registered: true };
+
+ const label = normalizeLabel(raw);
+ if (!label) return { ok: false, error: "not a valid name" };
+
+ const name = await getName(tld, label);
+ return {
+ ok: true, tld, label,
+ holderId: name ? name.user_id : ending.user_id,
+ registered: Boolean(name),
+ };
+}
+
+/**
+ * Put an offer to whoever holds a name.
+ *
+ * Recorded `unverified` and not mentioned to the holder until the address
+ * proves it wants to be here. Everything about the ordering is that: the offer
+ * exists first so a click can find it, and the holder learns about it second so
+ * that a form submission alone cannot reach them.
+ */
+export async function makeOffer({
+ tld: tldInput, label: labelInput, kind, amount, months, email, message, userId = null, now = Date.now(),
+}) {
+ const target = await offerTarget(tldInput, labelInput);
+ if (!target.ok) return target;
+
+ const offerKind = normalizeOfferKind(kind);
+ if (!offerKind) return { ok: false, error: `an offer is to ${OFFER_KINDS.join(" or ")}` };
+ // See the migration: a name minted during an ending's lease would outlive the
+ // lease, so an ending is bought rather than rented.
+ if (offerKind === "lease" && !target.label) return { ok: false, error: "an ending can be bought, not leased" };
+
+ const amountUsd = normalizeOfferAmount(amount);
+ if (amountUsd === null) {
+ return { ok: false, error: `an offer has to be a number between $${MIN_OFFER_USD} and $${MAX_OFFER_USD}` };
+ }
+
+ const leaseMonths = offerKind === "lease" ? normalizeLeaseMonths(months) : null;
+ if (offerKind === "lease" && leaseMonths === null) {
+ return { ok: false, error: `a lease runs ${MIN_LEASE_MONTHS} to ${MAX_LEASE_MONTHS} months` };
+ }
+
+ const address = normalizeContactEmail(email);
+ if (!address) return { ok: false, error: "that does not look like an email address" };
+
+ if (userId && userId === target.holderId) {
+ return { ok: false, error: target.label ? "you hold this name already" : "you hold this ending already" };
+ }
+
+ // A name that is out on lease cannot cleanly be sold or re-let: the tenancy
+ // would either be broken by the sale or silently inherited by a buyer who
+ // never agreed to it. Said plainly, with the date, so the asker knows when to
+ // come back rather than thinking the name is unavailable outright.
+ if (target.label) {
+ const lease = await activeLease(target.tld, target.label, now);
+ if (lease) {
+ return {
+ ok: false,
+ error: `${target.label}.${target.tld} is leased until ${new Date(lease.expires_at).toISOString().slice(0, 10)}`,
+ };
+ }
+ }
+
+ const recent = await get(
+ `SELECT COUNT(*) AS n FROM moshpit_offers WHERE offerer_email = ? AND created_at > ?`,
+ [address, now - 24 * 60 * 60 * 1000],
+ );
+ if (Number(recent?.n ?? 0) >= MAX_OFFERS_PER_DAY) {
+ return { ok: false, error: "that is a lot of offers for one day β try again tomorrow" };
+ }
+
+ const standing = await all(
+ `SELECT ${OFFER_COLS} FROM moshpit_offers
+ WHERE offerer_email = ? AND tld = ? AND label = ? AND status IN ('unverified','open','countered','accepted')`,
+ [address, target.tld, target.label],
+ );
+ if (standing.filter((o) => offerIsLive(o, now)).length >= OFFERS_PER_NAME) {
+ return { ok: false, error: "you already have an offer standing on this β wait for an answer, or withdraw it" };
+ }
+
+ const id = mintOfferId();
+ const token = mintVerifyToken();
+ await run(
+ `INSERT INTO moshpit_offers
+ (id, tld, label, kind, amount_usd, lease_months, offerer_email, offerer_user_id, message,
+ holder_user_id, status, verify_token, created_at, updated_at, expires_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?, 'unverified', ?,?,?,?)`,
+ [
+ id, target.tld, target.label, offerKind, amountUsd, leaseMonths,
+ address, userId, normalizeOfferMessage(message),
+ target.holderId, token, now, now, now + OFFER_TTL_MS,
+ ],
+ );
+
+ return { ok: true, offer: await getOffer(id), verifyToken: token };
+}
+
+/**
+ * The click in the confirmation mail: the offer becomes one the holder can see.
+ *
+ * Idempotent, because people click links twice and mail clients fetch them
+ * once before the person does. A second click on an already-verified offer is
+ * a success that changes nothing, not an error about a token being used up.
+ */
+export async function verifyOffer(token, now = Date.now()) {
+ const offer = await offerByVerifyToken(token);
+ if (!offer) return { ok: false, error: "that confirmation link is not one of ours" };
+ if (offer.status !== "unverified") {
+ return offerIsLive(offer, now)
+ ? { ok: true, offer, already: true }
+ : { ok: false, error: "that offer is no longer open", offer };
+ }
+ if (offer.expires_at <= now) return { ok: false, error: "that offer expired before it was confirmed", offer };
+
+ await run(
+ `UPDATE moshpit_offers SET status = 'open', verified_at = ?, updated_at = ? WHERE id = ? AND status = 'unverified'`,
+ [now, now, offer.id],
+ );
+ return { ok: true, offer: await getOffer(offer.id) };
+}
+
+/**
+ * Which side of the conversation an actor is on, or neither.
+ *
+ * The offerer may have no account at all, so possession of the token they were
+ * mailed stands in for one. It is the same proof the verification step already
+ * accepted -- if it is good enough to confirm the address, it is good enough to
+ * answer a counter from it.
+ */
+export function offerActor(offer, { userId = null, token = null } = {}) {
+ if (!offer) return null;
+ if (userId && offer.holder_user_id === userId) return "holder";
+ if (token && offer.verify_token === token) return "offerer";
+ if (userId && offer.offerer_user_id && offer.offerer_user_id === userId) return "offerer";
+ return null;
+}
+
+/**
+ * The holder's answer: take it, refuse it, or name a different number.
+ *
+ * Ownership is checked twice -- against the row, which says who it was put to,
+ * and against the registry, which says who holds the name now. A name that
+ * changed hands between the offer and the answer must not be sold by the
+ * person who used to have it.
+ */
+export async function respondToOffer({ id, userId, action, counterAmount, counterMonths, now = Date.now() }) {
+ const offer = await getOffer(id);
+ if (!offer) return { ok: false, error: "no such offer" };
+ if (offerActor(offer, { userId }) !== "holder") return { ok: false, error: "that offer was not made to you" };
+ if (effectiveStatus(offer, now) !== "open") return { ok: false, error: "that offer is not open" };
+
+ const target = await offerTarget(offer.tld, offer.label);
+ if (!target.ok || target.holderId !== userId) {
+ return { ok: false, error: "you no longer hold this, so it is not yours to answer" };
+ }
+
+ if (action === "reject") {
+ await run(`UPDATE moshpit_offers SET status = 'rejected', updated_at = ? WHERE id = ? AND status = 'open'`, [now, id]);
+ return { ok: true, offer: await getOffer(id) };
+ }
+
+ if (action === "accept") {
+ await run(`UPDATE moshpit_offers SET status = 'accepted', updated_at = ? WHERE id = ? AND status = 'open'`, [now, id]);
+ return { ok: true, offer: await getOffer(id) };
+ }
+
+ if (action === "counter") {
+ const amountUsd = normalizeOfferAmount(counterAmount);
+ if (amountUsd === null) {
+ return { ok: false, error: `a counter has to be a number between $${MIN_OFFER_USD} and $${MAX_OFFER_USD}` };
+ }
+ // Months may be countered too: "not for three months, but I would do a
+ // year" is a real answer, and without it the only reply to a term you do
+ // not like is no.
+ const months = offer.kind === "lease"
+ ? (counterMonths === undefined || counterMonths === null || counterMonths === ""
+ ? offer.lease_months
+ : normalizeLeaseMonths(counterMonths))
+ : null;
+ if (offer.kind === "lease" && months === null) {
+ return { ok: false, error: `a lease runs ${MIN_LEASE_MONTHS} to ${MAX_LEASE_MONTHS} months` };
+ }
+ await run(
+ `UPDATE moshpit_offers SET status = 'countered', counter_amount_usd = ?, counter_months = ?, countered_at = ?, updated_at = ?
+ WHERE id = ? AND status = 'open'`,
+ [amountUsd, months, now, now, id],
+ );
+ return { ok: true, offer: await getOffer(id) };
+ }
+
+ return { ok: false, error: "an answer is accept, reject or counter" };
+}
+
+/** The offerer's answer to a counter, and their way out of an offer they no longer want. */
+export async function answerCounter({ id, userId = null, token = null, action, now = Date.now() }) {
+ const offer = await getOffer(id);
+ if (!offer) return { ok: false, error: "no such offer" };
+ if (offerActor(offer, { userId, token }) !== "offerer") return { ok: false, error: "that offer is not yours" };
+
+ const status = effectiveStatus(offer, now);
+ if (action === "withdraw") {
+ if (!["unverified", "open", "countered"].includes(status)) {
+ return { ok: false, error: "that offer is not open" };
+ }
+ await run(`UPDATE moshpit_offers SET status = 'withdrawn', updated_at = ? WHERE id = ?`, [now, id]);
+ return { ok: true, offer: await getOffer(id) };
+ }
+
+ if (status !== "countered") return { ok: false, error: "there is no counter to answer" };
+ if (action === "accept") {
+ await run(
+ `UPDATE moshpit_offers SET status = 'accepted', updated_at = ? WHERE id = ? AND status = 'countered'`, [now, id]);
+ return { ok: true, offer: await getOffer(id) };
+ }
+ if (action === "reject") {
+ await run(
+ `UPDATE moshpit_offers SET status = 'rejected', updated_at = ? WHERE id = ? AND status = 'countered'`, [now, id]);
+ return { ok: true, offer: await getOffer(id) };
+ }
+ return { ok: false, error: "an answer to a counter is accept or reject" };
+}
+
+export async function listOffersForHolder(userId, { limit = 200 } = {}) {
+ return all(
+ `SELECT ${OFFER_COLS} FROM moshpit_offers
+ WHERE holder_user_id = ? AND status != 'unverified' ORDER BY created_at DESC LIMIT ?`,
+ [userId, limit],
+ );
+}
+
+export async function listOffersForEmail(email, { limit = 200 } = {}) {
+ return all(
+ `SELECT ${OFFER_COLS} FROM moshpit_offers WHERE offerer_email = ? ORDER BY created_at DESC LIMIT ?`,
+ [String(email ?? "").toLowerCase(), limit],
+ );
+}
+
+/** Live offers on one name, for the holder's own page. Never shown to a visitor. */
+export async function listOffersForName(tld, label = "", { now = Date.now() } = {}) {
+ const rows = await all(
+ `SELECT ${OFFER_COLS} FROM moshpit_offers WHERE tld = ? AND label = ? ORDER BY created_at DESC`, [tld, label]);
+ return rows.filter((o) => offerIsLive(o, now));
+}
+
+/**
+ * Record the checkout for an accepted offer.
+ *
+ * The buyer needs an account by now even though they did not need one to ask:
+ * a name has to belong to somebody, and a lease has to be controllable by
+ * somebody. Their id is written onto the offer here, which is the moment an
+ * address becomes an account.
+ */
+export async function openOfferPurchase({ offerId, paymentId, userId, now = Date.now() }) {
+ await run(
+ `UPDATE moshpit_offers SET payment_id = ?, offerer_user_id = ?, updated_at = ? WHERE id = ? AND status = 'accepted'`,
+ [paymentId, userId, now, offerId],
+ );
+ return getOffer(offerId);
+}
+
+/**
+ * Money confirmed: move the name, or start the lease. Idempotent on the payment id.
+ *
+ * Claimed with a conditional UPDATE for the reason settleNamePurchase gives --
+ * CoinPay retries a webhook it never got an ack for, so two deliveries can be
+ * in flight and both read 'accepted' before either write lands. Only the first
+ * claim moves anything.
+ */
+export async function settleOfferPurchase(paymentId, now = Date.now()) {
+ const offer = await get(
+ `SELECT ${OFFER_COLS} FROM moshpit_offers WHERE payment_id = ? AND status = 'accepted'`, [paymentId]);
+ if (!offer) return { ok: false, error: "no accepted offer for that payment" };
+
+ const claimed = await run(
+ `UPDATE moshpit_offers SET status = 'settling', updated_at = ? WHERE id = ? AND status = 'accepted'`,
+ [now, offer.id],
+ );
+ if (!claimed.rowsAffected) return { ok: false, error: "already settled" };
+
+ const terms = agreedTerms(offer);
+ const buyerId = offer.offerer_user_id;
+ if (!buyerId) {
+ await run(`UPDATE moshpit_offers SET status = 'refund_due', updated_at = ? WHERE id = ?`, [now, offer.id]);
+ console.error(`[moshpit] offer ${offer.id} was paid with no buyer account β refund due`);
+ return { ok: false, error: "no account to give it to", refundDue: true };
+ }
+
+ const failed = async (why) => {
+ await run(`UPDATE moshpit_offers SET status = 'refund_due', updated_at = ? WHERE id = ?`, [now, offer.id]);
+ console.error(`[moshpit] offer ${offer.id} paid but not delivered β ${why}. Refund due to ${buyerId}`);
+ return { ok: false, error: why, refundDue: true };
+ };
+
+ if (offer.kind === "lease") {
+ // Checked again here rather than trusted from acceptance: a lease could
+ // have been granted to somebody else in between, and two tenancies on one
+ // name is the state the primary key exists to refuse.
+ if (await activeLease(offer.tld, offer.label, now)) return failed("the name was leased to someone else first");
+ const expiresAt = leaseEndsAt(now, terms.months);
+
+ // A lease can be taken on a name nobody has minted yet -- the operator of
+ // the ending is the one who was asked, and they mint it to let it. The row
+ // has to exist before it can carry a tenant, and it belongs to the holder,
+ // not the tenant: a lease is the one transaction here that does not move
+ // ownership.
+ const existing = await getName(offer.tld, offer.label);
+ if (!existing) {
+ try {
+ await run(`INSERT INTO moshpit_names (tld, label, user_id, target, created_at) VALUES (?,?,?,?,?)`,
+ [offer.tld, offer.label, offer.holder_user_id, null, now]);
+ } catch {
+ return failed("the name was taken before payment settled");
+ }
+ } else if (existing.user_id !== offer.holder_user_id) {
+ return failed("the name changed hands before payment settled");
+ }
+
+ await run(
+ `INSERT OR REPLACE INTO moshpit_leases
+ (tld, label, lessee_user_id, holder_user_id, offer_id, months, amount_usd, starts_at, expires_at, created_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?)`,
+ [offer.tld, offer.label, buyerId, offer.holder_user_id, offer.id, terms.months, terms.amountUsd, now, expiresAt, now],
+ );
+ // The cache the hot path reads. Written in the same breath as the record
+ // above, because a lease that exists in one and not the other is a tenancy
+ // that has been paid for and grants nothing.
+ await run(`UPDATE moshpit_names SET leased_to = ?, leased_until = ? WHERE tld = ? AND label = ?`,
+ [buyerId, expiresAt, offer.tld, offer.label]);
+ await run(`UPDATE moshpit_offers SET status = 'paid', updated_at = ? WHERE id = ?`, [now, offer.id]);
+ await logAction(offer.tld, buyerId, `leased:${offer.label}`);
+ return { ok: true, kind: "lease", tld: offer.tld, label: offer.label, userId: buyerId, expiresAt };
+ }
+
+ if (offer.label) {
+ const existing = await getName(offer.tld, offer.label);
+ if (existing) {
+ // The seller must still be the seller. If the name moved between
+ // acceptance and confirmation, this buyer is paying its previous holder
+ // for something that is no longer theirs.
+ if (existing.user_id !== offer.holder_user_id) return failed("the name changed hands before payment settled");
+ await handOverName(offer.tld, offer.label, buyerId);
+ } else {
+ try {
+ await run(`INSERT INTO moshpit_names (tld, label, user_id, target, created_at) VALUES (?,?,?,?,?)`,
+ [offer.tld, offer.label, buyerId, null, now]);
+ } catch {
+ return failed("the name was taken before payment settled");
+ }
+ }
+ await run(`UPDATE moshpit_offers SET status = 'paid', updated_at = ? WHERE id = ?`, [now, offer.id]);
+ await logAction(offer.tld, buyerId, `bought:${offer.label}`);
+ await closeOtherOffers(offer, now);
+ return { ok: true, kind: "buy", tld: offer.tld, label: offer.label, userId: buyerId };
+ }
+
+ // An ending changing hands. Conditional on the seller still holding it, for
+ // the same reason as above.
+ const moved = await run(
+ `UPDATE moshpit_tlds SET user_id = ?, owner_email = NULL WHERE tld = ? AND user_id = ?`,
+ [buyerId, offer.tld, offer.holder_user_id],
+ );
+ if (!moved.rowsAffected) return failed("the ending changed hands before payment settled");
+
+ await run(`UPDATE moshpit_offers SET status = 'paid', updated_at = ? WHERE id = ?`, [now, offer.id]);
+ await logAction(offer.tld, buyerId, "bought");
+ await closeOtherOffers(offer, now);
+ return { ok: true, kind: "buy", tld: offer.tld, label: "", userId: buyerId };
+}
+
+/**
+ * Move a name to its buyer, and leave nothing of the seller's on it.
+ *
+ * The same list releaseName clears, and for the same reason: a pin, a record,
+ * a twin or a contact that survives a sale belongs to the person who just sold
+ * the name. The contact is the one with a consequence outside this database --
+ * its guard address forwards mail at our domain, so an inherited one would
+ * deliver the buyer's mail to the seller.
+ */
+async function handOverName(tld, label, buyerId) {
+ await revokeContactAlias(await getContactPrivate(tld, label));
+ await run(`DELETE FROM moshpit_contacts WHERE tld = ? AND label = ?`, [tld, label]);
+ await run(`DELETE FROM moshpit_name_pins WHERE tld = ? AND label = ?`, [tld, label]);
+ await run(`DELETE FROM moshpit_records WHERE tld = ? AND label = ?`, [tld, label]);
+ await run(`DELETE FROM moshpit_twins WHERE tld = ? AND label = ?`, [tld, label]);
+ // The target goes too. It names a server the seller runs, and leaving it
+ // would point the buyer's new name at the seller's machine.
+ await run(
+ `UPDATE moshpit_names SET user_id = ?, target = NULL, feed_url = NULL, feed_kind = NULL,
+ leased_to = NULL, leased_until = NULL WHERE tld = ? AND label = ?`,
+ [buyerId, tld, label],
+ );
+}
+
+/**
+ * Give a name back at the end of its term.
+ *
+ * effectiveStatus and the `leased_until` check are what make a lease end on
+ * time; this is what makes the name look like it. The tenant's target, feed,
+ * records and keys go with them, because the alternative is the holder getting
+ * their name back still serving somebody else's site under it -- for as long as
+ * they do not happen to look.
+ *
+ * Content is left alone deliberately. It is the one thing here that is written
+ * rather than pointed at, and deleting a tenant's posts because their lease
+ * lapsed destroys work rather than unlinking it. It stops being served the
+ * moment the target does, and the holder can clear it.
+ */
+export async function endExpiredLeases(now = Date.now(), limit = 200) {
+ const due = await all(
+ `SELECT ${LEASE_COLS} FROM moshpit_leases WHERE reverted_at IS NULL AND expires_at <= ? LIMIT ?`,
+ [now, limit],
+ );
+
+ for (const lease of due) {
+ await run(`DELETE FROM moshpit_records WHERE tld = ? AND label = ?`, [lease.tld, lease.label]);
+ await run(`DELETE FROM moshpit_name_pins WHERE tld = ? AND label = ?`, [lease.tld, lease.label]);
+ await run(
+ `UPDATE moshpit_names SET target = NULL, feed_url = NULL, feed_kind = NULL, leased_to = NULL, leased_until = NULL
+ WHERE tld = ? AND label = ?`,
+ [lease.tld, lease.label],
+ );
+ await run(`UPDATE moshpit_leases SET reverted_at = ? WHERE tld = ? AND label = ?`, [now, lease.tld, lease.label]);
+ await logAction(lease.tld, lease.holder_user_id, `unleased:${lease.label}`);
+ }
+
+ return { reverted: due.length };
+}
+
+/**
+ * Everyone else who was still asking about this is asking about something sold.
+ *
+ * Closed rather than left open, because an offer that cannot be accepted is
+ * worse than no offer: the holder would be looking at numbers for a name they
+ * no longer have, and the people who made them would be waiting on an answer
+ * that can never come.
+ */
+async function closeOtherOffers(sold, now) {
+ await run(
+ `UPDATE moshpit_offers SET status = 'rejected', updated_at = ?
+ WHERE tld = ? AND label = ? AND id != ? AND status IN ('unverified','open','countered')`,
+ [now, sold.tld, sold.label, sold.id],
+ );
+}
+
+/**
+ * Write down what the clock already decided.
+ *
+ * effectiveStatus() is what makes an offer expired; this only makes the column
+ * agree, so a listing query can filter on it. Nothing depends on it running.
+ */
+/**
+ * Where to mail the holder about an offer.
+ *
+ * Their account address, not the guard address a contact publishes. The two are
+ * for opposite directions: a guard address is how a stranger reaches them
+ * without learning who they are, and this is the registry telling its own user
+ * something about their account. Routing our own mail through the forwarder
+ * would make it undeliverable exactly when it matters -- for a holder who has
+ * no contact set, which is most of them.
+ */
+export async function userEmail(userId) {
+ const row = await get(`SELECT email FROM users WHERE id = ?`, [userId]);
+ return row?.email ?? null;
+}
+
+export async function expireOffers(now = Date.now()) {
+ const result = await run(
+ `UPDATE moshpit_offers SET status = 'expired', updated_at = ?
+ WHERE expires_at <= ? AND status IN ('unverified','open','countered')`,
+ [now, now],
+ );
+ return { expired: Number(result.rowsAffected ?? 0) };
+}
diff --git a/apps/pwa/src/routes/credits.mjs b/apps/pwa/src/routes/credits.mjs
index 44fc0a6..5567a58 100644
--- a/apps/pwa/src/routes/credits.mjs
+++ b/apps/pwa/src/routes/credits.mjs
@@ -6,7 +6,7 @@ import { id } from "../lib/crypto.mjs";
import { grant } from "../lib/credits.mjs";
import { verifySignature } from "../lib/signature.mjs";
import { requireAuth } from "../lib/session.mjs";
-import { settleNamePurchase, settleTldPurchase } from "../moshpit.mjs";
+import { settleNamePurchase, settleOfferPurchase, settleTldPurchase } from "../moshpit.mjs";
export const creditsRouter = Router();
@@ -75,6 +75,10 @@ creditsRouter.post("/webhooks/coinpay", async (req, res) => {
// An ending is a different table from a name, and this is the one webhook
// URL CoinPay is configured with β whichever row the id belongs to settles.
await settleTldPurchase(payId).catch((e) => console.error("[moshpit] tld settle failed:", e.message));
+ // And an accepted offer, which is a third table again β a negotiated sale
+ // or a lease rather than a listed price. Same reasoning: one webhook URL,
+ // and whichever row owns this id is the one that settles.
+ await settleOfferPurchase(payId).catch((e) => console.error("[moshpit] offer settle failed:", e.message));
const p = await get(`SELECT * FROM credit_purchases WHERE id = ? AND status = 'pending'`, [payId]);
if (p) {
diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs
index 93ee9b8..469d08c 100644
--- a/apps/pwa/src/routes/moshpit.mjs
+++ b/apps/pwa/src/routes/moshpit.mjs
@@ -43,6 +43,11 @@ import { balance } from "../lib/credits.mjs";
import { resolverConfig } from "../lib/moshpit-resolvers.mjs";
import { landingFor } from "../lib/moshpit-landing.mjs";
import { FEED_KINDS, loadFeed } from "../lib/feed.mjs";
+import {
+ sendOfferAnswer,
+ sendOfferToHolder,
+ sendOfferVerification,
+} from "../lib/moshpit-offer-mail.mjs";
import { FEED_CSS, feedPage, feedUnavailable } from "../lib/moshpit-feed-page.mjs";
import { CONTENT_KINDS, MAX_BATCH } from "../lib/moshpit-content.mjs";
import {
@@ -55,8 +60,11 @@ import {
MAX_BODY_BYTES, ORIGIN_TIMEOUT_MS, checkTarget, fetchOrigin, fetchOriginTls, forwardableHeaders, tlsRedirect,
} from "../lib/moshpit-gateway.mjs";
import {
+ activeLease,
addPin,
+ agreedTerms,
addRecord,
+ answerCounter,
bumpLink,
clearAlias,
clearExempt,
@@ -75,12 +83,15 @@ import {
DEFAULT_TLD_PRICE_USD,
DEFAULT_VISIBILITY,
ENDING_PRICE_USD,
+ describeOffer,
deleteContent,
deleteLink,
+ effectiveStatus,
getContactPrivate,
getContent,
getLink,
getName,
+ getOffer,
getTld,
getTldWithPrice,
guardAddress,
@@ -89,9 +100,11 @@ import {
listContent,
listAllNames,
listExempt,
+ listLeasesForUser,
listLinks,
listNames,
listNamesForUser,
+ listOffersForHolder,
listPins,
listRecordNames,
listRecords,
@@ -103,12 +116,16 @@ import {
MAX_LISTING_PRICE_USD,
MAX_TTL,
MIN_TTL,
+ makeOffer,
normalizeLabel,
normalizeMode,
normalizePinKind,
normalizeSlug,
normalizeTld,
+ offerActor,
+ offerIsLive,
openNamePurchase,
+ openOfferPurchase,
parseMoshpitName,
PIN_KINDS,
pinsForName,
@@ -129,6 +146,7 @@ import {
resolutionPreference,
resolveMoshpitName,
retryContactAlias,
+ respondToOffer,
searchTlds,
setAlias,
setContact,
@@ -141,6 +159,8 @@ import {
summarizeBulkClaim,
tldLog,
tldRejection,
+ userEmail,
+ verifyOffer,
zoneLine,
TWIN_PRICE_USD,
availableTwins,
@@ -1510,6 +1530,8 @@ function endingDirectory({ tld, owner, names, aliasesTo = [], sameOwner = [], su
` : ""}
+ ${offerBox({
+ tld,
+ label,
+ // Whoever would be selling. A name somebody holds is theirs to sell; one
+ // nobody has minted is the ending operator's to mint and sell, which is the
+ // case the old page turned away with "not for sale".
+ holderId: resolution.name_registered
+ ? names.find((n) => n.label === label)?.user_id ?? null
+ : owner?.user_id ?? null,
+ leasedUntil: resolution.leased_until ?? null,
+ user,
+ req,
+ })}
+
${contactCard(contact)}
@@ -1890,6 +1926,438 @@ const contactOut = (row) => ({
updated_at: row.updated_at,
});
+/* ---- offers on a parked name ---- */
+
+/**
+ * The form on a parked page.
+ *
+ * This is the whole point of the feature, so it is worth being clear about what
+ * it replaces: every branch below used to be a sentence that ended the
+ * conversation. "This name is claimed but does not point anywhere yet."
+ * ".eggs is not for sale." Both are true and both leave a person who wants the
+ * name with nowhere to go, while the holder never hears that anyone asked.
+ *
+ * No account needed. Requiring one first is asking a stranger to sign up before
+ * they may say what they would pay, on the one page whose job is converting
+ * that stranger. The address is confirmed by mail instead, which is also what
+ * keeps the form from being a way to write to every holder in the registry.
+ */
+function offerBox({ tld, label, holderId, user, req, leasedUntil = null }) {
+ if (!holderId) return "";
+ if (user && user.id === holderId) return "";
+
+ const name = label ? `${label}.${tld}` : `.${tld}`;
+ if (leasedUntil && leasedUntil > Date.now()) {
+ return `
+
Make an offer
+
+ ${esc(name)} is leased until
+ ${esc(new Date(leasedUntil).toISOString().slice(0, 10))} β it cannot be sold or
+ re-let until then. Worth asking again after that date.
+
`;
+ }
+
+ // Leases are names only. See the migration: a name minted under a leased
+ // ending would outlive the lease, so an ending is bought rather than rented.
+ const canLease = Boolean(label);
+
+ return `
+
Make an offer
+
+ Nobody has put a price on ${esc(name)}, which is not the same as it not being
+ for sale. Say what it is worth to you and the holder decides. Private β only they see it.
+
+
+ `;
+}
+
+/**
+ * Show the months box only when the offer is a lease.
+ *
+ * The one script on this form, and it does what the `hidden` attribute cannot
+ * do on its own. With the script blocked the field is simply hidden and a buy
+ * offer still submits correctly, which is the behaviour that matters.
+ */
+const OFFER_FORM_JS = `
+for (const form of document.querySelectorAll("form.pit-offer")) {
+ const kind = form.querySelector("[data-offer-kind]");
+ const months = form.querySelector("[data-offer-months]");
+ if (!kind || !months) continue;
+ const sync = () => { months.hidden = kind.value !== "lease"; };
+ kind.addEventListener("change", sync);
+ sync();
+}`;
+
+/**
+ * POST /pit/offer β a stranger says what a name is worth to them.
+ *
+ * Unauthenticated by design, and CSRF-guarded all the same: the token is a
+ * double-submit cookie that every visitor gets, signed in or not.
+ */
+moshpitRouter.post("/pit/offer", async (req, res) => {
+ const scope = contactScope(req.body?.name);
+ const backTo = scope
+ ? `/n/${encodeURIComponent(scope.label ? `${scope.label}.${scope.tld}` : scope.tld)}`
+ : "/pit";
+ if (!scope) return res.redirect(`/pit?err=${encodeURIComponent("which name? that is not one.")}`);
+
+ const result = await makeOffer({
+ tld: scope.tld, label: scope.label,
+ kind: req.body?.kind, amount: req.body?.amount, months: req.body?.months,
+ email: req.body?.email, message: req.body?.message,
+ userId: req.user?.id ?? null,
+ });
+ if (!result.ok) return res.redirect(`${backTo}?err=${encodeURIComponent(result.error)}`);
+
+ // The confirmation is the only thing standing between this form and the
+ // holder's inbox, so a send that fails has to be visible rather than leaving
+ // the offerer waiting for a mail that is not coming.
+ const url = `${config.pitOrigin}/offers/verify/${result.verifyToken}`;
+ const sent = await sendOfferVerification(result.offer, url);
+ return res.redirect(`${backTo}?${new URLSearchParams(sent.ok
+ ? { ok: `Offer recorded. Check ${result.offer.offerer_email} and click the link β the holder hears nothing until you do.` }
+ : { err: "the offer is saved, but the confirmation mail would not send β try again shortly" }).toString()}`);
+});
+
+/** GET /offers/verify/:token β the click that lets the holder see it. */
+moshpitRouter.get("/offers/verify/:token", async (req, res) => {
+ const result = await verifyOffer(req.params.token);
+ if (!result.ok) {
+ return res.status(400).send(page({
+ title: "moshpit βΈ offer",
+ head: ``,
+ body: `${appBar(req.user, 0, req.csrfToken)}
+
+
+${footer}`,
+ }));
+ }
+
+ const offer = result.offer;
+ // Told once, on the first confirmation. A second click is a person checking
+ // the link worked, not a reason to mail the holder again.
+ if (!result.already) {
+ const holderEmail = await userEmail(offer.holder_user_id);
+ if (holderEmail) {
+ await sendOfferToHolder(offer, holderEmail, `${config.pitOrigin}/pit/offers`);
+ } else {
+ console.error(`[moshpit] offer ${offer.id} verified but holder ${offer.holder_user_id} has no address`);
+ }
+ }
+
+ const name = offer.label ? `${offer.label}.${offer.tld}` : `.${offer.tld}`;
+ res.status(200).send(page({
+ title: "moshpit βΈ offer sent",
+ head: ``,
+ body: `${appBar(req.user, 0, req.csrfToken)}
+
+
It is with the holder
+
${esc(describeOffer(offer))}.
+
+ They can accept, refuse, or name a different number. You will hear either way at
+ ${esc(offer.offerer_email)}. Nothing is charged unless you agree on something.
+
+${footer}`,
+ }));
+});
+
+/**
+ * GET /offers/:id β the offerer's side of the conversation.
+ *
+ * Reached by the link mailed to them, which carries the token that stands in
+ * for an account they may not have. The holder never lands here; their view is
+ * /pit/offers, because these are two different jobs -- one person is deciding,
+ * the other is waiting and occasionally paying.
+ */
+moshpitRouter.get("/offers/:id", async (req, res) => {
+ const offer = await getOffer(req.params.id);
+ const token = req.query.t ? String(req.query.t) : null;
+ if (!offer || offerActor(offer, { userId: req.user?.id ?? null, token }) !== "offerer") {
+ return res.status(404).send(page({
+ title: "moshpit βΈ offer",
+ head: ``,
+ body: `${appBar(req.user, 0, req.csrfToken)}
+
+
No such offer
+
That link is not one of ours, or the offer is gone.
` : "";
+
+ // Sorted so the ones needing an answer are the ones you see. A page that
+ // leads with six months of rejections buries the one that is waiting.
+ const live = offers.filter((o) => offerIsLive(o));
+ const done = offers.filter((o) => !offerIsLive(o)).slice(0, 25);
+
+ const body = !req.user
+ ? `
Sign in to see what people have offered for your names.
+ Anyone can offer on a name you hold, whether or not you put a price on it. Only you see them.
+ Accept, refuse, or name a different number β nothing moves until the money confirms.
+
+ ${pitTabs("offers")}
+ ${msg}
+ ${body}
+${footer}`,
+ }));
+});
+
+/** POST /pit/offers/:id β the holder answers. */
+moshpitRouter.post("/pit/offers/:id", requireAuth, async (req, res) => {
+ const action = String(req.body?.action ?? "");
+ const result = await respondToOffer({
+ id: req.params.id, userId: req.user.id, action,
+ counterAmount: req.body?.counter_amount, counterMonths: req.body?.counter_months,
+ });
+ if (!result.ok) return res.redirect(`/pit/offers?err=${encodeURIComponent(result.error)}`);
+
+ // The offerer is told by mail, because they may have no account here at all
+ // and this page is the only place the answer exists otherwise.
+ const url = `${config.pitOrigin}/offers/${result.offer.id}?t=${encodeURIComponent(result.offer.verify_token)}`;
+ await sendOfferAnswer(result.offer, url);
+
+ const said = action === "accept" ? "Accepted. They have been sent a bill β the name moves when it confirms."
+ : action === "counter" ? "Countered. They have been told."
+ : "Refused. They have been told.";
+ res.redirect(`/pit/offers?ok=${encodeURIComponent(said)}`);
+});
+
/* ---- the market ---- */
/** TLDs other people hold. `?for_sale=1` narrows to the buyable ones. */
@@ -2237,6 +2705,10 @@ const PIT_CSS = `
/* A failure stated inline, next to the thing that failed, rather than in the
banner at the top β the contact tab can have one row broken and the rest fine. */
.pit-fail{color:var(--danger)}
+/* The offer form stacks its two rows rather than wrapping into one long line:
+ it carries six fields on a page that is mostly read on a phone. */
+.pit-offer{margin:10px 0 0}
+.pit-offer .pit-row{margin:8px 0 0;flex-wrap:wrap}
.pit-defaults{display:flex;gap:12px;flex-wrap:wrap;margin:10px 0 4px}
.pit-defaults label{display:flex;align-items:center;gap:6px;font-family:var(--mono);
font-size:.72rem;letter-spacing:.06em;color:var(--dim);white-space:nowrap}
@@ -2341,6 +2813,8 @@ const pitTabs = (active, counts = null, query = "") => {
counts?.theirs === undefined ? "" : `${counts.theirs}${counts.forSale ? ` Β· ${counts.forSale} for sale` : ""}`}
DNS Records${
counts?.records === undefined ? "" : `${counts.records}`}
+ Offers${
+ counts?.offers === undefined ? "" : `${counts.offers}`}ContactBulk publishUse it (DNS)
diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs
index f1198f4..b2362c1 100644
--- a/apps/pwa/src/server.mjs
+++ b/apps/pwa/src/server.mjs
@@ -17,6 +17,7 @@ import { settingsSyncRouter } from "./routes/settings-sync.mjs";
import { moshpitRouter } from "./routes/moshpit.mjs";
import { socialsRouter } from "./routes/socials.mjs";
import { MAX_BATCH, MAX_PUBLISH_BYTES } from "./lib/moshpit-content.mjs";
+import { endExpiredLeases, expireOffers } from "./moshpit.mjs";
const app = express();
app.disable("x-powered-by");
@@ -97,8 +98,38 @@ app.use((err, req, res, _next) => {
res.status(500).type("html").send(`
500
a bug got in. (there are no bugs, only features.)
`);
});
+/**
+ * How often the clock's decisions get written down.
+ *
+ * Both sweeps are tidy-up: an offer is expired because its date passed and a
+ * lease is over because its term ran out, and every reader works that out for
+ * itself. What the sweep adds is the part a reader cannot do, because it is a
+ * write -- taking the tenant's target, records and keys back off a name whose
+ * lease has ended, so the name stops serving their site rather than merely
+ * stopping resolving as theirs.
+ *
+ * Hourly, and once at boot. Nothing here is time-critical to the minute, and a
+ * lease that ends at 3am should not need somebody awake.
+ */
+const SWEEP_MS = 60 * 60 * 1000;
+
+async function sweep() {
+ try {
+ const { expired } = await expireOffers();
+ const { reverted } = await endExpiredLeases();
+ if (expired || reverted) console.log(`π§Ή moshpit: ${expired} offers expired, ${reverted} leases reverted`);
+ } catch (e) {
+ // Logged and swallowed. A sweep that throws must not take the process with
+ // it -- everything it does, the read-time checks already do correctly.
+ console.error("moshpit sweep failed:", e?.message ?? e);
+ }
+}
+
async function main() {
await migrate();
+ await sweep();
+ // Unref'd so it never holds the process open on its own.
+ setInterval(sweep, SWEEP_MS).unref();
app.listen(config.port, () => console.log(`π€ app.moshcode.sh on :${config.port} (${config.env}) β ${config.origin}`));
}
main().catch((e) => { console.error("boot failed:", e); process.exit(1); });
diff --git a/apps/pwa/test/moshpit-offers.test.mjs b/apps/pwa/test/moshpit-offers.test.mjs
new file mode 100644
index 0000000..99a0bb8
--- /dev/null
+++ b/apps/pwa/test/moshpit-offers.test.mjs
@@ -0,0 +1,462 @@
+// Offers on a parked name, and the leases some of them become.
+//
+// The behaviour under test is mostly about what does NOT happen: an offer does
+// not reach a holder until the address is confirmed, an accepted offer does not
+// move a name until money confirms, a tenant does not keep control after their
+// term, and nobody but the holder ever sees what was offered.
+//
+// Same harness as moshpit-contact.test.mjs: the real router against a throwaway
+// libsql file, skipped cleanly when the PWA deps are not installed. The pure
+// rules run either way.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+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";
+
+import {
+ agreedTerms,
+ describeOffer,
+ effectiveStatus,
+ leaseEndsAt,
+ leaseIsActive,
+ normalizeLeaseMonths,
+ normalizeOfferAmount,
+ normalizeOfferKind,
+ offerIsLive,
+} from "../src/lib/moshpit-offer.mjs";
+
+const require = createRequire(import.meta.url);
+let deps = null;
+try {
+ deps = { express: require("express") };
+} catch {
+ deps = null;
+}
+
+const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-offers-test-"));
+process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
+process.env.SESSION_SECRET = "test-secret";
+process.env.PUBLIC_ORIGIN = "https://app.example.test";
+process.env.PIT_ORIGIN = "https://pit.example.test";
+delete process.env.RESEND_API_KEY;
+delete process.env.FORWARDEMAIL_API_KEY;
+
+/* ---- the rules, which need nothing ---- */
+
+test("money is read the way people type it, and rounded to cents", () => {
+ assert.equal(normalizeOfferAmount("$1,200"), 1200);
+ assert.equal(normalizeOfferAmount(" 99.99 "), 99.99);
+ assert.equal(normalizeOfferAmount("2500"), 2500);
+});
+
+test("an offer below the floor or beyond the bound is not an offer", () => {
+ // The floor is not a view about what a name is worth. It is what makes
+ // "offer nothing on all 18,000 endings" cost something to say.
+ for (const bad of ["", "0", "0.5", "-100", "abc", "1e400", "2000000000"]) {
+ assert.equal(normalizeOfferAmount(bad), null, `expected ${JSON.stringify(bad)} to be refused`);
+ }
+});
+
+test("a term is whole months inside the bounds", () => {
+ assert.equal(normalizeLeaseMonths("12"), 12);
+ assert.equal(normalizeLeaseMonths("1"), 1);
+ assert.equal(normalizeLeaseMonths("60"), 60);
+ for (const bad of ["0", "61", "1.5", "-3", "", "twelve"]) {
+ assert.equal(normalizeLeaseMonths(bad), null, `expected ${JSON.stringify(bad)} to be refused`);
+ }
+});
+
+test("an offer is to buy or to lease, and nothing else", () => {
+ assert.equal(normalizeOfferKind("BUY"), "buy");
+ assert.equal(normalizeOfferKind("lease"), "lease");
+ assert.equal(normalizeOfferKind("rent"), null);
+});
+
+test("a term runs in calendar months, and the short-month case picks a side", () => {
+ // One month from 31 January is 28 February. The alternative is rolling into
+ // March, which quietly hands the tenant a day they did not pay for.
+ const jan31 = Date.UTC(2026, 0, 31, 12, 0, 0);
+ assert.equal(new Date(leaseEndsAt(jan31, 1)).toISOString().slice(0, 10), "2026-02-28");
+ const mar15 = Date.UTC(2026, 2, 15, 9, 30, 0);
+ assert.equal(new Date(leaseEndsAt(mar15, 12)).toISOString().slice(0, 10), "2027-03-15");
+ assert.equal(new Date(leaseEndsAt(mar15, 3)).toISOString().slice(0, 10), "2026-06-15");
+});
+
+test("a counter is what is operative once it exists", () => {
+ const offer = { kind: "lease", amount_usd: 100, lease_months: 3, counter_amount_usd: 250, counter_months: 12 };
+ assert.deepEqual(agreedTerms(offer), { amountUsd: 250, months: 12, countered: true });
+
+ const plain = { kind: "buy", amount_usd: 100, lease_months: null, counter_amount_usd: null, counter_months: null };
+ assert.deepEqual(agreedTerms(plain), { amountUsd: 100, months: null, countered: false });
+});
+
+test("an offer expires by the clock, not by a sweep having run", () => {
+ const now = Date.now();
+ const stale = { status: "open", expires_at: now - 1000 };
+ assert.equal(effectiveStatus(stale, now), "expired");
+ assert.equal(offerIsLive(stale, now), false);
+
+ // Accepted is exempt: from there the only thing outstanding is a payment, and
+ // a bill does not stop being owed because thirty days went by.
+ const agreed = { status: "accepted", expires_at: now - 1000 };
+ assert.equal(effectiveStatus(agreed, now), "accepted");
+ assert.equal(offerIsLive(agreed, now), true);
+});
+
+test("a lease is active only between its dates", () => {
+ const now = Date.now();
+ assert.equal(leaseIsActive({ starts_at: now - 1000, expires_at: now + 1000 }, now), true);
+ assert.equal(leaseIsActive({ starts_at: now - 2000, expires_at: now - 1000 }, now), false);
+ assert.equal(leaseIsActive({ starts_at: now + 1000, expires_at: now + 2000 }, now), false);
+ assert.equal(leaseIsActive(null, now), false);
+});
+
+test("an offer reads as one line, money first", () => {
+ assert.match(describeOffer({ kind: "buy", tld: "eggs", label: "blue", amount_usd: 2500 }), /^\$2,500 to buy blue\.eggs$/);
+ assert.match(
+ describeOffer({ kind: "lease", tld: "eggs", label: "blue", amount_usd: 300, lease_months: 1 }),
+ /\$300 to lease blue\.eggs for 1 month$/,
+ );
+ assert.match(describeOffer({ kind: "buy", tld: "eggs", label: "", amount_usd: 40 }), /to buy \.eggs$/);
+});
+
+/* ---- storage and routes ---- */
+
+async function boot() {
+ const { migrate } = await import("../src/migrate.mjs");
+ await migrate();
+ const { run, get, db } = await import("../src/db.mjs");
+ const { moshpitRouter } = await import("../src/routes/moshpit.mjs");
+ const moshpit = await import("../src/moshpit.mjs");
+
+ await run(`INSERT OR REPLACE INTO users (id,email,created_at) VALUES ('holder','holder@example.com',1)`);
+ await run(`INSERT OR REPLACE INTO users (id,email,created_at) VALUES ('buyer','buyer@example.com',1)`);
+ await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,created_at) VALUES ('eggs','holder','holder@example.com',1)`);
+ // Held and pointed somewhere: the "claimed but does not point anywhere" case
+ // the offer form exists to replace.
+ await run(`INSERT INTO moshpit_names (tld,label,user_id,target,created_at) VALUES ('eggs','blue','holder',NULL,1)`);
+ await run(`INSERT INTO moshpit_names (tld,label,user_id,target,created_at) VALUES ('eggs','rent','holder',NULL,1)`);
+
+ const app = deps.express();
+ app.use(deps.express.json());
+ app.use((req, _res, next) => { req.csrfToken = () => "csrf"; next(); });
+ app.use((req, _res, next) => {
+ const id = req.headers["x-test-user"];
+ if (id) req.user = { id, email: `${id}@example.com` };
+ next();
+ });
+ 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 getHtml = async (p, userId = null) => {
+ const res = await fetch(`${base}${p}`, { headers: userId ? { "x-test-user": userId } : {} });
+ return { status: res.status, body: await res.text() };
+ };
+
+ // Paying, without CoinPay: the two writes the checkout and the webhook would
+ // have made, in the order they make them.
+ const pay = async (offerId, buyerId, paymentId) => {
+ await moshpit.openOfferPurchase({ offerId, paymentId, userId: buyerId });
+ return moshpit.settleOfferPurchase(paymentId);
+ };
+
+ return { server, db, run, get, getHtml, moshpit, pay };
+}
+
+let booted = null;
+const app = () => (booted ||= boot());
+
+test.after(() => {
+ if (!booted) return;
+ booted.then(({ server, db }) => { server.close(); db.close?.(); })
+ .finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } });
+});
+
+const skip = { skip: !deps && "apps/pwa deps not installed" };
+
+test("an offer does not reach the holder until the address is confirmed", skip, async () => {
+ const { moshpit } = await app();
+ const made = await moshpit.makeOffer({
+ tld: "eggs", label: "blue", kind: "buy", amount: "2500", email: "Buyer ",
+ message: "I run a paint shop",
+ });
+
+ assert.equal(made.ok, true);
+ assert.equal(made.offer.status, "unverified");
+ assert.equal(made.offer.amount_usd, 2500);
+ assert.equal(made.offer.offerer_email, "buyer@example.com");
+ assert.equal(made.offer.holder_user_id, "holder");
+
+ // This is the anti-spam property, and it is worth asserting rather than
+ // trusting: an unverified offer is not merely unsent, it is not on the
+ // holder's page at all.
+ const theirs = await moshpit.listOffersForHolder("holder");
+ assert.equal(theirs.length, 0);
+});
+
+test("confirming it is what puts it in front of the holder, and twice is fine", skip, async () => {
+ const { moshpit } = await app();
+ const [offer] = await moshpit.listOffersForEmail("buyer@example.com");
+
+ const first = await moshpit.verifyOffer(offer.verify_token);
+ assert.equal(first.ok, true);
+ assert.equal(first.offer.status, "open");
+ assert.ok(first.offer.verified_at);
+
+ // Mail clients fetch links, and people click twice. A second confirmation is
+ // a success that changes nothing, not a used-up token.
+ const second = await moshpit.verifyOffer(offer.verify_token);
+ assert.equal(second.ok, true);
+ assert.equal(second.already, true);
+
+ const theirs = await moshpit.listOffersForHolder("holder");
+ assert.equal(theirs.length, 1);
+});
+
+test("a confirmation link that is not ours is refused", skip, async () => {
+ const { moshpit } = await app();
+ const result = await moshpit.verifyOffer("not-a-real-token");
+ assert.equal(result.ok, false);
+});
+
+test("you cannot offer on what you already hold", skip, async () => {
+ const { moshpit } = await app();
+ const result = await moshpit.makeOffer({
+ tld: "eggs", label: "blue", kind: "buy", amount: "10", email: "holder@example.com", userId: "holder",
+ });
+ assert.equal(result.ok, false);
+ assert.match(result.error, /hold this name already/);
+});
+
+test("one standing offer per name per address β a second is a counter, not a new offer", skip, async () => {
+ const { moshpit } = await app();
+ const again = await moshpit.makeOffer({
+ tld: "eggs", label: "blue", kind: "buy", amount: "3000", email: "buyer@example.com",
+ });
+ assert.equal(again.ok, false);
+ assert.match(again.error, /already have an offer standing/);
+});
+
+test("an unminted name is offerable, because the operator can mint it", skip, async () => {
+ const { moshpit } = await app();
+ // The case the old page turned away with ".eggs is not for sale" β true, and
+ // not the same as "you cannot have it".
+ const target = await moshpit.offerTarget("eggs", "unminted");
+ assert.equal(target.ok, true);
+ assert.equal(target.registered, false);
+ assert.equal(target.holderId, "holder");
+});
+
+test("an ending nobody holds is claimed, not offered on", skip, async () => {
+ const { moshpit } = await app();
+ const target = await moshpit.offerTarget("nobodyholdsthis", "");
+ assert.equal(target.ok, false);
+ assert.equal(target.claimable, true);
+});
+
+test("an ending can be bought and not rented", skip, async () => {
+ const { moshpit } = await app();
+ const result = await moshpit.makeOffer({
+ tld: "eggs", label: "", kind: "lease", amount: "500", months: "12", email: "someone@example.com",
+ });
+ assert.equal(result.ok, false);
+ assert.match(result.error, /bought, not leased/);
+});
+
+test("the holder can counter, and the counter is what gets paid", skip, async () => {
+ const { moshpit } = await app();
+ const [offer] = await moshpit.listOffersForHolder("holder");
+
+ const countered = await moshpit.respondToOffer({
+ id: offer.id, userId: "holder", action: "counter", counterAmount: "4000",
+ });
+ assert.equal(countered.ok, true);
+ assert.equal(countered.offer.status, "countered");
+ assert.equal(agreedTerms(countered.offer).amountUsd, 4000);
+ // The original is kept beside it: a negotiation that rewrites its own history
+ // is one neither side can check.
+ assert.equal(countered.offer.amount_usd, 2500);
+
+ const taken = await moshpit.answerCounter({
+ id: offer.id, token: offer.verify_token, action: "accept",
+ });
+ assert.equal(taken.ok, true);
+ assert.equal(taken.offer.status, "accepted");
+});
+
+test("only the holder answers an offer, and only the offerer answers a counter", skip, async () => {
+ const { moshpit } = await app();
+ const [offer] = await moshpit.listOffersForHolder("holder");
+
+ const notYours = await moshpit.respondToOffer({ id: offer.id, userId: "buyer", action: "accept" });
+ assert.equal(notYours.ok, false);
+ assert.match(notYours.error, /not made to you/);
+
+ const noToken = await moshpit.answerCounter({ id: offer.id, token: "wrong", action: "withdraw" });
+ assert.equal(noToken.ok, false);
+});
+
+test("paying transfers the name, and leaves nothing of the seller's on it", skip, async () => {
+ const { moshpit, run, get, pay } = await app();
+ const [offer] = await moshpit.listOffersForHolder("holder");
+
+ // The seller's fingerprints: a contact with a forwarding alias, a record, a
+ // key, and a target. All four belong to the person selling the name.
+ await moshpit.setContact({ tld: "eggs", label: "blue", userId: "holder", email: "holder@example.com" });
+ const pointedBySeller = await moshpit.setNameTarget({
+ tld: "eggs", label: "blue", userId: "holder", target: "2001:db8::9",
+ });
+ assert.equal(pointedBySeller.ok, true, JSON.stringify(pointedBySeller));
+ await run(`INSERT INTO moshpit_records (tld,label,type,value,ttl,priority,user_id,created_at)
+ VALUES ('eggs','blue','TXT','hello',300,NULL,'holder',1)`);
+
+ const settled = await pay(offer.id, "buyer", "pay_transfer_1");
+ assert.equal(settled.ok, true);
+ assert.equal(settled.kind, "buy");
+
+ const name = await moshpit.getName("eggs", "blue");
+ assert.equal(name.user_id, "buyer");
+ // Cleared, not inherited. A target names the seller's server; a guard address
+ // forwards the buyer's mail to the seller.
+ assert.equal(name.target, null);
+ assert.equal(await get(`SELECT tld FROM moshpit_contacts WHERE tld='eggs' AND label='blue'`), undefined ?? null);
+ assert.ok(!(await get(`SELECT tld FROM moshpit_records WHERE tld='eggs' AND label='blue'`)));
+ assert.ok(!(await get(`SELECT tld FROM moshpit_name_pins WHERE tld='eggs' AND label='blue'`)));
+});
+
+test("settling twice moves nothing twice", skip, async () => {
+ const { moshpit, pay } = await app();
+ // CoinPay retries a webhook it never got an ack for. The second delivery has
+ // to be a no-op rather than a second transfer.
+ const again = await moshpit.settleOfferPurchase("pay_transfer_1");
+ assert.equal(again.ok, false);
+ assert.equal((await moshpit.getName("eggs", "blue")).user_id, "buyer");
+});
+
+test("a lease grants control without moving the name", skip, async () => {
+ const { moshpit, pay } = await app();
+ const made = await moshpit.makeOffer({
+ tld: "eggs", label: "rent", kind: "lease", amount: "600", months: "6", email: "buyer@example.com",
+ });
+ await moshpit.verifyOffer(made.verifyToken);
+ await moshpit.respondToOffer({ id: made.offer.id, userId: "holder", action: "accept" });
+ const settled = await pay(made.offer.id, "buyer", "pay_lease_1");
+
+ assert.equal(settled.ok, true, JSON.stringify(settled));
+ assert.equal(settled.kind, "lease");
+
+ // Ownership does not move. That is the whole difference from a sale.
+ const name = await moshpit.getName("eggs", "rent");
+ assert.equal(name.user_id, "holder");
+ assert.equal(name.leased_to, "buyer");
+
+ // The tenant can use it...
+ const pointed = await moshpit.setNameTarget({ tld: "eggs", label: "rent", userId: "buyer", target: "2001:db8::20" });
+ assert.equal(pointed.ok, true, JSON.stringify(pointed));
+ assert.equal((await moshpit.resolveMoshpitName("rent.eggs")).target, "2001:db8::20");
+
+ // ...and the holder cannot point it out from under them mid-term.
+ const blocked = await moshpit.setNameTarget({ tld: "eggs", label: "rent", userId: "holder", target: "2001:db8::99" });
+ assert.equal(blocked.ok, false);
+ assert.match(blocked.error, /leased until/);
+});
+
+test("a leased name cannot be given up, sold or re-let mid-term", skip, async () => {
+ const { moshpit } = await app();
+ const released = await moshpit.releaseName({ tld: "eggs", label: "rent", userId: "holder" });
+ assert.equal(released.ok, false);
+ assert.match(released.error, /cannot give it up mid-term/);
+
+ const relet = await moshpit.makeOffer({
+ tld: "eggs", label: "rent", kind: "lease", amount: "900", months: "3", email: "third@example.com",
+ });
+ assert.equal(relet.ok, false);
+ assert.match(relet.error, /is leased until/);
+});
+
+test("a lease ends on its date, with or without a sweep", skip, async () => {
+ const { moshpit, run } = await app();
+ // Wind the term back rather than waiting six months for it.
+ await run(`UPDATE moshpit_leases SET expires_at = ? WHERE tld='eggs' AND label='rent'`, [Date.now() - 1000]);
+ await run(`UPDATE moshpit_names SET leased_until = ? WHERE tld='eggs' AND label='rent'`, [Date.now() - 1000]);
+
+ // Read-time: control is back with the holder immediately, and the tenant's
+ // site stops being served β before anything has been round to tidy up.
+ const resolution = await moshpit.resolveMoshpitName("rent.eggs");
+ assert.equal(resolution.target, null, "a lapsed tenant's target must stop being served at once");
+
+ const tenant = await moshpit.setNameTarget({ tld: "eggs", label: "rent", userId: "buyer", target: "2001:db8::21" });
+ assert.equal(tenant.ok, false);
+
+ const holder = await moshpit.setNameTarget({ tld: "eggs", label: "rent", userId: "holder", target: "2001:db8::1" });
+ assert.equal(holder.ok, true);
+});
+
+test("the sweep takes the tenant's things off the name", skip, async () => {
+ const { moshpit, run, get } = await app();
+ await run(`INSERT INTO moshpit_records (tld,label,type,value,ttl,priority,user_id,created_at)
+ VALUES ('eggs','rent','TXT','tenant',300,NULL,'buyer',1)`);
+
+ const swept = await moshpit.endExpiredLeases();
+ assert.equal(swept.reverted, 1);
+
+ const name = await moshpit.getName("eggs", "rent");
+ assert.equal(name.leased_to, null);
+ assert.equal(name.target, null);
+ assert.ok(!(await get(`SELECT tld FROM moshpit_records WHERE tld='eggs' AND label='rent'`)));
+
+ // Idempotent: a reverted lease is not reverted again on the next hour's run.
+ assert.equal((await moshpit.endExpiredLeases()).reverted, 0);
+});
+
+/* ---- the pages ---- */
+
+test("a parked name offers a way to ask, instead of ending the conversation", skip, async () => {
+ const { getHtml } = await app();
+ const { status, body } = await getHtml("/n/unminted.eggs");
+
+ assert.equal(status, 200);
+ assert.match(body, /Make an offer/);
+ assert.match(body, /action="\/pit\/offer"/);
+ assert.match(body, /lease it/);
+});
+
+test("an ending's page takes offers too, and does not offer to rent it", skip, async () => {
+ const { getHtml } = await app();
+ const { body } = await getHtml("/n/.eggs");
+
+ assert.match(body, /Make an offer/);
+ assert.doesNotMatch(body, /lease it/);
+});
+
+test("the holder is not invited to bid on their own name", skip, async () => {
+ const { getHtml } = await app();
+ const { body } = await getHtml("/n/unminted.eggs", "holder");
+ assert.doesNotMatch(body, /action="\/pit\/offer"/);
+});
+
+test("a visitor never sees what anyone offered", skip, async () => {
+ const { getHtml } = await app();
+ // Private negotiation is the product decision; this is the assertion that
+ // keeps it true as the page changes.
+ for (const route of ["/n/blue.eggs", "/n/.eggs", "/n/rent.eggs"]) {
+ const { body } = await getHtml(route);
+ assert.doesNotMatch(body, /2500|4000|I run a paint shop/, `${route} leaked an offer`);
+ }
+});
+
+test("the offers tab shows the holder what was offered, and the visitor nothing", skip, async () => {
+ const { getHtml } = await app();
+ const mine = await getHtml("/pit/offers", "holder");
+ assert.equal(mine.status, 200);
+ assert.match(mine.body, /blue\.eggs|rent\.eggs/);
+
+ const anon = await getHtml("/pit/offers");
+ assert.match(anon.body, /Sign in to see what people have offered/);
+ assert.doesNotMatch(anon.body, /I run a paint shop/);
+});
diff --git a/docs/offers-and-leases.md b/docs/offers-and-leases.md
new file mode 100644
index 0000000..88bda94
--- /dev/null
+++ b/docs/offers-and-leases.md
@@ -0,0 +1,157 @@
+# Offers on a parked name, and leasing
+
+Every parked page used to end the conversation. A name somebody held said
+"claimed but does not point anywhere yet". A name under an ending with no price
+said ".eggs is not for sale". Both are true, both arrive at the exact moment a
+visitor wants the name most, and both left the holder never hearing that anyone
+asked.
+
+An offer is the missing half. A visitor says what a name is worth to them; the
+holder accepts, refuses, or names a different number.
+
+## Who can offer
+
+Anyone, with no account. Requiring one first means asking a stranger to sign up
+before they may say what they would pay, on the one page whose job is converting
+that stranger.
+
+The address is confirmed by mail instead, and that step is load-bearing: an
+offer is recorded `unverified` and the holder is told nothing until the offerer
+clicks the link. Without it the form is a way to write to every holder in the
+registry, one name at a time, from our own domain. Two rate limits sit behind
+it β twenty offers per address per day across the whole registry, and one
+standing offer per address per name.
+
+An account is needed exactly once, at the end: a name has to belong to somebody,
+so paying means signing in with the address that made the offer.
+
+## Private, not an auction
+
+Only the holder sees what was offered. A public bid board tells every later
+bidder what the last one offered and tells the holder's next buyer precisely
+where their floor is. There is a test asserting a visitor's page never contains
+an amount, because that is the kind of property a later change breaks quietly.
+
+## What can be offered on
+
+| subject | buy | lease |
+|---|---|---|
+| a name somebody holds | yes | yes |
+| a name nobody has minted | yes | yes |
+| an ending | yes | **no** |
+
+An unminted name under an ending somebody holds is a legitimate subject: the
+operator can mint it and sell it, so they are who gets asked. That is the case
+the old page turned away β "not for sale at a fixed price" is not the same as
+"not for sale".
+
+Leases are names only, and that is a deliberate limit rather than an oversight.
+Leasing an ending would have to mean the lessee can mint names under it, and a
+name minted during a lease outlives the lease β so a six-month tenancy would
+permanently carve up a namespace its holder never sold. Until there is an answer
+to that, an ending is bought and not rented.
+
+## The conversation
+
+```
+offer βββΆ unverified ββ(offerer clicks the link)βββΆ open βββΆ accepted βββΆ paid
+ β β² β²
+ counteredβ ββββββββββ
+ offerer answers
+```
+
+A counter is held **beside** the original rather than replacing it. What was
+first offered is what the offerer will be comparing against, and a negotiation
+that rewrites its own history is one neither side can check. `agreedTerms()` is
+the single place that decides which number is operative β read `amount_usd`
+directly and you will charge the wrong one the first time a holder counters.
+
+Offers expire thirty days after they are made. An accepted one does not: from
+there the only thing outstanding is a payment, and a bill does not stop being
+owed because a month went by.
+
+## Accepting does not move anything
+
+Acceptance opens a CoinPay checkout, and the name moves when the payment
+confirms β the same webhook, `openOfferPurchase` and `settleOfferPurchase`
+sitting beside the existing name and ending purchases. Settlement is claimed
+with a conditional `accepted β settling` update, because CoinPay retries a
+webhook it never got an ack for and two deliveries can be in flight at once.
+
+Everything is re-checked at settlement. If the name changed hands between
+acceptance and confirmation, the buyer paid its previous holder for something
+that is no longer theirs, so the offer becomes `refund_due` and is logged rather
+than swallowed. Once a sale settles, every other live offer on that name is
+closed: an offer that can never be accepted is worse than no offer.
+
+**A sale leaves nothing of the seller's behind.** The contact and its forwarding
+alias, the pins, the records, the twin and the target all go, for the reason
+`releaseName` gives β a target names the seller's server, and an inherited guard
+address forwards the buyer's mail to the seller.
+
+## Leases
+
+The holder keeps the name. The lessee gets to point it, publish under it and
+present keys for it until the term runs out, and then it reverts with no action
+needed from either side.
+
+Paid **once, upfront, for the whole term**. Not because a monthly rate would be
+wrong β it is how leasing actually works β but because renewing one needs
+subscription billing, a grace period, and a story for what happens to a live
+site when a payment fails. A term paid in full before it starts cannot lapse
+halfway through, which makes this the version that can be built correctly today.
+
+What a tenant may do is everything that makes the name usable: target, feed,
+records, keys, content. What they may not do is anything that outlives the
+lease β give the name up, sell it, bind a clearnet twin, or change who is
+contacted about buying it. Those stay with the holder. `ownedName` is the holder
+check; `controlledName` is the one that also accepts a current tenant.
+
+Mid-term the name is frozen against everything that would break the tenancy: the
+holder cannot release it, and it cannot be sold or re-let.
+
+### Ending on time
+
+A lease ends when its term ends, not when a sweep runs. `leaseIsActive()` and
+the `leased_until` check are read-time, so a former tenant loses control the
+moment the clock passes β and `resolveMoshpitName` stops serving their target,
+so their site is not still answering under a name they no longer rent.
+
+`endExpiredLeases()` is the tidy-up, hourly and at boot. It does the part a
+reader cannot: taking the tenant's target, records and keys off the name. Their
+published content is deliberately left alone β it is the one thing here that was
+written rather than pointed at, and deleting somebody's posts because their
+lease lapsed destroys work rather than unlinking it. It stops being served the
+moment the target does.
+
+`leased_to` and `leased_until` are denormalised onto `moshpit_names`;
+`moshpit_leases` is the record. Every name lookup in the pit goes through
+`resolveMoshpitName`, and putting a second SELECT on the hottest query in the
+registry to answer a question that is null for almost every name is not worth
+it.
+
+## Where it shows
+
+- `/n/` and `/n/.` β the offer form, on the parked page.
+- `/pit/offers` β the holder's side: what was offered, and accept / refuse /
+ counter. Also lists names they are renting.
+- `/offers/?t=` β the offerer's side, reached from the mail. The
+ token stands in for the account they may not have.
+
+## Mail
+
+Four, all best-effort β a failed send must never lose an offer that is already
+recorded, and `/pit/offers` shows it either way.
+
+| when | to | why |
+|---|---|---|
+| offer made | offerer | confirm the address, or nothing is sent on |
+| offer confirmed | holder | somebody wants your name |
+| holder answers | offerer | accepted, countered or refused |
+| lease settles | tenant | what you now hold, and until when |
+
+Holder mail goes to their **account** address, not the guard address a contact
+publishes. The two point opposite ways: a guard address is how a stranger
+reaches them without learning who they are; this is the registry telling its own
+user something about their account, and it has to arrive for the holders who
+have no contact set β which is most of them.