Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions apps/pwa/src/lib/moshpit-name.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -226,28 +226,33 @@ export function shortCount(n) {
}

/**
* The most a child name should cost per year.
* The most a child name should cost. Once, for good.
*
* This is the `me.whatever` price — what a buyer pays to mint a name under an
* ending someone else holds. It is not the price of `.whatever` itself, which
* is a separate thing the registry does not charge for yet.
* is a separate thing.
*
* $2 flat. PRD 0005 R3 wrote this as $1.99; the extra cent buys nothing but a
* price tag that looks like a supermarket shelf, and every number a person has
* to reason about here — a default, a cap, a per-line override — reads better
* round. The PRD number is superseded by this one.
*
* The ceiling is on the annual registration/renewal price only. A one-time
* Buy Now resale transfers ownership rather than starting a term, and §10.2.4
* puts no ceiling on that.
* Not an annual price, and never was in practice: `moshpit_names` has never had
* an expiry column and nothing has ever renewed a name. The PRD called it a
* yearly fee, the schema sold it outright, and this comment used to describe
* the PRD. It now describes what the code does, which is the thing buyers were
* actually getting.
*
* A one-time Buy Now resale transfers ownership rather than starting anything,
* and §10.2.4 puts no ceiling on that.
*/
export const MAX_CHILD_PRICE_USD = 2;

/** Alias, for code that reads better naming the thing than the ceiling. */
export const CHILD_PRICE_USD = MAX_CHILD_PRICE_USD;

/**
* What a direct ending costs per year: `.whatever` itself.
* What a direct ending costs, once: `.whatever` itself.
*
* Nothing charges this yet — `registerTld` inserts a row and claiming is free.
* It lives here anyway so the two prices sit together and the number is settled
Expand All @@ -257,6 +262,12 @@ export const CHILD_PRICE_USD = MAX_CHILD_PRICE_USD;
* $5 flat, for the same reason the child price is $2: PRD 0005 §10.1 wrote
* these as $4.99 and $1.99, and the trailing cents buy nothing but a price tag
* shaped like a supermarket shelf.
*
* Paid once and held for good. §5's one-year term with renewals is withdrawn
* (migration 016). A name that lapses is a name somebody else can catch, and
* the whole reason to be here is to stop settling for the hyphenated version of
* the name you wanted — an annual invoice with a drop date attached is the
* thing people are leaving, not something to sell them again.
*/
export const ENDING_PRICE_USD = 5;

Expand Down
37 changes: 37 additions & 0 deletions apps/pwa/src/migrations/016_moshpit_lifetime.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- Endings are sold once and held for good. The term is withdrawn.
--
-- Migration 010 gave endings a one-year term with renewals, per PRD 0005 §5.
-- That is reversed here: $5 buys `.eggs` outright and $2 buys a name under one,
-- both paid once. The prices have not changed -- what changed is that they are
-- not charged again.
--
-- The reason is what the namespace is for rather than generosity. A name that
-- lapses is a name somebody else can catch, and the pitch is that you can
-- finally hold the clean name instead of the hyphenated one you settled for. An
-- annual invoice with a drop date on it is the thing people are trying to get
-- away from; selling it back to them undoes the pitch.
--
-- Worth recording that the term never actually shipped: nothing in the app
-- could open an ending checkout, so `quoteTld` and `quoteRenewal` were
-- unreachable and only the webhook settler was wired up. No ending was ever
-- charged a renewal, and no row in the wild has an expiry that this drop takes
-- away from somebody. That is why this is a plain drop rather than a
-- grandfathering policy -- §21.8 asks for one before putting endings INTO a
-- lifecycle, and taking them back out of one nobody was in needs no such thing.
--
-- `moshpit_tld_purchases` keeps its `kind` and `years` columns, deliberately.
-- They are a financial record of what was sold at the time, and a ledger is not
-- something to rewrite once the product changes. New rows are written
-- 'register' and 1 for good; a 'renew' row that settles late is honoured rather
-- than refused, because the buyer is owed what they were promised.
--
-- (Nothing may follow the last statement here but whitespace: migrate.mjs
-- splits on semicolons and hands each piece to libSQL, and a trailing
-- comment-only piece comes back as the opaque `SQLITE_OK: not an error`.)

