Skip to content

Sell the feed: an ad format for RSS, Atom and JSON Feed - #200

Merged
ralyodio merged 2 commits into
masterfrom
feed-ads
Aug 18, 2026
Merged

ralyodio merged 2 commits into
masterfrom
feed-ads

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Adds feed_item — an ad served as a syndication item, for splicing into a feed a publisher generates.

What

GET /api/ads/feed?slot=<id> returns one of seven shapes off a single creative:

as= what you get
rss an RSS 2.0 <item>, ready to paste inside a <channel>
atom an Atom <entry>
json JSON Feed 1.1 item objects
html / markdown / text just the body, if your generator owns the envelope
fields the raw material, for rendering the item yourself

Plus style=text|card|terminal, guid=daily|weekly|fill|static, n=1..5, label=, cols=, src=.

Three constraints that shaped it

No namespaces. A fragment is pasted inside a <channel> whose root element we did not write. A <dc:creator> whose prefix the publisher never declared does not make our item ugly — it makes their whole feed not well-formed, and every reader drops the entire document. Core RSS 2.0 and core Atom only; a test asserts no prefixed element can appear.

No CSS. Readers strip <style> and most strip style=. Hierarchy is carried by semantic elements, so the body reads correctly with every attribute removed. Links are rel="sponsored nofollow noopener" — a paid link mirrored into every aggregator is the exact shape of a link scheme.

A periodic identity. guid is the whole of a reader's memory. Per-fetch and the ad resurfaces as unread on every rebuild; frozen and the advertiser reaches each subscriber once. Default is daily, keyed by slot rather than campaign — the campaign changes on every fill and would defeat the mechanism within the hour.

Two traps avoided

  • Feed builders identify as HTTP libraries → the generic tracker calls that a bot → bots get the unmetered house ad → a feed slot could never have earned a cent. feedDeviceType fixes this the way terminalDeviceType did, checking reader patterns before crawler ones (Feedly’s fetcher advertises itself as "like FeedFetcher-Google").
  • Feed fills take the short /a/<code> click URL: as=text, as=markdown and style=terminal print it as literal text, where the 70-char form does not fit the box.

Snippets are data now

lib/ads/snippets.ts carries 36 recipes — Node, Next, Express, Hono, Eleventy, Hugo, Jekyll, Astro, WordPress, PHP, Python, Django, Ruby, Go, Cloudflare Workers, and the existing web/terminal ones. Every feed recipe fails open: a feed build that throws when an ad server is slow takes the publisher's whole deploy with it.

Metering

Unchanged, and worth stating: the impression is recorded at fetch time, so one build produces one impression for a document thousands then read. Feed impressions undercount reach by design. Clicks are exact.

Deploying

supabase/migrations/20260818120000_ad_feed_item.sql follows the terminal_ascii pattern (widen the CHECK, add to slot inventory, backfill a creative per campaign). Apply by hand via psql over the pooler — prod migration history diverged, do not supabase db push.

Verification

  • 1501 tests pass (35 new), tsc --noEmit clean, next build clean with /api/ads/feed registered.
  • fast-xml-parser added as a devDependency: the core risk is emitting XML into someone else’s document, so well-formedness is asserted with a real validator rather than a regex.

Consumed by profullstack/rssamplifier.com#feed-ads.

🤖 Generated with Claude Code

Every unit so far assumed a runtime we control the shape of -- a browser
running ad.js, or a terminal printing text. A feed is neither. The document is
built by somebody else's static site generator, spliced into a <channel> whose
root element we did not write, and then kept for weeks by software that
remembers what it has already shown. So `feed_item` is a new format rather
than a new renderer for an old one, and three constraints drove it.

**No namespaces.** The RSS fragment uses core RSS 2.0 elements only and the
Atom fragment core Atom only. A <dc:creator> whose prefix the publisher never
declared does not make our item ugly, it makes their whole feed not
well-formed -- every reader drops the entire document rather than the one
element. That rules out the advertiser name in dc: and the logo in Media RSS,
both nicer, neither worth an outage for every subscriber of every publisher
carrying the unit. A test asserts no prefixed element can appear.

**No CSS.** Readers strip <style> blocks and most strip style= too, so the
hierarchy is carried by semantic elements and the body reads correctly with
every attribute removed. Links are rel="sponsored nofollow noopener": a paid
link copied verbatim into every aggregator that mirrors the feed is the exact
shape of a link scheme.

**A periodic identity.** guid is the whole of a reader's memory. Mint one per
fetch and the ad resurfaces as unread on every rebuild, which is how a feed
gets unsubscribed from; freeze it forever and the advertiser reaches each
subscriber once. So it rotates -- daily by default, keyed by *slot* rather
than campaign, because the campaign changes on every fill and would defeat the
mechanism within the hour. weekly/fill/static are there for feeds the default
is wrong for.

Seven wire shapes off one creative (rss, atom, json, html, markdown, text,
fields) and three body styles (text, card, terminal). `fields` is the
integration contract: a consumer that already builds all three formats should
render the ad through its own renderers, so that one piece of software decides
how a title gets escaped inside one document.

Two things this would have got wrong without care. Feed builders identify as
HTTP libraries, which the generic tracker calls a bot, and bots get the
unmetered house ad -- so a feed slot could never have earned a cent.
feedDeviceType fixes that the way terminalDeviceType did, and checks the
reader patterns *before* the crawler ones because Feedly's fetcher advertises
itself as "like FeedFetcher-Google". And feed fills take the short /a/<code>
click URL: as=text, as=markdown and style=terminal all print it as literal
text, where the 70-character form does not fit the box.

Also: snippets are data now. The banners needed two branches in a component
because the browser was the runtime; the feed unit has to be installed in
whatever language builds the publisher's feed, so lib/ads/snippets carries 36
recipes across Node, Next, Express, Hono, Eleventy, Hugo, Jekyll, Astro,
WordPress, PHP, Python, Django, Ruby, Go and Cloudflare Workers. Every one of
them fails open, because a feed build that throws when an ad server is slow
takes the publisher's whole deploy with it.

Metering is unchanged and worth being explicit about: the impression is
recorded at fetch time, so one build produces one impression for a document
thousands then read. Feed impressions undercount reach by design; clicks are
exact.

The migration follows terminal_ascii exactly -- widen the CHECK, add the
format to slot inventory, backfill a creative per campaign from the copy it
already has. Apply by hand via psql over the pooler; prod history diverged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@socket-security

socket-security Bot commented Aug 18, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedfast-xml-parser@​5.11.010010010095100

View full report

@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

35 finding(s)

HIGH/CRITICAL: 3 | MEDIUM: 23 | LOW: 9

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:47
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:98
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

copyToCreatives maps over every format, so every new campaign now gets a
feed_item creative -- and AdPreview had no branch for it, so all three
advertiser screens (new, detail, edit) fell through to the banner renderer and
drew it as a 600x120 box. That is not what anybody receives.

The preview is deliberately not painted in the advertiser's brand colours. The
served body carries no CSS at all, because feed readers strip it, so its
appearance comes entirely from the subscriber's own stylesheet -- and showing
brand colours here would promise a look we have no way to deliver. What it
shows instead is the structure that does survive: the disclosure, the headline
as a link, the body, the call to action, the attribution, on the plain
serif-on-white a reader actually renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio marked this pull request as ready for review August 18, 2026 14:39
@ralyodio
ralyodio merged commit 91bef25 into master Aug 18, 2026
8 checks passed
@ralyodio
ralyodio deleted the feed-ads branch August 18, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant