[object Object]

← back to Govarbitrage

auto-save: 2026-07-26T17:14:46 (4 files) — src/app/api/agents/search/route.ts src/lib/places.ts src/lib/rate-limit.ts src/lib/places.test.ts

3b38d9148be5c5f2d93d40f8af8a9a720dd1f2cb · 2026-07-26 17:14:52 -0700 · Steve Abrams

Files touched

Diff

commit 3b38d9148be5c5f2d93d40f8af8a9a720dd1f2cb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 26 17:14:52 2026 -0700

    auto-save: 2026-07-26T17:14:46 (4 files) — src/app/api/agents/search/route.ts src/lib/places.ts src/lib/rate-limit.ts src/lib/places.test.ts
---
 src/app/api/agents/search/route.ts |   5 +-
 src/lib/places.test.ts             | 130 +++++++++++++++++++++++++++++++++++++
 src/lib/places.ts                  |   4 +-
 src/lib/rate-limit.ts              |  11 ++++
 4 files changed, 145 insertions(+), 5 deletions(-)

diff --git a/src/app/api/agents/search/route.ts b/src/app/api/agents/search/route.ts
index 59f33d5..5fd8c65 100644
--- a/src/app/api/agents/search/route.ts
+++ b/src/app/api/agents/search/route.ts
@@ -1,6 +1,6 @@
 import { NextRequest, NextResponse } from "next/server";
 import { searchCommercialAgents, type AgentKind } from "@/lib/places";
-import { rateLimit, clientIp } from "@/lib/rate-limit";
+import { rateLimit, clientIp, sessionOrIpKey } from "@/lib/rate-limit";
 import { getCurrentUser } from "@/lib/current-user";
 
 export const dynamic = "force-dynamic";
@@ -12,8 +12,7 @@ export const dynamic = "force-dynamic";
 // 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();
-  if (user?.sub) return "agents:u:" + user.sub;
-  return "agents:ip:" + clientIp(req);
+  return sessionOrIpKey("agents", user?.sub, clientIp(req));
 }
 
 // Lightweight input validation so user-supplied location can't pad the Places
