diff --git a/deploy/caddy/Caddyfile.snapshot b/deploy/caddy/Caddyfile.snapshot new file mode 100644 index 0000000..0ebf024 --- /dev/null +++ b/deploy/caddy/Caddyfile.snapshot @@ -0,0 +1,148 @@ +# SNAPSHOT (read-only) of the live Caddy config. +# Live source of truth: /home/jay/srv/caddy/caddy-config/Caddyfile (mounted into the 'caddy' container). +# Captured so the upbeatbytes try_files {path} {path}.html change is tracked. Do not edit here expecting it to deploy. + +{ + email thejayman77@gmail.com +} + +tjm77.com, www.tjm77.com { + tls { + dns cloudflare {env.CF_API_TOKEN} + } + root * /srv/sites/tjm77 + file_server + encode gzip zstd + log { + output file /data/access-tjm77.log + } +} + +jsj-designs.com, www.jsj-designs.com { + tls { + dns cloudflare {env.CF_API_TOKEN} + } + root * /srv/sites/jsj + file_server + encode gzip zstd + log { + output file /data/access-jsj.log + } +} + +# Canonical host = apex. www redirects to it BEFORE the app, so OAuth always +# starts from the same host its callback uses (the ub_oauth cookie is host-only; +# starting from www then bouncing to the apex callback loses it → error=google). +www.upbeatbytes.com { + tls { + dns cloudflare {env.CF_API_TOKEN} + } + redir https://upbeatbytes.com{uri} permanent +} + +upbeatbytes.com { + tls { + dns cloudflare {env.CF_API_TOKEN} + } + encode gzip zstd + + # Dynamic API + server-rendered pages (share, digest, sitemap) → FastAPI. + @api path /api/* /healthz /docs /docs/* /openapi.json /a/* /today /sitemap.xml + handle @api { + reverse_proxy upbeatbytes-api:8000 + } + + # Everything else → the static SvelteKit SPA. try_files falls back to + # index.html so deep client routes (e.g. /auth/verify) boot the app + # instead of 404ing. + handle { + root * /srv/sites/upbeatbytes + + # Content-hashed assets never change for a given URL — cache them forever. + @immutable path /_app/immutable/* + header @immutable Cache-Control "public, max-age=31536000, immutable" + + # The SPA shell: "/" and extensionless client routes (try_files → index.html). + # Briefly cacheable at the CDN edge (s-maxage) so a first paint never depends + # on this origin's uplink; browsers still revalidate every visit (max-age=0). + # A deploy propagates within ≤2min and old immutable chunks are kept for a + # 14-day grace window, so a briefly-stale shell still boots cleanly. + # (Requires a Cloudflare Cache Rule marking these paths eligible — CF does + # not cache HTML by default.) + @shell { + not path /_app/immutable/* + not path *.* + } + header @shell Cache-Control "public, max-age=0, s-maxage=120, stale-while-revalidate=600" + + # Mutable FILES (service worker, version manifest, webmanifest, word lists, + # icons) must revalidate every time — a pinned stale service worker is the + # classic blank-screen cause behind a CDN. + @revalidate { + not path /_app/immutable/* + path *.* + } + header @revalidate Cache-Control "no-cache" + + # Serve a route's own prerendered HTML when it exists (e.g. /play -> play.html, + # which carries its own canonical/OG metadata), else fall back to the SPA shell. + # Cache-Control matchers above run on the ORIGINAL extensionless path, so /play + # still gets the @shell header before this rewrite. + try_files {path} {path}.html /index.html + file_server + } + + log { + output file /data/access-upbeatbytes.log + } +} + +git.tjm77.com { + tls { + dns cloudflare {env.CF_API_TOKEN} + } + reverse_proxy gitea:3000 + encode gzip zstd + log { + output file /data/access-gitea.log + } +} + +api.tjm77.com { + tls { + dns cloudflare {env.CF_API_TOKEN} + } + encode gzip zstd + # Recipe finder (What can I make? web tier) — must precede the arbiter catch-all. + @finder path /v1/find-recipes /v1/find-recipes/* + handle @finder { + reverse_proxy finder:8090 + } + # Per-device token registration → auth service. + @register path /v1/register + handle @register { + reverse_proxy auth:8070 + } + # In-app feedback relay → auth service (validates the device token + SMTP-sends). + # Keeps the arbiter a pure LLM gateway. Must precede the arbiter catch-all. + @feedback path /v1/feedback + handle @feedback { + reverse_proxy auth:8070 + } + # App update policy — public static JSON (no secrets). Edit ~/srv/sites/api/app-version.json + # to change what testers are prompted to update to; no redeploy needed. Precedes the catch-all. + @appversion path /v1/app-version + handle @appversion { + root * /srv/sites + rewrite * /api/app-version.json + header Content-Type application/json + file_server + } + # LLM Arbiter (everything else); Bearer auth enforced by the arbiter. + handle { + reverse_proxy arbiter:8080 + } + log { + output file /data/access-arbiter.log + } +} diff --git a/frontend/package.json b/frontend/package.json index 666116a..b406604 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite dev --host", "build": "vite build", + "postbuild": "node scripts/patch-play-head.mjs", "preview": "vite preview", "test": "vitest run" }, diff --git a/frontend/scripts/patch-play-head.mjs b/frontend/scripts/patch-play-head.mjs new file mode 100644 index 0000000..6ac14e6 --- /dev/null +++ b/frontend/scripts/patch-play-head.mjs @@ -0,0 +1,41 @@ +// Post-build: give build/play.html its own social/canonical metadata. +// +// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's +// HOMEPAGE
— meaning a shared /play link previews as the news homepage. Client +// svelte:head can't fix that for non-JS social scrapers (Twitter/Slack/iMessage/etc.). +// So we rewrite the static head of play.html here, at build time. Deep-linked variants +// (/play?game=…) are served the same file, so they inherit this games-hub preview. +import { readFile, writeFile } from 'node:fs/promises'; + +const FILE = new URL('../build/play.html', import.meta.url); +const URL_PLAY = 'https://upbeatbytes.com/play'; +const TITLE = 'Play · Upbeat Bytes — calm daily games'; +const DESC = + 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' + + 'A friendly little break from the doomscroll.'; + +const subs = [ + [/| Game | Arrivals | Engaged | Completed | Shared |
|---|---|---|---|---|
| {label} | {stats.games.by_game[k].arrival} | {stats.games.by_game[k].started} | {stats.games.by_game[k].completed} | {stats.games.by_game[k].shared} |
“Share arrivals” = landings via a shared game link (utm_source=game_share). “Engaged” = first move (guess/find/flip), not just opening the page.
+