Skip to content

Offers and leases on a parked name (onto main) - #444

Merged
ralyodio merged 1 commit into
mainfrom
moshpit-offers-onto-main
Aug 29, 2026
Merged

Offers and leases on a parked name (onto main)#444
ralyodio merged 1 commit into
mainfrom
moshpit-offers-onto-main

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Same work as #443, retargeted at main.

#443 was merged into its stacked base moshpit-contact-guard rather than into main, so the offers work never reached main — only the contact PR (#442) did. This is that commit, rebased onto current main.

Why a rebase rather than merging the contact branch. moshpit-contact-guard was cut before #441 (the session arrow pad) landed, so a diff of that branch against main shows #441's files as deletions. Rebasing instead replays only the offers commit: git dropped the contact commit as already applied via the #442 squash, and git diff main..HEAD is now exactly the nine offers files, with #441's 017_session_features.sql, sessions-keys.test.mjs and mirror-keys.test.mjs untouched.

Full suite on the rebased branch: 2793 passing, 0 failing (4 skipped). The count is higher than #443 reported because #441's tests are now in scope.


The parked page now takes an offer to buy or lease instead of ending the conversation — replacing both dead ends ("claimed but does not point anywhere yet", and ".eggs is not for sale" on an unpriced ending).

  • Anyone can ask, no account. The address is confirmed by mail; an offer sits unverified and the holder is told nothing until the link is clicked. Without that the form is a way to mail every holder in the registry from our own domain. 20/address/day, one standing offer per address per name.
  • Private, not an auction. Only the holder sees an amount. A test asserts a visitor's page never contains one.
  • Accepting moves nothing — it opens a CoinPay checkout, claimed with a conditional accepted → settling update against webhook redelivery. A name that changed hands in between becomes refund_due. A completed sale closes every other live offer and strips the seller's contact, alias, pins, records, twin and target.
  • Leases are real. Holder keeps the name; the tenant points and publishes until the term ends, then it reverts. Paid once upfront — a monthly rate needs subscription billing and a story for a failed payment against a live site. leased_until is read at resolution time, so a former tenant loses control the moment it passes; the hourly sweep does the part a reader can't.
  • Endings can be bought but not leased — a name minted during an ending's lease would outlive it.

Full reasoning in docs/offers-and-leases.md.

🤖 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 c8e4b96 into main Aug 29, 2026
6 checks passed
@ralyodio
ralyodio deleted the moshpit-offers-onto-main branch August 29, 2026 09:01
@ralyodio ralyodio mentioned this pull request Aug 29, 2026
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