From 557efbcf5e9ec7a96606567374312d398e59ce45 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 13:04:59 +0000 Subject: [PATCH] fix(ads): demote self-owned campaigns instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paid serving has been dark since ~06:04 UTC today. Every request on every format fell through to the CrawlProof house ad, and because house fills are unmetered, nothing recorded an impression — so /ads reads as zero delivery rather than as an outage. Cause: the self-deal guard filtered any campaign whose owner matches the slot owner out of the candidate list, then bailed to the house ad when the list came back empty. That is correct on a network with other advertisers. It is fatal on one without: today every active slot and all 34 active campaigns belong to the same profile, so the filter removed 100% of inventory on every request. The intent of the guard is sound — ad_charge_click refuses to bill or accrue on a self-click, so a self-owned campaign must never win PAID inventory ahead of an advertiser who would actually pay. Dropping it outright was the wrong lever. It is now demoted to the free tier, the same place a campaign that has run out of funds goes. Between a real advertiser's creative and the house ad — neither of which can earn on this request — the real creative is strictly the better fill, and it records an impression, so delivery is visible again. Tests cover both halves: a single-tenant network serves and meters again (these three fail on master, reproducing the blackout), and a self-owned campaign still never takes paid inventory from a third-party advertiser. Co-Authored-By: Claude Opus 5 (1M context) --- lib/ads/serve.ts | 31 +++-- tests/contract/ads-self-deal.test.ts | 189 +++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 tests/contract/ads-self-deal.test.ts diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts index c90809eb..fa512c8c 100644 --- a/lib/ads/serve.ts +++ b/lib/ads/serve.ts @@ -151,16 +151,20 @@ export async function serveAd( Array.isArray(c) ? c[0] : c; const today = new Date().toISOString().slice(0, 10); // UTC yyyy-mm-dd - // Budget pacing: keep only campaigns with room for at least one more click at - // their bid today. spend_today resets implicitly when spend_date is earlier. - // Never serve someone their own ad on their own slot. ad_charge_click refuses - // to bill or accrue on a self-click anyway; filtering here means we don't burn - // an impression and a redirect on a click that can't earn. - const candidates = (creatives as unknown as Row[]).filter((row) => { - const c = oneCampaign(row.ad_campaigns); - if (!c) return false; - return !(slot.owner_id && c.owner_id === slot.owner_id); - }); + // A self-owned campaign (same profile owns the slot and the campaign) can + // never earn: ad_charge_click refuses to bill or accrue on a self-click. It + // used to be dropped here outright, which is correct on a network with other + // advertisers and catastrophic on one without — while every slot and every + // campaign belong to the same account, that filter removed 100% of inventory + // and every request fell through to the house ad. Serving stopped entirely + // and nothing recorded an impression, because house fills aren't metered. + // + // So self-owned campaigns are demoted rather than discarded, below. Between a + // real advertiser's creative and the house ad — neither of which can earn on + // this request — the real creative is strictly the better fill. + const candidates = (creatives as unknown as Row[]).filter( + (row) => !!oneCampaign(row.ad_campaigns), + ); if (candidates.length === 0) return houseFill(format); // A campaign is PAID-eligible only if its owner can actually cover a click at @@ -187,9 +191,14 @@ export async function serveAd( const spentToday = c.spend_date === today ? c.spend_today_cents : 0; const hasBudget = spentToday + bid * CREDIT_CENTS <= c.daily_budget_cents; const hasFunds = (creditsByOwner.get(c.owner_id) ?? 0) >= bid; + // Same owner on both sides of the transaction: the click can't be billed, + // so it must never win paid inventory ahead of an advertiser who would + // actually pay. Free tier is exactly the right home for it — same place a + // campaign that has run out of funds goes. + const isSelfDeal = !!(slot.owner_id && c.owner_id === slot.owner_id); // Legacy 'exhausted' rows never compete for paid inventory on that status // alone — funds decide, and a top-up puts them straight back in the auction. - (hasBudget && hasFunds ? paid : free).push(row); + (hasBudget && hasFunds && !isSelfDeal ? paid : free).push(row); } // Paid inventory first, always. Free-tier campaigns only ever fill requests no diff --git a/tests/contract/ads-self-deal.test.ts b/tests/contract/ads-self-deal.test.ts new file mode 100644 index 00000000..11601c15 --- /dev/null +++ b/tests/contract/ads-self-deal.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Regression: a self-owned campaign (same profile owns the slot and the +// campaign) used to be filtered out of the candidate list entirely. That is +// right on a network with other advertisers, and fatal on one without — while +// every slot and campaign belonged to a single account it removed 100% of +// inventory, every request fell through to the house ad, and because house +// fills are unmetered, impressions stopped being recorded at all. +// +// The rule that must survive: a self-deal can never win PAID inventory ahead of +// an advertiser who would actually pay. The rule that was wrong: dropping it. + +// Declared inside vi.hoisted: the mock factory below is hoisted above ordinary +// const declarations, so it cannot close over them. +const H = vi.hoisted(() => { + const OWNER = "11111111-1111-1111-1111-111111111111"; + const OTHER = "22222222-2222-2222-2222-222222222222"; + return { + OWNER, + OTHER, + state: { + slotOwner: OWNER as string | null, + campaignOwners: [OWNER] as string[], + credits: 9999, + inserted: [] as Record[], + }, + }; +}); +const { OWNER, OTHER, state } = H; + +function chain(result: unknown): unknown { + const c: unknown = new Proxy( + {}, + { + get(_t, prop) { + if (prop === "then") { + return (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) => + Promise.resolve(result).then(res, rej); + } + return () => c; + }, + }, + ); + return c; +} + +function creativeFor(ownerId: string, i: number) { + return { + id: `cre-${i}`, + campaign_id: `camp-${i}`, + format: "terminal_ascii", + headline: `Advertiser ${i}`, + body: "Real advertiser copy.", + cta_text: "Go", + image_url: null, + logo_url: null, + bg_color: "#0b0d10", + fg_color: "#e7e9ee", + accent_color: "#6ee7b7", + font_family: "system-ui", + ad_campaigns: { + id: `camp-${i}`, + owner_id: ownerId, + status: "active", + ref_slug: `adv-${i}`, + destination_url: "https://advertiser.example/", + daily_budget_cents: 500, + spend_today_cents: 0, + spend_date: null, + bid_credits: 4, + }, + }; +} + +vi.mock("@/lib/supabase/service", () => ({ + serviceClient: () => ({ + from(table: string) { + if (table === "ad_slots") { + return chain({ + data: { + id: "slot-1", + status: "active", + formats: ["terminal_ascii"], + owner_id: state.slotOwner, + }, + error: null, + }); + } + if (table === "ad_creatives") { + return chain({ + data: state.campaignOwners.map((o, i) => creativeFor(o, i)), + error: null, + }); + } + if (table === "profiles") { + return chain({ + data: [...new Set(state.campaignOwners)].map((id) => ({ + id, + credits_balance: state.credits, + ad_bonus_credits: 0, + })), + error: null, + }); + } + if (table === "ad_impressions") { + return { + insert(payload: Record) { + state.inserted.push(payload); + return chain({ data: { id: "imp-1", ...payload }, error: null }); + }, + }; + } + return chain({ data: null, error: null }); + }, + }), +})); + +async function serve() { + const { serveAd } = await import("@/lib/ads/serve"); + return serveAd("slot-1", "terminal_ascii", { device: "terminal" }); +} + +/** Fill repeatedly, since ~10% of paid-eligible fills are house by design. */ +async function fills(n: number) { + const out = []; + for (let i = 0; i < n; i++) out.push(await serve()); + return out; +} + +describe("self-owned campaigns on a single-tenant network", () => { + beforeEach(() => { + vi.resetModules(); + state.slotOwner = OWNER; + state.campaignOwners = [OWNER, OWNER, OWNER]; + state.credits = 9999; + state.inserted = []; + }); + + it("still serves real creatives when the only campaigns are self-owned", async () => { + const served = await fills(40); + const real = served.filter((f) => f && f.campaignId !== "house"); + // The blackout: every one of these came back as the house ad. + expect(real.length).toBeGreaterThan(0); + expect(real[0]!.creative.headline).toMatch(/^Advertiser /); + }); + + it("records impressions again, so the dashboard is not blank", async () => { + await fills(40); + expect(state.inserted.length).toBeGreaterThan(0); + }); + + it("meters a self-deal as free, never as paid", async () => { + await fills(40); + const tiers = new Set(state.inserted.map((r) => r.tier)); + expect(tiers).toEqual(new Set(["free"])); + }); +}); + +describe("self-deal still loses to a real advertiser", () => { + beforeEach(() => { + vi.resetModules(); + state.slotOwner = OWNER; + state.credits = 9999; + state.inserted = []; + }); + + it("never gives paid inventory to the slot owner's own campaign", async () => { + // One self-owned campaign, one genuine third-party advertiser. + state.campaignOwners = [OWNER, OTHER]; + await fills(60); + const paid = state.inserted.filter((r) => r.tier === "paid"); + expect(paid.length).toBeGreaterThan(0); + // camp-0 is the self-owned one; it must never be billed against this slot. + for (const row of paid) expect(row.campaign_id).toBe("camp-1"); + }); + + it("puts a third-party advertiser on the paid tier", async () => { + state.campaignOwners = [OTHER]; + await fills(40); + expect(state.inserted.some((r) => r.tier === "paid")).toBe(true); + }); + + it("keeps a slot with no owner working", async () => { + state.slotOwner = null; + state.campaignOwners = [OWNER]; + await fills(40); + expect(state.inserted.some((r) => r.tier === "paid")).toBe(true); + }); +});