diff --git a/apps/pwa/src/lib/moshpit-name.mjs b/apps/pwa/src/lib/moshpit-name.mjs index f7be06c..19d1952 100644 --- a/apps/pwa/src/lib/moshpit-name.mjs +++ b/apps/pwa/src/lib/moshpit-name.mjs @@ -226,20 +226,25 @@ 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; @@ -247,7 +252,7 @@ export const MAX_CHILD_PRICE_USD = 2; 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 @@ -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; diff --git a/apps/pwa/src/migrations/016_moshpit_lifetime.sql b/apps/pwa/src/migrations/016_moshpit_lifetime.sql new file mode 100644 index 0000000..790e156 --- /dev/null +++ b/apps/pwa/src/migrations/016_moshpit_lifetime.sql @@ -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; diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index e347b0f..727f0d1 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -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]); @@ -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 }; @@ -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]); @@ -1499,8 +1502,6 @@ 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) { @@ -1508,19 +1509,17 @@ export async function settleTldPurchase(paymentId, now = Date.now()) { 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 @@ -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) { @@ -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/ ---- */ diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index 600c98c..094bf38 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -64,7 +64,9 @@ import { countSearchTlds, countTldsNotOwnedBy, createLink, + CHILD_PRICE_USD, DEFAULT_TLD_PRICE_USD, + ENDING_PRICE_USD, deleteContent, deleteLink, getContent, @@ -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)} @@ -2457,6 +2465,12 @@ moshpitRouter.get("/pit", async (req, res) => { foo.agentic resolve to foo.agent — while any name you exempt stays exactly where it is.

+

+ Bought once. Yours for good.
+ $${ENDING_PRICE_USD} an ending, $${CHILD_PRICE_USD} 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. +

${landingCard(req, landing)} ${msg} ${req.user ? claimForm(req) + bulkClaimForm(req) : ""} @@ -2486,7 +2500,8 @@ moshpitRouter.get("/pit", async (req, res) => {

Endings somebody else holds. Where the operator has set a price you can buy a name under it — foo.whatever without owning .whatever. 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.

${theirsHtml} ${pager({ diff --git a/apps/pwa/test/moshpit-terms.test.mjs b/apps/pwa/test/moshpit-terms.test.mjs index b6f45dd..708b849 100644 --- a/apps/pwa/test/moshpit-terms.test.mjs +++ b/apps/pwa/test/moshpit-terms.test.mjs @@ -32,18 +32,24 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed const uniq = () => `e${randomBytes(4).toString("hex")}`; const pay = () => `pay-${randomBytes(6).toString("hex")}`; - await t.test("an unclaimed ending quotes at the ending price", async () => { + await t.test("an unclaimed ending quotes at the ending price, once", async () => { const q = await m.quoteTld({ tld: uniq(), buyerId: ALICE }); assert.equal(q.ok, true); assert.equal(q.priceUsd, 5, "PRD 0005 §10.1, rounded to whole dollars"); - assert.equal(q.years, 1); + assert.equal(q.years, undefined, "there is no term to quote"); }); - await t.test("multiple years multiply, up to the cap", async () => { - assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 3 })).priceUsd, 15); - assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 11 })).ok, false); - assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 0 })).ok, false); - assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 1.5 })).ok, false); + await t.test("there is no term to buy more of", async () => { + // A quantity used to multiply the price. Passing one now is not an error + // that needs naming, it is a field nothing reads -- what matters is that it + // cannot quietly produce a different price. + assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 3 })).priceUsd, 5); + assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 11 })).priceUsd, 5); + // And renewing is gone entirely rather than left as a no-op somebody could + // still wire a checkout to. + assert.equal(typeof m.quoteRenewal, "undefined"); + assert.equal(typeof m.MAX_TERM_YEARS, "undefined"); + assert.equal(typeof m.TERM_MS, "undefined"); }); await t.test("every refusal names itself", async () => { @@ -72,7 +78,7 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed assert.equal((await m.quoteTld({ tld, buyerId: BOB })).ok, true); }); - await t.test("settling hands it over with a term", async () => { + await t.test("settling hands it over for good", async () => { const tld = uniq(); const id = pay(); const now = Date.now(); @@ -80,11 +86,26 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed const result = await m.settleTldPurchase(id, now); assert.equal(result.ok, true); + assert.equal(result.lifetime, true); const row = await m.getTldWithTerm(tld); assert.equal(row.user_id, ALICE); - assert.equal(row.term_started_at, now); - assert.equal(row.expires_at, now + m.TERM_MS, "one year"); + // The columns are gone, not merely unset: a NULL expiry somebody could + // later populate is an annual term waiting to be switched back on. + assert.equal("expires_at" in row, false); + assert.equal("term_started_at" in row, false); + }); + + await t.test("the ledger still records what was sold", async () => { + const tld = uniq(); + const id = pay(); + await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5 }); + await m.settleTldPurchase(id); + + const row = (await m.listTldPurchases(ALICE, 50)).find((r) => r.id === id); + assert.equal(row.kind, "register"); + assert.equal(row.years, 1); + assert.equal(row.status, "cleared"); }); await t.test("a redelivered webhook does not settle twice", async () => { @@ -113,56 +134,50 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed assert.ok(row, "the purchase is still on Alice's record"); }); - await t.test("renewing extends, and never shortens", async () => { + await t.test("a renewal opened before the change is honoured, not refunded", async () => { + // Nothing can create one of these any more, but a checkout opened before + // endings went lifetime may still settle afterwards. The buyer is owed what + // they were promised -- they keep the ending, and it now keeps itself. const tld = uniq(); - const now = Date.now(); - const first = pay(); - await m.openTldPurchase({ paymentId: first, tld, userId: ALICE, amountUsd: 5, now }); - await m.settleTldPurchase(first, now); - - // Renewing early adds to what is left rather than throwing it away. - const second = pay(); - await m.openTldPurchase({ paymentId: second, tld, userId: ALICE, amountUsd: 5, kind: "renew", now }); - await m.settleTldPurchase(second, now + 1000); + await m.registerTld({ tld, userId: ALICE }); + const id = pay(); + await run( + `INSERT INTO moshpit_tld_purchases (id,tld,user_id,amount_usd,kind,status,years,created_at,reserved_until) + VALUES (?,?,?,?, 'renew', 'pending', 1, ?, ?)`, + [id, tld, ALICE, 5, Date.now(), Date.now() + 60_000], + ); - assert.equal((await m.getTldWithTerm(tld)).expires_at, now + m.TERM_MS * 2); + const result = await m.settleTldPurchase(id); + assert.equal(result.ok, true, result.error); + assert.equal(result.renewed, true); + assert.equal(result.lifetime, true); + assert.equal((await m.getTldWithTerm(tld)).user_id, ALICE); + assert.equal((await m.listTldPurchases(ALICE, 50)).find((r) => r.id === id).status, "cleared"); }); - await t.test("renewing a lapsed term runs from now, not from the past", async () => { + await t.test("a renewal for an ending that changed hands is still a refund", async () => { const tld = uniq(); - const past = Date.now() - m.TERM_MS * 2; + await m.registerTld({ tld, userId: BOB }); + const id = pay(); await run( - `INSERT INTO moshpit_tlds (tld,user_id,created_at,term_started_at,expires_at) VALUES (?,?,?,?,?)`, - [tld, ALICE, past, past, past + m.TERM_MS], + `INSERT INTO moshpit_tld_purchases (id,tld,user_id,amount_usd,kind,status,years,created_at,reserved_until) + VALUES (?,?,?,?, 'renew', 'pending', 1, ?, ?)`, + [id, tld, ALICE, 5, Date.now(), Date.now() + 60_000], ); - const id = pay(); - const now = Date.now(); - await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5, kind: "renew", now }); - await m.settleTldPurchase(id, now); - - assert.equal((await m.getTldWithTerm(tld)).expires_at, now + m.TERM_MS, "not backdated"); - }); - - await t.test("only the holder may renew", async () => { - const tld = uniq(); - await m.registerTld({ tld, userId: ALICE }); - assert.match((await m.quoteRenewal({ tld, userId: BOB })).error, /do not own/); - assert.equal((await m.quoteRenewal({ tld, userId: ALICE })).ok, true); + const result = await m.settleTldPurchase(id); + assert.equal(result.ok, false); + assert.equal(result.refundDue, true); }); - await t.test("an ending with no term recorded is not expired", async () => { - // Every ending claimed before terms existed has a NULL expiry. Treating - // that as expired would put a few hundred namespaces on a clock nobody - // agreed to. + await t.test("nothing expires any more", async () => { const tld = uniq(); await m.registerTld({ tld, userId: ALICE }); - const row = await m.getTldWithTerm(tld); - - assert.equal(row.expires_at, null); - assert.equal(m.isExpired(row), false); - assert.equal(m.isExpired({ expires_at: Date.now() - 1 }), true); - assert.equal(m.isExpired({ expires_at: Date.now() + 1000 }), false); + assert.equal(m.isExpired(await m.getTldWithTerm(tld)), false); + // Including for a row that still carries a date from somewhere. The answer + // is a permanent no, not a comparison against a column that no longer runs. + assert.equal(m.isExpired({ expires_at: Date.now() - 1 }), false); + assert.equal(m.isExpired(), false); }); });