← back to Govarbitrage
src/app/api/agents/search/route.ts
69 lines
import { NextRequest, NextResponse } from "next/server";
import { searchCommercialAgents, type AgentKind } from "@/lib/places";
import { rateLimit, clientIp, sessionOrIpKey } from "@/lib/rate-limit";
import { getCurrentUser } from "@/lib/current-user";
export const dynamic = "force-dynamic";
// Rate-limit bucket key. The upstream (Google Places) is a paid API, so one
// session must not be able to run up the bill. Key on the VERIFIED session
// subject (unforgeable) — NOT on X-Forwarded-For, which the client controls and
// can rotate to reset the bucket. This route is login-gated by middleware, so a
// user is normally present; fall back to the (spoofable) IP only as a last resort.
async function clientKey(req: NextRequest): Promise<string> {
const user = await getCurrentUser();
return sessionOrIpKey("agents", user?.sub, clientIp(req));
}
// Lightweight input validation so user-supplied location can't pad the Places
// textQuery with junk (cost/relevance noise) or a malformed ZIP.
const ZIP_RE = /^\d{5}(?:-\d{4})?$/;
const LOC_RE = /^[a-zA-Z0-9\s,.'-]{1,80}$/;
export async function GET(req: NextRequest) {
const rl = rateLimit(await clientKey(req), 20, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many searches — wait a moment." },
{ status: 429, headers: { "Retry-After": String(rl.retryAfterSec) } },
);
}
const sp = req.nextUrl.searchParams;
const city = sp.get("city")?.trim() || undefined;
const state = sp.get("state")?.trim() || undefined;
const zip = sp.get("zip")?.trim() || undefined;
const typeParam = (sp.get("type") ?? "both").toLowerCase();
const kinds: AgentKind[] =
typeParam === "buyer" ? ["buyer"] : typeParam === "leasing" ? ["leasing"] : ["buyer", "leasing"];
if (!city && !state && !zip) {
return NextResponse.json({ error: "Provide a city, state, or ZIP." }, { status: 400 });
}
if (zip && !ZIP_RE.test(zip)) {
return NextResponse.json({ error: "Invalid ZIP — use 5 digits (or ZIP+4)." }, { status: 400 });
}
if (city && !LOC_RE.test(city)) {
return NextResponse.json({ error: "Invalid city." }, { status: 400 });
}
if (state && !LOC_RE.test(state)) {
return NextResponse.json({ error: "Invalid state." }, { status: 400 });
}
try {
const data = await searchCommercialAgents({ city, state, zip, kinds });
return NextResponse.json(data);
} catch (e) {
// Don't leak the raw upstream (Google) error to the client — it can reveal
// key/quota details. Log server-side; return a generic message. Preserve the
// 503 "not configured" signal so ops can tell a missing key from a bad call.
const raw = e instanceof Error ? e.message : "Search failed";
console.error("[agents/search] upstream error:", raw);
const notConfigured = /not configured/.test(raw);
return NextResponse.json(
{ error: notConfigured ? "Agent search is temporarily unavailable." : "Search failed — try again." },
{ status: notConfigured ? 503 : 502 },
);
}
}