diff --git a/src/lib/places.test.ts b/src/lib/places.test.ts
new file mode 100644
index 0000000..15c8ab1
--- /dev/null
+++ b/src/lib/places.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+import { isCommercial, type RawPlace } from "./places";
+
+// isCommercial() is the finder's core value prop: precision-first
+// commercial-vs-residential classification (the LA 57→28 verified drop).
+// These offline tests lock that behavior in — zero live Google Places billing,
+// per project rails. The apply order under test (see places.ts):
+//   1. off-topic Google type → DROP
+//   2. strong commercial name OR allowlisted brand → KEEP (beats residential)
+//   3. residential/irrelevant name → DROP
+//   4. residential-flavored type → DROP
+//   5/6. no commercial evidence → DROP (precision > recall)
+
+function place(name: string, types: string[] = ["real_estate_agency"]): RawPlace {
+  return { id: "x", displayName: { text: name }, primaryType: types[0], types };
+}
+
+describe("isCommercial — KEEP on strong commercial name signals", () => {
+  it.each([
+    "Downtown Commercial Real Estate Brokers",
+    "Metro CRE Advisors",
+    "Industrial Property Group",
+    "Prime Office Leasing",
+    "Retail & Industrial Brokerage",
+    "Apex Investment Sales",
+    "Tenant Representation Partners",
+    "Net Lease Advisors",
+    "Summit Capital Markets",
+    "Bay Area Multifamily Group",
+    "Harbor Income Properties",
+  ])("keeps %s", (name) => {
+    expect(isCommercial(place(name))).toBe(true);
+  });
+});
+
+describe("isCommercial — KEEP on allowlisted national brokerage brands", () => {
+  it.each([
+    "CBRE",
+    "JLL Los Angeles",
+    "Cushman & Wakefield",
+    "Colliers International",
+    "Marcus & Millichap",
+    "Lee & Associates",
+    "Newmark Group",
+    "Avison Young",
+    "Hughes Marino",
+    "NAI Capital",
+  ])("keeps brand %s", (name) => {
+    expect(isCommercial(place(name))).toBe(true);
+  });
+
+  it("is case-insensitive on brand match", () => {
+    expect(isCommercial(place("cbre commercial"))).toBe(true);
+    expect(isCommercial(place("MARCUS AND MILLICHAP"))).toBe(true);
+  });
+});
+
+describe("isCommercial — commercial evidence WINS over a residential word", () => {
+  it("keeps a name carrying both commercial and residential signals", () => {
+    // "commercial" (KEEP, step 2) is evaluated before "homes" (DROP, step 3).
+    expect(isCommercial(place("Commercial & Home Sales Group"))).toBe(true);
+  });
+  it("keeps an allowlisted brand even next to a residential word", () => {
+    expect(isCommercial(place("CBRE Residential Division"))).toBe(true);
+  });
+});
+
+describe("isCommercial — DROP on residential / irrelevant names", () => {
+  it.each([
+    "John Smith, Realtor",
+    "Dream Homes Realty",
+    "Sunset Home Sales",
+    "First-Time Buyer Specialists",
+    "The Home Team",
+    "Bluewater Property Management",
+    "City Apartments Locators",
+    "Acme Mortgage & Home Loans",
+    "Luxury Condos of Malibu",
+    "Single Family Realty Team",
+  ])("drops %s", (name) => {
+    expect(isCommercial(place(name))).toBe(false);
+  });
+});
+
+describe("isCommercial — DROP on generic / no-evidence real-estate results", () => {
+  it("drops a plain real_estate_agency with no commercial signal", () => {
+    // Precision-first: Google's real_estate_agency type can't confirm commercial.
+    expect(isCommercial(place("Smith Real Estate"))).toBe(false);
+    expect(isCommercial(place("Downtown Realty"))).toBe(false);
+  });
+  it("drops a non-real-estate result with no signal either way", () => {
+    expect(isCommercial(place("Bob's Consulting", ["consultant"]))).toBe(false);
+  });
+});
+
+describe("isCommercial — DROP on off-topic Google place types (regardless of name)", () => {
+  it.each([
+    ["The Maimon Group", "travel_agency"],
+    ["Cozy Stays", "lodging"],
+    ["Shield Insurance", "insurance_agency"],
+    ["Two Guys Moving", "moving_company"],
+    ["SecureBox Self Storage", "self_storage"],
+    ["Mario's Restaurant", "restaurant"],
+  ])("drops %s tagged %s", (name, type) => {
+    expect(isCommercial(place(name, [type]))).toBe(false);
+  });
+
+  it("off-topic type beats an otherwise-commercial name", () => {
+    // A travel_agency mis-tag on a 'commercial'-named place is still dropped.
+    expect(isCommercial(place("Commercial Getaways", ["travel_agency"]))).toBe(false);
+  });
+});
+
+describe("isCommercial — DROP on residential-flavored place types", () => {
+  it.each(["apartment_complex", "home_goods_store"])("drops type %s with no commercial name", (type) => {
+    expect(isCommercial(place("Parkview", [type]))).toBe(false);
+  });
+  it("commercial name still wins over a residential-flavored type", () => {
+    expect(isCommercial(place("Commercial Leasing at Parkview", ["apartment_complex"]))).toBe(true);
+  });
+});
+
+describe("isCommercial — edge cases", () => {
+  it("drops a place with no name", () => {
+    expect(isCommercial({ id: "x", displayName: { text: "" }, types: ["real_estate_agency"] })).toBe(false);
+  });
+  it("drops a place with no types and no commercial name", () => {
+    expect(isCommercial({ id: "x", displayName: { text: "Generic Agency" } })).toBe(false);
+  });
+});
diff --git a/src/lib/places.ts b/src/lib/places.ts
index df8f778..b1d81fb 100644
--- a/src/lib/places.ts
+++ b/src/lib/places.ts
@@ -136,7 +136,7 @@ function hasCommercialBrand(name: string): boolean {
   return COMMERCIAL_BRANDS.some((b) => n.includes(b));
 }
 
-interface RawPlace {
+export interface RawPlace {
   id?: string;
   displayName?: { text?: string };
   formattedAddress?: string;
@@ -166,7 +166,7 @@ function locationString(city?: string, state?: string, zip?: string): string {
 //   4. Residential-flavored place type → DROP.
 //   5. Generic real-estate result with no commercial evidence → DROP.
 //   6. Non-real-estate result with no signal either way → DROP (nothing kept it).
-function isCommercial(p: RawPlace): boolean {
+export function isCommercial(p: RawPlace): boolean {
   const name = p.displayName?.text ?? "";
   const types = [p.primaryType ?? "", ...(p.types ?? [])];
 
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
index 7153f30..c148844 100644
--- a/src/lib/rate-limit.ts
+++ b/src/lib/rate-limit.ts
@@ -50,6 +50,17 @@ export function clientIp(req: NextRequest): string {
   return req.headers.get("x-real-ip") || "unknown";
 }
 
+/**
+ * Build a rate-limit bucket key that prefers the UNFORGEABLE session subject
+ * over the spoofable client IP. A logged-in user is keyed on their verified
+ * `sub` so they can't reset their bucket (and run up the paid Places bill) by
+ * rotating X-Forwarded-For; only anonymous/last-resort callers fall back to IP.
+ * Pure + exported so the security invariant is unit-testable.
+ */
+export function sessionOrIpKey(prefix: string, userSub: string | null | undefined, ip: string): string {
+  return userSub ? `${prefix}:u:${userSub}` : `${prefix}:ip:${ip}`;
+}
+
 /** 429 response with a Retry-After header. */
 export function tooManyRequests(r: RateResult, extraHeaders?: Record<string, string>): NextResponse {
   return NextResponse.json(

← 8b39674 agents: harden search route — session-keyed rate-limit (unsp  ·  back to Govarbitrage  ·  agents: contact-based dedupe — collapse same office surfaced b791b34 →