From 23fa73c895d94211c67dd63a184c0eeced22b241 Mon Sep 17 00:00:00 2001 From: OG T Date: Sun, 7 Jun 2026 14:53:45 +0800 Subject: [PATCH] fix: add external traffic monitoring and webhook alerts --- apps/web/src/app/api/mcp/[tool]/route.ts | 62 +++++++++++++++++++++++- apps/web/src/app/api/open-tasks/route.ts | 26 +++++++++- apps/web/src/app/api/traffic/route.ts | 41 ++++++++++++++++ apps/web/src/lib/traffic-alert.ts | 36 ++++++++++++++ 4 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/api/traffic/route.ts create mode 100644 apps/web/src/lib/traffic-alert.ts diff --git a/apps/web/src/app/api/mcp/[tool]/route.ts b/apps/web/src/app/api/mcp/[tool]/route.ts index 4b59191..7d91c01 100644 --- a/apps/web/src/app/api/mcp/[tool]/route.ts +++ b/apps/web/src/app/api/mcp/[tool]/route.ts @@ -12,6 +12,7 @@ import { runSubmissionInSandbox } from "@/lib/sandbox"; import { logAuditEvent } from "@/lib/audit"; import { redis } from "@/lib/redis"; import { authHold, capturePayment } from "@/lib/payment"; +import { sendTrafficAlert } from "@/lib/traffic-alert"; import crypto from "crypto"; import { z } from "zod"; @@ -29,8 +30,9 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool return NextResponse.json({ error: "Forbidden: Invalid API Key" }, { status: 403 }); } + let body: unknown = null; try { - const body = await request.json(); + body = await request.json(); switch (tool) { case "list_open_tasks": { @@ -56,6 +58,20 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool description_preview: t.description.substring(0, 100) + (t.description.length > 100 ? "..." : ""), })); + void sendTrafficAlert({ + level: "info", + action: "EXTERNAL_LIST_OPEN_TASKS_MCP", + surface: "mcp/list_open_tasks", + actorType: "USER", + actorId: "mcp-anonymous", + taskId: "open-tasks", + message: "外部 MCP 查詢任務列表", + metadata: { + count: formattedTasks.length, + source_tool: tool, + }, + }); + return NextResponse.json({ tasks: formattedTasks, total_open: formattedTasks.length, @@ -121,6 +137,21 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool return newClaim; }); + void sendTrafficAlert({ + level: "info", + action: "EXTERNAL_CLAIM_TASK_SUCCESS", + surface: "mcp/claim_task", + actorType: "AGENT", + actorId: parsed.developer_wallet, + taskId: claim.task_id, + message: `Agent 成功接案: ${parsed.task_id}`, + metadata: { + agent_id: parsed.agent_id, + reward: claim.held_amount, + currency: claim.held_currency, + }, + }); + // Set Redis TTL key (3600 seconds) await redis.set(`vw:task:${claim.task_id}:executing`, claim.claim_token, "EX", 3600); @@ -179,6 +210,20 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool return newSubmission; }); + void sendTrafficAlert({ + level: "info", + action: "EXTERNAL_SUBMIT_SOLUTION_SUCCESS", + surface: "mcp/submit_solution", + actorType: "AGENT", + actorId: parsed.developer_wallet, + taskId: submission.task_id, + message: `Agent 提交解法: ${parsed.task_id}`, + metadata: { + claim_id: submission.claim_id, + deliverable_count: Object.keys(parsed.deliverables ?? {}).length, + }, + }); + // Async trigger E2B Sandbox evaluation const taskObj = await prisma.task.findUnique({ where: { id: submission.task_id }}); if (taskObj && typeof taskObj.acceptance_criteria === "object" && taskObj.acceptance_criteria !== null) { @@ -288,6 +333,21 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool } } catch (error: any) { console.error(`[API Gateway] Error handling ${tool}:`, error); + + void sendTrafficAlert({ + level: "error", + action: `EXTERNAL_${tool.toUpperCase()}_ERROR`, + surface: `mcp/${tool}`, + actorType: "AGENT", + actorId: typeof body === "object" && body !== null && "developer_wallet" in body + ? String((body as { developer_wallet?: string }).developer_wallet ?? "unknown") + : "unknown", + taskId: + typeof body === "object" && body !== null && "task_id" in body + ? String((body as { task_id?: string }).task_id ?? "unknown") + : "unknown", + message: `${error instanceof Error ? error.message : String(error)}`, + }); if (error.name === "ZodError") { return NextResponse.json({ error_type: "InvalidParams", message: error.errors }, { status: 400 }); diff --git a/apps/web/src/app/api/open-tasks/route.ts b/apps/web/src/app/api/open-tasks/route.ts index 13fc717..a41cb97 100644 --- a/apps/web/src/app/api/open-tasks/route.ts +++ b/apps/web/src/app/api/open-tasks/route.ts @@ -18,7 +18,7 @@ const getPayoutMode = (task: { return "PAYMENT_PENDING"; }; -export async function GET() { +export async function GET(request: Request) { const tasks = await prisma.task.findMany({ where: { status: TaskStatus.OPEN }, orderBy: { created_at: "desc" }, @@ -56,6 +56,30 @@ export async function GET() { task_url: `https://agent.wooo.work/tasks/${task.id}`, })); + const sourceIp = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + "unknown"; + + void prisma.auditEvent.create({ + data: { + actorType: "USER", + actorId: `open-tasks:${sourceIp}`, + action: "EXTERNAL_LIST_OPEN_TASKS", + entityType: "TASK", + entityId: "open-tasks", + beforeState: null, + afterState: { + total_open: publicPayload.length, + }, + reason: "external-discovery", + metadata: { + source: "public-open-tasks", + user_agent: request.headers.get("user-agent") ?? "unknown", + }, + }, + }).catch(() => {}); + return NextResponse.json({ platform: "VibeWork", version: "v1", diff --git a/apps/web/src/app/api/traffic/route.ts b/apps/web/src/app/api/traffic/route.ts new file mode 100644 index 0000000..5debfa8 --- /dev/null +++ b/apps/web/src/app/api/traffic/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +const MONITOR_TOKEN = process.env.TRAFFIC_MONITOR_TOKEN; + +export async function GET(request: NextRequest) { + if (MONITOR_TOKEN) { + const token = request.headers.get("x-traffic-token"); + if (token !== MONITOR_TOKEN) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + const since = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const [summaryRows, totalRows] = await Promise.all([ + prisma.auditEvent.groupBy({ + by: ["action"], + where: { + createdAt: { gte: since }, + }, + _count: { _all: true }, + }), + prisma.auditEvent.count({ + where: { createdAt: { gte: since } }, + }), + ]); + + const actionSummary = Object.fromEntries( + summaryRows.map((row) => [row.action, row._count._all]) + ); + + return NextResponse.json({ + period_hours: 24, + total_events: totalRows, + action_summary: actionSummary, + updated_at: new Date().toISOString(), + }); +} diff --git a/apps/web/src/lib/traffic-alert.ts b/apps/web/src/lib/traffic-alert.ts new file mode 100644 index 0000000..2f6a40f --- /dev/null +++ b/apps/web/src/lib/traffic-alert.ts @@ -0,0 +1,36 @@ +export type TrafficAlertEvent = { + level: "info" | "warning" | "error"; + action: string; + surface: string; + actorType: "SYSTEM" | "AGENT" | "USER"; + actorId: string; + taskId?: string; + message: string; + metadata?: Record; +}; + +const TRAFFIC_WEBHOOK_URL = process.env.VIBEWORK_TRAFFIC_WEBHOOK_URL?.trim(); + +export async function sendTrafficAlert(event: TrafficAlertEvent): Promise { + if (!TRAFFIC_WEBHOOK_URL) return; + + const payload = { + platform: "agent-bounty-protocol", + created_at: new Date().toISOString(), + ...event, + }; + + try { + await fetch(TRAFFIC_WEBHOOK_URL, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-source": "agent-bounty-protocol", + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(3000), + }); + } catch { + // Avoid affecting request flow if webhook fails. + } +}