From d8e949733d48b86e812f3fc8b44f5c0029e95cd5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 21:04:03 +0000 Subject: [PATCH] Let a streaming break through selection, and only a streaming break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The break endpoint returned an empty fill for every request, because fitAdFormat refuses streaming formats outright and it is the gate in front of serveAd. That refusal is correct and deliberate: everything past that gate renders HTML and ASCII, and a video creative drawn as a banner is the leak the guard exists to prevent. The endpoint was calling straight into it and getting exactly what the guard promised. Selection is not duplicated to get around it. serveAd is where credits, paper spend, trend matching, the auction and the impression live, and a second copy of that is a second set of numbers — with the one not wired to billing being the one that quietly gives inventory away. Instead the context carries an explicit `streaming` flag, and it opens the gate and closes the renderer in the same move: a streaming request is matched against the slot's own format list with no width negotiation, and the fill's html and text come back empty. So the only path allowed past the refusal is also the one path that cannot render markup, which is what the refusal was protecting. A streaming break negotiates nothing on purpose. There is no width to fit and no smaller unit to fall back to, so the slot must offer the format outright, which keeps the publisher in control of what their break can carry. fitAdFormat's own refusal is now pinned by a test, including for a slot that lists the format, since that is the configuration someone will eventually try. Co-Authored-By: Claude Opus 5 (1M context) --- app/api/ads/stream/route.ts | 4 ++++ lib/ads/serve.ts | 30 ++++++++++++++++++++++++++---- tests/ads-video-format.test.ts | 10 ++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/api/ads/stream/route.ts b/app/api/ads/stream/route.ts index c9c4ace5..a8d5ea71 100644 --- a/app/api/ads/stream/route.ts +++ b/app/api/ads/stream/route.ts @@ -62,6 +62,10 @@ export async function GET(request: NextRequest) { const ip = clientIpFromHeaders(request.headers); const geo = await lookupGeo(ip).catch(() => null); const fill = await serveAd(slotId, VIDEO_FORMAT_ID, { + // The one path allowed past fitAdFormat's refusal of streaming formats. + // It also suppresses markup rendering, so this cannot become a way to + // draw a video creative as a banner. + streaming: true, ip, country: geo?.countryCode ?? null, device: parseDevice(request.headers.get("user-agent")).deviceType, diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts index 898301fb..421bfa4a 100644 --- a/lib/ads/serve.ts +++ b/lib/ads/serve.ts @@ -10,7 +10,7 @@ import { type AdCreative, type AdFormatId, } from "./creative"; -import { fitAdFormat, FEED_FORMAT_ID, TERMINAL_FORMAT_ID } from "./formats"; +import { isStreamingFormat, fitAdFormat, FEED_FORMAT_ID, TERMINAL_FORMAT_ID } from "./formats"; import { isAdTheme, type AdTheme, type AdThemePref } from "./theme"; import { houseFill, HOUSE_AD_ROTATION_RATE } from "./house"; import { CREDIT_CENTS, DEFAULT_BID_CREDITS, PLATFORM_RATE } from "./pricing"; @@ -159,6 +159,17 @@ export function resolveThemePref( } export type ServeContext = { + /** + * The caller is filling a streaming break, not a page. + * + * This is the ONLY way a streaming format reaches selection, and it comes + * with an obligation: nothing on this path may render the creative as + * markup. fitAdFormat refuses streaming formats precisely because serveAd + * produces HTML and ASCII, and a video creative drawn as a banner is the + * leak that guard exists to prevent. So the flag opens the gate and closes + * the renderer in the same move — see where `html` and `text` are built. + */ + streaming?: boolean; visitorId?: string | null; ip?: string | null; country?: string | null; @@ -207,7 +218,14 @@ export async function serveAd( // while it fits. Constrained to the slot's own list, so this can never turn a // servable request into an empty fill. Callers read the format actually // served back off `fill.creative.format`. - const format = fitAdFormat(requestedFormat, ctx.width, slot.formats); + // A streaming break negotiates nothing: there is no width to fit and no + // smaller unit to fall back to. The slot must offer the format outright, + // which keeps the publisher in control of what their break can carry. + const format = ctx.streaming && isStreamingFormat(requestedFormat) + ? (Array.isArray(slot.formats) && slot.formats.includes(requestedFormat) + ? requestedFormat + : null) + : fitAdFormat(requestedFormat, ctx.width, slot.formats); if (!format) return null; // `theme` rides behind `add column if not exists`, and migrations here are @@ -478,8 +496,12 @@ export async function serveAd( refSlug: campaign.ref_slug, creative, clickUrl, - html: renderCreativeHtml(creative, clickUrl, { theme }), - text: renderCreativeText(creative, clickUrl), + // Empty for a streaming fill, and deliberately so. The caller wants a media + // URL, and rendering this creative as a banner is exactly the thing + // fitAdFormat's refusal was protecting against — the guard has to hold on + // the one path that is allowed past it. + html: ctx.streaming ? "" : renderCreativeHtml(creative, clickUrl, { theme }), + text: ctx.streaming ? "" : renderCreativeText(creative, clickUrl), tier, trendTopics: matchFor(campaign.id).topics, promo: trend.any && promoActiveFor(trend, campaign.id), diff --git a/tests/ads-video-format.test.ts b/tests/ads-video-format.test.ts index 487c47b3..9b25943b 100644 --- a/tests/ads-video-format.test.ts +++ b/tests/ads-video-format.test.ts @@ -122,3 +122,13 @@ describe("the design-object write path refuses video", () => { expect(DESIGN_FORMAT_IDS.length).toBe(AD_FORMAT_IDS.length - STREAMING_FORMAT_IDS.length); }); }); + +describe("the streaming gate has exactly one door", () => { + it("still refuses a streaming format through the display path", () => { + // fitAdFormat is the gate in front of the HTML renderer. It must keep + // refusing video no matter how a slot is configured, because everything + // past it draws markup. + expect(fitAdFormat(VIDEO_FORMAT_ID, 1920, [VIDEO_FORMAT_ID])).toBeNull(); + expect(fitAdFormat(VIDEO_FORMAT_ID, null, [VIDEO_FORMAT_ID])).toBeNull(); + }); +});