From 00130c6292dd5fae95e4c575617086f5c28a7136 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 08:51:55 +0000 Subject: [PATCH] feat(moshpit): offers and leases on a parked name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every parked page ended 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 land at the 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 and the holder accepts, refuses, or names a different number. No account needed to ask. Requiring one means asking a stranger to sign up before they may say what they would pay, on the page whose whole 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 from our own domain. Twenty offers per address per day, one standing offer per address per name, behind that. Private, not an auction. Only the holder sees an amount; a public board tells every later bidder what the last one offered and shows the holder's next buyer their floor. A counter is held beside the original rather than replacing it — agreedTerms() is the one place that decides which number is operative. Accepting moves nothing. It opens a CoinPay checkout beside the existing name and ending purchases, claimed with a conditional accepted→settling update because a webhook gets redelivered. Everything is re-checked at settlement: a name that changed hands in between becomes refund_due rather than a silent loss, and a sale closes every other live offer on that name. A sale leaves nothing of the seller's behind — contact and its forwarding alias, pins, records, twin and target all go, for the reason releaseName gives. Leases are real. The holder keeps the name; the tenant points it, publishes under it and presents keys until the term ends, then it reverts on its own. Paid once upfront for the whole term — a monthly rate needs subscription billing, a grace period and a story for a failed payment against a live site, and a term paid in full before it starts cannot lapse halfway through. ownedName stays the holder check and controlledName also accepts a tenant, so what outlives the lease (release, sale, twin, contact) stays with the holder. A lease ends by the clock, not by a sweep: leased_until is read-time, so a former tenant loses control the moment it passes and resolveMoshpitName stops serving their target. endExpiredLeases() does the part a reader cannot — taking the target, records and keys back off — hourly and at boot. Their published content is left alone deliberately; a lapsed lease should unlink work, not destroy it. leased_to/leased_until are denormalised onto moshpit_names because every lookup in the pit goes through resolveMoshpitName, and a second SELECT there to answer a question that is null for almost every name is not worth it. Endings can be bought but not leased: a name minted during an ending's lease would outlive it, so a six-month tenancy would permanently carve up a namespace its holder never sold. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017QSp1aAoQQB7jFV7hwt4jJ --- apps/pwa/src/lib/moshpit-offer-mail.mjs | 119 ++++ apps/pwa/src/lib/moshpit-offer.mjs | 198 ++++++ .../pwa/src/migrations/018_moshpit_offers.sql | 166 +++++ apps/pwa/src/moshpit.mjs | 654 +++++++++++++++++- apps/pwa/src/routes/credits.mjs | 6 +- apps/pwa/src/routes/moshpit.mjs | 474 +++++++++++++ apps/pwa/src/server.mjs | 31 + apps/pwa/test/moshpit-offers.test.mjs | 462 +++++++++++++ docs/offers-and-leases.md | 157 +++++ 9 files changed, 2252 insertions(+), 15 deletions(-) create mode 100644 apps/pwa/src/lib/moshpit-offer-mail.mjs create mode 100644 apps/pwa/src/lib/moshpit-offer.mjs create mode 100644 apps/pwa/src/migrations/018_moshpit_offers.sql create mode 100644 apps/pwa/test/moshpit-offers.test.mjs create mode 100644 docs/offers-and-leases.md 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 00000000..2d75fa6b --- /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) => ` +
+

the moshpit

+

${esc(title)}

+ ${lines.map((l) => `

${l}

`).join("")} + ${cta ? `${esc(cta.label)}` : ""} +

no bugs, only features. 🤘

+
`; + +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 00000000..90d66b9b --- /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 00000000..37d1365f --- /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 fc07a5e3..32f933c4 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 44fc0a67..5567a583 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 93ee9b85..469d08c0 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

Related endings

${related.map(endingLink).join(" · ")}

` : ""} + ${offerBox({ tld, label: "", holderId: owner.user_id, user, req })} + ${contactCard(contact)}

the pit →

