Skip to content

Commit 825751c

Browse files
ralyodioclaude
andcommitted
pit: an ending can be bought and renewed
ENDING_PRICE_USD was $5 and charged by nothing. registerTld inserted a row and claiming was free and permanent — no term, no expiry, nothing to renew. This is the lifecycle underneath that price: PRD 0005 §5's one-year term on a direct ending, and the CoinPay checkout that starts one. Modelled on the name purchase flow rather than invented beside it. Same shape, same reasons: keyed on the payment id so a webhook redelivery settles the same row; a conditional UPDATE to claim it, because CoinPay retries anything it never got an ack for and two deliveries read 'pending' before either write lands; a reservation that stops two people paying at once while the UNIQUE constraint stays the real arbiter at settlement. An ending taken between checkout and confirmation is recorded `refund_due` and logged. That is real money against something the buyer cannot have, and the name flow already decided that is not a thing to swallow. A renewal extends from whichever is later, the current expiry or now. Renewing early adds to what is left instead of throwing it away; renewing after a lapse does not backdate the term into the past. PRD 0005 R7. Both new columns are NULLable and every existing row keeps NULL. A NULL expiry means "no term recorded", which is what all ~250 endings claimed before today have, and isExpired deliberately reads it as not-expired. Backfilling an invented expiry would put a namespace on a clock its owner never agreed to; §21.8 wants a published grandfathering policy first, and that is a decision rather than a migration. 13 tests, including the two races that cost money: a redelivered webhook, and an ending claimed in the gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 65386b7 commit 825751c

4 files changed

Lines changed: 366 additions & 1 deletion

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
-- Endings get a term, and a way to be paid for.
2+
--
3+
-- Claiming an ending has been free and permanent: a row in moshpit_tlds with a
4+
-- created_at and nothing that ever expires. PRD 0005 §5 puts a $5/year price on
5+
-- a direct ending and a one-year term under it, which needs two things this
6+
-- schema has never had — when the term started, and when it runs out.
7+
--
8+
-- Both are NULLable, and every existing row keeps NULL. A NULL expires_at means
9+
-- "no term recorded", which is what every ending claimed before today has, and
10+
-- it is deliberately not the same as an expired one. Backfilling those rows
11+
-- with an invented expiry would silently put a few hundred endings into a
12+
-- lifecycle their owners never agreed to; §21.8 calls for a published
13+
-- grandfathering policy first, and that is a decision rather than a migration.
14+
ALTER TABLE moshpit_tlds ADD COLUMN term_started_at INTEGER;
15+
ALTER TABLE moshpit_tlds ADD COLUMN expires_at INTEGER;
16+
17+
CREATE INDEX IF NOT EXISTS idx_moshpit_tlds_expires ON moshpit_tlds(expires_at);
18+
19+
-- One row per CoinPay checkout for an ending. Keyed on the payment id so a
20+
-- webhook redelivery settles the same row rather than creating a second one —
21+
-- the same shape as moshpit_name_purchases, because it is the same problem.
22+
CREATE TABLE IF NOT EXISTS moshpit_tld_purchases (
23+
id TEXT PRIMARY KEY,
24+
tld TEXT NOT NULL,
25+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
26+
amount_usd REAL NOT NULL,
27+
-- register | renew. A renewal extends a term the buyer already holds; a
28+
-- registration creates one. They settle differently and the row has to say
29+
-- which it is, or a redelivered webhook cannot know what it is finishing.
30+
kind TEXT NOT NULL DEFAULT 'register' CHECK (kind IN ('register','renew')),
31+
-- pending -> cleared, or -> refund_due when the ending was claimed between
32+
-- checkout and confirmation. That last state is real money against something
33+
-- the buyer cannot have, so it is recorded rather than swallowed.
34+
status TEXT NOT NULL,
35+
years INTEGER NOT NULL DEFAULT 1,
36+
created_at INTEGER NOT NULL,
37+
-- How long this checkout holds the ending against other buyers. The UNIQUE
38+
-- constraint on moshpit_tlds.tld is still the real arbiter; this only stops
39+
-- the ordinary case of two people paying for the same ending at once.
40+
reserved_until INTEGER NOT NULL
41+
);
42+
CREATE INDEX IF NOT EXISTS idx_moshpit_tld_purchases_user ON moshpit_tld_purchases(user_id);
43+
CREATE INDEX IF NOT EXISTS idx_moshpit_tld_purchases_tld ON moshpit_tld_purchases(tld, status);