-- The index goes first: SQLite refuses to drop a column an index reads.
DROP INDEX IF EXISTS idx_moshpit_tlds_expires;

ALTER TABLE moshpit_tlds DROP COLUMN expires_at;

ALTER TABLE moshpit_tlds DROP COLUMN term_started_at;
111 changes: 56 additions & 55 deletions apps/pwa/src/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1403,13 +1403,27 @@ export function summarizeBulkClaim(result, limit = MAX_BULK_TLDS) {
return parts.length ? parts.join(". ") + "." : "nothing to claim — paste one ending per line.";
}

/* ---- buying and renewing an ending ---- */
/* ---- buying an ending ---- */

/** A term is a year. Ten is the ceiling PRD 0005 R6 puts on one checkout. */
export const TERM_MS = 365 * 24 * 60 * 60 * 1000;
export const MAX_TERM_YEARS = 10;

const TLD_COLS_FULL = `tld, user_id, owner_email, alias_of, price_usd, term_started_at, expires_at, created_at`;
/**
* Endings are sold once and held for good.
*
* They used to carry a one-year term with renewals, per PRD 0005 §5. That is
* withdrawn: $5 buys `.eggs` outright, $2 buys a name under one, and neither
* ever comes up for renewal. The prices are unchanged -- what changed is that
* they are paid once.
*
* The reason is not generosity, it is what the namespace is for. A name that
* lapses is a name somebody else can catch, and the whole pitch here is that
* you can finally have the clean name instead of the hyphenated one you settled
* for. An annual invoice with a drop date attached is the thing people are
* trying to get away from, and selling it back to them undoes the pitch.
*
* The term columns are gone (migration 016). The purchase ledger keeps its
* `kind` and `years` columns, because those record what was actually sold at
* the time and a financial record is not something to rewrite after the fact.
*/
const TLD_COLS_FULL = `tld, user_id, owner_email, alias_of, price_usd, created_at`;

