Skip to content

Commit 9f01906

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 97366ae commit 9f01906

10 files changed

Lines changed: 53 additions & 268 deletions

File tree

‎app/api/admin/email-broadcast/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
22
import { createClient } from "@/lib/supabase/server";
33
import { serviceClient } from "@/lib/supabase/service";
44
import { env } from "@/lib/env";
5-
import { createEmailer } from "@profullstack/emailer";
5+
import { createEmailer } from "@profullstack/stack/email";
66

77
export const runtime = "nodejs";
88

‎app/api/referrals/route.ts‎

Lines changed: 2 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { NextResponse } from "next/server";
2-
import { createCode, validateCode, applyReferral } from "@profullstack/referrals";
1+
import { createReferralsRouteHandler } from "@profullstack/stack/referrals";
32
import { referralStore } from "@/lib/referrals";
43
import { createClient } from "@/lib/supabase/server";
54

@@ -11,47 +10,4 @@ async function getUserId(): Promise<string | null> {
1110
return user?.id ?? null;
1211
}
1312

14-
export async function GET(req: Request) {
15-
const { searchParams } = new URL(req.url);
16-
const action = searchParams.get("action");
17-
18-
if (action === "validate") {
19-
const code = searchParams.get("ref");
20-
if (!code) return NextResponse.json({ error: "Missing ref" }, { status: 400 });
21-
const record = await validateCode(code, referralStore);
22-
return NextResponse.json({ valid: !!record, code: record ?? null });
23-
}
24-
25-
if (action === "myusages") {
26-
const userId = await getUserId();
27-
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
28-
const usages = await referralStore.getUsagesByAffiliate(userId);
29-
return NextResponse.json({ usages });
30-
}
31-
32-
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
33-
}
34-
35-
export async function POST(req: Request) {
36-
const body = await req.json() as Record<string, unknown>;
37-
const action = body["action"];
38-
39-
if (action === "create") {
40-
const userId = await getUserId();
41-
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
42-
const code = await createCode(userId, referralStore);
43-
return NextResponse.json({ code });
44-
}
45-
46-
if (action === "apply") {
47-
const userId = await getUserId();
48-
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
49-
const code = typeof body["code"] === "string" ? body["code"] : null;
50-
const amount = typeof body["amount"] === "number" ? body["amount"] : null;
51-
if (!code || !amount) return NextResponse.json({ error: "Missing code or amount" }, { status: 400 });
52-
const usage = await applyReferral({ code, newUserId: userId, amountCents: amount, store: referralStore });
53-
return NextResponse.json({ usage });
54-
}
55-
56-
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
57-
}
13+
export const { GET, POST } = createReferralsRouteHandler({ store: referralStore, getUserId });

‎app/layout.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata, Viewport } from "next";
22
import Script from "next/script";
33
import { ReferralProvider } from "@profullstack/referrals/react";
4+
import { FeedbackWidget } from "@profullstack/stack/feedback";
45
import "./globals.css";
56

67
export const viewport: Viewport = {
@@ -150,7 +151,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
150151
strategy="afterInteractive"
151152
/>
152153
</ReferralProvider>
153-
<script async src="https://feedback.profullstack.com/embed/profullstack-feedback.js" data-property="crawlproof.com"></script>
154+
<FeedbackWidget property="crawlproof.com" />
154155
</body>
155156
</html>
156157
);

‎lib/coinpay.ts‎

Lines changed: 11 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import crypto from "node:crypto";
1+
import { verifyCoinPayWebhook } from "@profullstack/stack/coinpay";
22
import { env } from "./env";
33

44
// CoinPay HTTP client.
@@ -316,116 +316,20 @@ export async function createCryptoPayout(
316316
};
317317
}
318318

