Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

lith: wire CF cache auto-purge into the deploy pipeline

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/<host>/<rel> 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.

+191 -18
+16 -1
lith/Caddyfile
··· 54 54 file_server 55 55 } 56 56 root * /opt/ac/system/public/papers.aesthetic.computer 57 - try_files {path} {path}.html /index.html 57 + 58 + # SPA fallback for unknown paths: if neither {path} nor {path}.html 59 + # exists on disk, serve /index.html — but mark it no-cache. Without 60 + # this, a request for a not-yet-deployed PDF gets the SPA HTML, and 61 + # Cloudflare pins that HTML to the PDF URL for 4h via its default 62 + # static-asset cache. (See: 2026-04-26 latency-paper deploy gap.) 63 + @missing not file { 64 + try_files {path} {path}.html 65 + } 66 + handle @missing { 67 + header Cache-Control "no-cache, must-revalidate, max-age=0" 68 + rewrite * /index.html 69 + file_server 70 + } 71 + 72 + try_files {path} {path}.html 58 73 file_server 59 74 } 60 75
+119
lith/scripts/cf-purge.fish
··· 1 + #!/usr/bin/env fish 2 + # cf-purge.fish — Purge specific URLs from the Cloudflare edge cache. 3 + # 4 + # Usage: 5 + # fish lith/scripts/cf-purge.fish <url> [<url>...] 6 + # fish lith/scripts/cf-purge.fish --everything # full-zone purge (sledgehammer) 7 + # 8 + # Credentials are read in this order: 9 + # 1. $CLOUDFLARE_PURGE_TOKEN + $CLOUDFLARE_ZONE_ID environment variables 10 + # 2. aesthetic-computer-vault/lith/.env (after GPG-decryption to .env) 11 + # 3. aesthetic-computer-vault/.devcontainer/envs/devcontainer.env (global key fallback) 12 + # 13 + # This script is the workstation-side companion to lith/webhook.sh's 14 + # auto-purge — same API, different trigger. Use it when a build runs 15 + # locally, or to clear a stale negative-cache entry without redeploying. 16 + 17 + set SCRIPT_DIR (dirname (status --current-filename)) 18 + set REPO_ROOT (realpath "$SCRIPT_DIR/../..") 19 + set VAULT_DIR "$REPO_ROOT/aesthetic-computer-vault" 20 + 21 + set CF_TOKEN "" 22 + set CF_ZONE "" 23 + set CF_EMAIL "" 24 + set CF_GLOBAL_KEY "" 25 + 26 + # 1. Environment. 27 + if set -q CLOUDFLARE_PURGE_TOKEN 28 + set CF_TOKEN $CLOUDFLARE_PURGE_TOKEN 29 + end 30 + if set -q CLOUDFLARE_ZONE_ID 31 + set CF_ZONE $CLOUDFLARE_ZONE_ID 32 + end 33 + 34 + # 2. lith vault env (decrypted on demand if needed). 35 + if test -z "$CF_TOKEN"; and test -f "$VAULT_DIR/lith/.env" 36 + set token_line (rg -m1 '^CLOUDFLARE_PURGE_TOKEN=' "$VAULT_DIR/lith/.env" 2>/dev/null) 37 + if test -n "$token_line" 38 + set CF_TOKEN (string replace -r '^CLOUDFLARE_PURGE_TOKEN=' '' -- $token_line) 39 + end 40 + set zone_line (rg -m1 '^CLOUDFLARE_ZONE_ID=' "$VAULT_DIR/lith/.env" 2>/dev/null) 41 + if test -n "$zone_line" 42 + set CF_ZONE (string replace -r '^CLOUDFLARE_ZONE_ID=' '' -- $zone_line) 43 + end 44 + end 45 + 46 + # 3. Global key fallback (last resort). 47 + if test -z "$CF_TOKEN"; and test -f "$VAULT_DIR/.devcontainer/envs/devcontainer.env" 48 + set key_line (rg -m1 '^CLOUDFLARE_API_KEY=' "$VAULT_DIR/.devcontainer/envs/devcontainer.env" 2>/dev/null) 49 + if test -n "$key_line" 50 + set CF_GLOBAL_KEY (string replace -r '^CLOUDFLARE_API_KEY=' '' -- $key_line) 51 + end 52 + set email_line (rg -m1 '^CLOUDFLARE_EMAIL=' "$VAULT_DIR/.devcontainer/envs/devcontainer.env" 2>/dev/null) 53 + if test -n "$email_line" 54 + set CF_EMAIL (string replace -r '^CLOUDFLARE_EMAIL=' '' -- $email_line) 55 + end 56 + end 57 + 58 + # Look up zone ID if missing but we have credentials. 59 + if test -z "$CF_ZONE" 60 + if test -n "$CF_TOKEN" 61 + set CF_ZONE (curl -sS "https://api.cloudflare.com/client/v4/zones?name=aesthetic.computer" \ 62 + -H "Authorization: Bearer $CF_TOKEN" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("result",[{}])[0].get("id",""))') 63 + else if test -n "$CF_GLOBAL_KEY"; and test -n "$CF_EMAIL" 64 + set CF_ZONE (curl -sS "https://api.cloudflare.com/client/v4/zones?name=aesthetic.computer" \ 65 + -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",""))') 66 + end 67 + end 68 + 69 + if test -z "$CF_ZONE" 70 + echo "✗ no zone ID resolvable for aesthetic.computer" 71 + exit 1 72 + end 73 + 74 + if test (count $argv) -eq 0 75 + echo "usage: fish lith/scripts/cf-purge.fish <url> [<url>...]" 76 + echo " fish lith/scripts/cf-purge.fish --everything" 77 + exit 1 78 + end 79 + 80 + # Build curl auth flags. 81 + set AUTH_FLAGS 82 + if test -n "$CF_TOKEN" 83 + set AUTH_FLAGS -H "Authorization: Bearer $CF_TOKEN" 84 + else if test -n "$CF_GLOBAL_KEY"; and test -n "$CF_EMAIL" 85 + set AUTH_FLAGS -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL_KEY" 86 + else 87 + echo "✗ no Cloudflare credentials found" 88 + echo " expected one of:" 89 + echo " \$CLOUDFLARE_PURGE_TOKEN (preferred — scoped Zone.Cache Purge token)" 90 + echo " aesthetic-computer-vault/lith/.env: CLOUDFLARE_PURGE_TOKEN=..." 91 + echo " aesthetic-computer-vault/.devcontainer/envs/devcontainer.env: CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL" 92 + exit 1 93 + end 94 + 95 + # Build payload. 96 + if test "$argv[1]" = "--everything" 97 + set PAYLOAD '{"purge_everything":true}' 98 + echo "→ purging EVERYTHING on zone $CF_ZONE" 99 + else 100 + 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()]}))') 101 + echo "→ purging "(count $argv)" URL(s) on zone $CF_ZONE" 102 + for url in $argv 103 + echo " $url" 104 + end 105 + end 106 + 107 + set CF_RESPONSE (curl -sS -X POST \ 108 + "https://api.cloudflare.com/client/v4/zones/$CF_ZONE/purge_cache" \ 109 + $AUTH_FLAGS \ 110 + -H "Content-Type: application/json" \ 111 + --data "$PAYLOAD" \ 112 + --max-time 20) 113 + 114 + if echo "$CF_RESPONSE" | grep -q '"success":true' 115 + echo "✓ purged" 116 + else 117 + echo "✗ purge failed: $CF_RESPONSE" 118 + exit 1 119 + end
+56 -17
lith/webhook.sh
··· 63 63 NEED_CADDY_RELOAD=false 64 64 NEED_NPM_INSTALL=false 65 65 NEED_DP1_FEED_RESTART=false 66 - NEED_CF_PURGE=false 66 + PURGE_URLS=() 67 + 68 + # Map a system/public/<host>/<rel> path to the public URL(s) it serves. 69 + # papers.aesthetic.computer is also reachable as papers.prompt.ac, so emit both. 70 + emit_urls_for() { 71 + local file="$1" 72 + case "$file" in 73 + system/public/papers.aesthetic.computer/*) 74 + local rel="${file#system/public/papers.aesthetic.computer/}" 75 + PURGE_URLS+=("https://papers.aesthetic.computer/${rel}") 76 + PURGE_URLS+=("https://papers.prompt.ac/${rel}") 77 + ;; 78 + system/public/aesthetic.computer/*) 79 + local rel="${file#system/public/aesthetic.computer/}" 80 + PURGE_URLS+=("https://aesthetic.computer/${rel}") 81 + ;; 82 + system/public/*) 83 + # Other subdomains — strip the host segment and emit one URL. 84 + local stripped="${file#system/public/}" 85 + local host="${stripped%%/*}" 86 + local rel="${stripped#*/}" 87 + PURGE_URLS+=("https://${host}/${rel}") 88 + ;; 89 + esac 90 + } 67 91 68 92 while IFS= read -r file; do 69 93 case "$file" in ··· 91 115 NEED_DP1_FEED_RESTART=true 92 116 ;; 93 117 system/public/*) 94 - # Static files — Caddy serves directly from disk, but Cloudflare 95 - # caches them at the edge for up to an hour, so we need to purge. 96 - NEED_CF_PURGE=true 118 + emit_urls_for "$file" 97 119 ;; 98 120 *) 99 121 # Other files (docs, tests, etc.) — no action needed ··· 129 151 log "static-only deploy — no restart needed" 130 152 fi 131 153 132 - if $NEED_CF_PURGE; then 154 + if [ ${#PURGE_URLS[@]} -gt 0 ]; then 133 155 if [ -n "${CLOUDFLARE_PURGE_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ZONE_ID:-}" ]; then 134 - log "purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..." 135 - CF_RESPONSE=$(curl -sS -X POST \ 136 - "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \ 137 - -H "Authorization: Bearer ${CLOUDFLARE_PURGE_TOKEN}" \ 138 - -H "Content-Type: application/json" \ 139 - --data '{"purge_everything":true}' \ 140 - --max-time 20 || echo '{"success":false,"errors":[{"message":"curl failed"}]}') 141 - if echo "$CF_RESPONSE" | grep -q '"success":true'; then 142 - log "Cloudflare cache purged" 143 - else 144 - log "WARN: Cloudflare purge failed: $CF_RESPONSE" 156 + log "purging ${#PURGE_URLS[@]} Cloudflare URL(s) on zone $CLOUDFLARE_ZONE_ID..." 157 + # Cloudflare's purge_cache takes up to 30 URLs per request. Chunk the list. 158 + chunk=() 159 + purge_chunk() { 160 + local files_json 161 + 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()]}))') 162 + CF_RESPONSE=$(curl -sS -X POST \ 163 + "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \ 164 + -H "Authorization: Bearer ${CLOUDFLARE_PURGE_TOKEN}" \ 165 + -H "Content-Type: application/json" \ 166 + --data "$files_json" \ 167 + --max-time 20 || echo '{"success":false,"errors":[{"message":"curl failed"}]}') 168 + if echo "$CF_RESPONSE" | grep -q '"success":true'; then 169 + log " purged ${#chunk[@]} URL(s)" 170 + else 171 + log " WARN: purge failed: $CF_RESPONSE" 172 + fi 173 + } 174 + for url in "${PURGE_URLS[@]}"; do 175 + chunk+=("$url") 176 + if [ ${#chunk[@]} -ge 30 ]; then 177 + purge_chunk 178 + chunk=() 179 + fi 180 + done 181 + if [ ${#chunk[@]} -gt 0 ]; then 182 + purge_chunk 145 183 fi 146 184 else 147 - log "skipping CF purge (CLOUDFLARE_PURGE_TOKEN / CLOUDFLARE_ZONE_ID not set)" 185 + log "skipping CF purge of ${#PURGE_URLS[@]} URL(s) — CLOUDFLARE_PURGE_TOKEN / CLOUDFLARE_ZONE_ID not set" 186 + log " set them in aesthetic-computer-vault/lith/.env (uploaded to /opt/ac/system/.env on deploy)" 148 187 fi 149 188 fi 150 189