diff --git a/apps/pwa/src/lib/moshpit-offer-mail.mjs b/apps/pwa/src/lib/moshpit-offer-mail.mjs new file mode 100644 index 0000000..2d75fa6 --- /dev/null +++ b/apps/pwa/src/lib/moshpit-offer-mail.mjs @@ -0,0 +1,119 @@ +// The four mails an offer sends, and nothing else. +// +// An offer is a conversation between two people who have no other way to reach +// each other, so mail is not a notification here -- it is the channel. Each of +// these is one side learning it is their turn. +// +// Every send is best-effort and says so in its return value. A failed mail must +// never lose an offer that is already recorded: the row is the fact, and the +// holder can see it on /pit/offers whether or not the mail arrived. +import { config } from "../config.mjs"; +import { esc } from "./html.mjs"; +import { agreedTerms, describeOffer } from "./moshpit-offer.mjs"; + +const offerName = (offer) => (offer.label ? `${offer.label}.${offer.tld}` : `.${offer.tld}`); + +/** + * The house style, such as it is -- the same dark card the approval mails use. + * + * Plain enough to survive a client that strips the CSS, because the one thing + * that has to work in every reader is the link. + */ +const wrap = (title, lines, cta) => ` +
+

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 0000000..90d66b9 --- /dev/null +++ b/apps/pwa/src/lib/moshpit-offer.mjs @@ -0,0 +1,198 @@ +// What a stranger may offer for a name, and what a lease term actually means. +// +// The parked page is the one moment someone wants a name enough to say so, and +// until now every version of that moment ended in a sentence: "claimed but does +// not point anywhere", ".eggs is not for sale". The holder never heard that +// anybody asked. An offer is the missing half of that conversation. +// +// Deliberately free of any database, network or config import, for the same +// reason moshpit-name, moshpit-twin and moshpit-contact are: these are the +// rules, and the rules have to be checkable without a libSQL connection. +// src/moshpit.mjs owns storage and the routes own the pages. +import { randomBytes } from "node:crypto"; + +export const OFFER_KINDS = ["buy", "lease"]; + +/** + * The statuses an offer can be in, and which of them are still a conversation. + * + * `unverified` is live in the sense that it can still become something, but the + * holder has not been told it exists -- that is the whole point of it. Every + * listing the holder sees starts at `open`. + */ +export const OFFER_STATUSES = [ + "unverified", "open", "countered", "accepted", "settling", "paid", + "refund_due", "rejected", "withdrawn", "expired", +]; + +/** Statuses that can still change. Everything else is history. */ +const LIVE_STATUSES = new Set(["unverified", "open", "countered", "accepted", "settling"]); + +/** Statuses the clock is allowed to end. An accepted offer is waiting on money, not on a reply. */ +const EXPIRABLE_STATUSES = new Set(["unverified", "open", "countered"]); + +/** + * Thirty days, then an unanswered offer stops being one. + * + * Long enough that a holder who checks the pit monthly still sees it, short + * enough that a page does not accumulate a decade of stale numbers somebody + * might act on. An accepted offer is exempt: the clock stops the moment both + * sides agree, because from there the only thing outstanding is a payment. + */ +export const OFFER_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * The floor, and why there is one. + * + * Not a view about what a name is worth. A required, non-trivial number is the + * cheapest filter there is on a form that anybody on the internet can submit: + * it costs a real bidder nothing and it makes "offer $0 on all 18,000 endings" + * an activity with a stated price attached. + */ +export const MIN_OFFER_USD = 1; + +/** Matches MAX_LISTING_PRICE_USD in moshpit.mjs -- a bound against Infinity and 1e300, not a policy. */ +export const MAX_OFFER_USD = 1_000_000_000; + +/** One month to five years. Beyond that, somebody wants to buy the name. */ +export const MIN_LEASE_MONTHS = 1; +export const MAX_LEASE_MONTHS = 60; + +/** How long a message may be. Enough to explain who you are, not enough to be a payload. */ +export const MAX_OFFER_MESSAGE = 1000; + +/** + * Money, or null when what arrived could never be an amount. + * + * Rounded to cents rather than accepted as typed. A float that carries more + * precision than money does is a number that renders as $19.989999999999998 + * somewhere downstream, and it will be somewhere the holder is deciding whether + * to accept. + */ +export function normalizeOfferAmount(input) { + const raw = String(input ?? "").trim().replace(/^\$/, "").replace(/,/g, ""); + if (!raw) return null; + const value = Number(raw); + if (!Number.isFinite(value)) return null; + const cents = Math.round(value * 100) / 100; + if (cents < MIN_OFFER_USD || cents > MAX_OFFER_USD) return null; + return cents; +} + +/** A whole number of months inside the bounds, or null. */ +export function normalizeLeaseMonths(input) { + const raw = String(input ?? "").trim(); + if (!/^\d+$/.test(raw)) return null; + const months = Number.parseInt(raw, 10); + if (months < MIN_LEASE_MONTHS || months > MAX_LEASE_MONTHS) return null; + return months; +} + +export const normalizeOfferKind = (input) => { + const kind = String(input ?? "").trim().toLowerCase(); + return OFFER_KINDS.includes(kind) ? kind : null; +}; + +/** Trimmed and capped. Empty becomes null, so "no message" is one value and not two. */ +export function normalizeOfferMessage(input) { + const text = String(input ?? "").trim().slice(0, MAX_OFFER_MESSAGE); + return text || null; +} + +/** + * Ids and tokens. + * + * The verify token is the only one that is a secret: it arrives by mail and + * clicking it is what turns an address into a verified one, so it is sized + * against guessing rather than against collision. + */ +export const mintOfferId = () => `ofr_${randomBytes(9).toString("hex")}`; +export const mintVerifyToken = () => randomBytes(32).toString("base64url"); + +/** + * When a term that starts now runs out. + * + * Calendar months, not thirty-day blocks, because "three months" is what the + * two of them agreed and a tenant counting days off a calendar should reach the + * same date we did. The clamp is the awkward case every month-arithmetic + * implementation has to pick an answer for: one month from 31 January is 28 + * February, because the alternative is silently rolling into March and giving + * away a day of somebody's term. + */ +export function leaseEndsAt(startsAt, months) { + const start = new Date(startsAt); + const target = new Date(start.getTime()); + target.setUTCDate(1); + target.setUTCMonth(target.getUTCMonth() + months); + const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate(); + target.setUTCDate(Math.min(start.getUTCDate(), lastDay)); + target.setUTCHours(start.getUTCHours(), start.getUTCMinutes(), start.getUTCSeconds(), start.getUTCMilliseconds()); + return target.getTime(); +} + +/** + * What the two of them have actually agreed to, counter included. + * + * The counter is held beside the original rather than replacing it, so every + * reader has to know which one is operative. This is that knowledge, in one + * place -- a caller that reads `amount_usd` directly will charge the wrong + * number the first time a holder counters. + */ +export function agreedTerms(offer) { + if (!offer) return null; + return { + amountUsd: offer.counter_amount_usd ?? offer.amount_usd, + months: offer.kind === "lease" ? (offer.counter_months ?? offer.lease_months) : null, + countered: offer.counter_amount_usd !== null && offer.counter_amount_usd !== undefined, + }; +} + +/** + * The status an offer really has, which is not always the one in the column. + * + * Expiry is a date passing rather than a write happening, so a row can be + * `open` and long dead. Every reader goes through here, and the sweep that + * writes `expired` is a tidy-up rather than the thing that makes it true -- + * otherwise an offer would be live exactly as long as the sweep was broken. + */ +export function effectiveStatus(offer, now = Date.now()) { + if (!offer) return null; + if (EXPIRABLE_STATUSES.has(offer.status) && offer.expires_at <= now) return "expired"; + return offer.status; +} + +/** Whether this offer is still something either side can act on. */ +export const offerIsLive = (offer, now = Date.now()) => LIVE_STATUSES.has(effectiveStatus(offer, now)); + +/** Whether the holder is the one being waited on. */ +export const awaitingHolder = (offer, now = Date.now()) => effectiveStatus(offer, now) === "open"; + +/** Whether the offerer is the one being waited on -- a counter to answer, or a bill to pay. */ +export const awaitingOfferer = (offer, now = Date.now()) => + ["countered", "accepted"].includes(effectiveStatus(offer, now)); + +/** + * Whether a lease is running right now. + * + * Read-time, like everything else here. A lease that ended an hour ago must + * stop granting control the moment it ends, not the next time a sweep runs -- + * the alternative is a former tenant still able to repoint a name they no + * longer rent. + */ +export const leaseIsActive = (lease, now = Date.now()) => + Boolean(lease) && lease.starts_at <= now && lease.expires_at > now; + +/** + * How an offer reads in one line, for a subject line or a list row. + * + * Money first, because that is what the holder is deciding about. + */ +export function describeOffer(offer) { + const terms = agreedTerms(offer); + if (!terms) return ""; + const name = offer.label ? `${offer.label}.${offer.tld}` : `.${offer.tld}`; + const money = `$${terms.amountUsd.toLocaleString("en-US", { maximumFractionDigits: 2 })}`; + return offer.kind === "lease" + ? `${money} to lease ${name} for ${terms.months} month${terms.months === 1 ? "" : "s"}` + : `${money} to buy ${name}`; +} diff --git a/apps/pwa/src/migrations/018_moshpit_offers.sql b/apps/pwa/src/migrations/018_moshpit_offers.sql new file mode 100644 index 0000000..37d1365 --- /dev/null +++ b/apps/pwa/src/migrations/018_moshpit_offers.sql @@ -0,0 +1,166 @@ +-- Offers on a parked name, and the leases some of them turn into. +-- +-- Everything the registry could say to someone who wanted a name it could not +-- sell them was a dead end. A name somebody holds answered "claimed but does +-- not point anywhere"; a name under an ending with no price answered ".eggs is +-- not for sale". Both are the exact moment a visitor is most interested, and +-- both ended the conversation -- while the holder, who might well have sold, +-- never heard that anyone asked. +-- +-- An offer is that conversation. It is private between the two of them: only +-- the holder sees what was offered, and they accept, reject or counter. A +-- public bid board would tell every later bidder what the last one offered and +-- tell the holder's next buyer exactly where their floor is. +CREATE TABLE IF NOT EXISTS moshpit_offers ( + id TEXT PRIMARY KEY, + tld TEXT NOT NULL, + -- The name being offered on, or '' for the ending itself -- the same + -- convention moshpit_contacts uses, and for the same reason: the whole + -- lifecycle below is identical for a name and an ending. + label TEXT NOT NULL DEFAULT '', + + -- buy | lease. + -- + -- Leases are names only, never endings, and that is a deliberate limit + -- rather than an oversight. Leasing an ending would have to mean the lessee + -- can mint names under it, and a name minted during a lease outlives the + -- lease -- so a six-month tenancy would permanently carve up a namespace its + -- holder never sold. Until there is an answer to that, an ending can be + -- bought and not rented. + kind TEXT NOT NULL CHECK (kind IN ('buy','lease')), + + -- What is being offered, in whole dollars and cents. For a lease this is the + -- total for the whole term, paid once, not a monthly rate -- see + -- moshpit_leases on why there is no recurring billing here. + amount_usd REAL NOT NULL, + -- How long the lease runs. NULL for a purchase, and required for a lease. + lease_months INTEGER, + + -- Who is asking. An offer can come from someone with no account at all, + -- because requiring one first is asking a stranger to sign up before they + -- may say what they would pay, on a page whose entire job is to convert that + -- stranger. The address is the identity until there is a user id, which + -- appears when they sign in to pay. + offerer_email TEXT NOT NULL, + offerer_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + message TEXT, + + -- Who it was addressed to when it was made. Kept as a fact about the offer + -- rather than looked up later: names change hands, and an offer must stay + -- attached to the person it was actually put to. Acceptance re-checks who + -- holds the name now, so a stale row can never sell something twice. + holder_user_id TEXT NOT NULL, + + -- unverified -- made, but the address has not proved it wants to be here. + -- The holder is not told. This is where spam stops. + -- open -- verified and waiting on the holder. + -- countered -- the holder named a different number, waiting on the offerer. + -- accepted -- agreed by both, waiting on payment. + -- settling -- a confirmed payment is being turned into a transfer. Held + -- for one write, and it is the atomic claim that stops a + -- redelivered webhook moving the name twice. + -- paid -- settled, and the name or lease has moved. + -- refund_due -- the money arrived and the name could not be given. Someone + -- else took it between acceptance and confirmation, which is + -- real money against something the buyer cannot have, so it + -- is recorded rather than swallowed. + -- rejected / withdrawn / expired -- over, by each of the three people who + -- can end it: the holder, the offerer, and the clock. + status TEXT NOT NULL + CHECK (status IN ('unverified','open','countered','accepted','settling','paid','refund_due','rejected','withdrawn','expired')), + + -- Proves the address wants the mail before any is sent to the holder. + -- Without this the form is a way to mail every holder in the registry from + -- our own domain, one name at a time. + verify_token TEXT NOT NULL UNIQUE, + verified_at INTEGER, + + -- The holder's counter, held beside the original rather than overwriting it. + -- What was first offered is the fact the offerer will be comparing against, + -- and a negotiation that silently rewrites its own history is one neither + -- side can check. + counter_amount_usd REAL, + counter_months INTEGER, + countered_at INTEGER, + + -- The CoinPay checkout, once there is something agreed to pay for. UNIQUE so + -- a redelivered webhook settles the same offer rather than a second one -- + -- the same reasoning moshpit_name_purchases uses for its primary key. + payment_id TEXT UNIQUE, + + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + -- An offer nobody answered stops being one. Checked at read time as well as + -- swept, so an offer is never live merely because the sweep has not run. + expires_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_moshpit_offers_holder ON moshpit_offers(holder_user_id, status); +CREATE INDEX IF NOT EXISTS idx_moshpit_offers_name ON moshpit_offers(tld, label, status); +CREATE INDEX IF NOT EXISTS idx_moshpit_offers_offerer ON moshpit_offers(offerer_email, created_at); +-- The sweep reads "still live, past its date", which is a small slice. +CREATE INDEX IF NOT EXISTS idx_moshpit_offers_expiry ON moshpit_offers(expires_at) + WHERE status IN ('unverified','open','countered'); + +-- A name rented rather than sold. +-- +-- The holder keeps the name. The lessee gets to point it, publish under it and +-- present keys for it until the term runs out, and then it reverts with no +-- action needed from either of them. +-- +-- Paid once, upfront, for the whole term. Not because a monthly rate would be +-- wrong -- it is how leasing actually works -- but because renewing one needs +-- subscription billing, a grace period, and a story for what happens to a live +-- site when a card fails, and none of those exist here yet. A term that is +-- fully paid before it starts cannot lapse halfway through, which makes this +-- the version that can be built correctly today. +CREATE TABLE IF NOT EXISTS moshpit_leases ( + tld TEXT NOT NULL, + label TEXT NOT NULL, + lessee_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- Who it reverts to. Stored rather than read from moshpit_names, because + -- that row is what a lease exists to make ambiguous, and the answer to + -- "whose name is this really" must not depend on the thing being leased. + holder_user_id TEXT NOT NULL, + offer_id TEXT NOT NULL REFERENCES moshpit_offers(id), + months INTEGER NOT NULL, + amount_usd REAL NOT NULL, + starts_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + + -- One lease per name. Two people holding overlapping tenancies on one name + -- is not a state anything downstream could resolve -- whose target does it + -- serve? -- so the key refuses it rather than the application remembering to. + -- A finished lease stays as the row until a new one replaces it, which is + -- what makes "who had this last" answerable. + PRIMARY KEY (tld, label) +); + +CREATE INDEX IF NOT EXISTS idx_moshpit_leases_lessee ON moshpit_leases(lessee_user_id); +CREATE INDEX IF NOT EXISTS idx_moshpit_leases_expiry ON moshpit_leases(expires_at); + +-- The lease, denormalised onto the name it is on. +-- +-- moshpit_leases above is the record. These two are the cache, and the split is +-- the same one moshpit_tlds and moshpit_tld_log already make in this schema. +-- +-- The reason is resolution. Every name lookup in the pit goes through +-- resolveMoshpitName -- the DNS bridge, the browser extension, every /n/ page +-- -- and each one has to know whether the name is currently being served by a +-- tenant or by a tenancy that has quietly run out. Asking a second table on +-- that path would put an extra SELECT on the hottest query in the registry to +-- answer a question that is null for almost every name. Read from the row that +-- was already fetched, it is free. +-- +-- NULL means what it has always meant: nobody is renting this. +ALTER TABLE moshpit_names ADD COLUMN leased_to TEXT; +ALTER TABLE moshpit_names ADD COLUMN leased_until INTEGER; + +-- Reverted, so the sweep can tell a lease it has already cleaned up from one it +-- has not. Without it the sweep either has to re-clear every expired lease +-- forever, or track its own high-water mark somewhere else. +ALTER TABLE moshpit_leases ADD COLUMN reverted_at INTEGER; + +CREATE INDEX IF NOT EXISTS idx_moshpit_leases_unreverted ON moshpit_leases(expires_at) + WHERE reverted_at IS NULL; diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index fc07a5e..32f933c 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -49,6 +49,25 @@ import { parseTldList, tldRejection, } from "./lib/moshpit-name.mjs"; +import { + agreedTerms, + effectiveStatus, + leaseEndsAt, + leaseIsActive, + MAX_LEASE_MONTHS, + MAX_OFFER_USD, + MIN_LEASE_MONTHS, + MIN_OFFER_USD, + mintOfferId, + mintVerifyToken, + normalizeLeaseMonths, + normalizeOfferAmount, + normalizeOfferKind, + normalizeOfferMessage, + OFFER_KINDS, + OFFER_TTL_MS, + offerIsLive, +} from "./lib/moshpit-offer.mjs"; import { effectiveTarget, normalizeRecord, @@ -92,6 +111,13 @@ export { guardAddress, isGuardToken, mintGuardToken, normalizeContactEmail, normalizeVisibility, publishedContact, } from "./lib/moshpit-contact.mjs"; +export { + MAX_LEASE_MONTHS, MAX_OFFER_MESSAGE, MAX_OFFER_USD, MIN_LEASE_MONTHS, MIN_OFFER_USD, + OFFER_KINDS, OFFER_STATUSES, OFFER_TTL_MS, + agreedTerms, awaitingHolder, awaitingOfferer, describeOffer, effectiveStatus, leaseEndsAt, leaseIsActive, + normalizeLeaseMonths, normalizeOfferAmount, normalizeOfferKind, normalizeOfferMessage, offerIsLive, +} from "./lib/moshpit-offer.mjs"; + /** * The largest number this column will accept. * @@ -333,7 +359,7 @@ async function ownedTldAndLabel(tldInput, labelInput, userId) { /* ---- names under a TLD ---- */ -const NAME_COLS = `tld, label, user_id, target, feed_url, feed_kind, created_at`; +const NAME_COLS = `tld, label, user_id, target, feed_url, feed_kind, leased_to, leased_until, created_at`; export async function getName(tld, label) { return get(`SELECT ${NAME_COLS} FROM moshpit_names WHERE tld = ? AND label = ?`, [tld, label]); @@ -442,7 +468,7 @@ export async function registerName({ tld: tldInput, label: labelInput, userId, t /** Point an existing name somewhere else. */ export async function setNameTarget({ tld: tldInput, label: labelInput, userId, target }) { - const owned = await ownedName(tldInput, labelInput, userId); + const owned = await controlledName(tldInput, labelInput, userId); if (!owned.ok) return owned; const dest = normalizeTarget(target); if (!dest.ok) return { ok: false, error: dest.error }; @@ -467,7 +493,7 @@ export async function setNameTarget({ tld: tldInput, label: labelInput, userId, * longer exists, and it would silently apply to whatever feed came next. */ export async function setNameFeed({ tld: tldInput, label: labelInput, userId, feed, kind = null }) { - const owned = await ownedName(tldInput, labelInput, userId); + const owned = await controlledName(tldInput, labelInput, userId); if (!owned.ok) return owned; const stream = normalizeFeedUrl(feed); @@ -482,9 +508,20 @@ export async function setNameFeed({ tld: tldInput, label: labelInput, userId, fe } /** Give the name back. */ -export async function releaseName({ tld: tldInput, label: labelInput, userId }) { +export async function releaseName({ tld: tldInput, label: labelInput, userId, now = Date.now() }) { const owned = await ownedName(tldInput, labelInput, userId); if (!owned.ok) return owned; + + // Not while somebody is renting it. Releasing would drop the name back into + // the pool for anyone to mint, mid-tenancy, taking a paid-for term with it -- + // and the tenant would find out when their site stopped answering. + const lease = await activeLease(owned.tld, owned.label, now); + if (lease) { + return { + ok: false, + error: `${owned.label}.${owned.tld} is leased until ${new Date(lease.expires_at).toISOString().slice(0, 10)} β€” you cannot give it up mid-term`, + }; + } // Keys go with the name. Deleted explicitly rather than left to the foreign // key, because SQLite only enforces those with `PRAGMA foreign_keys = ON` // and nothing here sets it β€” so a cascade that looks declared would not fire, @@ -758,6 +795,16 @@ export async function resolveMoshpitName(input) { // legitimately come from. const entry = await getName(resolvedTld, label); + // A tenancy that has run out but not yet been swept up. The row still carries + // the tenant's target because nothing has been round to clear it, and serving + // it would keep a former tenant's site answering under a name they no longer + // rent -- for however long the sweep is behind, which on a registry that has + // just restarted is "since the restart". + // + // Read-time, so the lease ends when it ends. endExpiredLeases() makes the row + // agree afterwards; it is not what makes this true. + const lapsed = Boolean(entry?.leased_until) && entry.leased_until <= Date.now(); + return { name, resolved, @@ -768,12 +815,17 @@ export async function resolveMoshpitName(input) { registered: true, ...(Boolean(owner.alias_of) && !aliased ? { exempt: true } : {}), name_registered: Boolean(entry), - target: entry?.target ?? null, + target: lapsed ? null : (entry?.target ?? null), // Carried alongside the target rather than folded into it. A resolver // answering AAAA has no use for a feed and ignores these; /n/ is the caller // that turns them into a page, and it needs both to decide which it serves. - feed: entry?.feed_url ?? null, - feed_kind: entry?.feed_kind ?? null, + feed: lapsed ? null : (entry?.feed_url ?? null), + feed_kind: lapsed ? null : (entry?.feed_kind ?? null), + // Who is renting it, and until when. Null for almost every name. A resolver + // ignores both; the name's own page uses them to say why a name that is + // plainly somebody's is not answering as theirs. + leased_to: lapsed ? null : (entry?.leased_to ?? null), + leased_until: lapsed ? null : (entry?.leased_until ?? null), }; } @@ -842,7 +894,7 @@ export async function pinsForName(input, kind = null) { /** Publish a key for a name you hold. */ export async function addPin({ tld: tldInput, label: labelInput, pin, kind: kindInput, note = null, userId }) { - const owned = await ownedName(tldInput, labelInput, userId); + const owned = await controlledName(tldInput, labelInput, userId); if (!owned.ok) return owned; if (!isPin(pin)) { @@ -881,7 +933,7 @@ export async function addPin({ tld: tldInput, label: labelInput, pin, kind: kind * grounds that it breaks connections would be refusing the point. */ export async function removePin({ tld: tldInput, label: labelInput, pin, userId }) { - const owned = await ownedName(tldInput, labelInput, userId); + const owned = await controlledName(tldInput, labelInput, userId); if (!owned.ok) return owned; const result = await run( @@ -957,7 +1009,7 @@ export async function recordsForName(input) { * what they wanted. */ export async function addRecord({ tld: tldInput, label: labelInput, type, value, ttl, priority, userId }) { - const owned = await ownedName(tldInput, labelInput, userId); + const owned = await controlledName(tldInput, labelInput, userId); if (!owned.ok) return owned; const name = `${owned.label}.${owned.tld}`; @@ -1009,7 +1061,7 @@ export async function addRecord({ tld: tldInput, label: labelInput, type, value, * that already knows exactly which record it means. */ export async function removeRecord({ tld: tldInput, label: labelInput, type, value, userId }) { - const owned = await ownedName(tldInput, labelInput, userId); + const owned = await controlledName(tldInput, labelInput, userId); if (!owned.ok) return owned; const wanted = normalizeRecordType(type); @@ -1164,7 +1216,7 @@ export async function contentForName(input) { * which is not something a later edit gets to rewrite. */ export async function putContent({ tld: tldInput, label: labelInput, userId, item: input, now = Date.now() }) { - const owned = await ownedName(tldInput, labelInput, userId); + const owned = await controlledName(tldInput, labelInput, userId); if (!owned.ok) return owned; const normalized = normalizeContent(input, { now }); @@ -1207,7 +1259,7 @@ export async function putContent({ tld: tldInput, label: labelInput, userId, ite /** Take one item back down. */ export async function deleteContent({ tld: tldInput, label: labelInput, userId, slug: slugInput }) { - const owned = await ownedName(tldInput, labelInput, userId); + const owned = await controlledName(tldInput, labelInput, userId); if (!owned.ok) return owned; const slug = normalizeSlug(slugInput); if (!slug) return { ok: false, error: "not a valid slug" }; @@ -1674,7 +1726,7 @@ export async function createLink({ if (nameInput) { const parsed = parseMoshpitName(nameInput); if (!parsed) return { ok: false, error: `not a moshpit name: ${nameInput}` }; - const owned = await ownedName(parsed.tld, parsed.label, userId); + const owned = await controlledName(parsed.tld, parsed.label, userId); if (!owned.ok) return owned; name = `${owned.label}.${owned.tld}`; } @@ -2252,3 +2304,577 @@ export async function unsyncedContacts(limit = 200) { [limit], ); } + +/* ---- offers, and the leases some of them become ---- */ + +const OFFER_COLS = `id, tld, label, kind, amount_usd, lease_months, offerer_email, offerer_user_id, message, + holder_user_id, status, verify_token, verified_at, counter_amount_usd, counter_months, countered_at, + payment_id, created_at, updated_at, expires_at`; + +const LEASE_COLS = `tld, label, lessee_user_id, holder_user_id, offer_id, months, amount_usd, starts_at, expires_at, created_at`; + +/** + * How many offers one address may make in a day, across the whole registry. + * + * The verification step is what stops the form being a way to mail strangers; + * this is what stops it being a way to mail one holder two hundred times. Both + * are needed -- a verified address can still be a determined nuisance. + */ +export const MAX_OFFERS_PER_DAY = 20; + +/** And one live offer per address per name. A second is a counter, not a new offer. */ +const OFFERS_PER_NAME = 1; + +export async function getOffer(id) { + return get(`SELECT ${OFFER_COLS} FROM moshpit_offers WHERE id = ?`, [String(id ?? "")]); +} + +export async function offerByVerifyToken(token) { + const raw = String(token ?? ""); + if (!raw) return null; + return get(`SELECT ${OFFER_COLS} FROM moshpit_offers WHERE verify_token = ?`, [raw]); +} + +export async function getLease(tld, label) { + return get(`SELECT ${LEASE_COLS} FROM moshpit_leases WHERE tld = ? AND label = ?`, [tld, label]); +} + +/** The lease running right now, or null. Read-time, so it ends exactly when it ends. */ +export async function activeLease(tld, label, now = Date.now()) { + const lease = await getLease(tld, label); + return leaseIsActive(lease, now) ? lease : null; +} + +export async function listLeasesForUser(userId) { + return all(`SELECT ${LEASE_COLS} FROM moshpit_leases WHERE lessee_user_id = ? ORDER BY expires_at DESC`, [userId]); +} + +/** + * May this account act on the name -- as its holder, or as the tenant renting it? + * + * The distinction the whole lease feature turns on. A lessee can point the + * name, publish under it, put records and keys on it: everything that makes the + * name usable for the term they paid for. What they cannot do is anything that + * outlives the lease -- give the name up, sell it, bind a clearnet twin to it, + * or change who is contacted about buying it. Those stay with the holder, and + * they stay on `ownedName`. + */ +async function controlledName(tldInput, labelInput, userId, now = Date.now()) { + const tld = normalizeTld(tldInput); + const label = normalizeLabel(labelInput); + if (!tld || !label) return { ok: false, error: "not a valid name" }; + + const existing = await getName(tld, label); + if (!existing) return { ok: false, error: `${label}.${tld} is not registered` }; + + // The holder, unless somebody is renting it. A holder who has let the name + // out does not get to keep pointing it somewhere else for the term -- that + // is what the tenant paid for, and two people editing one name is the state + // a lease has to rule out rather than race on. + const tenanted = existing.leased_to && existing.leased_until > now; + if (tenanted) { + return existing.leased_to === userId + ? { ok: true, tld, label, leased: true } + : { ok: false, error: `${label}.${tld} is leased until ${new Date(existing.leased_until).toISOString().slice(0, 10)}` }; + } + + // The same wording ownedName uses, deliberately. Somebody refused here owns + // nothing and rents nothing, so ownership is still the honest reason -- and + // the one case where control and ownership genuinely differ, an active lease, + // has its own message above that says so with the date. + if (existing.user_id !== userId) return { ok: false, error: `you do not own ${label}.${tld}` }; + return { ok: true, tld, label }; +} + +/** + * Who an offer for this name or ending would be put to. + * + * An unregistered name under an ending somebody holds is a legitimate subject: + * the operator can mint it and sell it, so they are the one to ask. That is the + * case the old page turned away with ".eggs is not for sale", which was true + * and unhelpful -- not for sale at a fixed price is not the same as not for + * sale. + */ +export async function offerTarget(tldInput, labelInput) { + const tld = normalizeTld(tldInput); + if (!tld) return { ok: false, error: "not a valid ending" }; + + const ending = await getTld(tld); + // Nobody holds the ending, so there is nobody to offer to -- and no need. + if (!ending) return { ok: false, error: `nobody holds .${tld} β€” claim it instead`, claimable: true }; + + const raw = String(labelInput ?? "").trim(); + if (!raw) return { ok: true, tld, label: "", holderId: ending.user_id, registered: true }; + + const label = normalizeLabel(raw); + if (!label) return { ok: false, error: "not a valid name" }; + + const name = await getName(tld, label); + return { + ok: true, tld, label, + holderId: name ? name.user_id : ending.user_id, + registered: Boolean(name), + }; +} + +/** + * Put an offer to whoever holds a name. + * + * Recorded `unverified` and not mentioned to the holder until the address + * proves it wants to be here. Everything about the ordering is that: the offer + * exists first so a click can find it, and the holder learns about it second so + * that a form submission alone cannot reach them. + */ +export async function makeOffer({ + tld: tldInput, label: labelInput, kind, amount, months, email, message, userId = null, now = Date.now(), +}) { + const target = await offerTarget(tldInput, labelInput); + if (!target.ok) return target; + + const offerKind = normalizeOfferKind(kind); + if (!offerKind) return { ok: false, error: `an offer is to ${OFFER_KINDS.join(" or ")}` }; + // See the migration: a name minted during an ending's lease would outlive the + // lease, so an ending is bought rather than rented. + if (offerKind === "lease" && !target.label) return { ok: false, error: "an ending can be bought, not leased" }; + + const amountUsd = normalizeOfferAmount(amount); + if (amountUsd === null) { + return { ok: false, error: `an offer has to be a number between $${MIN_OFFER_USD} and $${MAX_OFFER_USD}` }; + } + + const leaseMonths = offerKind === "lease" ? normalizeLeaseMonths(months) : null; + if (offerKind === "lease" && leaseMonths === null) { + return { ok: false, error: `a lease runs ${MIN_LEASE_MONTHS} to ${MAX_LEASE_MONTHS} months` }; + } + + const address = normalizeContactEmail(email); + if (!address) return { ok: false, error: "that does not look like an email address" }; + + if (userId && userId === target.holderId) { + return { ok: false, error: target.label ? "you hold this name already" : "you hold this ending already" }; + } + + // A name that is out on lease cannot cleanly be sold or re-let: the tenancy + // would either be broken by the sale or silently inherited by a buyer who + // never agreed to it. Said plainly, with the date, so the asker knows when to + // come back rather than thinking the name is unavailable outright. + if (target.label) { + const lease = await activeLease(target.tld, target.label, now); + if (lease) { + return { + ok: false, + error: `${target.label}.${target.tld} is leased until ${new Date(lease.expires_at).toISOString().slice(0, 10)}`, + }; + } + } + + const recent = await get( + `SELECT COUNT(*) AS n FROM moshpit_offers WHERE offerer_email = ? AND created_at > ?`, + [address, now - 24 * 60 * 60 * 1000], + ); + if (Number(recent?.n ?? 0) >= MAX_OFFERS_PER_DAY) { + return { ok: false, error: "that is a lot of offers for one day β€” try again tomorrow" }; + } + + const standing = await all( + `SELECT ${OFFER_COLS} FROM moshpit_offers + WHERE offerer_email = ? AND tld = ? AND label = ? AND status IN ('unverified','open','countered','accepted')`, + [address, target.tld, target.label], + ); + if (standing.filter((o) => offerIsLive(o, now)).length >= OFFERS_PER_NAME) { + return { ok: false, error: "you already have an offer standing on this β€” wait for an answer, or withdraw it" }; + } + + const id = mintOfferId(); + const token = mintVerifyToken(); + await run( + `INSERT INTO moshpit_offers + (id, tld, label, kind, amount_usd, lease_months, offerer_email, offerer_user_id, message, + holder_user_id, status, verify_token, created_at, updated_at, expires_at) + VALUES (?,?,?,?,?,?,?,?,?,?, 'unverified', ?,?,?,?)`, + [ + id, target.tld, target.label, offerKind, amountUsd, leaseMonths, + address, userId, normalizeOfferMessage(message), + target.holderId, token, now, now, now + OFFER_TTL_MS, + ], + ); + + return { ok: true, offer: await getOffer(id), verifyToken: token }; +} + +/** + * The click in the confirmation mail: the offer becomes one the holder can see. + * + * Idempotent, because people click links twice and mail clients fetch them + * once before the person does. A second click on an already-verified offer is + * a success that changes nothing, not an error about a token being used up. + */ +export async function verifyOffer(token, now = Date.now()) { + const offer = await offerByVerifyToken(token); + if (!offer) return { ok: false, error: "that confirmation link is not one of ours" }; + if (offer.status !== "unverified") { + return offerIsLive(offer, now) + ? { ok: true, offer, already: true } + : { ok: false, error: "that offer is no longer open", offer }; + } + if (offer.expires_at <= now) return { ok: false, error: "that offer expired before it was confirmed", offer }; + + await run( + `UPDATE moshpit_offers SET status = 'open', verified_at = ?, updated_at = ? WHERE id = ? AND status = 'unverified'`, + [now, now, offer.id], + ); + return { ok: true, offer: await getOffer(offer.id) }; +} + +/** + * Which side of the conversation an actor is on, or neither. + * + * The offerer may have no account at all, so possession of the token they were + * mailed stands in for one. It is the same proof the verification step already + * accepted -- if it is good enough to confirm the address, it is good enough to + * answer a counter from it. + */ +export function offerActor(offer, { userId = null, token = null } = {}) { + if (!offer) return null; + if (userId && offer.holder_user_id === userId) return "holder"; + if (token && offer.verify_token === token) return "offerer"; + if (userId && offer.offerer_user_id && offer.offerer_user_id === userId) return "offerer"; + return null; +} + +/** + * The holder's answer: take it, refuse it, or name a different number. + * + * Ownership is checked twice -- against the row, which says who it was put to, + * and against the registry, which says who holds the name now. A name that + * changed hands between the offer and the answer must not be sold by the + * person who used to have it. + */ +export async function respondToOffer({ id, userId, action, counterAmount, counterMonths, now = Date.now() }) { + const offer = await getOffer(id); + if (!offer) return { ok: false, error: "no such offer" }; + if (offerActor(offer, { userId }) !== "holder") return { ok: false, error: "that offer was not made to you" }; + if (effectiveStatus(offer, now) !== "open") return { ok: false, error: "that offer is not open" }; + + const target = await offerTarget(offer.tld, offer.label); + if (!target.ok || target.holderId !== userId) { + return { ok: false, error: "you no longer hold this, so it is not yours to answer" }; + } + + if (action === "reject") { + await run(`UPDATE moshpit_offers SET status = 'rejected', updated_at = ? WHERE id = ? AND status = 'open'`, [now, id]); + return { ok: true, offer: await getOffer(id) }; + } + + if (action === "accept") { + await run(`UPDATE moshpit_offers SET status = 'accepted', updated_at = ? WHERE id = ? AND status = 'open'`, [now, id]); + return { ok: true, offer: await getOffer(id) }; + } + + if (action === "counter") { + const amountUsd = normalizeOfferAmount(counterAmount); + if (amountUsd === null) { + return { ok: false, error: `a counter has to be a number between $${MIN_OFFER_USD} and $${MAX_OFFER_USD}` }; + } + // Months may be countered too: "not for three months, but I would do a + // year" is a real answer, and without it the only reply to a term you do + // not like is no. + const months = offer.kind === "lease" + ? (counterMonths === undefined || counterMonths === null || counterMonths === "" + ? offer.lease_months + : normalizeLeaseMonths(counterMonths)) + : null; + if (offer.kind === "lease" && months === null) { + return { ok: false, error: `a lease runs ${MIN_LEASE_MONTHS} to ${MAX_LEASE_MONTHS} months` }; + } + await run( + `UPDATE moshpit_offers SET status = 'countered', counter_amount_usd = ?, counter_months = ?, countered_at = ?, updated_at = ? + WHERE id = ? AND status = 'open'`, + [amountUsd, months, now, now, id], + ); + return { ok: true, offer: await getOffer(id) }; + } + + return { ok: false, error: "an answer is accept, reject or counter" }; +} + +/** The offerer's answer to a counter, and their way out of an offer they no longer want. */ +export async function answerCounter({ id, userId = null, token = null, action, now = Date.now() }) { + const offer = await getOffer(id); + if (!offer) return { ok: false, error: "no such offer" }; + if (offerActor(offer, { userId, token }) !== "offerer") return { ok: false, error: "that offer is not yours" }; + + const status = effectiveStatus(offer, now); + if (action === "withdraw") { + if (!["unverified", "open", "countered"].includes(status)) { + return { ok: false, error: "that offer is not open" }; + } + await run(`UPDATE moshpit_offers SET status = 'withdrawn', updated_at = ? WHERE id = ?`, [now, id]); + return { ok: true, offer: await getOffer(id) }; + } + + if (status !== "countered") return { ok: false, error: "there is no counter to answer" }; + if (action === "accept") { + await run( + `UPDATE moshpit_offers SET status = 'accepted', updated_at = ? WHERE id = ? AND status = 'countered'`, [now, id]); + return { ok: true, offer: await getOffer(id) }; + } + if (action === "reject") { + await run( + `UPDATE moshpit_offers SET status = 'rejected', updated_at = ? WHERE id = ? AND status = 'countered'`, [now, id]); + return { ok: true, offer: await getOffer(id) }; + } + return { ok: false, error: "an answer to a counter is accept or reject" }; +} + +export async function listOffersForHolder(userId, { limit = 200 } = {}) { + return all( + `SELECT ${OFFER_COLS} FROM moshpit_offers + WHERE holder_user_id = ? AND status != 'unverified' ORDER BY created_at DESC LIMIT ?`, + [userId, limit], + ); +} + +export async function listOffersForEmail(email, { limit = 200 } = {}) { + return all( + `SELECT ${OFFER_COLS} FROM moshpit_offers WHERE offerer_email = ? ORDER BY created_at DESC LIMIT ?`, + [String(email ?? "").toLowerCase(), limit], + ); +} + +/** Live offers on one name, for the holder's own page. Never shown to a visitor. */ +export async function listOffersForName(tld, label = "", { now = Date.now() } = {}) { + const rows = await all( + `SELECT ${OFFER_COLS} FROM moshpit_offers WHERE tld = ? AND label = ? ORDER BY created_at DESC`, [tld, label]); + return rows.filter((o) => offerIsLive(o, now)); +} + +/** + * Record the checkout for an accepted offer. + * + * The buyer needs an account by now even though they did not need one to ask: + * a name has to belong to somebody, and a lease has to be controllable by + * somebody. Their id is written onto the offer here, which is the moment an + * address becomes an account. + */ +export async function openOfferPurchase({ offerId, paymentId, userId, now = Date.now() }) { + await run( + `UPDATE moshpit_offers SET payment_id = ?, offerer_user_id = ?, updated_at = ? WHERE id = ? AND status = 'accepted'`, + [paymentId, userId, now, offerId], + ); + return getOffer(offerId); +} + +/** + * Money confirmed: move the name, or start the lease. Idempotent on the payment id. + * + * Claimed with a conditional UPDATE for the reason settleNamePurchase gives -- + * CoinPay retries a webhook it never got an ack for, so two deliveries can be + * in flight and both read 'accepted' before either write lands. Only the first + * claim moves anything. + */ +export async function settleOfferPurchase(paymentId, now = Date.now()) { + const offer = await get( + `SELECT ${OFFER_COLS} FROM moshpit_offers WHERE payment_id = ? AND status = 'accepted'`, [paymentId]); + if (!offer) return { ok: false, error: "no accepted offer for that payment" }; + + const claimed = await run( + `UPDATE moshpit_offers SET status = 'settling', updated_at = ? WHERE id = ? AND status = 'accepted'`, + [now, offer.id], + ); + if (!claimed.rowsAffected) return { ok: false, error: "already settled" }; + + const terms = agreedTerms(offer); + const buyerId = offer.offerer_user_id; + if (!buyerId) { + await run(`UPDATE moshpit_offers SET status = 'refund_due', updated_at = ? WHERE id = ?`, [now, offer.id]); + console.error(`[moshpit] offer ${offer.id} was paid with no buyer account β€” refund due`); + return { ok: false, error: "no account to give it to", refundDue: true }; + } + + const failed = async (why) => { + await run(`UPDATE moshpit_offers SET status = 'refund_due', updated_at = ? WHERE id = ?`, [now, offer.id]); + console.error(`[moshpit] offer ${offer.id} paid but not delivered β€” ${why}. Refund due to ${buyerId}`); + return { ok: false, error: why, refundDue: true }; + }; + + if (offer.kind === "lease") { + // Checked again here rather than trusted from acceptance: a lease could + // have been granted to somebody else in between, and two tenancies on one + // name is the state the primary key exists to refuse. + if (await activeLease(offer.tld, offer.label, now)) return failed("the name was leased to someone else first"); + const expiresAt = leaseEndsAt(now, terms.months); + + // A lease can be taken on a name nobody has minted yet -- the operator of + // the ending is the one who was asked, and they mint it to let it. The row + // has to exist before it can carry a tenant, and it belongs to the holder, + // not the tenant: a lease is the one transaction here that does not move + // ownership. + const existing = await getName(offer.tld, offer.label); + if (!existing) { + try { + await run(`INSERT INTO moshpit_names (tld, label, user_id, target, created_at) VALUES (?,?,?,?,?)`, + [offer.tld, offer.label, offer.holder_user_id, null, now]); + } catch { + return failed("the name was taken before payment settled"); + } + } else if (existing.user_id !== offer.holder_user_id) { + return failed("the name changed hands before payment settled"); + } + + await run( + `INSERT OR REPLACE INTO moshpit_leases + (tld, label, lessee_user_id, holder_user_id, offer_id, months, amount_usd, starts_at, expires_at, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + [offer.tld, offer.label, buyerId, offer.holder_user_id, offer.id, terms.months, terms.amountUsd, now, expiresAt, now], + ); + // The cache the hot path reads. Written in the same breath as the record + // above, because a lease that exists in one and not the other is a tenancy + // that has been paid for and grants nothing. + await run(`UPDATE moshpit_names SET leased_to = ?, leased_until = ? WHERE tld = ? AND label = ?`, + [buyerId, expiresAt, offer.tld, offer.label]); + await run(`UPDATE moshpit_offers SET status = 'paid', updated_at = ? WHERE id = ?`, [now, offer.id]); + await logAction(offer.tld, buyerId, `leased:${offer.label}`); + return { ok: true, kind: "lease", tld: offer.tld, label: offer.label, userId: buyerId, expiresAt }; + } + + if (offer.label) { + const existing = await getName(offer.tld, offer.label); + if (existing) { + // The seller must still be the seller. If the name moved between + // acceptance and confirmation, this buyer is paying its previous holder + // for something that is no longer theirs. + if (existing.user_id !== offer.holder_user_id) return failed("the name changed hands before payment settled"); + await handOverName(offer.tld, offer.label, buyerId); + } else { + try { + await run(`INSERT INTO moshpit_names (tld, label, user_id, target, created_at) VALUES (?,?,?,?,?)`, + [offer.tld, offer.label, buyerId, null, now]); + } catch { + return failed("the name was taken before payment settled"); + } + } + await run(`UPDATE moshpit_offers SET status = 'paid', updated_at = ? WHERE id = ?`, [now, offer.id]); + await logAction(offer.tld, buyerId, `bought:${offer.label}`); + await closeOtherOffers(offer, now); + return { ok: true, kind: "buy", tld: offer.tld, label: offer.label, userId: buyerId }; + } + + // An ending changing hands. Conditional on the seller still holding it, for + // the same reason as above. + const moved = await run( + `UPDATE moshpit_tlds SET user_id = ?, owner_email = NULL WHERE tld = ? AND user_id = ?`, + [buyerId, offer.tld, offer.holder_user_id], + ); + if (!moved.rowsAffected) return failed("the ending changed hands before payment settled"); + + await run(`UPDATE moshpit_offers SET status = 'paid', updated_at = ? WHERE id = ?`, [now, offer.id]); + await logAction(offer.tld, buyerId, "bought"); + await closeOtherOffers(offer, now); + return { ok: true, kind: "buy", tld: offer.tld, label: "", userId: buyerId }; +} + +/** + * Move a name to its buyer, and leave nothing of the seller's on it. + * + * The same list releaseName clears, and for the same reason: a pin, a record, + * a twin or a contact that survives a sale belongs to the person who just sold + * the name. The contact is the one with a consequence outside this database -- + * its guard address forwards mail at our domain, so an inherited one would + * deliver the buyer's mail to the seller. + */ +async function handOverName(tld, label, buyerId) { + await revokeContactAlias(await getContactPrivate(tld, label)); + await run(`DELETE FROM moshpit_contacts WHERE tld = ? AND label = ?`, [tld, label]); + await run(`DELETE FROM moshpit_name_pins WHERE tld = ? AND label = ?`, [tld, label]); + await run(`DELETE FROM moshpit_records WHERE tld = ? AND label = ?`, [tld, label]); + await run(`DELETE FROM moshpit_twins WHERE tld = ? AND label = ?`, [tld, label]); + // The target goes too. It names a server the seller runs, and leaving it + // would point the buyer's new name at the seller's machine. + await run( + `UPDATE moshpit_names SET user_id = ?, target = NULL, feed_url = NULL, feed_kind = NULL, + leased_to = NULL, leased_until = NULL WHERE tld = ? AND label = ?`, + [buyerId, tld, label], + ); +} + +/** + * Give a name back at the end of its term. + * + * effectiveStatus and the `leased_until` check are what make a lease end on + * time; this is what makes the name look like it. The tenant's target, feed, + * records and keys go with them, because the alternative is the holder getting + * their name back still serving somebody else's site under it -- for as long as + * they do not happen to look. + * + * Content is left alone deliberately. It is the one thing here that is written + * rather than pointed at, and deleting a tenant's posts because their lease + * lapsed destroys work rather than unlinking it. It stops being served the + * moment the target does, and the holder can clear it. + */ +export async function endExpiredLeases(now = Date.now(), limit = 200) { + const due = await all( + `SELECT ${LEASE_COLS} FROM moshpit_leases WHERE reverted_at IS NULL AND expires_at <= ? LIMIT ?`, + [now, limit], + ); + + for (const lease of due) { + await run(`DELETE FROM moshpit_records WHERE tld = ? AND label = ?`, [lease.tld, lease.label]); + await run(`DELETE FROM moshpit_name_pins WHERE tld = ? AND label = ?`, [lease.tld, lease.label]); + await run( + `UPDATE moshpit_names SET target = NULL, feed_url = NULL, feed_kind = NULL, leased_to = NULL, leased_until = NULL + WHERE tld = ? AND label = ?`, + [lease.tld, lease.label], + ); + await run(`UPDATE moshpit_leases SET reverted_at = ? WHERE tld = ? AND label = ?`, [now, lease.tld, lease.label]); + await logAction(lease.tld, lease.holder_user_id, `unleased:${lease.label}`); + } + + return { reverted: due.length }; +} + +/** + * Everyone else who was still asking about this is asking about something sold. + * + * Closed rather than left open, because an offer that cannot be accepted is + * worse than no offer: the holder would be looking at numbers for a name they + * no longer have, and the people who made them would be waiting on an answer + * that can never come. + */ +async function closeOtherOffers(sold, now) { + await run( + `UPDATE moshpit_offers SET status = 'rejected', updated_at = ? + WHERE tld = ? AND label = ? AND id != ? AND status IN ('unverified','open','countered')`, + [now, sold.tld, sold.label, sold.id], + ); +} + +/** + * Write down what the clock already decided. + * + * effectiveStatus() is what makes an offer expired; this only makes the column + * agree, so a listing query can filter on it. Nothing depends on it running. + */ +/** + * Where to mail the holder about an offer. + * + * Their account address, not the guard address a contact publishes. The two are + * for opposite directions: a guard address is how a stranger reaches them + * without learning who they are, and this is the registry telling its own user + * something about their account. Routing our own mail through the forwarder + * would make it undeliverable exactly when it matters -- for a holder who has + * no contact set, which is most of them. + */ +export async function userEmail(userId) { + const row = await get(`SELECT email FROM users WHERE id = ?`, [userId]); + return row?.email ?? null; +} + +export async function expireOffers(now = Date.now()) { + const result = await run( + `UPDATE moshpit_offers SET status = 'expired', updated_at = ? + WHERE expires_at <= ? AND status IN ('unverified','open','countered')`, + [now, now], + ); + return { expired: Number(result.rowsAffected ?? 0) }; +} diff --git a/apps/pwa/src/routes/credits.mjs b/apps/pwa/src/routes/credits.mjs index 44fc0a6..5567a58 100644 --- a/apps/pwa/src/routes/credits.mjs +++ b/apps/pwa/src/routes/credits.mjs @@ -6,7 +6,7 @@ import { id } from "../lib/crypto.mjs"; import { grant } from "../lib/credits.mjs"; import { verifySignature } from "../lib/signature.mjs"; import { requireAuth } from "../lib/session.mjs"; -import { settleNamePurchase, settleTldPurchase } from "../moshpit.mjs"; +import { settleNamePurchase, settleOfferPurchase, settleTldPurchase } from "../moshpit.mjs"; export const creditsRouter = Router(); @@ -75,6 +75,10 @@ creditsRouter.post("/webhooks/coinpay", async (req, res) => { // An ending is a different table from a name, and this is the one webhook // URL CoinPay is configured with β€” whichever row the id belongs to settles. await settleTldPurchase(payId).catch((e) => console.error("[moshpit] tld settle failed:", e.message)); + // And an accepted offer, which is a third table again β€” a negotiated sale + // or a lease rather than a listed price. Same reasoning: one webhook URL, + // and whichever row owns this id is the one that settles. + await settleOfferPurchase(payId).catch((e) => console.error("[moshpit] offer settle failed:", e.message)); const p = await get(`SELECT * FROM credit_purchases WHERE id = ? AND status = 'pending'`, [payId]); if (p) { diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index 93ee9b8..469d08c 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -43,6 +43,11 @@ import { balance } from "../lib/credits.mjs"; import { resolverConfig } from "../lib/moshpit-resolvers.mjs"; import { landingFor } from "../lib/moshpit-landing.mjs"; import { FEED_KINDS, loadFeed } from "../lib/feed.mjs"; +import { + sendOfferAnswer, + sendOfferToHolder, + sendOfferVerification, +} from "../lib/moshpit-offer-mail.mjs"; import { FEED_CSS, feedPage, feedUnavailable } from "../lib/moshpit-feed-page.mjs"; import { CONTENT_KINDS, MAX_BATCH } from "../lib/moshpit-content.mjs"; import { @@ -55,8 +60,11 @@ import { MAX_BODY_BYTES, ORIGIN_TIMEOUT_MS, checkTarget, fetchOrigin, fetchOriginTls, forwardableHeaders, tlsRedirect, } from "../lib/moshpit-gateway.mjs"; import { + activeLease, addPin, + agreedTerms, addRecord, + answerCounter, bumpLink, clearAlias, clearExempt, @@ -75,12 +83,15 @@ import { DEFAULT_TLD_PRICE_USD, DEFAULT_VISIBILITY, ENDING_PRICE_USD, + describeOffer, deleteContent, deleteLink, + effectiveStatus, getContactPrivate, getContent, getLink, getName, + getOffer, getTld, getTldWithPrice, guardAddress, @@ -89,9 +100,11 @@ import { listContent, listAllNames, listExempt, + listLeasesForUser, listLinks, listNames, listNamesForUser, + listOffersForHolder, listPins, listRecordNames, listRecords, @@ -103,12 +116,16 @@ import { MAX_LISTING_PRICE_USD, MAX_TTL, MIN_TTL, + makeOffer, normalizeLabel, normalizeMode, normalizePinKind, normalizeSlug, normalizeTld, + offerActor, + offerIsLive, openNamePurchase, + openOfferPurchase, parseMoshpitName, PIN_KINDS, pinsForName, @@ -129,6 +146,7 @@ import { resolutionPreference, resolveMoshpitName, retryContactAlias, + respondToOffer, searchTlds, setAlias, setContact, @@ -141,6 +159,8 @@ import { summarizeBulkClaim, tldLog, tldRejection, + userEmail, + verifyOffer, zoneLine, TWIN_PRICE_USD, availableTwins, @@ -1510,6 +1530,8 @@ function endingDirectory({ tld, owner, names, aliasesTo = [], sameOwner = [], su

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 f1198f4..b2362c1 100644 --- a/apps/pwa/src/server.mjs +++ b/apps/pwa/src/server.mjs @@ -17,6 +17,7 @@ import { settingsSyncRouter } from "./routes/settings-sync.mjs"; import { moshpitRouter } from "./routes/moshpit.mjs"; import { socialsRouter } from "./routes/socials.mjs"; import { MAX_BATCH, MAX_PUBLISH_BYTES } from "./lib/moshpit-content.mjs"; +import { endExpiredLeases, expireOffers } from "./moshpit.mjs"; const app = express(); app.disable("x-powered-by"); @@ -97,8 +98,38 @@ app.use((err, req, res, _next) => { res.status(500).type("html").send(`

500

a bug got in. (there are no bugs, only features.)

`); }); +/** + * How often the clock's decisions get written down. + * + * Both sweeps are tidy-up: an offer is expired because its date passed and a + * lease is over because its term ran out, and every reader works that out for + * itself. What the sweep adds is the part a reader cannot do, because it is a + * write -- taking the tenant's target, records and keys back off a name whose + * lease has ended, so the name stops serving their site rather than merely + * stopping resolving as theirs. + * + * Hourly, and once at boot. Nothing here is time-critical to the minute, and a + * lease that ends at 3am should not need somebody awake. + */ +const SWEEP_MS = 60 * 60 * 1000; + +async function sweep() { + try { + const { expired } = await expireOffers(); + const { reverted } = await endExpiredLeases(); + if (expired || reverted) console.log(`🧹 moshpit: ${expired} offers expired, ${reverted} leases reverted`); + } catch (e) { + // Logged and swallowed. A sweep that throws must not take the process with + // it -- everything it does, the read-time checks already do correctly. + console.error("moshpit sweep failed:", e?.message ?? e); + } +} + async function main() { await migrate(); + await sweep(); + // Unref'd so it never holds the process open on its own. + setInterval(sweep, SWEEP_MS).unref(); app.listen(config.port, () => console.log(`🀘 app.moshcode.sh on :${config.port} (${config.env}) β€” ${config.origin}`)); } main().catch((e) => { console.error("boot failed:", e); process.exit(1); }); diff --git a/apps/pwa/test/moshpit-offers.test.mjs b/apps/pwa/test/moshpit-offers.test.mjs new file mode 100644 index 0000000..99a0bb8 --- /dev/null +++ b/apps/pwa/test/moshpit-offers.test.mjs @@ -0,0 +1,462 @@ +// Offers on a parked name, and the leases some of them become. +// +// The behaviour under test is mostly about what does NOT happen: an offer does +// not reach a holder until the address is confirmed, an accepted offer does not +// move a name until money confirms, a tenant does not keep control after their +// term, and nobody but the holder ever sees what was offered. +// +// Same harness as moshpit-contact.test.mjs: the real router against a throwaway +// libsql file, skipped cleanly when the PWA deps are not installed. The pure +// rules run either way. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; + +import { + agreedTerms, + describeOffer, + effectiveStatus, + leaseEndsAt, + leaseIsActive, + normalizeLeaseMonths, + normalizeOfferAmount, + normalizeOfferKind, + offerIsLive, +} from "../src/lib/moshpit-offer.mjs"; + +const require = createRequire(import.meta.url); +let deps = null; +try { + deps = { express: require("express") }; +} catch { + deps = null; +} + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-offers-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; +process.env.PUBLIC_ORIGIN = "https://app.example.test"; +process.env.PIT_ORIGIN = "https://pit.example.test"; +delete process.env.RESEND_API_KEY; +delete process.env.FORWARDEMAIL_API_KEY; + +/* ---- the rules, which need nothing ---- */ + +test("money is read the way people type it, and rounded to cents", () => { + assert.equal(normalizeOfferAmount("$1,200"), 1200); + assert.equal(normalizeOfferAmount(" 99.99 "), 99.99); + assert.equal(normalizeOfferAmount("2500"), 2500); +}); + +test("an offer below the floor or beyond the bound is not an offer", () => { + // The floor is not a view about what a name is worth. It is what makes + // "offer nothing on all 18,000 endings" cost something to say. + for (const bad of ["", "0", "0.5", "-100", "abc", "1e400", "2000000000"]) { + assert.equal(normalizeOfferAmount(bad), null, `expected ${JSON.stringify(bad)} to be refused`); + } +}); + +test("a term is whole months inside the bounds", () => { + assert.equal(normalizeLeaseMonths("12"), 12); + assert.equal(normalizeLeaseMonths("1"), 1); + assert.equal(normalizeLeaseMonths("60"), 60); + for (const bad of ["0", "61", "1.5", "-3", "", "twelve"]) { + assert.equal(normalizeLeaseMonths(bad), null, `expected ${JSON.stringify(bad)} to be refused`); + } +}); + +test("an offer is to buy or to lease, and nothing else", () => { + assert.equal(normalizeOfferKind("BUY"), "buy"); + assert.equal(normalizeOfferKind("lease"), "lease"); + assert.equal(normalizeOfferKind("rent"), null); +}); + +test("a term runs in calendar months, and the short-month case picks a side", () => { + // One month from 31 January is 28 February. The alternative is rolling into + // March, which quietly hands the tenant a day they did not pay for. + const jan31 = Date.UTC(2026, 0, 31, 12, 0, 0); + assert.equal(new Date(leaseEndsAt(jan31, 1)).toISOString().slice(0, 10), "2026-02-28"); + const mar15 = Date.UTC(2026, 2, 15, 9, 30, 0); + assert.equal(new Date(leaseEndsAt(mar15, 12)).toISOString().slice(0, 10), "2027-03-15"); + assert.equal(new Date(leaseEndsAt(mar15, 3)).toISOString().slice(0, 10), "2026-06-15"); +}); + +test("a counter is what is operative once it exists", () => { + const offer = { kind: "lease", amount_usd: 100, lease_months: 3, counter_amount_usd: 250, counter_months: 12 }; + assert.deepEqual(agreedTerms(offer), { amountUsd: 250, months: 12, countered: true }); + + const plain = { kind: "buy", amount_usd: 100, lease_months: null, counter_amount_usd: null, counter_months: null }; + assert.deepEqual(agreedTerms(plain), { amountUsd: 100, months: null, countered: false }); +}); + +test("an offer expires by the clock, not by a sweep having run", () => { + const now = Date.now(); + const stale = { status: "open", expires_at: now - 1000 }; + assert.equal(effectiveStatus(stale, now), "expired"); + assert.equal(offerIsLive(stale, now), false); + + // Accepted is exempt: from there the only thing outstanding is a payment, and + // a bill does not stop being owed because thirty days went by. + const agreed = { status: "accepted", expires_at: now - 1000 }; + assert.equal(effectiveStatus(agreed, now), "accepted"); + assert.equal(offerIsLive(agreed, now), true); +}); + +test("a lease is active only between its dates", () => { + const now = Date.now(); + assert.equal(leaseIsActive({ starts_at: now - 1000, expires_at: now + 1000 }, now), true); + assert.equal(leaseIsActive({ starts_at: now - 2000, expires_at: now - 1000 }, now), false); + assert.equal(leaseIsActive({ starts_at: now + 1000, expires_at: now + 2000 }, now), false); + assert.equal(leaseIsActive(null, now), false); +}); + +test("an offer reads as one line, money first", () => { + assert.match(describeOffer({ kind: "buy", tld: "eggs", label: "blue", amount_usd: 2500 }), /^\$2,500 to buy blue\.eggs$/); + assert.match( + describeOffer({ kind: "lease", tld: "eggs", label: "blue", amount_usd: 300, lease_months: 1 }), + /\$300 to lease blue\.eggs for 1 month$/, + ); + assert.match(describeOffer({ kind: "buy", tld: "eggs", label: "", amount_usd: 40 }), /to buy \.eggs$/); +}); + +/* ---- storage and routes ---- */ + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, get, db } = await import("../src/db.mjs"); + const { moshpitRouter } = await import("../src/routes/moshpit.mjs"); + const moshpit = await import("../src/moshpit.mjs"); + + await run(`INSERT OR REPLACE INTO users (id,email,created_at) VALUES ('holder','holder@example.com',1)`); + await run(`INSERT OR REPLACE INTO users (id,email,created_at) VALUES ('buyer','buyer@example.com',1)`); + await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,created_at) VALUES ('eggs','holder','holder@example.com',1)`); + // Held and pointed somewhere: the "claimed but does not point anywhere" case + // the offer form exists to replace. + await run(`INSERT INTO moshpit_names (tld,label,user_id,target,created_at) VALUES ('eggs','blue','holder',NULL,1)`); + await run(`INSERT INTO moshpit_names (tld,label,user_id,target,created_at) VALUES ('eggs','rent','holder',NULL,1)`); + + const app = deps.express(); + app.use(deps.express.json()); + app.use((req, _res, next) => { req.csrfToken = () => "csrf"; next(); }); + app.use((req, _res, next) => { + const id = req.headers["x-test-user"]; + if (id) req.user = { id, email: `${id}@example.com` }; + next(); + }); + app.use(moshpitRouter); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const getHtml = async (p, userId = null) => { + const res = await fetch(`${base}${p}`, { headers: userId ? { "x-test-user": userId } : {} }); + return { status: res.status, body: await res.text() }; + }; + + // Paying, without CoinPay: the two writes the checkout and the webhook would + // have made, in the order they make them. + const pay = async (offerId, buyerId, paymentId) => { + await moshpit.openOfferPurchase({ offerId, paymentId, userId: buyerId }); + return moshpit.settleOfferPurchase(paymentId); + }; + + return { server, db, run, get, getHtml, moshpit, pay }; +} + +let booted = null; +const app = () => (booted ||= boot()); + +test.after(() => { + if (!booted) return; + booted.then(({ server, db }) => { server.close(); db.close?.(); }) + .finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } }); +}); + +const skip = { skip: !deps && "apps/pwa deps not installed" }; + +test("an offer does not reach the holder until the address is confirmed", skip, async () => { + const { moshpit } = await app(); + const made = await moshpit.makeOffer({ + tld: "eggs", label: "blue", kind: "buy", amount: "2500", email: "Buyer ", + message: "I run a paint shop", + }); + + assert.equal(made.ok, true); + assert.equal(made.offer.status, "unverified"); + assert.equal(made.offer.amount_usd, 2500); + assert.equal(made.offer.offerer_email, "buyer@example.com"); + assert.equal(made.offer.holder_user_id, "holder"); + + // This is the anti-spam property, and it is worth asserting rather than + // trusting: an unverified offer is not merely unsent, it is not on the + // holder's page at all. + const theirs = await moshpit.listOffersForHolder("holder"); + assert.equal(theirs.length, 0); +}); + +test("confirming it is what puts it in front of the holder, and twice is fine", skip, async () => { + const { moshpit } = await app(); + const [offer] = await moshpit.listOffersForEmail("buyer@example.com"); + + const first = await moshpit.verifyOffer(offer.verify_token); + assert.equal(first.ok, true); + assert.equal(first.offer.status, "open"); + assert.ok(first.offer.verified_at); + + // Mail clients fetch links, and people click twice. A second confirmation is + // a success that changes nothing, not a used-up token. + const second = await moshpit.verifyOffer(offer.verify_token); + assert.equal(second.ok, true); + assert.equal(second.already, true); + + const theirs = await moshpit.listOffersForHolder("holder"); + assert.equal(theirs.length, 1); +}); + +test("a confirmation link that is not ours is refused", skip, async () => { + const { moshpit } = await app(); + const result = await moshpit.verifyOffer("not-a-real-token"); + assert.equal(result.ok, false); +}); + +test("you cannot offer on what you already hold", skip, async () => { + const { moshpit } = await app(); + const result = await moshpit.makeOffer({ + tld: "eggs", label: "blue", kind: "buy", amount: "10", email: "holder@example.com", userId: "holder", + }); + assert.equal(result.ok, false); + assert.match(result.error, /hold this name already/); +}); + +test("one standing offer per name per address β€” a second is a counter, not a new offer", skip, async () => { + const { moshpit } = await app(); + const again = await moshpit.makeOffer({ + tld: "eggs", label: "blue", kind: "buy", amount: "3000", email: "buyer@example.com", + }); + assert.equal(again.ok, false); + assert.match(again.error, /already have an offer standing/); +}); + +test("an unminted name is offerable, because the operator can mint it", skip, async () => { + const { moshpit } = await app(); + // The case the old page turned away with ".eggs is not for sale" β€” true, and + // not the same as "you cannot have it". + const target = await moshpit.offerTarget("eggs", "unminted"); + assert.equal(target.ok, true); + assert.equal(target.registered, false); + assert.equal(target.holderId, "holder"); +}); + +test("an ending nobody holds is claimed, not offered on", skip, async () => { + const { moshpit } = await app(); + const target = await moshpit.offerTarget("nobodyholdsthis", ""); + assert.equal(target.ok, false); + assert.equal(target.claimable, true); +}); + +test("an ending can be bought and not rented", skip, async () => { + const { moshpit } = await app(); + const result = await moshpit.makeOffer({ + tld: "eggs", label: "", kind: "lease", amount: "500", months: "12", email: "someone@example.com", + }); + assert.equal(result.ok, false); + assert.match(result.error, /bought, not leased/); +}); + +test("the holder can counter, and the counter is what gets paid", skip, async () => { + const { moshpit } = await app(); + const [offer] = await moshpit.listOffersForHolder("holder"); + + const countered = await moshpit.respondToOffer({ + id: offer.id, userId: "holder", action: "counter", counterAmount: "4000", + }); + assert.equal(countered.ok, true); + assert.equal(countered.offer.status, "countered"); + assert.equal(agreedTerms(countered.offer).amountUsd, 4000); + // The original is kept beside it: a negotiation that rewrites its own history + // is one neither side can check. + assert.equal(countered.offer.amount_usd, 2500); + + const taken = await moshpit.answerCounter({ + id: offer.id, token: offer.verify_token, action: "accept", + }); + assert.equal(taken.ok, true); + assert.equal(taken.offer.status, "accepted"); +}); + +test("only the holder answers an offer, and only the offerer answers a counter", skip, async () => { + const { moshpit } = await app(); + const [offer] = await moshpit.listOffersForHolder("holder"); + + const notYours = await moshpit.respondToOffer({ id: offer.id, userId: "buyer", action: "accept" }); + assert.equal(notYours.ok, false); + assert.match(notYours.error, /not made to you/); + + const noToken = await moshpit.answerCounter({ id: offer.id, token: "wrong", action: "withdraw" }); + assert.equal(noToken.ok, false); +}); + +test("paying transfers the name, and leaves nothing of the seller's on it", skip, async () => { + const { moshpit, run, get, pay } = await app(); + const [offer] = await moshpit.listOffersForHolder("holder"); + + // The seller's fingerprints: a contact with a forwarding alias, a record, a + // key, and a target. All four belong to the person selling the name. + await moshpit.setContact({ tld: "eggs", label: "blue", userId: "holder", email: "holder@example.com" }); + const pointedBySeller = await moshpit.setNameTarget({ + tld: "eggs", label: "blue", userId: "holder", target: "2001:db8::9", + }); + assert.equal(pointedBySeller.ok, true, JSON.stringify(pointedBySeller)); + await run(`INSERT INTO moshpit_records (tld,label,type,value,ttl,priority,user_id,created_at) + VALUES ('eggs','blue','TXT','hello',300,NULL,'holder',1)`); + + const settled = await pay(offer.id, "buyer", "pay_transfer_1"); + assert.equal(settled.ok, true); + assert.equal(settled.kind, "buy"); + + const name = await moshpit.getName("eggs", "blue"); + assert.equal(name.user_id, "buyer"); + // Cleared, not inherited. A target names the seller's server; a guard address + // forwards the buyer's mail to the seller. + assert.equal(name.target, null); + assert.equal(await get(`SELECT tld FROM moshpit_contacts WHERE tld='eggs' AND label='blue'`), undefined ?? null); + assert.ok(!(await get(`SELECT tld FROM moshpit_records WHERE tld='eggs' AND label='blue'`))); + assert.ok(!(await get(`SELECT tld FROM moshpit_name_pins WHERE tld='eggs' AND label='blue'`))); +}); + +test("settling twice moves nothing twice", skip, async () => { + const { moshpit, pay } = await app(); + // CoinPay retries a webhook it never got an ack for. The second delivery has + // to be a no-op rather than a second transfer. + const again = await moshpit.settleOfferPurchase("pay_transfer_1"); + assert.equal(again.ok, false); + assert.equal((await moshpit.getName("eggs", "blue")).user_id, "buyer"); +}); + +test("a lease grants control without moving the name", skip, async () => { + const { moshpit, pay } = await app(); + const made = await moshpit.makeOffer({ + tld: "eggs", label: "rent", kind: "lease", amount: "600", months: "6", email: "buyer@example.com", + }); + await moshpit.verifyOffer(made.verifyToken); + await moshpit.respondToOffer({ id: made.offer.id, userId: "holder", action: "accept" }); + const settled = await pay(made.offer.id, "buyer", "pay_lease_1"); + + assert.equal(settled.ok, true, JSON.stringify(settled)); + assert.equal(settled.kind, "lease"); + + // Ownership does not move. That is the whole difference from a sale. + const name = await moshpit.getName("eggs", "rent"); + assert.equal(name.user_id, "holder"); + assert.equal(name.leased_to, "buyer"); + + // The tenant can use it... + const pointed = await moshpit.setNameTarget({ tld: "eggs", label: "rent", userId: "buyer", target: "2001:db8::20" }); + assert.equal(pointed.ok, true, JSON.stringify(pointed)); + assert.equal((await moshpit.resolveMoshpitName("rent.eggs")).target, "2001:db8::20"); + + // ...and the holder cannot point it out from under them mid-term. + const blocked = await moshpit.setNameTarget({ tld: "eggs", label: "rent", userId: "holder", target: "2001:db8::99" }); + assert.equal(blocked.ok, false); + assert.match(blocked.error, /leased until/); +}); + +test("a leased name cannot be given up, sold or re-let mid-term", skip, async () => { + const { moshpit } = await app(); + const released = await moshpit.releaseName({ tld: "eggs", label: "rent", userId: "holder" }); + assert.equal(released.ok, false); + assert.match(released.error, /cannot give it up mid-term/); + + const relet = await moshpit.makeOffer({ + tld: "eggs", label: "rent", kind: "lease", amount: "900", months: "3", email: "third@example.com", + }); + assert.equal(relet.ok, false); + assert.match(relet.error, /is leased until/); +}); + +test("a lease ends on its date, with or without a sweep", skip, async () => { + const { moshpit, run } = await app(); + // Wind the term back rather than waiting six months for it. + await run(`UPDATE moshpit_leases SET expires_at = ? WHERE tld='eggs' AND label='rent'`, [Date.now() - 1000]); + await run(`UPDATE moshpit_names SET leased_until = ? WHERE tld='eggs' AND label='rent'`, [Date.now() - 1000]); + + // Read-time: control is back with the holder immediately, and the tenant's + // site stops being served β€” before anything has been round to tidy up. + const resolution = await moshpit.resolveMoshpitName("rent.eggs"); + assert.equal(resolution.target, null, "a lapsed tenant's target must stop being served at once"); + + const tenant = await moshpit.setNameTarget({ tld: "eggs", label: "rent", userId: "buyer", target: "2001:db8::21" }); + assert.equal(tenant.ok, false); + + const holder = await moshpit.setNameTarget({ tld: "eggs", label: "rent", userId: "holder", target: "2001:db8::1" }); + assert.equal(holder.ok, true); +}); + +test("the sweep takes the tenant's things off the name", skip, async () => { + const { moshpit, run, get } = await app(); + await run(`INSERT INTO moshpit_records (tld,label,type,value,ttl,priority,user_id,created_at) + VALUES ('eggs','rent','TXT','tenant',300,NULL,'buyer',1)`); + + const swept = await moshpit.endExpiredLeases(); + assert.equal(swept.reverted, 1); + + const name = await moshpit.getName("eggs", "rent"); + assert.equal(name.leased_to, null); + assert.equal(name.target, null); + assert.ok(!(await get(`SELECT tld FROM moshpit_records WHERE tld='eggs' AND label='rent'`))); + + // Idempotent: a reverted lease is not reverted again on the next hour's run. + assert.equal((await moshpit.endExpiredLeases()).reverted, 0); +}); + +/* ---- the pages ---- */ + +test("a parked name offers a way to ask, instead of ending the conversation", skip, async () => { + const { getHtml } = await app(); + const { status, body } = await getHtml("/n/unminted.eggs"); + + assert.equal(status, 200); + assert.match(body, /Make an offer/); + assert.match(body, /action="\/pit\/offer"/); + assert.match(body, /lease it/); +}); + +test("an ending's page takes offers too, and does not offer to rent it", skip, async () => { + const { getHtml } = await app(); + const { body } = await getHtml("/n/.eggs"); + + assert.match(body, /Make an offer/); + assert.doesNotMatch(body, /lease it/); +}); + +test("the holder is not invited to bid on their own name", skip, async () => { + const { getHtml } = await app(); + const { body } = await getHtml("/n/unminted.eggs", "holder"); + assert.doesNotMatch(body, /action="\/pit\/offer"/); +}); + +test("a visitor never sees what anyone offered", skip, async () => { + const { getHtml } = await app(); + // Private negotiation is the product decision; this is the assertion that + // keeps it true as the page changes. + for (const route of ["/n/blue.eggs", "/n/.eggs", "/n/rent.eggs"]) { + const { body } = await getHtml(route); + assert.doesNotMatch(body, /2500|4000|I run a paint shop/, `${route} leaked an offer`); + } +}); + +test("the offers tab shows the holder what was offered, and the visitor nothing", skip, async () => { + const { getHtml } = await app(); + const mine = await getHtml("/pit/offers", "holder"); + assert.equal(mine.status, 200); + assert.match(mine.body, /blue\.eggs|rent\.eggs/); + + const anon = await getHtml("/pit/offers"); + assert.match(anon.body, /Sign in to see what people have offered/); + assert.doesNotMatch(anon.body, /I run a paint shop/); +}); diff --git a/docs/offers-and-leases.md b/docs/offers-and-leases.md new file mode 100644 index 0000000..88bda94 --- /dev/null +++ b/docs/offers-and-leases.md @@ -0,0 +1,157 @@ +# Offers on a parked name, and leasing + +Every parked page used to end the conversation. A name somebody held said +"claimed but does not point anywhere yet". A name under an ending with no price +said ".eggs is not for sale". Both are true, both arrive at the exact moment a +visitor wants the name most, and both left the holder never hearing that anyone +asked. + +An offer is the missing half. A visitor says what a name is worth to them; the +holder accepts, refuses, or names a different number. + +## Who can offer + +Anyone, with no account. Requiring one first means asking a stranger to sign up +before they may say what they would pay, on the one page whose job is converting +that stranger. + +The address is confirmed by mail instead, and that step is load-bearing: an +offer is recorded `unverified` and the holder is told nothing until the offerer +clicks the link. Without it the form is a way to write to every holder in the +registry, one name at a time, from our own domain. Two rate limits sit behind +it β€” twenty offers per address per day across the whole registry, and one +standing offer per address per name. + +An account is needed exactly once, at the end: a name has to belong to somebody, +so paying means signing in with the address that made the offer. + +## Private, not an auction + +Only the holder sees what was offered. A public bid board tells every later +bidder what the last one offered and tells the holder's next buyer precisely +where their floor is. There is a test asserting a visitor's page never contains +an amount, because that is the kind of property a later change breaks quietly. + +## What can be offered on + +| subject | buy | lease | +|---|---|---| +| a name somebody holds | yes | yes | +| a name nobody has minted | yes | yes | +| an ending | yes | **no** | + +An unminted name under an ending somebody holds is a legitimate subject: the +operator can mint it and sell it, so they are who gets asked. That is the case +the old page turned away β€” "not for sale at a fixed price" is not the same as +"not for sale". + +Leases are names only, and that is a deliberate limit rather than an oversight. +Leasing an ending would have to mean the lessee can mint names under it, and a +name minted during a lease outlives the lease β€” so a six-month tenancy would +permanently carve up a namespace its holder never sold. Until there is an answer +to that, an ending is bought and not rented. + +## The conversation + +``` +offer ──▢ unverified ──(offerer clicks the link)──▢ open ──▢ accepted ──▢ paid + β”‚ β–² β–² + counteredβ”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + offerer answers +``` + +A counter is held **beside** the original rather than replacing it. What was +first offered is what the offerer will be comparing against, and a negotiation +that rewrites its own history is one neither side can check. `agreedTerms()` is +the single place that decides which number is operative β€” read `amount_usd` +directly and you will charge the wrong one the first time a holder counters. + +Offers expire thirty days after they are made. An accepted one does not: from +there the only thing outstanding is a payment, and a bill does not stop being +owed because a month went by. + +## Accepting does not move anything + +Acceptance opens a CoinPay checkout, and the name moves when the payment +confirms β€” the same webhook, `openOfferPurchase` and `settleOfferPurchase` +sitting beside the existing name and ending purchases. Settlement is claimed +with a conditional `accepted β†’ settling` update, because CoinPay retries a +webhook it never got an ack for and two deliveries can be in flight at once. + +Everything is re-checked at settlement. If the name changed hands between +acceptance and confirmation, the buyer paid its previous holder for something +that is no longer theirs, so the offer becomes `refund_due` and is logged rather +than swallowed. Once a sale settles, every other live offer on that name is +closed: an offer that can never be accepted is worse than no offer. + +**A sale leaves nothing of the seller's behind.** The contact and its forwarding +alias, the pins, the records, the twin and the target all go, for the reason +`releaseName` gives β€” a target names the seller's server, and an inherited guard +address forwards the buyer's mail to the seller. + +## Leases + +The holder keeps the name. The lessee gets to point it, publish under it and +present keys for it until the term runs out, and then it reverts with no action +needed from either side. + +Paid **once, upfront, for the whole term**. Not because a monthly rate would be +wrong β€” it is how leasing actually works β€” but because renewing one needs +subscription billing, a grace period, and a story for what happens to a live +site when a payment fails. A term paid in full before it starts cannot lapse +halfway through, which makes this the version that can be built correctly today. + +What a tenant may do is everything that makes the name usable: target, feed, +records, keys, content. What they may not do is anything that outlives the +lease β€” give the name up, sell it, bind a clearnet twin, or change who is +contacted about buying it. Those stay with the holder. `ownedName` is the holder +check; `controlledName` is the one that also accepts a current tenant. + +Mid-term the name is frozen against everything that would break the tenancy: the +holder cannot release it, and it cannot be sold or re-let. + +### Ending on time + +A lease ends when its term ends, not when a sweep runs. `leaseIsActive()` and +the `leased_until` check are read-time, so a former tenant loses control the +moment the clock passes β€” and `resolveMoshpitName` stops serving their target, +so their site is not still answering under a name they no longer rent. + +`endExpiredLeases()` is the tidy-up, hourly and at boot. It does the part a +reader cannot: taking the tenant's target, records and keys off the name. Their +published content is deliberately left alone β€” it is the one thing here that was +written rather than pointed at, and deleting somebody's posts because their +lease lapsed destroys work rather than unlinking it. It stops being served the +moment the target does. + +`leased_to` and `leased_until` are denormalised onto `moshpit_names`; +`moshpit_leases` is the record. Every name lookup in the pit goes through +`resolveMoshpitName`, and putting a second SELECT on the hottest query in the +registry to answer a question that is null for almost every name is not worth +it. + +## Where it shows + +- `/n/` and `/n/.` β€” the offer form, on the parked page. +- `/pit/offers` β€” the holder's side: what was offered, and accept / refuse / + counter. Also lists names they are renting. +- `/offers/?t=` β€” the offerer's side, reached from the mail. The + token stands in for the account they may not have. + +## Mail + +Four, all best-effort β€” a failed send must never lose an offer that is already +recorded, and `/pit/offers` shows it either way. + +| when | to | why | +|---|---|---| +| offer made | offerer | confirm the address, or nothing is sent on | +| offer confirmed | holder | somebody wants your name | +| holder answers | offerer | accepted, countered or refused | +| lease settles | tenant | what you now hold, and until when | + +Holder mail goes to their **account** address, not the guard address a contact +publishes. The two point opposite ways: a guard address is how a stranger +reaches them without learning who they are; this is the registry telling its own +user something about their account, and it has to arrive for the holders who +have no contact set β€” which is most of them.