319-
// Parse the Stripe-style `t=...,v1=...` header into its parts.
320-
function parseSignatureHeader(header: string): { t: string; v1: string[] } | null {
321-
const t: string[] = [];
322-
const v1: string[] = [];
323-
for (const part of header.split(",")) {
324-
const [k, v] = part.split("=", 2);
325-
if (!k || v === undefined) continue;
326-
if (k.trim() === "t") t.push(v.trim());
327-
else if (k.trim() === "v1") v1.push(v.trim());
328-
}
329-
if (t.length !== 1 || v1.length === 0) return null;
330-
return { t: t[0], v1 };
331-
}
332-
333-
// Reject signatures older than this window to limit replay attacks.
334-
const TOLERANCE_SECONDS = 5 * 60;
335-
336-
function hmac(secret: string | Buffer, message: string): string {
337-
return crypto.createHmac("sha256", secret).update(message).digest("hex");
338-
}
339-
340-
// CoinPay's webhook secret is "whsecret_<64 hex chars>" — the hex is a 32-byte
341-
// key. We don't know which form they HMAC with, so we try them all.
342-
function secretCandidates(raw: string): Array<[string, string | Buffer]> {
343-
const candidates: Array<[string, string | Buffer]> = [];
344-
candidates.push(["raw_utf8", raw]);
345-
const m = raw.match(/^whsecret_([0-9a-f]+)$/i);
346-
if (m) {
347-
candidates.push(["hex_string", m[1]]);
348-
if (m[1].length % 2 === 0) {
349-
try {
350-
candidates.push(["hex_decoded", Buffer.from(m[1], "hex")]);
351-
} catch {
352-
/* skip */
353-
}
354-
}
355-
}
356-
return candidates;
357-
}
358-
359-
function eqTimingSafe(a: string, b: string): boolean {
360-
if (a.length !== b.length) return false;
361-
return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
362-
}
363-
364-
// Build several plausible signing forms and try them all. Stripe uses
365-
// `${t}.${body}`; if CoinPay diverges we'll cover the common variants
366-
// (omit timestamp, omit separator, reverse). Whichever wins, the next
367-
// log line tells us which one to keep.
368-
function candidateMessages(t: string, body: string): Array<[string, string]> {
369-
return [
370-
["t.body", `${t}.${body}`],
371-
["t+body", `${t}${body}`],
372-
["body.t", `${body}.${t}`],
373-
["body", body],
374-
];
375-
}
376-
319+
// Verification itself lives in @profullstack/stack/coinpay: HMAC-SHA256 over
320+
// `${t}.${rawBody}` with a 5-minute replay tolerance, constant-time compare,
321+
// and support for multiple `v1=` parts during secret rotation.
377322
export function verifyWebhookSignature(
378323
rawBody: string,
379324
signatureHeader: string | null,
380325
options: { now?: number; tolerance?: number } = {},
381326
): boolean {
382-
if (!env.coinpayWebhookSecret || !signatureHeader) return false;
383-
const parsed = parseSignatureHeader(signatureHeader);
384-
if (!parsed) {
385-
console.warn("[coinpay] verify: unparseable signature header", { header: signatureHeader.slice(0, 60) });
386-
return false;
387-
}
388-
389-
const ts = Number.parseInt(parsed.t, 10);
390-
if (!Number.isFinite(ts)) return false;
391-
const now = options.now ?? Math.floor(Date.now() / 1000);
392-
const tolerance = options.tolerance ?? TOLERANCE_SECONDS;
393-
if (Math.abs(now - ts) > tolerance) {
394-
console.warn("[coinpay] verify: timestamp out of tolerance", { ts, now, drift: now - ts });
395-
return false;
396-
}
397-
398-
const variants = candidateMessages(parsed.t, rawBody);
399-
const secrets = secretCandidates(env.coinpayWebhookSecret);
400-
401-
for (const [sLabel, secret] of secrets) {
402-
for (const [mLabel, message] of variants) {
403-
const expected = hmac(secret, message);
404-
if (parsed.v1.some((cand) => eqTimingSafe(cand, expected))) {
405-
if (sLabel !== "raw_utf8" || mLabel !== "t.body") {
406-
console.warn(
407-
`[coinpay] verify OK via secret=${sLabel}, signing=${mLabel}`,
408-
);
409-
}
410-
return true;
411-
}
412-
}
413-
}
414-
415-
// None matched — log enough to compare with the sender. No secret leak.
416-
console.warn("[coinpay] verify FAIL", {
417-
bodyLen: rawBody.length,
418-
ts: parsed.t,
419-
v1_tail: parsed.v1.map((s) => s.slice(-8)),
420-
secret_len: env.coinpayWebhookSecret.length,
421-
tails: Object.fromEntries(
422-
secrets.flatMap(([sLabel, secret]) =>
423-
variants.map(([mLabel, message]) => [
424-
`${sLabel}:${mLabel}`,
425-
hmac(secret, message).slice(-8),
426-
]),
427-
),
428-
),
327+
if (!env.coinpayWebhookSecret) return false;
328+
return verifyCoinPayWebhook({
329+
signature: signatureHeader,
330+
rawBody,
331+
secret: env.coinpayWebhookSecret,
332+
now: options.now,
333+
toleranceSeconds: options.tolerance,
429334
});
430-
return false;
431335
}

‎lib/email.ts‎

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { Resend } from "resend";
1+
import { createEmailer, type Emailer } from "@profullstack/stack/email";
22
import { env } from "./env";
33

4-
let cached: Resend | null = null;
4+
let cached: Emailer | null = null;
55
function client() {
66
if (cached) return cached;
77
if (!env.resendApiKey) return null;
8-
cached = new Resend(env.resendApiKey);
8+
cached = createEmailer({
9+
resendApiKey: env.resendApiKey,
10+
defaultFrom: env.resendFrom,
11+
});
912
return cached;
1013
}
1114

@@ -309,7 +312,7 @@ export async function sendAuditReadyEmail(input: {
309312
}) {
310313
const c = client();
311314
if (!c) return;
312-
await c.emails.send({
315+
await c.send({
313316
from: env.resendFrom,
314317
to: input.to,
315318
subject: `Your AEO audit for ${input.targetUrl} is ready (${input.score}/100)`,
@@ -337,7 +340,7 @@ export async function sendAuditReportPdfEmail(input: {
337340
return input.targetUrl;
338341
}
339342
})();
340-
const res = await c.emails.send({
343+
const res = await c.send({
341344
from: env.resendFrom,
342345
to: input.to,
343346
subject: `Your CrawlProof audit for ${host} (${input.score}/100)`,
@@ -349,7 +352,7 @@ export async function sendAuditReportPdfEmail(input: {
349352
}),
350353
attachments: [{ filename: `crawlproof-${host}.pdf`, content: input.pdf.toString("base64") }],
351354
});
352-
if (res.error) return { sent: false, error: String(res.error) };
355+
if (!res.sent) return { sent: false, error: res.error };
353356
return { sent: true };
354357
}
355358

@@ -365,7 +368,7 @@ export async function sendDigestEmail(input: {
365368
`<li><a href="${r.url}">${r.target}</a> — <strong>${r.score}/100</strong></li>`,
366369
)
367370
.join("");
368-
await c.emails.send({
371+
await c.send({
369372
from: env.resendFrom,
370373
to: input.to,
371374
subject: `Your weekly CrawlProof digest`,
@@ -392,7 +395,7 @@ export async function sendMarketingEmail(input: {
392395
<a href="${unsubUrl}" style="color:#888">Unsubscribe</a>.
393396
</p>`;
394397

395-
const res = await c.emails.send({
398+
const res = await c.send({
396399
from: env.resendFrom,
397400
to: input.to,
398401
subject: input.subject,
@@ -402,7 +405,7 @@ export async function sendMarketingEmail(input: {
402405
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
403406
},
404407
});
405-
if (res.error) return { sent: false, error: String(res.error) };
408+
if (!res.sent) return { sent: false, error: res.error };
406409
return { sent: true };
407410
}
408411

@@ -425,7 +428,7 @@ export async function sendPurchaseReceiptEmail(input: {
425428
})}`;
426429
const filename = `crawlproof-receipt-${input.paymentId.slice(0, 12)}.pdf`;
427430

428-
const res = await c.emails.send({
431+
const res = await c.send({
429432
from: env.resendFrom,
430433
to: input.to,
431434
subject: `Your CrawlProof receipt — ${input.creditsAdded} credit${input.creditsAdded === 1 ? "" : "s"}`,
@@ -439,7 +442,7 @@ export async function sendPurchaseReceiptEmail(input: {
439442
}),
440443
attachments: [{ filename, content: input.pdf.toString("base64") }],
441444
});
442-
if (res.error) return { sent: false, error: String(res.error) };
445+
if (!res.sent) return { sent: false, error: res.error };
443446
return { sent: true };
444447
}
445448

@@ -487,7 +490,7 @@ export async function sendPremiumDeckEmail(input: {
487490
if (!c) return { sent: false, error: "RESEND_API_KEY not set" };
488491

489492
const deckUrl = `${env.siteUrl}/pdfs/CrawlProof_Premium_Deck.pdf`;
490-
const res = await c.emails.send({
493+
const res = await c.send({
491494
from: env.resendFrom,
492495
to: input.to,
493496
subject: "Your CrawlProof premium deck",
@@ -499,7 +502,7 @@ export async function sendPremiumDeckEmail(input: {
499502
},
500503
],
501504
});
502-
if (res.error) return { sent: false, error: String(res.error) };
505+
if (!res.sent) return { sent: false, error: res.error };
503506
return { sent: true };
504507
}
505508

@@ -537,14 +540,14 @@ export async function sendHireInquiryEmail(input: {
537540
${input.message ? `<p style="margin-top:16px"><strong>Message</strong><br/><pre style="white-space:pre-wrap;font-family:inherit;background:#f6f8fa;padding:12px;border-radius:6px">${esc(input.message)}</pre></p>` : ""}
538541
</body></html>`;
539542

540-
const res = await c.emails.send({
543+
const res = await c.send({
541544
from: env.resendFrom,
542545
to: "hello@crawlproof.com",
543546
replyTo: input.email,
544547
subject: `Hire-us inquiry: ${input.name} (${input.website})`,
545548
html,
546549
});
547-
if (res.error) return { sent: false, error: String(res.error) };
550+
if (!res.sent) return { sent: false, error: res.error };
548551
return { sent: true };
549552
}
550553

@@ -595,13 +598,13 @@ export async function sendProjectInviteEmail(input: {
595598
footerNote: "If you weren't expecting this invitation, you can safely ignore this email.",
596599
});
597600

598-
const res = await c.emails.send({
601+
const res = await c.send({
599602
from: env.resendFrom,
600603
to: input.to,
601604
subject: `${escapeHtml(input.invitedBy)} invited you to ${escapeHtml(input.projectName)} on CrawlProof`,
602605
html,
603606
});
604-
if (res.error) return { sent: false, error: String(res.error) };
607+
if (!res.sent) return { sent: false, error: res.error };
605608
return { sent: true };
606609
}
607610

@@ -644,12 +647,12 @@ export async function sendOrgInviteEmail(input: {
644647
footerNote: "If you weren't expecting this invitation, you can safely ignore this email.",
645648
});
646649

647-
const res = await c.emails.send({
650+
const res = await c.send({
648651
from: env.resendFrom,
649652
to: input.to,
650653
subject: `${escapeHtml(input.invitedBy)} invited you to ${escapeHtml(input.orgName)} on CrawlProof`,
651654
html,
652655
});
653-
if (res.error) return { sent: false, error: String(res.error) };
656+
if (!res.sent) return { sent: false, error: res.error };
654657
return { sent: true };
655658
}

0 commit comments

Comments
 (0)