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 ? ( + + + + + + + + + + + + {referrers.map((r) => ( + + + + + + + + ))} + +
DomainChannelVisitorsw/wPageviews
{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 ? ( + + + + + + + + + + + {b.byCategory.map((c) => ( + + + + + + + ))} + +
CategoryLiveStaleExpired
{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 ? ( + + + + + + + + + + {h.down.map((t) => ( + + + + + + ))} + +
JobInstanceLast error
{t.job} + {t.instance} + + {t.lastError.slice(0, 120)} +
+ ) : h ? ( + + ) : ( + + )} +
+
+ +
+

Benches needing attention (oldest first)

+ {b && b.attention.length > 0 ? ( + + + + + + + + + + + + {b.attention.map((r) => ( + + + + + + + + ))} + +
BenchCategoryStateAgeLast run
+ + {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 ? ( +
+ + + + + + + + + + + + + + + {[...history].reverse().slice(0, 60).map((l) => ( + + + + + + + + + + + ))} + +
DayVisitors 7 dPageviews 7 dAI 7 dSearch 7 dBenchesStale + expiredTargets down
{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. +

+ ) : ( +
+ + + {sp.error && ( +

+ {sp.error === "limited" ? "Too many attempts; wait 15 minutes." : "Wrong password."} +

+ )} + +
+ )} +
+ ); +} 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 ? ( +
+ + + + + + + + + + + + + {[...weekly].reverse().map((w, i) => ( + + + + + + + + + ))} + +
Week ofVisitorsPageviewsFrom AIAI shareFrom search
+ {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 ? ( +
+ + + + + + + + + + + + {sections.map((s) => ( + + + + + + + + ))} + +
SectionPage visitsw/wPageviewsPages with a visit
+ + {SECTION_LABEL[s.section]} + + {fmtInt(s.visitors)} + + {fmtInt(s.pageviews)}{fmtInt(s.pages)}
+
+ ) : ( + + )} +
+ +
+
+

Biggest gains, 7 d vs previous 7 d

+ +
+
+

Biggest losses

+ +
+
+ +
+
+

Top pages, 7 d{filter ? ` · ${SECTION_LABEL[filter]}` : ""}

+ {filter && ( + + all sections + + )} +
+ {shown.length > 0 ? ( +
+ + + + + + + + + + + + {shown.map((p) => ( + + + + + + + + ))} + +
PathSectionVisitorsw/wPageviews
+ + {p.path} + + {SECTION_LABEL[p.section]}{fmtInt(p.visitors)} + + {fmtInt(p.pageviews)}
+
+ ) : ( + + )} +
+ +
+

Entry pages, 7 d (first page of a session)

+ {entries.length > 0 ? ( + + + + + + + + + + {entries.map((e) => ( + + + + + + ))} + +
PathSectionSessions
{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 ( +
+
+ + OCB CRM + + +
+ + Snapshot {fmtAge(age)} · PostHog budget {snapshot.budget.used}/{snapshot.budget.limit} per hour + +
+ +
+
+ +
+
+
+ {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 ( + + ); +} + +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" ] }