Skip to content

Offers and leases on a parked name - #443

Merged
ralyodio merged 1 commit into
moshpit-contact-guardfrom
moshpit-offers
Aug 29, 2026
Merged

Offers and leases on a parked name#443
ralyodio merged 1 commit into
moshpit-contact-guardfrom
moshpit-offers

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Stacked on #442 — base is moshpit-contact-guard, so review that one first. The diff here is offers only.

The dead end this replaces

Every parked page ended the conversation:

  • a name somebody holds → "claimed but does not point anywhere yet"
  • a name under an ending with no price → ".eggs is not for sale"

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 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 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 — read amount_usd directly 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 → 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 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 releaseName gives. 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.

ownedName stays the holder check; controlledName also 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_until is checked at read time, so a former tenant loses control the moment it passes and resolveMoshpitName stops 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_until are denormalised onto moshpit_names because every lookup in the pit goes through resolveMoshpitName, 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_leases stays 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 have

Four 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 setNameTarget fail for a reason that has nothing to do with what you're testing.

🤖 Generated with Claude Code

https://claude.ai/code/session_017QSp1aAoQQB7jFV7hwt4jJ

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
Comment thread apps/pwa/src/moshpit.mjs
const OFFERS_PER_NAME = 1;

export async function getOffer(id) {
return get(`SELECT ${OFFER_COLS} FROM moshpit_offers WHERE id = ?`, [String(id ?? "")]);
Comment thread apps/pwa/src/moshpit.mjs
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]);
Comment thread apps/pwa/src/moshpit.mjs
}

export async function getLease(tld, label) {
return get(`SELECT ${LEASE_COLS} FROM moshpit_leases WHERE tld = ? AND label = ?`, [tld, label]);
Comment thread apps/pwa/src/moshpit.mjs
}

export async function listLeasesForUser(userId) {
return all(`SELECT ${LEASE_COLS} FROM moshpit_leases WHERE lessee_user_id = ? ORDER BY expires_at DESC`, [userId]);
Comment thread apps/pwa/src/moshpit.mjs
}

const standing = await all(
`SELECT ${OFFER_COLS} FROM moshpit_offers
Comment thread apps/pwa/src/moshpit.mjs

export async function listOffersForHolder(userId, { limit = 200 } = {}) {
return all(
`SELECT ${OFFER_COLS} FROM moshpit_offers
Comment thread apps/pwa/src/moshpit.mjs

export async function listOffersForEmail(email, { limit = 200 } = {}) {
return all(
`SELECT ${OFFER_COLS} FROM moshpit_offers WHERE offerer_email = ? ORDER BY created_at DESC LIMIT ?`,
Comment thread apps/pwa/src/moshpit.mjs
/** 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]);
Comment thread apps/pwa/src/moshpit.mjs
*/
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]);
Comment thread apps/pwa/src/moshpit.mjs
*/
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 ?`,
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

40 finding(s) in the 9 file(s) this pull request changes.

MEDIUM: 40

Severity Rule Location
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:138
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:152
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:178
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:365
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:369
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:414
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:663
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:859
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:861
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:920
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:966
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1036
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1139
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1162
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1184
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1383
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1533
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1682
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1688
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1735
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1784
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1815
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1915
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2034
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2046
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2058
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2092
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2290
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2303
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2329
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2335
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2339
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2349
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2480
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2632
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2640
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2648
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2678
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2817
MEDIUM js-unescaped-html-sink apps/pwa/src/routes/moshpit.mjs:2880
41 pre-existing finding(s) elsewhere in the repository — **HIGH/CRITICAL**: 5 | **MEDIUM**: 27 | **LOW**: 9

Not introduced by this pull request. The full set is in the Security tab.

Severity Rule Location
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
HIGH sh-remote-script-execution install.sh:79
HIGH sh-remote-script-execution install.sh:83
HIGH tls-verification-disabled src/dns.mjs:766
MEDIUM js-timing-unsafe-mac-compare apps/pwa/src/routes/settings-sync.mjs:192
MEDIUM js-dynamic-code-execution apps/pwa/test/apikey-mask.test.mjs:129
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUM sql-template-interpolation apps/pwa/test/moshpit-terms.test.mjs:207
MEDIUM sql-string-concatenation src/cli-schema.mjs:162
MEDIUM sql-string-concatenation src/cli-schema.mjs:504
MEDIUM sql-string-concatenation src/cli-schema.mjs:630
MEDIUM redos-nested-quantifier src/clients.mjs:29
MEDIUM js-uninitialized-buffer src/pty.mjs:120
MEDIUM js-timing-unsafe-mac-compare src/settings-sync.mjs:325
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:93
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:314
MEDIUM insecure-temp-file test/news.test.mjs:693
MEDIUM insecure-temp-file test/plugins.test.mjs:167

…and 21 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 6c32ecc into moshpit-contact-guard Aug 29, 2026
6 checks passed
@ralyodio
ralyodio deleted the moshpit-offers branch August 29, 2026 09:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants