diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0bd56a96a..efc71d584 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,3 +26,40 @@ jobs:
- run: pnpm lint
- run: pnpm test
- run: pnpm build
+
+ # crm/ is its own package (Railway); the root typecheck excludes it, so it
+ # gets its own check. Runs only when the app changes.
+ crm:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dorny/paths-filter@v3
+ id: changed
+ with:
+ filters: |
+ crm:
+ - 'crm/**'
+ - uses: pnpm/action-setup@v4
+ if: steps.changed.outputs.crm == 'true'
+ with:
+ version: 10
+ - uses: actions/setup-node@v4
+ if: steps.changed.outputs.crm == 'true'
+ with:
+ node-version: 22
+ - uses: oven-sh/setup-bun@v2
+ if: steps.changed.outputs.crm == 'true'
+ with:
+ bun-version: latest
+ - run: pnpm install --frozen-lockfile --ignore-workspace
+ if: steps.changed.outputs.crm == 'true'
+ working-directory: crm
+ - run: pnpm typecheck
+ if: steps.changed.outputs.crm == 'true'
+ working-directory: crm
+ - run: pnpm test
+ if: steps.changed.outputs.crm == 'true'
+ working-directory: crm
+ - run: pnpm build
+ if: steps.changed.outputs.crm == 'true'
+ working-directory: crm
diff --git a/crm/.env.example b/crm/.env.example
new file mode 100644
index 000000000..10b293d3c
--- /dev/null
+++ b/crm/.env.example
@@ -0,0 +1,18 @@
+# Shared password (16+ chars) and a random secret (16+ chars) that signs session cookies.
+# Rotating either logs everyone out.
+CRM_PASSWORD=
+CRM_SESSION_SECRET=
+# PostHog personal API key with query:read on the OpenChainBench project, and the project id.
+POSTHOG_PERSONAL_API_KEY=
+POSTHOG_PROJECT_ID=
+POSTHOG_HOST=https://us.posthog.com
+# Host the site is served from; events from staging/localhost are excluded.
+SITE_HOST=openchainbench.com
+# HogQL queries the app may spend per rolling hour (PostHog allows 2400/hour per organisation).
+POSTHOG_HOURLY_BUDGET=300
+# Minutes between two automatic refreshes of the snapshot.
+REFRESH_MINUTES=60
+# Directory the snapshot and its daily history are written to (a Railway volume in production).
+SNAPSHOT_DIR=/data
+# Optional: Dune API key, to show credits left on the plan.
+DUNE_API_KEY=
diff --git a/crm/.gitignore b/crm/.gitignore
new file mode 100644
index 000000000..dfbefdac3
--- /dev/null
+++ b/crm/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+.next
+.snapshots
+.env.local
+*.tsbuildinfo
+next-env.d.ts
diff --git a/crm/.railwayignore b/crm/.railwayignore
new file mode 100644
index 000000000..134893a6d
--- /dev/null
+++ b/crm/.railwayignore
@@ -0,0 +1,4 @@
+node_modules
+.next
+.snapshots
+.env.local
diff --git a/crm/Dockerfile b/crm/Dockerfile
new file mode 100644
index 000000000..d2800ef3f
--- /dev/null
+++ b/crm/Dockerfile
@@ -0,0 +1,26 @@
+# syntax=docker/dockerfile:1
+FROM node:22-alpine AS deps
+WORKDIR /app
+RUN corepack enable
+COPY package.json pnpm-lock.yaml ./
+RUN pnpm install --frozen-lockfile
+
+FROM node:22-alpine AS build
+WORKDIR /app
+RUN corepack enable
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+RUN pnpm build
+
+FROM node:22-alpine AS run
+WORKDIR /app
+ENV NODE_ENV=production
+ENV HOSTNAME=0.0.0.0
+COPY --from=build /app/.next/standalone ./
+COPY --from=build /app/.next/static ./.next/static
+COPY --from=build /app/public ./public
+# Runs as root: the Railway volume at /data is mounted root-owned and the
+# platform offers no fsGroup; the container holds one password and read keys.
+RUN mkdir -p /data
+EXPOSE 3210
+CMD ["node", "server.js"]
diff --git a/crm/README.md b/crm/README.md
new file mode 100644
index 000000000..3dfab701c
--- /dev/null
+++ b/crm/README.md
@@ -0,0 +1,94 @@
+# OCB CRM
+
+Internal dashboard for OpenChainBench: traffic (PostHog), data health (the
+worker's index blob), harness health (Prometheus targets) and the Dune plan.
+One shared password, per-login sessions, one Railway service, no database.
+
+## What it shows
+
+| Page | Source | Numbers |
+|---|---|---|
+| Overview | PostHog, blob, Prometheus | visitors, pageviews, sessions (7 d vs previous 7 d), **AI-referred visitors** (ChatGPT, Perplexity, Claude, Gemini, Copilot, …), search-referred visitors, 28 d daily series, 12 w weekly series with the AI share, channels, AI domains, sections |
+| Pages | PostHog | sections week over week, biggest gains and losses, top 100 pages (filter by section), entry pages |
+| Audience | PostHog | new vs returning, bounce, countries, devices, UTM sources, referring domains with their channel |
+| Data health | index blob, Prometheus, Dune | live / stale (> 24 h) / expired (> 7 d) benches per category, benches needing attention, scrape targets down, Dune credits and period end, the daily history kept on the volume |
+
+The site captures `$pageview` and `$pageleave` only (autocapture off, nobody
+identified); every query reads `$pageview`, so every traffic number is a
+pageview aggregate and a visitor is a device cookie. Events from staging and
+localhost are excluded (`properties.$host`).
+
+Bench health reads `aggregate/index.json` (every bench with its status and
+last run), not the sitemap blob: the worker drops expired chain RPC benches
+from the sitemap before publishing, which is exactly what this page must show.
+
+## How it stays under the PostHog rate limit
+
+PostHog allows **2400 query requests per hour per organisation**, shared by
+every key and every team member. This app never queries in the request path:
+
+- a refresh runs a **fixed list of 11 HogQL queries**, one at a time
+ (`lib/traffic.ts`), and writes a snapshot; pages read the snapshot;
+- the scheduler (`instrumentation.ts`) refreshes every `REFRESH_MINUTES`
+ (default 60): **11 queries per hour, about 0.5 % of the organisation's
+ budget**;
+- the Refresh button is refused for 10 minutes after any refresh;
+- a local budget (`POSTHOG_HOURLY_BUDGET`, default 300 per rolling hour) is a
+ second guard; a 429 or an exhausted budget stops the batch, the sections that
+ did not run keep their previous values, and the next scheduled refresh
+ retries.
+
+A section that fails keeps its previous value and shows its error in the
+header, so an upstream blip never blanks the dashboard.
+
+## Run locally
+
+```bash
+cd crm
+pnpm install --ignore-workspace
+cp .env.example .env.local # fill CRM_PASSWORD, CRM_SESSION_SECRET, POSTHOG_*, optionally DUNE_API_KEY
+set -a; source .env.local; set +a
+SNAPSHOT_DIR=.snapshots pnpm refresh # one refresh from the CLI
+SNAPSHOT_DIR=.snapshots pnpm dev # http://localhost:3210
+pnpm test && pnpm typecheck
+```
+
+## Deploy (Railway)
+
+The service is `ocb-crm` in the Railway project `Dashboard OpenChainBench`,
+built from `crm/Dockerfile`, with a volume mounted at `/data` for the snapshot
+and its history.
+
+```bash
+cd crm
+railway link # project Dashboard OpenChainBench, service ocb-crm
+railway up --detach # uploads this directory, builds the Dockerfile
+railway logs
+```
+
+Variables (Railway service settings): `CRM_PASSWORD`, `CRM_SESSION_SECRET`, `POSTHOG_PERSONAL_API_KEY`,
+`POSTHOG_PROJECT_ID`, optionally `DUNE_API_KEY`, `POSTHOG_HOURLY_BUDGET`,
+`REFRESH_MINUTES`. `SNAPSHOT_DIR=/data` and `PORT` are set on the service.
+
+## Sessions
+
+The cookie is `nonce.expiry.signature`, signed with `CRM_SESSION_SECRET` (random,
+not the password, so a leaked cookie gives nothing to brute force) and valid
+only while its nonce is listed in `/data/sessions.json`: logout revokes it,
+rotating either variable logs everyone out. Login attempts are limited to 10
+per client per 15 minutes. Both variables must be 16 characters or more.
+
+Module state (snapshot cache, refresh mutex, PostHog budget, login counters)
+lives on `globalThis` and the snapshot file is re-read whenever its mtime
+moves: Next bundles `instrumentation.ts` and the routes in different layers,
+and each layer gets its own module instance otherwise.
+
+## Adding a metric
+
+1. Add a query to `QUERIES` in `lib/traffic.ts` and its mapping in
+ `loadTrafficSection`; the refresh budget grows by one query per hour.
+2. Extend the `Traffic` type, render it on a page.
+3. `pnpm test`: the query test checks every query stays scoped to
+ `$pageview` on the production host.
+
+Non-PostHog sources go in `lib/ocb.ts` and get a `step()` in `lib/snapshot.ts`.
diff --git a/crm/app/api/login/route.ts b/crm/app/api/login/route.ts
new file mode 100644
index 000000000..dbed784a9
--- /dev/null
+++ b/crm/app/api/login/route.ts
@@ -0,0 +1,23 @@
+import type { NextRequest } from "next/server";
+import { authConfigured, clientKey, COOKIE, issueSession, loginAllowed, passwordMatches, recordLoginAttempt, seeOther, SESSION_DAYS } from "@/lib/auth";
+
+// A small fixed delay per attempt on top of the per-client limit.
+const ATTEMPT_DELAY_MS = 600;
+
+export async function POST(request: NextRequest) {
+ const form = await request.formData();
+ const password = String(form.get("password") ?? "");
+ const nextPath = String(form.get("next") ?? "/");
+ const back = (error: string) => seeOther(request, `/login?error=${error}${nextPath !== "/" ? `&next=${encodeURIComponent(nextPath)}` : ""}`);
+ const key = clientKey(request);
+ if (!loginAllowed(key)) return back("limited");
+ recordLoginAttempt(key);
+ await new Promise((r) => setTimeout(r, ATTEMPT_DELAY_MS));
+ if (!authConfigured() || !(await passwordMatches(password))) return back("1");
+ const res = seeOther(request, nextPath);
+ const token = await issueSession();
+ const attrs = [`${COOKIE}=${token}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${SESSION_DAYS * 86_400}`];
+ if (process.env.NODE_ENV === "production") attrs.push("Secure");
+ res.headers.append("Set-Cookie", attrs.join("; "));
+ return res;
+}
diff --git a/crm/app/api/logout/route.ts b/crm/app/api/logout/route.ts
new file mode 100644
index 000000000..853c17d13
--- /dev/null
+++ b/crm/app/api/logout/route.ts
@@ -0,0 +1,9 @@
+import type { NextRequest } from "next/server";
+import { COOKIE, revokeSession, seeOther } from "@/lib/auth";
+
+export async function POST(request: NextRequest) {
+ await revokeSession(request.cookies.get(COOKIE)?.value);
+ const res = seeOther(request, "/login");
+ res.headers.append("Set-Cookie", `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
+ return res;
+}
diff --git a/crm/app/api/refresh/route.ts b/crm/app/api/refresh/route.ts
new file mode 100644
index 000000000..3b4528667
--- /dev/null
+++ b/crm/app/api/refresh/route.ts
@@ -0,0 +1,24 @@
+import type { NextRequest } from "next/server";
+import { seeOther } from "@/lib/auth";
+import { MANUAL_COOLDOWN_MINUTES, readSnapshot, refreshSnapshot, snapshotAgeMinutes } from "@/lib/snapshot";
+
+// Manual refresh, behind the session cookie (proxy.ts) and a cooldown: the
+// button cannot become a way to spend the PostHog budget.
+export async function POST(request: NextRequest) {
+ const snap = await readSnapshot();
+ const age = snapshotAgeMinutes(snap);
+ // Back to the page the button was on; only the path of the referer is kept.
+ let back = "/";
+ try {
+ const ref = new URL(request.headers.get("referer") ?? "", "http://x");
+ back = ref.pathname.startsWith("/") ? ref.pathname : "/";
+ } catch {
+ // fall through to the overview
+ }
+ if (age != null && age < MANUAL_COOLDOWN_MINUTES) {
+ return seeOther(request, `${back}?refresh=cooldown:${Math.ceil(MANUAL_COOLDOWN_MINUTES - age)}`);
+ }
+ const result = await refreshSnapshot("manual");
+ const flag = result.joined ? "joined" : result.stoppedBy ? "partial" : result.failed.length ? "errors" : "ok";
+ return seeOther(request, `${back}?refresh=${flag}`);
+}
diff --git a/crm/app/api/snapshot/route.ts b/crm/app/api/snapshot/route.ts
new file mode 100644
index 000000000..904c5f279
--- /dev/null
+++ b/crm/app/api/snapshot/route.ts
@@ -0,0 +1,8 @@
+import { NextResponse } from "next/server";
+import { readHistory, readSnapshot } from "@/lib/snapshot";
+
+// The raw snapshot and history, for a spreadsheet or a script. Session cookie required.
+export async function GET() {
+ const [snapshot, history] = await Promise.all([readSnapshot(), readHistory()]);
+ return NextResponse.json({ snapshot, history }, { headers: { "Cache-Control": "private, no-store" } });
+}
diff --git a/crm/app/audience/page.tsx b/crm/app/audience/page.tsx
new file mode 100644
index 000000000..b65dea648
--- /dev/null
+++ b/crm/app/audience/page.tsx
@@ -0,0 +1,93 @@
+import { Shell } from "@/components/shell";
+import { Bars, Delta, Empty, fmtInt, fmtPct, Kpi } from "@/components/ui";
+import { readSnapshot } from "@/lib/snapshot";
+
+export const dynamic = "force-dynamic";
+
+const CHANNEL_LABEL = { ai: "AI", search: "Search", social: "Social", direct: "Direct", referral: "Referral", internal: "Internal" } as const;
+
+export default async function AudiencePage({ searchParams }: { searchParams: Promise<{ refresh?: string }> }) {
+ const [snap, sp] = await Promise.all([readSnapshot(), searchParams]);
+ const t = snap.traffic;
+ const a = t.audience;
+ const total = a ? a.newVisitors + a.returningVisitors : 0;
+ const referrers = (t.referrers ?? []).filter((r) => r.channel !== "direct" && r.channel !== "internal" && (r.visitors > 0 || r.prevVisitors > 0)).slice(0, 40);
+
+ return (
+
+
+ 0 ? `${fmtPct(a.newVisitors / total)} of active` : undefined} />
+ 0 ? `${fmtPct(a.returningVisitors / total)} of active` : "first seen before the window"} />
+
+
+
+
+
+
+
Countries, 7 d
+ {t.countries && t.countries.length > 0 ? (
+
({ label: c.name, value: c.visitors, hint: `· ${fmtPct(c.share)}` }))} />
+ ) : (
+
+ )}
+
+
+
Devices, 7 d
+ {t.devices && t.devices.length > 0 ? (
+
({ label: d.name, value: d.visitors, hint: `· ${fmtPct(d.share)}` }))} />
+ ) : (
+
+ )}
+ UTM sources, 7 d
+ {t.utm && t.utm.length > 0 ? (
+
+
+ {t.utm.map((u) => (
+
+ {u.source}
+ {u.medium}
+ {fmtInt(u.visitors)}
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
+
+ Referring domains, 7 d (direct and internal excluded)
+ {referrers.length > 0 ? (
+
+
+
+ Domain
+ Channel
+ Visitors
+ w/w
+ Pageviews
+
+
+
+ {referrers.map((r) => (
+
+ {r.domain}
+ {CHANNEL_LABEL[r.channel]}
+ {fmtInt(r.visitors)}
+
+
+
+ {fmtInt(r.pageviews)}
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/crm/app/globals.css b/crm/app/globals.css
new file mode 100644
index 000000000..39fe6da5b
--- /dev/null
+++ b/crm/app/globals.css
@@ -0,0 +1,33 @@
+@import "tailwindcss";
+
+:root {
+ --bg: #0b0d10;
+ --panel: #12151a;
+ --line: #22272f;
+ --ink: #e6e8eb;
+ --muted: #8b939e;
+ --faint: #5c6673;
+ --accent: #7c8cff;
+ --good: #3ecf8e;
+ --warn: #f5b942;
+ --bad: #f0625d;
+}
+
+html { color-scheme: dark; }
+body {
+ background: var(--bg);
+ color: var(--ink);
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ font-size: 14px;
+}
+.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; }
+.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; }
+.label { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); }
+table.data { width: 100%; border-collapse: collapse; }
+table.data th { text-align: left; font-weight: 500; color: var(--muted); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; padding: 6px 8px; border-bottom: 1px solid var(--line); }
+table.data td { padding: 6px 8px; border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent); vertical-align: top; }
+table.data td.num, table.data th.num { text-align: right; font-variant-numeric: tabular-nums; }
+a.nav { color: var(--muted); padding: 6px 10px; border-radius: 6px; }
+a.nav:hover { color: var(--ink); background: var(--panel); }
+a.nav[aria-current="page"] { color: var(--ink); background: var(--panel); border: 1px solid var(--line); }
+.up { color: var(--good); } .down { color: var(--bad); } .flat { color: var(--faint); }
diff --git a/crm/app/health/page.tsx b/crm/app/health/page.tsx
new file mode 100644
index 000000000..1a6e96f75
--- /dev/null
+++ b/crm/app/health/page.tsx
@@ -0,0 +1,177 @@
+import { Shell, fmtAge } from "@/components/shell";
+import { Empty, fmtInt, Kpi } from "@/components/ui";
+import { readHistory, readSnapshot } from "@/lib/snapshot";
+
+export const dynamic = "force-dynamic";
+const SITE = `https://${process.env.SITE_HOST ?? "openchainbench.com"}`;
+
+export default async function HealthPage({ searchParams }: { searchParams: Promise<{ refresh?: string }> }) {
+ const [snap, history, sp] = await Promise.all([readSnapshot(), readHistory(), searchParams]);
+ const b = snap.benches;
+ const h = snap.harness;
+ const d = snap.dune;
+ const builtAge = b?.builtAt ? (Date.now() - Date.parse(b.builtAt)) / 60_000 : null;
+ const duneDaysLeft = d?.periodEnd ? Math.max(0, Math.ceil((Date.parse(d.periodEnd) - Date.now()) / 86_400_000)) : null;
+
+ return (
+
+
+
+
+
+
+
Benches by category
+ {b ? (
+
+
+
+ Category
+ Live
+ Stale
+ Expired
+
+
+
+ {b.byCategory.map((c) => (
+
+ {c.category}
+ {fmtInt(c.total)}
+ 0 ? { color: "var(--warn)" } : undefined}>
+ {fmtInt(c.stale)}
+
+ 0 ? { color: "var(--bad)" } : undefined}>
+ {fmtInt(c.expired)}
+
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
Targets down
+ {h && h.down.length > 0 ? (
+
+
+
+ Job
+ Instance
+ Last error
+
+
+
+ {h.down.map((t) => (
+
+ {t.job}
+
+ {t.instance}
+
+
+ {t.lastError.slice(0, 120)}
+
+
+ ))}
+
+
+ ) : h ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ Benches needing attention (oldest first)
+ {b && b.attention.length > 0 ? (
+
+
+
+ Bench
+ Category
+ State
+ Age
+ Last run
+
+
+
+ {b.attention.map((r) => (
+
+
+
+ {r.slug}
+
+
+ {r.category}
+ {r.state}
+ {r.ageHours != null ? `${r.ageHours.toFixed(r.ageHours < 100 ? 1 : 0)} h` : "–"}
+
+ {r.lastRunAt ?? "never"}
+
+
+ ))}
+
+
+ ) : b ? (
+
+ ) : (
+
+ )}
+
+
+
+ Daily history (one line per refresh day, kept on the volume)
+ {history.length > 0 ? (
+
+
+
+
+ Day
+ Visitors 7 d
+ Pageviews 7 d
+ AI 7 d
+ Search 7 d
+ Benches
+ Stale + expired
+ Targets down
+
+
+
+ {[...history].reverse().slice(0, 60).map((l) => (
+
+ {l.day}
+ {fmtInt(l.visitors7d)}
+ {fmtInt(l.pageviews7d)}
+ {fmtInt(l.aiVisitors7d)}
+ {fmtInt(l.searchVisitors7d)}
+ {fmtInt(l.benches)}
+ {fmtInt(l.stale)}
+ {fmtInt(l.targetsDown)}
+
+ ))}
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/crm/app/icon.svg b/crm/app/icon.svg
new file mode 100644
index 000000000..edc76a007
--- /dev/null
+++ b/crm/app/icon.svg
@@ -0,0 +1 @@
+
diff --git a/crm/app/layout.tsx b/crm/app/layout.tsx
new file mode 100644
index 000000000..75ac09836
--- /dev/null
+++ b/crm/app/layout.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from "next";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: "OCB CRM",
+ description: "OpenChainBench internal dashboard",
+ robots: { index: false, follow: false },
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
{children}
+
+ );
+}
diff --git a/crm/app/login/page.tsx b/crm/app/login/page.tsx
new file mode 100644
index 000000000..863c50047
--- /dev/null
+++ b/crm/app/login/page.tsx
@@ -0,0 +1,43 @@
+import { authConfigured } from "@/lib/auth";
+
+export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
+ const sp = await searchParams;
+ return (
+
+
+ OCB CRM
+
+
+ OpenChainBench internal dashboard.
+
+ {!authConfigured() ? (
+
+ CRM_PASSWORD and CRM_SESSION_SECRET must both be set (16 characters minimum). Nobody can log in until they are.
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/crm/app/page.tsx b/crm/app/page.tsx
new file mode 100644
index 000000000..7bceeeda4
--- /dev/null
+++ b/crm/app/page.tsx
@@ -0,0 +1,189 @@
+import Link from "next/link";
+import { Shell } from "@/components/shell";
+import { Bars, Delta, Empty, fmtInt, fmtPct, Kpi, Spark } from "@/components/ui";
+import { SECTION_LABEL } from "@/lib/channels";
+import { readSnapshot } from "@/lib/snapshot";
+import { channelTotals, sectionTotals } from "@/lib/traffic";
+
+export const dynamic = "force-dynamic";
+
+const CHANNEL_LABEL = { ai: "AI assistants", search: "Search", social: "Social", direct: "Direct", referral: "Other sites", internal: "Internal" } as const;
+
+export default async function Overview({ searchParams }: { searchParams: Promise<{ refresh?: string }> }) {
+ const [snap, sp] = await Promise.all([readSnapshot(), searchParams]);
+ const t = snap.traffic;
+ const totals = t.totals;
+ const weekly = t.weekly ?? [];
+ const lastFull = weekly.length >= 2 ? weekly[weekly.length - 2] : null;
+ const prevFull = weekly.length >= 3 ? weekly[weekly.length - 3] : null;
+ const channels = channelTotals(t.referrers ?? []);
+ // Exact 7 d uniques for the two headline channels; the channel table
+ // below sums per-domain uniques and can count a visitor twice.
+ const ai = totals ? { visitors: totals.aiVisitors, prevVisitors: totals.prevAiVisitors } : undefined;
+ const search = totals ? { visitors: totals.searchVisitors, prevVisitors: totals.prevSearchVisitors } : undefined;
+ const fullWeeks = weekly.slice(0, -1);
+ const sections = sectionTotals(t.pages ?? []);
+ const aiDomains = (t.referrers ?? []).filter((r) => r.channel === "ai" && (r.visitors > 0 || r.prevVisitors > 0)).slice(0, 12);
+ const b = snap.benches;
+ const h = snap.harness;
+
+ return (
+
+
+
+
+
+
+
+ 0 ? `${h.down.map((d) => d.job).slice(0, 3).join(", ")}${h.down.length > 3 ? "…" : ""} down` : "all up"} />
+
+
+
+
+
Daily visitors, 28 d
+ {t.daily && t.daily.length > 1 ? (
+ <>
+
d.visitors)} />
+
+ {t.daily[0].day}
+ peak {fmtInt(Math.max(...t.daily.map((d) => d.visitors)))}
+ {t.daily[t.daily.length - 1].day}
+
+ >
+ ) : (
+
+ )}
+
+
+
Weekly visitors from AI assistants, full weeks
+ {fullWeeks.length > 1 ? (
+ <>
+
w.ai)} color="var(--good)" />
+
+ last full week {lastFull ? `${fmtInt(lastFull.ai)} AI · ${fmtInt(lastFull.search)} search · ${fmtInt(lastFull.visitors)} total` : "–"}
+ {lastFull && prevFull ? (
+ <>
+ {" "}
+ · AI w/w
+ >
+ ) : null}
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+
Channels, 7 d (per-domain visitors summed)
+ {channels.length > 0 ? (
+
+
+ {channels.map((c) => (
+
+ {CHANNEL_LABEL[c.channel]}
+ {fmtInt(c.visitors)}
+
+
+
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
AI assistants by domain, 7 d
+ {aiDomains.length > 0 ? (
+
+
+ {aiDomains.map((r) => (
+
+ {r.domain}
+ {fmtInt(r.visitors)}
+
+
+
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
Sections, 7 d (page visits)
+ {sections.length > 0 ? (
+
({ label: SECTION_LABEL[s.section], value: s.visitors, hint: `· ${s.pages} pages` }))} />
+ ) : (
+
+ )}
+
+ Page visits sum per-page visitors; a visitor who saw two pages of a section counts twice. Pages has the per-page table.
+
+
+
+
+
+ Weekly series
+ {weekly.length > 0 ? (
+
+
+
+
+ Week of
+ Visitors
+ Pageviews
+ From AI
+ AI share
+ From search
+
+
+
+ {[...weekly].reverse().map((w, i) => (
+
+
+ {w.week}
+ {i === 0 ? " (current, partial)" : ""}
+
+ {fmtInt(w.visitors)}
+ {fmtInt(w.pageviews)}
+ {fmtInt(w.ai)}
+ {fmtPct(w.visitors > 0 ? w.ai / w.visitors : 0, 1)}
+ {fmtInt(w.search)}
+
+ ))}
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/crm/app/pages/page.tsx b/crm/app/pages/page.tsx
new file mode 100644
index 000000000..f5618edba
--- /dev/null
+++ b/crm/app/pages/page.tsx
@@ -0,0 +1,167 @@
+import { Shell } from "@/components/shell";
+import { Delta, Empty, fmtInt } from "@/components/ui";
+import { SECTION_LABEL, type Section } from "@/lib/channels";
+import { readSnapshot } from "@/lib/snapshot";
+import { sectionTotals } from "@/lib/traffic";
+
+export const dynamic = "force-dynamic";
+const SITE = `https://${process.env.SITE_HOST ?? "openchainbench.com"}`;
+
+export default async function PagesPage({ searchParams }: { searchParams: Promise<{ refresh?: string; section?: string }> }) {
+ const [snap, sp] = await Promise.all([readSnapshot(), searchParams]);
+ const pages = snap.traffic.pages ?? [];
+ const sections = sectionTotals(pages);
+ const filter = (sp.section ?? "") as Section | "";
+ const shown = (filter ? pages.filter((p) => p.section === filter) : pages).slice(0, 100);
+ const risers = pages.filter((p) => p.prevVisitors >= 3 || p.visitors >= 3).map((p) => ({ ...p, diff: p.visitors - p.prevVisitors }));
+ const up = [...risers].sort((a, b) => b.diff - a.diff).slice(0, 10);
+ const down = [...risers].sort((a, b) => a.diff - b.diff).filter((p) => p.diff < 0).slice(0, 10);
+ const entries = snap.traffic.entries ?? [];
+
+ return (
+
+
+ Sections, 7 d vs previous 7 d
+ {sections.length > 0 ? (
+
+
+
+
+ Section
+ Page visits
+ w/w
+ Pageviews
+ Pages with a visit
+
+
+
+ {sections.map((s) => (
+
+
+
+ {SECTION_LABEL[s.section]}
+
+
+ {fmtInt(s.visitors)}
+
+
+
+ {fmtInt(s.pageviews)}
+ {fmtInt(s.pages)}
+
+ ))}
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
Biggest gains, 7 d vs previous 7 d
+
+
+
+
+
+
+
+
Top pages, 7 d{filter ? ` · ${SECTION_LABEL[filter]}` : ""}
+ {filter && (
+
+ all sections
+
+ )}
+
+ {shown.length > 0 ? (
+
+
+
+
+ Path
+ Section
+ Visitors
+ w/w
+ Pageviews
+
+
+
+ {shown.map((p) => (
+
+
+
+ {p.path}
+
+
+ {SECTION_LABEL[p.section]}
+ {fmtInt(p.visitors)}
+
+
+
+ {fmtInt(p.pageviews)}
+
+ ))}
+
+
+
+ ) : (
+
+ )}
+
+
+
+ Entry pages, 7 d (first page of a session)
+ {entries.length > 0 ? (
+
+
+
+ Path
+ Section
+ Sessions
+
+
+
+ {entries.map((e) => (
+
+ {e.path}
+ {SECTION_LABEL[e.section]}
+ {fmtInt(e.sessions)}
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function MoverTable({ rows }: { rows: { path: string; visitors: number; prevVisitors: number; diff: number }[] }) {
+ if (rows.length === 0) return ;
+ return (
+
+
+ {rows.map((p) => (
+
+
+ {p.path}
+
+
+ {fmtInt(p.prevVisitors)} → {fmtInt(p.visitors)}
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/crm/components/shell.tsx b/crm/components/shell.tsx
new file mode 100644
index 000000000..273a49c8c
--- /dev/null
+++ b/crm/components/shell.tsx
@@ -0,0 +1,89 @@
+import Link from "next/link";
+import { MANUAL_COOLDOWN_MINUTES, snapshotAgeMinutes, type Snapshot } from "@/lib/snapshot";
+
+const NAV = [
+ ["/", "Overview"],
+ ["/pages", "Pages"],
+ ["/audience", "Audience"],
+ ["/health", "Data health"],
+] as const;
+
+export function fmtAge(min: number | null): string {
+ if (min == null) return "never";
+ if (min < 1) return "just now";
+ if (min < 90) return `${Math.round(min)} min ago`;
+ if (min < 48 * 60) return `${(min / 60).toFixed(1)} h ago`;
+ return `${Math.round(min / 1440)} d ago`;
+}
+
+export function Shell({ current, snapshot, refreshFlag, children }: { current: string; snapshot: Snapshot; refreshFlag?: string; children: React.ReactNode }) {
+ const age = snapshotAgeMinutes(snapshot);
+ const errors = Object.entries(snapshot.status).filter(([, s]) => s.error);
+ const canRefresh = age == null || age >= MANUAL_COOLDOWN_MINUTES;
+ return (
+
+
+ {refreshFlag && (
+
+ {refreshFlag === "ok" && "Refreshed."}
+ {refreshFlag === "joined" && "A refresh was already running; this is its result."}
+ {refreshFlag === "errors" && "Refreshed; some sections failed and kept their previous values (see below)."}
+ {refreshFlag === "partial" && "Refresh stopped early (PostHog rate limit or local budget); the remaining sections kept their previous values."}
+ {refreshFlag.startsWith("cooldown:") && `Last refresh is too recent; try again in ${refreshFlag.split(":")[1]} min.`}
+
+ )}
+ {!snapshot.posthogConfigured && (
+
+ PostHog is not configured on this deployment (POSTHOG_PERSONAL_API_KEY, POSTHOG_PROJECT_ID). Traffic sections are empty; data health still works.
+
+ )}
+ {errors.length > 0 && (
+
+
+ {errors.length} section{errors.length > 1 ? "s" : ""} failed on the last refresh (previous values shown)
+
+
+ {errors.map(([k, s]) => (
+
+ {k}: {s.error}
+
+ ))}
+
+
+ )}
+ {children}
+
+ );
+}
diff --git a/crm/components/ui.tsx b/crm/components/ui.tsx
new file mode 100644
index 000000000..2f5a66a98
--- /dev/null
+++ b/crm/components/ui.tsx
@@ -0,0 +1,81 @@
+export function fmtInt(n: number | null | undefined): string {
+ if (n == null || !Number.isFinite(n)) return "–";
+ return Math.round(n).toLocaleString("en-US");
+}
+export function fmtPct(x: number | null | undefined, digits = 0): string {
+ if (x == null || !Number.isFinite(x)) return "–";
+ return `${(x * 100).toFixed(digits)}%`;
+}
+
+/** Week over week change, as a signed percentage. */
+export function Delta({ now, prev }: { now: number; prev: number }) {
+ if (prev <= 0 && now <= 0) return – ;
+ if (prev <= 0) return new ;
+ const d = (now - prev) / prev;
+ const cls = Math.abs(d) < 0.02 ? "flat" : d > 0 ? "up" : "down";
+ return (
+
+ {d > 0 ? "+" : ""}
+ {(d * 100).toFixed(0)}%
+
+ );
+}
+
+export function Kpi({ label, value, sub, delta }: { label: string; value: string; sub?: string; delta?: { now: number; prev: number } }) {
+ return (
+
+
{label}
+
{value}
+
+ {delta && (
+ <>
+ vs previous 7 d{sub ? " · " : ""}
+ >
+ )}
+ {sub}
+
+
+ );
+}
+
+/** Inline SVG line chart; no client code. */
+export function Spark({ series, height = 56, width = 320, color = "var(--accent)" }: { series: number[]; height?: number; width?: number; color?: string }) {
+ if (series.length < 2) return
;
+ const max = Math.max(1, ...series);
+ const pts = series.map((v, i) => `${((i / (series.length - 1)) * (width - 2) + 1).toFixed(1)},${(height - 2 - (v / max) * (height - 6)).toFixed(1)}`);
+ return (
+
+
+
+ );
+}
+
+export function Bars({ rows, max }: { rows: { label: string; value: number; hint?: string }[]; max?: number }) {
+ const m = Math.max(1, max ?? Math.max(...rows.map((r) => r.value)));
+ return (
+
+ {rows.map((r) => (
+
+
+ {r.label}
+
+
+
+
+
+ {fmtInt(r.value)}
+ {r.hint ? ` ${r.hint}` : ""}
+
+
+ ))}
+
+ );
+}
+
+export function Empty({ text }: { text: string }) {
+ return (
+
+ {text}
+
+ );
+}
diff --git a/crm/instrumentation.ts b/crm/instrumentation.ts
new file mode 100644
index 000000000..f7323da51
--- /dev/null
+++ b/crm/instrumentation.ts
@@ -0,0 +1,21 @@
+/**
+ * The refresher. One interval per server process: a refresh at boot when
+ * the stored snapshot is older than the interval, then every REFRESH_MINUTES
+ * with a little jitter so two instances would not align on PostHog.
+ */
+export async function register() {
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
+ if (process.env.CRM_DISABLE_SCHEDULER === "1") return;
+ const { readSnapshot, refreshSnapshot, REFRESH_MINUTES, snapshotAgeMinutes } = await import("@/lib/snapshot");
+ const snap = await readSnapshot();
+ const age = snapshotAgeMinutes(snap);
+ const firstDelayMs = age == null || age >= REFRESH_MINUTES ? 5_000 : (REFRESH_MINUTES - age) * 60_000;
+ const tick = () => {
+ refreshSnapshot("scheduled").catch((e) => console.error("[scheduler]", e));
+ };
+ setTimeout(() => {
+ tick();
+ setInterval(tick, REFRESH_MINUTES * 60_000 + Math.floor(Math.random() * 30_000));
+ }, firstDelayMs).unref();
+ console.log(`[scheduler] first refresh in ${Math.round(firstDelayMs / 1000)} s, then every ${REFRESH_MINUTES} min`);
+}
diff --git a/crm/lib/auth.ts b/crm/lib/auth.ts
new file mode 100644
index 000000000..434693c87
--- /dev/null
+++ b/crm/lib/auth.ts
@@ -0,0 +1,142 @@
+/**
+ * One shared password, per-login sessions.
+ *
+ * The cookie is `nonce.expiry.signature`, signed with CRM_SESSION_SECRET (a
+ * random value, not the password: a leaked cookie gives nothing to brute
+ * force offline). A session is valid while its signature checks, its expiry
+ * is ahead and its nonce is still listed on the volume (lib/sessions.ts), so
+ * logout revokes it. Rotating either env logs everyone out. Web Crypto only:
+ * this runs in proxy.ts as well as in route handlers.
+ */
+import { listSession, sessionListed, unlistSession } from "@/lib/sessions";
+
+export const COOKIE = "ocb_crm";
+export const SESSION_DAYS = 30;
+const MIN_LEN = 16;
+
+const password = () => process.env.CRM_PASSWORD ?? "";
+const secret = () => process.env.CRM_SESSION_SECRET ?? "";
+
+export function authConfigured(): boolean {
+ return password().length >= MIN_LEN && secret().length >= MIN_LEN;
+}
+
+const enc = new TextEncoder();
+
+async function hmacHex(key: string, message: string): Promise {
+ const k = await crypto.subtle.importKey("raw", enc.encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
+ const sig = await crypto.subtle.sign("HMAC", k, enc.encode(message));
+ return Array.from(new Uint8Array(sig), (b) => b.toString(16).padStart(2, "0")).join("");
+}
+
+function timingSafeEqual(a: string, b: string): boolean {
+ if (a.length !== b.length) return false;
+ let diff = 0;
+ for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
+ return diff === 0;
+}
+
+export async function passwordMatches(candidate: string): Promise {
+ if (!authConfigured() || candidate.length === 0) return false;
+ // Compare HMACs of the two strings under the session secret: equal
+ // length whatever the input, constant time.
+ return timingSafeEqual(await hmacHex(secret(), candidate), await hmacHex(secret(), password()));
+}
+
+export type ParsedSession = { nonce: string; expiresAt: number; sig: string };
+
+export function parseSession(cookieValue: string | undefined): ParsedSession | null {
+ const parts = (cookieValue ?? "").split(".");
+ if (parts.length !== 3) return null;
+ const [nonce, expRaw, sig] = parts;
+ const expiresAt = Number.parseInt(expRaw, 10);
+ if (!/^[0-9a-f]{32}$/.test(nonce) || !Number.isFinite(expiresAt) || !/^[0-9a-f]{64}$/.test(sig)) return null;
+ return { nonce, expiresAt, sig };
+}
+
+const payload = (nonce: string, expiresAt: number) => `${nonce}.${expiresAt}`;
+
+/** Signature and expiry only; the listing check is separate so tests can cover each. */
+export async function sessionSigned(s: ParsedSession, now = Date.now()): Promise {
+ if (!authConfigured() || s.expiresAt <= now) return false;
+ return timingSafeEqual(s.sig, await hmacHex(secret(), payload(s.nonce, s.expiresAt)));
+}
+
+export async function isValidSession(cookieValue: string | undefined, now = Date.now()): Promise {
+ const s = parseSession(cookieValue);
+ if (!s) return false;
+ if (!(await sessionSigned(s, now))) return false;
+ return sessionListed(s.nonce, now);
+}
+
+export async function issueSession(now = Date.now()): Promise {
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
+ const nonce = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
+ const expiresAt = now + SESSION_DAYS * 86_400_000;
+ const sig = await hmacHex(secret(), payload(nonce, expiresAt));
+ await listSession(nonce, expiresAt, now);
+ return `${payload(nonce, expiresAt)}.${sig}`;
+}
+
+export async function revokeSession(cookieValue: string | undefined): Promise {
+ const s = parseSession(cookieValue);
+ if (s) await unlistSession(s.nonce);
+}
+
+/** Login attempts, in memory, on globalThis so every bundler layer shares
+ * the map. Two brakes: per client (10 per 15 minutes) and global (60 per
+ * 15 minutes, whatever the keys), because the client key is only as good as
+ * the edge's forwarded header. Keys are evicted when their window is empty. */
+const WINDOW_MS = 15 * 60_000;
+const MAX_ATTEMPTS = 10;
+const MAX_ATTEMPTS_GLOBAL = 60;
+const MAX_KEYS = 1000;
+type Attempts = { byKey: Map; all: number[] };
+const g = globalThis as unknown as { __ocbLoginAttempts?: Attempts };
+const attempts: Attempts = (g.__ocbLoginAttempts ??= { byKey: new Map(), all: [] });
+
+/** The hop the edge appended, i.e. the last X-Forwarded-For entry: the
+ * first entry is whatever the client wrote. No x-real-ip fallback, the
+ * platform is not known to set it. */
+export function clientKey(request: Request): string {
+ const hops = (request.headers.get("x-forwarded-for") ?? "").split(",").map((h) => h.trim()).filter(Boolean);
+ return hops.at(-1) ?? "unknown";
+}
+
+function sweep(now: number): void {
+ attempts.all = attempts.all.filter((t) => now - t < WINDOW_MS);
+ for (const [k, list] of attempts.byKey) {
+ const recent = list.filter((t) => now - t < WINDOW_MS);
+ if (recent.length === 0) attempts.byKey.delete(k);
+ else attempts.byKey.set(k, recent);
+ }
+}
+
+export function loginAllowed(key: string, now = Date.now()): boolean {
+ sweep(now);
+ if (attempts.all.length >= MAX_ATTEMPTS_GLOBAL) return false;
+ if (attempts.byKey.size >= MAX_KEYS && !attempts.byKey.has(key)) return false;
+ return (attempts.byKey.get(key) ?? []).length < MAX_ATTEMPTS;
+}
+
+export function recordLoginAttempt(key: string, now = Date.now()): void {
+ attempts.all.push(now);
+ attempts.byKey.set(key, [...(attempts.byKey.get(key) ?? []), now]);
+}
+
+/** Test seam. */
+export function resetLoginAttempts(): void {
+ attempts.byKey.clear();
+ attempts.all = [];
+}
+
+/** 303 to a same-origin path. The origin is rebuilt from the forwarded
+ * headers: the standalone server binds 0.0.0.0 and Next absolutises a
+ * relative Location with that host, which behind Railway's proxy sends
+ * the browser to http://0.0.0.0. */
+export function seeOther(request: Request, pathWithQuery: string): Response {
+ const safe = pathWithQuery.startsWith("/") && !pathWithQuery.startsWith("//") ? pathWithQuery : "/";
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? "localhost";
+ const proto = request.headers.get("x-forwarded-proto") ?? (host.startsWith("localhost") || host.startsWith("127.") ? "http" : "https");
+ return new Response(null, { status: 303, headers: { Location: `${proto}://${host.split(",")[0].trim()}${safe}` } });
+}
diff --git a/crm/lib/channels.ts b/crm/lib/channels.ts
new file mode 100644
index 000000000..67171f78b
--- /dev/null
+++ b/crm/lib/channels.ts
@@ -0,0 +1,183 @@
+/**
+ * Referrer and path classification. Pure functions, unit tested.
+ *
+ * The AI assistant channel is the number this dashboard exists for: a visit
+ * referred by ChatGPT, Perplexity, Claude or Gemini is a citation that
+ * converted, the only direct measurement of the GEO work. The lists are
+ * exported so the weekly HogQL series can embed the same domains and the two
+ * surfaces cannot disagree.
+ */
+
+export type Channel = "ai" | "search" | "social" | "direct" | "referral" | "internal";
+
+export const AI_DOMAINS = [
+ "chatgpt.com",
+ "chat.openai.com",
+ "openai.com",
+ "perplexity.ai",
+ "www.perplexity.ai",
+ "claude.ai",
+ "gemini.google.com",
+ "bard.google.com",
+ "copilot.microsoft.com",
+ "you.com",
+ "phind.com",
+ "poe.com",
+ "kagi.com",
+ "grok.com",
+ "x.ai",
+ "mistral.ai",
+ "chat.mistral.ai",
+ "meta.ai",
+] as const;
+
+export const SEARCH_DOMAINS = [
+ "google.com",
+ "www.google.com",
+ "bing.com",
+ "www.bing.com",
+ "duckduckgo.com",
+ "search.yahoo.com",
+ "yandex.ru",
+ "yandex.com",
+ "baidu.com",
+ "www.baidu.com",
+ "ecosia.org",
+ "www.ecosia.org",
+ "search.brave.com",
+ "startpage.com",
+ "qwant.com",
+ "www.qwant.com",
+ "naver.com",
+ "search.naver.com",
+] as const;
+
+export const SOCIAL_DOMAINS = [
+ "x.com",
+ "twitter.com",
+ "t.co",
+ "linkedin.com",
+ "www.linkedin.com",
+ "lnkd.in",
+ "reddit.com",
+ "www.reddit.com",
+ "old.reddit.com",
+ "out.reddit.com",
+ "news.ycombinator.com",
+ "t.me",
+ "telegram.org",
+ "web.telegram.org",
+ "warpcast.com",
+ "farcaster.xyz",
+ "facebook.com",
+ "www.facebook.com",
+ "l.facebook.com",
+ "youtube.com",
+ "www.youtube.com",
+ "discord.com",
+ "medium.com",
+ "substack.com",
+ "mirror.xyz",
+ "paragraph.xyz",
+] as const;
+
+const siteHost = (process.env.SITE_HOST ?? "openchainbench.com").toLowerCase();
+
+export function classifyReferrer(domain: string | null | undefined): Channel {
+ const d = (domain ?? "").toLowerCase().trim();
+ if (!d || d === "$direct" || d === "direct" || d === "(none)") return "direct";
+ if (d === siteHost || d.endsWith(`.${siteHost}`)) return "internal";
+ const matches = (list: readonly string[]) => list.some((x) => d === x || d.endsWith(`.${x}`));
+ if (matches(AI_DOMAINS)) return "ai";
+ // Google: the search engine on any country TLD is search; every other
+ // property (docs, mail, translate, sites) is a referral.
+ if (/^(www\.)?google\.[a-z.]+$/.test(d)) return "search";
+ if (/(^|\.)google\.[a-z.]+$/.test(d)) return "referral";
+ if (/^(www\.)?bing\.com$/.test(d) || d.endsWith(".bing.com")) return "search";
+ if (matches(SEARCH_DOMAINS)) return "search";
+ if (matches(SOCIAL_DOMAINS)) return "social";
+ return "referral";
+}
+
+/** HogQL predicate on `properties.$referring_domain` mirroring classifyReferrer's
+ * rules for one channel: exact or subdomain match on the list, plus, for
+ * search, Google's and Bing's country hosts. Kept next to the TS rule so
+ * the KPI (SQL) and the channel table (TS) count the same visitors. */
+export function referrerPredicate(channel: "ai" | "search", column = "properties.$referring_domain"): string {
+ const list = channel === "ai" ? AI_DOMAINS : SEARCH_DOMAINS;
+ const quoted = list.map((x) => `'${x.replace(/'/g, "")}'`);
+ const exact = `${column} IN (${quoted.join(", ")})`;
+ const suffix = list.map((x) => `endsWith(${column}, '.${x.replace(/'/g, "")}')`).join(" OR ");
+ const extra =
+ channel === "search"
+ // Character classes instead of backslash escapes: HogQL strings reject `\.`.
+ ? ` OR match(${column}, '^(www[.])?google[.][a-z.]+$') OR match(${column}, '^([a-z]+[.])?bing[.]com$')`
+ : "";
+ // gemini.google.com is AI, never search: excluded here, listed in AI_DOMAINS.
+ const notAi = channel === "search" ? ` AND NOT (${column} IN ('gemini.google.com', 'bard.google.com'))` : "";
+ return `((${exact} OR ${suffix}${extra})${notAi})`;
+}
+
+export type Section =
+ | "home"
+ | "benchmarks"
+ | "rpc"
+ | "compare"
+ | "answers"
+ | "products"
+ | "chains"
+ | "hubs"
+ | "reports"
+ | "docs"
+ | "api"
+ | "other";
+
+export const SECTION_LABEL: Record = {
+ home: "Home",
+ benchmarks: "Bench pages",
+ rpc: "RPC pages",
+ compare: "Compare",
+ answers: "Answers",
+ products: "Products",
+ chains: "Chains",
+ hubs: "Hubs",
+ reports: "Reports",
+ docs: "Docs and about",
+ api: "API and feeds",
+ other: "Other",
+};
+
+const HUBS = new Set([
+ "/rpc",
+ "/bridge",
+ "/perps",
+ "/perp",
+ "/hyperliquid",
+ "/prediction-markets",
+ "/wallets",
+ "/aggregators",
+ "/staking",
+ "/stablecoins",
+ "/l2",
+ "/memecoins",
+]);
+
+export function classifyPath(pathname: string | null | undefined): Section {
+ const raw = (pathname ?? "/").split("?")[0].split("#")[0];
+ const p = raw.length > 1 ? raw.replace(/\/+$/, "") : raw;
+ if (p === "/" || p === "") return "home";
+ if (p.startsWith("/benchmarks/")) {
+ const slug = p.split("/")[2] ?? "";
+ return slug.endsWith("-rpc") || slug.startsWith("keyed-rpc-") ? "rpc" : "benchmarks";
+ }
+ if (p === "/benchmarks") return "benchmarks";
+ if (p.startsWith("/compare")) return "compare";
+ if (p.startsWith("/answers")) return "answers";
+ if (p.startsWith("/products") || p.startsWith("/alternatives")) return "products";
+ if (p.startsWith("/chains")) return "chains";
+ if (HUBS.has(p) || p.startsWith("/hyperliquid/") || p.startsWith("/perp/")) return "hubs";
+ if (p.startsWith("/reports")) return "reports";
+ if (p.startsWith("/api") || p === "/llms.txt" || p === "/llms-full.txt" || p.endsWith(".xml") || p.endsWith(".json")) return "api";
+ if (["/methodology", "/about", "/contribute", "/mcp", "/team", "/press", "/partners", "/docs"].some((x) => p === x || p.startsWith(`${x}/`))) return "docs";
+ return "other";
+}
diff --git a/crm/lib/ocb.ts b/crm/lib/ocb.ts
new file mode 100644
index 000000000..ae7fd5851
--- /dev/null
+++ b/crm/lib/ocb.ts
@@ -0,0 +1,143 @@
+/**
+ * The non-PostHog sections: what the site itself publishes. All public
+ * endpoints except Dune, whose key is optional.
+ *
+ * - Bench health: the index blob the materialize worker publishes, one
+ * row per live bench with its last measurement. Same thresholds as the
+ * site: stale after 24 h (the page says so), expired after 168 h (the page
+ * is noindex and leaves the sitemap).
+ * - Harness health: Prometheus scrape targets, up or down.
+ * - Dune: credits left on the plan and when the period ends.
+ */
+import { z } from "zod";
+
+// index.json lists every bench the worker knows with its status; the
+// sitemap blob would not do, the worker drops expired chain RPC benches and
+// thin ones from it before publishing, which is what this page must show.
+const INDEX_BLOB_URL = process.env.INDEX_BLOB_URL ?? "https://kv.openchainbench.com/aggregate/index.json";
+const SITEMAP_BLOB_URL = process.env.SITEMAP_BLOB_URL ?? "https://kv.openchainbench.com/aggregate/sitemap.json";
+const PROM_URL = (process.env.PROM_URL ?? "https://prom.openchainbench.com").replace(/\/$/, "");
+const STALE_AFTER_HOURS = 24;
+const EXPIRED_AFTER_HOURS = 168;
+
+const indexSchema = z.object({
+ builtAt: z.union([z.string(), z.number()]).optional(),
+ benches: z.array(
+ z.object({ slug: z.string(), status: z.string().optional(), lastRunAt: z.string().nullable().optional(), category: z.string().optional() }),
+ ),
+});
+const sitemapSchema = z.object({ providerSlugs: z.array(z.string()).optional() });
+
+export type BenchRow = { slug: string; category: string; lastRunAt: string | null; ageHours: number | null; state: "fresh" | "stale" | "expired" | "unknown" };
+export type BenchHealth = {
+ builtAt: string | null;
+ total: number;
+ fresh: number;
+ stale: number;
+ expired: number;
+ providers: number;
+ byCategory: { category: string; total: number; stale: number; expired: number }[];
+ attention: BenchRow[];
+};
+
+export async function loadBenchHealth(now = Date.now()): Promise {
+ const [res, sm] = await Promise.all([
+ fetch(INDEX_BLOB_URL, { signal: AbortSignal.timeout(20_000), cache: "no-store" }),
+ fetch(SITEMAP_BLOB_URL, { signal: AbortSignal.timeout(20_000), cache: "no-store" }).catch(() => null),
+ ]);
+ if (!res.ok) throw new Error(`index blob ${res.status}`);
+ const blob = indexSchema.parse(await res.json());
+ // Providers count is decoration; a bad sitemap body must not fail the section.
+ const providers = await (async () => {
+ try {
+ return sm && sm.ok ? (sitemapSchema.safeParse(await sm.json()).data?.providerSlugs?.length ?? 0) : 0;
+ } catch {
+ return 0;
+ }
+ })();
+ const rows: BenchRow[] = blob.benches.filter((b) => b.status === "live").map((b) => {
+ const t = Date.parse(b.lastRunAt ?? "");
+ const ageHours = Number.isFinite(t) ? (now - t) / 3_600_000 : null;
+ const state = ageHours == null ? "unknown" : ageHours > EXPIRED_AFTER_HOURS ? "expired" : ageHours > STALE_AFTER_HOURS ? "stale" : "fresh";
+ return { slug: b.slug, category: b.category ?? "Uncategorised", lastRunAt: b.lastRunAt ?? null, ageHours, state };
+ });
+ const cats = new Map();
+ for (const r of rows) {
+ const c = cats.get(r.category) ?? { total: 0, stale: 0, expired: 0 };
+ c.total += 1;
+ if (r.state === "stale") c.stale += 1;
+ if (r.state === "expired" || r.state === "unknown") c.expired += 1;
+ cats.set(r.category, c);
+ }
+ // builtAt is epoch milliseconds in the blob; normalised to ISO here.
+ const builtAt =
+ typeof blob.builtAt === "number" ? new Date(blob.builtAt).toISOString() : typeof blob.builtAt === "string" ? blob.builtAt : null;
+ return {
+ builtAt,
+ total: rows.length,
+ fresh: rows.filter((r) => r.state === "fresh").length,
+ stale: rows.filter((r) => r.state === "stale").length,
+ expired: rows.filter((r) => r.state === "expired" || r.state === "unknown").length,
+ providers,
+ byCategory: [...cats.entries()].map(([category, v]) => ({ category, ...v })).sort((a, b) => b.total - a.total),
+ attention: rows.filter((r) => r.state !== "fresh").sort((a, b) => (b.ageHours ?? Infinity) - (a.ageHours ?? Infinity)),
+ };
+}
+
+const targetsSchema = z.object({
+ data: z.object({
+ activeTargets: z.array(
+ z.object({
+ labels: z.record(z.string(), z.string()),
+ health: z.string(),
+ lastScrape: z.string().optional(),
+ lastError: z.string().optional(),
+ scrapeUrl: z.string().optional(),
+ }),
+ ),
+ }),
+});
+
+export type TargetRow = { job: string; instance: string; health: string; lastScrape: string | null; lastError: string };
+export type HarnessHealth = { total: number; up: number; down: TargetRow[] };
+
+export async function loadHarnessHealth(): Promise {
+ const res = await fetch(`${PROM_URL}/api/v1/targets?state=active`, { signal: AbortSignal.timeout(20_000), cache: "no-store" });
+ if (!res.ok) throw new Error(`prometheus targets ${res.status}`);
+ const parsed = targetsSchema.parse(await res.json());
+ const rows: TargetRow[] = parsed.data.activeTargets.map((t) => ({
+ job: t.labels.job ?? "?",
+ // The instance label names hosts and ports; keyed RPC URLs never reach
+ // the labels (the harness reads them from env), so this is safe to show.
+ instance: t.labels.instance ?? "",
+ health: t.health,
+ lastScrape: t.lastScrape ?? null,
+ lastError: t.lastError ?? "",
+ }));
+ return { total: rows.length, up: rows.filter((r) => r.health === "up").length, down: rows.filter((r) => r.health !== "up") };
+}
+
+export type DuneUsage = { creditsUsed: number; creditsIncluded: number; periodStart: string | null; periodEnd: string | null };
+
+const duneSchema = z.object({
+ billing_periods: z.array(z.object({ start_date: z.string(), end_date: z.string(), credits_used: z.number(), credits_included: z.number() })),
+});
+
+/** Only when DUNE_API_KEY is set; the key never leaves the server. */
+export async function loadDuneUsage(): Promise {
+ const key = process.env.DUNE_API_KEY;
+ if (!key) return null;
+ const res = await fetch("https://api.dune.com/api/v1/usage", {
+ method: "POST",
+ headers: { "X-Dune-API-Key": key, "Content-Type": "application/json" },
+ body: "{}",
+ signal: AbortSignal.timeout(20_000),
+ cache: "no-store",
+ });
+ if (!res.ok) throw new Error(`dune usage ${res.status}`);
+ const parsed = duneSchema.parse(await res.json());
+ // The current period is the one that ends last.
+ const period = [...parsed.billing_periods].sort((x, y) => y.end_date.localeCompare(x.end_date))[0];
+ if (!period) return { creditsUsed: 0, creditsIncluded: 0, periodStart: null, periodEnd: null };
+ return { creditsUsed: period.credits_used, creditsIncluded: period.credits_included, periodStart: period.start_date, periodEnd: period.end_date };
+}
diff --git a/crm/lib/posthog.ts b/crm/lib/posthog.ts
new file mode 100644
index 000000000..a55a2afec
--- /dev/null
+++ b/crm/lib/posthog.ts
@@ -0,0 +1,115 @@
+/**
+ * PostHog HogQL client with a spend budget.
+ *
+ * PostHog rate-limits the query endpoint at 2400 requests per hour for the
+ * whole organisation (every key, every team member). This app never queries
+ * in the request path: a refresh runs a fixed list of about a dozen queries,
+ * one at a time, and the pages read the resulting snapshot. The budget below
+ * is a second guard so a bug in a loop cannot spend the organisation's hour.
+ * A 429 stops the batch for the Retry-After the server names; the sections
+ * that did not run keep their previous values (see snapshot.ts).
+ */
+import { z } from "zod";
+
+const HOST = (process.env.POSTHOG_HOST ?? "https://us.posthog.com").replace(/\/$/, "");
+const PROJECT_ID = process.env.POSTHOG_PROJECT_ID ?? "";
+const API_KEY = process.env.POSTHOG_PERSONAL_API_KEY ?? "";
+const REQUEST_TIMEOUT_MS = 60_000;
+
+/** PostHog's organisation-wide limit on the query endpoint, per hour. */
+export const POSTHOG_ORG_LIMIT_PER_HOUR = 2400;
+
+export function readBudgetLimit(raw: string | undefined, fallback = 300): number {
+ const n = Number.parseInt(raw ?? "", 10);
+ if (!Number.isFinite(n)) return fallback;
+ return Math.min(POSTHOG_ORG_LIMIT_PER_HOUR, Math.max(1, n));
+}
+
+export const HOURLY_BUDGET = readBudgetLimit(process.env.POSTHOG_HOURLY_BUDGET);
+
+export function posthogConfigured(): boolean {
+ return API_KEY.length > 0 && PROJECT_ID.length > 0;
+}
+
+const responseSchema = z.object({
+ results: z.array(z.array(z.unknown())),
+ columns: z.array(z.string()).optional(),
+});
+
+export type HogQLRows = unknown[][];
+
+/** Rolling-hour spend, kept in memory (one process, one refresher). */
+export class Budget {
+ private stamps: number[] = [];
+ constructor(private readonly limit: number) {}
+ /** Milliseconds until a slot frees, 0 when one is free now. */
+ waitMs(now = Date.now()): number {
+ this.stamps = this.stamps.filter((t) => now - t < 3_600_000);
+ if (this.stamps.length < this.limit) return 0;
+ return this.stamps[0] + 3_600_000 - now;
+ }
+ spend(now = Date.now()): void {
+ this.stamps.push(now);
+ }
+ used(now = Date.now()): number {
+ this.stamps = this.stamps.filter((t) => now - t < 3_600_000);
+ return this.stamps.length;
+ }
+}
+
+// One budget and one queue per process, whatever the bundler layer.
+const g = globalThis as unknown as { __ocbPosthog?: { budget: Budget; chain: Promise } };
+const shared = (g.__ocbPosthog ??= { budget: new Budget(HOURLY_BUDGET), chain: Promise.resolve() });
+export const budget = shared.budget;
+
+export class RateLimited extends Error {
+ constructor(public readonly retryAfterMs: number) {
+ super(`posthog rate limited, retry after ${Math.round(retryAfterMs / 1000)} s`);
+ }
+}
+
+export class BudgetExhausted extends Error {
+ constructor(public readonly waitMs: number) {
+ super(`local posthog budget exhausted, next slot in ${Math.round(waitMs / 1000)} s`);
+ }
+}
+
+/** Serialises calls: two refreshes (interval plus manual) never run queries side by side. */
+export function queryHogQL(name: string, query: string): Promise {
+ const run = shared.chain.then(() => queryOnce(name, query));
+ shared.chain = run.catch(() => undefined);
+ return run;
+}
+
+async function queryOnce(name: string, query: string): Promise {
+ if (!posthogConfigured()) throw new Error("posthog not configured");
+ const wait = budget.waitMs();
+ if (wait > 0) throw new BudgetExhausted(wait);
+ budget.spend();
+ const started = Date.now();
+ const res = await fetch(`${HOST}/api/projects/${PROJECT_ID}/query/`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
+ body: JSON.stringify({ query: { kind: "HogQLQuery", query }, name: `ocb-crm:${name}` }),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ cache: "no-store",
+ });
+ if (res.status === 429) {
+ const ra = Number.parseInt(res.headers.get("retry-after") ?? "", 10);
+ throw new RateLimited(Number.isFinite(ra) && ra > 0 ? ra * 1000 : 15 * 60_000);
+ }
+ if (!res.ok) {
+ const body = await res.text().catch(() => "");
+ throw new Error(`posthog ${res.status} on ${name}: ${body.slice(0, 300)}`);
+ }
+ const parsed = responseSchema.safeParse(await res.json());
+ if (!parsed.success) throw new Error(`posthog: unexpected response shape on ${name}`);
+ console.log(`[posthog] ${name}: ${parsed.data.results.length} rows in ${Date.now() - started} ms`);
+ return parsed.data.results;
+}
+
+export const num = (v: unknown): number => {
+ const n = typeof v === "number" ? v : Number(v);
+ return Number.isFinite(n) ? n : 0;
+};
+export const str = (v: unknown): string => (v == null ? "" : String(v));
diff --git a/crm/lib/sessions.ts b/crm/lib/sessions.ts
new file mode 100644
index 000000000..5f97f3fa5
--- /dev/null
+++ b/crm/lib/sessions.ts
@@ -0,0 +1,73 @@
+/**
+ * Issued sessions, on the volume next to the snapshot: {nonce: expiresAtMs}.
+ * A session is valid when its cookie signature checks out (lib/auth.ts) AND
+ * its nonce is still listed here, so logout revokes it for real and the list
+ * is the only state. Read through an mtime check: proxy.ts calls this on
+ * every request, from a different bundler layer than the routes that write.
+ */
+import { promises as fs } from "node:fs";
+import path from "node:path";
+
+const DIR = process.env.SNAPSHOT_DIR ?? path.join(process.cwd(), ".snapshots");
+const FILE = path.join(DIR, "sessions.json");
+
+type Store = Record;
+let cache: { mtimeMs: number; data: Store } | null = null;
+
+async function read(): Promise {
+ try {
+ const st = await fs.stat(FILE);
+ if (cache && cache.mtimeMs === st.mtimeMs) return cache.data;
+ const data = JSON.parse(await fs.readFile(FILE, "utf8")) as Store;
+ cache = { mtimeMs: st.mtimeMs, data: data && typeof data === "object" ? data : {} };
+ return cache.data;
+ } catch {
+ return {};
+ }
+}
+
+let counter = 0;
+async function write(data: Store): Promise {
+ await fs.mkdir(DIR, { recursive: true });
+ const tmp = `${FILE}.${process.pid}.${++counter}.tmp`;
+ await fs.writeFile(tmp, JSON.stringify(data));
+ await fs.rename(tmp, FILE);
+ cache = null;
+}
+
+// Read-modify-write under one in-process queue, so two logins in the same
+// second cannot drop each other's nonce.
+const g = globalThis as unknown as { __ocbSessionsChain?: Promise };
+function serial(fn: () => Promise): Promise {
+ const prev = g.__ocbSessionsChain ?? Promise.resolve();
+ const run = prev.then(fn, fn);
+ g.__ocbSessionsChain = run.catch(() => undefined);
+ return run;
+}
+
+function prune(data: Store, now: number): Store {
+ const out: Store = {};
+ for (const [k, exp] of Object.entries(data)) if (typeof exp === "number" && exp > now) out[k] = exp;
+ return out;
+}
+
+export async function sessionListed(nonce: string, now = Date.now()): Promise {
+ const exp = (await read())[nonce];
+ return typeof exp === "number" && exp > now;
+}
+
+export function listSession(nonce: string, expiresAtMs: number, now = Date.now()): Promise {
+ return serial(async () => {
+ const data = prune(await read(), now);
+ data[nonce] = expiresAtMs;
+ await write(data);
+ });
+}
+
+export function unlistSession(nonce: string, now = Date.now()): Promise {
+ return serial(async () => {
+ const data = prune(await read(), now);
+ delete data[nonce];
+ await write(data);
+ });
+}
diff --git a/crm/lib/snapshot.ts b/crm/lib/snapshot.ts
new file mode 100644
index 000000000..2675a6f29
--- /dev/null
+++ b/crm/lib/snapshot.ts
@@ -0,0 +1,198 @@
+/**
+ * The snapshot is the only thing pages read. A refresh rebuilds it section
+ * by section; a section that fails keeps its previous value and records the
+ * error, so an upstream blip never blanks the dashboard and a PostHog 429
+ * stops the batch instead of the app.
+ *
+ * Storage is a JSON file (SNAPSHOT_DIR, a Railway volume in production) plus
+ * one line per day in history.jsonl with the headline numbers, so the
+ * dashboard keeps a record longer than PostHog's retention and independent
+ * of it.
+ */
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { budget, BudgetExhausted, HOURLY_BUDGET, posthogConfigured, RateLimited } from "@/lib/posthog";
+import { loadBenchHealth, loadDuneUsage, loadHarnessHealth, type BenchHealth, type DuneUsage, type HarnessHealth } from "@/lib/ocb";
+import { loadTrafficSection, TRAFFIC_SECTIONS, type Traffic } from "@/lib/traffic";
+
+export const REFRESH_MINUTES = clampInt(process.env.REFRESH_MINUTES, 60, 10, 24 * 60);
+/** A manual refresh is refused while the last one is younger than this. */
+export const MANUAL_COOLDOWN_MINUTES = 10;
+
+const DIR = process.env.SNAPSHOT_DIR ?? path.join(process.cwd(), ".snapshots");
+const FILE = path.join(DIR, "snapshot.json");
+const HISTORY = path.join(DIR, "history.jsonl");
+
+export type SectionStatus = { at: string | null; error: string | null };
+
+export type Snapshot = {
+ v: 1;
+ refreshedAt: string | null;
+ posthogConfigured: boolean;
+ traffic: Partial;
+ benches: BenchHealth | null;
+ harness: HarnessHealth | null;
+ dune: DuneUsage | null;
+ status: Record;
+ budget: { used: number; limit: number };
+};
+
+export type HistoryLine = { day: string; visitors7d: number; pageviews7d: number; aiVisitors7d: number; searchVisitors7d: number; benches: number; stale: number; targetsDown: number };
+
+const EMPTY: Snapshot = { v: 1, refreshedAt: null, posthogConfigured: posthogConfigured(), traffic: {}, benches: null, harness: null, dune: null, status: {}, budget: { used: 0, limit: HOURLY_BUDGET } };
+
+function clampInt(raw: string | undefined, fallback: number, min: number, max: number): number {
+ const n = Number.parseInt(raw ?? "", 10);
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
+}
+
+// Module state lives on globalThis: Next bundles instrumentation.ts (the
+// scheduler) and the app routes in separate layers, and a module reached
+// from two layers is two instances. The file is the source of truth and is
+// re-read whenever its mtime moved, so a refresh from the scheduler, from
+// the CLI or from another process is seen by the next request.
+type Shared = { cache: { mtimeMs: number; snapshot: Snapshot } | null; running: Promise | null };
+const g = globalThis as unknown as { __ocbSnapshot?: Shared };
+const shared: Shared = (g.__ocbSnapshot ??= { cache: null, running: null });
+
+export async function readSnapshot(): Promise {
+ try {
+ const st = await fs.stat(FILE);
+ if (shared.cache && shared.cache.mtimeMs === st.mtimeMs) return shared.cache.snapshot;
+ const parsed = JSON.parse(await fs.readFile(FILE, "utf8")) as Snapshot;
+ if (parsed && parsed.v === 1) {
+ const snapshot = { ...EMPTY, ...parsed, posthogConfigured: posthogConfigured() };
+ shared.cache = { mtimeMs: st.mtimeMs, snapshot };
+ return snapshot;
+ }
+ } catch {
+ // first boot, or an unreadable file: start empty
+ }
+ return { ...EMPTY };
+}
+
+async function writeSnapshot(s: Snapshot): Promise {
+ await fs.mkdir(DIR, { recursive: true });
+ const tmp = `${FILE}.${process.pid}.tmp`;
+ await fs.writeFile(tmp, JSON.stringify(s));
+ await fs.rename(tmp, FILE);
+ shared.cache = null;
+}
+
+/** Parses the journal: one JSON object per line, the last line of a day
+ * wins, unparsable lines are skipped (never the whole file). */
+export function parseHistory(raw: string): HistoryLine[] {
+ const byDay = new Map();
+ for (const line of raw.split("\n")) {
+ if (!line.trim()) continue;
+ try {
+ const l = JSON.parse(line) as HistoryLine;
+ if (l && typeof l.day === "string") byDay.set(l.day, l);
+ } catch {
+ // a torn write; the other lines still count
+ }
+ }
+ return [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day));
+}
+
+export async function readHistory(): Promise {
+ try {
+ return parseHistory(await fs.readFile(HISTORY, "utf8"));
+ } catch {
+ return [];
+ }
+}
+
+/** Append only: one line per refresh, deduped per UTC day on read. */
+async function appendHistory(s: Snapshot): Promise {
+ const t = s.traffic;
+ const line: HistoryLine = {
+ day: new Date().toISOString().slice(0, 10),
+ visitors7d: t.totals?.visitors ?? 0,
+ pageviews7d: t.totals?.pageviews ?? 0,
+ aiVisitors7d: t.totals?.aiVisitors ?? 0,
+ searchVisitors7d: t.totals?.searchVisitors ?? 0,
+ benches: s.benches?.total ?? 0,
+ stale: (s.benches?.stale ?? 0) + (s.benches?.expired ?? 0),
+ targetsDown: s.harness?.down.length ?? 0,
+ };
+ await fs.mkdir(DIR, { recursive: true });
+ await fs.appendFile(HISTORY, `${JSON.stringify(line)}\n`);
+}
+
+export type RefreshResult = { snapshot: Snapshot; ran: string[]; failed: string[]; stoppedBy: string | null; joined: boolean };
+
+/** Rebuilds the snapshot. Concurrent calls join the run in progress. */
+export function refreshSnapshot(reason: string): Promise {
+ if (shared.running) return shared.running.then((snapshot) => ({ snapshot, ran: [], failed: [], stoppedBy: null, joined: true }));
+ const p = doRefresh(reason);
+ shared.running = p.then((r) => r.snapshot);
+ // The shared promise is released whichever way the run ends; the caller
+ // of `p` still sees the rejection.
+ void shared.running.catch(() => undefined).finally(() => {
+ shared.running = null;
+ });
+ return p;
+}
+
+async function doRefresh(reason: string): Promise {
+ const started = Date.now();
+ const prev = await readSnapshot();
+ const next: Snapshot = { ...prev, traffic: { ...prev.traffic }, status: { ...prev.status }, posthogConfigured: posthogConfigured() };
+ const ran: string[] = [];
+ const failed: string[] = [];
+ let stoppedBy: string | null = null;
+ const stamp = () => new Date().toISOString();
+
+ const step = async (name: string, fn: () => Promise) => {
+ try {
+ await fn();
+ next.status[name] = { at: stamp(), error: null };
+ ran.push(name);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ next.status[name] = { at: prev.status[name]?.at ?? null, error: msg };
+ failed.push(name);
+ console.warn(`[refresh] ${name}: ${msg}`);
+ if (err instanceof RateLimited || err instanceof BudgetExhausted) throw err;
+ }
+ };
+
+ await step("benches", async () => {
+ next.benches = await loadBenchHealth();
+ });
+ await step("harness", async () => {
+ next.harness = await loadHarnessHealth();
+ });
+ await step("dune", async () => {
+ next.dune = await loadDuneUsage();
+ });
+
+ if (posthogConfigured()) {
+ try {
+ for (const section of TRAFFIC_SECTIONS) {
+ await step(`traffic.${section}`, async () => {
+ Object.assign(next.traffic, await loadTrafficSection(section));
+ });
+ }
+ } catch (err) {
+ // A 429 or an exhausted local budget: the remaining sections keep their
+ // previous values and the next scheduled refresh retries them.
+ stoppedBy = err instanceof Error ? err.message : String(err);
+ }
+ } else {
+ next.status.posthog = { at: null, error: "POSTHOG_PERSONAL_API_KEY or POSTHOG_PROJECT_ID not set" };
+ }
+
+ next.refreshedAt = stamp();
+ next.budget = { used: budget.used(), limit: HOURLY_BUDGET };
+ await writeSnapshot(next);
+ await appendHistory(next).catch((e) => console.warn("[refresh] history:", e));
+ console.log(`[refresh] ${reason}: ${ran.length} sections in ${Date.now() - started} ms, ${failed.length} failed${stoppedBy ? `, stopped: ${stoppedBy}` : ""}`);
+ return { snapshot: next, ran, failed, stoppedBy, joined: false };
+}
+
+export function snapshotAgeMinutes(s: Snapshot, now = Date.now()): number | null {
+ const t = Date.parse(s.refreshedAt ?? "");
+ return Number.isFinite(t) ? (now - t) / 60_000 : null;
+}
diff --git a/crm/lib/traffic.ts b/crm/lib/traffic.ts
new file mode 100644
index 000000000..690491df6
--- /dev/null
+++ b/crm/lib/traffic.ts
@@ -0,0 +1,214 @@
+/**
+ * The PostHog side of the snapshot: one fixed list of HogQL queries per
+ * refresh (eleven today), each mapped to a plain JSON section. Every query is
+ * scoped to the production host, so staging and localhost never count, and
+ * to `$pageview`, the only event the site captures today (autocapture is off).
+ *
+ * Distinct id, not person id: the site runs `person_profiles: identified_only`
+ * and never identifies anyone, so a visitor is a device cookie.
+ */
+import { classifyPath, classifyReferrer, referrerPredicate, type Channel, type Section } from "@/lib/channels";
+import { num, queryHogQL, str } from "@/lib/posthog";
+
+const SITE_HOST = process.env.SITE_HOST ?? "openchainbench.com";
+const HOST_FILTER = `properties.$host = '${SITE_HOST}'`;
+const PV = `event = '$pageview' AND ${HOST_FILTER}`;
+
+export type DailyPoint = { day: string; pageviews: number; visitors: number; sessions: number };
+export type WeeklyPoint = { week: string; visitors: number; ai: number; search: number; pageviews: number };
+export type PageRow = { path: string; section: Section; visitors: number; prevVisitors: number; pageviews: number };
+export type ReferrerRow = { domain: string; channel: Channel; visitors: number; prevVisitors: number; pageviews: number };
+export type NamedCount = { name: string; visitors: number; share: number };
+export type EntryRow = { path: string; section: Section; sessions: number };
+
+export type Traffic = {
+ daily: DailyPoint[];
+ weekly: WeeklyPoint[];
+ pages: PageRow[];
+ entries: EntryRow[];
+ referrers: ReferrerRow[];
+ countries: NamedCount[];
+ devices: NamedCount[];
+ utm: { source: string; medium: string; visitors: number }[];
+ audience: { newVisitors: number; returningVisitors: number };
+ totals: {
+ visitors: number;
+ prevVisitors: number;
+ pageviews: number;
+ prevPageviews: number;
+ sessions: number;
+ prevSessions: number;
+ /** Distinct visitors whose pageview carried an AI assistant referrer; exact uniques, unlike the per-domain sum. */
+ aiVisitors: number;
+ prevAiVisitors: number;
+ searchVisitors: number;
+ prevSearchVisitors: number;
+ };
+ engagement: { pagesPerSession: number; bounceRate: number; sessions: number };
+};
+
+export const QUERIES = {
+ daily: () => `
+ SELECT toDate(timestamp) AS day, count() AS pageviews, uniq(distinct_id) AS visitors, uniq(properties.$session_id) AS sessions
+ FROM events
+ WHERE ${PV} AND timestamp >= toStartOfDay(now() - INTERVAL 27 DAY)
+ GROUP BY day ORDER BY day`,
+ weekly: () => `
+ SELECT toStartOfWeek(timestamp, 1) AS week,
+ uniq(distinct_id) AS visitors,
+ uniqIf(distinct_id, ${referrerPredicate("ai")}) AS ai,
+ uniqIf(distinct_id, ${referrerPredicate("search")}) AS search,
+ count() AS pageviews
+ FROM events
+ WHERE ${PV} AND timestamp >= toStartOfWeek(now() - INTERVAL 11 WEEK, 1)
+ GROUP BY week ORDER BY week`,
+ pages: () => `
+ SELECT properties.$pathname AS path,
+ uniqIf(distinct_id, timestamp >= now() - INTERVAL 7 DAY) AS visitors,
+ uniqIf(distinct_id, timestamp < now() - INTERVAL 7 DAY) AS prev_visitors,
+ countIf(timestamp >= now() - INTERVAL 7 DAY) AS pageviews
+ FROM events
+ WHERE ${PV} AND timestamp >= now() - INTERVAL 14 DAY
+ GROUP BY path ORDER BY greatest(visitors, prev_visitors) DESC, pageviews DESC LIMIT 2000`,
+ entries: () => `
+ SELECT path, count() AS sessions FROM (
+ SELECT properties.$session_id AS s, argMin(properties.$pathname, timestamp) AS path
+ FROM events WHERE ${PV} AND timestamp >= now() - INTERVAL 7 DAY GROUP BY s
+ ) GROUP BY path ORDER BY sessions DESC LIMIT 40`,
+ referrers: () => `
+ SELECT properties.$referring_domain AS domain,
+ uniqIf(distinct_id, timestamp >= now() - INTERVAL 7 DAY) AS visitors,
+ uniqIf(distinct_id, timestamp < now() - INTERVAL 7 DAY) AS prev_visitors,
+ countIf(timestamp >= now() - INTERVAL 7 DAY) AS pageviews
+ FROM events
+ WHERE ${PV} AND timestamp >= now() - INTERVAL 14 DAY
+ GROUP BY domain ORDER BY greatest(visitors, prev_visitors) DESC LIMIT 400`,
+ countries: () => `
+ SELECT properties.$geoip_country_code AS country, uniq(distinct_id) AS visitors
+ FROM events WHERE ${PV} AND timestamp >= now() - INTERVAL 7 DAY
+ GROUP BY country ORDER BY visitors DESC LIMIT 20`,
+ devices: () => `
+ SELECT properties.$device_type AS device, uniq(distinct_id) AS visitors
+ FROM events WHERE ${PV} AND timestamp >= now() - INTERVAL 7 DAY
+ GROUP BY device ORDER BY visitors DESC LIMIT 6`,
+ utm: () => `
+ SELECT properties.utm_source AS source, properties.utm_medium AS medium, uniq(distinct_id) AS visitors
+ FROM events WHERE ${PV} AND timestamp >= now() - INTERVAL 7 DAY AND properties.utm_source IS NOT NULL AND properties.utm_source != ''
+ GROUP BY source, medium ORDER BY visitors DESC LIMIT 25`,
+ totals: () => `
+ SELECT uniqIf(distinct_id, timestamp >= now() - INTERVAL 7 DAY) AS visitors,
+ uniqIf(distinct_id, timestamp < now() - INTERVAL 7 DAY) AS prev_visitors,
+ countIf(timestamp >= now() - INTERVAL 7 DAY) AS pageviews,
+ countIf(timestamp < now() - INTERVAL 7 DAY) AS prev_pageviews,
+ uniqIf(properties.$session_id, timestamp >= now() - INTERVAL 7 DAY) AS sessions,
+ uniqIf(properties.$session_id, timestamp < now() - INTERVAL 7 DAY) AS prev_sessions,
+ uniqIf(distinct_id, timestamp >= now() - INTERVAL 7 DAY AND ${referrerPredicate("ai")}) AS ai_visitors,
+ uniqIf(distinct_id, timestamp < now() - INTERVAL 7 DAY AND ${referrerPredicate("ai")}) AS prev_ai_visitors,
+ uniqIf(distinct_id, timestamp >= now() - INTERVAL 7 DAY AND ${referrerPredicate("search")}) AS search_visitors,
+ uniqIf(distinct_id, timestamp < now() - INTERVAL 7 DAY AND ${referrerPredicate("search")}) AS prev_search_visitors
+ FROM events WHERE ${PV} AND timestamp >= now() - INTERVAL 14 DAY`,
+ audience: () => `
+ SELECT countIf(first_seen >= now() - INTERVAL 7 DAY) AS new_visitors,
+ countIf(first_seen < now() - INTERVAL 7 DAY) AS returning_visitors
+ FROM (
+ SELECT distinct_id, min(timestamp) AS first_seen, max(timestamp) AS last_seen
+ FROM events WHERE ${PV} GROUP BY distinct_id
+ ) WHERE last_seen >= now() - INTERVAL 7 DAY`,
+ engagement: () => `
+ SELECT avg(n) AS pages_per_session, countIf(n = 1) / count() AS bounce_rate, count() AS sessions FROM (
+ SELECT properties.$session_id AS s, count() AS n
+ FROM events WHERE ${PV} AND timestamp >= now() - INTERVAL 7 DAY AND s IS NOT NULL GROUP BY s
+ )`,
+} as const;
+
+export type TrafficSection = keyof typeof QUERIES;
+export const TRAFFIC_SECTIONS = Object.keys(QUERIES) as TrafficSection[];
+
+/** Runs one section; the caller decides what a failure means for the snapshot. */
+export async function loadTrafficSection(section: TrafficSection): Promise> {
+ const rows = await queryHogQL(section, QUERIES[section]());
+ switch (section) {
+ case "daily":
+ return { daily: rows.map((r) => ({ day: str(r[0]).slice(0, 10), pageviews: num(r[1]), visitors: num(r[2]), sessions: num(r[3]) })) };
+ case "weekly":
+ return { weekly: rows.map((r) => ({ week: str(r[0]).slice(0, 10), visitors: num(r[1]), ai: num(r[2]), search: num(r[3]), pageviews: num(r[4]) })) };
+ case "pages":
+ return {
+ pages: rows.map((r) => ({ path: str(r[0]) || "/", section: classifyPath(str(r[0])), visitors: num(r[1]), prevVisitors: num(r[2]), pageviews: num(r[3]) })),
+ };
+ case "entries":
+ return { entries: rows.map((r) => ({ path: str(r[0]) || "/", section: classifyPath(str(r[0])), sessions: num(r[1]) })) };
+ case "referrers":
+ return {
+ referrers: rows.map((r) => ({ domain: str(r[0]) || "$direct", channel: classifyReferrer(str(r[0])), visitors: num(r[1]), prevVisitors: num(r[2]), pageviews: num(r[3]) })),
+ };
+ case "countries":
+ return { countries: withShare(rows.map((r) => ({ name: str(r[0]) || "unknown", visitors: num(r[1]) }))) };
+ case "devices":
+ return { devices: withShare(rows.map((r) => ({ name: str(r[0]) || "unknown", visitors: num(r[1]) }))) };
+ case "utm":
+ return { utm: rows.map((r) => ({ source: str(r[0]), medium: str(r[1]) || "(none)", visitors: num(r[2]) })) };
+ case "totals":
+ return {
+ totals: {
+ visitors: num(rows[0]?.[0]),
+ prevVisitors: num(rows[0]?.[1]),
+ pageviews: num(rows[0]?.[2]),
+ prevPageviews: num(rows[0]?.[3]),
+ sessions: num(rows[0]?.[4]),
+ prevSessions: num(rows[0]?.[5]),
+ aiVisitors: num(rows[0]?.[6]),
+ prevAiVisitors: num(rows[0]?.[7]),
+ searchVisitors: num(rows[0]?.[8]),
+ prevSearchVisitors: num(rows[0]?.[9]),
+ },
+ };
+ case "audience":
+ return { audience: { newVisitors: num(rows[0]?.[0]), returningVisitors: num(rows[0]?.[1]) } };
+ case "engagement":
+ return { engagement: { pagesPerSession: num(rows[0]?.[0]), bounceRate: num(rows[0]?.[1]), sessions: num(rows[0]?.[2]) } };
+ }
+}
+
+function withShare(rows: { name: string; visitors: number }[]): NamedCount[] {
+ const total = rows.reduce((a, r) => a + r.visitors, 0);
+ return rows.map((r) => ({ ...r, share: total > 0 ? r.visitors / total : 0 }));
+}
+
+/** Derived views, computed from the snapshot at render time. */
+export function sectionTotals(pages: PageRow[]): { section: Section; visitors: number; prevVisitors: number; pageviews: number; pages: number }[] {
+ const acc = new Map();
+ for (const p of pages) {
+ const cur = acc.get(p.section) ?? { visitors: 0, prevVisitors: 0, pageviews: 0, pages: 0 };
+ // Visitors summed over pages overcount a visitor who saw two pages of the
+ // section; the column is labelled "page visits" on the dashboard for that reason.
+ cur.visitors += p.visitors;
+ cur.prevVisitors += p.prevVisitors;
+ cur.pageviews += p.pageviews;
+ if (p.visitors > 0) cur.pages += 1;
+ acc.set(p.section, cur);
+ }
+ return [...acc.entries()].map(([section, v]) => ({ section, ...v })).sort((a, b) => b.visitors - a.visitors);
+}
+
+export function channelTotals(referrers: ReferrerRow[]): { channel: Channel; visitors: number; prevVisitors: number; domains: number }[] {
+ const acc = new Map();
+ for (const r of referrers) {
+ const cur = acc.get(r.channel) ?? { visitors: 0, prevVisitors: 0, domains: 0 };
+ cur.visitors += r.visitors;
+ cur.prevVisitors += r.prevVisitors;
+ if (r.visitors > 0) cur.domains += 1;
+ acc.set(r.channel, cur);
+ }
+ return [...acc.entries()].map(([channel, v]) => ({ channel, ...v })).sort((a, b) => b.visitors - a.visitors);
+}
+
+export function sumWindow(daily: DailyPoint[], days: number, offsetDays = 0): { pageviews: number; visitors: number; sessions: number } {
+ const sorted = [...daily].sort((a, b) => a.day.localeCompare(b.day));
+ const end = sorted.length - offsetDays;
+ const slice = sorted.slice(Math.max(0, end - days), Math.max(0, end));
+ return slice.reduce(
+ (a, d) => ({ pageviews: a.pageviews + d.pageviews, visitors: a.visitors + d.visitors, sessions: a.sessions + d.sessions }),
+ { pageviews: 0, visitors: 0, sessions: 0 },
+ );
+}
diff --git a/crm/next.config.ts b/crm/next.config.ts
new file mode 100644
index 000000000..4a80d370d
--- /dev/null
+++ b/crm/next.config.ts
@@ -0,0 +1,13 @@
+import path from "node:path";
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ output: "standalone",
+ // The app lives inside the site's repository; without this Next traces
+ // from the repository root (it finds the parent lockfile) and nests the
+ // standalone server under crm/.
+ outputFileTracingRoot: path.join(__dirname),
+ poweredByHeader: false,
+};
+
+export default nextConfig;
diff --git a/crm/package.json b/crm/package.json
new file mode 100644
index 000000000..11fd7dd57
--- /dev/null
+++ b/crm/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "ocb-crm",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev -p 3210",
+ "build": "next build",
+ "start": "next start -p ${PORT:-3210}",
+ "typecheck": "tsc --noEmit",
+ "test": "bun test",
+ "refresh": "tsx scripts/refresh.ts"
+ },
+ "dependencies": {
+ "next": "16.2.12",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/bun": "^1.4.2",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "tailwindcss": "^4",
+ "tsx": "4.23.13",
+ "typescript": "^5"
+ },
+ "packageManager": "pnpm@10.34.5"
+}
diff --git a/crm/pnpm-lock.yaml b/crm/pnpm-lock.yaml
new file mode 100644
index 000000000..48f9fb202
--- /dev/null
+++ b/crm/pnpm-lock.yaml
@@ -0,0 +1,1300 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ next:
+ specifier: 16.2.12
+ version: 16.2.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react:
+ specifier: 19.2.4
+ version: 19.2.4
+ react-dom:
+ specifier: 19.2.4
+ version: 19.2.4(react@19.2.4)
+ zod:
+ specifier: ^4.3.6
+ version: 4.6.5
+ devDependencies:
+ '@tailwindcss/postcss':
+ specifier: ^4
+ version: 4.3.3
+ '@types/bun':
+ specifier: ^1.4.2
+ version: 1.4.2
+ '@types/node':
+ specifier: ^20
+ version: 20.19.43
+ '@types/react':
+ specifier: ^19
+ version: 19.3.0
+ '@types/react-dom':
+ specifier: ^19
+ version: 19.3.0(@types/react@19.3.0)
+ tailwindcss:
+ specifier: ^4
+ version: 4.3.3
+ tsx:
+ specifier: 4.23.13
+ version: 4.23.13
+ typescript:
+ specifier: ^5
+ version: 5.9.3
+
+packages:
+
+ '@alloc/quick-lru@5.3.0':
+ resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
+ engines: {node: '>=10'}
+
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
+ '@esbuild/aix-ppc64@0.28.2':
+ resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.28.2':
+ resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.28.2':
+ resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.28.2':
+ resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.28.2':
+ resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.28.2':
+ resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.28.2':
+ resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.28.2':
+ resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.28.2':
+ resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.28.2':
+ resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.28.2':
+ resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.28.2':
+ resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.28.2':
+ resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.28.2':
+ resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.28.2':
+ resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.28.2':
+ resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.28.2':
+ resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.28.2':
+ resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.28.2':
+ resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.28.2':
+ resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.28.2':
+ resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.28.2':
+ resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@img/colour@1.1.0':
+ resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
+ engines: {node: '>=18'}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.34.5':
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@img/sharp-linux-arm64@0.34.5':
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linux-arm@0.34.5':
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linux-s390x@0.34.5':
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linux-x64@0.34.5':
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@img/sharp-wasm32@0.34.5':
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-arm64@0.34.5':
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@img/sharp-win32-ia32@0.34.5':
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.34.5':
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@next/env@16.2.12':
+ resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==}
+
+ '@next/swc-darwin-arm64@16.2.12':
+ resolution: {integrity: sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@next/swc-darwin-x64@16.2.12':
+ resolution: {integrity: sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@next/swc-linux-arm64-gnu@16.2.12':
+ resolution: {integrity: sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@next/swc-linux-arm64-musl@16.2.12':
+ resolution: {integrity: sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@next/swc-linux-x64-gnu@16.2.12':
+ resolution: {integrity: sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@next/swc-linux-x64-musl@16.2.12':
+ resolution: {integrity: sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@next/swc-win32-arm64-msvc@16.2.12':
+ resolution: {integrity: sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@next/swc-win32-x64-msvc@16.2.12':
+ resolution: {integrity: sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/helpers@0.5.15':
+ resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
+
+ '@tailwindcss/node@4.3.3':
+ resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
+
+ '@tailwindcss/oxide-android-arm64@4.3.3':
+ resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
+ resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.3.3':
+ resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
+ resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
+ resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
+ resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
+ resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
+ resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
+ resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
+ resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
+ resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
+ resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.3.3':
+ resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/postcss@4.3.3':
+ resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
+
+ '@types/bun@1.4.2':
+ resolution: {integrity: sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ==}
+
+ '@types/node@20.19.43':
+ resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
+
+ '@types/react-dom@19.3.0':
+ resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
+ peerDependencies:
+ '@types/react': ^19.3.0
+
+ '@types/react@19.3.0':
+ resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
+
+ baseline-browser-mapping@2.11.25:
+ resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ bun-types@1.4.2:
+ resolution: {integrity: sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w==}
+
+ caniuse-lite@1.0.30001810:
+ resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
+
+ client-only@0.0.1:
+ resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ enhanced-resolve@5.25.1:
+ resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==}
+ engines: {node: '>=10.13.0'}
+
+ esbuild@0.28.2:
+ resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ jiti@2.7.0:
+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
+ hasBin: true
+
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ nanoid@3.3.19:
+ resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ next@16.2.12:
+ resolution: {integrity: sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==}
+ engines: {node: '>=20.9.0'}
+ hasBin: true
+ peerDependencies:
+ '@opentelemetry/api': ^1.1.0
+ '@playwright/test': ^1.51.1
+ babel-plugin-react-compiler: '*'
+ react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+ react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+ sass: ^1.3.0
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+ '@playwright/test':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+ sass:
+ optional: true
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ postcss@8.4.31:
+ resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ postcss@8.5.28:
+ resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ react-dom@19.2.4:
+ resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
+ peerDependencies:
+ react: ^19.2.4
+
+ react@19.2.4:
+ resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
+ engines: {node: '>=0.10.0'}
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ sharp@0.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ styled-jsx@5.1.6:
+ resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
+ engines: {node: '>= 12.0.0'}
+ peerDependencies:
+ '@babel/core': '*'
+ babel-plugin-macros: '*'
+ react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ babel-plugin-macros:
+ optional: true
+
+ tailwindcss@4.3.3:
+ resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
+
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
+ engines: {node: '>=6'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ tsx@4.23.13:
+ resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+ zod@4.6.5:
+ resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
+
+snapshots:
+
+ '@alloc/quick-lru@5.3.0': {}
+
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@esbuild/aix-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm@0.28.2':
+ optional: true
+
+ '@esbuild/android-x64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.28.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.28.2':
+ optional: true
+
+ '@img/colour@1.1.0':
+ optional: true
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-darwin-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-arm@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-s390x@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-wasm32@0.34.5':
+ dependencies:
+ '@emnapi/runtime': 1.11.3
+ optional: true
+
+ '@img/sharp-win32-arm64@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-ia32@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-x64@0.34.5':
+ optional: true
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ '@next/env@16.2.12': {}
+
+ '@next/swc-darwin-arm64@16.2.12':
+ optional: true
+
+ '@next/swc-darwin-x64@16.2.12':
+ optional: true
+
+ '@next/swc-linux-arm64-gnu@16.2.12':
+ optional: true
+
+ '@next/swc-linux-arm64-musl@16.2.12':
+ optional: true
+
+ '@next/swc-linux-x64-gnu@16.2.12':
+ optional: true
+
+ '@next/swc-linux-x64-musl@16.2.12':
+ optional: true
+
+ '@next/swc-win32-arm64-msvc@16.2.12':
+ optional: true
+
+ '@next/swc-win32-x64-msvc@16.2.12':
+ optional: true
+
+ '@swc/helpers@0.5.15':
+ dependencies:
+ tslib: 2.8.1
+
+ '@tailwindcss/node@4.3.3':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.25.1
+ jiti: 2.7.0
+ lightningcss: 1.32.0
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.3.3
+
+ '@tailwindcss/oxide-android-arm64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide@4.3.3':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.3.3
+ '@tailwindcss/oxide-darwin-arm64': 4.3.3
+ '@tailwindcss/oxide-darwin-x64': 4.3.3
+ '@tailwindcss/oxide-freebsd-x64': 4.3.3
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
+ '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
+ '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
+ '@tailwindcss/oxide-linux-x64-musl': 4.3.3
+ '@tailwindcss/oxide-wasm32-wasi': 4.3.3
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
+ '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
+
+ '@tailwindcss/postcss@4.3.3':
+ dependencies:
+ '@alloc/quick-lru': 5.3.0
+ '@tailwindcss/node': 4.3.3
+ '@tailwindcss/oxide': 4.3.3
+ postcss: 8.5.28
+ tailwindcss: 4.3.3
+
+ '@types/bun@1.4.2':
+ dependencies:
+ bun-types: 1.4.2
+
+ '@types/node@20.19.43':
+ dependencies:
+ undici-types: 6.21.0
+
+ '@types/react-dom@19.3.0(@types/react@19.3.0)':
+ dependencies:
+ '@types/react': 19.3.0
+
+ '@types/react@19.3.0':
+ dependencies:
+ csstype: 3.2.3
+
+ baseline-browser-mapping@2.11.25: {}
+
+ bun-types@1.4.2:
+ dependencies:
+ '@types/node': 20.19.43
+
+ caniuse-lite@1.0.30001810: {}
+
+ client-only@0.0.1: {}
+
+ csstype@3.2.3: {}
+
+ detect-libc@2.1.2: {}
+
+ enhanced-resolve@5.25.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.3
+
+ esbuild@0.28.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.28.2
+ '@esbuild/android-arm': 0.28.2
+ '@esbuild/android-arm64': 0.28.2
+ '@esbuild/android-x64': 0.28.2
+ '@esbuild/darwin-arm64': 0.28.2
+ '@esbuild/darwin-x64': 0.28.2
+ '@esbuild/freebsd-arm64': 0.28.2
+ '@esbuild/freebsd-x64': 0.28.2
+ '@esbuild/linux-arm': 0.28.2
+ '@esbuild/linux-arm64': 0.28.2
+ '@esbuild/linux-ia32': 0.28.2
+ '@esbuild/linux-loong64': 0.28.2
+ '@esbuild/linux-mips64el': 0.28.2
+ '@esbuild/linux-ppc64': 0.28.2
+ '@esbuild/linux-riscv64': 0.28.2
+ '@esbuild/linux-s390x': 0.28.2
+ '@esbuild/linux-x64': 0.28.2
+ '@esbuild/netbsd-arm64': 0.28.2
+ '@esbuild/netbsd-x64': 0.28.2
+ '@esbuild/openbsd-arm64': 0.28.2
+ '@esbuild/openbsd-x64': 0.28.2
+ '@esbuild/openharmony-arm64': 0.28.2
+ '@esbuild/sunos-x64': 0.28.2
+ '@esbuild/win32-arm64': 0.28.2
+ '@esbuild/win32-ia32': 0.28.2
+ '@esbuild/win32-x64': 0.28.2
+
+ fsevents@2.3.3:
+ optional: true
+
+ graceful-fs@4.2.11: {}
+
+ jiti@2.7.0: {}
+
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ nanoid@3.3.19: {}
+
+ next@16.2.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ '@next/env': 16.2.12
+ '@swc/helpers': 0.5.15
+ baseline-browser-mapping: 2.11.25
+ caniuse-lite: 1.0.30001810
+ postcss: 8.4.31
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ styled-jsx: 5.1.6(react@19.2.4)
+ optionalDependencies:
+ '@next/swc-darwin-arm64': 16.2.12
+ '@next/swc-darwin-x64': 16.2.12
+ '@next/swc-linux-arm64-gnu': 16.2.12
+ '@next/swc-linux-arm64-musl': 16.2.12
+ '@next/swc-linux-x64-gnu': 16.2.12
+ '@next/swc-linux-x64-musl': 16.2.12
+ '@next/swc-win32-arm64-msvc': 16.2.12
+ '@next/swc-win32-x64-msvc': 16.2.12
+ sharp: 0.34.5
+ transitivePeerDependencies:
+ - '@babel/core'
+ - babel-plugin-macros
+
+ picocolors@1.1.1: {}
+
+ postcss@8.4.31:
+ dependencies:
+ nanoid: 3.3.19
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ postcss@8.5.28:
+ dependencies:
+ nanoid: 3.3.19
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ react-dom@19.2.4(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ scheduler: 0.27.0
+
+ react@19.2.4: {}
+
+ scheduler@0.27.0: {}
+
+ semver@7.8.5:
+ optional: true
+
+ sharp@0.34.5:
+ dependencies:
+ '@img/colour': 1.1.0
+ detect-libc: 2.1.2
+ semver: 7.8.5
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.34.5
+ '@img/sharp-darwin-x64': 0.34.5
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-linux-arm': 0.34.5
+ '@img/sharp-linux-arm64': 0.34.5
+ '@img/sharp-linux-ppc64': 0.34.5
+ '@img/sharp-linux-riscv64': 0.34.5
+ '@img/sharp-linux-s390x': 0.34.5
+ '@img/sharp-linux-x64': 0.34.5
+ '@img/sharp-linuxmusl-arm64': 0.34.5
+ '@img/sharp-linuxmusl-x64': 0.34.5
+ '@img/sharp-wasm32': 0.34.5
+ '@img/sharp-win32-arm64': 0.34.5
+ '@img/sharp-win32-ia32': 0.34.5
+ '@img/sharp-win32-x64': 0.34.5
+ optional: true
+
+ source-map-js@1.2.1: {}
+
+ styled-jsx@5.1.6(react@19.2.4):
+ dependencies:
+ client-only: 0.0.1
+ react: 19.2.4
+
+ tailwindcss@4.3.3: {}
+
+ tapable@2.3.3: {}
+
+ tslib@2.8.1: {}
+
+ tsx@4.23.13:
+ dependencies:
+ esbuild: 0.28.2
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ typescript@5.9.3: {}
+
+ undici-types@6.21.0: {}
+
+ zod@4.6.5: {}
diff --git a/crm/postcss.config.mjs b/crm/postcss.config.mjs
new file mode 100644
index 000000000..d062fcdae
--- /dev/null
+++ b/crm/postcss.config.mjs
@@ -0,0 +1,2 @@
+const config = { plugins: { "@tailwindcss/postcss": {} } };
+export default config;
diff --git a/crm/proxy.ts b/crm/proxy.ts
new file mode 100644
index 000000000..c6e6539f4
--- /dev/null
+++ b/crm/proxy.ts
@@ -0,0 +1,20 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { COOKIE, isValidSession } from "@/lib/auth";
+
+// Everything except the login page and its POST needs the session cookie.
+// Static assets are excluded by the matcher.
+export async function proxy(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+ if (pathname === "/login" || pathname === "/api/login") return NextResponse.next();
+ const ok = await isValidSession(request.cookies.get(COOKIE)?.value);
+ if (ok) return NextResponse.next();
+ if (pathname.startsWith("/api/")) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
+ const url = request.nextUrl.clone();
+ url.pathname = "/login";
+ url.search = pathname !== "/" ? `?next=${encodeURIComponent(pathname)}` : "";
+ return NextResponse.redirect(url);
+}
+
+export const config = {
+ matcher: ["/((?!_next/static|_next/image|favicon.ico|icon.svg|robots.txt).*)"],
+};
diff --git a/crm/public/.gitkeep b/crm/public/.gitkeep
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/crm/public/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/crm/scripts/refresh.ts b/crm/scripts/refresh.ts
new file mode 100644
index 000000000..af29d0886
--- /dev/null
+++ b/crm/scripts/refresh.ts
@@ -0,0 +1,13 @@
+// One refresh from the command line (local checks, or a cron on a host
+// without the scheduler): `pnpm refresh`.
+import { refreshSnapshot } from "../lib/snapshot";
+
+refreshSnapshot("cli")
+ .then((r) => {
+ console.log(JSON.stringify({ ran: r.ran, failed: r.failed, stoppedBy: r.stoppedBy, joined: r.joined, refreshedAt: r.snapshot.refreshedAt }, null, 2));
+ process.exit(r.failed.length > 0 || r.stoppedBy ? 1 : 0);
+ })
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ });
diff --git a/crm/test/auth.test.ts b/crm/test/auth.test.ts
new file mode 100644
index 000000000..22080717b
--- /dev/null
+++ b/crm/test/auth.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, test } from "bun:test";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+
+process.env.CRM_PASSWORD = "correct horse battery staple";
+process.env.CRM_SESSION_SECRET = "a-random-session-secret-for-tests";
+process.env.SNAPSHOT_DIR = mkdtempSync(path.join(tmpdir(), "ocb-crm-auth-"));
+const { authConfigured, clientKey, isValidSession, issueSession, loginAllowed, parseSession, passwordMatches, recordLoginAttempt, resetLoginAttempts, revokeSession, sessionSigned } =
+ await import("../lib/auth");
+
+describe("auth", () => {
+ test("configured with both secrets long enough", () => {
+ expect(authConfigured()).toBe(true);
+ });
+ test("password check", async () => {
+ expect(await passwordMatches("correct horse battery staple")).toBe(true);
+ expect(await passwordMatches("correct horse battery stapl")).toBe(false);
+ expect(await passwordMatches("")).toBe(false);
+ });
+ test("a session is random, signed, listed, and revocable", async () => {
+ const a = await issueSession();
+ const b = await issueSession();
+ expect(a).not.toBe(b);
+ expect(await isValidSession(a)).toBe(true);
+ expect(await isValidSession(b)).toBe(true);
+ await revokeSession(a);
+ expect(await isValidSession(a)).toBe(false);
+ expect(await isValidSession(b)).toBe(true);
+ });
+ test("tampering and expiry", async () => {
+ const tok = await issueSession();
+ const s = parseSession(tok)!;
+ expect(await sessionSigned(s)).toBe(true);
+ expect(await sessionSigned({ ...s, sig: `${s.sig.slice(0, 63)}${s.sig[63] === "0" ? "1" : "0"}` })).toBe(false);
+ expect(await sessionSigned({ ...s, expiresAt: s.expiresAt + 1 })).toBe(false);
+ expect(await sessionSigned(s, s.expiresAt + 1)).toBe(false);
+ expect(parseSession("garbage")).toBeNull();
+ expect(parseSession(undefined)).toBeNull();
+ expect(await isValidSession(`${s.nonce}.${s.expiresAt}.${"0".repeat(64)}`)).toBe(false);
+ });
+ test("login attempts are limited per client and globally", () => {
+ resetLoginAttempts();
+ const key = "203.0.113.9";
+ const t0 = 1_700_000_000_000;
+ for (let i = 0; i < 10; i += 1) {
+ expect(loginAllowed(key, t0 + i)).toBe(true);
+ recordLoginAttempt(key, t0 + i);
+ }
+ expect(loginAllowed(key, t0 + 11)).toBe(false);
+ expect(loginAllowed("198.51.100.1", t0 + 11)).toBe(true);
+ // Rotating keys hit the global cap.
+ for (let i = 0; i < 50; i += 1) recordLoginAttempt(`10.0.0.${i}`, t0 + 20 + i);
+ expect(loginAllowed("198.51.100.2", t0 + 100)).toBe(false);
+ expect(loginAllowed(key, t0 + 15 * 60_000 + 1)).toBe(true);
+ resetLoginAttempts();
+ });
+ test("the client key is the last forwarded hop, never a header the client wrote alone", () => {
+ const req = (h: Record) => new Request("http://x/api/login", { headers: h });
+ expect(clientKey(req({ "x-forwarded-for": "1.1.1.1, 203.0.113.7" }))).toBe("203.0.113.7");
+ expect(clientKey(req({ "x-forwarded-for": "203.0.113.7" }))).toBe("203.0.113.7");
+ expect(clientKey(req({ "x-real-ip": "9.9.9.9" }))).toBe("unknown");
+ });
+ test("concurrent logins all end up listed", async () => {
+ const toks = await Promise.all([issueSession(), issueSession(), issueSession()]);
+ for (const t of toks) expect(await isValidSession(t)).toBe(true);
+ });
+});
diff --git a/crm/test/channels.test.ts b/crm/test/channels.test.ts
new file mode 100644
index 000000000..d9660123b
--- /dev/null
+++ b/crm/test/channels.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, test } from "bun:test";
+import { AI_DOMAINS, classifyPath, classifyReferrer, referrerPredicate } from "../lib/channels";
+
+describe("classifyReferrer", () => {
+ test("AI assistants", () => {
+ for (const d of ["chatgpt.com", "www.perplexity.ai", "claude.ai", "gemini.google.com", "copilot.microsoft.com", "chat.mistral.ai"]) {
+ expect(classifyReferrer(d)).toBe("ai");
+ }
+ });
+ test("search engines, including country TLDs", () => {
+ for (const d of ["www.google.com", "google.fr", "www.google.co.uk", "bing.com", "cn.bing.com", "duckduckgo.com", "search.brave.com", "yandex.ru"]) {
+ expect(classifyReferrer(d)).toBe("search");
+ }
+ });
+ test("gemini is AI even though it is a google host", () => {
+ expect(classifyReferrer("gemini.google.com")).toBe("ai");
+ });
+ test("other google properties are referrals", () => {
+ expect(classifyReferrer("docs.google.com")).toBe("referral");
+ expect(classifyReferrer("mail.google.com")).toBe("referral");
+ });
+ test("social", () => {
+ for (const d of ["t.co", "x.com", "www.linkedin.com", "old.reddit.com", "news.ycombinator.com", "t.me", "warpcast.com"]) {
+ expect(classifyReferrer(d)).toBe("social");
+ }
+ });
+ test("direct and internal", () => {
+ expect(classifyReferrer("$direct")).toBe("direct");
+ expect(classifyReferrer("")).toBe("direct");
+ expect(classifyReferrer(null)).toBe("direct");
+ expect(classifyReferrer("openchainbench.com")).toBe("internal");
+ expect(classifyReferrer("staging.openchainbench.com")).toBe("internal");
+ });
+ test("suffix matching never crosses a label boundary", () => {
+ expect(classifyReferrer("notchatgpt.com")).toBe("referral");
+ expect(classifyReferrer("fakex.com")).toBe("referral");
+ });
+});
+
+describe("referrerPredicate", () => {
+ test("AI: exact list plus subdomains", () => {
+ const sql = referrerPredicate("ai");
+ expect(sql).toContain("'chatgpt.com'");
+ expect(sql).toContain("endsWith(properties.$referring_domain, '.perplexity.ai')");
+ expect(sql.split("'").length).toBeGreaterThan(AI_DOMAINS.length * 2);
+ });
+ test("search: Google and Bing country hosts, Gemini excluded", () => {
+ const sql = referrerPredicate("search");
+ expect(sql).toContain("google[.][a-z.]+$");
+ expect(sql).toContain("bing[.]com$");
+ expect(sql).toContain("NOT (properties.$referring_domain IN ('gemini.google.com'");
+ });
+});
+
+describe("classifyPath", () => {
+ test("rpc pages are the -rpc bench slugs and the hub is a hub", () => {
+ expect(classifyPath("/benchmarks/arbitrum-rpc")).toBe("rpc");
+ expect(classifyPath("/benchmarks/keyed-rpc-solana")).toBe("rpc");
+ expect(classifyPath("/benchmarks/arbitrum-rpc/base")).toBe("rpc");
+ expect(classifyPath("/rpc")).toBe("hubs");
+ });
+ test("other benches, compare, answers, products, chains", () => {
+ expect(classifyPath("/benchmarks/bridge-fee")).toBe("benchmarks");
+ expect(classifyPath("/benchmarks/bridge-fee/?chain=base")).toBe("benchmarks");
+ expect(classifyPath("/benchmarks")).toBe("benchmarks");
+ expect(classifyPath("/compare/lifi-vs-relay")).toBe("compare");
+ expect(classifyPath("/answers/cheapest-bridge-usdc-to-base")).toBe("answers");
+ expect(classifyPath("/products/relay")).toBe("products");
+ expect(classifyPath("/alternatives/alchemy")).toBe("products");
+ expect(classifyPath("/chains/base")).toBe("chains");
+ });
+ test("home, hubs, docs, api, other", () => {
+ expect(classifyPath("/")).toBe("home");
+ expect(classifyPath("")).toBe("home");
+ expect(classifyPath(null)).toBe("home");
+ expect(classifyPath("/bridge")).toBe("hubs");
+ expect(classifyPath("/hyperliquid/metamask")).toBe("hubs");
+ expect(classifyPath("/methodology")).toBe("docs");
+ expect(classifyPath("/mcp")).toBe("docs");
+ expect(classifyPath("/api/stat/bridge-fee")).toBe("api");
+ expect(classifyPath("/llms.txt")).toBe("api");
+ expect(classifyPath("/sitemap.xml")).toBe("api");
+ expect(classifyPath("/something-else")).toBe("other");
+ });
+});
diff --git a/crm/test/posthog.test.ts b/crm/test/posthog.test.ts
new file mode 100644
index 000000000..925a3af3e
--- /dev/null
+++ b/crm/test/posthog.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from "bun:test";
+import { Budget, POSTHOG_ORG_LIMIT_PER_HOUR, readBudgetLimit } from "../lib/posthog";
+
+describe("budget", () => {
+ test("limit from env, clamped to PostHog's organisation limit", () => {
+ expect(readBudgetLimit(undefined)).toBe(300);
+ expect(readBudgetLimit("abc")).toBe(300);
+ expect(readBudgetLimit("50")).toBe(50);
+ expect(readBudgetLimit("0")).toBe(1);
+ expect(readBudgetLimit("99999")).toBe(POSTHOG_ORG_LIMIT_PER_HOUR);
+ });
+ test("frees slots after an hour", () => {
+ const budget = new Budget(3);
+ const t0 = 1_000_000_000;
+ expect(budget.waitMs(t0)).toBe(0);
+ budget.spend(t0);
+ budget.spend(t0 + 1);
+ budget.spend(t0 + 2);
+ expect(budget.used(t0 + 3)).toBe(3);
+ expect(budget.waitMs(t0 + 3)).toBe(3_600_000 - 3);
+ expect(budget.waitMs(t0 + 3_600_000)).toBe(0);
+ expect(budget.used(t0 + 3_600_001)).toBe(1);
+ });
+});
diff --git a/crm/test/snapshot.test.ts b/crm/test/snapshot.test.ts
new file mode 100644
index 000000000..e36860b74
--- /dev/null
+++ b/crm/test/snapshot.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "bun:test";
+import { parseHistory } from "../lib/snapshot";
+
+describe("parseHistory", () => {
+ test("last line of a day wins, torn lines are skipped, sorted by day", () => {
+ const raw = [
+ '{"day":"2026-09-02","visitors7d":10}',
+ '{"day":"2026-09-01","visitors7d":5}',
+ '{"day":"2026-09-02","visi',
+ '{"day":"2026-09-02","visitors7d":12}',
+ "",
+ ].join("\n");
+ const h = parseHistory(raw);
+ expect(h.map((l) => l.day)).toEqual(["2026-09-01", "2026-09-02"]);
+ expect(h[1].visitors7d).toBe(12);
+ });
+ test("empty file", () => {
+ expect(parseHistory("")).toEqual([]);
+ });
+});
diff --git a/crm/test/traffic.test.ts b/crm/test/traffic.test.ts
new file mode 100644
index 000000000..513373256
--- /dev/null
+++ b/crm/test/traffic.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, test } from "bun:test";
+import { channelTotals, QUERIES, sectionTotals, sumWindow, TRAFFIC_SECTIONS } from "../lib/traffic";
+
+describe("queries", () => {
+ test("every query is scoped to pageviews on the production host", () => {
+ for (const name of TRAFFIC_SECTIONS) {
+ const q = QUERIES[name]();
+ expect(q).toContain("event = '$pageview'");
+ expect(q).toContain("properties.$host = 'openchainbench.com'");
+ }
+ });
+ test("the refresh spends a bounded number of queries", () => {
+ expect(TRAFFIC_SECTIONS.length).toBeLessThanOrEqual(12);
+ });
+ test("the weekly series and the totals embed the AI domain list", () => {
+ expect(QUERIES.weekly()).toContain("'chatgpt.com'");
+ expect(QUERIES.weekly()).toContain("'perplexity.ai'");
+ expect(QUERIES.totals()).toContain("'chatgpt.com'");
+ expect(QUERIES.totals()).toContain("google[.][a-z.]+$");
+ });
+ test("page and referrer rows are ranked on either week, so losses survive the LIMIT", () => {
+ expect(QUERIES.pages()).toContain("ORDER BY greatest(visitors, prev_visitors) DESC");
+ expect(QUERIES.referrers()).toContain("ORDER BY greatest(visitors, prev_visitors) DESC");
+ });
+});
+
+describe("aggregations", () => {
+ const pages = [
+ { path: "/benchmarks/arbitrum-rpc", section: "rpc" as const, visitors: 10, prevVisitors: 5, pageviews: 12 },
+ { path: "/benchmarks/base-rpc", section: "rpc" as const, visitors: 0, prevVisitors: 2, pageviews: 0 },
+ { path: "/compare/a-vs-b", section: "compare" as const, visitors: 4, prevVisitors: 4, pageviews: 4 },
+ ];
+ test("sectionTotals sums and counts pages with a visit", () => {
+ const t = sectionTotals(pages);
+ expect(t[0]).toEqual({ section: "rpc", visitors: 10, prevVisitors: 7, pageviews: 12, pages: 1 });
+ expect(t[1].section).toBe("compare");
+ });
+ test("channelTotals", () => {
+ const c = channelTotals([
+ { domain: "chatgpt.com", channel: "ai", visitors: 3, prevVisitors: 1, pageviews: 3 },
+ { domain: "perplexity.ai", channel: "ai", visitors: 2, prevVisitors: 0, pageviews: 2 },
+ { domain: "$direct", channel: "direct", visitors: 20, prevVisitors: 25, pageviews: 30 },
+ ]);
+ expect(c[0]).toEqual({ channel: "direct", visitors: 20, prevVisitors: 25, domains: 1 });
+ expect(c[1]).toEqual({ channel: "ai", visitors: 5, prevVisitors: 1, domains: 2 });
+ });
+ test("sumWindow takes the last N days, with an offset", () => {
+ const daily = [1, 2, 3, 4].map((i) => ({ day: `2026-09-0${i}`, pageviews: i, visitors: i, sessions: i }));
+ expect(sumWindow(daily, 2).pageviews).toBe(7);
+ expect(sumWindow(daily, 2, 2).pageviews).toBe(3);
+ });
+});
diff --git a/crm/tsconfig.json b/crm/tsconfig.json
new file mode 100644
index 000000000..f835b727f
--- /dev/null
+++ b/crm/tsconfig.json
@@ -0,0 +1,43 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": [
+ "./*"
+ ]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules",
+ ".next",
+ ".snapshots"
+ ]
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 4446caa27..ad6b663ed 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -20,6 +20,7 @@ const eslintConfig = defineConfig([
// in their own dedicated PRs.
"infrastructure/**",
"worker/**",
+ "crm/**",
]),
]);
diff --git a/tsconfig.json b/tsconfig.json
index 03db12d64..1b6ad9d1c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -38,6 +38,8 @@
// SITE's src/ and breaks `next build` (Cannot find module
// '@/lib/promote' — 2026-07-03 staging+prod deploy outage).
"infrastructure",
- "harnesses"
+ "harnesses",
+ // crm/ is the internal dashboard (own package, Railway); same rule.
+ "crm"
]
}