diff --git a/app/a/[id]/route.ts b/app/a/[id]/route.ts index 697ae313..6fe742b3 100644 --- a/app/a/[id]/route.ts +++ b/app/a/[id]/route.ts @@ -12,7 +12,8 @@ import { NextRequest, NextResponse } from "next/server"; import { resolveClick } from "@/lib/ads/serve"; import { serviceClient } from "@/lib/supabase/service"; -import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo"; +import { lookupGeo } from "@/lib/tracker/geo"; +import { adClickIp } from "@/lib/ads/client-ip"; import { parseDevice } from "@/lib/tracker/device"; import { isShortCode } from "@/lib/ads/shortcode"; import { env } from "@/lib/env"; @@ -80,7 +81,7 @@ export async function GET(request: NextRequest, ctx: { params: Promise<{ id: str const imp = await findImpression(sb, id, byCode); if (!imp) return NextResponse.redirect(fallback, { status: 302 }); - const ip = clientIpFromHeaders(request.headers); + const ip = adClickIp(request.headers); const geo = await lookupGeo(ip).catch(() => null); // Deliberately the STRICT classification here, unlike /api/ads/motd: a // terminal ad is served to curl, but it's clicked from a browser when the diff --git a/app/api/ads/click/route.ts b/app/api/ads/click/route.ts index 440c2c21..2b28496b 100644 --- a/app/api/ads/click/route.ts +++ b/app/api/ads/click/route.ts @@ -4,7 +4,8 @@ import { NextRequest, NextResponse } from "next/server"; import { resolveClick } from "@/lib/ads/serve"; -import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo"; +import { lookupGeo } from "@/lib/tracker/geo"; +import { adClickIp } from "@/lib/ads/client-ip"; import { parseDevice } from "@/lib/tracker/device"; import { env } from "@/lib/env"; @@ -21,7 +22,7 @@ export async function GET(request: NextRequest) { const creativeId = url.searchParams.get("cr"); const visitorId = url.searchParams.get("v"); - const ip = clientIpFromHeaders(request.headers); + const ip = adClickIp(request.headers); const geo = await lookupGeo(ip).catch(() => null); const device = parseDevice(request.headers.get("user-agent")).deviceType; diff --git a/docs/ad-click-throttling.md b/docs/ad-click-throttling.md new file mode 100644 index 00000000..8de3211d --- /dev/null +++ b/docs/ad-click-throttling.md @@ -0,0 +1,35 @@ +# Ad click throttling + +A visitor or IP may produce one accepted ad click per five seconds across the +network. The cooldown covers every campaign and publisher, including promotional +and paper-auction clicks. A repeated click still redirects to the advertiser, but +is recorded as invalid: no advertiser debit, publisher accrual or paper spend. + +An atomic Redis script claims both the salted IP and visitor buckets together. +The keys expire after five seconds and rejected attempts do not extend that +expiry. Using a shared Redis instance prevents simultaneous requests handled by +different app instances from passing the same cooldown. Changing only a cookie +or only an IP does not reset the other bucket. People sharing an IP also share +this short cooldown. + +The existing six-hour campaign deduplication also covers legitimate free-tier +delivery. Invalid bot traffic does not count as prior delivery. Missing identity +and failed validation withhold billing. During a Redis outage, redirects continue +and all cash and paper charges are withheld; an availability warning is logged +at most once a minute per process. Configure `REDIS_URL` and `IP_HASH_SALT` on +every app instance before deploying. + +On Railway, click accounting uses the edge-provided `X-Real-IP`, rather than +letting a caller-supplied Cloudflare header replace it. See the +[Railway request-header contract](https://docs.railway.com/networking/public-networking/specs-and-limits). + +Run the concurrency tests against a disposable Redis instance: + +```sh +TEST_AD_REDIS_URL=redis://127.0.0.1:6379 npm test +``` + +These tests use unique temporary keys and cover concurrent connections, expiry, +cookie/IP changes, rejection without extending the window, failed validation +and every accounting tier. `TEST_AD_REDIS_URL` is deliberately separate from +the application's `REDIS_URL`. diff --git a/lib/ads/click-cooldown.ts b/lib/ads/click-cooldown.ts new file mode 100644 index 00000000..3723b53c --- /dev/null +++ b/lib/ads/click-cooldown.ts @@ -0,0 +1,68 @@ +import Redis from "ioredis"; +import { hashIp } from "@/lib/ipHash"; + +export const CLICK_COOLDOWN_MS = 5_000; + +// One atomic operation across every app instance, campaign and publisher. +// Both identity buckets must be clear; changing a visitor cookie cannot reset +// an IP's cooldown. Rejected attempts do not keep extending the window. +export const CLAIM_CLICK_LUA = ` +for _, key in ipairs(KEYS) do + if redis.call('EXISTS', key) == 1 then return 0 end +end +for _, key in ipairs(KEYS) do + redis.call('SET', key, '1', 'PX', ARGV[1]) +end +return 1 +`; + +let redis: Redis | undefined; +let lastWarning = 0; +function unavailable(): ClickCooldown { + if (Date.now() - lastWarning >= 60_000) { + lastWarning = Date.now(); + console.warn("[ads] Click cooldown unavailable; cash and paper charges withheld."); + } + return { allowed: false, reason: "cooldown_unavailable" }; +} +function client(): Redis | null { + const url = process.env.REDIS_URL; + if (!url) return null; + if (!redis || redis.status === "end") { + redis = new Redis(url, { + lazyConnect: true, + maxRetriesPerRequest: 0, + connectTimeout: 1_000, + commandTimeout: 1_500, + retryStrategy: () => null, + }); + // Errors are handled by claimClickCooldown. Never log connection URLs. + redis.on("error", () => {}); + } + return redis; +} + +export type ClickCooldown = { allowed: boolean; reason?: "click_cooldown" | "missing_identity" | "cooldown_unavailable" }; + +export async function claimClickCooldown(input: { + visitorId?: string | null; + ip?: string | null; +}): Promise { + const visitor = input.visitorId?.trim(); + const keys = [ + ...(input.ip ? [`ad:click:ip:${hashIp(input.ip)}`] : []), + ...(visitor && /^[\w-]{1,128}$/.test(visitor) + ? [`ad:click:visitor:${hashIp(`ad-visitor:${visitor}`)}`] : []), + ]; + if (!keys.length) return { allowed: false, reason: "missing_identity" }; + try { + const connection = client(); + if (!connection) return unavailable(); + const accepted = await connection.eval(CLAIM_CLICK_LUA, keys.length, ...keys, CLICK_COOLDOWN_MS); + return accepted === 1 ? { allowed: true } : { allowed: false, reason: "click_cooldown" }; + } catch { + // Redirects still work during an outage; cash, publisher accrual and paper + // charges require a positively confirmed admission. + return unavailable(); + } +} diff --git a/lib/ads/client-ip.ts b/lib/ads/client-ip.ts new file mode 100644 index 00000000..2ffc94f5 --- /dev/null +++ b/lib/ads/client-ip.ts @@ -0,0 +1,13 @@ +import { clientIpFromHeaders } from "@/lib/tracker/geo"; + +/** Railway sets X-Real-IP at its edge. Other forwarded headers can be supplied + * by the caller and must not override that identity for click accounting. + * https://docs.railway.com/networking/public-networking/specs-and-limits + */ +export function adClickIp(headers: Headers): string | null { + if (!process.env.RAILWAY_ENVIRONMENT_ID) return clientIpFromHeaders(headers); + const trusted = new Headers(); + const ip = headers.get("x-real-ip"); + if (ip) trusted.set("x-real-ip", ip); + return clientIpFromHeaders(trusted); +} diff --git a/lib/ads/fraud.ts b/lib/ads/fraud.ts index 4b93212c..08679631 100644 --- a/lib/ads/fraud.ts +++ b/lib/ads/fraud.ts @@ -71,6 +71,14 @@ export async function assessClickValidity(input: { ipHashes?: string[] | null; device?: string | null; }): Promise { + try { + return await checkClickValidity(input); + } catch { + return { valid: false, reason: "validation_unavailable" }; + } +} + +async function checkClickValidity(input: Parameters[0]): Promise { // 1. Bots never bill. if (isBotDevice(input.device)) return { valid: false, reason: "bot" }; @@ -79,11 +87,12 @@ export async function assessClickValidity(input: { // 2. Anti-forgery: if the click claims an impression, it must exist and match // the campaign/slot it says it clicked. if (input.impressionId) { - const { data: imp } = await sb + const { data: imp, error } = await sb .from("ad_impressions") .select("campaign_id, slot_id") .eq("id", input.impressionId) .maybeSingle(); + if (error) return { valid: false, reason: "validation_unavailable" }; if (!imp) return { valid: false, reason: "no_impression" }; if (imp.campaign_id !== input.campaignId) return { valid: false, reason: "impression_mismatch" }; if (input.slotId && imp.slot_id !== input.slotId) return { valid: false, reason: "impression_mismatch" }; @@ -94,14 +103,13 @@ export async function assessClickValidity(input: { const ipHashes = (input.ipHashes ?? []) .map((h) => safeId(h)) .filter((h): h is string => h !== null); - if (!visitor && ipHashes.length === 0) return { valid: true }; // nothing to dedupe on + if (!visitor && ipHashes.length === 0) return { valid: false, reason: "missing_identity" }; const since = new Date(Date.now() - CLICK_DEDUPE_WINDOW_MS).toISOString(); const q = sb .from("ad_clicks") .select("id") .eq("campaign_id", input.campaignId) - .eq("valid", true) .gte("ts", since) .limit(1); @@ -113,7 +121,8 @@ export async function assessClickValidity(input: { ...ipHashes.map((h) => `ip_hash.eq.${h}`), ]; - const { data: dupe } = await q.or(terms.join(",")); + const { data: dupe, error } = await q.or(`and(or(valid.eq.true,tier.eq.free),or(${terms.join(",")}))`); + if (error) return { valid: false, reason: "validation_unavailable" }; if (dupe && dupe.length > 0) return { valid: false, reason: "duplicate" }; return { valid: true }; diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts index 06a30a64..60ff9846 100644 --- a/lib/ads/serve.ts +++ b/lib/ads/serve.ts @@ -29,6 +29,7 @@ import { promoForCampaign } from "./promos"; import { promoState, clickChargeCents } from "./trending"; import { paperWeight, type PaperBudgetFields } from "./autobid"; import { paperCharge } from "./bids"; +import { claimClickCooldown } from "./click-cooldown"; // Server-side ad selection + metering. Runs under the service-role client so // the public serving endpoints can read cross-tenant campaigns/creatives and @@ -626,7 +627,7 @@ export async function resolveClick(input: { // yesterday's too, or every check silently misses for the first hours after // the salt rotates. const ipHash = hashIpRotating(input.ctx?.ip ?? null); - const validity = await assessClickValidity({ + let validity = await assessClickValidity({ campaignId: campaign.id, slotId: input.slotId, impressionId: input.impressionId, @@ -634,6 +635,10 @@ export async function resolveClick(input: { ipHashes: rotatingIpHashCandidates(input.ctx?.ip ?? null, CLICK_DEDUPE_WINDOW_MS), device: input.ctx?.device, }); + if (validity.valid) { + const admission = await claimClickCooldown({ visitorId, ip: input.ctx?.ip }); + if (!admission.allowed) validity = { valid: false, reason: admission.reason }; + } // The 90-day promo, settled here rather than inside ad_charge_click. // diff --git a/tests/contract/ads-click-accounting.test.ts b/tests/contract/ads-click-accounting.test.ts new file mode 100644 index 00000000..7dd76d86 --- /dev/null +++ b/tests/contract/ads-click-accounting.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + admitted: true, promo: false, free: false, inserts: [] as Record[], + rpc: vi.fn(), paper: vi.fn(), +})); +vi.mock("@/lib/supabase/service", () => ({ serviceClient: () => ({ + rpc: state.rpc, + from(table: string) { + const q = { + select: () => q, eq: () => q, + insert(row: Record) { state.inserts.push(row); return q; }, + maybeSingle: async () => ({ data: table === "ad_campaigns" + ? { id: "campaign", destination_url: "https://example.com/offer", ref_slug: "ad-test", bid_credits: 4 } + : { id: "click" } }), + }; + return q; + }, +}) })); +vi.mock("@/lib/ads/fraud", async (original) => ({ + ...await original(), assessClickValidity: async () => ({ valid: true }), +})); +vi.mock("@/lib/ads/click-cooldown", () => ({ claimClickCooldown: async () => ({ + allowed: state.admitted, reason: state.admitted ? undefined : "click_cooldown", +}) })); +vi.mock("@/lib/ads/promos", () => ({ promoForCampaign: async () => null })); +vi.mock("@/lib/ads/trending", async (original) => ({ + ...await original(), promoState: () => ({ active: state.promo }), +})); +vi.mock("@/lib/ads/bids", () => ({ paperCharge: state.paper })); +import { resolveClick } from "@/lib/ads/serve"; + +beforeEach(() => { + state.admitted = true; state.promo = false; state.free = false; + state.inserts = []; state.rpc.mockReset(); state.paper.mockReset(); + state.rpc.mockImplementation(async () => ({ data: [{ click_id: "click", valid: !state.free, charged_cents: state.free ? 0 : 20 }] })); +}); +const click = () => resolveClick({ campaignId: "campaign", slotId: "slot", ctx: { ip: "8.8.8.8", visitorId: "visitor" } }); + +describe("click admission before accounting", () => { + it.each(["paid", "promo", "free"])("withholds %s accounting on a rejected click while preserving the destination", async (tier) => { + state.admitted = false; state.promo = tier === "promo"; state.free = tier === "free"; + expect(await click()).toBe("https://example.com/offer?ref=ad-test"); + expect(state.rpc).not.toHaveBeenCalled(); + expect(state.paper).not.toHaveBeenCalled(); + expect(state.inserts).toHaveLength(1); + expect(state.inserts[0]).toMatchObject({ valid: false, tier: "paid", charged_cents: 0, publisher_earn_cents: 0 }); + }); + it("keeps admitted paid accounting intact", async () => { + await click(); + expect(state.rpc).toHaveBeenCalledWith("ad_charge_click", expect.objectContaining({ p_campaign: "campaign", p_cpc_credits: 4 })); + expect(state.paper).not.toHaveBeenCalled(); + }); + it("keeps admitted free-tier paper accounting separate from cash", async () => { + state.free = true; + await click(); + expect(state.paper).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ clickId: "click" })); + }); + it("keeps admitted promo clicks unbilled", async () => { + state.promo = true; + await click(); + expect(state.rpc).not.toHaveBeenCalled(); + expect(state.inserts[0]).toMatchObject({ tier: "free", charged_cents: 0, publisher_earn_cents: 0 }); + expect(state.paper).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/contract/ads-click-cooldown-redis.test.ts b/tests/contract/ads-click-cooldown-redis.test.ts new file mode 100644 index 00000000..da392dda --- /dev/null +++ b/tests/contract/ads-click-cooldown-redis.test.ts @@ -0,0 +1,37 @@ +import Redis from "ioredis"; +import { randomUUID } from "node:crypto"; +import { afterAll, describe, expect, it } from "vitest"; +import { CLAIM_CLICK_LUA, CLICK_COOLDOWN_MS } from "@/lib/ads/click-cooldown"; + +// Opt-in, disposable Redis only. Never points at the application's REDIS_URL. +const testUrl = process.env.TEST_AD_REDIS_URL; +describe.skipIf(!testUrl)("atomic click admission against Redis", () => { + const clients = testUrl ? Array.from({ length: 8 }, () => new Redis(testUrl)) : []; + const prefix = `test:ad-click:${randomUUID()}:`; + const keys: string[] = []; + const key = (name: string) => { const k = prefix + name; keys.push(k); return k; }; + afterAll(async () => { + if (keys.length) await clients[0].del(...keys); + await Promise.all(clients.map((c) => c.quit())); + }); + it("admits exactly one of 32 concurrent cross-campaign clicks across connections", async () => { + const ip = key("ip"), visitor = key("visitor"); + const accepted = await Promise.all(Array.from({ length: 32 }, (_, i) => + clients[i % clients.length].eval(CLAIM_CLICK_LUA, 2, ip, visitor, CLICK_COOLDOWN_MS))); + expect(accepted.filter((n) => n === 1)).toHaveLength(1); + expect(await clients[0].pttl(ip)).toBeGreaterThan(4_000); + expect(await clients[0].pttl(ip)).toBeLessThanOrEqual(5_000); + expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, ip, key("rotated-visitor"), 5_000)).toBe(0); + expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, key("rotated-ip"), visitor, 5_000)).toBe(0); + expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, key("different-ip"), key("different-visitor"), 5_000)).toBe(1); + }); + it("does not claim the other identity or extend expiry on rejection", async () => { + const ip = key("busy-ip"), visitor = key("new-visitor"); + await clients[0].set(ip, "1", "PX", 100); + expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, ip, visitor, 5_000)).toBe(0); + expect(await clients[0].exists(visitor)).toBe(0); + expect(await clients[0].pttl(ip)).toBeLessThanOrEqual(100); + await new Promise((r) => setTimeout(r, 120)); + expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, ip, visitor, 5_000)).toBe(1); + }); +}); diff --git a/tests/contract/ads-click-cooldown.test.ts b/tests/contract/ads-click-cooldown.test.ts new file mode 100644 index 00000000..fdb775ab --- /dev/null +++ b/tests/contract/ads-click-cooldown.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mock = vi.hoisted(() => ({ eval: vi.fn() })); +vi.mock("ioredis", () => ({ default: class { + status = "ready"; + eval = mock.eval; + on() {} +} })); +import { claimClickCooldown, CLICK_COOLDOWN_MS } from "@/lib/ads/click-cooldown"; +import { adClickIp } from "@/lib/ads/client-ip"; + +beforeEach(() => { + vi.stubEnv("REDIS_URL", "redis://localhost:6379"); + mock.eval.mockReset().mockResolvedValue(1); +}); +afterEach(() => vi.unstubAllEnvs()); + +describe("network-wide click cooldown", () => { + it("claims both identities together for five seconds without storing raw identifiers", async () => { + expect(await claimClickCooldown({ ip: "8.8.8.8", visitorId: "visitor-123" })).toEqual({ allowed: true }); + const [, count, ipKey, visitorKey, ttl] = mock.eval.mock.calls[0]; + expect(count).toBe(2); + expect(ipKey).toMatch(/^ad:click:ip:[a-f0-9]{32}$/); + expect(visitorKey).toMatch(/^ad:click:visitor:[a-f0-9]{32}$/); + expect(ttl).toBe(5_000); + expect(CLICK_COOLDOWN_MS).toBe(ttl); + expect(JSON.stringify(mock.eval.mock.calls)).not.toContain("8.8.8.8"); + expect(JSON.stringify(mock.eval.mock.calls)).not.toContain("visitor-123"); + }); + it("rejects an occupied identity instead of charging another campaign", async () => { + mock.eval.mockResolvedValue(0); + expect(await claimClickCooldown({ ip: "8.8.8.8" })).toEqual({ allowed: false, reason: "click_cooldown" }); + }); + it("withholds charges when Redis fails or is unconfigured", async () => { + mock.eval.mockRejectedValue(new Error("offline")); + expect((await claimClickCooldown({ ip: "8.8.8.8" })).reason).toBe("cooldown_unavailable"); + vi.stubEnv("REDIS_URL", ""); + expect((await claimClickCooldown({ ip: "8.8.8.8" })).reason).toBe("cooldown_unavailable"); + }); + it("rejects unidentified traffic and unsafe visitor IDs", async () => { + expect((await claimClickCooldown({})).reason).toBe("missing_identity"); + expect((await claimClickCooldown({ visitorId: "forged),filter" })).reason).toBe("missing_identity"); + expect(mock.eval).not.toHaveBeenCalled(); + }); + it("does not reset the IP bucket at UTC midnight", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-09-13T23:59:59Z")); + await claimClickCooldown({ ip: "8.8.8.8" }); + vi.setSystemTime(new Date("2026-09-14T00:00:01Z")); + await claimClickCooldown({ ip: "8.8.8.8" }); + expect(mock.eval.mock.calls[0][2]).toBe(mock.eval.mock.calls[1][2]); + } finally { vi.useRealTimers(); } + }); +}); + +describe("Railway click identity", () => { + it("cannot be overridden by caller-controlled forwarded headers", () => { + vi.stubEnv("RAILWAY_ENVIRONMENT_ID", "production"); + expect(adClickIp(new Headers({ + "x-real-ip": "8.8.8.8", "cf-connecting-ip": "1.1.1.1", "x-forwarded-for": "9.9.9.9", + }))).toBe("8.8.8.8"); + }); + it("does not fall back to a forged header when the trusted identity is missing", () => { + vi.stubEnv("RAILWAY_ENVIRONMENT_ID", "production"); + expect(adClickIp(new Headers({ "cf-connecting-ip": "1.1.1.1" }))).toBeNull(); + }); +}); diff --git a/tests/contract/ads-click-validity.test.ts b/tests/contract/ads-click-validity.test.ts new file mode 100644 index 00000000..a1ca99b0 --- /dev/null +++ b/tests/contract/ads-click-validity.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ error: false, throws: false, duplicate: false, filter: "", impression: { campaign_id: "campaign", slot_id: "slot" } })); +vi.mock("@/lib/supabase/service", () => ({ serviceClient: () => ({ + from() { + if (state.throws) throw new Error("database unavailable"); + const q = { + select: () => q, eq: () => q, gte: () => q, limit: () => q, + maybeSingle: async () => ({ data: state.impression, error: state.error ? {} : null }), + or: async (filter: string) => { + state.filter = filter; + return { data: state.duplicate ? [{ id: "previous" }] : [], error: state.error ? {} : null }; + }, + }; + return q; + }, +}) })); +import { assessClickValidity } from "@/lib/ads/fraud"; +beforeEach(() => { + state.error = false; state.throws = false; state.duplicate = false; state.filter = ""; + state.impression = { campaign_id: "campaign", slot_id: "slot" }; +}); +const input = { campaignId: "campaign", slotId: "slot", visitorId: "visitor", ipHashes: ["abc123"] }; +describe("click validation", () => { + it("deduplicates both paid and free delivery while excluding invalid traffic", async () => { + state.duplicate = true; + expect(await assessClickValidity(input)).toEqual({ valid: false, reason: "duplicate" }); + expect(state.filter).toBe("and(or(valid.eq.true,tier.eq.free),or(visitor_id.eq.visitor,ip_hash.eq.abc123))"); + }); + it("withholds billing if a database lookup fails or throws", async () => { + state.error = true; + expect((await assessClickValidity(input)).reason).toBe("validation_unavailable"); + expect((await assessClickValidity({ ...input, impressionId: "impression" })).reason).toBe("validation_unavailable"); + state.throws = true; + expect((await assessClickValidity(input)).reason).toBe("validation_unavailable"); + }); + it("rejects bots, mismatched impressions and missing identities", async () => { + expect((await assessClickValidity({ ...input, device: "bot" })).reason).toBe("bot"); + state.impression.campaign_id = "forged"; + expect((await assessClickValidity({ ...input, impressionId: "impression" })).reason).toBe("impression_mismatch"); + expect((await assessClickValidity({ campaignId: "campaign", visitorId: "v),injected" })).reason).toBe("missing_identity"); + }); + it("admits a new recognized visitor after successful validation", async () => { + expect(await assessClickValidity(input)).toEqual({ valid: true }); + }); +});