Offers and leases on a parked name - #443
Merged
Merged
Conversation
Every parked page ended the conversation. A name somebody held said "claimed but does not point anywhere yet"; a name under an ending with no price said ".eggs is not for sale". Both are true, both land at the moment a visitor wants the name most, and both left the holder never hearing that anyone asked. An offer is the missing half. A visitor says what a name is worth to them and the holder accepts, refuses, or names a different number. No account needed to ask. Requiring one means asking a stranger to sign up before they may say what they would pay, on the page whose whole job is converting that stranger. The address is confirmed by mail instead, and that step is load-bearing: an offer is recorded `unverified` and the holder is told nothing until the offerer clicks the link — without it the form is a way to write to every holder in the registry from our own domain. Twenty offers per address per day, one standing offer per address per name, behind that. Private, not an auction. Only the holder sees an amount; a public board tells every later bidder what the last one offered and shows the holder's next buyer their floor. A counter is held beside the original rather than replacing it — agreedTerms() is the one place that decides which number is operative. Accepting moves nothing. It opens a CoinPay checkout beside the existing name and ending purchases, claimed with a conditional accepted→settling update because a webhook gets redelivered. Everything is re-checked at settlement: a name that changed hands in between becomes refund_due rather than a silent loss, and a sale closes every other live offer on that name. A sale leaves nothing of the seller's behind — contact and its forwarding alias, pins, records, twin and target all go, for the reason releaseName gives. Leases are real. The holder keeps the name; the tenant points it, publishes under it and presents keys until the term ends, then it reverts on its own. Paid once upfront for the whole term — a monthly rate needs subscription billing, a grace period and a story for a failed payment against a live site, and a term paid in full before it starts cannot lapse halfway through. ownedName stays the holder check and controlledName also accepts a tenant, so what outlives the lease (release, sale, twin, contact) stays with the holder. A lease ends by the clock, not by a sweep: leased_until is read-time, so a former tenant loses control the moment it passes and resolveMoshpitName stops serving their target. endExpiredLeases() does the part a reader cannot — taking the target, records and keys back off — hourly and at boot. Their published content is left alone deliberately; a lapsed lease should unlink work, not destroy it. leased_to/leased_until are denormalised onto moshpit_names because every lookup in the pit goes through resolveMoshpitName, and a second SELECT there to answer a question that is null for almost every name is not worth it. Endings can be bought but not leased: a name minted during an ending's lease would outlive it, so a six-month tenancy would permanently carve up a namespace its holder never sold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017QSp1aAoQQB7jFV7hwt4jJ
| 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]); |
| } | ||
|
|
||
| export async function listLeasesForUser(userId) { | ||
| return all(`SELECT ${LEASE_COLS} FROM moshpit_leases WHERE lessee_user_id = ? ORDER BY expires_at DESC`, [userId]); |
| } | ||
|
|
||
| const standing = await all( | ||
| `SELECT ${OFFER_COLS} FROM moshpit_offers |
|
|
||
| export async function listOffersForHolder(userId, { limit = 200 } = {}) { | ||
| return all( | ||
| `SELECT ${OFFER_COLS} FROM moshpit_offers |
|
|
||
| export async function listOffersForEmail(email, { limit = 200 } = {}) { | ||
| return all( | ||
| `SELECT ${OFFER_COLS} FROM moshpit_offers WHERE offerer_email = ? ORDER BY created_at DESC 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]); |
| */ | ||
| 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]); |
| */ | ||
| 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 ?`, |
ThreatCrush Security Scan40 finding(s) in the 9 file(s) this pull request changes. MEDIUM: 40
41 pre-existing finding(s) elsewhere in the repository — **HIGH/CRITICAL**: 5 | **MEDIUM**: 27 | **LOW**: 9Not introduced by this pull request. The full set is in the Security tab.
…and 21 more. Full results in the Security tab. Snippets are redacted; ThreatCrush never prints matched credential material. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The dead end this replaces
Every parked page ended the conversation:
Both are true, both land at the exact moment a visitor wants the name most, and both left the holder never hearing that anyone asked. "Not for sale at a fixed price" is not the same as "not for sale".
Now the page takes an offer to buy or lease, and the holder accepts, refuses, or counters.
Decisions, and why
No account needed to ask. Requiring one means asking a stranger to sign up before they may say what they'd pay, on the page whose whole job is converting that stranger. The address is confirmed by mail instead, and that step is load-bearing: an offer is recorded
unverifiedand the holder is told nothing until the offerer clicks the link. Without it the form is a way to write to every holder in the registry from our own domain. Twenty offers per address per day and one standing offer per address per name sit behind it. An account is needed exactly once, at the end — a name has to belong to somebody.Private, not an auction. Only the holder sees an amount. A public board tells every later bidder what the last one offered and shows the holder's next buyer exactly where their floor is. There's a test asserting a visitor's page never contains an amount, because that's the kind of property a later change breaks quietly.
A counter sits beside the original, not on top of it. What was first offered is what the offerer compares against, and a negotiation that rewrites its own history is one neither side can check.
agreedTerms()is the single place deciding which number is operative — readamount_usddirectly and you charge the wrong one the first time a holder counters.Accepting moves nothing. It opens a CoinPay checkout beside the existing name and ending purchases, claimed with a conditional
accepted → settlingupdate because a webhook gets redelivered. Everything is re-checked at settlement: a name that changed hands in between becomesrefund_duerather than a silent loss, and a completed sale closes every other live offer on that name.A sale leaves nothing of the seller's behind — contact and its forwarding alias, pins, records, twin and target all go, for the reason
releaseNamegives. A target names the seller's server; an inherited guard address forwards the buyer's mail to the seller.Leases
The holder keeps the name. The tenant points it, publishes under it and presents keys until the term ends, then it reverts on its own.
Paid once upfront for the whole term. Not because a monthly rate would be wrong — it's 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 it the version that can be built correctly today.
ownedNamestays the holder check;controlledNamealso accepts a current tenant. What outlives the lease — release, sale, clearnet twin, contact — stays with the holder. Mid-term the name is frozen against release, sale and re-letting.A lease ends by the clock, not by a sweep.
leased_untilis checked at read time, so a former tenant loses control the moment it passes andresolveMoshpitNamestops serving their target — their site isn't still answering under a name they no longer rent.endExpiredLeases()(hourly, and at boot) does the part a reader can't: taking the target, records and keys back off. Their published content is left alone deliberately — a lapsed lease should unlink work, not destroy it.leased_to/leased_untilare denormalised ontomoshpit_namesbecause every lookup in the pit goes throughresolveMoshpitName, and a second SELECT on the hottest query in the registry to answer a question that's null for almost every name isn't worth it.moshpit_leasesstays the record.Endings can be bought but not leased. A name minted during an ending's lease would outlive the lease, so a six-month tenancy would permanently carve up a namespace its holder never sold. Flagged rather than fudged.
Surfaces
/n/<name>,/n/.<ending>— the offer form on the parked page/pit/offers— the holder's side, plus names they're renting/offers/<id>?t=<token>— the offerer's side, from the mail; the token stands in for the account they may not haveFour mails, all best-effort — a failed send must never lose an offer that's already recorded. Holder mail goes to their account address, not the guard address from #442: those point opposite ways, and this has to arrive for holders with no contact set, which is most of them.
Tests
30 new. Full repo suite 2778 passing, 0 failing.
One caught a real trap worth knowing: this registry refuses IPv4 targets (v6 or hostname only), so an IPv4 fixture makes
setNameTargetfail for a reason that has nothing to do with what you're testing.🤖 Generated with Claude Code
https://claude.ai/code/session_017QSp1aAoQQB7jFV7hwt4jJ