Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

feat: add lith — self-hosted monolith replacing Netlify

Migrates AC frontend + API from Netlify to a DO droplet (ac-lith)
running Caddy + Express. All 122 functions load via an adapter that
shims awslambda and translates Netlify handler signatures.

- lith/server.mjs: Express adapter with awslambda shim, route aliases,
v2 function support, ReadableStream piping for SSE
- lith/Caddyfile: full subdomain routing for 8 zones / 36 domains
- lith/scripts: droplet provisioning and Cloudflare DNS cutover
- Function fixes: he ESM import, OpenAI v4 SDK, pixel require removal,
news/sotce path prefix stripping
- ac-lith fish command for local dev testing

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

+2243 -15
+21
.devcontainer/config.fish
··· 1936 1936 npm run local-dev 1937 1937 end 1938 1938 1939 + function ac-lith 1940 + cd ~/aesthetic-computer/lith 1941 + echo "🪨 Starting lith (monolith server)..." 1942 + echo "🔍 Cleaning up any stuck processes..." 1943 + pkill -f "lith/server.mjs" 2>/dev/null 1944 + sleep 1 1945 + echo "🔌 Killing port 8888..." 1946 + timeout 5 npx kill-port 8888 2>/dev/null; or true 1947 + # Load env vars from system/.env if present 1948 + if test -f ~/aesthetic-computer/system/.env 1949 + for line in (cat ~/aesthetic-computer/system/.env | grep -v '^#' | grep -v '^$' | grep '=') 1950 + set -l parts (string split -m1 '=' $line) 1951 + if test (count $parts) -ge 2 1952 + set -gx $parts[1] $parts[2] 1953 + end 1954 + end 1955 + end 1956 + echo "🚀 Starting server on https://localhost:8888..." 1957 + node --watch server.mjs 1958 + end 1959 + 1939 1960 function ac-media 1940 1961 cd ~/aesthetic-computer/system 1941 1962 echo "📦 Starting Caddy media server on :8111..."
+309
lith/Caddyfile
··· 1 + # lith production Caddyfile — full subdomain routing 2 + # Cloudflare handles TLS. Caddy serves HTTP on :80. 3 + 4 + # --- papers.aesthetic.computer --- 5 + :80 { 6 + @papers host papers.aesthetic.computer papers.prompt.ac 7 + handle @papers { 8 + handle /en { 9 + rewrite * /index.html?lang=en 10 + root * /opt/ac/system/public/papers.aesthetic.computer 11 + file_server 12 + } 13 + handle /da { 14 + rewrite * /index.html?lang=da 15 + root * /opt/ac/system/public/papers.aesthetic.computer 16 + file_server 17 + } 18 + handle /es { 19 + rewrite * /index.html?lang=es 20 + root * /opt/ac/system/public/papers.aesthetic.computer 21 + file_server 22 + } 23 + handle /cn { 24 + rewrite * /index.html?lang=zh 25 + root * /opt/ac/system/public/papers.aesthetic.computer 26 + file_server 27 + } 28 + root * /opt/ac/system/public/papers.aesthetic.computer 29 + try_files {path} {path}.html /index.html 30 + file_server 31 + } 32 + 33 + # --- bills.aesthetic.computer --- 34 + @bills host bills.aesthetic.computer 35 + handle @bills { 36 + root * /opt/ac/system/public/bills.aesthetic.computer 37 + try_files {path} {path}.html /index.html 38 + file_server 39 + } 40 + 41 + # --- give.aesthetic.computer --- 42 + @give host give.aesthetic.computer 43 + handle @give { 44 + handle /api/* { 45 + reverse_proxy localhost:8888 46 + } 47 + handle /da { 48 + rewrite * /index.html?lang=da&currency=dkk 49 + root * /opt/ac/system/public/give.aesthetic.computer 50 + file_server 51 + } 52 + handle /es { 53 + rewrite * /index.html?lang=es&currency=usd 54 + root * /opt/ac/system/public/give.aesthetic.computer 55 + file_server 56 + } 57 + handle /de { 58 + rewrite * /index.html?lang=de&currency=eur 59 + root * /opt/ac/system/public/give.aesthetic.computer 60 + file_server 61 + } 62 + handle /cn { 63 + rewrite * /index.html?lang=zh&currency=usd 64 + root * /opt/ac/system/public/give.aesthetic.computer 65 + file_server 66 + } 67 + root * /opt/ac/system/public/give.aesthetic.computer 68 + try_files {path} {path}.html /index.html 69 + file_server 70 + } 71 + 72 + # --- news.aesthetic.computer --- 73 + @news host news.aesthetic.computer 74 + handle @news { 75 + handle /api/* { 76 + reverse_proxy localhost:8888 77 + } 78 + handle { 79 + rewrite * /api/news{uri} 80 + reverse_proxy localhost:8888 81 + } 82 + } 83 + 84 + # --- api.aesthetic.computer --- 85 + @apidomain host api.aesthetic.computer api.prompt.ac 86 + handle @apidomain { 87 + reverse_proxy localhost:8888 88 + } 89 + 90 + # --- justanothersystem.org --- 91 + @jas host justanothersystem.org www.justanothersystem.org 92 + handle @jas { 93 + handle /api/* { 94 + reverse_proxy localhost:8888 95 + } 96 + root * /opt/ac/system/public/justanothersystem.org 97 + try_files {path} {path}.html /index.html 98 + file_server 99 + } 100 + 101 + # --- builds.false.work --- 102 + @builds host builds.false.work 103 + handle @builds { 104 + handle /api/* { 105 + reverse_proxy localhost:8888 106 + } 107 + root * /opt/ac/system/public/builds.false.work 108 + try_files {path} {path}.html /index.html 109 + file_server 110 + } 111 + 112 + # --- sotce.net --- 113 + @sotce host sotce.net www.sotce.net 114 + handle @sotce { 115 + handle /api/* { 116 + reverse_proxy localhost:8888 117 + } 118 + handle /user { 119 + reverse_proxy localhost:8888 120 + } 121 + handle /handle { 122 + reverse_proxy localhost:8888 123 + } 124 + handle /authorized { 125 + reverse_proxy localhost:8888 126 + } 127 + handle /aesthetic.computer/* { 128 + uri strip_prefix /aesthetic.computer 129 + root * /opt/ac/system/public/aesthetic.computer 130 + file_server 131 + } 132 + # Everything else → sotce-net function 133 + handle { 134 + rewrite * /api/sotce-net{uri} 135 + reverse_proxy localhost:8888 136 + } 137 + } 138 + 139 + # --- kidlisp.com subdomains --- 140 + @keep host keep.kidlisp.com 141 + handle @keep { 142 + handle /api/* { 143 + reverse_proxy localhost:8888 144 + } 145 + handle /technology { 146 + root * /opt/ac/system/public/kidlisp.com 147 + rewrite * /keeps-tech.html 148 + file_server 149 + } 150 + handle { 151 + root * /opt/ac/system/public/kidlisp.com 152 + rewrite * /keeps.html 153 + file_server 154 + } 155 + } 156 + 157 + @buy host buy.kidlisp.com 158 + handle @buy { 159 + root * /opt/ac/system/public/kidlisp.com 160 + rewrite * /buy.html 161 + file_server 162 + } 163 + 164 + @pj host pj.kidlisp.com 165 + handle @pj { 166 + root * /opt/ac/system/public/kidlisp.com 167 + rewrite * /pj.html 168 + file_server 169 + } 170 + 171 + @device host device.kidlisp.com 172 + handle @device { 173 + handle /api/* { 174 + reverse_proxy localhost:8888 175 + } 176 + handle /js/* { 177 + root * /opt/ac/system/public/kidlisp.com 178 + file_server 179 + } 180 + handle /qr/* { 181 + root * /opt/ac/system/public/kidlisp.com 182 + rewrite * /qr.html 183 + file_server 184 + } 185 + handle { 186 + root * /opt/ac/system/public/kidlisp.com 187 + rewrite * /device.html 188 + file_server 189 + } 190 + } 191 + 192 + @topcalm host top.kidlisp.com calm.kidlisp.com 193 + handle @topcalm { 194 + handle /js/* { 195 + root * /opt/ac/system/public/kidlisp.com 196 + file_server 197 + } 198 + reverse_proxy localhost:8888 199 + } 200 + 201 + @learn host learn.kidlisp.com 202 + handle @learn { 203 + handle /api/* { 204 + reverse_proxy localhost:8888 205 + } 206 + root * /opt/ac/system/public/kidlisp.com 207 + rewrite * /learn.html 208 + file_server 209 + } 210 + 211 + @keepsredirect host keeps.kidlisp.com 212 + handle @keepsredirect { 213 + redir https://keep.kidlisp.com{uri} 301 214 + } 215 + 216 + @keepsac host keeps.aesthetic.computer 217 + handle @keepsac { 218 + reverse_proxy localhost:8888 219 + } 220 + 221 + # --- jas.life --- 222 + @jaslife host jas.life 223 + handle @jaslife { 224 + root * /opt/ac/system/public/jas.life 225 + try_files {path} {path}.html /index.html 226 + file_server 227 + } 228 + 229 + # --- www.jas.life redirect --- 230 + @wwwjas host www.jas.life 231 + handle @wwwjas { 232 + redir https://jas.life{uri} 301 233 + } 234 + 235 + # --- pals.aesthetic.computer --- 236 + # pals.aesthetic.computer → /api/logo (dynamic logo generation) 237 + @pals host pals.aesthetic.computer 238 + handle @pals { 239 + rewrite * /api/logo{uri} 240 + reverse_proxy localhost:8888 241 + } 242 + 243 + # --- prompt.ac aliases → aesthetic.computer --- 244 + @promptaliases host l5.prompt.ac p5.prompt.ac processing.prompt.ac sitemap.prompt.ac 245 + handle @promptaliases { 246 + reverse_proxy localhost:8888 247 + } 248 + 249 + # --- notepat.com, kidlisp.com root, prompt.ac, www variants --- 250 + # These all serve the main AC SPA 251 + @mainspa host aesthetic.computer www.aesthetic.computer prompt.ac kidlisp.com www.kidlisp.com notepat.com www.notepat.com l5.aesthetic.computer p5.aesthetic.computer processing.aesthetic.computer sitemap.aesthetic.computer 252 + handle @mainspa { 253 + # Assets → DO Spaces CDN 254 + handle /assets/* { 255 + redir https://assets.aesthetic.computer{uri} 302 256 + } 257 + # API → lith 258 + handle /api/* { 259 + reverse_proxy localhost:8888 260 + } 261 + handle /.netlify/functions/* { 262 + reverse_proxy localhost:8888 263 + } 264 + # Media → lith 265 + handle /media/* { 266 + reverse_proxy localhost:8888 267 + } 268 + # Static rewrite shortcuts 269 + handle /disks/* { 270 + uri strip_prefix /disks 271 + root * /opt/ac/system/public/aesthetic.computer/disks 272 + file_server 273 + } 274 + handle /lib/* { 275 + uri strip_prefix /lib 276 + root * /opt/ac/system/public/aesthetic.computer/lib 277 + file_server 278 + } 279 + # Static files, then SPA fallback 280 + handle { 281 + root * /opt/ac/system/public 282 + @static file 283 + handle @static { 284 + file_server 285 + } 286 + handle { 287 + reverse_proxy localhost:8888 288 + } 289 + } 290 + } 291 + 292 + # --- false.work --- 293 + @falseroot host false.work 294 + handle @falseroot { 295 + root * /opt/ac/system/public/false.work 296 + try_files {path} {path}.html /index.html 297 + file_server 298 + } 299 + 300 + @wwwfalse host www.false.work 301 + handle @wwwfalse { 302 + redir https://false.work{uri} 301 303 + } 304 + 305 + # --- Fallback for any unmatched host --- 306 + handle { 307 + reverse_proxy localhost:8888 308 + } 309 + }
+135
lith/DNS.md
··· 1 + # Cloudflare DNS Records — Migration Reference 2 + # Exported 2026-03-28 3 + 4 + ## Records Pointing to Netlify (aesthetic-computer.netlify.app or 75.2.60.5) 5 + 6 + These are what need to change when migrating to the new DO droplet. 7 + 8 + ### aesthetic.computer (zone: da794a6ae8f17b80424907f81ed0db7c) 9 + | Type | Name | Target | Proxy | 10 + |-------|-----------------------------------|-------------------------------------|-------| 11 + | A | aesthetic.computer | 75.2.60.5 | proxied | 12 + | CNAME | api.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 13 + | CNAME | bills.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 14 + | CNAME | give.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 15 + | CNAME | keeps.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 16 + | CNAME | l5.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 17 + | CNAME | news.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 18 + | CNAME | p5.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 19 + | CNAME | pals.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 20 + | CNAME | papers.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 21 + | CNAME | processing.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 22 + | CNAME | sitemap.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 23 + | CNAME | www.aesthetic.computer | aesthetic-computer.netlify.app | proxied | 24 + 25 + ### false.work (zone: 0fa28e0097b24e187f41fea0ec036c0d) 26 + | Type | Name | Target | Proxy | 27 + |-------|-----------------------------------|-------------------------------------|-------| 28 + | CNAME | builds.false.work | aesthetic-computer.netlify.app | proxied | 29 + 30 + ### justanothersystem.org (zone: a3366b124c7ca95fe902a54f868dcc51) 31 + | Type | Name | Target | Proxy | 32 + |-------|-----------------------------------|-------------------------------------|-------| 33 + | A | justanothersystem.org | 75.2.60.5 | DNS-only | 34 + | CNAME | www.justanothersystem.org | aesthetic-computer.netlify.app | DNS-only | 35 + 36 + ### kidlisp.com (zone: bac7b811ac7b4df664b696fafa9e6207) 37 + | Type | Name | Target | Proxy | 38 + |-------|-----------------------------------|-------------------------------------|-------| 39 + | A | kidlisp.com | 75.2.60.5 | proxied | 40 + | A | www.kidlisp.com | 75.2.60.5 | proxied | 41 + | CNAME | buy.kidlisp.com | aesthetic-computer.netlify.app | proxied | 42 + | CNAME | calm.kidlisp.com | aesthetic-computer.netlify.app | proxied | 43 + | CNAME | device.kidlisp.com | aesthetic-computer.netlify.app | proxied | 44 + | CNAME | keep.kidlisp.com | aesthetic-computer.netlify.app | proxied | 45 + | CNAME | keeps.kidlisp.com | aesthetic-computer.netlify.app | proxied | 46 + | CNAME | learn.kidlisp.com | aesthetic-computer.netlify.app | proxied | 47 + | CNAME | pj.kidlisp.com | aesthetic-computer.netlify.app | proxied | 48 + | CNAME | top.kidlisp.com | aesthetic-computer.netlify.app | proxied | 49 + 50 + ### notepat.com (zone: 8d289a1e56563dbcc9bc88747428c8ee) 51 + | Type | Name | Target | Proxy | 52 + |-------|-----------------------------------|-------------------------------------|-------| 53 + | A | notepat.com | 75.2.60.5 | proxied | 54 + | CNAME | www.notepat.com | aesthetic-computer.netlify.app | proxied | 55 + 56 + ### prompt.ac (zone: 1f93ca86e2d9de0def0acb0b8c4e722b) 57 + | Type | Name | Target | Proxy | 58 + |-------|-----------------------------------|-------------------------------------|-------| 59 + | A | prompt.ac | 75.2.60.5 | proxied | 60 + | CNAME | api.prompt.ac | aesthetic-computer.netlify.app | proxied | 61 + | CNAME | l5.prompt.ac | aesthetic-computer.netlify.app | proxied | 62 + | CNAME | p5.prompt.ac | aesthetic-computer.netlify.app | proxied | 63 + | CNAME | papers.prompt.ac | aesthetic-computer.netlify.app | proxied | 64 + | CNAME | processing.prompt.ac | aesthetic-computer.netlify.app | proxied | 65 + | CNAME | sitemap.prompt.ac | aesthetic-computer.netlify.app | proxied | 66 + 67 + ### sotce.net (zone: 1f56f8b5fd7b3db92d31bad0714a518f) 68 + | Type | Name | Target | Proxy | 69 + |-------|-----------------------------------|-------------------------------------|-------| 70 + | A | sotce.net | 75.2.60.5 | proxied | 71 + | A | www.sotce.net | 75.2.60.5 | proxied | 72 + 73 + --- 74 + 75 + ## Records NOT Pointing to Netlify (keep as-is) 76 + 77 + ### aesthetic.computer — DigitalOcean droplets 78 + - A at.aesthetic.computer → 165.227.120.137 (PDS) 79 + - A *.at.aesthetic.computer → 165.227.120.137 (PDS wildcard) 80 + - A chat-clock.aesthetic.computer → 157.245.134.225 (session server) 81 + - A chat-system.aesthetic.computer → 157.245.134.225 (session server) 82 + - A feed.aesthetic.computer → 64.23.151.169 (silo) 83 + - A help.aesthetic.computer → 146.190.150.173 (help) 84 + - A judge.aesthetic.computer → 64.227.102.108 (judge) 85 + - A oven-origin.aesthetic.computer → 137.184.237.166 (oven) 86 + - A oven.aesthetic.computer → 137.184.237.166 (oven) 87 + - A session-server.aesthetic.computer → 157.245.134.225 (session) 88 + - A silo.aesthetic.computer → 64.23.151.169 (silo) 89 + - A udp.aesthetic.computer → 157.245.134.225 (session) 90 + 91 + ### aesthetic.computer — DO Spaces CDN 92 + - CNAME art.aesthetic.computer → art-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com 93 + - CNAME assets.aesthetic.computer → assets-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com 94 + - CNAME at-blobs.aesthetic.computer → at-blobs-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com 95 + - CNAME logo.aesthetic.computer → logo.aesthetic.computer.nyc3.cdn.digitaloceanspaces.com 96 + - CNAME music.aesthetic.computer → music.aesthetic.computer.fra1.cdn.digitaloceanspaces.com 97 + - CNAME private.aesthetic.computer → private-aesthetic-computer.sfo3.digitaloceanspaces.com 98 + - CNAME releases.aesthetic.computer → releases-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com 99 + - CNAME sotce-media.aesthetic.computer → sotce-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com 100 + - CNAME user.aesthetic.computer → user-aesthetic-computer.sfo3.digitaloceanspaces.com 101 + - CNAME wand.aesthetic.computer → wand-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com 102 + 103 + ### aesthetic.computer — Third-party services 104 + - CNAME ai.aesthetic.computer → cname.vercel-dns.com (Vercel) 105 + - CNAME gucci.aesthetic.computer → glitch.edgeapp.net (Glitch) 106 + - CNAME hi.aesthetic.computer → Auth0 tenant 107 + - CNAME ipfs.aesthetic.computer → Pinata IPFS 108 + - CNAME pay.aesthetic.computer → Stripe hosted checkout 109 + - CNAME shop.aesthetic.computer → Shopify 110 + - CNAME duckweedtri.aesthetic.computer → ida-surface.netlify.app (separate Netlify site) 111 + 112 + ### aesthetic.computer — Cloudflare Workers 113 + - AAAA grab.aesthetic.computer → 100:: (Cloudflare Worker) 114 + - AAAA os.aesthetic.computer → 100:: (Cloudflare Worker) 115 + 116 + ### aesthetic.computer — Ngrok tunnels (local dev) 117 + - CNAME local.aesthetic.computer → ngrok 118 + - CNAME chat-sotce.local.aesthetic.computer → ngrok 119 + - CNAME chat-system.local.aesthetic.computer → ngrok 120 + - CNAME session.local.aesthetic.computer → ngrok 121 + 122 + ### Stripe DKIM + bounce records (keep as-is) 123 + - 6x CNAME _domainkey records 124 + - CNAME bounce.aesthetic.computer → custom-email-domain.stripe.com 125 + 126 + ### jas.life — separate site (Vercel + old droplet) 127 + - A jas.life → 75.2.60.5 (**NETLIFY — NEEDS UPDATING to new droplet**) 128 + - A www.jas.life → 76.76.21.21 (Vercel) 129 + - A *.jas.life → 76.76.21.21 (Vercel wildcard) 130 + - Various A records → 162.243.163.221 (old DO droplet for archived sites) 131 + 132 + ### sotce.net — non-Netlify 133 + - A chat.sotce.net → 157.245.134.225 (session server) 134 + - CNAME bounce.sotce.net → Stripe 135 + - CNAME hi.sotce.net → Auth0 tenant
+298
lith/PLAN.md
··· 1 + # Netlify → DigitalOcean Migration Plan 2 + # 2026-03-28 3 + 4 + ## Overview 5 + 6 + Migrate aesthetic.computer from Netlify to a new DigitalOcean droplet. 7 + Session server stays on its own droplet (157.245.134.225). 8 + 9 + ## Existing DO Droplets 10 + 11 + | Droplet | IP | Purpose | 12 + |--------------------------|-----------------|----------------------------| 13 + | session-server | 157.245.134.225 | WebSocket + UDP sessions | 14 + | silo | 64.23.151.169 | MongoDB, feed | 15 + | oven | 137.184.237.166 | Image/code processing | 16 + | help | 146.190.150.173 | Help service | 17 + | judge | 64.227.102.108 | AI moderation | 18 + | pds | 165.227.120.137 | AT Protocol PDS (NYC3) | 19 + | **NEW: ac-web** | **TBD** | **Frontend + API server** | 20 + 21 + ## Phase 1: Provision Droplet 22 + 23 + ```bash 24 + doctl compute droplet create ac-web \ 25 + --region sfo3 \ 26 + --size s-2vcpu-4gb \ 27 + --image ubuntu-24-04-x64 \ 28 + --ssh-keys <your-key-id> \ 29 + --tag-names ac,web 30 + ``` 31 + 32 + Why s-2vcpu-4gb ($24/mo): 33 + - 125 Node.js API functions (some do Sharp/image processing) 34 + - Static file serving 35 + - Plenty of headroom vs Netlify's function limits 36 + 37 + ## Phase 2: Install Stack on Droplet 38 + 39 + ```bash 40 + # Caddy (automatic HTTPS, reverse proxy) 41 + apt install -y caddy 42 + 43 + # Node.js 22 LTS 44 + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - 45 + apt install -y nodejs 46 + 47 + # Sharp dependencies 48 + apt install -y libvips-dev 49 + 50 + # Chromium for puppeteer (screenshot function) 51 + apt install -y chromium-browser 52 + 53 + # PM2 for process management 54 + npm install -g pm2 55 + ``` 56 + 57 + ## Phase 3: Deploy Code 58 + 59 + ```bash 60 + # Clone repo 61 + git clone https://github.com/user/aesthetic-computer.git /opt/ac 62 + 63 + # Install deps 64 + cd /opt/ac/system && npm install 65 + 66 + # Copy env vars 67 + cp vault/netlify-production.env /opt/ac/system/.env 68 + # Edit .env: remove NETLIFY_DEV, add NODE_ENV=production 69 + 70 + # Start API server 71 + pm2 start /opt/ac/system/server.mjs --name ac-api 72 + 73 + # Or use systemd unit (see below) 74 + ``` 75 + 76 + ## Phase 4: Caddy Config 77 + 78 + ``` 79 + # /etc/caddy/Caddyfile 80 + 81 + # Main site — handles all Netlify subdomain routing 82 + aesthetic.computer, www.aesthetic.computer, 83 + api.aesthetic.computer, 84 + bills.aesthetic.computer, give.aesthetic.computer, 85 + keeps.aesthetic.computer, news.aesthetic.computer, 86 + papers.aesthetic.computer, pals.aesthetic.computer, 87 + l5.aesthetic.computer, p5.aesthetic.computer, 88 + processing.aesthetic.computer, sitemap.aesthetic.computer, 89 + kidlisp.com, www.kidlisp.com, 90 + buy.kidlisp.com, calm.kidlisp.com, device.kidlisp.com, 91 + keep.kidlisp.com, keeps.kidlisp.com, learn.kidlisp.com, 92 + pj.kidlisp.com, top.kidlisp.com, 93 + notepat.com, www.notepat.com, 94 + prompt.ac, api.prompt.ac, l5.prompt.ac, p5.prompt.ac, 95 + papers.prompt.ac, processing.prompt.ac, sitemap.prompt.ac, 96 + sotce.net, www.sotce.net, 97 + justanothersystem.org, www.justanothersystem.org, 98 + builds.false.work { 99 + 100 + # Static files 101 + root * /opt/ac/system/public 102 + file_server 103 + 104 + # API functions → Node.js 105 + handle /api/* { 106 + reverse_proxy localhost:3000 107 + } 108 + handle /.netlify/functions/* { 109 + reverse_proxy localhost:3000 110 + } 111 + 112 + # Media proxy → DO Spaces 113 + handle /media/* { 114 + reverse_proxy localhost:3000 115 + } 116 + 117 + # Assets → DO Spaces CDN 118 + handle /assets/* { 119 + redir https://assets.aesthetic.computer{uri} 302 120 + } 121 + 122 + # SPA fallback (index.mjs function) 123 + handle { 124 + try_files {path} {path}/ /api/index 125 + } 126 + } 127 + ``` 128 + 129 + Note: With Cloudflare proxied (orange cloud), Caddy won't need to handle 130 + TLS itself. Use `http://` or set Cloudflare SSL to "Full" mode and let 131 + Caddy use its auto-HTTPS with Cloudflare's origin certificate. 132 + 133 + ## Phase 5: Express Adapter (server.mjs) 134 + 135 + Create a thin Express server that wraps existing Netlify function handlers: 136 + 137 + ```javascript 138 + // system/server.mjs 139 + import express from 'express'; 140 + import { readdirSync } from 'fs'; 141 + 142 + const app = express(); 143 + app.use(express.json()); 144 + app.use(express.raw({ type: '*/*', limit: '50mb' })); 145 + 146 + // Load all functions 147 + const fnDir = new URL('./netlify/functions/', import.meta.url); 148 + const functions = {}; 149 + 150 + for (const file of readdirSync(fnDir)) { 151 + if (file.endsWith('.mjs') || file.endsWith('.js')) { 152 + const name = file.replace(/\.(mjs|js)$/, ''); 153 + functions[name] = await import(`./netlify/functions/${file}`); 154 + } 155 + } 156 + 157 + // Netlify event adapter 158 + function toNetlifyEvent(req) { 159 + return { 160 + httpMethod: req.method, 161 + headers: req.headers, 162 + body: typeof req.body === 'string' ? req.body : JSON.stringify(req.body), 163 + rawBody: req.body, 164 + queryStringParameters: req.query, 165 + path: req.path, 166 + isBase64Encoded: false, 167 + }; 168 + } 169 + 170 + // Route: /api/:function or /.netlify/functions/:function 171 + app.all(['/api/:fn', '/.netlify/functions/:fn'], async (req, res) => { 172 + const fn = functions[req.params.fn]; 173 + if (!fn?.handler) return res.status(404).send('Function not found'); 174 + 175 + try { 176 + const event = toNetlifyEvent(req); 177 + const context = { clientContext: {} }; 178 + const result = await fn.handler(event, context); 179 + 180 + // Handle streaming (ask.js SSE) 181 + if (result.body && typeof result.body === 'object' && result.body.pipe) { 182 + result.body.pipe(res); 183 + return; 184 + } 185 + 186 + res.status(result.statusCode || 200); 187 + if (result.headers) res.set(result.headers); 188 + if (result.isBase64Encoded) { 189 + res.send(Buffer.from(result.body, 'base64')); 190 + } else { 191 + res.send(result.body); 192 + } 193 + } catch (err) { 194 + console.error(`Function ${req.params.fn} error:`, err); 195 + res.status(500).send('Internal Server Error'); 196 + } 197 + }); 198 + 199 + app.listen(3000, () => console.log('AC API on :3000')); 200 + ``` 201 + 202 + ## Phase 6: DNS Cutover 203 + 204 + Update all Netlify-pointing records to the new droplet IP. 205 + See vault/cloudflare-dns-records.md for the complete list. 206 + 207 + ### Script to update all at once: 208 + 209 + ```bash 210 + #!/bin/bash 211 + NEW_IP="<DROPLET_IP>" 212 + CF_EMAIL="me@jas.life" 213 + CF_KEY="<cloudflare-api-key>" 214 + 215 + # aesthetic.computer zone 216 + ZONE="da794a6ae8f17b80424907f81ed0db7c" 217 + 218 + # Update root A record 219 + curl -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records/<record-id>" \ 220 + -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" \ 221 + -H "Content-Type: application/json" \ 222 + --data "{\"content\":\"$NEW_IP\"}" 223 + 224 + # Update CNAMEs to A records pointing at new IP 225 + # api.aesthetic.computer, bills, give, keeps, news, etc. 226 + # Each CNAME → aesthetic-computer.netlify.app becomes A → $NEW_IP 227 + 228 + # Repeat for: kidlisp.com, notepat.com, prompt.ac, sotce.net, etc. 229 + ``` 230 + 231 + ### Records to Update (35 total): 232 + 233 + **aesthetic.computer** (13 records): 234 + - A aesthetic.computer → NEW_IP 235 + - CNAME→A api, bills, give, keeps, l5, news, p5, pals, papers, processing, sitemap, www 236 + 237 + **false.work** (1 record): 238 + - CNAME→A builds.false.work 239 + 240 + **justanothersystem.org** (2 records): 241 + - A justanothersystem.org → NEW_IP 242 + - CNAME→A www.justanothersystem.org 243 + 244 + **kidlisp.com** (10 records): 245 + - A kidlisp.com, www.kidlisp.com → NEW_IP 246 + - CNAME→A buy, calm, device, keep, keeps, learn, pj, top 247 + 248 + **notepat.com** (2 records): 249 + - A notepat.com → NEW_IP 250 + - CNAME→A www.notepat.com 251 + 252 + **prompt.ac** (7 records): 253 + - A prompt.ac → NEW_IP 254 + - CNAME→A api, l5, p5, papers, processing, sitemap 255 + 256 + **sotce.net** (2 records): 257 + - A sotce.net, www.sotce.net → NEW_IP 258 + 259 + **jas.life** (1 record — also points to Netlify): 260 + - A jas.life → NEW_IP (currently 75.2.60.5) 261 + 262 + **Total: 36 records across 8 zones** 263 + 264 + ## Phase 7: Stripe Webhook URLs 265 + 266 + Update webhook endpoints in Stripe dashboard: 267 + - aesthetic.computer Stripe account: `https://aesthetic.computer/api/print` (and /api/mug, /api/ticket, /api/give) 268 + - sotce Stripe account: `https://sotce.net/api/ticket` 269 + 270 + These should work as-is if DNS resolves to the new server correctly. 271 + 272 + ## Phase 8: Verify & Monitor 273 + 274 + 1. Test each API endpoint 275 + 2. Test Stripe webhooks (use Stripe CLI to send test events) 276 + 3. Test social previews (keeps-social edge function → Express middleware) 277 + 4. Test media proxy (media edge function → Express route) 278 + 5. Monitor PM2 logs: `pm2 logs ac-api` 279 + 6. Check Cloudflare analytics for error rates 280 + 281 + ## Rollback 282 + 283 + If anything breaks, repoint DNS back to aesthetic-computer.netlify.app. 284 + Netlify site stays intact as long as the account exists (even unpaid, 285 + the site config persists — you just can't deploy). 286 + 287 + ## Cost Comparison 288 + 289 + | Item | Netlify | DO Droplet | 290 + |---------------|------------|-------------| 291 + | Hosting | $19+/mo | $24/mo | 292 + | Functions | included | included | 293 + | Bandwidth | 100GB free | 4TB free | 294 + | SSL | included | Cloudflare | 295 + | CDN edge | included | Cloudflare | 296 + | Build minutes | 300/mo | instant | 297 + 298 + Net: similar base cost but **much** more bandwidth and no function invocation limits.
+59
lith/deploy.fish
··· 1 + #!/usr/bin/env fish 2 + # lith Deployment Script 3 + # Deploys the AC monolith (frontend + API) to DigitalOcean droplet 4 + 5 + set RED '\033[0;31m' 6 + set GREEN '\033[0;32m' 7 + set YELLOW '\033[1;33m' 8 + set NC '\033[0m' 9 + 10 + set SCRIPT_DIR (dirname (status --current-filename)) 11 + set VAULT_DIR "$SCRIPT_DIR/../aesthetic-computer-vault" 12 + set SSH_KEY "$VAULT_DIR/home/.ssh/id_rsa" 13 + set SERVICE_ENV "$VAULT_DIR/lith/.env" 14 + set LITH_HOST "lith.aesthetic.computer" 15 + set LITH_USER "root" 16 + set REMOTE_DIR "/opt/ac" 17 + 18 + # Check for required files 19 + if not test -f $SSH_KEY 20 + echo -e "$RED x SSH key not found: $SSH_KEY$NC" 21 + exit 1 22 + end 23 + 24 + if not test -f $SERVICE_ENV 25 + echo -e "$RED x Service env not found: $SERVICE_ENV$NC" 26 + exit 1 27 + end 28 + 29 + # Test SSH connection 30 + echo -e "$GREEN-> Testing SSH connection to $LITH_HOST...$NC" 31 + if not ssh -i $SSH_KEY -o StrictHostKeyChecking=no -o ConnectTimeout=10 $LITH_USER@$LITH_HOST "echo ok" &>/dev/null 32 + echo -e "$RED x Cannot connect to $LITH_HOST$NC" 33 + exit 1 34 + end 35 + 36 + echo -e "$GREEN-> Connected.$NC" 37 + 38 + # Sync repo (git pull on remote) 39 + echo -e "$GREEN-> Pulling latest code...$NC" 40 + ssh -i $SSH_KEY $LITH_USER@$LITH_HOST "cd $REMOTE_DIR && git pull origin main" 41 + 42 + # Upload env 43 + echo -e "$GREEN-> Uploading environment...$NC" 44 + scp -i $SSH_KEY $SERVICE_ENV $LITH_USER@$LITH_HOST:$REMOTE_DIR/system/.env 45 + 46 + # Install deps 47 + echo -e "$GREEN-> Installing dependencies...$NC" 48 + ssh -i $SSH_KEY $LITH_USER@$LITH_HOST "cd $REMOTE_DIR/lith && npm install && cd $REMOTE_DIR/system && npm install" 49 + 50 + # Upload Caddyfile 51 + echo -e "$GREEN-> Updating Caddy config...$NC" 52 + scp -i $SSH_KEY $SCRIPT_DIR/Caddyfile $LITH_USER@$LITH_HOST:/etc/caddy/Caddyfile 53 + ssh -i $SSH_KEY $LITH_USER@$LITH_HOST "systemctl reload caddy" 54 + 55 + # Restart lith service 56 + echo -e "$GREEN-> Restarting lith...$NC" 57 + ssh -i $SSH_KEY $LITH_USER@$LITH_HOST "systemctl restart lith" 58 + 59 + echo -e "$GREEN-> Done. lith deployed to $LITH_HOST$NC"
+21
lith/lith.service
··· 1 + [Unit] 2 + Description=lith — AC monolith (frontend + API) 3 + After=network.target 4 + 5 + [Service] 6 + Type=simple 7 + User=root 8 + WorkingDirectory=/opt/ac/lith 9 + EnvironmentFile=/opt/ac/system/.env 10 + ExecStart=/usr/bin/node server.mjs 11 + Restart=on-failure 12 + RestartSec=5 13 + StandardOutput=journal 14 + StandardError=journal 15 + 16 + # Generous limits for Sharp/Puppeteer 17 + LimitNOFILE=65535 18 + MemoryMax=3G 19 + 20 + [Install] 21 + WantedBy=multi-user.target
+826
lith/package-lock.json
··· 1 + { 2 + "name": "lith", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "lith", 9 + "version": "0.1.0", 10 + "dependencies": { 11 + "express": "^5.1.0" 12 + } 13 + }, 14 + "node_modules/accepts": { 15 + "version": "2.0.0", 16 + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", 17 + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", 18 + "license": "MIT", 19 + "dependencies": { 20 + "mime-types": "^3.0.0", 21 + "negotiator": "^1.0.0" 22 + }, 23 + "engines": { 24 + "node": ">= 0.6" 25 + } 26 + }, 27 + "node_modules/body-parser": { 28 + "version": "2.2.2", 29 + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", 30 + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", 31 + "license": "MIT", 32 + "dependencies": { 33 + "bytes": "^3.1.2", 34 + "content-type": "^1.0.5", 35 + "debug": "^4.4.3", 36 + "http-errors": "^2.0.0", 37 + "iconv-lite": "^0.7.0", 38 + "on-finished": "^2.4.1", 39 + "qs": "^6.14.1", 40 + "raw-body": "^3.0.1", 41 + "type-is": "^2.0.1" 42 + }, 43 + "engines": { 44 + "node": ">=18" 45 + }, 46 + "funding": { 47 + "type": "opencollective", 48 + "url": "https://opencollective.com/express" 49 + } 50 + }, 51 + "node_modules/bytes": { 52 + "version": "3.1.2", 53 + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", 54 + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", 55 + "license": "MIT", 56 + "engines": { 57 + "node": ">= 0.8" 58 + } 59 + }, 60 + "node_modules/call-bind-apply-helpers": { 61 + "version": "1.0.2", 62 + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", 63 + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 64 + "license": "MIT", 65 + "dependencies": { 66 + "es-errors": "^1.3.0", 67 + "function-bind": "^1.1.2" 68 + }, 69 + "engines": { 70 + "node": ">= 0.4" 71 + } 72 + }, 73 + "node_modules/call-bound": { 74 + "version": "1.0.4", 75 + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", 76 + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", 77 + "license": "MIT", 78 + "dependencies": { 79 + "call-bind-apply-helpers": "^1.0.2", 80 + "get-intrinsic": "^1.3.0" 81 + }, 82 + "engines": { 83 + "node": ">= 0.4" 84 + }, 85 + "funding": { 86 + "url": "https://github.com/sponsors/ljharb" 87 + } 88 + }, 89 + "node_modules/content-disposition": { 90 + "version": "1.0.1", 91 + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", 92 + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", 93 + "license": "MIT", 94 + "engines": { 95 + "node": ">=18" 96 + }, 97 + "funding": { 98 + "type": "opencollective", 99 + "url": "https://opencollective.com/express" 100 + } 101 + }, 102 + "node_modules/content-type": { 103 + "version": "1.0.5", 104 + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", 105 + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", 106 + "license": "MIT", 107 + "engines": { 108 + "node": ">= 0.6" 109 + } 110 + }, 111 + "node_modules/cookie": { 112 + "version": "0.7.2", 113 + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", 114 + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", 115 + "license": "MIT", 116 + "engines": { 117 + "node": ">= 0.6" 118 + } 119 + }, 120 + "node_modules/cookie-signature": { 121 + "version": "1.2.2", 122 + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", 123 + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", 124 + "license": "MIT", 125 + "engines": { 126 + "node": ">=6.6.0" 127 + } 128 + }, 129 + "node_modules/debug": { 130 + "version": "4.4.3", 131 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 132 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 133 + "license": "MIT", 134 + "dependencies": { 135 + "ms": "^2.1.3" 136 + }, 137 + "engines": { 138 + "node": ">=6.0" 139 + }, 140 + "peerDependenciesMeta": { 141 + "supports-color": { 142 + "optional": true 143 + } 144 + } 145 + }, 146 + "node_modules/depd": { 147 + "version": "2.0.0", 148 + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", 149 + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", 150 + "license": "MIT", 151 + "engines": { 152 + "node": ">= 0.8" 153 + } 154 + }, 155 + "node_modules/dunder-proto": { 156 + "version": "1.0.1", 157 + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", 158 + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 159 + "license": "MIT", 160 + "dependencies": { 161 + "call-bind-apply-helpers": "^1.0.1", 162 + "es-errors": "^1.3.0", 163 + "gopd": "^1.2.0" 164 + }, 165 + "engines": { 166 + "node": ">= 0.4" 167 + } 168 + }, 169 + "node_modules/ee-first": { 170 + "version": "1.1.1", 171 + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 172 + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", 173 + "license": "MIT" 174 + }, 175 + "node_modules/encodeurl": { 176 + "version": "2.0.0", 177 + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", 178 + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", 179 + "license": "MIT", 180 + "engines": { 181 + "node": ">= 0.8" 182 + } 183 + }, 184 + "node_modules/es-define-property": { 185 + "version": "1.0.1", 186 + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", 187 + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", 188 + "license": "MIT", 189 + "engines": { 190 + "node": ">= 0.4" 191 + } 192 + }, 193 + "node_modules/es-errors": { 194 + "version": "1.3.0", 195 + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 196 + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", 197 + "license": "MIT", 198 + "engines": { 199 + "node": ">= 0.4" 200 + } 201 + }, 202 + "node_modules/es-object-atoms": { 203 + "version": "1.1.1", 204 + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", 205 + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 206 + "license": "MIT", 207 + "dependencies": { 208 + "es-errors": "^1.3.0" 209 + }, 210 + "engines": { 211 + "node": ">= 0.4" 212 + } 213 + }, 214 + "node_modules/escape-html": { 215 + "version": "1.0.3", 216 + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 217 + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", 218 + "license": "MIT" 219 + }, 220 + "node_modules/etag": { 221 + "version": "1.8.1", 222 + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 223 + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", 224 + "license": "MIT", 225 + "engines": { 226 + "node": ">= 0.6" 227 + } 228 + }, 229 + "node_modules/express": { 230 + "version": "5.2.1", 231 + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", 232 + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", 233 + "license": "MIT", 234 + "dependencies": { 235 + "accepts": "^2.0.0", 236 + "body-parser": "^2.2.1", 237 + "content-disposition": "^1.0.0", 238 + "content-type": "^1.0.5", 239 + "cookie": "^0.7.1", 240 + "cookie-signature": "^1.2.1", 241 + "debug": "^4.4.0", 242 + "depd": "^2.0.0", 243 + "encodeurl": "^2.0.0", 244 + "escape-html": "^1.0.3", 245 + "etag": "^1.8.1", 246 + "finalhandler": "^2.1.0", 247 + "fresh": "^2.0.0", 248 + "http-errors": "^2.0.0", 249 + "merge-descriptors": "^2.0.0", 250 + "mime-types": "^3.0.0", 251 + "on-finished": "^2.4.1", 252 + "once": "^1.4.0", 253 + "parseurl": "^1.3.3", 254 + "proxy-addr": "^2.0.7", 255 + "qs": "^6.14.0", 256 + "range-parser": "^1.2.1", 257 + "router": "^2.2.0", 258 + "send": "^1.1.0", 259 + "serve-static": "^2.2.0", 260 + "statuses": "^2.0.1", 261 + "type-is": "^2.0.1", 262 + "vary": "^1.1.2" 263 + }, 264 + "engines": { 265 + "node": ">= 18" 266 + }, 267 + "funding": { 268 + "type": "opencollective", 269 + "url": "https://opencollective.com/express" 270 + } 271 + }, 272 + "node_modules/finalhandler": { 273 + "version": "2.1.1", 274 + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", 275 + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", 276 + "license": "MIT", 277 + "dependencies": { 278 + "debug": "^4.4.0", 279 + "encodeurl": "^2.0.0", 280 + "escape-html": "^1.0.3", 281 + "on-finished": "^2.4.1", 282 + "parseurl": "^1.3.3", 283 + "statuses": "^2.0.1" 284 + }, 285 + "engines": { 286 + "node": ">= 18.0.0" 287 + }, 288 + "funding": { 289 + "type": "opencollective", 290 + "url": "https://opencollective.com/express" 291 + } 292 + }, 293 + "node_modules/forwarded": { 294 + "version": "0.2.0", 295 + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", 296 + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", 297 + "license": "MIT", 298 + "engines": { 299 + "node": ">= 0.6" 300 + } 301 + }, 302 + "node_modules/fresh": { 303 + "version": "2.0.0", 304 + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", 305 + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", 306 + "license": "MIT", 307 + "engines": { 308 + "node": ">= 0.8" 309 + } 310 + }, 311 + "node_modules/function-bind": { 312 + "version": "1.1.2", 313 + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 314 + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 315 + "license": "MIT", 316 + "funding": { 317 + "url": "https://github.com/sponsors/ljharb" 318 + } 319 + }, 320 + "node_modules/get-intrinsic": { 321 + "version": "1.3.0", 322 + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", 323 + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 324 + "license": "MIT", 325 + "dependencies": { 326 + "call-bind-apply-helpers": "^1.0.2", 327 + "es-define-property": "^1.0.1", 328 + "es-errors": "^1.3.0", 329 + "es-object-atoms": "^1.1.1", 330 + "function-bind": "^1.1.2", 331 + "get-proto": "^1.0.1", 332 + "gopd": "^1.2.0", 333 + "has-symbols": "^1.1.0", 334 + "hasown": "^2.0.2", 335 + "math-intrinsics": "^1.1.0" 336 + }, 337 + "engines": { 338 + "node": ">= 0.4" 339 + }, 340 + "funding": { 341 + "url": "https://github.com/sponsors/ljharb" 342 + } 343 + }, 344 + "node_modules/get-proto": { 345 + "version": "1.0.1", 346 + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", 347 + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 348 + "license": "MIT", 349 + "dependencies": { 350 + "dunder-proto": "^1.0.1", 351 + "es-object-atoms": "^1.0.0" 352 + }, 353 + "engines": { 354 + "node": ">= 0.4" 355 + } 356 + }, 357 + "node_modules/gopd": { 358 + "version": "1.2.0", 359 + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", 360 + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", 361 + "license": "MIT", 362 + "engines": { 363 + "node": ">= 0.4" 364 + }, 365 + "funding": { 366 + "url": "https://github.com/sponsors/ljharb" 367 + } 368 + }, 369 + "node_modules/has-symbols": { 370 + "version": "1.1.0", 371 + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", 372 + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", 373 + "license": "MIT", 374 + "engines": { 375 + "node": ">= 0.4" 376 + }, 377 + "funding": { 378 + "url": "https://github.com/sponsors/ljharb" 379 + } 380 + }, 381 + "node_modules/hasown": { 382 + "version": "2.0.2", 383 + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 384 + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 385 + "license": "MIT", 386 + "dependencies": { 387 + "function-bind": "^1.1.2" 388 + }, 389 + "engines": { 390 + "node": ">= 0.4" 391 + } 392 + }, 393 + "node_modules/http-errors": { 394 + "version": "2.0.1", 395 + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", 396 + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", 397 + "license": "MIT", 398 + "dependencies": { 399 + "depd": "~2.0.0", 400 + "inherits": "~2.0.4", 401 + "setprototypeof": "~1.2.0", 402 + "statuses": "~2.0.2", 403 + "toidentifier": "~1.0.1" 404 + }, 405 + "engines": { 406 + "node": ">= 0.8" 407 + }, 408 + "funding": { 409 + "type": "opencollective", 410 + "url": "https://opencollective.com/express" 411 + } 412 + }, 413 + "node_modules/iconv-lite": { 414 + "version": "0.7.2", 415 + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", 416 + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", 417 + "license": "MIT", 418 + "dependencies": { 419 + "safer-buffer": ">= 2.1.2 < 3.0.0" 420 + }, 421 + "engines": { 422 + "node": ">=0.10.0" 423 + }, 424 + "funding": { 425 + "type": "opencollective", 426 + "url": "https://opencollective.com/express" 427 + } 428 + }, 429 + "node_modules/inherits": { 430 + "version": "2.0.4", 431 + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 432 + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 433 + "license": "ISC" 434 + }, 435 + "node_modules/ipaddr.js": { 436 + "version": "1.9.1", 437 + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", 438 + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", 439 + "license": "MIT", 440 + "engines": { 441 + "node": ">= 0.10" 442 + } 443 + }, 444 + "node_modules/is-promise": { 445 + "version": "4.0.0", 446 + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", 447 + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", 448 + "license": "MIT" 449 + }, 450 + "node_modules/math-intrinsics": { 451 + "version": "1.1.0", 452 + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", 453 + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", 454 + "license": "MIT", 455 + "engines": { 456 + "node": ">= 0.4" 457 + } 458 + }, 459 + "node_modules/media-typer": { 460 + "version": "1.1.0", 461 + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", 462 + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", 463 + "license": "MIT", 464 + "engines": { 465 + "node": ">= 0.8" 466 + } 467 + }, 468 + "node_modules/merge-descriptors": { 469 + "version": "2.0.0", 470 + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", 471 + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", 472 + "license": "MIT", 473 + "engines": { 474 + "node": ">=18" 475 + }, 476 + "funding": { 477 + "url": "https://github.com/sponsors/sindresorhus" 478 + } 479 + }, 480 + "node_modules/mime-db": { 481 + "version": "1.54.0", 482 + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", 483 + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", 484 + "license": "MIT", 485 + "engines": { 486 + "node": ">= 0.6" 487 + } 488 + }, 489 + "node_modules/mime-types": { 490 + "version": "3.0.2", 491 + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", 492 + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", 493 + "license": "MIT", 494 + "dependencies": { 495 + "mime-db": "^1.54.0" 496 + }, 497 + "engines": { 498 + "node": ">=18" 499 + }, 500 + "funding": { 501 + "type": "opencollective", 502 + "url": "https://opencollective.com/express" 503 + } 504 + }, 505 + "node_modules/ms": { 506 + "version": "2.1.3", 507 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 508 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 509 + "license": "MIT" 510 + }, 511 + "node_modules/negotiator": { 512 + "version": "1.0.0", 513 + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", 514 + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", 515 + "license": "MIT", 516 + "engines": { 517 + "node": ">= 0.6" 518 + } 519 + }, 520 + "node_modules/object-inspect": { 521 + "version": "1.13.4", 522 + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", 523 + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", 524 + "license": "MIT", 525 + "engines": { 526 + "node": ">= 0.4" 527 + }, 528 + "funding": { 529 + "url": "https://github.com/sponsors/ljharb" 530 + } 531 + }, 532 + "node_modules/on-finished": { 533 + "version": "2.4.1", 534 + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", 535 + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", 536 + "license": "MIT", 537 + "dependencies": { 538 + "ee-first": "1.1.1" 539 + }, 540 + "engines": { 541 + "node": ">= 0.8" 542 + } 543 + }, 544 + "node_modules/once": { 545 + "version": "1.4.0", 546 + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 547 + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 548 + "license": "ISC", 549 + "dependencies": { 550 + "wrappy": "1" 551 + } 552 + }, 553 + "node_modules/parseurl": { 554 + "version": "1.3.3", 555 + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", 556 + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", 557 + "license": "MIT", 558 + "engines": { 559 + "node": ">= 0.8" 560 + } 561 + }, 562 + "node_modules/path-to-regexp": { 563 + "version": "8.4.0", 564 + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", 565 + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", 566 + "license": "MIT", 567 + "funding": { 568 + "type": "opencollective", 569 + "url": "https://opencollective.com/express" 570 + } 571 + }, 572 + "node_modules/proxy-addr": { 573 + "version": "2.0.7", 574 + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", 575 + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 576 + "license": "MIT", 577 + "dependencies": { 578 + "forwarded": "0.2.0", 579 + "ipaddr.js": "1.9.1" 580 + }, 581 + "engines": { 582 + "node": ">= 0.10" 583 + } 584 + }, 585 + "node_modules/qs": { 586 + "version": "6.15.0", 587 + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", 588 + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", 589 + "license": "BSD-3-Clause", 590 + "dependencies": { 591 + "side-channel": "^1.1.0" 592 + }, 593 + "engines": { 594 + "node": ">=0.6" 595 + }, 596 + "funding": { 597 + "url": "https://github.com/sponsors/ljharb" 598 + } 599 + }, 600 + "node_modules/range-parser": { 601 + "version": "1.2.1", 602 + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", 603 + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", 604 + "license": "MIT", 605 + "engines": { 606 + "node": ">= 0.6" 607 + } 608 + }, 609 + "node_modules/raw-body": { 610 + "version": "3.0.2", 611 + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", 612 + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", 613 + "license": "MIT", 614 + "dependencies": { 615 + "bytes": "~3.1.2", 616 + "http-errors": "~2.0.1", 617 + "iconv-lite": "~0.7.0", 618 + "unpipe": "~1.0.0" 619 + }, 620 + "engines": { 621 + "node": ">= 0.10" 622 + } 623 + }, 624 + "node_modules/router": { 625 + "version": "2.2.0", 626 + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", 627 + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", 628 + "license": "MIT", 629 + "dependencies": { 630 + "debug": "^4.4.0", 631 + "depd": "^2.0.0", 632 + "is-promise": "^4.0.0", 633 + "parseurl": "^1.3.3", 634 + "path-to-regexp": "^8.0.0" 635 + }, 636 + "engines": { 637 + "node": ">= 18" 638 + } 639 + }, 640 + "node_modules/safer-buffer": { 641 + "version": "2.1.2", 642 + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 643 + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", 644 + "license": "MIT" 645 + }, 646 + "node_modules/send": { 647 + "version": "1.2.1", 648 + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", 649 + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", 650 + "license": "MIT", 651 + "dependencies": { 652 + "debug": "^4.4.3", 653 + "encodeurl": "^2.0.0", 654 + "escape-html": "^1.0.3", 655 + "etag": "^1.8.1", 656 + "fresh": "^2.0.0", 657 + "http-errors": "^2.0.1", 658 + "mime-types": "^3.0.2", 659 + "ms": "^2.1.3", 660 + "on-finished": "^2.4.1", 661 + "range-parser": "^1.2.1", 662 + "statuses": "^2.0.2" 663 + }, 664 + "engines": { 665 + "node": ">= 18" 666 + }, 667 + "funding": { 668 + "type": "opencollective", 669 + "url": "https://opencollective.com/express" 670 + } 671 + }, 672 + "node_modules/serve-static": { 673 + "version": "2.2.1", 674 + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", 675 + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", 676 + "license": "MIT", 677 + "dependencies": { 678 + "encodeurl": "^2.0.0", 679 + "escape-html": "^1.0.3", 680 + "parseurl": "^1.3.3", 681 + "send": "^1.2.0" 682 + }, 683 + "engines": { 684 + "node": ">= 18" 685 + }, 686 + "funding": { 687 + "type": "opencollective", 688 + "url": "https://opencollective.com/express" 689 + } 690 + }, 691 + "node_modules/setprototypeof": { 692 + "version": "1.2.0", 693 + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", 694 + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", 695 + "license": "ISC" 696 + }, 697 + "node_modules/side-channel": { 698 + "version": "1.1.0", 699 + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", 700 + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", 701 + "license": "MIT", 702 + "dependencies": { 703 + "es-errors": "^1.3.0", 704 + "object-inspect": "^1.13.3", 705 + "side-channel-list": "^1.0.0", 706 + "side-channel-map": "^1.0.1", 707 + "side-channel-weakmap": "^1.0.2" 708 + }, 709 + "engines": { 710 + "node": ">= 0.4" 711 + }, 712 + "funding": { 713 + "url": "https://github.com/sponsors/ljharb" 714 + } 715 + }, 716 + "node_modules/side-channel-list": { 717 + "version": "1.0.0", 718 + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", 719 + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", 720 + "license": "MIT", 721 + "dependencies": { 722 + "es-errors": "^1.3.0", 723 + "object-inspect": "^1.13.3" 724 + }, 725 + "engines": { 726 + "node": ">= 0.4" 727 + }, 728 + "funding": { 729 + "url": "https://github.com/sponsors/ljharb" 730 + } 731 + }, 732 + "node_modules/side-channel-map": { 733 + "version": "1.0.1", 734 + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", 735 + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", 736 + "license": "MIT", 737 + "dependencies": { 738 + "call-bound": "^1.0.2", 739 + "es-errors": "^1.3.0", 740 + "get-intrinsic": "^1.2.5", 741 + "object-inspect": "^1.13.3" 742 + }, 743 + "engines": { 744 + "node": ">= 0.4" 745 + }, 746 + "funding": { 747 + "url": "https://github.com/sponsors/ljharb" 748 + } 749 + }, 750 + "node_modules/side-channel-weakmap": { 751 + "version": "1.0.2", 752 + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", 753 + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", 754 + "license": "MIT", 755 + "dependencies": { 756 + "call-bound": "^1.0.2", 757 + "es-errors": "^1.3.0", 758 + "get-intrinsic": "^1.2.5", 759 + "object-inspect": "^1.13.3", 760 + "side-channel-map": "^1.0.1" 761 + }, 762 + "engines": { 763 + "node": ">= 0.4" 764 + }, 765 + "funding": { 766 + "url": "https://github.com/sponsors/ljharb" 767 + } 768 + }, 769 + "node_modules/statuses": { 770 + "version": "2.0.2", 771 + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", 772 + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", 773 + "license": "MIT", 774 + "engines": { 775 + "node": ">= 0.8" 776 + } 777 + }, 778 + "node_modules/toidentifier": { 779 + "version": "1.0.1", 780 + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", 781 + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", 782 + "license": "MIT", 783 + "engines": { 784 + "node": ">=0.6" 785 + } 786 + }, 787 + "node_modules/type-is": { 788 + "version": "2.0.1", 789 + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", 790 + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", 791 + "license": "MIT", 792 + "dependencies": { 793 + "content-type": "^1.0.5", 794 + "media-typer": "^1.1.0", 795 + "mime-types": "^3.0.0" 796 + }, 797 + "engines": { 798 + "node": ">= 0.6" 799 + } 800 + }, 801 + "node_modules/unpipe": { 802 + "version": "1.0.0", 803 + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 804 + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", 805 + "license": "MIT", 806 + "engines": { 807 + "node": ">= 0.8" 808 + } 809 + }, 810 + "node_modules/vary": { 811 + "version": "1.1.2", 812 + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 813 + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", 814 + "license": "MIT", 815 + "engines": { 816 + "node": ">= 0.8" 817 + } 818 + }, 819 + "node_modules/wrappy": { 820 + "version": "1.0.2", 821 + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 822 + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", 823 + "license": "ISC" 824 + } 825 + } 826 + }
+13
lith/package.json
··· 1 + { 2 + "name": "lith", 3 + "version": "0.1.0", 4 + "description": "AC monolith — static frontend + API server (replaces Netlify)", 5 + "type": "module", 6 + "scripts": { 7 + "start": "node server.mjs", 8 + "dev": "node --watch server.mjs" 9 + }, 10 + "dependencies": { 11 + "express": "^5.1.0" 12 + } 13 + }
+157
lith/scripts/dns-cutover.fish
··· 1 + #!/usr/bin/env fish 2 + # DNS Cutover — Update all Netlify-pointing records to the new lith droplet IP 3 + # 4 + # Usage: fish scripts/dns-cutover.fish <NEW_IP> 5 + # 6 + # This updates 36 DNS records across 8 Cloudflare zones. 7 + # Records are changed from aesthetic-computer.netlify.app / 75.2.60.5 → NEW_IP 8 + 9 + set RED '\033[0;31m' 10 + set GREEN '\033[0;32m' 11 + set YELLOW '\033[1;33m' 12 + set NC '\033[0m' 13 + 14 + if test (count $argv) -lt 1 15 + echo "Usage: fish scripts/dns-cutover.fish <NEW_IP>" 16 + exit 1 17 + end 18 + 19 + set NEW_IP $argv[1] 20 + 21 + # Load Cloudflare credentials 22 + set SCRIPT_DIR (dirname (status --current-filename)) 23 + set VAULT_DIR "$SCRIPT_DIR/../../aesthetic-computer-vault" 24 + 25 + if test -f "$VAULT_DIR/cloudflare.env" 26 + for line in (cat "$VAULT_DIR/cloudflare.env" | grep -v '^#' | grep -v '^$' | grep '=') 27 + set -l parts (string split '=' $line) 28 + if test (count $parts) -ge 2 29 + set -gx $parts[1] (string join '=' $parts[2..-1]) 30 + end 31 + end 32 + else 33 + # Fallback to feed.env 34 + set FEED_ENV "$SCRIPT_DIR/../../.devcontainer/envs/feed.env" 35 + if test -f $FEED_ENV 36 + set -gx CF_EMAIL (grep CLOUDFLARE_EMAIL $FEED_ENV | head -1 | cut -d= -f2 | tr -d '"') 37 + set -gx CF_KEY (grep CLOUDFLARE_API_KEY $FEED_ENV | head -1 | cut -d= -f2 | tr -d '"') 38 + end 39 + end 40 + 41 + if test -z "$CF_EMAIL" -o -z "$CF_KEY" 42 + echo -e "$RED x Cloudflare credentials not found$NC" 43 + exit 1 44 + end 45 + 46 + function cf_headers 47 + echo -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" -H "Content-Type: application/json" 48 + end 49 + 50 + # Helper: update or create an A record 51 + function update_a_record -a zone_id name 52 + echo -e "$GREEN -> $name → $NEW_IP$NC" 53 + 54 + # Find existing record 55 + set record_id (curl -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?type=A&name=$name" \ 56 + -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" \ 57 + | python3 -c "import json,sys; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')") 58 + 59 + if test -n "$record_id" 60 + # Update existing A record 61 + curl -s -X PATCH "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$record_id" \ 62 + -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" -H "Content-Type: application/json" \ 63 + --data "{\"type\":\"A\",\"content\":\"$NEW_IP\",\"proxied\":true}" | python3 -c "import json,sys; print(' OK' if json.load(sys.stdin)['success'] else ' FAIL')" 64 + else 65 + # Check for CNAME and delete it first 66 + set cname_id (curl -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?type=CNAME&name=$name" \ 67 + -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" \ 68 + | python3 -c "import json,sys; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')") 69 + 70 + if test -n "$cname_id" 71 + curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$cname_id" \ 72 + -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" > /dev/null 73 + end 74 + 75 + # Create new A record 76 + curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \ 77 + -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" -H "Content-Type: application/json" \ 78 + --data "{\"type\":\"A\",\"name\":\"$name\",\"content\":\"$NEW_IP\",\"proxied\":true}" | python3 -c "import json,sys; print(' OK' if json.load(sys.stdin)['success'] else ' FAIL')" 79 + end 80 + end 81 + 82 + echo -e "$YELLOW=== DNS Cutover: 36 records → $NEW_IP ===$NC" 83 + echo "" 84 + 85 + # --- aesthetic.computer (da794a6ae8f17b80424907f81ed0db7c) --- 86 + set Z da794a6ae8f17b80424907f81ed0db7c 87 + echo -e "$YELLOW[aesthetic.computer]$NC" 88 + update_a_record $Z "aesthetic.computer" 89 + update_a_record $Z "api.aesthetic.computer" 90 + update_a_record $Z "bills.aesthetic.computer" 91 + update_a_record $Z "give.aesthetic.computer" 92 + update_a_record $Z "keeps.aesthetic.computer" 93 + update_a_record $Z "l5.aesthetic.computer" 94 + update_a_record $Z "news.aesthetic.computer" 95 + update_a_record $Z "p5.aesthetic.computer" 96 + update_a_record $Z "pals.aesthetic.computer" 97 + update_a_record $Z "papers.aesthetic.computer" 98 + update_a_record $Z "processing.aesthetic.computer" 99 + update_a_record $Z "sitemap.aesthetic.computer" 100 + update_a_record $Z "www.aesthetic.computer" 101 + 102 + # --- false.work (0fa28e0097b24e187f41fea0ec036c0d) --- 103 + set Z 0fa28e0097b24e187f41fea0ec036c0d 104 + echo -e "$YELLOW[false.work]$NC" 105 + update_a_record $Z "builds.false.work" 106 + 107 + # --- jas.life (79e214366285134e1fc7952db8aff75e) --- 108 + set Z 79e214366285134e1fc7952db8aff75e 109 + echo -e "$YELLOW[jas.life]$NC" 110 + update_a_record $Z "jas.life" 111 + 112 + # --- justanothersystem.org (a3366b124c7ca95fe902a54f868dcc51) --- 113 + set Z a3366b124c7ca95fe902a54f868dcc51 114 + echo -e "$YELLOW[justanothersystem.org]$NC" 115 + update_a_record $Z "justanothersystem.org" 116 + update_a_record $Z "www.justanothersystem.org" 117 + 118 + # --- kidlisp.com (bac7b811ac7b4df664b696fafa9e6207) --- 119 + set Z bac7b811ac7b4df664b696fafa9e6207 120 + echo -e "$YELLOW[kidlisp.com]$NC" 121 + update_a_record $Z "kidlisp.com" 122 + update_a_record $Z "www.kidlisp.com" 123 + update_a_record $Z "buy.kidlisp.com" 124 + update_a_record $Z "calm.kidlisp.com" 125 + update_a_record $Z "device.kidlisp.com" 126 + update_a_record $Z "keep.kidlisp.com" 127 + update_a_record $Z "keeps.kidlisp.com" 128 + update_a_record $Z "learn.kidlisp.com" 129 + update_a_record $Z "pj.kidlisp.com" 130 + update_a_record $Z "top.kidlisp.com" 131 + 132 + # --- notepat.com (8d289a1e56563dbcc9bc88747428c8ee) --- 133 + set Z 8d289a1e56563dbcc9bc88747428c8ee 134 + echo -e "$YELLOW[notepat.com]$NC" 135 + update_a_record $Z "notepat.com" 136 + update_a_record $Z "www.notepat.com" 137 + 138 + # --- prompt.ac (1f93ca86e2d9de0def0acb0b8c4e722b) --- 139 + set Z 1f93ca86e2d9de0def0acb0b8c4e722b 140 + echo -e "$YELLOW[prompt.ac]$NC" 141 + update_a_record $Z "prompt.ac" 142 + update_a_record $Z "api.prompt.ac" 143 + update_a_record $Z "l5.prompt.ac" 144 + update_a_record $Z "p5.prompt.ac" 145 + update_a_record $Z "papers.prompt.ac" 146 + update_a_record $Z "processing.prompt.ac" 147 + update_a_record $Z "sitemap.prompt.ac" 148 + 149 + # --- sotce.net (1f56f8b5fd7b3db92d31bad0714a518f) --- 150 + set Z 1f56f8b5fd7b3db92d31bad0714a518f 151 + echo -e "$YELLOW[sotce.net]$NC" 152 + update_a_record $Z "sotce.net" 153 + update_a_record $Z "www.sotce.net" 154 + 155 + echo "" 156 + echo -e "$GREEN=== Done. 36 records updated. ===$NC" 157 + echo -e "$YELLOW Cloudflare propagation: ~30 seconds (proxied) or ~5 minutes (DNS-only)$NC"
+80
lith/scripts/provision.fish
··· 1 + #!/usr/bin/env fish 2 + # Provision a new DO droplet for lith 3 + # Run locally — creates droplet, installs deps, clones repo 4 + 5 + set RED '\033[0;31m' 6 + set GREEN '\033[0;32m' 7 + set YELLOW '\033[1;33m' 8 + set NC '\033[0m' 9 + 10 + set DROPLET_NAME "ac-lith" 11 + set REGION "sfo3" 12 + set SIZE "s-2vcpu-4gb" 13 + set IMAGE "ubuntu-24-04-x64" 14 + 15 + # Get SSH key IDs 16 + echo -e "$GREEN-> Fetching SSH keys...$NC" 17 + set SSH_KEYS (doctl compute ssh-key list --format ID --no-header | string join ',') 18 + 19 + if test -z "$SSH_KEYS" 20 + echo -e "$RED x No SSH keys found in DO account$NC" 21 + exit 1 22 + end 23 + 24 + echo -e "$GREEN-> Creating droplet $DROPLET_NAME ($SIZE in $REGION)...$NC" 25 + set DROPLET_ID (doctl compute droplet create $DROPLET_NAME \ 26 + --region $REGION \ 27 + --size $SIZE \ 28 + --image $IMAGE \ 29 + --ssh-keys $SSH_KEYS \ 30 + --tag-names ac,lith,web \ 31 + --wait \ 32 + --format ID \ 33 + --no-header) 34 + 35 + set DROPLET_IP (doctl compute droplet get $DROPLET_ID --format PublicIPv4 --no-header) 36 + 37 + echo -e "$GREEN-> Droplet created: $DROPLET_NAME @ $DROPLET_IP$NC" 38 + echo -e "$YELLOW Save this IP for Cloudflare DNS updates!$NC" 39 + echo "" 40 + echo "LITH_IP=$DROPLET_IP" > /tmp/lith-droplet.env 41 + echo "LITH_ID=$DROPLET_ID" >> /tmp/lith-droplet.env 42 + 43 + # Wait for SSH 44 + echo -e "$GREEN-> Waiting for SSH...$NC" 45 + sleep 10 46 + while not ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@$DROPLET_IP "echo ok" &>/dev/null 47 + sleep 5 48 + end 49 + 50 + # Install stack 51 + echo -e "$GREEN-> Installing Caddy + Node.js + system deps...$NC" 52 + ssh root@$DROPLET_IP " 53 + # Caddy 54 + apt-get update -qq 55 + apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https curl 56 + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg 57 + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list 58 + apt-get update -qq 59 + apt-get install -y -qq caddy 60 + 61 + # Node.js 22 LTS 62 + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - 63 + apt-get install -y -qq nodejs 64 + 65 + # Sharp + Puppeteer deps 66 + apt-get install -y -qq libvips-dev chromium-browser git 67 + 68 + # Clone repo 69 + git clone https://github.com/aesthetic-computer/aesthetic-computer.git /opt/ac 70 + cd /opt/ac/lith && npm install 71 + cd /opt/ac/system && npm install 72 + " 73 + 74 + echo -e "$GREEN-> Droplet provisioned.$NC" 75 + echo -e "$YELLOW-> Next steps:$NC" 76 + echo " 1. Upload .env: scp vault/netlify-production.env root@$DROPLET_IP:/opt/ac/system/.env" 77 + echo " 2. Upload Caddyfile: scp lith/Caddyfile root@$DROPLET_IP:/etc/caddy/Caddyfile" 78 + echo " 3. Install service: scp lith/lith.service root@$DROPLET_IP:/etc/systemd/system/" 79 + echo " 4. Enable service: ssh root@$DROPLET_IP 'systemctl daemon-reload && systemctl enable --now lith && systemctl reload caddy'" 80 + echo " 5. Update DNS: Point 36 records to $DROPLET_IP (see vault/cloudflare-dns-records.md)"
+303
lith/server.mjs
··· 1 + // lith — AC monolith server 2 + // Wraps Netlify function handlers in Express routes + serves static files. 3 + 4 + // Shim awslambda before anything imports @netlify/functions. 5 + // Netlify's stream() calls awslambda.streamifyResponse() at wrap time, 6 + // which doesn't exist outside AWS Lambda. This shim makes the wrapped 7 + // handler just call the original function and return its result. 8 + if (typeof globalThis.awslambda === "undefined") { 9 + globalThis.awslambda = { 10 + streamifyResponse: (fn) => fn, 11 + HttpResponseStream: { 12 + from: (stream, _metadata) => stream, 13 + }, 14 + }; 15 + } 16 + 17 + import express from "express"; 18 + import { readdirSync, readFileSync, existsSync } from "fs"; 19 + import { join, dirname } from "path"; 20 + import { fileURLToPath } from "url"; 21 + import { createServer as createHttpsServer } from "https"; 22 + import { createServer as createHttpServer } from "http"; 23 + 24 + const __dirname = dirname(fileURLToPath(import.meta.url)); 25 + const SYSTEM = join(__dirname, "..", "system"); 26 + const PUBLIC = join(SYSTEM, "public"); 27 + const FN_DIR = join(SYSTEM, "netlify", "functions"); 28 + 29 + // Load .env from system/ if present (handles special chars in values) 30 + const envPath = join(SYSTEM, ".env"); 31 + if (existsSync(envPath)) { 32 + for (const line of readFileSync(envPath, "utf-8").split("\n")) { 33 + if (!line || line.startsWith("#")) continue; 34 + const idx = line.indexOf("="); 35 + if (idx === -1) continue; 36 + const key = line.slice(0, idx).trim(); 37 + const val = line.slice(idx + 1).trim(); 38 + if (key && !process.env[key]) process.env[key] = val; 39 + } 40 + } 41 + 42 + const PORT = process.env.PORT || 8888; 43 + const DEV = process.env.NODE_ENV !== "production"; 44 + 45 + // Tell functions we're in dev mode (so index.mjs uses cwd instead of /var/task) 46 + if (DEV) { 47 + process.env.CONTEXT = process.env.CONTEXT || "dev"; 48 + process.env.NETLIFY_DEV = process.env.NETLIFY_DEV || "true"; 49 + } 50 + 51 + // Set cwd to system/ so relative paths in functions resolve correctly 52 + process.chdir(SYSTEM); 53 + 54 + // SSL certs for local dev (same ones Netlify local context uses) 55 + const SSL_CERT = join(__dirname, "..", "ssl-dev", "localhost.pem"); 56 + const SSL_KEY = join(__dirname, "..", "ssl-dev", "localhost-key.pem"); 57 + const HAS_SSL = existsSync(SSL_CERT) && existsSync(SSL_KEY); 58 + 59 + const app = express(); 60 + 61 + // --- Body parsing --- 62 + app.use(express.json({ limit: "50mb" })); 63 + app.use(express.urlencoded({ extended: true, limit: "50mb" })); 64 + app.use(express.raw({ type: "*/*", limit: "50mb" })); 65 + 66 + // --- CORS (mirrors Netlify _headers) --- 67 + app.use((req, res, next) => { 68 + res.set("Access-Control-Allow-Origin", "*"); 69 + res.set( 70 + "Access-Control-Allow-Headers", 71 + "Content-Type, Authorization, X-Requested-With", 72 + ); 73 + res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); 74 + if (req.method === "OPTIONS") return res.sendStatus(204); 75 + next(); 76 + }); 77 + 78 + // --- Load Netlify functions --- 79 + const functions = {}; 80 + 81 + // Scripts that call process.exit() at import time — not API functions. 82 + const SKIP = new Set(["backfill-painting-codes", "test-tv-hits"]); 83 + 84 + for (const file of readdirSync(FN_DIR)) { 85 + if (!file.endsWith(".mjs") && !file.endsWith(".js")) continue; 86 + const name = file.replace(/\.(mjs|js)$/, ""); 87 + if (SKIP.has(name)) continue; 88 + try { 89 + const mod = await import(join(FN_DIR, file)); 90 + if (mod.handler) { 91 + // Netlify Functions v1: export { handler } 92 + functions[name] = mod.handler; 93 + } else if (mod.default && typeof mod.default === "function") { 94 + // Netlify Functions v2: export default async (req) => { ... } 95 + // Wrap v2 handler to match v1 event/context signature 96 + const v2fn = mod.default; 97 + functions[name] = async (event, context) => { 98 + // V2 functions receive a Request-like object; build one from the event 99 + const url = event.rawUrl || `http://localhost${event.path || "/"}`; 100 + const req = new Request(url, { 101 + method: event.httpMethod, 102 + headers: event.headers, 103 + body: event.httpMethod !== "GET" && event.httpMethod !== "HEAD" ? event.body : undefined, 104 + }); 105 + req.query = event.queryStringParameters; 106 + const resp = await v2fn(req, context); 107 + // V2 returns a Web Response object 108 + const body = await resp.text(); 109 + const headers = {}; 110 + resp.headers.forEach((v, k) => { headers[k] = v; }); 111 + return { statusCode: resp.status, headers, body }; 112 + }; 113 + } 114 + } catch (err) { 115 + console.warn(` skip: ${name} (${err.message})`); 116 + } 117 + } 118 + 119 + console.log(`Loaded ${Object.keys(functions).length} functions`); 120 + 121 + // --- Netlify event adapter --- 122 + function toEvent(req) { 123 + // Reconstruct body as string (Netlify handlers expect string or null) 124 + let body = null; 125 + if (req.body) { 126 + body = 127 + typeof req.body === "string" 128 + ? req.body 129 + : Buffer.isBuffer(req.body) 130 + ? req.body.toString("utf-8") 131 + : JSON.stringify(req.body); 132 + } 133 + 134 + return { 135 + httpMethod: req.method, 136 + headers: req.headers, 137 + body, 138 + rawBody: req.body, 139 + queryStringParameters: req.query || {}, 140 + path: req.path, 141 + rawUrl: `${req.protocol}://${req.get("host")}${req.originalUrl}`, 142 + isBase64Encoded: false, 143 + }; 144 + } 145 + 146 + // --- Function handler --- 147 + async function handleFunction(req, res) { 148 + const name = req.params.fn; 149 + const handler = functions[name]; 150 + if (!handler) return res.status(404).send("Function not found: " + name); 151 + 152 + try { 153 + const event = toEvent(req); 154 + const context = { clientContext: {} }; 155 + const result = await handler(event, context); 156 + 157 + const statusCode = result.statusCode || 200; 158 + if (result.headers) res.set(result.headers); 159 + if (result.multiValueHeaders) { 160 + for (const [k, vals] of Object.entries(result.multiValueHeaders)) { 161 + for (const v of vals) res.append(k, v); 162 + } 163 + } 164 + 165 + // Handle ReadableStream bodies (from streaming functions like ask, keep-mint) 166 + if (result.body && typeof result.body === "object" && typeof result.body.getReader === "function") { 167 + res.status(statusCode); 168 + const reader = result.body.getReader(); 169 + const pump = async () => { 170 + while (true) { 171 + const { done, value } = await reader.read(); 172 + if (done) { res.end(); return; } 173 + res.write(value); 174 + } 175 + }; 176 + return pump().catch((err) => { 177 + console.error(`fn/${name} stream error:`, err); 178 + res.end(); 179 + }); 180 + } 181 + 182 + if (result.isBase64Encoded) { 183 + res.status(statusCode).send(Buffer.from(result.body, "base64")); 184 + } else { 185 + res.status(statusCode).send(result.body); 186 + } 187 + } catch (err) { 188 + console.error(`fn/${name} error:`, err); 189 + res.status(500).send("Internal Server Error"); 190 + } 191 + } 192 + 193 + // --- Route aliases (from netlify.toml where URL path ≠ function name) --- 194 + const ROUTE_ALIASES = { 195 + "verify-password": "verify-builds-password", 196 + "pack-telemetry": "bundle-telemetry", 197 + "pack-telemetry-query": "bundle-telemetry-query", 198 + "track-tape": "track-media", 199 + }; 200 + 201 + // --- Nested API routes (e.g. /api/chat/messages → chat-messages) --- 202 + const NESTED_ROUTES = { 203 + "chat/messages": "chat-messages", 204 + "chat/heart": "chat-heart", 205 + "auth/cli-callback": "auth-cli-callback", 206 + "news/toll": "news-toll", 207 + // /api/news/* (posts, updates, submit, etc.) → news-api function 208 + "news/": "news-api", 209 + }; 210 + 211 + // Resolve function name from URL params 212 + function resolveFunction(req) { 213 + const fn = req.params.fn; 214 + const rest = req.params.rest; 215 + 216 + // Check nested routes first (e.g., /api/chat/messages) 217 + if (rest) { 218 + const nested = `${fn}/${rest}`; 219 + for (const [pattern, target] of Object.entries(NESTED_ROUTES)) { 220 + if (nested === pattern || nested.startsWith(pattern)) { 221 + return target; 222 + } 223 + } 224 + } 225 + 226 + // Check aliases 227 + if (ROUTE_ALIASES[fn]) return ROUTE_ALIASES[fn]; 228 + 229 + // ff1 dynamic routing: /api/ff1-proxy, /api/ff1-pair, /api/ff1-devices 230 + if (fn === "ff1" && rest) { 231 + const subFn = `ff1-${rest.split("/")[0]}`; 232 + if (functions[subFn]) return subFn; 233 + } 234 + 235 + return fn; 236 + } 237 + 238 + // --- Function handler (updated to use resolveFunction) --- 239 + async function handleFunctionResolved(req, res) { 240 + req.params.fn = resolveFunction(req); 241 + return handleFunction(req, res); 242 + } 243 + 244 + // --- Routes --- 245 + 246 + // API functions (matches Netlify redirect rules) 247 + app.all("/api/:fn", handleFunctionResolved); 248 + app.all("/api/:fn/*rest", handleFunctionResolved); 249 + app.all("/.netlify/functions/:fn", handleFunction); 250 + 251 + // Non-/api/ function routes (from netlify.toml) 252 + function directFn(fnName) { 253 + return (req, res) => { req.params = { fn: fnName }; return handleFunction(req, res); }; 254 + } 255 + app.all("/handle", directFn("handle")); 256 + app.all("/user", directFn("user")); 257 + app.all("/run", directFn("run")); 258 + app.all("/reload/*rest", directFn("reload")); 259 + app.all("/session/*rest", directFn("session")); 260 + app.all("/authorized", directFn("authorized")); 261 + app.all("/handles", directFn("handles")); 262 + app.all("/redirect-proxy", directFn("redirect-proxy")); 263 + app.all("/redirect-proxy-sotce", directFn("redirect-proxy")); 264 + app.all("/presigned-upload-url/*rest", directFn("presigned-url")); 265 + app.all("/presigned-download-url/*rest", directFn("presigned-url")); 266 + app.all("/docs", directFn("docs")); 267 + app.all("/docs.json", directFn("docs")); 268 + app.all("/docs/*rest", directFn("docs")); 269 + app.all("/media-collection/*rest", directFn("media-collection")); 270 + app.all("/device-login", directFn("device-login")); 271 + app.all("/device-auth", directFn("device-auth")); 272 + app.all("/mcp", directFn("mcp-remote")); 273 + app.all("/m4l-plugins", directFn("m4l-plugins")); 274 + app.all("/slash", directFn("slash")); 275 + app.all("/sotce-blog/*rest", directFn("sotce-blog")); 276 + app.all("/profile/*rest", directFn("profile")); 277 + 278 + // Static files 279 + app.use(express.static(PUBLIC, { extensions: ["html"], dotfiles: "allow" })); 280 + 281 + // SPA fallback → index function 282 + app.use(async (req, res) => { 283 + if (functions["index"]) { 284 + req.params = { fn: "index" }; 285 + return handleFunction(req, res); 286 + } 287 + res.status(404).send("Not found"); 288 + }); 289 + 290 + // --- Start server --- 291 + if (DEV && HAS_SSL) { 292 + const opts = { 293 + cert: readFileSync(SSL_CERT), 294 + key: readFileSync(SSL_KEY), 295 + }; 296 + createHttpsServer(opts, app).listen(PORT, () => { 297 + console.log(`lith listening on https://localhost:${PORT}`); 298 + }); 299 + } else { 300 + createHttpServer(app).listen(PORT, () => { 301 + console.log(`lith listening on http://localhost:${PORT}`); 302 + }); 303 + }
+2 -1
system/netlify/functions/index.mjs
··· 4 4 import path from "path"; 5 5 import { promises as fs } from "fs"; 6 6 import { URLSearchParams } from "url"; 7 - import { encode } from "he"; 7 + import he from "he"; 8 + const { encode } = he; 8 9 import * as num from "../../public/aesthetic.computer/lib/num.mjs"; 9 10 import { 10 11 parse,
+1 -1
system/netlify/functions/news.mjs
··· 407 407 // Extract route from path, stripping the function prefix 408 408 let path = event.path || ""; 409 409 // Handle both direct calls and routed calls via /news.aesthetic.computer/ prefix 410 - const prefixes = ["/.netlify/functions/news", "/news.aesthetic.computer"]; 410 + const prefixes = ["/.netlify/functions/news", "/news.aesthetic.computer", "/api/news"]; 411 411 for (const prefix of prefixes) { 412 412 if (path.startsWith(prefix)) { 413 413 path = path.slice(prefix.length);
+2 -1
system/netlify/functions/pixel.js
··· 16 16 - [x] Nearest neighbor scale a painting after opening it. 17 17 #endregion */ 18 18 19 - const { builder } = require("@netlify/functions"); 19 + // builder from @netlify/functions was imported here but never used. 20 + // Removed require() to support ESM-only environments (lith). 20 21 import sharp from "sharp"; 21 22 import QRCode from "qrcode"; 22 23 import { readFileSync, mkdirSync, copyFileSync, existsSync } from "fs";
+2
system/netlify/functions/sotce-net.mjs
··· 108 108 // 🚙 Router 109 109 const method = event.httpMethod.toLowerCase(); 110 110 let path = event.path; 111 + if (path.startsWith("/api/sotce-net")) 112 + path = path.replace("/api/sotce-net", "/").replace("//", "/"); 111 113 if (path.startsWith("/sotce-net")) 112 114 path = path.replace("/sotce-net", "/").replace("//", "/"); 113 115 if (path.startsWith("/sotce.net"))
+9 -12
system/netlify/functions/vary.js
··· 1 - import { Configuration, OpenAIApi } from "openai"; 1 + import OpenAI from "openai"; 2 2 import Busboy from "busboy"; 3 3 import { respond } from "../../backend/http.mjs"; 4 4 import { PassThrough } from "stream"; ··· 11 11 form.image.data.name = "painting.png"; 12 12 13 13 try { 14 - const configuration = new Configuration({ 15 - apiKey: process.env.OPENAI_API_KEY, 14 + const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); 15 + 16 + const response = await openai.images.createVariation({ 17 + image: form.image.data, 18 + n: 1, 19 + size: "256x256", 16 20 }); 17 - const openai = new OpenAIApi(configuration); 18 - 19 - const response = await openai.createImageVariation( 20 - form.image.data, 21 - 1, 22 - "256x256" 23 - ); 24 21 25 22 // Fetch the returned image URL and respond with the file. 26 - const { got } = await import("got"); // Import "got" 27 - const imageResponse = await got.get(response.data.data[0].url, { 23 + const { got } = await import("got"); 24 + const imageResponse = await got.get(response.data[0].url, { 28 25 responseType: "buffer", 29 26 }); 30 27
+5
vault/.gitignore
··· 1 + # vault — sensitive credentials, never commit 2 + *.env 3 + *.secrets 4 + *.key 5 + *.pem