diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..db2bbcd --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Cloudflare Web Analytics beacon token for the web build (threatforge.dev). +# Get it from: Cloudflare dashboard → Web Analytics → your site → JS snippet token. +# Leave unset for local dev and for the Tauri desktop build (analytics is web-only). +# In production, set this as a build-time env var in the Cloudflare Pages project. +VITE_CF_BEACON_TOKEN= diff --git a/.gitignore b/.gitignore index 8a4b0cc..917a76b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,15 @@ screenshots/ *.njsproj *.sln *.sw? + +# Local env files +.env +.env.* +!.env.example + +# Cloudflare Wrangler +.wrangler/ + +# Local-only todo / scratch (per-developer, never committed) +docs/plans/todo.local.md +*.local.md diff --git a/docs/knowledge/architecture.md b/docs/knowledge/architecture.md index 7609c85..f4b2632 100644 --- a/docs/knowledge/architecture.md +++ b/docs/knowledge/architecture.md @@ -104,7 +104,7 @@ threat-forge/ | Local | Developer workstation | `npm run tauri dev` (hot reload) | $0 | | CI | Automated builds + tests | GitHub Actions matrix (ubuntu, macos, windows) | $0 (public repo) | | Release | Signed binaries | GitHub Releases | $0 | -| Website | Docs + landing page | GitHub Pages or Cloudflare Pages | $0 | +| Website | Docs + landing page (threatforge.dev) | Cloudflare Pages (Git integration, auto-deploy on push to `main`) | $0 | ### CI Pipeline diff --git a/docs/plans/todo.md b/docs/plans/todo.md index f29ed2e..a7fc96c 100644 --- a/docs/plans/todo.md +++ b/docs/plans/todo.md @@ -109,3 +109,37 @@ Shared execution plan for humans and LLM agents. Update this file before, during - [x] Update `docs/runbooks/releasing-a-version.md` — AI smoke test checklist - [x] Update `CLAUDE.md` — AI module description in project structure - [x] Final validation pass: Biome clean, Clippy clean, 416 TS tests, 59 Rust tests + +## 2026-06-28 — Migrate web hosting from Vercel to Cloudflare Pages + +Goal: eliminate the Vercel Pro bill. Move the web/SPA build (threatforge.dev) to +Cloudflare Pages (Git integration) and replace Vercel Analytics with Cloudflare Web Analytics. +Desktop app (Tauri/GitHub Releases) is unaffected. + +### Plan +- [x] Remove Vercel hosting config + - [x] Delete `vercel.json` + - [x] Add `wrangler.toml` (Pages: `pages_build_output_dir = "dist"`) + - [x] Add `public/_redirects` (`/* /index.html 200`) for SPA routing +- [x] Replace analytics + - [x] Remove `@vercel/analytics` from `package.json` + lockfile + - [x] Add `src/components/cloudflare-analytics.tsx` (env-gated beacon, web-only) + - [x] Swap `` → `` in `src/app.tsx` + - [x] Add `.env.example` documenting `VITE_CF_BEACON_TOKEN` + - [x] Add `.env` / `.wrangler/` to `.gitignore` +- [x] Update user-facing + project docs + - [x] `src/pages/privacy-page.tsx` — Vercel → Cloudflare (hosting + analytics) + - [x] `docs/runbooks/` — add web deploy runbook (CF Pages) + - [x] `docs/knowledge/architecture.md` — confirm host = Cloudflare Pages + - [x] `CLAUDE.md` — note web deploy target if referenced +- [x] Validate + - [x] `npm install` (lockfile updated, no @vercel/analytics) + - [x] `npm run build:web` — builds clean to `dist`, `_redirects` present in `dist` + - [x] `npx biome check --write .` + - [x] `npx tsc --noEmit` + - [x] `npx vitest --run` +- [x] Hand off Cloudflare dashboard steps to user (account-side, can't be done from repo) + +### Notes +- Deploy method chosen: CF Pages Git integration (mirrors current Vercel auto-deploy, zero Actions minutes). +- Analytics chosen: Cloudflare Web Analytics (free, cookieless). Beacon gated by `!isTauri()` + token, so it never ships in the desktop build. diff --git a/docs/runbooks/deploying-the-website.md b/docs/runbooks/deploying-the-website.md new file mode 100644 index 0000000..2c6674c --- /dev/null +++ b/docs/runbooks/deploying-the-website.md @@ -0,0 +1,76 @@ +# Deploying the Website + +The web version of ThreatForge — the marketing site plus the browser build — is served +at [threatforge.dev](https://threatforge.dev) from **Cloudflare Pages**. The desktop app +ships separately via GitHub Releases (see [releasing-a-version.md](./releasing-a-version.md)). + +## How it deploys + +Cloudflare Pages is connected to this repository via Git integration. Every push to `main` +triggers an automatic production build and deploy. Pull requests get preview deployments. +There is no deploy workflow in the repo — the build runs on Cloudflare's infrastructure. + +## Build settings (Cloudflare Pages project) + +| Setting | Value | +|---------|-------| +| Production branch | `main` | +| Build command | `npm run build:web` | +| Build output directory | `dist` | +| Node version | from `.node-version` (Node 20) | + +`wrangler.toml` records `pages_build_output_dir = "dist"`. SPA routing is handled by +`public/_redirects` (`/* /index.html 200`), which Vite copies into `dist` on build — +every path falls back to `index.html` so react-router can take over client-side. + +## Environment variables (set in the Pages dashboard) + +| Variable | Purpose | +|----------|---------| +| `VITE_CF_BEACON_TOKEN` | Cloudflare Web Analytics beacon token. Web-only — the desktop build never reads it. | + +Set this under **Settings → Environment variables → Production** (and Preview if you want +analytics on previews). See `.env.example` for the full description. + +## First-time setup (one-time, in the Cloudflare dashboard) + +1. **Create the Pages project** + - Cloudflare dashboard → **Workers & Pages** → **Create** → **Pages** → **Connect to Git**. + - Select this repository, production branch `main`. + - Framework preset: **None** (Vite). Build command `npm run build:web`, output `dist`. + - Deploy. The first build provisions a `*.pages.dev` URL. + +2. **Attach the custom domain** + - Pages project → **Custom domains** → **Set up a domain** → `threatforge.dev`. + - If the domain's DNS is on Cloudflare, the record is added automatically. Otherwise + point a CNAME at the `*.pages.dev` target. + +3. **Enable Web Analytics** + - Dashboard → **Web Analytics** → add `threatforge.dev` → copy the beacon **token**. + - Add it as the `VITE_CF_BEACON_TOKEN` env var (above) and redeploy. The beacon then + loads on the web build only (gated by `!isTauri()`). + +4. **Decommission Vercel** + - Once threatforge.dev resolves to Cloudflare and the site is verified, remove the + project from the Vercel dashboard to stop billing. Delete the domain from Vercel only + after DNS has fully cut over to Cloudflare. + +## Verifying a deploy + +- Check the deploy status in the Pages project's **Deployments** tab. +- Load `https://threatforge.dev`, hard-refresh, and click through `/downloads`, `/about`, + `/privacy`, `/terms`, `/support` — deep links must resolve (confirms `_redirects` works). +- Confirm analytics traffic appears under **Web Analytics** (only if the beacon token is set). + +## Rollback + +Pages project → **Deployments** → pick the last good deployment → **Rollback to this +deployment**. Production traffic switches instantly; no rebuild needed. + +## Local check before pushing + +```bash +npm run build:web # builds to dist/ +ls dist/_redirects # confirm the SPA fallback shipped +npx vite preview # serve dist/ locally and click through routes +``` diff --git a/package-lock.json b/package-lock.json index ce48f06..d40b156 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", - "@vercel/analytics": "^1.6.1", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -2468,44 +2467,6 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, - "node_modules/@vercel/analytics": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", - "integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==", - "license": "MPL-2.0", - "peerDependencies": { - "@remix-run/react": "^2", - "@sveltejs/kit": "^1 || ^2", - "next": ">= 13", - "react": "^18 || ^19 || ^19.0.0-rc", - "svelte": ">= 4", - "vue": "^3", - "vue-router": "^4" - }, - "peerDependenciesMeta": { - "@remix-run/react": { - "optional": true - }, - "@sveltejs/kit": { - "optional": true - }, - "next": { - "optional": true - }, - "react": { - "optional": true - }, - "svelte": { - "optional": true - }, - "vue": { - "optional": true - }, - "vue-router": { - "optional": true - } - } - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", diff --git a/package.json b/package.json index 933eb26..3ddf308 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", - "@vercel/analytics": "^1.6.1", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 0000000..2e4fd52 --- /dev/null +++ b/public/_redirects @@ -0,0 +1,2 @@ +# SPA fallback — client-side routing (react-router) handles all paths. +/* /index.html 200 diff --git a/src/app.tsx b/src/app.tsx index 339ae8d..f57e72d 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,6 +1,6 @@ -import { Analytics } from "@vercel/analytics/react"; import { lazy, Suspense } from "react"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { CloudflareAnalytics } from "./components/cloudflare-analytics"; import { isTauri } from "./lib/platform"; const AppLayout = lazy(() => @@ -24,7 +24,7 @@ const SupportPage = lazy(() => export function App() { return ( - {!isTauri() && } + {!isTauri() && } }> : } /> diff --git a/src/components/cloudflare-analytics.test.tsx b/src/components/cloudflare-analytics.test.tsx new file mode 100644 index 0000000..0f5761d --- /dev/null +++ b/src/components/cloudflare-analytics.test.tsx @@ -0,0 +1,52 @@ +import { render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +function removeBeacons() { + for (const s of document.querySelectorAll("script[data-cf-beacon]")) { + s.remove(); + } +} + +describe("CloudflareAnalytics", () => { + afterEach(() => { + removeBeacons(); + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("injects no beacon when the token is unset", async () => { + vi.stubEnv("VITE_CF_BEACON_TOKEN", ""); + vi.resetModules(); + const { CloudflareAnalytics } = await import("./cloudflare-analytics"); + + render(); + + expect(document.querySelector("script[data-cf-beacon]")).toBeNull(); + }); + + it("injects the Cloudflare beacon with the token when set", async () => { + vi.stubEnv("VITE_CF_BEACON_TOKEN", "test-token-123"); + vi.resetModules(); + const { CloudflareAnalytics } = await import("./cloudflare-analytics"); + + render(); + + const script = document.querySelector("script[data-cf-beacon]"); + expect(script).not.toBeNull(); + expect(script?.getAttribute("src")).toBe("https://static.cloudflareinsights.com/beacon.min.js"); + expect(script?.getAttribute("data-cf-beacon")).toBe( + JSON.stringify({ token: "test-token-123" }), + ); + }); + + it("injects only one beacon even when mounted twice", async () => { + vi.stubEnv("VITE_CF_BEACON_TOKEN", "test-token-123"); + vi.resetModules(); + const { CloudflareAnalytics } = await import("./cloudflare-analytics"); + + render(); + render(); + + expect(document.querySelectorAll("script[data-cf-beacon]")).toHaveLength(1); + }); +}); diff --git a/src/components/cloudflare-analytics.tsx b/src/components/cloudflare-analytics.tsx new file mode 100644 index 0000000..a8990c6 --- /dev/null +++ b/src/components/cloudflare-analytics.tsx @@ -0,0 +1,27 @@ +import { useEffect } from "react"; + +const BEACON_SRC = "https://static.cloudflareinsights.com/beacon.min.js"; +const BEACON_TOKEN = import.meta.env.VITE_CF_BEACON_TOKEN as string | undefined; + +/** + * Cloudflare Web Analytics beacon for the web build only. + * + * Privacy-first and cookieless — no user profiling. The beacon is injected at + * runtime so it is never bundled into the Tauri desktop build, and it only loads + * when `VITE_CF_BEACON_TOKEN` is set at build time (Cloudflare Pages env var). + * Callers must still gate this behind `!isTauri()`. + */ +export function CloudflareAnalytics() { + useEffect(() => { + if (!BEACON_TOKEN) return; + if (document.querySelector("script[data-cf-beacon]")) return; + + const script = document.createElement("script"); + script.defer = true; + script.src = BEACON_SRC; + script.setAttribute("data-cf-beacon", JSON.stringify({ token: BEACON_TOKEN })); + document.head.appendChild(script); + }, []); + + return null; +} diff --git a/src/pages/privacy-page.tsx b/src/pages/privacy-page.tsx index 748348b..18a5db2 100644 --- a/src/pages/privacy-page.tsx +++ b/src/pages/privacy-page.tsx @@ -33,10 +33,12 @@ export function PrivacyPage() {

- The web version at threatforge.dev is hosted on Vercel. Standard Vercel hosting logs - (IP addresses, request timestamps) may be collected as part of infrastructure - operation. No cookies are used beyond sessionStorage for caching GitHub - API responses on the downloads page. + The web version at threatforge.dev is hosted on Cloudflare Pages. Standard hosting + logs (IP addresses, request timestamps) may be collected as part of infrastructure + operation. We use Cloudflare Web Analytics, which is privacy-first and cookieless — it + does not use cookies, fingerprinting, or cross-site tracking, and does not profile + individual users. No cookies are used beyond sessionStorage for caching + GitHub API responses on the downloads page.

@@ -54,7 +56,8 @@ export function PrivacyPage() { GitHub — source code hosting, releases, issue tracking
  • - Vercel — web application hosting + Cloudflare — web application hosting and privacy-first, cookieless + web analytics
  • diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 38b7f63..0000000 --- a/vercel.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "buildCommand": "npm run build:web", - "outputDirectory": "dist", - "rewrites": [{ "source": "/(.*)", "destination": "/" }] -} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..d954032 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,4 @@ +# Cloudflare Pages config +# Build command is set in the Pages dashboard: `npm run build:web` +name = "threat-forge" +pages_build_output_dir = "dist"