: } />
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"