diff --git a/apps/web/app/api/book/webhook/route.ts b/apps/web/app/api/book/webhook/route.ts index 0721a4e..1002875 100644 --- a/apps/web/app/api/book/webhook/route.ts +++ b/apps/web/app/api/book/webhook/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { signDownloadToken, verifyWebhook } from "@/lib/coinpay"; +import { isOurs, readWebhook, signDownloadToken, verifyWebhook } from "@/lib/coinpay"; import { markPaid } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -11,6 +11,10 @@ export const dynamic = "force-dynamic"; * The signature is over the raw body, so the body is read as text and parsed * afterwards. Re-serialising the parsed object would change the bytes and the * signature would never match. + * + * Every authenticated event answers 200, including ones we ignore and payment + * ids we never sold: a non-2xx makes CoinPay retry something that will never + * succeed. */ export async function POST(request: Request): Promise { const raw = await request.text(); @@ -21,25 +25,29 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: "bad signature" }, { status: 401 }); } - let event: { event?: string; type?: string; payment?: { id?: string }; data?: { id?: string } }; + let body: unknown; try { - event = JSON.parse(raw); + body = JSON.parse(raw); } catch { return NextResponse.json({ error: "bad body" }, { status: 400 }); } - const name = event.event ?? event.type ?? ""; - const paymentId = event.payment?.id ?? event.data?.id; - if (!paymentId) return NextResponse.json({ error: "no payment id" }, { status: 400 }); - - // `payment.forwarded` follows `payment.confirmed`; either means the money - // arrived, and marking twice is harmless. - if (name === "payment.confirmed" || name === "payment.forwarded") { - await markPaid(paymentId); - // Returned so a caller watching the response can hand the buyer their link - // without waiting for the redirect to come back around. - return NextResponse.json({ ok: true, download: `/api/book/download?token=${signDownloadToken(paymentId)}` }); + const event = readWebhook(body); + if (!event.paymentId) return NextResponse.json({ ok: true, ignored: "no payment id" }); + if (!event.settled) { + return NextResponse.json({ ok: true, ignored: event.type || event.status || "event" }); } - return NextResponse.json({ ok: true, ignored: name }); + const result = await markPaid(event.paymentId, isOurs(event.metadata)); + if (result === "unknown") return NextResponse.json({ ok: true, settled: false, reason: "unknown payment" }); + if (result === "unconfigured") return NextResponse.json({ ok: true, settled: false, reason: "no database" }); + + // Returned so a caller watching the response can hand the buyer their link + // without waiting for the redirect to come back around. + return NextResponse.json({ + ok: true, + settled: true, + duplicate: result === "already_paid", + download: `/api/book/download?token=${signDownloadToken(event.paymentId)}`, + }); } diff --git a/apps/web/lib/coinpay.ts b/apps/web/lib/coinpay.ts index e484239..4842aae 100644 --- a/apps/web/lib/coinpay.ts +++ b/apps/web/lib/coinpay.ts @@ -36,6 +36,8 @@ export function configured(): boolean { } export const PRICE_USD = 1; +export const PROPERTY = "hqtui.com"; +export const PRODUCT = "hqtui-cookbook-pdf"; export interface Chain { /** What the payments API calls `blockchain`. */ @@ -90,7 +92,10 @@ export interface CreatedPayment { * resolves the right wallet per chain. Passing one here would offer a single * address for every chain, which is wrong the moment there is more than one. */ -export async function createPayment(chain: string = DEFAULT_CHAIN): Promise { +export async function createPayment( + chain: string = DEFAULT_CHAIN, + buyer: { accountId?: string | null; email?: string | null } = {}, +): Promise { const key = env("COINPAY_API_KEY"); const business = env("COINPAY_BUSINESS_ID"); if (!key || !business) throw new Error("CoinPay is not configured"); @@ -107,7 +112,16 @@ export async function createPayment(chain: string = DEFAULT_CHAIN): Promise,v1=` where the hex is + * HMAC-SHA256 of `${t}.${rawBody}` (coinpayportal src/lib/webhooks/service.ts + * signWebhookPayload). An HMAC of the bare body never matches, which is how + * this route used to refuse every real webhook. The timestamp is bound into + * the MAC and checked against a 300s window so a captured delivery cannot be + * replayed later. + * * Compared in constant time, and length-checked first because * `timingSafeEqual` throws on a length mismatch rather than returning false. */ -export function verifyWebhook(rawBody: string, signature: string | null): boolean { - const secret = env("COINPAY_WEBHOOK_SECRET"); +export function verifyWebhook( + rawBody: string, + signature: string | null, + secret: string | undefined = env("COINPAY_WEBHOOK_SECRET"), + nowSeconds: number = Math.floor(Date.now() / 1000), +): boolean { if (!secret || !signature) return false; - const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); - const given = signature.replace(/^sha256=/, "").trim(); + const parts: Record = {}; + for (const part of signature.split(",")) { + const at = part.indexOf("="); + if (at > 0) parts[part.slice(0, at).trim()] = part.slice(at + 1).trim(); + } + const t = parts.t; + const given = parts.v1; + if (!t || !given || !/^\d+$/.test(t)) return false; + if (Math.abs(nowSeconds - Number(t)) > SIGNATURE_TOLERANCE_SECONDS) return false; + + const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); if (given.length !== expected.length) return false; return timingSafeEqual(Buffer.from(given, "utf8"), Buffer.from(expected, "utf8")); } +export interface WebhookEvent { + type: string; + paymentId: string | null; + status: string; + metadata: Record; + /** True when the event says the money arrived. */ + settled: boolean; +} + +/** Statuses that mean the buyer paid. `forwarded` follows `confirmed`. */ +const SETTLED = new Set(["confirmed", "forwarded", "completed", "paid"]); + +/** + * Reads CoinPay's webhook body. + * + * The payment is nested under `data`, and the top-level `id` is the EVENT id + * (`evt__`), not the payment. Reading the flat fields first + * would key the sale to the event id, so `data` is read first and the flat + * shape is only a fallback for the older test sender. + */ +export function readWebhook(body: unknown): WebhookEvent { + const payload = (body ?? {}) as Record; + const data = (payload.data && typeof payload.data === "object" ? payload.data : payload) as Record; + const type = String(payload.type ?? payload.event ?? ""); + const paymentId = data.payment_id ?? payload.payment?.id ?? (payload.data ? data.id : null) ?? null; + const status = String(data.status ?? "").toLowerCase(); + const metadata = (data.metadata ?? payload.metadata ?? {}) as Record; + const settled = + SETTLED.has(status) || (!status && (type === "payment.confirmed" || type === "payment.forwarded")); + return { type, paymentId: paymentId ? String(paymentId) : null, status, metadata, settled }; +} + +/** True when the metadata says this payment was created by this site. */ +export function isOurs(metadata: Record): boolean { + return metadata.property === PROPERTY || metadata.product === PRODUCT; +} + const TOKEN_TTL_SECONDS = 48 * 60 * 60; /** diff --git a/apps/web/lib/db.ts b/apps/web/lib/db.ts index f8860f5..2e8415a 100644 --- a/apps/web/lib/db.ts +++ b/apps/web/lib/db.ts @@ -196,17 +196,36 @@ export async function recordCheckout(paymentId: string, chain: string): Promise< } } -export async function markPaid(paymentId: string): Promise { - if (!configured()) return; +export type MarkPaidResult = "paid" | "already_paid" | "unknown" | "unconfigured"; + +/** + * Marks a purchase paid. Idempotent: a repeat delivery of the same payment + * (CoinPay sends `payment.confirmed` then `payment.forwarded`, and retries) + * changes nothing and keeps the first paid_at. + * + * A payment with no checkout row is only inserted when `ours` says the + * payment's metadata names this site. Otherwise it is an id we never sold, + * and the webhook answers a 200 no-op rather than minting a download for it. + */ +export async function markPaid(paymentId: string, ours = false): Promise { + if (!configured()) return "unconfigured"; await migrate(); + const [rows] = await execute([ + { sql: `SELECT status FROM book_purchases WHERE payment_id = ?`, args: [paymentId] }, + ]); + const existing = rows?.[0]; + if (existing?.status === "paid") return "already_paid"; + if (!existing && !ours) return "unknown"; await execute([ { sql: `INSERT INTO book_purchases (payment_id, chain, status, paid_at) VALUES (?, 'unknown', 'paid', datetime('now')) - ON CONFLICT(payment_id) DO UPDATE SET status = 'paid', paid_at = datetime('now')`, + ON CONFLICT(payment_id) DO UPDATE SET status = 'paid', paid_at = datetime('now') + WHERE book_purchases.status <> 'paid'`, args: [paymentId], }, ]); + return "paid"; } /** True when the payment confirmed. Counts the download on the way past. */ diff --git a/apps/web/test/coinpay-webhook.test.ts b/apps/web/test/coinpay-webhook.test.ts new file mode 100644 index 0000000..415c452 --- /dev/null +++ b/apps/web/test/coinpay-webhook.test.ts @@ -0,0 +1,69 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { isOurs, readWebhook, verifyWebhook } from "../lib/coinpay.ts"; + +const SECRET = "whsecret_hqtuiTestSecret"; + +// The exact shape coinpayportal src/lib/webhooks/service.ts sends. +const payload = { + id: "evt_d084f959-c33c-4540-82b8-221181347639_1775551263", + type: "payment.confirmed", + data: { + payment_id: "d084f959-c33c-4540-82b8-221181347639", + status: "confirmed", + amount: 1, + amount_crypto: "1.000000", + amount_usd: 1, + currency: "USDC_SOL", + confirmations: 1, + payment_address: "So1anaAddr", + tx_hash: "0xabc", + metadata: { property: "hqtui.com", product: "hqtui-cookbook-pdf", reference: "r-1" }, + }, + created_at: "2026-10-21T00:00:00.000Z", + business_id: "biz_1", +}; + +function sign(raw: string, t: number, secret = SECRET): string { + return `t=${t},v1=${createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex")}`; +} + +test("a real CoinPay signature verifies", () => { + const raw = JSON.stringify(payload); + const now = 1775551263; + assert.equal(verifyWebhook(raw, sign(raw, now), SECRET, now), true); +}); + +test("tampered, stale, wrong-secret and bare-HMAC signatures are refused", () => { + const raw = JSON.stringify(payload); + const now = 1775551263; + assert.equal(verifyWebhook(raw + " ", sign(raw, now), SECRET, now), false); + assert.equal(verifyWebhook(raw, sign(raw, now - 301), SECRET, now), false); + assert.equal(verifyWebhook(raw, sign(raw, now, "other"), SECRET, now), false); + const bare = createHmac("sha256", SECRET).update(raw).digest("hex"); + assert.equal(verifyWebhook(raw, bare, SECRET, now), false); + assert.equal(verifyWebhook(raw, null, SECRET, now), false); + assert.equal(verifyWebhook(raw, sign(raw, now), undefined, now), false); +}); + +test("the payment id comes from data.payment_id, never the event id", () => { + const event = readWebhook(payload); + assert.equal(event.paymentId, "d084f959-c33c-4540-82b8-221181347639"); + assert.equal(event.type, "payment.confirmed"); + assert.equal(event.settled, true); + assert.equal(isOurs(event.metadata), true); +}); + +test("forwarded settles; pending and expired do not", () => { + assert.equal(readWebhook({ ...payload, type: "payment.forwarded", data: { ...payload.data, status: "forwarded" } }).settled, true); + assert.equal(readWebhook({ ...payload, type: "payment.pending", data: { ...payload.data, status: "pending" } }).settled, false); + assert.equal(readWebhook({ ...payload, type: "payment.expired", data: { ...payload.data, status: "expired" } }).settled, false); +}); + +test("the older flat test-sender shape still reads", () => { + const event = readWebhook({ type: "payment.confirmed", payment_id: "p-flat", status: "confirmed", metadata: {} }); + assert.equal(event.paymentId, "p-flat"); + assert.equal(event.settled, true); + assert.equal(isOurs(event.metadata), false); +});