Skip to content

Commit 28a95a5

Browse files
committed
refactor: adopt @profullstack/stack modules
Replace vendored/drifted referrals, email, supabase, feedback, coinpay and crawlproof code with @profullstack/stack@^0.1.0 subpath imports.
1 parent 5881a2a commit 28a95a5

3 files changed

Lines changed: 27 additions & 54 deletions

File tree

app/api/coinpay/webhook/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { verifyWebhook, isPaidStatus } from "@/lib/coinpay";
2+
import { verifyCoinPayWebhook } from "@profullstack/stack/coinpay";
3+
import { isPaidStatus } from "@/lib/coinpay";
34
import { activateAccount } from "@/lib/db";
45
import { provisionTenant } from "@/lib/provision";
56

@@ -11,8 +12,12 @@ export const dynamic = "force-dynamic";
1112
// account to active and provision its tenant page. Idempotent.
1213
export async function POST(req: NextRequest) {
1314
const raw = await req.text();
14-
const sig = req.headers.get("x-coinpay-signature");
15-
if (!verifyWebhook(raw, sig)) {
15+
const ok = verifyCoinPayWebhook({
16+
signature: req.headers.get("x-coinpay-signature"),
17+
rawBody: raw,
18+
secret: process.env.COINPAY_WEBHOOK_SECRET || "",
19+
});
20+
if (!ok) {
1621
return NextResponse.json({ error: "invalid signature" }, { status: 401 });
1722
}
1823

lib/coinpay.ts

Lines changed: 18 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,23 @@
11
// Server-to-server CoinPayPortal payments for the $1 account-setup fee.
22
//
3-
// Contract (from the coinpayportal repo):
3+
// The HTTP client is `createCoinPayClient` from @profullstack/stack/coinpay:
44
// POST <ISSUER>/api/payments/create Authorization: Bearer <cp_live_ key>
5-
// body { amount, blockchain, description, metadata, redirect_url, business_id? }
6-
// -> 201 { payment: { id, status, ... } }
5+
// -> { payment: { id, status, ... } }
76
// Hosted pay page = <ISSUER>/pay/<id> (constructed from the returned id)
87
// Confirmation = webhook signed "X-CoinPay-Signature: t=<ts>,v1=<hmac>"
9-
// (HMAC-SHA256 of "<ts>.<rawBody>"), event "payment.confirmed".
8+
// (verified with verifyCoinPayWebhook in the webhook route),
9+
// event "payment.confirmed".
1010
//
1111
// When COINPAY_API_KEY is unset (local dev / not yet provisioned) payConfigured()
1212
// is false and callers auto-activate instead of charging, so the flow is testable
1313
// offline. The $1 fee is collected to moshcoding's own business payout wallet — a
1414
// per-user payout wallet is captured on the account for the user's OWN future
1515
// earnings, not for this charge.
16-
import crypto from "node:crypto";
16+
import { createCoinPayClient } from "@profullstack/stack/coinpay";
1717

1818
const ISSUER = (process.env.COINPAY_ISSUER || "https://coinpayportal.com").replace(/\/+$/, "");
1919
const API_KEY = process.env.COINPAY_API_KEY || "";
2020
const BUSINESS_ID = process.env.COINPAY_BUSINESS_ID || "";
21-
const WEBHOOK_SECRET = process.env.COINPAY_WEBHOOK_SECRET || "";
2221
const PAY_CHAIN = process.env.COINPAY_PAY_CHAIN || "USDC_POL";
2322

2423
export const SETUP_FEE_USD = process.env.SETUP_FEE_USD || "1.00";
@@ -41,50 +40,19 @@ export async function createSetupPayment(opts: {
4140
redirectUrl?: string;
4241
amount?: string;
4342
}): Promise<CreatedPayment> {
44-
const body: Record<string, unknown> = {
45-
amount: opts.amount || SETUP_FEE_USD,
46-
blockchain: PAY_CHAIN,
43+
// Lazy construction: createCoinPayClient throws without an apiKey, and this
44+
// module is imported even when CoinPay isn't configured (offline dev mode).
45+
const coinpay = createCoinPayClient({ apiKey: API_KEY, baseUrl: ISSUER });
46+
const { paymentId, payment } = await coinpay.createCheckout({
47+
amountUsd: Number(opts.amount || SETUP_FEE_USD),
48+
currency: PAY_CHAIN.toLowerCase(),
49+
paymentMethod: "crypto",
4750
description: `moshcoding account setup — ${opts.domain}`,
4851
metadata: { kind: "account_setup", account_id: opts.accountId, email: opts.email, domain: opts.domain },
49-
};
50-
if (opts.redirectUrl) body.redirect_url = opts.redirectUrl;
51-
if (BUSINESS_ID) body.business_id = BUSINESS_ID;
52-
53-
const res = await fetch(`${ISSUER}/api/payments/create`, {
54-
method: "POST",
55-
headers: { "content-type": "application/json", authorization: `Bearer ${API_KEY}` },
56-
body: JSON.stringify(body),
52+
...(opts.redirectUrl ? { redirectUrl: opts.redirectUrl } : {}),
53+
...(BUSINESS_ID ? { businessId: BUSINESS_ID } : {}),
5754
});
58-
if (!res.ok) {
59-
const detail = await res.text().catch(() => "");
60-
throw new Error(`coinpay create ${res.status}: ${detail.slice(0, 200)}`);
61-
}
62-
const data = (await res.json().catch(() => ({}))) as { payment?: { id?: string; status?: string } };
63-
const id = data.payment?.id;
64-
if (!id) throw new Error("coinpay: no payment id in response");
65-
return { id, status: data.payment?.status || "pending", payUrl: payUrl(id) };
66-
}
67-
68-
/**
69-
* Verifies an "X-CoinPay-Signature: t=<ts>,v1=<hex>" header. HMAC-SHA256 over
70-
* "<ts>.<rawBody>" with the shared webhook secret, 5-minute timestamp tolerance,
71-
* constant-time compare. Returns false when no secret is configured.
72-
*/
73-
export function verifyWebhook(rawBody: string, sigHeader: string | null): boolean {
74-
if (!WEBHOOK_SECRET || !sigHeader) return false;
75-
const parts: Record<string, string> = {};
76-
for (const kv of sigHeader.split(",")) {
77-
const i = kv.indexOf("=");
78-
if (i > -1) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
79-
}
80-
const { t, v1 } = parts;
81-
if (!t || !v1) return false;
82-
const ts = Number(t);
83-
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
84-
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(`${t}.${rawBody}`).digest("hex");
85-
const a = Buffer.from(v1);
86-
const b = Buffer.from(expected);
87-
return a.length === b.length && crypto.timingSafeEqual(a, b);
55+
return { id: paymentId, status: payment.status || "pending", payUrl: payUrl(paymentId) };
8856
}
8957

9058
const PAID = new Set(["confirmed", "forwarded", "completed"]);
@@ -96,10 +64,9 @@ export function isPaidStatus(s: unknown): boolean {
9664
/** GET <ISSUER>/api/payments/<id> — poll fallback when a webhook is missed. */
9765
export async function fetchPaymentStatus(id: string): Promise<string | null> {
9866
try {
99-
const res = await fetch(`${ISSUER}/api/payments/${encodeURIComponent(id)}`);
100-
if (!res.ok) return null;
101-
const data = (await res.json()) as { payment?: { status?: string } };
102-
return data.payment?.status || null;
67+
const coinpay = createCoinPayClient({ apiKey: API_KEY, baseUrl: ISSUER });
68+
const { status } = await coinpay.getCheckout(id);
69+
return status || null;
10370
} catch {
10471
return null;
10572
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"dependencies": {
1313
"@libsql/client": "^0.15.7",
14+
"@profullstack/stack": "^0.1.0",
1415
"next": "^15.1.6",
1516
"react": "^19.0.0",
1617
"react-dom": "^19.0.0"

0 commit comments

Comments
 (0)