@@ -1601,6 +1623,20 @@ function directory({ resolution, tld, owner, names, tlds, quote, contact = null,

More endings

${others.map(tldLink).join(" · ")}

` : ""} + ${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)}

the pit →

@@ -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. +

+
+ ${csrfInput(req)} + +
+ + $ + + ${canLease ? ` + ` : ""} +
+
+ + + +
+

+ We mail you once to confirm it is really your address. Nothing reaches the holder until you click it. + A lease is paid once for the whole term and reverts when it ends. +

+
+ `; +} + +/** + * 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)} +
+

That link did not work

+

${esc(result.error)}

+

the pit →

+
${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. +

+

+ Track this offer → + ${esc(name)} → +

+
${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.

+

the pit →

+
${footer}`, + })); + } + + const bal = req.user ? await balance(req.user.id) : 0; + const status = effectiveStatus(offer); + const terms = agreedTerms(offer); + const name = offer.label ? `${offer.label}.${offer.tld}` : `.${offer.tld}`; + const hidden = `${csrfInput(req)}`; + + const msg = req.query.err ? `

${esc(req.query.err)}

` + : req.query.ok ? `

${esc(req.query.ok)}

` : ""; + + const body = + status === "countered" ? ` +

The holder countered at $${esc(String(terms.amountUsd))}${ + terms.months ? ` for ${esc(String(terms.months))} months` : ""}.

+

You offered ${esc(describeOffer({ ...offer, counter_amount_usd: null, counter_months: null }))}.

+
+ ${hidden} + + +
` + : status === "accepted" ? ` +

Accepted at $${esc(String(terms.amountUsd))}. Nothing has moved yet.

+ ${req.user ? ` +
+ ${hidden} + + paid in crypto via CoinPay +
` + : ` +

+ Sign in with ${esc(offer.offerer_email)} to pay — a name has to belong to an + account, which is the one thing an offer did not need. +

+

Sign in →

`}` + : status === "paid" ? ` +

Settled. ${esc(name)} is ${offer.kind === "lease" ? "yours for the term" : "yours"}.

+

the pit →

` + : status === "open" ? ` +

With the holder. Nothing to do but wait — you will hear at + ${esc(offer.offerer_email)}.

+
+ ${hidden} + +
` + : status === "unverified" ? ` +

Not confirmed yet. Check ${esc(offer.offerer_email)} — + the holder hears nothing until you click the link.

` + : `

This offer is over: ${esc(status)}.

`; + + res.status(200).send(page({ + title: `moshpit ▸ offer on ${name}`, + head: ``, + body: `${appBar(req.user, bal, req.csrfToken)} +
+

your offer

+

${esc(name)}

+ ${msg} +
${body}
+
${footer}`, + })); +}); + +/** POST /offers/:id/answer — the offerer takes a counter, leaves it, or withdraws. */ +moshpitRouter.post("/offers/:id/answer", async (req, res) => { + const token = req.body?.t ? String(req.body.t) : null; + const result = await answerCounter({ + id: req.params.id, userId: req.user?.id ?? null, token, action: req.body?.action, + }); + const qs = new URLSearchParams(result.ok + ? { ok: result.offer.status === "accepted" ? "Agreed. Sign in and pay to take it." : "Done." } + : { err: result.error }).toString(); + const t = token ? `t=${encodeURIComponent(token)}&` : ""; + res.redirect(`/offers/${encodeURIComponent(req.params.id)}?${t}${qs}`); +}); + +/** + * POST /offers/:id/pay — turn an agreed offer into a CoinPay checkout. + * + * The agreed amount is read from the row here rather than trusted from the form + * the buyer was looking at, for the same reason startCheckout re-quotes: the + * page they clicked may be minutes old and a counter may have landed since. + */ +moshpitRouter.post("/offers/:id/pay", requireAuth, async (req, res) => { + const offer = await getOffer(req.params.id); + const token = req.body?.t ? String(req.body.t) : null; + const backTo = `/offers/${encodeURIComponent(req.params.id)}${token ? `?t=${encodeURIComponent(token)}` : ""}`; + const fail = (err) => res.redirect(`${backTo}${token ? "&" : "?"}err=${encodeURIComponent(err)}`); + + if (!offer || offerActor(offer, { userId: req.user.id, token }) !== "offerer") return fail("that offer is not yours"); + if (effectiveStatus(offer) !== "accepted") return fail("that offer is not agreed yet"); + if (!config.coinpay.businessId) return fail("payments are not configured yet"); + + const terms = agreedTerms(offer); + const name = offer.label ? `${offer.label}.${offer.tld}` : `.${offer.tld}`; + try { + const r = await fetch(`${config.coinpay.apiBase}/api/payments/create`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + business_id: config.coinpay.businessId, + amount: terms.amountUsd, + currency: "USD", + payment_method: "both", + metadata: { + app: "moshcode", kind: `moshpit_offer_${offer.kind}`, user_id: req.user.id, + offer_id: offer.id, tld: offer.tld, label: offer.label, + }, + redirect_url: `${config.origin}/offers/${offer.id}${token ? `?t=${encodeURIComponent(token)}` : ""}`, + }), + }); + const pay = await r.json(); + const payId = pay.id || pay.payment_id; + if (!payId) throw new Error("no payment id in response"); + + // Recorded before the buyer leaves, because the webhook can arrive before + // they are redirected back and with no payment id on the row it has nothing + // to settle. + await openOfferPurchase({ offerId: offer.id, paymentId: payId, userId: req.user.id }); + return res.redirect(pay.hosted_url || pay.url || `${config.coinpay.apiBase}/pay/${payId}`); + } catch (e) { + console.error(`[moshpit] offer checkout failed for ${name}:`, e.message); + return fail("could not start checkout"); + } +}); + +/* ---------- the Offers tab ---------- */ + +/** One offer as its holder sees it, with the three answers they can give. */ +function offerCard(req, offer) { + const status = effectiveStatus(offer); + const terms = agreedTerms(offer); + const name = offer.label ? `${offer.label}.${offer.tld}` : `.${offer.tld}`; + const open = status === "open"; + + return ` +
+
+

+ ${esc(name)} +

+ ${esc(status)} +
+

${esc(describeOffer(offer))}

+ ${offer.message ? `

“${esc(offer.message)}”

` : ""} + ${terms.countered ? ` +

You countered — waiting on them.

` : ""} + + ${open ? ` +
+ ${csrfInput(req)} + + + or counter at $ + + ${offer.kind === "lease" ? ` + for + + months` : ""} + +
+

+ Accepting does not move the name — they pay first, and it transfers when the payment confirms. +

` : ""} +
`; +} + +moshpitRouter.get("/pit/offers", async (req, res) => { + const bal = req.user ? await balance(req.user.id) : 0; + const offers = req.user ? await listOffersForHolder(req.user.id) : []; + const leases = req.user ? await listLeasesForUser(req.user.id) : []; + + const msg = req.query.err ? `

${esc(req.query.err)}

` + : req.query.ok ? `

${esc(req.query.ok)}

` : ""; + + // 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.

+

Sign in →

` + : ` + ${live.length + ? live.map((o) => offerCard(req, o)).join("") + : `

Nothing on the table. Offers land here when somebody asks about a name you hold — + they arrive by mail too, so you do not have to watch this page.

`} + ${done.length ? ` +

Settled

+ ${done.map((o) => offerCard(req, o)).join("")}` : ""} + ${leases.length ? ` +

Names you are renting

+ ` : ""}`; + + res.type("html").send(page({ + title: "moshcode ▸ the pit ▸ offers", + head: ``, + body: `${appBar(req.user, bal, req.csrfToken)} +
+

what people will pay

+

Offers on 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}`} Contact Bulk publish Use it (DNS) diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs index f1198f41..b2362c10 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 00000000..99a0bb87 --- /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 00000000..88bda947 --- /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.