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
43 changes: 43 additions & 0 deletions apps/pwa/src/migrations/010_moshpit_terms.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
-- Endings get a term, and a way to be paid for.
--
-- Claiming an ending has been free and permanent: a row in moshpit_tlds with a
-- created_at and nothing that ever expires. PRD 0005 §5 puts a $5/year price on
-- a direct ending and a one-year term under it, which needs two things this
-- schema has never had — when the term started, and when it runs out.
--
-- Both are NULLable, and every existing row keeps NULL. A NULL expires_at means
-- "no term recorded", which is what every ending claimed before today has, and
-- it is deliberately not the same as an expired one. Backfilling those rows
-- with an invented expiry would silently put a few hundred endings into a
-- lifecycle their owners never agreed to; §21.8 calls for a published
-- grandfathering policy first, and that is a decision rather than a migration.
ALTER TABLE moshpit_tlds ADD COLUMN term_started_at INTEGER;
ALTER TABLE moshpit_tlds ADD COLUMN expires_at INTEGER;

CREATE INDEX IF NOT EXISTS idx_moshpit_tlds_expires ON moshpit_tlds(expires_at);

-- One row per CoinPay checkout for an ending. Keyed on the payment id so a
-- webhook redelivery settles the same row rather than creating a second one —
-- the same shape as moshpit_name_purchases, because it is the same problem.
CREATE TABLE IF NOT EXISTS moshpit_tld_purchases (
id TEXT PRIMARY KEY,
tld TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
amount_usd REAL NOT NULL,
-- register | renew. A renewal extends a term the buyer already holds; a
-- registration creates one. They settle differently and the row has to say
-- which it is, or a redelivered webhook cannot know what it is finishing.
kind TEXT NOT NULL DEFAULT 'register' CHECK (kind IN ('register','renew')),
-- pending -> cleared, or -> refund_due when the ending was claimed between
-- checkout and confirmation. That last state is real money against something
-- the buyer cannot have, so it is recorded rather than swallowed.
status TEXT NOT NULL,
years INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
-- How long this checkout holds the ending against other buyers. The UNIQUE
-- constraint on moshpit_tlds.tld is still the real arbiter; this only stops
-- the ordinary case of two people paying for the same ending at once.
reserved_until INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_moshpit_tld_purchases_user ON moshpit_tld_purchases(user_id);
CREATE INDEX IF NOT EXISTS idx_moshpit_tld_purchases_tld ON moshpit_tld_purchases(tld, status);
152 changes: 152 additions & 0 deletions apps/pwa/src/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { db, get, all, run } from "./db.mjs";
import {
BULK_CHUNK,
BULK_TIME_BUDGET_MS,
ENDING_PRICE_USD,
MAX_BULK_TLDS,
MAX_CHILD_PRICE_USD,
normalizeLabel,
Expand Down Expand Up @@ -718,3 +719,154 @@ 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 ---- */

/** 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`;

export async function getTldWithTerm(tld) {
return get(`SELECT ${TLD_COLS_FULL} FROM moshpit_tlds WHERE tld = ?`, [tld]);
}

/**
* What it costs to take an unclaimed ending.
*
* Quoted rather than assumed, and the same call the checkout makes, so an offer
* shown anywhere is one the next click can honour. Every refusal names itself:
* 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() }) {
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 };
return { ok: false, error: `.${tld} is already registered`, taken: true };
}

// Someone else's open checkout holds it. The UNIQUE constraint is still the
// real arbiter at settlement; this only stops two people paying at once.
const held = await get(
`SELECT id FROM moshpit_tld_purchases
WHERE tld = ? AND status = 'pending' AND reserved_until > ? LIMIT 1`,
[tld, now],
);
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 };
}

/** 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() }) {
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],
);
}

/**
* Hand over a paid-for ending, or extend one. 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.
*/
export async function settleTldPurchase(paymentId, now = Date.now()) {
const p = await get(`SELECT * FROM moshpit_tld_purchases WHERE id = ? AND status = 'pending'`, [paymentId]);
if (!p) return { ok: false, error: "no pending purchase for that payment" };

const claimed = await run(
`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]);
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 };
}

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],
);
} catch {
// Claimed by someone else between checkout and confirmation. Real money
// against something the buyer cannot have, so it is recorded, not dropped.
await run(`UPDATE moshpit_tld_purchases SET status = 'refund_due' WHERE id = ?`, [paymentId]);
console.error(`[moshpit] .${p.tld} was taken before payment ${paymentId} settled — refund due to ${p.user_id}`);
return { ok: false, error: "ending was taken before payment settled", refundDue: true };
}

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 };
}

export async function listTldPurchases(userId, limit = 50) {
return all(
`SELECT id, tld, amount_usd, kind, years, status, created_at FROM moshpit_tld_purchases
WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`,
[userId, limit],
);
}

/**
* Is this ending inside its term?
*
* 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.
*/
export function isExpired(tld, now = Date.now()) {
return Boolean(tld?.expires_at) && tld.expires_at <= now;
}
5 changes: 4 additions & 1 deletion apps/pwa/src/routes/credits.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { id } from "../lib/crypto.mjs";
import { grant } from "../lib/credits.mjs";
import { verifySignature } from "../lib/signature.mjs";
import { requireAuth } from "../lib/session.mjs";
import { settleNamePurchase } from "../moshpit.mjs";
import { settleNamePurchase, settleTldPurchase } from "../moshpit.mjs";

export const creditsRouter = Router();

Expand Down Expand Up @@ -72,6 +72,9 @@ creditsRouter.post("/webhooks/coinpay", async (req, res) => {
// a credit top-up are different rows in different tables; whichever one
// this id belongs to is the one that settles.
await settleNamePurchase(payId).catch((e) => console.error("[moshpit] settle failed:", e.message));
// An ending is a different table from a name, and this is the one webhook
// URL CoinPay is configured with — whichever row the id belongs to settles.
await settleTldPurchase(payId).catch((e) => console.error("[moshpit] tld settle failed:", e.message));

const p = await get(`SELECT * FROM credit_purchases WHERE id = ? AND status = 'pending'`, [payId]);
if (p) {
Expand Down
167 changes: 167 additions & 0 deletions apps/pwa/test/moshpit-terms.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Paying for an ending, and keeping it.
//
// Against a real throwaway libSQL database: the interesting behaviour is in
// conditional UPDATEs and a UNIQUE constraint, and a stub has neither.
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { randomBytes } from "node:crypto";
import test from "node:test";

const require = createRequire(import.meta.url);
let installed = true;
try { require("@libsql/client"); } catch { installed = false; }

const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-terms-"));
process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
process.env.SESSION_SECRET = "test-secret";

const ALICE = "user-alice";
const BOB = "user-bob";

test("ending terms", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => {
const { migrate } = await import("../src/migrate.mjs");
await migrate();
const { run } = await import("../src/db.mjs");
for (const [id, email] of [[ALICE, "a@e.com"], [BOB, "b@e.com"]]) {
await run(`INSERT OR IGNORE INTO users (id,email,created_at) VALUES (?,?,?)`, [id, email, Date.now()]);
}
const m = await import("../src/moshpit.mjs");
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 () => {
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);
});

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("every refusal names itself", async () => {
const held = uniq();
await m.registerTld({ tld: held, userId: BOB });

assert.match((await m.quoteTld({ tld: held, buyerId: ALICE })).error, /already registered/);
assert.match((await m.quoteTld({ tld: held, buyerId: BOB })).error, /already yours/);
assert.match((await m.quoteTld({ tld: "bank", buyerId: ALICE })).error, /reserved/);
assert.match((await m.quoteTld({ tld: "a.b", buyerId: ALICE })).error, /not a valid TLD/);
});

await t.test("an open checkout holds the ending against other buyers", async () => {
const tld = uniq();
await m.openTldPurchase({ paymentId: pay(), tld, userId: ALICE, amountUsd: 5 });

const q = await m.quoteTld({ tld, buyerId: BOB });
assert.equal(q.ok, false);
assert.equal(q.taken, true);
assert.match(q.error, /in someone's checkout/);
});

await t.test("an expired reservation releases it", async () => {
const tld = uniq();
await m.openTldPurchase({ paymentId: pay(), tld, userId: ALICE, amountUsd: 5, now: Date.now() - m.RESERVATION_MS - 1000 });
assert.equal((await m.quoteTld({ tld, buyerId: BOB })).ok, true);
});

await t.test("settling hands it over with a term", async () => {
const tld = uniq();
const id = pay();
const now = Date.now();
await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5, now });

const result = await m.settleTldPurchase(id, now);
assert.equal(result.ok, 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");
});

await t.test("a redelivered webhook does not settle twice", async () => {
const tld = uniq();
const id = pay();
await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5 });

assert.equal((await m.settleTldPurchase(id)).ok, true);
// CoinPay retries anything it never got an ack for.
const again = await m.settleTldPurchase(id);
assert.equal(again.ok, false);
assert.match(again.error, /no pending purchase|already settled/);
});

await t.test("money against an ending taken first is a refund, not a shrug", async () => {
const tld = uniq();
const id = pay();
await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5 });
await m.registerTld({ tld, userId: BOB }); // Bob claims it in the gap

const result = await m.settleTldPurchase(id);
assert.equal(result.ok, false);
assert.equal(result.refundDue, true);
const [row] = await m.listTldPurchases(ALICE, 50);
assert.equal((await m.getTldWithTerm(tld)).user_id, BOB, "not taken from Bob");
assert.ok(row, "the purchase is still on Alice's record");
});

await t.test("renewing extends, and never shortens", async () => {
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);

assert.equal((await m.getTldWithTerm(tld)).expires_at, now + m.TERM_MS * 2);
});

await t.test("renewing a lapsed term runs from now, not from the past", async () => {
const tld = uniq();
const past = Date.now() - m.TERM_MS * 2;
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],
);

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);
});

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.
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);
});
});
Loading