apps/pwa/src/moshpit.mjs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { db, get, all, run } from "./db.mjs";
1414
import {
1515
BULK_CHUNK,
1616
BULK_TIME_BUDGET_MS,
17+
ENDING_PRICE_USD,
1718
MAX_BULK_TLDS,
1819
MAX_CHILD_PRICE_USD,
1920
normalizeLabel,
@@ -718,3 +719,154 @@ export function summarizeBulkClaim(result, limit = MAX_BULK_TLDS) {
718719

719720
return parts.length ? parts.join(". ") + "." : "nothing to claim — paste one ending per line.";
720721
}
722+
723+
/* ---- buying and renewing an ending ---- */
724+
725+
/** A term is a year. Ten is the ceiling PRD 0005 R6 puts on one checkout. */
726+
export const TERM_MS = 365 * 24 * 60 * 60 * 1000;
727+
export const MAX_TERM_YEARS = 10;
728+
729+
const TLD_COLS_FULL = `tld, user_id, owner_email, alias_of, price_usd, term_started_at, expires_at, created_at`;
730+
731+
export async function getTldWithTerm(tld) {
732+
return get(`SELECT ${TLD_COLS_FULL} FROM moshpit_tlds WHERE tld = ?`, [tld]);
733+
}
734+
735+
/**
736+
* What it costs to take an unclaimed ending.
737+
*
738+
* Quoted rather than assumed, and the same call the checkout makes, so an offer
739+
* shown anywhere is one the next click can honour. Every refusal names itself:
740+
* reserved, already held, and "you already own it" are three different answers
741+
* and a single "unavailable" would be none of them.
742+
*/
743+
export async function quoteTld({ tld: tldInput, buyerId, years = 1, now = Date.now() }) {
744+
const tld = normalizeTld(tldInput);
745+
if (!tld) return { ok: false, error: "not a valid TLD — letters, digits and dashes only, no dots" };
746+
747+
const why = tldRejection(tld);
748+
if (why) return { ok: false, error: why };
749+
750+
const term = Number(years);
751+
if (!Number.isInteger(term) || term < 1 || term > MAX_TERM_YEARS) {
752+
return { ok: false, error: `a term is 1 to ${MAX_TERM_YEARS} years` };
753+
}
754+
755+
const owner = await getTldWithTerm(tld);
756+
if (owner) {
757+
if (owner.user_id === buyerId) return { ok: false, error: `.${tld} is already yours`, taken: true };
758+
return { ok: false, error: `.${tld} is already registered`, taken: true };
759+
}
760+
761+
// Someone else's open checkout holds it. The UNIQUE constraint is still the
762+
// real arbiter at settlement; this only stops two people paying at once.
763+
const held = await get(
764+
`SELECT id FROM moshpit_tld_purchases
765+
WHERE tld = ? AND status = 'pending' AND reserved_until > ? LIMIT 1`,
766+
[tld, now],
767+
);
768+
if (held) return { ok: false, error: `.${tld} is in someone's checkout right now — try again shortly`, taken: true };
769+
770+
return { ok: true, tld, years: term, priceUsd: Math.round(ENDING_PRICE_USD * term * 100) / 100 };
771+
}
772+
773+
/** What it costs to keep one you hold. */
774+
export async function quoteRenewal({ tld: tldInput, userId, years = 1 }) {
775+
const tld = normalizeTld(tldInput);
776+
if (!tld) return { ok: false, error: "not a valid TLD" };
777+
778+
const term = Number(years);
779+
if (!Number.isInteger(term) || term < 1 || term > MAX_TERM_YEARS) {
780+
return { ok: false, error: `a term is 1 to ${MAX_TERM_YEARS} years` };
781+
}
782+
783+
const owner = await getTldWithTerm(tld);
784+
if (!owner) return { ok: false, error: `.${tld} is not registered` };
785+
if (owner.user_id !== userId) return { ok: false, error: `you do not own .${tld}` };
786+
787+
return { ok: true, tld, years: term, priceUsd: Math.round(ENDING_PRICE_USD * term * 100) / 100, expiresAt: owner.expires_at };
788+
}
789+
790+
export async function openTldPurchase({ paymentId, tld, userId, amountUsd, years = 1, kind = "register", now = Date.now() }) {
791+
await run(
792+
`INSERT INTO moshpit_tld_purchases (id, tld, user_id, amount_usd, kind, status, years, created_at, reserved_until)
793+
VALUES (?,?,?,?,?, 'pending', ?,?,?)`,
794+
[paymentId, tld, userId, amountUsd, kind, years, now, now + RESERVATION_MS],
795+
);
796+
}
797+
798+
/**
799+
* Hand over a paid-for ending, or extend one. Idempotent on the payment id.
800+
*
801+
* The claim is a conditional UPDATE for the same reason every other settlement
802+
* here uses one: CoinPay retries a webhook it never got an ack for, so two
803+
* deliveries can be in flight at once and both read 'pending' before either
804+
* write lands.
805+
*
806+
* A renewal never shortens a term. It extends from whichever is later — the
807+
* current expiry or now — so renewing early adds to what is left rather than
808+
* throwing it away, and renewing late does not backdate the new term into the
809+
* past. PRD 0005 R7.
810+
*/
811+
export async function settleTldPurchase(paymentId, now = Date.now()) {
812+
const p = await get(`SELECT * FROM moshpit_tld_purchases WHERE id = ? AND status = 'pending'`, [paymentId]);
813+
if (!p) return { ok: false, error: "no pending purchase for that payment" };
814+
815+
const claimed = await run(
816+
`UPDATE moshpit_tld_purchases SET status = 'settling' WHERE id = ? AND status = 'pending'`, [paymentId]);
817+
if (!claimed.rowsAffected) return { ok: false, error: "already settled" };
818+
819+
const span = TERM_MS * (p.years || 1);
820+
821+
if (p.kind === "renew") {
822+
const owner = await getTldWithTerm(p.tld);
823+
if (!owner || owner.user_id !== p.user_id) {
824+
await run(`UPDATE moshpit_tld_purchases SET status = 'refund_due' WHERE id = ?`, [paymentId]);
825+
console.error(`[moshpit] .${p.tld} left ${p.user_id} before renewal ${paymentId} settled — refund due`);
826+
return { ok: false, error: "ending changed hands before the renewal settled", refundDue: true };
827+
}
828+
const from = Math.max(owner.expires_at || 0, now);
829+
await run(`UPDATE moshpit_tlds SET expires_at = ? WHERE tld = ? AND user_id = ?`,
830+
[from + span, p.tld, p.user_id]);
831+
await run(`UPDATE moshpit_tld_purchases SET status = 'cleared' WHERE id = ?`, [paymentId]);
832+
await logAction(p.tld, p.user_id, `renew:${p.years}y`);
833+
return { ok: true, tld: p.tld, userId: p.user_id, expiresAt: from + span, renewed: true };
834+
}
835+
836+
try {
837+
await run(
838+
`INSERT INTO moshpit_tlds (tld, user_id, owner_email, owner_key, created_at, term_started_at, expires_at)
839+
VALUES (?,?,?,?,?,?,?)`,
840+
[p.tld, p.user_id, null, null, now, now, now + span],
841+
);
842+
} catch {
843+
// Claimed by someone else between checkout and confirmation. Real money
844+
// against something the buyer cannot have, so it is recorded, not dropped.
845+
await run(`UPDATE moshpit_tld_purchases SET status = 'refund_due' WHERE id = ?`, [paymentId]);
846+
console.error(`[moshpit] .${p.tld} was taken before payment ${paymentId} settled — refund due to ${p.user_id}`);
847+
return { ok: false, error: "ending was taken before payment settled", refundDue: true };
848+
}
849+
850+
await run(`UPDATE moshpit_tld_purchases SET status = 'cleared' WHERE id = ?`, [paymentId]);
851+
await logAction(p.tld, p.user_id, `bought:.${p.tld}`);
852+
return { ok: true, tld: p.tld, userId: p.user_id, expiresAt: now + span };
853+
}
854+
855+
export async function listTldPurchases(userId, limit = 50) {
856+
return all(
857+
`SELECT id, tld, amount_usd, kind, years, status, created_at FROM moshpit_tld_purchases
858+
WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`,
859+
[userId, limit],
860+
);
861+
}
862+
863+
/**
864+
* Is this ending inside its term?
865+
*
866+
* A NULL expiry is not expired. Every ending claimed before terms existed has
867+
* one, and treating "no term recorded" as "term ended" would expire a few
868+
* hundred namespaces that nobody agreed to put on a clock.
869+
*/
870+
export function isExpired(tld, now = Date.now()) {
871+
return Boolean(tld?.expires_at) && tld.expires_at <= now;
872+
}

apps/pwa/src/routes/credits.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { id } from "../lib/crypto.mjs";
66
import { grant } from "../lib/credits.mjs";
77
import { verifySignature } from "../lib/signature.mjs";
88
import { requireAuth } from "../lib/session.mjs";
9-
import { settleNamePurchase } from "../moshpit.mjs";
9+
import { settleNamePurchase, settleTldPurchase } from "../moshpit.mjs";
1010

1111
export const creditsRouter = Router();
1212

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

7679
const p = await get(`SELECT * FROM credit_purchases WHERE id = ? AND status = 'pending'`, [payId]);
7780
if (p) {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Paying for an ending, and keeping it.
2+
//
3+
// Against a real throwaway libSQL database: the interesting behaviour is in
4+
// conditional UPDATEs and a UNIQUE constraint, and a stub has neither.
5+
import assert from "node:assert/strict";
6+
import { mkdtempSync } from "node:fs";
7+
import { tmpdir } from "node:os";
8+
import path from "node:path";
9+
import { createRequire } from "node:module";
10+
import { randomBytes } from "node:crypto";
11+
import test from "node:test";
12+
13+
const require = createRequire(import.meta.url);
14+
let installed = true;
15+
try { require("@libsql/client"); } catch { installed = false; }
16+
17+
const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-terms-"));
18+
process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
19+
process.env.SESSION_SECRET = "test-secret";
20+
21+
const ALICE = "user-alice";
22+
const BOB = "user-bob";
23+
24+
test("ending terms", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => {
25+
const { migrate } = await import("../src/migrate.mjs");
26+
await migrate();
27+
const { run } = await import("../src/db.mjs");
28+
for (const [id, email] of [[ALICE, "a@e.com"], [BOB, "b@e.com"]]) {
29+
await run(`INSERT OR IGNORE INTO users (id,email,created_at) VALUES (?,?,?)`, [id, email, Date.now()]);
30+
}
31+
const m = await import("../src/moshpit.mjs");
32+
const uniq = () => `e${randomBytes(4).toString("hex")}`;
33+
const pay = () => `pay-${randomBytes(6).toString("hex")}`;
34+
35+
await t.test("an unclaimed ending quotes at the ending price", async () => {
36+
const q = await m.quoteTld({ tld: uniq(), buyerId: ALICE });
37+
assert.equal(q.ok, true);
38+
assert.equal(q.priceUsd, 5, "PRD 0005 §10.1, rounded to whole dollars");
39+
assert.equal(q.years, 1);
40+
});
41+
42+
await t.test("multiple years multiply, up to the cap", async () => {
43+
assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 3 })).priceUsd, 15);
44+
assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 11 })).ok, false);
45+
assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 0 })).ok, false);
46+
assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 1.5 })).ok, false);
47+
});
48+
49+
await t.test("every refusal names itself", async () => {
50+
const held = uniq();
51+
await m.registerTld({ tld: held, userId: BOB });
52+
53+
assert.match((await m.quoteTld({ tld: held, buyerId: ALICE })).error, /already registered/);
54+
assert.match((await m.quoteTld({ tld: held, buyerId: BOB })).error, /already yours/);
55+
assert.match((await m.quoteTld({ tld: "bank", buyerId: ALICE })).error, /reserved/);
56+
assert.match((await m.quoteTld({ tld: "a.b", buyerId: ALICE })).error, /not a valid TLD/);
57+
});
58+
59+
await t.test("an open checkout holds the ending against other buyers", async () => {
60+
const tld = uniq();
61+
await m.openTldPurchase({ paymentId: pay(), tld, userId: ALICE, amountUsd: 5 });
62+
63+
const q = await m.quoteTld({ tld, buyerId: BOB });
64+
assert.equal(q.ok, false);
65+
assert.equal(q.taken, true);
66+
assert.match(q.error, /in someone's checkout/);
67+
});
68+
69+
await t.test("an expired reservation releases it", async () => {
70+
const tld = uniq();
71+
await m.openTldPurchase({ paymentId: pay(), tld, userId: ALICE, amountUsd: 5, now: Date.now() - m.RESERVATION_MS - 1000 });
72+
assert.equal((await m.quoteTld({ tld, buyerId: BOB })).ok, true);
73+
});
74+
75+
await t.test("settling hands it over with a term", async () => {
76+
const tld = uniq();
77+
const id = pay();
78+
const now = Date.now();
79+
await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5, now });
80+
81+
const result = await m.settleTldPurchase(id, now);
82+
assert.equal(result.ok, true);
83+
84+
const row = await m.getTldWithTerm(tld);
85+
assert.equal(row.user_id, ALICE);
86+
assert.equal(row.term_started_at, now);
87+
assert.equal(row.expires_at, now + m.TERM_MS, "one year");
88+
});
89+
90+
await t.test("a redelivered webhook does not settle twice", async () => {
91+
const tld = uniq();
92+
const id = pay();
93+
await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5 });
94+
95+
assert.equal((await m.settleTldPurchase(id)).ok, true);
96+
// CoinPay retries anything it never got an ack for.
97+
const again = await m.settleTldPurchase(id);
98+
assert.equal(again.ok, false);
99+
assert.match(again.error, /no pending purchase|already settled/);
100+
});
101+
102+
await t.test("money against an ending taken first is a refund, not a shrug", async () => {
103+
const tld = uniq();
104+
const id = pay();
105+
await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5 });
106+
await m.registerTld({ tld, userId: BOB }); // Bob claims it in the gap
107+
108+
const result = await m.settleTldPurchase(id);
109+
assert.equal(result.ok, false);
110+
assert.equal(result.refundDue, true);
111+
const [row] = await m.listTldPurchases(ALICE, 50);
112+
assert.equal((await m.getTldWithTerm(tld)).user_id, BOB, "not taken from Bob");
113+
assert.ok(row, "the purchase is still on Alice's record");
114+
});
115+
116+
await t.test("renewing extends, and never shortens", async () => {
117+
const tld = uniq();
118+
const now = Date.now();
119+
const first = pay();
120+
await m.openTldPurchase({ paymentId: first, tld, userId: ALICE, amountUsd: 5, now });
121+
await m.settleTldPurchase(first, now);
122+
123+
// Renewing early adds to what is left rather than throwing it away.
124+
const second = pay();
125+
await m.openTldPurchase({ paymentId: second, tld, userId: ALICE, amountUsd: 5, kind: "renew", now });
126+
await m.settleTldPurchase(second, now + 1000);
127+
128+
assert.equal((await m.getTldWithTerm(tld)).expires_at, now + m.TERM_MS * 2);
129+
});
130+
131+
await t.test("renewing a lapsed term runs from now, not from the past", async () => {
132+
const tld = uniq();
133+
const past = Date.now() - m.TERM_MS * 2;
134+
await run(
135+
`INSERT INTO moshpit_tlds (tld,user_id,created_at,term_started_at,expires_at) VALUES (?,?,?,?,?)`,
136+
[tld, ALICE, past, past, past + m.TERM_MS],
137+
);
138+
139+
const id = pay();
140+
const now = Date.now();
141+
await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5, kind: "renew", now });
142+
await m.settleTldPurchase(id, now);
143+
144+
assert.equal((await m.getTldWithTerm(tld)).expires_at, now + m.TERM_MS, "not backdated");
145+
});
146+
147+
await t.test("only the holder may renew", async () => {
148+
const tld = uniq();
149+
await m.registerTld({ tld, userId: ALICE });
150+
assert.match((await m.quoteRenewal({ tld, userId: BOB })).error, /do not own/);
151+
assert.equal((await m.quoteRenewal({ tld, userId: ALICE })).ok, true);
152+
});
153+
154+
await t.test("an ending with no term recorded is not expired", async () => {
155+
// Every ending claimed before terms existed has a NULL expiry. Treating
156+
// that as expired would put a few hundred namespaces on a clock nobody
157+
// agreed to.
158+
const tld = uniq();
159+
await m.registerTld({ tld, userId: ALICE });
160+
const row = await m.getTldWithTerm(tld);
161+
162+
assert.equal(row.expires_at, null);
163+
assert.equal(m.isExpired(row), false);
164+
assert.equal(m.isExpired({ expires_at: Date.now() - 1 }), true);
165+
assert.equal(m.isExpired({ expires_at: Date.now() + 1000 }), false);
166+
});
167+
});

0 commit comments

Comments
 (0)