Skip to content

fix(web-og): stop workers-og's 1-year immutable default overriding OG cache-control - #141

Merged
lukaso merged 4 commits into
mainfrom
fix/og-cache-control-override
Aug 21, 2026
Merged

fix(web-og): stop workers-og's 1-year immutable default overriding OG cache-control#141
lukaso merged 4 commits into
mainfrom
fix/og-cache-control-override

Conversation

@lukaso-bot

@lukaso-bot lukaso-bot commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

The bug

Every OG image in production ships a merged cache-control header. Live right now:

$ GET https://og.released.blabberate.com/placeholder.png
cache-control: public, immutable, no-transform, max-age=31536000, public, max-age=60

workers-og builds its response headers as

headers: { 'Content-Type': , 'Cache-Control': 'public, immutable, no-transform, max-age=31536000', ...opts.headers }

Object spread is case-sensitive, so renderImage's lowercase 'cache-control' never
replaced the library's capitalized 'Cache-Control' default. Both keys reached
new Response, where Headers merged them into one comma-joined value — and caches honor
the first max-age.

Why it matters

The damage is on the short-cached cards. A placeholder render — cold lookup, upstream
rate-limit, service-binding miss, or the notFound path hit during the webweb-og
deploy window — is deliberately max-age=60 so the unfurl refreshes once the real answer
exists. Instead it went out immutable for a year, so one transient failure froze a wrong
social card in every downstream cache that honors it.

This is a concrete mechanism for the "sustained placeholder on a stably-released MR"
behaviour previously observed on the federated PR OG route: once a placeholder was cached,
nothing could dislodge it.

Front door stayed green throughout — status 200, valid PNG, correct content-type. Only the
header was wrong.

Fix

Set cache-control on the Response after construction. Headers.set is case-insensitive,
so it replaces the library default whatever casing the library uses (rather than relying on
matching its casing in the spread, which would re-break on a library change).

Why the tests didn't catch it

test/routing.test.ts mocked ImageResponse with a stub that passed the caller's headers
straight through, so it never reproduced the library default. All 11 cache-control
assertions were unfalsifiable — they read back exactly what the caller passed. They were
also substring matches (toMatch(/max-age=60/)), which pass on the merged value even with a
faithful mock. Both were fixed:

  • the mock now mirrors workers-og's real header construction (capitalized default +
    case-sensitive spread of the caller's headers);
  • the 11 assertions are exact-match (toBe);
  • test/cards.render.test.ts gets two assertions against the real library under
    workerd — no mock fidelity required.

Mutation proof

Reverting the one-line fix (passing 'cache-control' back through ImageResponse's
headers option) while keeping the tests:

Failed Tests 10
 FAIL  test/routing.test.ts > returns a placeholder PNG with SHORT cache when the service binding misses
   Expected: "public, max-age=60"
   Received: "public, immutable, no-transform, max-age=31536000, public, max-age=60"
 FAIL  test/cards.render.test.ts > placeholder card: cache-control is EXACTLY the short cache
   Expected: "public, max-age=60"
   Received: "public, immutable, no-transform, max-age=31536000, public, max-age=60"
 …8 more

The Received: values are byte-identical to what production serves today. Restored → 35/35 pass
(27 routing + 8 render).

Second commit: fix(deps) — clearing the red osv dependency scan

This branch was based on main, which carries 3 High CVEs, so the osv gate was red for a
reason unrelated to the OG fix. Rather than leave the PR unmergeable, the floors are raised
here (root pnpm.overrides, lockfile-only resolution changes):

package main this PR advisory
undici 7.28.0 7.29.0 GHSA-4cwx-7wf7-3272 (High 7.4) + 4 more
js-yaml 4.3.0 4.3.1 GHSA-5p4m-2wfm-xmqj (High 7.5)
nanoid 3.3.16 3.3.18 GHSA-2v37-7h3g-55p8 (High 8.2)

The undici and js-yaml floors are byte-identical to the ones #137/#138/#139 already
carry. nanoid is a newer advisory that none of those three cover — their osv checks
last ran 2026-08-07 and passed, which is now stale. So merging #137, #138 or #139 alone
leaves main at 1 High; this is currently the only open PR that takes main to zero High.

