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
38 changes: 23 additions & 15 deletions apps/web/app/api/book/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<Response> {
const raw = await request.text();
Expand All @@ -21,25 +25,29 @@ export async function POST(request: Request): Promise<Response> {
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)}`,
});
}
86 changes: 80 additions & 6 deletions apps/web/lib/coinpay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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<CreatedPayment> {
export async function createPayment(
chain: string = DEFAULT_CHAIN,
buyer: { accountId?: string | null; email?: string | null } = {},
): Promise<CreatedPayment> {
const key = env("COINPAY_API_KEY");
const business = env("COINPAY_BUSINESS_ID");
if (!key || !business) throw new Error("CoinPay is not configured");
Expand All @@ -107,7 +112,16 @@ export async function createPayment(chain: string = DEFAULT_CHAIN): Promise<Crea
amount: PRICE_USD,
blockchain: chain,
description: "The High Quality Terminal UI Cookbook (PDF)",
metadata: { product: "hqtui-cookbook-pdf", reference: randomUUID() },
// `property` names the site so a webhook (or a human reading the CoinPay
// dashboard) can tell which fleet site a payment belongs to. The buyer is
// anonymous today; account_id and email ride along when a caller has them.
metadata: {
property: PROPERTY,
product: PRODUCT,
reference: randomUUID(),
...(buyer.accountId ? { account_id: buyer.accountId } : {}),
...(buyer.email ? { email: buyer.email } : {}),
},
}),
});

Expand All @@ -123,23 +137,83 @@ export async function createPayment(chain: string = DEFAULT_CHAIN): Promise<Crea
return { id, url: `${siteBase()}/pay/${id}` };
}

/** CoinPay rejects a signature older than this, and so do we. */
const SIGNATURE_TOLERANCE_SECONDS = 300;

/**
* Verifies a webhook against the business's plaintext secret.
*
* CoinPay sends `X-CoinPay-Signature: t=<unix>,v1=<hex>` 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<string, string> = {};
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<string, unknown>;
/** 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_<paymentId>_<ts>`), 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<string, any>;
const data = (payload.data && typeof payload.data === "object" ? payload.data : payload) as Record<string, any>;
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<string, unknown>;
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<string, unknown>): boolean {
return metadata.property === PROPERTY || metadata.product === PRODUCT;
}

const TOKEN_TTL_SECONDS = 48 * 60 * 60;

/**
Expand Down
25 changes: 22 additions & 3 deletions apps/web/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,17 +196,36 @@ export async function recordCheckout(paymentId: string, chain: string): Promise<
}
}

export async function markPaid(paymentId: string): Promise<void> {
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<MarkPaidResult> {
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. */
Expand Down
69 changes: 69 additions & 0 deletions apps/web/test/coinpay-webhook.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading