Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion docs/knowledge/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions docs/plans/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Analytics />` → `<CloudflareAnalytics />` 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.
76 changes: 76 additions & 0 deletions docs/runbooks/deploying-the-website.md
Original file line number Diff line number Diff line change
@@ -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
```
39 changes: 0 additions & 39 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions public/_redirects
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# SPA fallback — client-side routing (react-router) handles all paths.
/* /index.html 200
4 changes: 2 additions & 2 deletions src/app.tsx
Original file line number Diff line number Diff line change
@@ -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(() =>
Expand All @@ -24,7 +24,7 @@ const SupportPage = lazy(() =>
export function App() {
return (
<BrowserRouter>
{!isTauri() && <Analytics />}
{!isTauri() && <CloudflareAnalytics />}
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={isTauri() ? <Navigate to="/app" replace /> : <LandingPage />} />
Expand Down
52 changes: 52 additions & 0 deletions src/components/cloudflare-analytics.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CloudflareAnalytics />);

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(<CloudflareAnalytics />);

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(<CloudflareAnalytics />);
render(<CloudflareAnalytics />);

expect(document.querySelectorAll("script[data-cf-beacon]")).toHaveLength(1);
});
});
27 changes: 27 additions & 0 deletions src/components/cloudflare-analytics.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 8 additions & 5 deletions src/pages/privacy-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ export function PrivacyPage() {

<Section title="Web Application">
<p>
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 <code>sessionStorage</code> 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 <code>sessionStorage</code> for caching
GitHub API responses on the downloads page.
</p>
</Section>

Expand All @@ -54,7 +56,8 @@ export function PrivacyPage() {
<strong>GitHub</strong> — source code hosting, releases, issue tracking
</li>
<li>
<strong>Vercel</strong> — web application hosting
<strong>Cloudflare</strong> — web application hosting and privacy-first, cookieless
web analytics
</li>
</ul>
<p>
Expand Down
5 changes: 0 additions & 5 deletions vercel.json

This file was deleted.

4 changes: 4 additions & 0 deletions wrangler.toml
Original file line number Diff line number Diff line change
@@ -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"
Loading