From 531034221cfd07c520a008bb55cbac323b54ffa3 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 25 Apr 2026 23:43:07 -0700 Subject: [PATCH] lith: wire CF cache auto-purge into the deploy pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for the issue that left the latency-paper cards URL pinned to a stale SPA-fallback HTML response in Cloudflare's edge cache: 1. Caddyfile — when neither {path} nor {path}.html exists on disk, serve /index.html as a SPA fallback but tag it Cache-Control: no-cache, must-revalidate, max-age=0. Without this, a request for a not-yet-deployed PDF gets the SPA HTML and CF pins that HTML to the PDF URL for 4h via its default static-asset cache. 2. webhook.sh — replace the purge_everything sledgehammer with a URL-scoped purge: collect changed system/public// paths, map each to its public URL(s) (papers.aesthetic.computer files also get purged as papers.prompt.ac), and POST in 30-URL chunks per Cloudflare's per-request limit. The trigger condition is unchanged but the env-var gate now logs which URLs would have been purged when CLOUDFLARE_PURGE_TOKEN / CLOUDFLARE_ZONE_ID aren't set. 3. lith/scripts/cf-purge.fish — workstation-side ad-hoc tool with the same API. Reads creds from $CLOUDFLARE_PURGE_TOKEN, then the lith vault env, then falls back to the Global API Key. Used to clear the currently-poisoned cards URL. Production still needs CLOUDFLARE_PURGE_TOKEN + CLOUDFLARE_ZONE_ID added to aesthetic-computer-vault/lith/.env.gpg before the webhook auto-purge fires; .env.example is updated alongside this commit (in the vault repo) to document the keys. --- lith/Caddyfile | 17 +++++- lith/scripts/cf-purge.fish | 119 +++++++++++++++++++++++++++++++++++++ lith/webhook.sh | 73 +++++++++++++++++------ 3 files changed, 191 insertions(+), 18 deletions(-) create mode 100755 lith/scripts/cf-purge.fish diff --git a/lith/Caddyfile b/lith/Caddyfile index 946f49786b..c1012d387e 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -54,7 +54,22 @@ file_server } root * /opt/ac/system/public/papers.aesthetic.computer - try_files {path} {path}.html /index.html + + # SPA fallback for unknown paths: if neither {path} nor {path}.html + # exists on disk, serve /index.html — but mark it no-cache. Without + # this, a request for a not-yet-deployed PDF gets the SPA HTML, and + # Cloudflare pins that HTML to the PDF URL for 4h via its default + # static-asset cache. (See: 2026-04-26 latency-paper deploy gap.) + @missing not file { + try_files {path} {path}.html + } + handle @missing { + header Cache-Control "no-cache, must-revalidate, max-age=0" + rewrite * /index.html + file_server + } + + try_files {path} {path}.html file_server } diff --git a/lith/scripts/cf-purge.fish b/lith/scripts/cf-purge.fish new file mode 100755 index 0000000000..52dfbc2270 --- /dev/null +++ b/lith/scripts/cf-purge.fish @@ -0,0 +1,119 @@ +#!/usr/bin/env fish +# cf-purge.fish — Purge specific URLs from the Cloudflare edge cache. +# +# Usage: +# fish lith/scripts/cf-purge.fish [...] +# fish lith/scripts/cf-purge.fish --everything # full-zone purge (sledgehammer) +# +# Credentials are read in this order: +# 1. $CLOUDFLARE_PURGE_TOKEN + $CLOUDFLARE_ZONE_ID environment variables +# 2. aesthetic-computer-vault/lith/.env (after GPG-decryption to .env) +# 3. aesthetic-computer-vault/.devcontainer/envs/devcontainer.env (global key fallback) +# +# This script is the workstation-side companion to lith/webhook.sh's +# auto-purge — same API, different trigger. Use it when a build runs +# locally, or to clear a stale negative-cache entry without redeploying. + +set SCRIPT_DIR (dirname (status --current-filename)) +set REPO_ROOT (realpath "$SCRIPT_DIR/../..") +set VAULT_DIR "$REPO_ROOT/aesthetic-computer-vault" + +set CF_TOKEN "" +set CF_ZONE "" +set CF_EMAIL "" +set CF_GLOBAL_KEY "" + +# 1. Environment. +if set -q CLOUDFLARE_PURGE_TOKEN + set CF_TOKEN $CLOUDFLARE_PURGE_TOKEN +end +if set -q CLOUDFLARE_ZONE_ID + set CF_ZONE $CLOUDFLARE_ZONE_ID +end + +# 2. lith vault env (decrypted on demand if needed). +if test -z "$CF_TOKEN"; and test -f "$VAULT_DIR/lith/.env" + set token_line (rg -m1 '^CLOUDFLARE_PURGE_TOKEN=' "$VAULT_DIR/lith/.env" 2>/dev/null) + if test -n "$token_line" + set CF_TOKEN (string replace -r '^CLOUDFLARE_PURGE_TOKEN=' '' -- $token_line) + end + set zone_line (rg -m1 '^CLOUDFLARE_ZONE_ID=' "$VAULT_DIR/lith/.env" 2>/dev/null) + if test -n "$zone_line" + set CF_ZONE (string replace -r '^CLOUDFLARE_ZONE_ID=' '' -- $zone_line) + end +end + +# 3. Global key fallback (last resort). +if test -z "$CF_TOKEN"; and test -f "$VAULT_DIR/.devcontainer/envs/devcontainer.env" + set key_line (rg -m1 '^CLOUDFLARE_API_KEY=' "$VAULT_DIR/.devcontainer/envs/devcontainer.env" 2>/dev/null) + if test -n "$key_line" + set CF_GLOBAL_KEY (string replace -r '^CLOUDFLARE_API_KEY=' '' -- $key_line) + end + set email_line (rg -m1 '^CLOUDFLARE_EMAIL=' "$VAULT_DIR/.devcontainer/envs/devcontainer.env" 2>/dev/null) + if test -n "$email_line" + set CF_EMAIL (string replace -r '^CLOUDFLARE_EMAIL=' '' -- $email_line) + end +end + +# Look up zone ID if missing but we have credentials. +if test -z "$CF_ZONE" + if test -n "$CF_TOKEN" + set CF_ZONE (curl -sS "https://api.cloudflare.com/client/v4/zones?name=aesthetic.computer" \ + -H "Authorization: Bearer $CF_TOKEN" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("result",[{}])[0].get("id",""))') + else if test -n "$CF_GLOBAL_KEY"; and test -n "$CF_EMAIL" + set CF_ZONE (curl -sS "https://api.cloudflare.com/client/v4/zones?name=aesthetic.computer" \ + -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL_KEY" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("result",[{}])[0].get("id",""))') + end +end + +if test -z "$CF_ZONE" + echo "✗ no zone ID resolvable for aesthetic.computer" + exit 1 +end + +if test (count $argv) -eq 0 + echo "usage: fish lith/scripts/cf-purge.fish [...]" + echo " fish lith/scripts/cf-purge.fish --everything" + exit 1 +end + +# Build curl auth flags. +set AUTH_FLAGS +if test -n "$CF_TOKEN" + set AUTH_FLAGS -H "Authorization: Bearer $CF_TOKEN" +else if test -n "$CF_GLOBAL_KEY"; and test -n "$CF_EMAIL" + set AUTH_FLAGS -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL_KEY" +else + echo "✗ no Cloudflare credentials found" + echo " expected one of:" + echo " \$CLOUDFLARE_PURGE_TOKEN (preferred — scoped Zone.Cache Purge token)" + echo " aesthetic-computer-vault/lith/.env: CLOUDFLARE_PURGE_TOKEN=..." + echo " aesthetic-computer-vault/.devcontainer/envs/devcontainer.env: CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL" + exit 1 +end + +# Build payload. +if test "$argv[1]" = "--everything" + set PAYLOAD '{"purge_everything":true}' + echo "→ purging EVERYTHING on zone $CF_ZONE" +else + set PAYLOAD (printf '%s\n' $argv | python3 -c 'import sys, json; print(json.dumps({"files": [l.strip() for l in sys.stdin if l.strip()]}))') + echo "→ purging "(count $argv)" URL(s) on zone $CF_ZONE" + for url in $argv + echo " $url" + end +end + +set CF_RESPONSE (curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/zones/$CF_ZONE/purge_cache" \ + $AUTH_FLAGS \ + -H "Content-Type: application/json" \ + --data "$PAYLOAD" \ + --max-time 20) + +if echo "$CF_RESPONSE" | grep -q '"success":true' + echo "✓ purged" +else + echo "✗ purge failed: $CF_RESPONSE" + exit 1 +end diff --git a/lith/webhook.sh b/lith/webhook.sh index 235bfe148c..776922178f 100755 --- a/lith/webhook.sh +++ b/lith/webhook.sh @@ -63,7 +63,31 @@ NEED_RESTART=false NEED_CADDY_RELOAD=false NEED_NPM_INSTALL=false NEED_DP1_FEED_RESTART=false -NEED_CF_PURGE=false +PURGE_URLS=() + +# Map a system/public// path to the public URL(s) it serves. +# papers.aesthetic.computer is also reachable as papers.prompt.ac, so emit both. +emit_urls_for() { + local file="$1" + case "$file" in + system/public/papers.aesthetic.computer/*) + local rel="${file#system/public/papers.aesthetic.computer/}" + PURGE_URLS+=("https://papers.aesthetic.computer/${rel}") + PURGE_URLS+=("https://papers.prompt.ac/${rel}") + ;; + system/public/aesthetic.computer/*) + local rel="${file#system/public/aesthetic.computer/}" + PURGE_URLS+=("https://aesthetic.computer/${rel}") + ;; + system/public/*) + # Other subdomains — strip the host segment and emit one URL. + local stripped="${file#system/public/}" + local host="${stripped%%/*}" + local rel="${stripped#*/}" + PURGE_URLS+=("https://${host}/${rel}") + ;; + esac +} while IFS= read -r file; do case "$file" in @@ -91,9 +115,7 @@ while IFS= read -r file; do NEED_DP1_FEED_RESTART=true ;; system/public/*) - # Static files — Caddy serves directly from disk, but Cloudflare - # caches them at the edge for up to an hour, so we need to purge. - NEED_CF_PURGE=true + emit_urls_for "$file" ;; *) # Other files (docs, tests, etc.) — no action needed @@ -129,22 +151,39 @@ else log "static-only deploy — no restart needed" fi -if $NEED_CF_PURGE; then +if [ ${#PURGE_URLS[@]} -gt 0 ]; then if [ -n "${CLOUDFLARE_PURGE_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ZONE_ID:-}" ]; then - log "purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..." - CF_RESPONSE=$(curl -sS -X POST \ - "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \ - -H "Authorization: Bearer ${CLOUDFLARE_PURGE_TOKEN}" \ - -H "Content-Type: application/json" \ - --data '{"purge_everything":true}' \ - --max-time 20 || echo '{"success":false,"errors":[{"message":"curl failed"}]}') - if echo "$CF_RESPONSE" | grep -q '"success":true'; then - log "Cloudflare cache purged" - else - log "WARN: Cloudflare purge failed: $CF_RESPONSE" + log "purging ${#PURGE_URLS[@]} Cloudflare URL(s) on zone $CLOUDFLARE_ZONE_ID..." + # Cloudflare's purge_cache takes up to 30 URLs per request. Chunk the list. + chunk=() + purge_chunk() { + local files_json + files_json=$(printf '%s\n' "${chunk[@]}" | python3 -c 'import sys, json; print(json.dumps({"files": [l.strip() for l in sys.stdin if l.strip()]}))') + CF_RESPONSE=$(curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \ + -H "Authorization: Bearer ${CLOUDFLARE_PURGE_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$files_json" \ + --max-time 20 || echo '{"success":false,"errors":[{"message":"curl failed"}]}') + if echo "$CF_RESPONSE" | grep -q '"success":true'; then + log " purged ${#chunk[@]} URL(s)" + else + log " WARN: purge failed: $CF_RESPONSE" + fi + } + for url in "${PURGE_URLS[@]}"; do + chunk+=("$url") + if [ ${#chunk[@]} -ge 30 ]; then + purge_chunk + chunk=() + fi + done + if [ ${#chunk[@]} -gt 0 ]; then + purge_chunk fi else - log "skipping CF purge (CLOUDFLARE_PURGE_TOKEN / CLOUDFLARE_ZONE_ID not set)" + log "skipping CF purge of ${#PURGE_URLS[@]} URL(s) — CLOUDFLARE_PURGE_TOKEN / CLOUDFLARE_ZONE_ID not set" + log " set them in aesthetic-computer-vault/lith/.env (uploaded to /opt/ac/system/.env on deploy)" fi fi -- 2.51.2