Verified locally on this exact tree — the gate goes red → green:

before:  Total 4 packages affected by 11 known vulnerabilities (0 Critical, 3 High, …)
         ✗ osv: 0 Critical + 3 High vulnerability(ies) — must be resolved before merge.
after:   Total 1 package affected by 4 known vulnerabilities (0 Critical, 0 High, 3 Medium, 1 Low)
         ✓ osv: no High/Critical vulnerabilities.

pnpm lint, pnpm -r typecheck, pnpm -r test (357 tests) all green on the merged branch.

Deliberately not fixed here: the 4 remaining hono mediums (4.12.32 → 4.12.34,
GHSA-54fx / -79qm / -8j4g / -f23p). hono is the production runtime framework and a direct
dependency; the osv gate does not block on Medium, and Dependabot's weekly npm-minor-patch
group already has it in range (^4.12.32). Left to that PR so this one stays reviewable.

@github-actions

Copy link
Copy Markdown
Contributor

Preview deployed

Federated GitLab lookups (freedesktop / GNOME) degrade to the "use the CLI" card — the Anubis relay is off in preview. GitHub lookups, permalinks, and OG render work once INTERNAL_SECRET/GITHUB_TOKEN are set on the preview env.

@lukaso-bot

Copy link
Copy Markdown
Collaborator Author

Preview verification (head 082bd14)

Exercised the isolated preview deploy rather than relying on the gate alone.

The fix works in a real deploy. Same route, two environments:

cache-control on /placeholder.png
production (og.released.blabberate.com) public, immutable, no-transform, max-age=31536000, public, max-age=60
preview (this PR) public, max-age=60

The library default is gone and the caller's value is the whole header. That is the bug this PR is about, confirmed end-to-end against workers-og as actually bundled — not against the test mock.

osv dependency scan is now green on this head (was the only red check).

Honest limit on this verification: preview only exercises the SHORT-cache path

I could not confirm the long-cache (real-result) path on the preview, and want to be explicit rather than imply broader coverage than I have.

preview  /r/honojs/hono/c/f82aba8….png  → 200, 14348B, cache-control: public, max-age=60
prod     /r/honojs/hono/c/f82aba8….png  → 200, 28899B, cache-control: public, …, max-age=86400

The preview is serving a placeholder for a commit that is stably released — so every OG image on the preview is the short-cache branch, whatever the input.

It is not this PR and not a production bug (prod renders the real card correctly). Isolating it:

  • preview web resolves the lookup fine — POST /api/lookup on released-web-preview returns firstRelease for that commit, so provider access and the algorithm are healthy there;
  • preview web-og still falls back to placeholder for the same commit.

