Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/a/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions app/api/ads/click/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;

Expand Down
35 changes: 35 additions & 0 deletions docs/ad-click-throttling.md
Original file line number Diff line number Diff line change
@@ -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`.
68 changes: 68 additions & 0 deletions lib/ads/click-cooldown.ts
Original file line number Diff line number Diff line change
@@ -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<ClickCooldown> {
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();
}
}
13 changes: 13 additions & 0 deletions lib/ads/client-ip.ts
Original file line number Diff line number Diff line change
@@ -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);
}
17 changes: 13 additions & 4 deletions lib/ads/fraud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export async function assessClickValidity(input: {
ipHashes?: string[] | null;
device?: string | null;
}): Promise<ClickValidity> {
try {
return await checkClickValidity(input);
} catch {
return { valid: false, reason: "validation_unavailable" };
}
}

async function checkClickValidity(input: Parameters<typeof assessClickValidity>[0]): Promise<ClickValidity> {
// 1. Bots never bill.
if (isBotDevice(input.device)) return { valid: false, reason: "bot" };

Expand All @@ -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" };
Expand All @@ -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);

Expand All @@ -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 };
Expand Down
7 changes: 6 additions & 1 deletion lib/ads/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -626,14 +627,18 @@ 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,
visitorId,
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.
//
Expand Down
66 changes: 66 additions & 0 deletions tests/contract/ads-click-accounting.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[],
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<string, unknown>) { 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<object>(), 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<object>(), 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();
});
});
37 changes: 37 additions & 0 deletions tests/contract/ads-click-cooldown-redis.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading