全局配置更新:OAuth 回调修复、支付安全加固、站点 URL 工具

- 新增 NEXT_PUBLIC_SITE_URL 支持及 site-url.ts 工具模块
- 修复 OAuth 回调域名:import.meta.env 统一读取站点 URL
- 支付 API 路由新增收款地址校验
- 后端支付服务更新
- middleware 清理
- 新增 paymentSecurity 测试
This commit is contained in:
2569718930@qq.com
2026-05-24 18:33:47 +08:00
parent 2be0b71018
commit 20c8395c0b
22 changed files with 259 additions and 88 deletions
+19 -8
View File
@@ -44,6 +44,10 @@ import {
getCurrentPaymentHost,
isPaymentHostAllowed,
} from "@/lib/payment-host";
import {
assertExpectedPaymentReceiver,
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
} from "@/lib/payment-receiver";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import { useI18n } from "@/hooks/useI18n";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
@@ -1062,11 +1066,7 @@ export function AccountCenter() {
(resolvedSelectedTokenAddress.startsWith("0x")
? shortAddress(resolvedSelectedTokenAddress)
: "USDC");
const paymentReceiverAddress = String(
selectedPaymentToken?.receiver_contract ||
paymentConfig?.receiver_contract ||
"",
).toLowerCase();
const paymentReceiverAddress = EXPECTED_PAYMENT_RECEIVER_ADDRESS;
const paymentWalletLabel = String(
selectedWallet ||
walletAddress ||
@@ -1348,6 +1348,15 @@ export function AccountCenter() {
options: ConnectBindOptions = {},
): Promise<boolean> => {
clearPaymentMessages();
if (!paymentHostAllowed) {
setPaymentError(
copy.paymentHostBlocked.replace(
"{host}",
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
),
);
return false;
}
if (!isAuthenticated) {
setPaymentError(copy.loginBeforeBind);
return false;
@@ -1621,9 +1630,7 @@ export function AccountCenter() {
const expectedReceiver = String(
latestConfig.receiver_contract || "",
).toLowerCase();
if (!expectedReceiver.startsWith("0x")) {
throw new Error("payment receiver contract is not configured");
}
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
if (
paymentConfig?.receiver_contract &&
String(paymentConfig.receiver_contract).toLowerCase() !==
@@ -1920,6 +1927,10 @@ export function AccountCenter() {
if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) {
throw new Error("manual payment payload invalid");
}
assertExpectedPaymentReceiver(
direct.receiver_address,
"manual payment receiver",
);
setLastIntentId(intentId);
setManualPayment(direct);
setPaymentMethodTab("manual");
@@ -0,0 +1,76 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
const EXPECTED_RECEIVER = "0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a";
export function runTests() {
const projectRoot = process.cwd();
const receiverModulePath = path.join(projectRoot, "lib", "payment-receiver.ts");
const backendAuthPath = path.join(projectRoot, "lib", "backend-auth.ts");
const middlewarePath = path.join(projectRoot, "middleware.ts");
const accountCenterPath = path.join(
projectRoot,
"components",
"account",
"AccountCenter.tsx",
);
const paymentRoutes = [
"app/api/payments/wallets/challenge/route.ts",
"app/api/payments/wallets/verify/route.ts",
"app/api/payments/wallets/route.ts",
"app/api/payments/intents/route.ts",
"app/api/payments/intents/[intentId]/submit/route.ts",
"app/api/payments/intents/[intentId]/confirm/route.ts",
"app/api/payments/intents/[intentId]/validate/route.ts",
"app/api/payments/reconcile-latest/route.ts",
];
assert(
fs.existsSync(receiverModulePath),
"payment receiver guard module must exist",
);
const receiverSource = fs.readFileSync(receiverModulePath, "utf8");
assert(
receiverSource.includes(EXPECTED_RECEIVER),
"payment receiver guard must pin the production receiver address",
);
assert(
receiverSource.includes("assertExpectedPaymentReceiver"),
"payment receiver guard must expose an assertion helper",
);
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
assert(
accountCenterSource.includes("assertExpectedPaymentReceiver"),
"AccountCenter must validate backend-returned manual payment receiver before displaying it",
);
assert(
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS"),
"AccountCenter must show the pinned payment receiver address in its payment guard",
);
const backendAuthSource = fs.readFileSync(backendAuthPath, "utf8");
assert(
backendAuthSource.includes("requireBackendAuthUser"),
"backend auth helper must expose a real-user requirement for payment mutations",
);
const middlewareSource = fs.readFileSync(middlewarePath, "utf8");
assert(
!middlewareSource.includes("/^bearer\\s+\\S+/i.test(authHeader)") &&
!middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"),
"middleware must not treat the mere presence of a bearer token as authenticated",
);
for (const route of paymentRoutes) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
assert(
routeSource.includes("requireBackendAuthUser"),
`${route} must reject payment mutations without a real Supabase user`,
);
}
}
+8 -3
View File
@@ -17,6 +17,7 @@ import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import { getConfiguredSiteUrl, PRODUCTION_SITE_URL } from "@/lib/site-url";
import { useI18n } from "@/hooks/useI18n";
type Mode = "login" | "signup";
@@ -38,8 +39,8 @@ export function LoginClient({ nextPath }: LoginClientProps) {
const supabaseReady = hasSupabasePublicEnv();
const siteOrigin =
process.env.NEXT_PUBLIC_SITE_URL?.trim() ||
(typeof window !== "undefined" ? window.location.origin : "");
getConfiguredSiteUrl() ||
(typeof window !== "undefined" ? window.location.origin : PRODUCTION_SITE_URL);
const isEn = locale === "en-US";
const copy = {
backHome: isEn ? "Back to Home" : "返回首页",
@@ -102,7 +103,11 @@ export function LoginClient({ nextPath }: LoginClientProps) {
setLoading(true);
try {
const supabase = getSupabaseBrowserClient();
const { error } = await supabase.auth.resetPasswordForEmail(email.trim());
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent(
"/account",
)}`,
});
if (error) {
setErrorText(error.message);
return;