So the failure is between them: the web-ogweb Service Binding handshake over /internal/*, which fails closed without a matching INTERNAL_SECRET on the preview env (1bdafac). That secret is set on production but appears not to be set for [env.preview].

Worth fixing separately, because it is quietly misleading: anyone exercising an OG change on a preview is only ever testing the placeholder branch, and a real-result regression would look green there. Filed to the backlog; not a merge blocker for this PR.

The long-cache path here is covered by the unit tests instead — test/cards.render.test.ts asserts exact cache-control against the real workers-og under workerd, and the mutation proof in the description shows those assertions failing on the reverted fix.

Comment thread packages/web-og/src/index.tsx
@lukaso-bot

Copy link
Copy Markdown
Collaborator Author

Third commit: fix(web-og) — keep the static /placeholder.png on the long cache

Follow-up to my own review finding on this PR (thread resolved above).

The un-merge fix in c102b89 was right for every card, but it also swept up /placeholder.png, which routes through renderImage(null, …) and so inherited the null-result short cache — dropping it from an accidentally-immutable header to max-age=60.

That route is the one null-result render that isn't transient: it takes no owner/repo/sha (byte-identical PNG every time), and web only ever links it as ?v=${OG_TEMPLATE_VERSION} (og-meta.tsx:56), so a template change busts the URL instead of waiting out a TTL. At 60s, every homepage unfurl re-ran a ~700ms satori+resvg wasm render for an image that cannot differ.

renderImage now takes an explicit cacheOverride (LONG_CACHE / SHORT_CACHE hoisted). Only the static route passes it — the genuinely transient null renders (binding miss, rate-limit, notFound deploy-window) keep the short cache, which is the point of this PR.

Guard watched failing on the real defect first: the new test read public, max-age=60 against an expected public, max-age=86400, s-maxage=86400. The notFound short-cache assertion still stands as the discriminator, so this can't silently long-cache everything.

Full gate green locally (./scripts/validate.sh: lint, typecheck, 358 tests, gitleaks, osv → 0 High). CI re-running on 268c0dc; I'll re-exercise the preview deploy against the new head next cycle before calling it done.

Merge-order note unchanged: this is still the only open PR that takes main to zero High CVEs (it carries the nanoid floor that #137/#138/#139 predate).

Comment thread packages/web-og/src/index.tsx Outdated
Comment thread packages/web-og/src/index.tsx
Comment thread packages/web-og/src/index.tsx
@lukaso-bot

Copy link
Copy Markdown
Collaborator Author

liveapp: both review threads on 3487147 are addressed in ff3068e (gate pnpm test green, validate.sh green, typecheck clean).

  • Version gate was incomplete — it only covered /placeholder.png, while web versions every card URL. Moved into a shared renderCard helper all six dynamic routes call.
  • no-transform was silently dropped by the Headers.set override. Folded back into both cache constants.

Both guards were mutation-tested against the defect in their own title (evidence in each thread). Test count on this PR: 44 unit + 8 workers-runtime, all green.

@lukaso-bot

Copy link
Copy Markdown
Collaborator Author

Independent production evidence that this PR fixes a live bug — gathered from prod
response headers this cycle, not from the test suite.

https://og.released.blabberate.com/placeholder.png today returns:

cache-control: public, immutable, no-transform, max-age=31536000, public, max-age=60

and a dynamic card (/r/honojs/hono/c/f82aba8e….png):

cache-control: public, immutable, no-transform, max-age=31536000, public, max-age=86400, s-maxage=86400

Two cache-control values are concatenated into one field. workers-og's 1-year default
lands first, ahead of the intended 60s / 86400s value — and it carries immutable,
which tells clients not to revalidate at all. So the shorter max-age this code sets is
effectively dead on every OG response in production right now, which is the flip that
CUJ #3 depends on.

Verification detail, since it changes how much the check is worth:

  • The shared preview environment is currently serving the pr-142 build
    (/version{"tag":"pr-142"}), so I could not exercise this PR's fix there.
    released-web-og-preview.lukaso.workers.dev is one shared origin overwritten by
    whichever preview build ran last — it is not per-PR. Exercising this PR's preview
    still needs its build to be the most recent one.
  • What the preview did confirm is the bug's presence in the un-fixed build: pr-142
    (main + dep bumps, without this PR) reproduces the same doubled header as production.

This PR remains 10/10 green with 0 unresolved review threads. Not re-pushing it just to
claim the shared preview — that would restart CI on an already-green PR. Flagging instead
that the defect it fixes is live in production, in case that changes merge priority.

One note on the CVE floors this branch also carries (undici >=7.29.0, js-yaml@4 ^4.3.1,
nanoid@3 ^3.3.17): #142 now carries the same undici/js-yaml floors, so whichever of the
two merges second needs a trivial rebase on the pnpm.overrides block. Those advisories
are dev-only (jsdom/miniflare/@changesets), so they are a red gate on main, not a
production exposure.

liveapp-bot and others added 4 commits August 20, 2026 18:07
… cache-control

Every OG image shipped a merged cache-control header:

  public, immutable, no-transform, max-age=31536000, public, max-age=60

workers-og builds its response headers as

  { 'Content-Type': …, 'Cache-Control': <1-year immutable default>, ...opts.headers }

and object spread is case-SENSITIVE, so renderImage's lowercase 'cache-control'
never replaced the capitalized default. Both keys reached `new Response`, where
Headers merged them into one comma-joined value — and caches honor the FIRST
max-age.

Impact is on the short-cached cards. A placeholder render (cold lookup, upstream
rate-limit, service-binding miss, or the notFound path hit during the web→web-og
deploy window) is deliberately max-age=60 so the unfurl refreshes once the real
answer is available. Instead it went out immutable for a year, so a transient
failure froze a wrong social card in every downstream cache. This is the
mechanism behind the "sustained placeholder on a stably-released MR" seen on the
federated PR OG route.

Fix: set cache-control on the Response after construction. Headers.set is
case-insensitive, so it replaces the library default whatever casing it uses.

Tests: the routing suite mocked ImageResponse with a stub that passed the
caller's headers straight through, so it never saw the library default — every
cache-control assertion there was unfalsifiable. The mock now mirrors
workers-og's real header construction (capitalized default + case-sensitive
spread), the assertions are exact-match instead of substring (a substring match
passes on the merged value), and cards.render.test.ts asserts the header against
the REAL library under workerd.
The un-merge fix in c102b89 corrected every short-cached card, but it also
dropped /placeholder.png from an (accidentally) effectively-immutable header
to public, max-age=60 — it routes through renderImage(null, …), so it inherits
the null-result short cache.

That route is the one null-result render that is not transient: it takes no
owner/repo/sha, so the PNG is byte-identical on every request, and web only
ever links it as /placeholder.png?v=${OG_TEMPLATE_VERSION} (og-meta.tsx:56),
so a template change busts the URL rather than waiting out a TTL. At 60s every
homepage/result-less unfurl re-runs a ~700ms satori+resvg wasm render for an
image that cannot differ.

renderImage takes an explicit cacheOverride and the two cache strings are
hoisted to LONG_CACHE / SHORT_CACHE. Only the static route passes it; the
genuinely transient null renders (service-binding miss, the notFound
deploy-window path) keep the short cache, which is the point of c102b89.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
release.yml deploys `web` before `web-og`, so on an OG_TEMPLATE_VERSION bump
`web` is already emitting `og:image=.../placeholder.png?v=og.vNEXT` while this
Worker is still the old build. The old route ignored the query and stamped the
24h cache on a stale-template card — poisoning the busting URL itself, with no
second URL left to bump. That is the same "one transient render freezes a wrong
social card" class this PR exists to fix.

Take the long cache only when the requested `v` is a version this build can
actually render; anything else falls back to the 60s cache and self-heals once
web-og lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ransform

Two review findings on this PR's own diff.

1. The deploy-window version gate landed on `/placeholder.png` only, but
   `web` links EVERY card as `<url>.png?v=${OG_TEMPLATE_VERSION}`
   (og-meta.tsx) and `release.yml` deploys `web` before `web-og`. So during
   the window the six dynamic routes (/r, /h/../r, /i, /p, /h/../i, /h/../p)
   still took the 24h cache for a `?v=og.vNEXT` URL rendered from the OLD
   template — pinning a stale card, on exactly the routes whose content
   differs per commit/issue/PR, with no second URL left to bust. The gate now
   lives in a shared `renderCard` helper the routes call: a `v` this build
   cannot render forces SHORT_CACHE and self-heals 60s after web-og lands. No
   `v` at all is not evidence of a mismatch, so it keeps the default.

2. `res.headers.set` replaces workers-og's whole default value, which
   silently dropped the `no-transform` that shipped on every OG response
   before this PR. Folded back into LONG_CACHE/SHORT_CACHE with a comment
   saying why: these PNGs are the byte-exact social card, and a transforming
   edge (Polish/Mirage) must not recompress them.
@lukaso
lukaso force-pushed the fix/og-cache-control-override branch from ff3068e to ea85570 Compare August 20, 2026 17:08
@lukaso
lukaso merged commit 0ee8e68 into main Aug 21, 2026
10 checks passed
@lukaso
lukaso deleted the fix/og-cache-control-override branch August 21, 2026 23:56
@lukaso-bot lukaso-bot mentioned this pull request Sep 1, 2026
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.

2 participants