From 3e8f024899a22532c459acd6a825a69745db31e5 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Tue, 7 Jul 2026 19:10:04 -0400 Subject: [PATCH 1/3] fix(api): keep replaced uploads fresh at the edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2 custom domains edge-cache cacheable objects (images) under a default max-age=14400, so overwriting a key at its stable URL kept serving the old body for up to 4h — breaking the GitHub-attachments promise that re-uploading the same key updates every embed. Two layers: - Set `Cache-Control: public, max-age=60` on every upload. Bounds staleness to ~60s everywhere, including bring-your-own-domain buckets. - Purge-on-overwrite for zones we control (the shared `storage.uploads.sh` domain): after writing, purge the exact URL via the Cloudflare API so replacements propagate immediately. Gated on a CF_PURGE_TOKEN secret plus CF_PURGE_ZONE_ID / CF_PURGE_HOSTS vars; unset or a non-matching host (BYO domains) => no-op, falling back to the short TTL. Best-effort: purge failures never fail the upload. --- apps/api/.dev.vars.example | 7 ++++ apps/api/src/env.d.ts | 4 +++ apps/api/src/purge.ts | 64 ++++++++++++++++++++++++++++++++++++ apps/api/src/routes/files.ts | 17 +++++++++- apps/api/wrangler.jsonc | 8 +++++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/purge.ts diff --git a/apps/api/.dev.vars.example b/apps/api/.dev.vars.example index 5385e04..05dc141 100644 --- a/apps/api/.dev.vars.example +++ b/apps/api/.dev.vars.example @@ -8,3 +8,10 @@ # cd apps/api && pnpm exec wrangler secret put ADMIN_TOKEN # If unset, /admin/* rejects every request (fails closed). ADMIN_TOKEN= + +# Cloudflare API token (Zone → Cache Purge) for purge-on-overwrite on the core +# zone. Set in production with: +# cd apps/api && pnpm exec wrangler secret put CF_PURGE_TOKEN +# Zone id + purgeable hosts are non-secret `vars` in wrangler.jsonc. If unset, +# purging is skipped and overwrites propagate within the upload Cache-Control TTL. +CF_PURGE_TOKEN= diff --git a/apps/api/src/env.d.ts b/apps/api/src/env.d.ts index 6df7107..e41ce9b 100644 --- a/apps/api/src/env.d.ts +++ b/apps/api/src/env.d.ts @@ -2,4 +2,8 @@ // in wrangler.jsonc, so `wrangler types` does not generate it. Augment Env here. interface Env { ADMIN_TOKEN?: string; + // Cache purge-on-overwrite for zones we control (see purge.ts). This is the + // Worker secret (Zone → Cache Purge scope), so it isn't generated by + // `wrangler types`; the CF_PURGE_ZONE_ID / CF_PURGE_HOSTS vars are. + CF_PURGE_TOKEN?: string; } diff --git a/apps/api/src/purge.ts b/apps/api/src/purge.ts new file mode 100644 index 0000000..82985a7 --- /dev/null +++ b/apps/api/src/purge.ts @@ -0,0 +1,64 @@ +/** + * Cloudflare edge-cache invalidation for objects served off a zone we control. + * + * R2 custom domains edge-cache cacheable objects (e.g. images) under a default + * TTL, so overwriting a key at a stable URL keeps serving the old body until the + * TTL lapses. `Cache-Control: max-age=60` on upload bounds that everywhere; for + * the core `uploads.sh` zone we additionally purge the exact URL on write so + * replacements propagate immediately. + * + * Gated on config: no token / no zone / a host we don't control → no-op, and + * callers rely on the short TTL instead. Bring-your-own-domain workspaces + * (different `publicBaseUrl` host) fall through here by design. + */ + +/** Hosts whose URLs we're allowed to purge, from `CF_PURGE_HOSTS` (comma-separated). */ +function purgeHosts(env: Env): Set { + return new Set( + (env.CF_PURGE_HOSTS ?? "") + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean), + ); +} + +/** True when the URL sits on a host we're configured to purge. */ +export function isPurgeable(env: Env, url: string): boolean { + if (!env.CF_PURGE_TOKEN || !env.CF_PURGE_ZONE_ID) return false; + let host: string; + try { + host = new URL(url).host.toLowerCase(); + } catch { + return false; + } + return purgeHosts(env).has(host); +} + +/** + * Purge specific URLs from Cloudflare's edge cache. Best-effort: resolves even + * on failure (logs), since the short Cache-Control is the correctness backstop. + * Call the guard {@link isPurgeable} first; this assumes token/zone are set. + */ +export async function purgeUrls(env: Env, urls: string[]): Promise { + if (urls.length === 0) return; + try { + const res = await fetch( + `https://api.cloudflare.com/client/v4/zones/${env.CF_PURGE_ZONE_ID}/purge_cache`, + { + method: "POST", + headers: { + Authorization: `Bearer ${env.CF_PURGE_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ files: urls }), + }, + ); + if (!res.ok) { + console.error( + JSON.stringify({ msg: "cache purge failed", status: res.status, body: await res.text() }), + ); + } + } catch (err) { + console.error(JSON.stringify({ msg: "cache purge error", error: String(err) })); + } +} diff --git a/apps/api/src/routes/files.ts b/apps/api/src/routes/files.ts index 9782d4f..b5c41f2 100644 --- a/apps/api/src/routes/files.ts +++ b/apps/api/src/routes/files.ts @@ -1,7 +1,12 @@ import { Hono } from "hono"; import { publicUrl, storage, storageConfig } from "../storage"; +import { isPurgeable, purgeUrls } from "../purge"; import type { WorkspaceVars } from "../workspace"; +// Bounds edge/camo staleness on overwrite for every bucket, including +// bring-your-own domains. The core zone additionally purges on write (below). +const UPLOAD_CACHE_CONTROL = "public, max-age=60"; + const KEY_RE = /^[\w!*'().\/-]+$/; function badKey(key: string): boolean { @@ -23,9 +28,19 @@ export const files = new Hono() const ws = c.get("workspace"); const contentType = c.req.header("Content-Type") ?? "application/octet-stream"; - await storage(c.env, ws).upload(key, new Uint8Array(body), { contentType }); + await storage(c.env, ws).upload(key, new Uint8Array(body), { + contentType, + cacheControl: UPLOAD_CACHE_CONTROL, + }); const url = publicUrl(storageConfig(c.env, ws), key); + // Instant propagation for the core zone: the object may already be edge-cached + // under a previous version, so purge the exact URL before returning — callers + // (e.g. the GitHub-embed flow) reference the URL immediately after. Best-effort: + // purgeUrls never throws, and the short Cache-Control above is the backstop. + if (url && isPurgeable(c.env, url)) { + await purgeUrls(c.env, [url]); + } return c.json({ workspace: c.get("workspaceName"), key, url, size: body.byteLength, contentType }, 201); }) diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index 09c6807..fa77610 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -14,6 +14,14 @@ "enabled": true, "head_sampling_rate": 1 }, + // Cache purge-on-overwrite for the core zone. Non-secret: zone id + the hosts + // we're allowed to purge (the shared bucket's custom domain). The matching API + // token is a secret: `wrangler secret put CF_PURGE_TOKEN` (Zone → Cache Purge). + // Leave CF_PURGE_TOKEN unset to disable purging (uploads fall back to the TTL). + "vars": { + "CF_PURGE_ZONE_ID": "", + "CF_PURGE_HOSTS": "storage.uploads.sh" + }, // Workspace registry: `ws:` → WorkspaceRecord (see src/workspace.ts). // Create with `wrangler kv namespace create ` and paste the id here. // The account-level namespace title (ours: UPLOADS_REGISTRY) is independent From 00dffc2d6dd75861577aea2cbaf807189ac29ac6 Mon Sep 17 00:00:00 2001 From: Zach Dunn <zach@rally.space> Date: Tue, 7 Jul 2026 19:36:09 -0400 Subject: [PATCH 2/3] =?UTF-8?q?docs(api):=20clarify=20purge=20scope=20?= =?UTF-8?q?=E2=80=94=20edge-only,=20Camo=20governs=20GitHub=20embeds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: purging our Cloudflare edge doesn't evict GitHub Camo's cache, so for the GitHub-embed path the upload max-age is the operative freshness lever, not the purge. Corrects comments that implied purge made embeds fresh; no behavior change. --- apps/api/src/purge.ts | 7 ++++++- apps/api/src/routes/files.ts | 16 ++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/api/src/purge.ts b/apps/api/src/purge.ts index 82985a7..9b8ac46 100644 --- a/apps/api/src/purge.ts +++ b/apps/api/src/purge.ts @@ -5,7 +5,12 @@ * TTL, so overwriting a key at a stable URL keeps serving the old body until the * TTL lapses. `Cache-Control: max-age=60` on upload bounds that everywhere; for * the core `uploads.sh` zone we additionally purge the exact URL on write so - * replacements propagate immediately. + * our edge serves fresh bytes immediately. + * + * Scope note: this evicts OUR Cloudflare edge only. GitHub embeds are proxied + * through Camo/Fastly, which keeps its own cache we can't purge — there, the + * upload `Cache-Control` is what governs how fast a replacement shows up. This + * purge mainly helps direct storage.uploads.sh viewers. * * Gated on config: no token / no zone / a host we don't control → no-op, and * callers rely on the short TTL instead. Bring-your-own-domain workspaces diff --git a/apps/api/src/routes/files.ts b/apps/api/src/routes/files.ts index e12447e..d0f8265 100644 --- a/apps/api/src/routes/files.ts +++ b/apps/api/src/routes/files.ts @@ -3,8 +3,10 @@ import { publicUrl, storage, storageConfig } from "../storage"; import { isPurgeable, purgeUrls } from "../purge"; import type { WorkspaceVars } from "../workspace"; -// Bounds edge/camo staleness on overwrite for every bucket, including -// bring-your-own domains. The core zone additionally purges on write (below). +// The freshness floor on overwrite for every bucket, incl. bring-your-own +// domains. This is the operative lever for GitHub embeds: they're proxied +// through GitHub's Camo/Fastly cache, which the origin purge below can't +// evict — max-age caps how long Camo serves a stale copy before revalidating. const UPLOAD_CACHE_CONTROL = "public, max-age=60"; const KEY_RE = /^[\w!*'()./-]+$/; @@ -34,10 +36,12 @@ export const files = new Hono<WorkspaceVars>() }); const url = publicUrl(storageConfig(c.env, ws), key); - // Instant propagation for the core zone: the object may already be edge-cached - // under a previous version, so purge the exact URL before returning — callers - // (e.g. the GitHub-embed flow) reference the URL immediately after. Best-effort: - // purgeUrls never throws, and the short Cache-Control above is the backstop. + // Keep OUR edge fresh on the core zone: the object may still be edge-cached + // under a previous version, so purge the exact URL before returning. This + // benefits direct storage.uploads.sh viewers and means Camo gets fresh bytes + // the moment it revalidates — but it does NOT evict Camo's own copy, so the + // max-age above still governs GitHub-embed freshness. Best-effort: purgeUrls + // never throws, and the short Cache-Control is the backstop when unconfigured. if (url && isPurgeable(c.env, url)) { await purgeUrls(c.env, [url]); } From 52666a5810791e93f58b87d8d5add232589722a4 Mon Sep 17 00:00:00 2001 From: Zach Dunn <zach@rally.space> Date: Tue, 7 Jul 2026 19:44:45 -0400 Subject: [PATCH 3/3] Drop purge-on-overwrite; keep max-age=60 as the fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The purge only evicts our Cloudflare edge, not GitHub's Camo/Fastly cache, so it did nothing for the GitHub-embed path (Camo honors the origin max-age, as the shields.io/Actions badges demonstrate). It only helped direct browser viewers — not worth the CF token setup + per-upload round-trip. Keep the max-age=60 upload header, which is the actual fix; purge can be revisited later if direct-view freshness ever matters. --- apps/api/.dev.vars.example | 7 ---- apps/api/src/env.d.ts | 4 --- apps/api/src/purge.ts | 69 ------------------------------------ apps/api/src/routes/files.ts | 19 +++------- apps/api/wrangler.jsonc | 8 ----- 5 files changed, 5 insertions(+), 102 deletions(-) delete mode 100644 apps/api/src/purge.ts diff --git a/apps/api/.dev.vars.example b/apps/api/.dev.vars.example index 05dc141..5385e04 100644 --- a/apps/api/.dev.vars.example +++ b/apps/api/.dev.vars.example @@ -8,10 +8,3 @@ # cd apps/api && pnpm exec wrangler secret put ADMIN_TOKEN # If unset, /admin/* rejects every request (fails closed). ADMIN_TOKEN= - -# Cloudflare API token (Zone → Cache Purge) for purge-on-overwrite on the core -# zone. Set in production with: -# cd apps/api && pnpm exec wrangler secret put CF_PURGE_TOKEN -# Zone id + purgeable hosts are non-secret `vars` in wrangler.jsonc. If unset, -# purging is skipped and overwrites propagate within the upload Cache-Control TTL. -CF_PURGE_TOKEN= diff --git a/apps/api/src/env.d.ts b/apps/api/src/env.d.ts index e41ce9b..6df7107 100644 --- a/apps/api/src/env.d.ts +++ b/apps/api/src/env.d.ts @@ -2,8 +2,4 @@ // in wrangler.jsonc, so `wrangler types` does not generate it. Augment Env here. interface Env { ADMIN_TOKEN?: string; - // Cache purge-on-overwrite for zones we control (see purge.ts). This is the - // Worker secret (Zone → Cache Purge scope), so it isn't generated by - // `wrangler types`; the CF_PURGE_ZONE_ID / CF_PURGE_HOSTS vars are. - CF_PURGE_TOKEN?: string; } diff --git a/apps/api/src/purge.ts b/apps/api/src/purge.ts deleted file mode 100644 index 9b8ac46..0000000 --- a/apps/api/src/purge.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Cloudflare edge-cache invalidation for objects served off a zone we control. - * - * R2 custom domains edge-cache cacheable objects (e.g. images) under a default - * TTL, so overwriting a key at a stable URL keeps serving the old body until the - * TTL lapses. `Cache-Control: max-age=60` on upload bounds that everywhere; for - * the core `uploads.sh` zone we additionally purge the exact URL on write so - * our edge serves fresh bytes immediately. - * - * Scope note: this evicts OUR Cloudflare edge only. GitHub embeds are proxied - * through Camo/Fastly, which keeps its own cache we can't purge — there, the - * upload `Cache-Control` is what governs how fast a replacement shows up. This - * purge mainly helps direct storage.uploads.sh viewers. - * - * Gated on config: no token / no zone / a host we don't control → no-op, and - * callers rely on the short TTL instead. Bring-your-own-domain workspaces - * (different `publicBaseUrl` host) fall through here by design. - */ - -/** Hosts whose URLs we're allowed to purge, from `CF_PURGE_HOSTS` (comma-separated). */ -function purgeHosts(env: Env): Set<string> { - return new Set( - (env.CF_PURGE_HOSTS ?? "") - .split(",") - .map((h) => h.trim().toLowerCase()) - .filter(Boolean), - ); -} - -/** True when the URL sits on a host we're configured to purge. */ -export function isPurgeable(env: Env, url: string): boolean { - if (!env.CF_PURGE_TOKEN || !env.CF_PURGE_ZONE_ID) return false; - let host: string; - try { - host = new URL(url).host.toLowerCase(); - } catch { - return false; - } - return purgeHosts(env).has(host); -} - -/** - * Purge specific URLs from Cloudflare's edge cache. Best-effort: resolves even - * on failure (logs), since the short Cache-Control is the correctness backstop. - * Call the guard {@link isPurgeable} first; this assumes token/zone are set. - */ -export async function purgeUrls(env: Env, urls: string[]): Promise<void> { - if (urls.length === 0) return; - try { - const res = await fetch( - `https://api.cloudflare.com/client/v4/zones/${env.CF_PURGE_ZONE_ID}/purge_cache`, - { - method: "POST", - headers: { - Authorization: `Bearer ${env.CF_PURGE_TOKEN}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ files: urls }), - }, - ); - if (!res.ok) { - console.error( - JSON.stringify({ msg: "cache purge failed", status: res.status, body: await res.text() }), - ); - } - } catch (err) { - console.error(JSON.stringify({ msg: "cache purge error", error: String(err) })); - } -} diff --git a/apps/api/src/routes/files.ts b/apps/api/src/routes/files.ts index d0f8265..0b35084 100644 --- a/apps/api/src/routes/files.ts +++ b/apps/api/src/routes/files.ts @@ -1,12 +1,12 @@ import { Hono } from "hono"; import { publicUrl, storage, storageConfig } from "../storage"; -import { isPurgeable, purgeUrls } from "../purge"; import type { WorkspaceVars } from "../workspace"; -// The freshness floor on overwrite for every bucket, incl. bring-your-own -// domains. This is the operative lever for GitHub embeds: they're proxied -// through GitHub's Camo/Fastly cache, which the origin purge below can't -// evict — max-age caps how long Camo serves a stale copy before revalidating. +// The freshness floor on overwrite for every bucket. This is the operative lever +// for GitHub embeds: they're proxied through GitHub's Camo/Fastly cache, and +// max-age caps how long Camo serves a stale copy before revalidating against the +// (now-overwritten) origin. Without it, R2's custom-domain default (max-age=14400) +// kept replaced images stale for hours. const UPLOAD_CACHE_CONTROL = "public, max-age=60"; const KEY_RE = /^[\w!*'()./-]+$/; @@ -36,15 +36,6 @@ export const files = new Hono<WorkspaceVars>() }); const url = publicUrl(storageConfig(c.env, ws), key); - // Keep OUR edge fresh on the core zone: the object may still be edge-cached - // under a previous version, so purge the exact URL before returning. This - // benefits direct storage.uploads.sh viewers and means Camo gets fresh bytes - // the moment it revalidates — but it does NOT evict Camo's own copy, so the - // max-age above still governs GitHub-embed freshness. Best-effort: purgeUrls - // never throws, and the short Cache-Control is the backstop when unconfigured. - if (url && isPurgeable(c.env, url)) { - await purgeUrls(c.env, [url]); - } return c.json( { workspace: c.get("workspaceName"), key, url, size: body.byteLength, contentType }, 201, diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index 0a8d013..9884f2c 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -14,14 +14,6 @@ "enabled": true, "head_sampling_rate": 1, }, - // Cache purge-on-overwrite for the core zone. Non-secret: zone id + the hosts - // we're allowed to purge (the shared bucket's custom domain). The matching API - // token is a secret: `wrangler secret put CF_PURGE_TOKEN` (Zone → Cache Purge). - // Leave CF_PURGE_TOKEN unset to disable purging (uploads fall back to the TTL). - "vars": { - "CF_PURGE_ZONE_ID": "", - "CF_PURGE_HOSTS": "storage.uploads.sh", - }, // Workspace registry: `ws:<name>` → WorkspaceRecord (see src/workspace.ts). // Create with `wrangler kv namespace create <title>` and paste the id here. // The account-level namespace title (ours: UPLOADS_REGISTRY) is independent