export async function getTldWithTerm(tld) {
return get(`SELECT ${TLD_COLS_FULL} FROM moshpit_tlds WHERE tld = ?`, [tld]);
Expand All @@ -1423,18 +1437,13 @@ export async function getTldWithTerm(tld) {
* reserved, already held, and "you already own it" are three different answers
* and a single "unavailable" would be none of them.
*/
export async function quoteTld({ tld: tldInput, buyerId, years = 1, now = Date.now() }) {
export async function quoteTld({ tld: tldInput, buyerId, now = Date.now() }) {
const tld = normalizeTld(tldInput);
if (!tld) return { ok: false, error: "not a valid TLD — letters, digits and dashes only, no dots" };

const why = tldRejection(tld);
if (why) return { ok: false, error: why };

const term = Number(years);
if (!Number.isInteger(term) || term < 1 || term > MAX_TERM_YEARS) {
return { ok: false, error: `a term is 1 to ${MAX_TERM_YEARS} years` };
}

const owner = await getTldWithTerm(tld);
if (owner) {
if (owner.user_id === buyerId) return { ok: false, error: `.${tld} is already yours`, taken: true };
Expand All @@ -1450,46 +1459,40 @@ export async function quoteTld({ tld: tldInput, buyerId, years = 1, now = Date.n
);
if (held) return { ok: false, error: `.${tld} is in someone's checkout right now — try again shortly`, taken: true };

return { ok: true, tld, years: term, priceUsd: Math.round(ENDING_PRICE_USD * term * 100) / 100 };
// Not multiplied by anything. There is one price and one purchase, and a
// quote that still carried a term would be an offer the checkout cannot make.
return { ok: true, tld, priceUsd: ENDING_PRICE_USD };
}

/** What it costs to keep one you hold. */
export async function quoteRenewal({ tld: tldInput, userId, years = 1 }) {
const tld = normalizeTld(tldInput);
if (!tld) return { ok: false, error: "not a valid TLD" };

const term = Number(years);
if (!Number.isInteger(term) || term < 1 || term > MAX_TERM_YEARS) {
return { ok: false, error: `a term is 1 to ${MAX_TERM_YEARS} years` };
}

const owner = await getTldWithTerm(tld);
if (!owner) return { ok: false, error: `.${tld} is not registered` };
if (owner.user_id !== userId) return { ok: false, error: `you do not own .${tld}` };

return { ok: true, tld, years: term, priceUsd: Math.round(ENDING_PRICE_USD * term * 100) / 100, expiresAt: owner.expires_at };
}

export async function openTldPurchase({ paymentId, tld, userId, amountUsd, years = 1, kind = "register", now = Date.now() }) {
/**
* Open a checkout for an ending.
*
* `kind` and `years` are written as the constants they now always are rather
* than dropped from the INSERT: the columns are the ledger's, and a row that
* left them NULL would be indistinguishable from one written before they
* existed. Every ending sold from here on is one registration, held for good.
*/
export async function openTldPurchase({ paymentId, tld, userId, amountUsd, now = Date.now() }) {
await run(
`INSERT INTO moshpit_tld_purchases (id, tld, user_id, amount_usd, kind, status, years, created_at, reserved_until)
VALUES (?,?,?,?,?, 'pending', ?,?,?)`,
[paymentId, tld, userId, amountUsd, kind, years, now, now + RESERVATION_MS],
VALUES (?,?,?,?, 'register', 'pending', 1, ?,?)`,
[paymentId, tld, userId, amountUsd, now, now + RESERVATION_MS],
);
}

/**
* Hand over a paid-for ending, or extend one. Idempotent on the payment id.
* Hand over a paid-for ending. Idempotent on the payment id.
*
* The claim is a conditional UPDATE for the same reason every other settlement
* here uses one: CoinPay retries a webhook it never got an ack for, so two
* deliveries can be in flight at once and both read 'pending' before either
* write lands.
*
* A renewal never shortens a term. It extends from whichever is later — the
* current expiry or now — so renewing early adds to what is left rather than
* throwing it away, and renewing late does not backdate the new term into the
* past. PRD 0005 R7.
* A 'renew' row can no longer be created, but one may still arrive here: a
* checkout opened before endings went lifetime can settle after. It is honoured
* as what the buyer was actually promised -- they hold the ending, and it now
* holds for good -- rather than refused for naming a kind this code no longer
* sells. Refusing it would take money for nothing.
*/
export async function settleTldPurchase(paymentId, now = Date.now()) {
const p = await get(`SELECT * FROM moshpit_tld_purchases WHERE id = ? AND status = 'pending'`, [paymentId]);
Expand All @@ -1499,28 +1502,24 @@ export async function settleTldPurchase(paymentId, now = Date.now()) {
`UPDATE moshpit_tld_purchases SET status = 'settling' WHERE id = ? AND status = 'pending'`, [paymentId]);
if (!claimed.rowsAffected) return { ok: false, error: "already settled" };

const span = TERM_MS * (p.years || 1);

if (p.kind === "renew") {
const owner = await getTldWithTerm(p.tld);
if (!owner || owner.user_id !== p.user_id) {
await run(`UPDATE moshpit_tld_purchases SET status = 'refund_due' WHERE id = ?`, [paymentId]);
console.error(`[moshpit] .${p.tld} left ${p.user_id} before renewal ${paymentId} settled — refund due`);
return { ok: false, error: "ending changed hands before the renewal settled", refundDue: true };
}
const from = Math.max(owner.expires_at || 0, now);
await run(`UPDATE moshpit_tlds SET expires_at = ? WHERE tld = ? AND user_id = ?`,
[from + span, p.tld, p.user_id]);
// Nothing to extend any more. The ending is already theirs for good.
await run(`UPDATE moshpit_tld_purchases SET status = 'cleared' WHERE id = ?`, [paymentId]);
await logAction(p.tld, p.user_id, `renew:${p.years}y`);
return { ok: true, tld: p.tld, userId: p.user_id, expiresAt: from + span, renewed: true };
await logAction(p.tld, p.user_id, `renew:lifetime`);
return { ok: true, tld: p.tld, userId: p.user_id, renewed: true, lifetime: true };
}

try {
await run(
`INSERT INTO moshpit_tlds (tld, user_id, owner_email, owner_key, created_at, term_started_at, expires_at)
VALUES (?,?,?,?,?,?,?)`,
[p.tld, p.user_id, null, null, now, now, now + span],
`INSERT INTO moshpit_tlds (tld, user_id, owner_email, owner_key, created_at)
VALUES (?,?,?,?,?)`,
[p.tld, p.user_id, null, null, now],
);
} catch {
// Claimed by someone else between checkout and confirmation. Real money
Expand All @@ -1532,7 +1531,7 @@ export async function settleTldPurchase(paymentId, now = Date.now()) {

await run(`UPDATE moshpit_tld_purchases SET status = 'cleared' WHERE id = ?`, [paymentId]);
await logAction(p.tld, p.user_id, `bought:.${p.tld}`);
return { ok: true, tld: p.tld, userId: p.user_id, expiresAt: now + span };
return { ok: true, tld: p.tld, userId: p.user_id, lifetime: true };
}

export async function listTldPurchases(userId, limit = 50) {
Expand All @@ -1544,14 +1543,16 @@ export async function listTldPurchases(userId, limit = 50) {
}

/**
* Is this ending inside its term?
* Kept, and it always answers no.
*
* A NULL expiry is not expired. Every ending claimed before terms existed has
* one, and treating "no term recorded" as "term ended" would expire a few
* hundred namespaces that nobody agreed to put on a clock.
* Endings do not expire any more. This stays as a named answer rather than
* being deleted because "does this ending still belong to its holder" is a
* question callers are entitled to keep asking -- the CLI, the DNS bridge and
* the resolvers all reasonably might -- and the honest reply is now a permanent
* no rather than a missing export that fails at import time.
*/
export function isExpired(tld, now = Date.now()) {
return Boolean(tld?.expires_at) && tld.expires_at <= now;
export function isExpired() {
return false;
}

/* ---- short links: /f/<code> ---- */
Expand Down
17 changes: 16 additions & 1 deletion apps/pwa/src/routes/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ import {
countSearchTlds,
countTldsNotOwnedBy,
createLink,
CHILD_PRICE_USD,
DEFAULT_TLD_PRICE_USD,
ENDING_PRICE_USD,
deleteContent,
deleteLink,
getContent,
Expand Down Expand Up @@ -1868,6 +1870,12 @@ const PIT_CSS = `
.pit-forsale{border-color:color-mix(in srgb,var(--acid) 35%,var(--line))}
.pit-tab .count{font-size:.68rem;color:var(--faint);margin-left:6px}
.pit-tab.on .count{color:var(--acid)}
/* The one claim worth making above the fold, so it reads as a fact about the
namespace rather than as a banner. Bordered on one side only — a full box
here would sit next to the error and success boxes and be mistaken for one. */
.pit-forever{border-left:2px solid var(--acid);padding:2px 0 2px 14px;margin:18px 0 0;max-width:62ch}
.pit-forever b{color:var(--acid);font-weight:600}
.pit-forever .mono{color:var(--acid)}
.pit-msg{border-radius:8px;padding:10px 14px;margin:14px 0;font-family:var(--mono);font-size:.84rem}
.pit-msg.err{border:1px solid var(--danger);color:var(--danger)}
.pit-msg.ok{border:1px solid var(--acid);color:var(--acid)}
Expand Down Expand Up @@ -2457,6 +2465,12 @@ moshpitRouter.get("/pit", async (req, res) => {
<span class="mono">foo.agentic</span> resolve to <span class="mono">foo.agent</span> — while any name
you exempt stays exactly where it is.
</p>
<p class="pit-forever dim">
<b>Bought once. Yours for good.</b><br>
<span class="mono">$${ENDING_PRICE_USD}</span> an ending, <span class="mono">$${CHILD_PRICE_USD}</span> a name —
paid one time, not every year. Nothing here renews, nothing lapses, and no name you hold can drop
because an invoice went to an address you stopped reading.
</p>
${landingCard(req, landing)}
${msg}
${req.user ? claimForm(req) + bulkClaimForm(req) : ""}
Expand Down Expand Up @@ -2486,7 +2500,8 @@ moshpitRouter.get("/pit", async (req, res) => {
<p class="dim" style="max-width:62ch;margin:0 0 14px">
Endings somebody else holds. Where the operator has set a price you can buy a name under it —
<span class="mono">foo.whatever</span> without owning <span class="mono">.whatever</span>. Paid in crypto
through CoinPay; the name lands the moment the payment confirms.
through CoinPay; the name lands the moment the payment confirms, and it is yours from then on —
there is no renewal and no expiry date.
</p>
${theirsHtml}
${pager({
Expand Down
Loading
Loading