SPB Git

spb/fetcha

Public

Fetcha v0.1.0 — public preview: monorepo (web, api, core, db, providers, routing, email, sdk)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 7, 2026) Browse files

199 changed files +20,001 −0

199 changed files
Unified Split
added .env.example +34 −0
@@ -0,0 +1,34 @@
1+# ---- Core ----
2+NODE_ENV=development
3+DATABASE_URL=postgres://localhost:5432/fetcha
4+REDIS_URL=redis://127.0.0.1:6379
5+AUTH_SECRET=change-me-32-bytes-minimum
6+# Public URL of the web app (marketing + dashboard) and the API
7+WEB_URL=http://localhost:8220
8+API_URL=http://127.0.0.1:8221
9+NEXT_PUBLIC_SITE_URL=http://localhost:8220
10+NEXT_PUBLIC_API_URL=http://localhost:8220
11+API_PORT=8221
12+WEB_PORT=8220
13+# Shared secret between the web app and the API (playground, internal calls)
14+INTERNAL_SERVICE_TOKEN=change-me
15+
16+# ---- Email (Resend) ----
17+RESEND_API_KEY=
18+EMAIL_FROM="Fetcha <hello@fetcha.co>"
19+EMAIL_FROM_TRANSACTIONAL="Fetcha <notifications@fetcha.co>"
20+
21+# ---- Billing (Stripe, optional in V1) ----
22+STRIPE_SECRET_KEY=
23+STRIPE_WEBHOOK_SECRET=
24+
25+# ---- Upstream providers (never exposed to customers) ----
26+DECODO_USERNAME=
27+DECODO_PASSWORD=
28+OXYLABS_USERNAME=
29+OXYLABS_PASSWORD=
30+SOAX_USERNAME=
31+SOAX_PASSWORD=
32+
33+# ---- Admin ----
34+ADMIN_EMAILS=you@example.com
added .github/workflows/ci.yml +43 −0
@@ -0,0 +1,43 @@
1+name: CI
2+on:
3+ push:
4+ branches: [main]
5+ pull_request:
6+jobs:
7+ verify:
8+ runs-on: ubuntu-latest
9+ services:
10+ postgres:
11+ image: postgres:17-alpine
12+ env: { POSTGRES_USER: fetcha, POSTGRES_PASSWORD: fetcha, POSTGRES_DB: fetcha }
13+ ports: ["5432:5432"]
14+ options: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
15+ redis:
16+ image: redis:7-alpine
17+ ports: ["6379:6379"]
18+ env:
19+ DATABASE_URL: postgres://fetcha:fetcha@localhost:5432/fetcha
20+ REDIS_URL: redis://localhost:6379
21+ AUTH_SECRET: ci-secret-0123456789abcdef0123456789abcdef
22+ INTERNAL_SERVICE_TOKEN: ci-internal
23+ NEXT_PUBLIC_SITE_URL: http://localhost:8220
24+ NEXT_TELEMETRY_DISABLED: "1"
25+ steps:
26+ - uses: actions/checkout@v4
27+ - uses: pnpm/action-setup@v4
28+ with: { version: 11 }
29+ - uses: actions/setup-node@v4
30+ with: { node-version: 22, cache: pnpm }
31+ - run: pnpm install --frozen-lockfile
32+ - name: Lint
33+ run: pnpm --filter @fetcha/web lint
34+ - name: Typecheck
35+ run: pnpm typecheck
36+ - name: Migrate
37+ run: pnpm db:migrate && pnpm db:seed
38+ - name: Unit tests
39+ run: pnpm test
40+ - name: Security audit (high+)
41+ run: pnpm audit --audit-level high || true
42+ - name: Build
43+ run: pnpm build
added .gitignore +17 −0
@@ -0,0 +1,17 @@
1+node_modules/
2+.next/
3+dist/
4+*.tsbuildinfo
5+.env
6+.env.*
7+!.env.example
8+logs/
9+tmp/
10+coverage/
11+.DS_Store
12+.turbo/
13+.claude/
14+qa/
15+deploy/fetcha.mld.json
16+apps/web/next-env.d.ts
17+qa/node_modules/
added CLAUDE.md +30 −0
@@ -0,0 +1,30 @@
1+# Fetcha — repository guide
2+
3+Fetcha (www.fetcha.co) is "Intelligent Web Access Infrastructure": one API (`POST /v1/fetch`) that routes web requests
4+across upstream proxy networks (Oxylabs, Decodo, SOAX — never exposed to customers) with smart routing, retries,
5+sticky sessions, usage metering and a full dashboard.
6+
7+## Layout
8+- `apps/web` — Next.js 16 (marketing site, docs, auth, customer dashboard, admin). Port 8220. Proxies `/v1/*` to the API.
9+- `apps/api` — Fastify API service (public `/v1/*`, `/health`, `/ready`, internal `/internal/*` for the dashboard). Port 8221.
10+- `packages/core` — zod schemas, error codes, ids/api-key hashing, SSRF policy, geo model, plan limits.
11+- `packages/db` — Drizzle schema + migrations (`drizzle/`), `pnpm db:generate|migrate|seed`.
12+- `packages/providers` — `ProxyProvider` interface + adapters (oxylabs, decodo, soax, direct). Provider code lives ONLY here.
13+- `packages/routing` — circuit breaker, routing score, `FetchExecutor` (attempt loop, block detection, escalation).
14+- `packages/email` — Resend abstraction + React Email templates. `packages/sdk` (JS), `sdk-python/` (Python).
15+- `docs/AGENT-BRIEF.md` — product/engineering rules used when generating UI.
16+
17+## Rules
18+- Never expose provider names, credentials or provider error text to customers. Public debug output uses aliases (network-a/b/c).
19+- Every external URL goes through `assertUrlAllowed()` (SSRF) — including redirects.
20+- Business logic in server actions / `apps/api` / packages, not in React components.
21+- API keys: shown once, SHA-256 stored. Request IDs `req_…` on every response (`X-Fetcha-Request-ID`).
22+- Not implemented yet (say so, don't fake): browser mode, `/v1/extract`, Stripe checkout, teams, webhook delivery, OAuth, 2FA.
23+
24+## Dev
25+`cp .env.example .env` (fill provider creds + RESEND_API_KEY), `createdb fetcha`, `pnpm db:migrate && pnpm db:seed`,
26+`pnpm dev:api` + `pnpm dev:web`. Tests: `pnpm test`. Dev seed: `cd scripts && npx tsx dev-seed.ts you@example.com` prints an API key.
27+
28+## Deploy (MacLustr)
29+Deployed by `mld` (gateway M1M32) on M3U96a: web 8220 + api 8221, Postgres 17 `fetcha` + Redis local, ngrok `www.fetcha.co`.
30+Manifest: `deploy/fetcha.mld.json` → `M1M32:~/dispatch/apps/fetcha.json`. See `deploy/README.md`.
added README.md +41 −0
@@ -0,0 +1,41 @@
1+<p align="center"><img src="apps/web/public/icon.svg" width="56" alt="Fetcha" /></p>
2+<h1 align="center">Fetcha</h1>
3+<p align="center"><strong>Intelligent Web Access Infrastructure</strong><br/>One API. Multiple proxy networks. Reliable web access.</p>
4+
5+```bash
6+curl https://www.fetcha.co/v1/fetch -X POST \
7+ -H "Authorization: Bearer $FETCHA_API_KEY" \
8+ -H "Content-Type: application/json" \
9+ -d '{"url": "https://example.com", "country": "CA"}'
10+```
11+
12+Fetcha takes a URL and figures out how to access it: which network class, which geography, whether a sticky session is
13+needed, when to retry and how to escalate — behind a single, provider-agnostic API with request IDs, usage metering,
14+a Playground and full request logs.
15+
16+## Monorepo
17+| Path | What |
18+|---|---|
19+| `apps/web` | Next.js 16 — marketing site, docs, auth (Better Auth), dashboard, admin |
20+| `apps/api` | Fastify — public `/v1/*` API, health, internal endpoints for the dashboard |
21+| `packages/core` | Schemas, errors, SSRF policy, plans, ids |
22+| `packages/db` | Drizzle ORM schema + migrations (PostgreSQL) |
23+| `packages/providers` | Provider adapters (interchangeable, isolated) |
24+| `packages/routing` | Routing score, circuit breakers, execution engine |
25+| `packages/email` | Resend abstraction + React Email templates |
26+| `packages/sdk`, `sdk-python` | Official SDKs (JS/TS, Python) |
27+
28+## Quick start (local)
29+```bash
30+pnpm install
31+cp .env.example .env # fill DATABASE_URL, REDIS_URL, AUTH_SECRET, RESEND_API_KEY, provider credentials
32+createdb fetcha && pnpm db:migrate && pnpm db:seed
33+pnpm dev:api & # http://127.0.0.1:8221
34+pnpm dev:web # http://localhost:8220
35+```
36+Tests: `pnpm test` · Typecheck: `pnpm typecheck` · Build: `pnpm build`.
37+
38+## Status (v0.1.0 — public preview)
39+Live: Fetch API, auto routing (residential network class), sticky sessions, Playground, request logs, usage metering,
40+API keys, projects, admin console, docs, SDK source. Coming: managed browser, structured extraction, Stripe checkout,
41+teams, webhooks. See `/changelog`.
added apps/api/package.json +31 −0
@@ -0,0 +1,31 @@
1+{
2+ "name": "@fetcha/api",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "tsx watch src/server.ts",
8+ "start": "tsx src/server.ts",
9+ "typecheck": "tsc -p tsconfig.json --noEmit",
10+ "test": "vitest run --passWithNoTests"
11+ },
12+ "dependencies": {
13+ "@fastify/cors": "^11.0.0",
14+ "@fetcha/core": "workspace:*",
15+ "@fetcha/db": "workspace:*",
16+ "@fetcha/email": "workspace:*",
17+ "@fetcha/providers": "workspace:*",
18+ "@fetcha/routing": "workspace:*",
19+ "fastify": "^5.4.0",
20+ "ioredis": "^5.6.0",
21+ "pino": "^9.7.0",
22+ "pino-pretty": "^13.0.0",
23+ "tsx": "^4.20.0",
24+ "zod": "^4.0.0"
25+ },
26+ "devDependencies": {
27+ "@types/node": "^24.0.0",
28+ "typescript": "^5.9.3",
29+ "vitest": "^3.2.0"
30+ }
31+}
added apps/api/src/auth.ts +194 −0
@@ -0,0 +1,194 @@
1+import { FetchaError, hashApiKey, parseApiKeyMode, safeEqual, type ApiKeyScope, type Plan } from "@fetcha/core";
2+import { apiKeys, db, eq, organizations, projects, sql, users } from "@fetcha/db";
3+import { config } from "./config";
4+import { getKV } from "./redis";
5+
6+export interface ApiPrincipal {
7+ keyId: string | null;
8+ keyName: string | null;
9+ mode: "live" | "test";
10+ scopes: ApiKeyScope[];
11+ organizationId: string;
12+ organizationName: string;
13+ plan: Plan;
14+ providerVisibility: boolean;
15+ suspended: boolean;
16+ orgSoftLimitUsd: number | null;
17+ orgHardLimitUsd: number | null;
18+ projectId: string;
19+ projectName: string;
20+ projectLogLevel: "none" | "metadata" | "headers" | "full";
21+ projectSoftLimitUsd: number | null;
22+ projectHardLimitUsd: number | null;
23+ projectMonthlyRequestLimit: number | null;
24+ ownerEmail: string;
25+ ownerVerified: boolean;
26+ /** Playground / dashboard calls (service token) — bypass email verification for limited use. */
27+ internal: boolean;
28+}
29+
30+const CACHE_TTL_SEC = 30;
31+
32+async function loadPrincipalByKeyHash(hash: string): Promise<ApiPrincipal | null> {
33+ const [row] = await db
34+ .select({
35+ keyId: apiKeys.id,
36+ keyName: apiKeys.name,
37+ mode: apiKeys.mode,
38+ scopes: apiKeys.scopes,
39+ expiresAt: apiKeys.expiresAt,
40+ revokedAt: apiKeys.revokedAt,
41+ organizationId: organizations.id,
42+ organizationName: organizations.name,
43+ plan: organizations.plan,
44+ providerVisibility: organizations.providerVisibility,
45+ suspended: organizations.suspended,
46+ orgSoft: organizations.softLimitUsd,
47+ orgHard: organizations.hardLimitUsd,
48+ projectId: projects.id,
49+ projectName: projects.name,
50+ logLevel: projects.logLevel,
51+ projSoft: projects.softLimitUsd,
52+ projHard: projects.hardLimitUsd,
53+ projMonthly: projects.monthlyRequestLimit,
54+ archivedAt: projects.archivedAt,
55+ ownerEmail: users.email,
56+ ownerVerified: users.emailVerified,
57+ ownerBanned: users.banned,
58+ })
59+ .from(apiKeys)
60+ .innerJoin(projects, eq(apiKeys.projectId, projects.id))
61+ .innerJoin(organizations, eq(apiKeys.organizationId, organizations.id))
62+ .innerJoin(users, eq(organizations.ownerUserId, users.id))
63+ .where(eq(apiKeys.keyHash, hash))
64+ .limit(1);
65+ if (!row) return null;
66+ if (row.revokedAt) throw new FetchaError("INVALID_API_KEY", "This API key was revoked.");
67+ if (row.expiresAt && row.expiresAt.getTime() < Date.now()) throw new FetchaError("INVALID_API_KEY", "This API key has expired.");
68+ if (row.archivedAt) throw new FetchaError("FORBIDDEN", "This project is archived.");
69+ if (row.ownerBanned) throw new FetchaError("FORBIDDEN", "This account is suspended.");
70+ return {
71+ keyId: row.keyId,
72+ keyName: row.keyName,
73+ mode: row.mode as "live" | "test",
74+ scopes: (row.scopes ?? []) as ApiKeyScope[],
75+ organizationId: row.organizationId,
76+ organizationName: row.organizationName,
77+ plan: row.plan as Plan,
78+ providerVisibility: row.providerVisibility,
79+ suspended: row.suspended,
80+ orgSoftLimitUsd: row.orgSoft,
81+ orgHardLimitUsd: row.orgHard,
82+ projectId: row.projectId,
83+ projectName: row.projectName,
84+ projectLogLevel: row.logLevel as ApiPrincipal["projectLogLevel"],
85+ projectSoftLimitUsd: row.projSoft,
86+ projectHardLimitUsd: row.projHard,
87+ projectMonthlyRequestLimit: row.projMonthly,
88+ ownerEmail: row.ownerEmail,
89+ ownerVerified: row.ownerVerified,
90+ internal: false,
91+ };
92+}
93+
94+export async function authenticateApiKey(authorization: string | undefined, xApiKey?: string): Promise<ApiPrincipal> {
95+ let token = xApiKey?.trim();
96+ if (!token && authorization) {
97+ const m = authorization.match(/^Bearer\s+(.+)$/i);
98+ token = m?.[1]?.trim();
99+ }
100+ if (!token) throw new FetchaError("INVALID_API_KEY", "Missing API key. Send `Authorization: Bearer fch_live_…`.");
101+ if (!parseApiKeyMode(token) || token.length < 20 || token.length > 128) throw new FetchaError("INVALID_API_KEY", "Malformed API key.");
102+ const hash = hashApiKey(token);
103+ const kv = getKV();
104+ const cacheKey = `fch:key:${hash}`;
105+ const cached = await kv.get(cacheKey).catch(() => null);
106+ let principal: ApiPrincipal | null = null;
107+ if (cached) {
108+ if (cached === "invalid") throw new FetchaError("INVALID_API_KEY");
109+ principal = JSON.parse(cached) as ApiPrincipal;
110+ } else {
111+ principal = await loadPrincipalByKeyHash(hash);
112+ await kv.set(cacheKey, principal ? JSON.stringify(principal) : "invalid", CACHE_TTL_SEC).catch(() => {});
113+ }
114+ if (!principal) throw new FetchaError("INVALID_API_KEY");
115+ if (principal.suspended) throw new FetchaError("FORBIDDEN", "This organization is suspended. Contact support@fetcha.co.");
116+ if (!principal.ownerVerified) throw new FetchaError("EMAIL_NOT_VERIFIED");
117+ // Fire-and-forget last-used update, throttled by the cache TTL.
118+ const luKey = `fch:key:lu:${principal.keyId}`;
119+ if (!(await kv.get(luKey).catch(() => "1"))) {
120+ await kv.set(luKey, "1", 60).catch(() => {});
121+ db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, principal.keyId!)).catch(() => {});
122+ }
123+ return principal;
124+}
125+
126+export function requireScope(p: ApiPrincipal, scope: ApiKeyScope): void {
127+ if (p.internal) return;
128+ if (!p.scopes.includes(scope)) throw new FetchaError("FORBIDDEN", `This API key lacks the "${scope}" scope.`);
129+}
130+
131+/** Internal service authentication (dashboard → API). */
132+export function assertInternalToken(header: string | undefined): void {
133+ const token = header?.replace(/^Bearer\s+/i, "").trim();
134+ if (!config.internalToken || !token || !safeEqual(token, config.internalToken)) {
135+ throw new FetchaError("FORBIDDEN", "Invalid internal service token.");
136+ }
137+}
138+
139+/** Build a principal for a project on behalf of the dashboard (playground). */
140+export async function principalForProject(projectId: string, userId: string): Promise<ApiPrincipal> {
141+ const [row] = await db
142+ .select({
143+ organizationId: organizations.id,
144+ organizationName: organizations.name,
145+ plan: organizations.plan,
146+ providerVisibility: organizations.providerVisibility,
147+ suspended: organizations.suspended,
148+ orgSoft: organizations.softLimitUsd,
149+ orgHard: organizations.hardLimitUsd,
150+ projectId: projects.id,
151+ projectName: projects.name,
152+ logLevel: projects.logLevel,
153+ projSoft: projects.softLimitUsd,
154+ projHard: projects.hardLimitUsd,
155+ projMonthly: projects.monthlyRequestLimit,
156+ ownerEmail: users.email,
157+ ownerVerified: users.emailVerified,
158+ isMember: sql<boolean>`exists (select 1 from organization_members m where m.organization_id = ${organizations.id} and m.user_id = ${userId})`,
159+ })
160+ .from(projects)
161+ .innerJoin(organizations, eq(projects.organizationId, organizations.id))
162+ .innerJoin(users, eq(organizations.ownerUserId, users.id))
163+ .where(eq(projects.id, projectId))
164+ .limit(1);
165+ if (!row) throw new FetchaError("NOT_FOUND", "Project not found.");
166+ if (!row.isMember) throw new FetchaError("FORBIDDEN");
167+ if (row.suspended) throw new FetchaError("FORBIDDEN", "This organization is suspended.");
168+ return {
169+ keyId: null,
170+ keyName: null,
171+ mode: "live",
172+ scopes: ["fetch:execute", "sessions:write", "usage:read"],
173+ organizationId: row.organizationId,
174+ organizationName: row.organizationName,
175+ plan: row.plan as Plan,
176+ providerVisibility: row.providerVisibility,
177+ suspended: row.suspended,
178+ orgSoftLimitUsd: row.orgSoft,
179+ orgHardLimitUsd: row.orgHard,
180+ projectId: row.projectId,
181+ projectName: row.projectName,
182+ projectLogLevel: row.logLevel as ApiPrincipal["projectLogLevel"],
183+ projectSoftLimitUsd: row.projSoft,
184+ projectHardLimitUsd: row.projHard,
185+ projectMonthlyRequestLimit: row.projMonthly,
186+ ownerEmail: row.ownerEmail,
187+ ownerVerified: row.ownerVerified,
188+ internal: true,
189+ };
190+}
191+
192+export async function invalidateKeyCache(keyHash: string): Promise<void> {
193+ await getKV().del(`fch:key:${keyHash}`).catch(() => {});
194+}
added apps/api/src/config.ts +24 −0
@@ -0,0 +1,24 @@
1+export const config = {
2+ env: process.env.NODE_ENV ?? "development",
3+ port: Number(process.env.API_PORT ?? 8221),
4+ host: process.env.API_HOST ?? "127.0.0.1",
5+ webUrl: (process.env.WEB_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:8220").replace(/\/$/, ""),
6+ redisUrl: process.env.REDIS_URL ?? "",
7+ internalToken: process.env.INTERNAL_SERVICE_TOKEN ?? "",
8+ logLevel: process.env.LOG_LEVEL ?? "info",
9+ /** Store response bodies for playground inspection (never for API by default). */
10+ storeBodies: process.env.FETCHA_STORE_BODIES === "1",
11+ healthProbeIntervalMs: Number(process.env.HEALTH_PROBE_INTERVAL_MS ?? 5 * 60_000),
12+ maxResponseBytesDefault: Number(process.env.MAX_RESPONSE_BYTES ?? 20_000_000),
13+ /** Multiplier over upstream cost used for customer pricing when a plan has no explicit unit price. */
14+ defaultMargin: Number(process.env.FETCHA_DEFAULT_MARGIN ?? 2.5),
15+ version: process.env.FETCHA_VERSION ?? "0.1.0",
16+} as const;
17+
18+export function assertProductionConfig(): void {
19+ if (config.env !== "production") return;
20+ const missing: string[] = [];
21+ if (!process.env.DATABASE_URL) missing.push("DATABASE_URL");
22+ if (!config.internalToken || config.internalToken === "change-me") missing.push("INTERNAL_SERVICE_TOKEN");
23+ if (missing.length) throw new Error(`Missing required production configuration: ${missing.join(", ")}`);
24+}
added apps/api/src/limits.ts +98 −0
@@ -0,0 +1,98 @@
1+import { FetchaError, PLAN_LIMITS } from "@fetcha/core";
2+import { db, sql, usageEvents } from "@fetcha/db";
3+import type { ApiPrincipal } from "./auth";
4+import { getKV } from "./redis";
5+
6+const CONCURRENCY_TTL_SEC = 150; // safety net if a release is lost
7+
8+/** Reserve a concurrency slot for the organization. Returns a release function. */
9+export async function acquireConcurrency(p: ApiPrincipal): Promise<() => Promise<void>> {
10+ const kv = getKV();
11+ const limit = PLAN_LIMITS[p.plan].concurrency;
12+ const key = `fch:conc:${p.organizationId}`;
13+ const n = await kv.incrWithTtl(key, CONCURRENCY_TTL_SEC);
14+ if (n > limit) {
15+ await kv.decr(key);
16+ throw new FetchaError("CONCURRENCY_LIMIT", `Your plan allows ${limit} concurrent requests.`, { details: { limit } });
17+ }
18+ let released = false;
19+ return async () => {
20+ if (released) return;
21+ released = true;
22+ await kv.decr(key).catch(() => {});
23+ };
24+}
25+
26+/** Sliding-window rate limits: per key (burst), per organization (sustained), per IP (anonymous abuse). */
27+export async function checkRateLimits(p: ApiPrincipal, ip: string | null): Promise<void> {
28+ const kv = getKV();
29+ const limits = PLAN_LIMITS[p.plan];
30+ const perMinute = Math.max(60, limits.concurrency * 60); // generous: concurrency is the real cap
31+ const checks: Array<Promise<{ allowed: boolean; retryAfterMs: number }>> = [
32+ kv.slidingWindow(`fch:rl:org:${p.organizationId}`, 60_000, perMinute),
33+ ];
34+ if (p.keyId) checks.push(kv.slidingWindow(`fch:rl:key:${p.keyId}`, 1_000, Math.max(10, limits.concurrency * 2)));
35+ if (ip) checks.push(kv.slidingWindow(`fch:rl:ip:${ip}`, 1_000, 200));
36+ const results = await Promise.all(checks);
37+ const blocked = results.find((r) => !r.allowed);
38+ if (blocked) throw new FetchaError("RATE_LIMITED", undefined, { details: { retry_after_ms: blocked.retryAfterMs } });
39+}
40+
41+export interface MonthlyUsage {
42+ requests: number;
43+ spendUsd: number;
44+ projectRequests: number;
45+ projectSpendUsd: number;
46+}
47+
48+export async function monthlyUsage(p: ApiPrincipal): Promise<MonthlyUsage> {
49+ const kv = getKV();
50+ const cacheKey = `fch:usage:${p.organizationId}:${p.projectId}`;
51+ const cached = await kv.get(cacheKey).catch(() => null);
52+ if (cached) return JSON.parse(cached) as MonthlyUsage;
53+ const start = new Date();
54+ start.setUTCDate(1);
55+ start.setUTCHours(0, 0, 0, 0);
56+ const [org] = await db
57+ .select({
58+ requests: sql<number>`coalesce(sum(case when ${usageEvents.metric} = 'request' then ${usageEvents.quantity} else 0 end), 0)::float`,
59+ spend: sql<number>`coalesce(sum(${usageEvents.costUsd}), 0)::float`,
60+ })
61+ .from(usageEvents)
62+ .where(sql`${usageEvents.organizationId} = ${p.organizationId} and ${usageEvents.createdAt} >= ${start}`);
63+ const [proj] = await db
64+ .select({
65+ requests: sql<number>`coalesce(sum(case when ${usageEvents.metric} = 'request' then ${usageEvents.quantity} else 0 end), 0)::float`,
66+ spend: sql<number>`coalesce(sum(${usageEvents.costUsd}), 0)::float`,
67+ })
68+ .from(usageEvents)
69+ .where(sql`${usageEvents.projectId} = ${p.projectId} and ${usageEvents.createdAt} >= ${start}`);
70+ const usage: MonthlyUsage = { requests: Number(org?.requests ?? 0), spendUsd: Number(org?.spend ?? 0), projectRequests: Number(proj?.requests ?? 0), projectSpendUsd: Number(proj?.spend ?? 0) };
71+ await kv.set(cacheKey, JSON.stringify(usage), 20).catch(() => {});
72+ return usage;
73+}
74+
75+/** Enforce plan quota and hard spending limits. Soft limits are handled by the alerting job. */
76+export async function checkMonthlyLimits(p: ApiPrincipal): Promise<MonthlyUsage> {
77+ const usage = await monthlyUsage(p);
78+ const limits = PLAN_LIMITS[p.plan];
79+ if (usage.requests >= limits.monthly_requests) {
80+ throw new FetchaError("USAGE_LIMIT_REACHED", `Monthly request quota of ${limits.monthly_requests.toLocaleString("en-US")} reached for the ${limits.label} plan.`, {
81+ details: { limit: limits.monthly_requests, used: usage.requests },
82+ });
83+ }
84+ if (p.projectMonthlyRequestLimit && usage.projectRequests >= p.projectMonthlyRequestLimit) {
85+ throw new FetchaError("USAGE_LIMIT_REACHED", "Project monthly request limit reached.", { details: { limit: p.projectMonthlyRequestLimit, used: usage.projectRequests } });
86+ }
87+ if (p.orgHardLimitUsd !== null && usage.spendUsd >= p.orgHardLimitUsd) {
88+ throw new FetchaError("USAGE_LIMIT_REACHED", "Organization hard spending limit reached.", { details: { limit_usd: p.orgHardLimitUsd, spent_usd: usage.spendUsd } });
89+ }
90+ if (p.projectHardLimitUsd !== null && usage.projectSpendUsd >= p.projectHardLimitUsd) {
91+ throw new FetchaError("USAGE_LIMIT_REACHED", "Project hard spending limit reached.", { details: { limit_usd: p.projectHardLimitUsd, spent_usd: usage.projectSpendUsd } });
92+ }
93+ return usage;
94+}
95+
96+export async function invalidateUsageCache(organizationId: string, projectId: string): Promise<void> {
97+ await getKV().del(`fch:usage:${organizationId}:${projectId}`).catch(() => {});
98+}
added apps/api/src/redis.ts +133 −0
@@ -0,0 +1,133 @@
1+import Redis from "ioredis";
2+import { config } from "./config";
3+
4+/**
5+ * Redis client with an in-memory fallback for local development. Production requires
6+ * REDIS_URL — the fallback logs a warning and only works within a single process.
7+ */
8+export interface KV {
9+ incrWithTtl(key: string, ttlSec: number): Promise<number>;
10+ decr(key: string): Promise<number>;
11+ get(key: string): Promise<string | null>;
12+ set(key: string, value: string, ttlSec?: number): Promise<void>;
13+ del(key: string): Promise<void>;
14+ /** Sliding-window counter: returns count within window after adding one hit. */
15+ slidingWindow(key: string, windowMs: number, limit: number): Promise<{ count: number; allowed: boolean; retryAfterMs: number }>;
16+ ping(): Promise<boolean>;
17+ kind: "redis" | "memory";
18+}
19+
20+class MemoryKV implements KV {
21+ kind = "memory" as const;
22+ private store = new Map<string, { v: string; exp: number | null }>();
23+ private windows = new Map<string, number[]>();
24+
25+ private alive(k: string) {
26+ const e = this.store.get(k);
27+ if (!e) return null;
28+ if (e.exp !== null && e.exp < Date.now()) {
29+ this.store.delete(k);
30+ return null;
31+ }
32+ return e;
33+ }
34+ async incrWithTtl(key: string, ttlSec: number) {
35+ const e = this.alive(key);
36+ const n = (e ? Number(e.v) : 0) + 1;
37+ this.store.set(key, { v: String(n), exp: e?.exp ?? Date.now() + ttlSec * 1000 });
38+ return n;
39+ }
40+ async decr(key: string) {
41+ const e = this.alive(key);
42+ const n = Math.max(0, (e ? Number(e.v) : 0) - 1);
43+ if (e) this.store.set(key, { v: String(n), exp: e.exp });
44+ return n;
45+ }
46+ async get(key: string) {
47+ return this.alive(key)?.v ?? null;
48+ }
49+ async set(key: string, value: string, ttlSec?: number) {
50+ this.store.set(key, { v: value, exp: ttlSec ? Date.now() + ttlSec * 1000 : null });
51+ }
52+ async del(key: string) {
53+ this.store.delete(key);
54+ }
55+ async slidingWindow(key: string, windowMs: number, limit: number) {
56+ const now = Date.now();
57+ const arr = (this.windows.get(key) ?? []).filter((t) => t > now - windowMs);
58+ arr.push(now);
59+ this.windows.set(key, arr);
60+ const allowed = arr.length <= limit;
61+ return { count: arr.length, allowed, retryAfterMs: allowed ? 0 : Math.max(0, arr[0]! + windowMs - now) };
62+ }
63+ async ping() {
64+ return true;
65+ }
66+}
67+
68+class RedisKV implements KV {
69+ kind = "redis" as const;
70+ constructor(readonly client: Redis) {}
71+ async incrWithTtl(key: string, ttlSec: number) {
72+ const res = await this.client.multi().incr(key).expire(key, ttlSec, "NX").exec();
73+ return Number(res?.[0]?.[1] ?? 0);
74+ }
75+ async decr(key: string) {
76+ const n = await this.client.decr(key);
77+ if (n < 0) await this.client.set(key, "0");
78+ return Math.max(0, n);
79+ }
80+ async get(key: string) {
81+ return this.client.get(key);
82+ }
83+ async set(key: string, value: string, ttlSec?: number) {
84+ if (ttlSec) await this.client.set(key, value, "EX", ttlSec);
85+ else await this.client.set(key, value);
86+ }
87+ async del(key: string) {
88+ await this.client.del(key);
89+ }
90+ async slidingWindow(key: string, windowMs: number, limit: number) {
91+ const now = Date.now();
92+ const member = `${now}-${Math.random().toString(36).slice(2, 8)}`;
93+ const res = await this.client
94+ .multi()
95+ .zremrangebyscore(key, 0, now - windowMs)
96+ .zadd(key, now, member)
97+ .zcard(key)
98+ .zrange(key, 0, 0, "WITHSCORES")
99+ .pexpire(key, windowMs)
100+ .exec();
101+ const count = Number(res?.[2]?.[1] ?? 0);
102+ const oldest = Number((res?.[3]?.[1] as string[] | undefined)?.[1] ?? now);
103+ const allowed = count <= limit;
104+ return { count, allowed, retryAfterMs: allowed ? 0 : Math.max(0, oldest + windowMs - now) };
105+ }
106+ async ping() {
107+ try {
108+ return (await this.client.ping()) === "PONG";
109+ } catch {
110+ return false;
111+ }
112+ }
113+}
114+
115+let _kv: KV | null = null;
116+
117+export function getKV(): KV {
118+ if (_kv) return _kv;
119+ if (config.redisUrl) {
120+ const client = new Redis(config.redisUrl, { maxRetriesPerRequest: 2, enableOfflineQueue: true, lazyConnect: false });
121+ client.on("error", (e) => console.error("[redis]", e.message));
122+ _kv = new RedisKV(client);
123+ } else {
124+ if (config.env === "production") console.warn("[redis] REDIS_URL not set — falling back to in-memory limits (single process only)");
125+ _kv = new MemoryKV();
126+ }
127+ return _kv;
128+}
129+
130+export async function closeKV(): Promise<void> {
131+ if (_kv && _kv.kind === "redis") await (_kv as RedisKV).client.quit().catch(() => {});
132+ _kv = null;
133+}
added apps/api/src/routes/fetch.ts +136 −0
@@ -0,0 +1,136 @@
1+import { FetchaError, PLAN_LIMITS, extractDomain, fetchRequestSchema, newId, type FetchRequest } from "@fetcha/core";
2+import { db, eq, proxySessions, sql } from "@fetcha/db";
3+import type { ProviderId } from "@fetcha/providers";
4+import type { AttemptRecord } from "@fetcha/routing";
5+import type { ConcreteNetwork } from "@fetcha/core";
6+import type { ApiPrincipal } from "../auth";
7+import { config } from "../config";
8+import { acquireConcurrency, checkMonthlyLimits, checkRateLimits, invalidateUsageCache } from "../limits";
9+import { getEngine } from "../services/engine";
10+import { completeRequest, createRequestRow, failRequest, recordAttempt, type RequestContext } from "../services/persist";
11+import { checkSpendAlerts, notifyHardLimit, recordAbuse } from "../services/alerts";
12+
13+export interface FetchHandlerInput {
14+ principal: ApiPrincipal;
15+ body: unknown;
16+ source: "api" | "playground" | "sdk";
17+ clientIp: string | null;
18+ userAgent: string | null;
19+}
20+
21+export interface FetchHandlerOutput {
22+ requestId: string;
23+ status: number;
24+ body: unknown;
25+}
26+
27+/**
28+ * Shared /v1/fetch pipeline used by the public API and the dashboard playground.
29+ * Order matters: validate → limits → SSRF (inside executor) → route → persist.
30+ */
31+export async function handleFetch(input: FetchHandlerInput): Promise<FetchHandlerOutput> {
32+ const { principal } = input;
33+ const requestId = newId("req");
34+ const started = performance.now();
35+
36+ const parsed = fetchRequestSchema.safeParse(input.body);
37+ if (!parsed.success) {
38+ throw new FetchaError("INVALID_REQUEST", "The request body is invalid.", {
39+ requestId,
40+ details: { issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) },
41+ });
42+ }
43+ const req: FetchRequest = parsed.data;
44+ const limits = PLAN_LIMITS[principal.plan];
45+ if (req.timeout > limits.max_timeout_ms) req.timeout = limits.max_timeout_ms;
46+ if (req.network !== "auto" && !limits.networks.includes(req.network)) {
47+ throw new FetchaError("NETWORK_UNAVAILABLE", `The "${req.network}" network is not included in the ${limits.label} plan.`, { requestId });
48+ }
49+ if (req.browser) throw new FetchaError("BROWSER_UNAVAILABLE", undefined, { requestId });
50+
51+ await checkRateLimits(principal, input.clientIp);
52+ let monthly: Awaited<ReturnType<typeof checkMonthlyLimits>>;
53+ try {
54+ monthly = await checkMonthlyLimits(principal);
55+ } catch (e) {
56+ if (e instanceof FetchaError && e.code === "USAGE_LIMIT_REACHED" && (principal.projectHardLimitUsd !== null || principal.orgHardLimitUsd !== null)) {
57+ const { monthlyUsage } = await import("../limits");
58+ notifyHardLimit(principal, await monthlyUsage(principal)).catch(() => {});
59+ }
60+ throw e;
61+ }
62+ const release = await acquireConcurrency(principal);
63+
64+ const domain = extractDomain(req.url);
65+ const ctx: RequestContext = {
66+ requestId,
67+ organizationId: principal.organizationId,
68+ projectId: principal.projectId,
69+ apiKeyId: principal.keyId,
70+ source: input.source,
71+ plan: principal.plan,
72+ clientIp: input.clientIp,
73+ userAgent: input.userAgent,
74+ logLevel: principal.projectLogLevel,
75+ };
76+
77+ const attempts: AttemptRecord[] = [];
78+ try {
79+ await createRequestRow(ctx, req, domain);
80+ const engine = await getEngine();
81+
82+ // Resolve a Fetcha session (sess_…) into a provider-pinned sticky key.
83+ let sessionKey: string | null = null;
84+ let sessionProvider: ProviderId | null = null;
85+ let sessionNetwork: ConcreteNetwork | null = null;
86+ if (req.session) {
87+ const [s] = await db.select().from(proxySessions).where(eq(proxySessions.id, req.session)).limit(1);
88+ if (!s || s.projectId !== principal.projectId) throw new FetchaError("SESSION_NOT_FOUND", undefined, { requestId });
89+ if (s.status !== "active" || s.expiresAt.getTime() < Date.now()) throw new FetchaError("SESSION_EXPIRED", undefined, { requestId });
90+ sessionKey = s.stickyKey;
91+ sessionProvider = s.provider as ProviderId;
92+ sessionNetwork = s.network as ConcreteNetwork;
93+ if (!req.country && s.country) req.country = s.country;
94+ if (req.network === "auto") req.network = s.network as FetchRequest["network"];
95+ db.update(proxySessions)
96+ .set({ lastUsedAt: new Date(), requestCount: sql`${proxySessions.requestCount} + 1` })
97+ .where(eq(proxySessions.id, s.id))
98+ .catch(() => {});
99+ }
100+
101+ const knowledge = await engine.knowledge(domain);
102+ const result = await engine.executor.execute({
103+ requestId,
104+ plan: principal.plan,
105+ request: req,
106+ sessionKey,
107+ sessionProvider,
108+ sessionNetwork,
109+ knowledge,
110+ providerVisibility: principal.providerVisibility,
111+ maxResponseBytes: Math.min(req.max_response_bytes ?? config.maxResponseBytesDefault, config.maxResponseBytesDefault),
112+ onAttempt: async (a) => {
113+ attempts.push(a);
114+ await recordAttempt(requestId, a).catch(() => {});
115+ },
116+ });
117+ const latencyMs = Math.round(performance.now() - started);
118+ await completeRequest(ctx, req, result, latencyMs);
119+ await invalidateUsageCache(principal.organizationId, principal.projectId);
120+ if (principal.projectSoftLimitUsd !== null || principal.orgSoftLimitUsd !== null) {
121+ const { monthlyUsage } = await import("../limits");
122+ monthlyUsage(principal).then((u) => checkSpendAlerts(principal, u)).catch(() => {});
123+ }
124+ void monthly;
125+ return { requestId, status: 200, body: result.body };
126+ } catch (e) {
127+ const latencyMs = Math.round(performance.now() - started);
128+ const err = e instanceof FetchaError ? e : new FetchaError("INTERNAL_ERROR", undefined, { requestId, cause: e });
129+ await failRequest(ctx, err.code, err.message, attempts, latencyMs, domain).catch(() => {});
130+ if (err.code === "URL_NOT_ALLOWED") await recordAbuse(principal, requestId, "ssrf_attempt", `${req.method} ${req.url}`);
131+ if (!(e instanceof FetchaError)) console.error(`[fetch] ${requestId} internal error`, e);
132+ throw Object.assign(err, { requestId });
133+ } finally {
134+ await release();
135+ }
136+}
added apps/api/src/routes/internal.ts +111 −0
@@ -0,0 +1,111 @@
1+import type { FastifyInstance } from "fastify";
2+import { z } from "zod";
3+import { FetchaError } from "@fetcha/core";
4+import { db, desc, providerHealth, sql } from "@fetcha/db";
5+import { assertInternalToken, principalForProject } from "../auth";
6+import { getEngine } from "../services/engine";
7+import { handleFetch } from "./fetch";
8+import { closeSession, createSession, listSessions } from "./sessions";
9+import { usageResponse } from "./misc";
10+
11+/**
12+ * Internal routes used by the dashboard (Next.js server) with the shared service token.
13+ * They are never exposed publicly: the web app proxies only /v1/* to this service.
14+ */
15+export async function registerInternalRoutes(app: FastifyInstance) {
16+ app.addHook("onRequest", async (req) => {
17+ if (req.url.startsWith("/internal/")) assertInternalToken(req.headers.authorization);
18+ });
19+
20+ const playgroundSchema = z.object({ project_id: z.string(), user_id: z.string(), request: z.unknown() });
21+
22+ app.post("/internal/fetch", async (req, reply) => {
23+ const parsed = playgroundSchema.safeParse(req.body);
24+ if (!parsed.success) throw new FetchaError("INVALID_REQUEST", "project_id, user_id and request are required.");
25+ const principal = await principalForProject(parsed.data.project_id, parsed.data.user_id);
26+ const out = await handleFetch({
27+ principal,
28+ body: parsed.data.request,
29+ source: "playground",
30+ clientIp: (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? req.ip,
31+ userAgent: "fetcha-playground",
32+ });
33+ reply.header("x-fetcha-request-id", out.requestId);
34+ return out.body;
35+ });
36+
37+ const sessionSchema = z.object({ project_id: z.string(), user_id: z.string(), options: z.unknown().optional() });
38+ app.post("/internal/sessions", async (req) => {
39+ const parsed = sessionSchema.safeParse(req.body);
40+ if (!parsed.success) throw new FetchaError("INVALID_REQUEST");
41+ const principal = await principalForProject(parsed.data.project_id, parsed.data.user_id);
42+ return createSession(principal, parsed.data.options ?? {});
43+ });
44+ app.get<{ Querystring: { project_id: string; user_id: string } }>("/internal/sessions", async (req) => {
45+ const principal = await principalForProject(req.query.project_id, req.query.user_id);
46+ return listSessions(principal);
47+ });
48+ app.delete<{ Params: { id: string }; Querystring: { project_id: string; user_id: string } }>("/internal/sessions/:id", async (req) => {
49+ const principal = await principalForProject(req.query.project_id, req.query.user_id);
50+ return closeSession(principal, req.params.id);
51+ });
52+ app.get<{ Querystring: { project_id: string; user_id: string } }>("/internal/usage", async (req) => {
53+ const principal = await principalForProject(req.query.project_id, req.query.user_id);
54+ return usageResponse(principal);
55+ });
56+
57+ /** Admin: provider status (names visible — admin only). */
58+ app.get("/internal/providers", async () => {
59+ const engine = await getEngine();
60+ const latest = await db
61+ .select()
62+ .from(providerHealth)
63+ .where(sql`${providerHealth.checkedAt} > now() - interval '1 day'`)
64+ .orderBy(desc(providerHealth.checkedAt))
65+ .limit(200);
66+ const seen = new Set<string>();
67+ const health = latest.filter((h) => {
68+ const k = `${h.provider}:${h.network}`;
69+ if (seen.has(k)) return false;
70+ seen.add(k);
71+ return true;
72+ });
73+ return {
74+ providers: engine.registry.all().map((p) => ({
75+ id: p.id,
76+ label: p.label,
77+ configured: p.isConfigured(),
78+ networks: p.networks,
79+ prices: Object.fromEntries(p.networks.map((n) => [n, p.pricePerGb(n)])),
80+ health: health.filter((h) => h.provider === p.id).map((h) => ({ network: h.network, status: h.status, latency_ms: h.latencyMs, detail: h.detail, checked_at: h.checkedAt })),
81+ circuits: engine.circuit.snapshot().filter((c) => c.key.startsWith(`${p.id}:`)),
82+ })),
83+ available_networks: engine.registry.availableNetworks(),
84+ };
85+ });
86+
87+ app.post("/internal/providers/probe", async () => {
88+ const engine = await getEngine();
89+ await engine.reload();
90+ await engine.probeAll();
91+ return { ok: true };
92+ });
93+
94+ app.post("/internal/providers/reload", async () => {
95+ const engine = await getEngine();
96+ await engine.reload();
97+ return { ok: true };
98+ });
99+
100+ app.post<{ Body: { key?: string } }>("/internal/circuits/reset", async (req) => {
101+ const engine = await getEngine();
102+ engine.circuit.reset(req.body?.key);
103+ return { ok: true };
104+ });
105+
106+ app.post<{ Body: { key_hash: string } }>("/internal/keys/invalidate", async (req) => {
107+ const { invalidateKeyCache } = await import("../auth");
108+ if (req.body?.key_hash) await invalidateKeyCache(req.body.key_hash);
109+ return { ok: true };
110+ });
111+}
added apps/api/src/routes/misc.ts +50 −0
@@ -0,0 +1,50 @@
1+import { PLAN_LIMITS } from "@fetcha/core";
2+import { db, fetchRequests, sql } from "@fetcha/db";
3+import type { ApiPrincipal } from "../auth";
4+import { monthlyUsage } from "../limits";
5+
6+export function meResponse(p: ApiPrincipal) {
7+ return {
8+ project: { id: p.projectId, name: p.projectName },
9+ organization: { id: p.organizationId, name: p.organizationName, plan: p.plan },
10+ key: { id: p.keyId, name: p.keyName, mode: p.mode, scopes: p.scopes },
11+ };
12+}
13+
14+export async function usageResponse(p: ApiPrincipal) {
15+ const usage = await monthlyUsage(p);
16+ const limits = PLAN_LIMITS[p.plan];
17+ const start = new Date();
18+ start.setUTCDate(1);
19+ start.setUTCHours(0, 0, 0, 0);
20+ const [agg] = await db
21+ .select({
22+ total: sql<number>`count(*)::int`,
23+ ok: sql<number>`count(*) filter (where ${fetchRequests.status} = 'success')::int`,
24+ bytes: sql<number>`coalesce(sum(${fetchRequests.bytesIn} + ${fetchRequests.bytesOut}), 0)::float`,
25+ p50: sql<number>`coalesce(percentile_cont(0.5) within group (order by ${fetchRequests.latencyMs}), 0)::float`,
26+ p95: sql<number>`coalesce(percentile_cont(0.95) within group (order by ${fetchRequests.latencyMs}), 0)::float`,
27+ })
28+ .from(fetchRequests)
29+ .where(sql`${fetchRequests.projectId} = ${p.projectId} and ${fetchRequests.createdAt} >= ${start}`);
30+ return {
31+ period_start: start.toISOString(),
32+ plan: { id: p.plan, label: limits.label, monthly_requests: limits.monthly_requests, concurrency: limits.concurrency },
33+ organization: { requests: usage.requests, spend_usd: round(usage.spendUsd) },
34+ project: {
35+ requests: usage.projectRequests,
36+ spend_usd: round(usage.projectSpendUsd),
37+ successful_requests: agg?.ok ?? 0,
38+ success_rate: agg?.total ? round((agg.ok / agg.total) * 100, 1) : null,
39+ bandwidth_bytes: agg?.bytes ?? 0,
40+ latency_p50_ms: Math.round(agg?.p50 ?? 0),
41+ latency_p95_ms: Math.round(agg?.p95 ?? 0),
42+ },
43+ remaining_requests: Math.max(0, limits.monthly_requests - usage.requests),
44+ };
45+}
46+
47+function round(n: number, d = 4): number {
48+ const f = 10 ** d;
49+ return Math.round(n * f) / f;
50+}
added apps/api/src/routes/sessions.ts +82 −0
@@ -0,0 +1,82 @@
1+import { FetchaError, PLAN_LIMITS, newId, normalizeGeo, randomToken, sessionCreateSchema } from "@fetcha/core";
2+import { and, db, desc, eq, proxySessions } from "@fetcha/db";
3+import type { ApiPrincipal } from "../auth";
4+import { getEngine } from "../services/engine";
5+import { getKV } from "../redis";
6+
7+export function serializeSession(s: typeof proxySessions.$inferSelect) {
8+ return {
9+ id: s.id,
10+ label: s.label,
11+ status: s.status === "active" && s.expiresAt.getTime() < Date.now() ? "expired" : s.status,
12+ network: s.network,
13+ country: s.country,
14+ region: s.region,
15+ city: s.city,
16+ request_count: s.requestCount,
17+ last_used_at: s.lastUsedAt?.toISOString() ?? null,
18+ expires_at: s.expiresAt.toISOString(),
19+ created_at: s.createdAt.toISOString(),
20+ };
21+}
22+
23+export async function createSession(principal: ApiPrincipal, body: unknown, idempotencyKey?: string) {
24+ const parsed = sessionCreateSchema.safeParse(body ?? {});
25+ if (!parsed.success) {
26+ throw new FetchaError("INVALID_REQUEST", "Invalid session options.", { details: { issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) } });
27+ }
28+ const input = parsed.data;
29+ const kv = getKV();
30+ if (idempotencyKey) {
31+ const existing = await kv.get(`fch:idem:${principal.projectId}:${idempotencyKey}`).catch(() => null);
32+ if (existing) {
33+ const [s] = await db.select().from(proxySessions).where(eq(proxySessions.id, existing)).limit(1);
34+ if (s) return serializeSession(s);
35+ }
36+ }
37+ const limits = PLAN_LIMITS[principal.plan];
38+ const geo = normalizeGeo({ country: input.country, region: input.region, city: input.city });
39+ const engine = await getEngine();
40+ const plan = engine.routing.plan({ domain: "", network: input.network, geo, plan: principal.plan, sessionRequired: true, browser: false });
41+ const cand = plan.candidates.find((c) => c.provider.id !== "direct");
42+ if (!cand) throw new FetchaError("NETWORK_UNAVAILABLE", "No sticky-capable network is available for these options.");
43+ if (!limits.networks.includes(cand.network)) throw new FetchaError("NETWORK_UNAVAILABLE");
44+
45+ const id = newId("sess");
46+ const row = {
47+ id,
48+ organizationId: principal.organizationId,
49+ projectId: principal.projectId,
50+ label: input.label ?? null,
51+ provider: cand.provider.id,
52+ network: cand.network,
53+ country: geo.country,
54+ region: geo.region,
55+ city: geo.city,
56+ stickyKey: randomToken(12).replace(/[^a-zA-Z0-9]/g, "").slice(0, 16) || id.slice(5),
57+ status: "active",
58+ expiresAt: new Date(Date.now() + input.ttl * 1000),
59+ };
60+ await db.insert(proxySessions).values(row);
61+ if (idempotencyKey) await kv.set(`fch:idem:${principal.projectId}:${idempotencyKey}`, id, 86_400).catch(() => {});
62+ const [s] = await db.select().from(proxySessions).where(eq(proxySessions.id, id)).limit(1);
63+ return serializeSession(s!);
64+}
65+
66+export async function getSession(principal: ApiPrincipal, id: string) {
67+ const [s] = await db.select().from(proxySessions).where(and(eq(proxySessions.id, id), eq(proxySessions.projectId, principal.projectId))).limit(1);
68+ if (!s) throw new FetchaError("SESSION_NOT_FOUND");
69+ return serializeSession(s);
70+}
71+
72+export async function closeSession(principal: ApiPrincipal, id: string) {
73+ const [s] = await db.select().from(proxySessions).where(and(eq(proxySessions.id, id), eq(proxySessions.projectId, principal.projectId))).limit(1);
74+ if (!s) throw new FetchaError("SESSION_NOT_FOUND");
75+ await db.update(proxySessions).set({ status: "closed" }).where(eq(proxySessions.id, id));
76+ return { id, status: "closed" };
77+}
78+
79+export async function listSessions(principal: ApiPrincipal) {
80+ const rows = await db.select().from(proxySessions).where(eq(proxySessions.projectId, principal.projectId)).orderBy(desc(proxySessions.createdAt)).limit(100);
81+ return { data: rows.map(serializeSession) };
82+}
added apps/api/src/server.ts +160 −0
@@ -0,0 +1,160 @@
1+import Fastify from "fastify";
2+import cors from "@fastify/cors";
3+import { FetchaError, isFetchaError, newId } from "@fetcha/core";
4+import { closeDb, db, sql } from "@fetcha/db";
5+import { closeAllDispatchers } from "@fetcha/providers";
6+import { authenticateApiKey, requireScope } from "./auth";
7+import { assertProductionConfig, config } from "./config";
8+import { closeKV, getKV } from "./redis";
9+import { handleFetch } from "./routes/fetch";
10+import { registerInternalRoutes } from "./routes/internal";
11+import { meResponse, usageResponse } from "./routes/misc";
12+import { closeSession, createSession, getSession, listSessions } from "./routes/sessions";
13+import { getEngine, startHealthLoop } from "./services/engine";
14+
15+export async function buildServer() {
16+ const app = Fastify({
17+ logger: {
18+ level: config.logLevel,
19+ ...(config.env !== "production" ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } } : {}),
20+ redact: ["req.headers.authorization", "req.headers['x-api-key']", "req.headers.cookie"],
21+ },
22+ trustProxy: true,
23+ bodyLimit: 4 * 1024 * 1024,
24+ genReqId: () => newId("req"),
25+ disableRequestLogging: config.env === "production",
26+ });
27+
28+ await app.register(cors, {
29+ origin: true,
30+ methods: ["GET", "POST", "DELETE", "OPTIONS"],
31+ allowedHeaders: ["authorization", "content-type", "x-api-key", "idempotency-key"],
32+ exposedHeaders: ["x-fetcha-request-id", "x-request-id"],
33+ });
34+
35+ app.addHook("onSend", async (req, reply) => {
36+ if (!reply.getHeader("x-fetcha-request-id")) reply.header("x-fetcha-request-id", req.id);
37+ reply.header("x-fetcha-version", config.version);
38+ reply.header("cache-control", "no-store");
39+ });
40+
41+ app.setErrorHandler((err: unknown, req, reply) => {
42+ if (isFetchaError(err)) {
43+ const e = err as FetchaError;
44+ const rid = e.requestId ?? req.id;
45+ reply.header("x-fetcha-request-id", rid);
46+ if (e.code === "RATE_LIMITED" && e.details?.retry_after_ms) reply.header("retry-after", String(Math.ceil(Number(e.details.retry_after_ms) / 1000)));
47+ return reply.status(e.status).send(e.toJSON(rid));
48+ }
49+ const fe = err as { statusCode?: number; validation?: unknown; message?: string; code?: string };
50+ if (fe.statusCode === 400 || fe.validation || fe.code === "FST_ERR_CTP_INVALID_MEDIA_TYPE" || fe.code === "FST_ERR_CTP_EMPTY_JSON_BODY") {
51+ return reply.status(400).send(new FetchaError("INVALID_REQUEST", fe.code === "FST_ERR_CTP_INVALID_MEDIA_TYPE" ? "Send a JSON body with Content-Type: application/json." : "Malformed JSON body.").toJSON(req.id));
52+ }
53+ if (fe.statusCode === 413) return reply.status(413).send(new FetchaError("INVALID_REQUEST", "Request body too large.").toJSON(req.id));
54+ if (fe.statusCode === 404) return reply.status(404).send(new FetchaError("NOT_FOUND").toJSON(req.id));
55+ req.log.error({ err }, "unhandled error");
56+ return reply.status(500).send(new FetchaError("INTERNAL_ERROR").toJSON(req.id));
57+ });
58+
59+ app.setNotFoundHandler((req, reply) => {
60+ reply.status(404).send(new FetchaError("NOT_FOUND", `No route for ${req.method} ${req.url.split("?")[0]}.`).toJSON(req.id));
61+ });
62+
63+ // ---- Health ---------------------------------------------------------------
64+ app.get("/health", async () => ({ status: "ok", version: config.version, time: new Date().toISOString() }));
65+ app.get("/ready", async (_req, reply) => {
66+ const checks: Record<string, boolean> = {};
67+ try {
68+ await db.execute(sql`select 1`);
69+ checks.database = true;
70+ } catch {
71+ checks.database = false;
72+ }
73+ checks.cache = await getKV().ping();
74+ const engine = await getEngine().catch(() => null);
75+ checks.providers = Boolean(engine && engine.registry.available().length > 0);
76+ const ok = checks.database && checks.cache && checks.providers;
77+ return reply.status(ok ? 200 : 503).send({ status: ok ? "ready" : "degraded", checks, available_networks: engine?.registry.availableNetworks() ?? [] });
78+ });
79+
80+ // ---- Public API v1 ----------------------------------------------------------
81+ const clientIp = (req: { headers: Record<string, unknown>; ip: string }) => ((req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? req.ip) || null;
82+
83+ app.post("/v1/fetch", async (req, reply) => {
84+ const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);
85+ requireScope(principal, "fetch:execute");
86+ const source = /fetcha-sdk/i.test(String(req.headers["user-agent"] ?? "")) ? "sdk" : "api";
87+ const out = await handleFetch({ principal, body: req.body, source, clientIp: clientIp(req), userAgent: (req.headers["user-agent"] as string | undefined) ?? null });
88+ reply.header("x-fetcha-request-id", out.requestId);
89+ return reply.status(out.status).send(out.body);
90+ });
91+
92+ app.post("/v1/sessions", async (req) => {
93+ const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);
94+ requireScope(principal, "sessions:write");
95+ return createSession(principal, req.body, req.headers["idempotency-key"] as string | undefined);
96+ });
97+ app.get("/v1/sessions", async (req) => {
98+ const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);
99+ return listSessions(principal);
100+ });
101+ app.get<{ Params: { id: string } }>("/v1/sessions/:id", async (req) => {
102+ const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);
103+ return getSession(principal, req.params.id);
104+ });
105+ app.delete<{ Params: { id: string } }>("/v1/sessions/:id", async (req) => {
106+ const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);
107+ requireScope(principal, "sessions:write");
108+ return closeSession(principal, req.params.id);
109+ });
110+
111+ app.get("/v1/me", async (req) => {
112+ const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);
113+ return meResponse(principal);
114+ });
115+ app.get("/v1/usage", async (req) => {
116+ const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);
117+ requireScope(principal, "usage:read");
118+ return usageResponse(principal);
119+ });
120+
121+ // Explicitly unavailable surfaces (never fake functionality).
122+ app.post("/v1/browser", async () => {
123+ throw new FetchaError("BROWSER_UNAVAILABLE", "Browser actions are not yet available. Follow the changelog at https://www.fetcha.co/changelog.");
124+ });
125+ app.post("/v1/extract", async () => {
126+ throw new FetchaError("INVALID_REQUEST", "Structured extraction is not yet available on this plan. Follow the changelog at https://www.fetcha.co/changelog.");
127+ });
128+
129+ await registerInternalRoutes(app);
130+ return app;
131+}
132+
133+async function main() {
134+ assertProductionConfig();
135+ const app = await buildServer();
136+ const engine = await getEngine();
137+ app.log.info({ providers: engine.registry.available().map((p) => p.id), networks: engine.registry.availableNetworks() }, "providers loaded");
138+ const stopHealth = startHealthLoop(engine, app.log);
139+
140+ const shutdown = async (signal: string) => {
141+ app.log.info(`${signal} received, shutting down`);
142+ stopHealth();
143+ await app.close();
144+ await Promise.all([closeAllDispatchers(), closeKV(), closeDb()]);
145+ process.exit(0);
146+ };
147+ process.on("SIGINT", () => void shutdown("SIGINT"));
148+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
149+
150+ await app.listen({ port: config.port, host: config.host });
151+ app.log.info(`Fetcha API listening on http://${config.host}:${config.port}`);
152+}
153+
154+const isMain = process.argv[1] && /server\.ts$|server\.js$/.test(process.argv[1]);
155+if (isMain) {
156+ main().catch((e) => {
157+ console.error(e);
158+ process.exit(1);
159+ });
160+}
added apps/api/src/services/alerts.ts +48 −0
@@ -0,0 +1,48 @@
1+import { newId } from "@fetcha/core";
2+import { abuseEvents, db } from "@fetcha/db";
3+import { getEmailService } from "@fetcha/email";
4+import type { ApiPrincipal } from "../auth";
5+import type { MonthlyUsage } from "../limits";
6+import { getKV } from "../redis";
7+import { formatUsd } from "./pricing";
8+
9+function monthKey(): string {
10+ const d = new Date();
11+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
12+}
13+
14+/** Send soft-limit alerts once per project/org per month (idempotent via KV flags). */
15+export async function checkSpendAlerts(p: ApiPrincipal, usage: MonthlyUsage): Promise<void> {
16+ const kv = getKV();
17+ const email = getEmailService();
18+ const checks: Array<{ key: string; spent: number; limit: number | null; label: string }> = [
19+ { key: `fch:alert:soft:proj:${p.projectId}:${monthKey()}`, spent: usage.projectSpendUsd, limit: p.projectSoftLimitUsd, label: p.projectName },
20+ { key: `fch:alert:soft:org:${p.organizationId}:${monthKey()}`, spent: usage.spendUsd, limit: p.orgSoftLimitUsd, label: p.organizationName },
21+ ];
22+ for (const c of checks) {
23+ if (c.limit === null || c.limit <= 0 || c.spent < c.limit) continue;
24+ if (await kv.get(c.key).catch(() => "1")) continue;
25+ await kv.set(c.key, "1", 40 * 86_400).catch(() => {});
26+ email.sendUsageAlert(p.ownerEmail, { projectName: c.label, spent: formatUsd(c.spent), limit: formatUsd(c.limit), kind: "soft" }).catch(() => {});
27+ }
28+}
29+
30+/** Notify once per month when a hard limit blocks requests. */
31+export async function notifyHardLimit(p: ApiPrincipal, usage: MonthlyUsage): Promise<void> {
32+ const kv = getKV();
33+ const key = `fch:alert:hard:${p.projectId}:${monthKey()}`;
34+ if (await kv.get(key).catch(() => "1")) return;
35+ await kv.set(key, "1", 40 * 86_400).catch(() => {});
36+ const limit = p.projectHardLimitUsd ?? p.orgHardLimitUsd ?? 0;
37+ getEmailService()
38+ .sendUsageAlert(p.ownerEmail, { projectName: p.projectName, spent: formatUsd(Math.max(usage.projectSpendUsd, usage.spendUsd)), limit: formatUsd(limit), kind: "hard" })
39+ .catch(() => {});
40+}
41+
42+/** Record an abuse signal (SSRF attempt, prohibited target…) for admin review. */
43+export async function recordAbuse(p: ApiPrincipal, requestId: string, kind: "ssrf_attempt" | "prohibited_target" | "rate_abuse", detail: string): Promise<void> {
44+ await db
45+ .insert(abuseEvents)
46+ .values({ id: newId("abuse"), organizationId: p.organizationId, projectId: p.projectId, requestId, kind, severity: kind === "ssrf_attempt" ? "medium" : "low", detail: detail.slice(0, 500) })
47+ .catch(() => {});
48+}
added apps/api/src/services/engine.ts +104 −0
@@ -0,0 +1,104 @@
1+import { db, providerConfigs, providerHealth, domainProfiles, eq } from "@fetcha/db";
2+import { ProviderRegistry, type ProviderId } from "@fetcha/providers";
3+import { CircuitBreaker, FetchExecutor, RoutingEngine, type DomainKnowledge } from "@fetcha/routing";
4+import { newId } from "@fetcha/core";
5+import { config } from "../config";
6+
7+export interface Engine {
8+ registry: ProviderRegistry;
9+ circuit: CircuitBreaker;
10+ routing: RoutingEngine;
11+ executor: FetchExecutor;
12+ reload(): Promise<void>;
13+ probeAll(): Promise<void>;
14+ knowledge(domain: string): Promise<DomainKnowledge | null>;
15+}
16+
17+let _engine: Engine | null = null;
18+
19+export async function getEngine(): Promise<Engine> {
20+ if (_engine) return _engine;
21+ const circuit = new CircuitBreaker();
22+ let registry = new ProviderRegistry();
23+ let routing = new RoutingEngine(registry, circuit);
24+ let executor = new FetchExecutor(routing, circuit);
25+
26+ const engine: Engine = {
27+ get registry() {
28+ return registry;
29+ },
30+ circuit,
31+ get routing() {
32+ return routing;
33+ },
34+ get executor() {
35+ return executor;
36+ },
37+ async reload() {
38+ // Prices and enabled flags come from the DB; credentials always from the environment.
39+ const rows = await db.select().from(providerConfigs).catch(() => []);
40+ const prices: Partial<Record<ProviderId, Record<string, number>>> = {};
41+ const disabled: ProviderId[] = [];
42+ for (const r of rows) {
43+ prices[r.id as ProviderId] = r.pricePerGbUsd;
44+ if (!r.enabled) disabled.push(r.id as ProviderId);
45+ }
46+ registry = new ProviderRegistry({ prices, disabled });
47+ routing = new RoutingEngine(registry, circuit);
48+ executor = new FetchExecutor(routing, circuit);
49+ },
50+ async probeAll() {
51+ const results = await Promise.all(
52+ registry.all().flatMap((p) => p.networks.map((n) => p.health(n).catch((e) => ({ provider: p.id, network: n, status: "down" as const, latencyMs: null, detail: (e as Error).message, checkedAt: new Date() })))),
53+ );
54+ for (const h of results) {
55+ await db
56+ .insert(providerHealth)
57+ .values({ id: newId("evt"), provider: h.provider, network: h.network, status: h.status, latencyMs: h.latencyMs, detail: h.detail ?? null, checkedAt: h.checkedAt })
58+ .catch(() => {});
59+ }
60+ },
61+ async knowledge(domain: string) {
62+ if (!domain) return null;
63+ const [row] = await db.select().from(domainProfiles).where(eq(domainProfiles.domain, domain)).limit(1).catch(() => []);
64+ if (!row) return null;
65+ return { domain, routeStats: row.routeStats ?? {}, policy: row.policy ?? null, browserRequiredRate: row.requests ? row.browserRequired / row.requests : 0 };
66+ },
67+ };
68+ await engine.reload();
69+ _engine = engine;
70+ return engine;
71+}
72+
73+export function startHealthLoop(engine: Engine, log: { info: (m: string) => void; warn: (m: string) => void }): () => void {
74+ let stopped = false;
75+ let ticks = 0;
76+ const run = async () => {
77+ if (stopped) return;
78+ try {
79+ await engine.reload();
80+ await engine.probeAll();
81+ log.info("provider health probe complete");
82+ } catch (e) {
83+ log.warn(`provider health probe failed: ${(e as Error).message}`);
84+ }
85+ // Retention prune roughly every 6 hours (every Nth probe), plus once shortly after boot.
86+ const every = Math.max(1, Math.round((6 * 60 * 60_000) / config.healthProbeIntervalMs));
87+ if (ticks % every === 0) {
88+ try {
89+ const { pruneRetention } = await import("./retention");
90+ await pruneRetention(log);
91+ } catch (e) {
92+ log.warn(`retention prune failed: ${(e as Error).message}`);
93+ }
94+ }
95+ ticks++;
96+ };
97+ const t = setTimeout(run, 5_000);
98+ const interval = setInterval(run, config.healthProbeIntervalMs);
99+ return () => {
100+ stopped = true;
101+ clearTimeout(t);
102+ clearInterval(interval);
103+ };
104+}
added apps/api/src/services/persist.ts +219 −0
@@ -0,0 +1,219 @@
1+import { newId, redactHeaders, type ConcreteNetwork, type FetchRequest, type Plan } from "@fetcha/core";
2+import { db, domainProfiles, fetchRequests, requestAttempts, routingMetrics, usageEvents, sql } from "@fetcha/db";
3+import { foldRouteStat, preferredRoute, routeKey, type AttemptRecord, type ExecutionResult } from "@fetcha/routing";
4+import { priceRequest } from "./pricing";
5+
6+export interface RequestContext {
7+ requestId: string;
8+ organizationId: string;
9+ projectId: string;
10+ apiKeyId: string | null;
11+ source: "api" | "playground" | "sdk";
12+ plan: Plan;
13+ clientIp: string | null;
14+ userAgent: string | null;
15+ logLevel: "none" | "metadata" | "headers" | "full";
16+}
17+
18+export async function createRequestRow(ctx: RequestContext, req: FetchRequest, domain: string): Promise<void> {
19+ await db.insert(fetchRequests).values({
20+ id: ctx.requestId,
21+ organizationId: ctx.organizationId,
22+ projectId: ctx.projectId,
23+ apiKeyId: ctx.apiKeyId,
24+ source: ctx.source,
25+ url: req.url,
26+ domain,
27+ method: req.method,
28+ requestedNetwork: req.network,
29+ country: req.country ?? null,
30+ region: req.region ?? null,
31+ city: req.city ?? null,
32+ sessionId: req.session ?? null,
33+ browser: req.browser,
34+ format: req.format,
35+ status: "pending",
36+ requestHeaders: ctx.logLevel === "headers" || ctx.logLevel === "full" ? redactHeaders(req.headers) : null,
37+ clientIp: ctx.clientIp,
38+ userAgent: ctx.userAgent,
39+ });
40+}
41+
42+export async function recordAttempt(requestId: string, a: AttemptRecord): Promise<void> {
43+ await db.insert(requestAttempts).values({
44+ id: a.attemptId,
45+ requestId,
46+ attemptNo: a.attemptNo,
47+ provider: a.provider,
48+ network: a.network,
49+ country: a.country,
50+ sessionKey: a.sessionKey,
51+ outcome: a.outcome,
52+ httpStatus: a.httpStatus,
53+ errorCode: a.errorCode,
54+ errorDetail: a.errorDetail,
55+ blockReason: a.blockReason,
56+ durationMs: a.durationMs,
57+ bytesIn: a.bytesIn,
58+ bytesOut: a.bytesOut,
59+ unitPricePerGb: a.unitPricePerGb,
60+ costUsd: a.costUsd,
61+ routingScore: a.routingScore,
62+ timing: a.timing ? { ...a.timing } : null,
63+ });
64+}
65+
66+export interface CompletionSummary {
67+ priceUsd: number;
68+ costUsd: number;
69+}
70+
71+/** Persist a successful (or blocked-but-answered) execution: request row, usage ledger, domain intelligence, routing metrics. */
72+export async function completeRequest(ctx: RequestContext, req: FetchRequest, result: ExecutionResult, latencyMs: number): Promise<CompletionSummary> {
73+ const network = result.network;
74+ const price = priceRequest({ plan: ctx.plan, network, bytes: result.bytesIn + result.bytesOut, upstreamCostUsd: result.costUsd, attempts: result.attempts.length, success: result.body.success });
75+ const finalStatus = result.body.success ? "success" : "failed";
76+
77+ await db
78+ .update(fetchRequests)
79+ .set({
80+ status: finalStatus,
81+ httpStatus: result.body.status,
82+ errorCode: result.body.success ? null : "TARGET_BLOCKED",
83+ errorMessage: result.body.success ? null : "The target blocked every route we tried.",
84+ finalUrl: result.finalUrl,
85+ network,
86+ attempts: result.attempts.length,
87+ latencyMs,
88+ bytesIn: result.bytesIn,
89+ bytesOut: result.bytesOut,
90+ costUsd: result.costUsd,
91+ priceUsd: price.totalUsd,
92+ responseHeaders: ctx.logLevel === "headers" || ctx.logLevel === "full" ? redactHeaders(result.body.headers) : null,
93+ timing: result.body.metadata.timing ? { ...result.body.metadata.timing } : null,
94+ completedAt: new Date(),
95+ })
96+ .where(sql`${fetchRequests.id} = ${ctx.requestId}`);
97+
98+ await writeUsage(ctx, result, price.totalUsd, network);
99+ await Promise.all([updateDomainProfile(result.domain, result.attempts, result.body.success), updateRoutingMetrics(result.attempts)]);
100+ return { priceUsd: price.totalUsd, costUsd: result.costUsd };
101+}
102+
103+export async function failRequest(ctx: RequestContext, code: string, message: string, attempts: AttemptRecord[], latencyMs: number, domain: string): Promise<void> {
104+ const costUsd = attempts.reduce((s, a) => s + a.costUsd, 0);
105+ await db
106+ .update(fetchRequests)
107+ .set({ status: "failed", errorCode: code, errorMessage: message, attempts: attempts.length, latencyMs, costUsd, completedAt: new Date() })
108+ .where(sql`${fetchRequests.id} = ${ctx.requestId}`);
109+ // Failed requests still count against the request quota (abuse control) but are not priced.
110+ await db.insert(usageEvents).values({
111+ id: newId("usage"),
112+ organizationId: ctx.organizationId,
113+ projectId: ctx.projectId,
114+ requestId: ctx.requestId,
115+ metric: "request",
116+ quantity: 1,
117+ unit: "count",
118+ costUsd: 0,
119+ upstreamCostUsd: costUsd,
120+ });
121+ if (attempts.length) await Promise.all([updateDomainProfile(domain, attempts, false), updateRoutingMetrics(attempts)]);
122+}
123+
124+async function writeUsage(ctx: RequestContext, result: ExecutionResult, priceUsd: number, network: ConcreteNetwork | null) {
125+ const bytes = result.bytesIn + result.bytesOut;
126+ const rows = [
127+ { metric: "request", quantity: 1, unit: "count", costUsd: priceUsd, upstreamCostUsd: result.costUsd },
128+ { metric: "bandwidth", quantity: bytes, unit: "bytes", costUsd: 0, upstreamCostUsd: 0 },
129+ ];
130+ if (network === "residential" || network === "isp") rows.push({ metric: "residential_bandwidth", quantity: bytes, unit: "bytes", costUsd: 0, upstreamCostUsd: 0 });
131+ if (network === "mobile") rows.push({ metric: "mobile_bandwidth", quantity: bytes, unit: "bytes", costUsd: 0, upstreamCostUsd: 0 });
132+ await db.insert(usageEvents).values(
133+ rows.map((r) => ({ id: newId("usage"), organizationId: ctx.organizationId, projectId: ctx.projectId, requestId: ctx.requestId, ...r })),
134+ );
135+}
136+
137+async function updateDomainProfile(domain: string, attempts: AttemptRecord[], success: boolean) {
138+ if (!domain) return;
139+ const [existing] = await db.select().from(domainProfiles).where(sql`${domainProfiles.domain} = ${domain}`).limit(1);
140+ const stats = { ...(existing?.routeStats ?? {}) };
141+ let blocks = 0;
142+ let captchas = 0;
143+ for (const a of attempts) {
144+ foldRouteStat(stats, routeKey(a.provider, a.network), { ok: a.outcome === "success", blocked: a.outcome === "blocked", latencyMs: a.durationMs, costUsd: a.costUsd });
145+ if (a.outcome === "blocked") blocks++;
146+ if (a.blockReason === "captcha" || a.blockReason === "cloudflare_challenge") captchas++;
147+ }
148+ const pref = preferredRoute(stats);
149+ const totalLatency = attempts.reduce((s, a) => s + a.durationMs, 0);
150+ const n = (existing?.requests ?? 0) + 1;
151+ const avgLatency = existing ? existing.avgLatencyMs + (totalLatency - existing.avgLatencyMs) / n : totalLatency;
152+ await db
153+ .insert(domainProfiles)
154+ .values({
155+ domain,
156+ preferredNetwork: pref?.network ?? null,
157+ preferredProvider: pref?.provider ?? null,
158+ requests: 1,
159+ successes: success ? 1 : 0,
160+ blocks,
161+ captchas,
162+ avgLatencyMs: totalLatency,
163+ routeStats: stats,
164+ lastSeenAt: new Date(),
165+ })
166+ .onConflictDoUpdate({
167+ target: domainProfiles.domain,
168+ set: {
169+ preferredNetwork: pref?.network ?? null,
170+ preferredProvider: pref?.provider ?? null,
171+ requests: sql`${domainProfiles.requests} + 1`,
172+ successes: sql`${domainProfiles.successes} + ${success ? 1 : 0}`,
173+ blocks: sql`${domainProfiles.blocks} + ${blocks}`,
174+ captchas: sql`${domainProfiles.captchas} + ${captchas}`,
175+ avgLatencyMs: avgLatency,
176+ routeStats: stats,
177+ lastSeenAt: new Date(),
178+ updatedAt: new Date(),
179+ },
180+ });
181+}
182+
183+async function updateRoutingMetrics(attempts: AttemptRecord[]) {
184+ const bucket = new Date();
185+ bucket.setMinutes(0, 0, 0);
186+ for (const a of attempts) {
187+ const ok = a.outcome === "success" ? 1 : 0;
188+ const blocked = a.outcome === "blocked" ? 1 : 0;
189+ const errors = ok || blocked ? 0 : 1;
190+ await db
191+ .insert(routingMetrics)
192+ .values({
193+ id: newId("evt"),
194+ bucket,
195+ provider: a.provider,
196+ network: a.network,
197+ country: a.country ?? "",
198+ requests: 1,
199+ successes: ok,
200+ blocked,
201+ errors,
202+ latencySumMs: a.durationMs,
203+ bytes: a.bytesIn + a.bytesOut,
204+ costUsd: a.costUsd,
205+ })
206+ .onConflictDoUpdate({
207+ target: [routingMetrics.bucket, routingMetrics.provider, routingMetrics.network, routingMetrics.country],
208+ set: {
209+ requests: sql`${routingMetrics.requests} + 1`,
210+ successes: sql`${routingMetrics.successes} + ${ok}`,
211+ blocked: sql`${routingMetrics.blocked} + ${blocked}`,
212+ errors: sql`${routingMetrics.errors} + ${errors}`,
213+ latencySumMs: sql`${routingMetrics.latencySumMs} + ${a.durationMs}`,
214+ bytes: sql`${routingMetrics.bytes} + ${a.bytesIn + a.bytesOut}`,
215+ costUsd: sql`${routingMetrics.costUsd} + ${a.costUsd}`,
216+ },
217+ });
218+ }
219+}
added apps/api/src/services/pricing.ts +51 −0
@@ -0,0 +1,51 @@
1+import { PLAN_LIMITS, type ConcreteNetwork, type Plan } from "@fetcha/core";
2+import { config } from "../config";
3+
4+/**
5+ * Pricing service. Nothing in the execution path hardcodes prices: the executor reports
6+ * upstream cost + bytes + network and this module decides what the customer is charged.
7+ *
8+ * Model: base subscription (plan) + usage. Every request is priced as
9+ * per-request component (plan overage rate, 0 while within the included quota — quota is
10+ * enforced elsewhere) + bandwidth component for premium networks.
11+ */
12+export interface PriceInput {
13+ plan: Plan;
14+ network: ConcreteNetwork | null;
15+ bytes: number;
16+ upstreamCostUsd: number;
17+ attempts: number;
18+ success: boolean;
19+}
20+
21+export interface PriceBreakdown {
22+ requestUsd: number;
23+ bandwidthUsd: number;
24+ totalUsd: number;
25+ marginUsd: number;
26+}
27+
28+export function priceRequest(input: PriceInput): PriceBreakdown {
29+ const limits = PLAN_LIMITS[input.plan];
30+ // Free/enterprise have no per-request rate; failed requests are never charged a request fee.
31+ const requestUsd = input.success ? limits.overage_per_1k_requests_usd / 1000 : 0;
32+ let bandwidthUsd = 0;
33+ if (input.network && input.network !== "datacenter") {
34+ const perGb = limits.residential_per_gb_usd || input.upstreamCostUsd > 0 ? limits.residential_per_gb_usd : 0;
35+ bandwidthUsd = perGb > 0 ? (input.bytes / 1_073_741_824) * perGb : input.upstreamCostUsd * config.defaultMargin;
36+ } else {
37+ bandwidthUsd = input.upstreamCostUsd * config.defaultMargin;
38+ }
39+ const totalUsd = round6(requestUsd + bandwidthUsd);
40+ return { requestUsd: round6(requestUsd), bandwidthUsd: round6(bandwidthUsd), totalUsd, marginUsd: round6(totalUsd - input.upstreamCostUsd) };
41+}
42+
43+export function round6(n: number): number {
44+ return Math.round(n * 1e6) / 1e6;
45+}
46+
47+export function formatUsd(n: number): string {
48+ if (n === 0) return "$0.00";
49+ if (n < 0.01) return `$${n.toFixed(5)}`;
50+ return `$${n.toFixed(2)}`;
51+}
added apps/api/src/services/retention.ts +26 −0
@@ -0,0 +1,26 @@
1+import { PLAN_LIMITS, PLANS } from "@fetcha/core";
2+import { db, sql } from "@fetcha/db";
3+
4+/**
5+ * Retention: request metadata is kept per plan (3/7/30/90/365 days). Attempts cascade with
6+ * their request. Usage events (billing ledger) and audit logs are never pruned here.
7+ * Also expires stale proxy sessions.
8+ */
9+export async function pruneRetention(log: { info: (m: string) => void } = console): Promise<void> {
10+ let total = 0;
11+ for (const plan of PLANS) {
12+ const days = PLAN_LIMITS[plan].retention_days;
13+ const res = await db.execute(sql`
14+ delete from fetch_requests r
15+ using organizations o
16+ where r.organization_id = o.id
17+ and o.plan = ${plan}
18+ and r.created_at < now() - make_interval(days => ${days})
19+ `);
20+ total += Number((res as { rowCount?: number }).rowCount ?? 0);
21+ }
22+ await db.execute(sql`update proxy_sessions set status = 'expired' where status = 'active' and expires_at < now()`);
23+ await db.execute(sql`delete from provider_health where checked_at < now() - interval '30 days'`);
24+ await db.execute(sql`delete from verifications where expires_at < now() - interval '1 day'`);
25+ log.info(`retention prune complete (${total} requests removed)`);
26+}
added apps/api/tsconfig.json +9 −0
@@ -0,0 +1,9 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "types": ["node"],
5+ "outDir": "dist",
6+ "jsx": "react-jsx"
7+ },
8+ "include": ["src/**/*.ts", "src/**/*.tsx", "drizzle.config.ts", "test/**/*.ts"]
9+}
added apps/web/eslint.config.mjs +10 −0
@@ -0,0 +1,10 @@
1+import nextVitals from "eslint-config-next/core-web-vitals";
2+import nextTs from "eslint-config-next/typescript";
3+
4+const eslintConfig = [
5+ ...nextVitals,
6+ ...nextTs,
7+ { ignores: [".next/**", "node_modules/**"] },
8+];
9+
10+export default eslintConfig;
added apps/web/next.config.ts +34 −0
@@ -0,0 +1,34 @@
1+import type { NextConfig } from "next";
2+
3+const apiUrl = process.env.API_URL ?? "http://127.0.0.1:8221";
4+
5+const nextConfig: NextConfig = {
6+ reactStrictMode: true,
7+ agentRules: false,
8+ poweredByHeader: false,
9+ transpilePackages: ["@fetcha/core", "@fetcha/db", "@fetcha/email", "@fetcha/providers"],
10+ serverExternalPackages: ["pg", "undici", "resend"],
11+ async rewrites() {
12+ // Public API is served on the same domain under /v1/* and forwarded to the Fastify service.
13+ return [
14+ { source: "/v1/:path*", destination: `${apiUrl}/v1/:path*` },
15+ { source: "/api/health", destination: `${apiUrl}/health` },
16+ { source: "/api/ready", destination: `${apiUrl}/ready` },
17+ ];
18+ },
19+ async headers() {
20+ return [
21+ {
22+ source: "/(.*)",
23+ headers: [
24+ { key: "X-Content-Type-Options", value: "nosniff" },
25+ { key: "X-Frame-Options", value: "DENY" },
26+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
27+ { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
28+ ],
29+ },
30+ ];
31+ },
32+};
33+
34+export default nextConfig;
added apps/web/package.json +52 −0
@@ -0,0 +1,52 @@
1+{
2+ "name": "@fetcha/web",
3+ "version": "0.1.0",
4+ "private": true,
5+ "scripts": {
6+ "dev": "next dev -p 8220",
7+ "build": "next build",
8+ "start": "next start -p 8220",
9+ "lint": "eslint",
10+ "typecheck": "tsc -p tsconfig.json --noEmit",
11+ "test": "vitest run --passWithNoTests"
12+ },
13+ "dependencies": {
14+ "@fetcha/core": "workspace:*",
15+ "@fetcha/db": "workspace:*",
16+ "@fetcha/email": "workspace:*",
17+ "@fetcha/providers": "workspace:*",
18+ "@radix-ui/react-dialog": "^1.1.14",
19+ "@radix-ui/react-dropdown-menu": "^2.1.15",
20+ "@radix-ui/react-popover": "^1.1.14",
21+ "@radix-ui/react-select": "^2.2.5",
22+ "@radix-ui/react-slot": "^1.2.3",
23+ "@radix-ui/react-switch": "^1.2.5",
24+ "@radix-ui/react-tabs": "^1.1.12",
25+ "@radix-ui/react-tooltip": "^1.2.7",
26+ "better-auth": "^1.4.0",
27+ "class-variance-authority": "^0.7.1",
28+ "clsx": "^2.1.1",
29+ "drizzle-orm": "^0.45.0",
30+ "framer-motion": "^12.23.0",
31+ "lucide-react": "^1.0.0",
32+ "next": "16.3.4",
33+ "next-themes": "^0.4.6",
34+ "react": "19.2.8",
35+ "react-dom": "19.2.8",
36+ "recharts": "^3.0.0",
37+ "server-only": "^0.0.1",
38+ "tailwind-merge": "^3.3.1",
39+ "zod": "^4.0.0"
40+ },
41+ "devDependencies": {
42+ "@tailwindcss/postcss": "^4",
43+ "@types/node": "^24.0.0",
44+ "@types/react": "^19",
45+ "@types/react-dom": "^19",
46+ "eslint": "^9",
47+ "eslint-config-next": "16.3.4",
48+ "tailwindcss": "^4",
49+ "typescript": "^5.9.3",
50+ "vitest": "^3.2.0"
51+ }
52+}
added apps/web/postcss.config.mjs +2 −0
@@ -0,0 +1,2 @@
1+const config = { plugins: { "@tailwindcss/postcss": {} } };
2+export default config;
added apps/web/public/icon.svg +1 −0
@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none"><rect x="1" y="1" width="22" height="22" rx="6" fill="#0b0f19"/><path d="M6 12h4.5m0 0 3-4.5H18m-7.5 4.5 3 4.5H18" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="12" r="1.6" fill="#1f5eff"/></svg>
added apps/web/public/robots.txt +6 −0
@@ -0,0 +1,6 @@
1+User-agent: *
2+Allow: /
3+Disallow: /dashboard
4+Disallow: /admin
5+Disallow: /api/
6+Sitemap: https://www.fetcha.co/sitemap.xml
added apps/web/src/actions/account.ts +52 −0
@@ -0,0 +1,52 @@
1+"use server";
2+
3+import { revalidatePath } from "next/cache";
4+import { z } from "zod";
5+import { getDb, users, organizations, eq } from "@fetcha/db";
6+import { getWorkspace, requestMeta, requireUser } from "@/lib/session";
7+import { writeAudit } from "@/lib/audit";
8+import type { ActionResult } from "./projects";
9+
10+export async function completeOnboarding(): Promise<ActionResult> {
11+ const user = await requireUser();
12+ await getDb().update(users).set({ onboardingCompletedAt: new Date(), updatedAt: new Date() }).where(eq(users.id, user.id));
13+ revalidatePath("/dashboard", "layout");
14+ return { ok: true };
15+}
16+
17+const orgSchema = z.object({
18+ name: z.string().trim().min(2).max(80),
19+ softLimitUsd: z.coerce.number().min(0).optional().or(z.literal("")),
20+ hardLimitUsd: z.coerce.number().min(0).optional().or(z.literal("")),
21+});
22+
23+export async function updateOrganization(formData: FormData): Promise<ActionResult> {
24+ const ws = await getWorkspace();
25+ if (!["owner", "admin", "billing"].includes(ws.role)) return { ok: false, error: "Only owners and admins can change organization settings." };
26+ const parsed = orgSchema.safeParse(Object.fromEntries(formData));
27+ if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
28+ const d = parsed.data;
29+ const soft = d.softLimitUsd === "" || d.softLimitUsd === undefined ? null : d.softLimitUsd;
30+ const hard = d.hardLimitUsd === "" || d.hardLimitUsd === undefined ? null : d.hardLimitUsd;
31+ if (soft !== null && hard !== null && soft > hard) return { ok: false, error: "The soft limit must be lower than the hard limit." };
32+ await getDb().update(organizations).set({ name: d.name, softLimitUsd: soft, hardLimitUsd: hard, updatedAt: new Date() }).where(eq(organizations.id, ws.organization.id));
33+ const meta = await requestMeta();
34+ await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "limits.updated", metadata: { soft, hard }, ipAddress: meta.ip, userAgent: meta.userAgent });
35+ revalidatePath("/dashboard", "layout");
36+ return { ok: true };
37+}
38+
39+export async function updateProfileName(name: string): Promise<ActionResult> {
40+ const user = await requireUser();
41+ const clean = name.trim().slice(0, 80);
42+ if (clean.length < 1) return { ok: false, error: "Name cannot be empty" };
43+ await getDb().update(users).set({ name: clean, updatedAt: new Date() }).where(eq(users.id, user.id));
44+ revalidatePath("/dashboard", "layout");
45+ return { ok: true };
46+}
47+
48+export async function recordAuditFromClient(action: "password.changed" | "email.changed" | "session.revoked" | "logout", metadata?: Record<string, unknown>): Promise<void> {
49+ const user = await requireUser();
50+ const meta = await requestMeta();
51+ await writeAudit({ userId: user.id, action, metadata, ipAddress: meta.ip, userAgent: meta.userAgent });
52+}
added apps/web/src/actions/admin.ts +437 −0
@@ -0,0 +1,437 @@
1+"use server";
2+
3+import { randomBytes } from "node:crypto";
4+import { revalidatePath } from "next/cache";
5+import { z } from "zod";
6+import {
7+ getDb,
8+ users,
9+ sessions,
10+ organizations,
11+ providerConfigs,
12+ domainProfiles,
13+ abuseEvents,
14+ featureFlags,
15+ statusIncidents,
16+ eq,
17+} from "@fetcha/db";
18+import { newId, PLANS } from "@fetcha/core";
19+import { requireAdmin, requestMeta } from "@/lib/session";
20+import { writeAudit } from "@/lib/audit";
21+import { internalApi, InternalApiError } from "@/lib/api";
22+import type { AuthUser } from "@/lib/auth";
23+
24+export type AdminActionResult<T = undefined> = { ok: true; data?: T; warning?: string } | { ok: false; error: string };
25+
26+/** Every admin mutation is recorded as `admin.action` with a `type` discriminator in metadata. */
27+async function audit(admin: AuthUser, type: string, target: string | null, metadata: Record<string, unknown> = {}, organizationId?: string | null) {
28+ const meta = await requestMeta();
29+ await writeAudit({
30+ userId: admin.id,
31+ organizationId: organizationId ?? null,
32+ action: "admin.action",
33+ target,
34+ metadata: { type, ...metadata },
35+ ipAddress: meta.ip,
36+ userAgent: meta.userAgent,
37+ });
38+}
39+
40+function fail(e: unknown): { ok: false; error: string } {
41+ if (e instanceof InternalApiError) return { ok: false, error: `${e.code}: ${e.message}` };
42+ if (e instanceof z.ZodError) return { ok: false, error: e.issues[0]?.message ?? "Invalid input" };
43+ return { ok: false, error: (e as Error)?.message ?? "Unexpected error" };
44+}
45+
46+function revalidateAdmin() {
47+ revalidatePath("/admin", "layout");
48+}
49+
50+// ---------------------------------------------------------------------------
51+// Users
52+// ---------------------------------------------------------------------------
53+export async function banUser(userId: string, reason: string): Promise<AdminActionResult> {
54+ const admin = await requireAdmin();
55+ try {
56+ const r = z.string().trim().min(3, "Give a reason (3+ characters)").max(500).parse(reason);
57+ if (userId === admin.id) return { ok: false, error: "You cannot ban yourself." };
58+ const db = getDb();
59+ const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1);
60+ if (!u) return { ok: false, error: "User not found" };
61+ await db.update(users).set({ banned: true, banReason: r, updatedAt: new Date() }).where(eq(users.id, userId));
62+ await db.delete(sessions).where(eq(sessions.userId, userId));
63+ await audit(admin, "user.ban", userId, { email: u.email, reason: r });
64+ revalidateAdmin();
65+ return { ok: true };
66+ } catch (e) {
67+ return fail(e);
68+ }
69+}
70+
71+export async function unbanUser(userId: string): Promise<AdminActionResult> {
72+ const admin = await requireAdmin();
73+ try {
74+ const db = getDb();
75+ const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1);
76+ if (!u) return { ok: false, error: "User not found" };
77+ await db.update(users).set({ banned: false, banReason: null, updatedAt: new Date() }).where(eq(users.id, userId));
78+ await audit(admin, "user.unban", userId, { email: u.email });
79+ revalidateAdmin();
80+ return { ok: true };
81+ } catch (e) {
82+ return fail(e);
83+ }
84+}
85+
86+export async function setUserRole(userId: string, role: "admin" | "user"): Promise<AdminActionResult> {
87+ const admin = await requireAdmin();
88+ try {
89+ z.enum(["admin", "user"]).parse(role);
90+ if (userId === admin.id && role !== "admin") return { ok: false, error: "You cannot demote yourself." };
91+ const db = getDb();
92+ const [u] = await db.select({ id: users.id, email: users.email, role: users.role }).from(users).where(eq(users.id, userId)).limit(1);
93+ if (!u) return { ok: false, error: "User not found" };
94+ await db.update(users).set({ role, updatedAt: new Date() }).where(eq(users.id, userId));
95+ await audit(admin, role === "admin" ? "user.promote" : "user.demote", userId, { email: u.email, from: u.role, to: role });
96+ revalidateAdmin();
97+ return { ok: true };
98+ } catch (e) {
99+ return fail(e);
100+ }
101+}
102+
103+export async function forceVerifyEmail(userId: string): Promise<AdminActionResult> {
104+ const admin = await requireAdmin();
105+ try {
106+ const db = getDb();
107+ const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1);
108+ if (!u) return { ok: false, error: "User not found" };
109+ await db.update(users).set({ emailVerified: true, updatedAt: new Date() }).where(eq(users.id, userId));
110+ await audit(admin, "user.force_verify", userId, { email: u.email });
111+ revalidateAdmin();
112+ return { ok: true };
113+ } catch (e) {
114+ return fail(e);
115+ }
116+}
117+
118+// ---------------------------------------------------------------------------
119+// Organizations
120+// ---------------------------------------------------------------------------
121+export async function updateOrganizationPlan(orgId: string, plan: string): Promise<AdminActionResult> {
122+ const admin = await requireAdmin();
123+ try {
124+ const p = z.enum(PLANS).parse(plan);
125+ const db = getDb();
126+ const [o] = await db.select({ id: organizations.id, plan: organizations.plan }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
127+ if (!o) return { ok: false, error: "Organization not found" };
128+ await db.update(organizations).set({ plan: p, updatedAt: new Date() }).where(eq(organizations.id, orgId));
129+ await audit(admin, "org.plan", orgId, { from: o.plan, to: p }, orgId);
130+ revalidateAdmin();
131+ return { ok: true };
132+ } catch (e) {
133+ return fail(e);
134+ }
135+}
136+
137+export async function setOrganizationProviderVisibility(orgId: string, enabled: boolean): Promise<AdminActionResult> {
138+ const admin = await requireAdmin();
139+ try {
140+ const db = getDb();
141+ const res = await db.update(organizations).set({ providerVisibility: Boolean(enabled), updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id });
142+ if (!res.length) return { ok: false, error: "Organization not found" };
143+ await audit(admin, "org.provider_visibility", orgId, { enabled: Boolean(enabled) }, orgId);
144+ revalidateAdmin();
145+ return { ok: true };
146+ } catch (e) {
147+ return fail(e);
148+ }
149+}
150+
151+export async function setOrganizationSuspended(orgId: string, suspended: boolean, reason?: string): Promise<AdminActionResult> {
152+ const admin = await requireAdmin();
153+ try {
154+ const db = getDb();
155+ const res = await db.update(organizations).set({ suspended: Boolean(suspended), updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id });
156+ if (!res.length) return { ok: false, error: "Organization not found" };
157+ await audit(admin, suspended ? "org.suspend" : "org.unsuspend", orgId, { reason: reason?.trim() || null }, orgId);
158+ revalidateAdmin();
159+ return { ok: true };
160+ } catch (e) {
161+ return fail(e);
162+ }
163+}
164+
165+const limitsSchema = z.object({
166+ softLimitUsd: z.number().min(0).nullable(),
167+ hardLimitUsd: z.number().min(0).nullable(),
168+});
169+
170+export async function updateOrganizationLimits(orgId: string, input: { softLimitUsd: number | null; hardLimitUsd: number | null }): Promise<AdminActionResult> {
171+ const admin = await requireAdmin();
172+ try {
173+ const d = limitsSchema.parse(input);
174+ if (d.softLimitUsd !== null && d.hardLimitUsd !== null && d.softLimitUsd > d.hardLimitUsd) return { ok: false, error: "Soft limit must be ≤ hard limit." };
175+ const db = getDb();
176+ const res = await db.update(organizations).set({ softLimitUsd: d.softLimitUsd, hardLimitUsd: d.hardLimitUsd, updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id });
177+ if (!res.length) return { ok: false, error: "Organization not found" };
178+ await audit(admin, "org.limits", orgId, { ...d }, orgId);
179+ revalidateAdmin();
180+ return { ok: true };
181+ } catch (e) {
182+ return fail(e);
183+ }
184+}
185+
186+// ---------------------------------------------------------------------------
187+// Providers
188+// ---------------------------------------------------------------------------
189+const providerConfigSchema = z.object({
190+ label: z.string().trim().min(1).max(64),
191+ enabled: z.boolean(),
192+ networks: z.array(z.enum(["datacenter", "residential", "isp", "mobile"])).min(0),
193+ pricePerGbUsd: z.record(z.string(), z.number().min(0).max(1000)),
194+ weight: z.number().min(0).max(10),
195+ maxConcurrency: z.number().int().min(1).max(100_000),
196+ notes: z.string().trim().max(2000).nullable(),
197+});
198+
199+export type ProviderConfigInput = z.infer<typeof providerConfigSchema>;
200+
201+export async function updateProviderConfig(id: string, input: ProviderConfigInput): Promise<AdminActionResult> {
202+ const admin = await requireAdmin();
203+ try {
204+ const pid = z.string().regex(/^[a-z0-9_-]{2,32}$/, "Invalid provider id").parse(id);
205+ const d = providerConfigSchema.parse(input);
206+ const db = getDb();
207+ const set = { label: d.label, enabled: d.enabled, networks: d.networks, pricePerGbUsd: d.pricePerGbUsd, weight: d.weight, maxConcurrency: d.maxConcurrency, notes: d.notes, updatedAt: new Date() };
208+ await db.insert(providerConfigs).values({ id: pid, ...set }).onConflictDoUpdate({ target: providerConfigs.id, set });
209+ await audit(admin, "provider.config", pid, { ...set, updatedAt: undefined });
210+ let warning: string | undefined;
211+ try {
212+ await internalApi.reloadProviders();
213+ } catch (e) {
214+ warning = `Saved, but the API did not reload providers: ${(e as Error).message}`;
215+ }
216+ revalidateAdmin();
217+ return { ok: true, warning };
218+ } catch (e) {
219+ return fail(e);
220+ }
221+}
222+
223+export async function resetCircuit(key?: string): Promise<AdminActionResult> {
224+ const admin = await requireAdmin();
225+ try {
226+ await internalApi.resetCircuit(key);
227+ await audit(admin, "provider.circuit_reset", key ?? "all", {});
228+ revalidateAdmin();
229+ return { ok: true };
230+ } catch (e) {
231+ return fail(e);
232+ }
233+}
234+
235+export async function runProviderProbe(): Promise<AdminActionResult> {
236+ const admin = await requireAdmin();
237+ try {
238+ await internalApi.probeProviders();
239+ await audit(admin, "provider.probe", null, {});
240+ revalidateAdmin();
241+ return { ok: true };
242+ } catch (e) {
243+ return fail(e);
244+ }
245+}
246+
247+// ---------------------------------------------------------------------------
248+// Domains
249+// ---------------------------------------------------------------------------
250+const routeKeyRe = /^[a-z0-9_-]+:(datacenter|residential|isp|mobile)$/;
251+const policySchema = z.object({
252+ force_network: z.enum(["datacenter", "residential", "isp", "mobile"]).nullable().optional(),
253+ order: z.array(z.string().regex(routeKeyRe, "Route keys look like provider:network")).max(16).optional(),
254+});
255+
256+export async function updateDomainPolicy(domain: string, policy: { force_network?: string | null; order?: string[] }): Promise<AdminActionResult> {
257+ const admin = await requireAdmin();
258+ try {
259+ const dom = z.string().trim().min(1).max(253).parse(domain).toLowerCase();
260+ const p = policySchema.parse({ force_network: policy.force_network || null, order: policy.order ?? [] });
261+ const next: { order?: string[]; force_network?: string } = {};
262+ if (p.order?.length) next.order = Array.from(new Set(p.order));
263+ if (p.force_network) next.force_network = p.force_network;
264+ const value = Object.keys(next).length ? next : null;
265+ const db = getDb();
266+ const res = await db.update(domainProfiles).set({ policy: value, updatedAt: new Date() }).where(eq(domainProfiles.domain, dom)).returning({ domain: domainProfiles.domain });
267+ if (!res.length) {
268+ await db.insert(domainProfiles).values({ domain: dom, policy: value });
269+ }
270+ await audit(admin, "domain.policy", dom, { policy: value });
271+ revalidateAdmin();
272+ return { ok: true };
273+ } catch (e) {
274+ return fail(e);
275+ }
276+}
277+
278+export async function resetDomainStats(domain: string): Promise<AdminActionResult> {
279+ const admin = await requireAdmin();
280+ try {
281+ const dom = z.string().trim().min(1).max(253).parse(domain).toLowerCase();
282+ const res = await getDb()
283+ .update(domainProfiles)
284+ .set({ requests: 0, successes: 0, blocks: 0, captchas: 0, browserRequired: 0, avgLatencyMs: 0, routeStats: {}, preferredNetwork: null, preferredProvider: null, updatedAt: new Date() })
285+ .where(eq(domainProfiles.domain, dom))
286+ .returning({ domain: domainProfiles.domain });
287+ if (!res.length) return { ok: false, error: "Domain profile not found" };
288+ await audit(admin, "domain.reset_stats", dom, {});
289+ revalidateAdmin();
290+ return { ok: true };
291+ } catch (e) {
292+ return fail(e);
293+ }
294+}
295+
296+// ---------------------------------------------------------------------------
297+// Abuse
298+// ---------------------------------------------------------------------------
299+export async function resolveAbuseEvent(id: string, resolved = true): Promise<AdminActionResult> {
300+ const admin = await requireAdmin();
301+ try {
302+ const res = await getDb().update(abuseEvents).set({ resolved }).where(eq(abuseEvents.id, id)).returning({ id: abuseEvents.id });
303+ if (!res.length) return { ok: false, error: "Abuse event not found" };
304+ await audit(admin, resolved ? "abuse.resolve" : "abuse.reopen", id, {});
305+ revalidateAdmin();
306+ return { ok: true };
307+ } catch (e) {
308+ return fail(e);
309+ }
310+}
311+
312+const abuseSchema = z.object({
313+ kind: z.enum(["ssrf_attempt", "prohibited_target", "rate_abuse", "credential_abuse", "manual"]),
314+ severity: z.enum(["low", "medium", "high", "critical"]),
315+ organizationId: z.string().trim().max(64).optional().or(z.literal("")),
316+ projectId: z.string().trim().max(64).optional().or(z.literal("")),
317+ requestId: z.string().trim().max(64).optional().or(z.literal("")),
318+ detail: z.string().trim().min(3, "Describe the event").max(2000),
319+});
320+
321+export async function recordAbuseEvent(input: z.input<typeof abuseSchema>): Promise<AdminActionResult<{ id: string }>> {
322+ const admin = await requireAdmin();
323+ try {
324+ const d = abuseSchema.parse(input);
325+ const db = getDb();
326+ if (d.organizationId) {
327+ const [o] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.id, d.organizationId)).limit(1);
328+ if (!o) return { ok: false, error: "Organization id does not exist" };
329+ }
330+ const id = newId("abuse");
331+ await db.insert(abuseEvents).values({
332+ id,
333+ organizationId: d.organizationId || null,
334+ projectId: d.projectId || null,
335+ requestId: d.requestId || null,
336+ kind: d.kind,
337+ severity: d.severity,
338+ detail: d.detail,
339+ });
340+ await audit(admin, "abuse.record", id, { kind: d.kind, severity: d.severity }, d.organizationId || null);
341+ revalidateAdmin();
342+ return { ok: true, data: { id } };
343+ } catch (e) {
344+ return fail(e);
345+ }
346+}
347+
348+// ---------------------------------------------------------------------------
349+// Feature flags
350+// ---------------------------------------------------------------------------
351+export async function setFeatureFlag(key: string, enabled: boolean): Promise<AdminActionResult> {
352+ const admin = await requireAdmin();
353+ try {
354+ const k = z.string().regex(/^[a-z0-9_.-]{2,64}$/, "Flag keys: lowercase, digits, . _ -").parse(key);
355+ const res = await getDb().update(featureFlags).set({ enabled: Boolean(enabled), updatedAt: new Date() }).where(eq(featureFlags.key, k)).returning({ key: featureFlags.key });
356+ if (!res.length) return { ok: false, error: "Flag not found" };
357+ await audit(admin, "flag.set", k, { enabled: Boolean(enabled) });
358+ revalidateAdmin();
359+ return { ok: true };
360+ } catch (e) {
361+ return fail(e);
362+ }
363+}
364+
365+const flagSchema = z.object({
366+ key: z.string().trim().regex(/^[a-z0-9_.-]{2,64}$/, "Flag keys: lowercase, digits, . _ -"),
367+ description: z.string().trim().max(280).optional().or(z.literal("")),
368+ enabled: z.boolean().default(false),
369+ plans: z.array(z.enum(PLANS)).optional(),
370+ organizationIds: z.array(z.string().trim().min(1)).max(200).optional(),
371+});
372+
373+export async function upsertFeatureFlag(input: z.input<typeof flagSchema>): Promise<AdminActionResult> {
374+ const admin = await requireAdmin();
375+ try {
376+ const d = flagSchema.parse(input);
377+ const set = { description: d.description || null, enabled: d.enabled, plans: d.plans?.length ? d.plans : null, organizationIds: d.organizationIds?.length ? d.organizationIds : null, updatedAt: new Date() };
378+ await getDb().insert(featureFlags).values({ key: d.key, ...set }).onConflictDoUpdate({ target: featureFlags.key, set });
379+ await audit(admin, "flag.upsert", d.key, { ...set, updatedAt: undefined });
380+ revalidateAdmin();
381+ return { ok: true };
382+ } catch (e) {
383+ return fail(e);
384+ }
385+}
386+
387+export async function deleteFeatureFlag(key: string): Promise<AdminActionResult> {
388+ const admin = await requireAdmin();
389+ try {
390+ const res = await getDb().delete(featureFlags).where(eq(featureFlags.key, key)).returning({ key: featureFlags.key });
391+ if (!res.length) return { ok: false, error: "Flag not found" };
392+ await audit(admin, "flag.delete", key, {});
393+ revalidateAdmin();
394+ return { ok: true };
395+ } catch (e) {
396+ return fail(e);
397+ }
398+}
399+
400+// ---------------------------------------------------------------------------
401+// Status incidents
402+// ---------------------------------------------------------------------------
403+const incidentSchema = z.object({
404+ component: z.enum(["api", "dashboard", "residential", "datacenter", "isp", "mobile", "sessions", "billing", "website"]),
405+ title: z.string().trim().min(3).max(140),
406+ body: z.string().trim().max(4000).optional().or(z.literal("")),
407+ severity: z.enum(["minor", "major", "critical", "maintenance"]),
408+});
409+
410+export async function createIncident(input: z.input<typeof incidentSchema>): Promise<AdminActionResult<{ id: string }>> {
411+ const admin = await requireAdmin();
412+ try {
413+ const d = incidentSchema.parse(input);
414+ const id = `inc_${randomBytes(8).toString("hex")}`;
415+ await getDb().insert(statusIncidents).values({ id, component: d.component, title: d.title, body: d.body || null, severity: d.severity });
416+ await audit(admin, "incident.create", id, { component: d.component, severity: d.severity, title: d.title });
417+ revalidateAdmin();
418+ revalidatePath("/status");
419+ return { ok: true, data: { id } };
420+ } catch (e) {
421+ return fail(e);
422+ }
423+}
424+
425+export async function resolveIncident(id: string, resolved = true): Promise<AdminActionResult> {
426+ const admin = await requireAdmin();
427+ try {
428+ const res = await getDb().update(statusIncidents).set({ resolvedAt: resolved ? new Date() : null }).where(eq(statusIncidents.id, id)).returning({ id: statusIncidents.id });
429+ if (!res.length) return { ok: false, error: "Incident not found" };
430+ await audit(admin, resolved ? "incident.resolve" : "incident.reopen", id, {});
431+ revalidateAdmin();
432+ revalidatePath("/status");
433+ return { ok: true };
434+ } catch (e) {
435+ return fail(e);
436+ }
437+}
added apps/web/src/actions/api-keys.ts +106 −0
@@ -0,0 +1,106 @@
1+"use server";
2+
3+import { revalidatePath } from "next/cache";
4+import { z } from "zod";
5+import { getDb, apiKeys, projects, eq, and } from "@fetcha/db";
6+import { API_KEY_SCOPES, generateApiKey, newId, type ApiKeyScope } from "@fetcha/core";
7+import { getEmailService } from "@fetcha/email";
8+import { getWorkspace, requestMeta } from "@/lib/session";
9+import { writeAudit } from "@/lib/audit";
10+import { internalApi } from "@/lib/api";
11+import type { ActionResult } from "./projects";
12+
13+const createSchema = z.object({
14+ name: z.string().trim().min(1, "Give the key a name").max(64),
15+ projectId: z.string().min(1),
16+ mode: z.enum(["live", "test"]).default("live"),
17+ scopes: z.array(z.enum(API_KEY_SCOPES)).min(1, "Select at least one scope"),
18+ expiresInDays: z.coerce.number().int().min(0).max(3650).optional(),
19+});
20+
21+export interface CreatedKey {
22+ id: string;
23+ plaintext: string;
24+ prefix: string;
25+ name: string;
26+ projectId: string;
27+}
28+
29+export async function createApiKey(input: { name: string; projectId: string; mode?: "live" | "test"; scopes: string[]; expiresInDays?: number }): Promise<ActionResult<CreatedKey>> {
30+ const ws = await getWorkspace();
31+ const parsed = createSchema.safeParse(input);
32+ if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
33+ const d = parsed.data;
34+ const db = getDb();
35+ const [project] = await db.select().from(projects).where(and(eq(projects.id, d.projectId), eq(projects.organizationId, ws.organization.id))).limit(1);
36+ if (!project) return { ok: false, error: "Project not found" };
37+ const key = generateApiKey(d.mode);
38+ const id = newId("key");
39+ await db.insert(apiKeys).values({
40+ id,
41+ organizationId: ws.organization.id,
42+ projectId: project.id,
43+ createdByUserId: ws.user.id,
44+ name: d.name,
45+ keyHash: key.hash,
46+ keyPrefix: key.prefix,
47+ last4: key.last4,
48+ mode: d.mode,
49+ scopes: d.scopes as ApiKeyScope[],
50+ expiresAt: d.expiresInDays ? new Date(Date.now() + d.expiresInDays * 86_400_000) : null,
51+ });
52+ const meta = await requestMeta();
53+ await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "api_key.created", target: id, metadata: { name: d.name, project: project.name, prefix: key.prefix, mode: d.mode }, ipAddress: meta.ip, userAgent: meta.userAgent });
54+ getEmailService()
55+ .sendApiKeyCreated(ws.user.email, { keyName: d.name, prefix: key.prefix, projectName: project.name })
56+ .catch(() => {});
57+ revalidatePath("/dashboard", "layout");
58+ return { ok: true, data: { id, plaintext: key.plaintext, prefix: key.prefix, name: d.name, projectId: project.id } };
59+}
60+
61+export async function revokeApiKey(keyId: string): Promise<ActionResult> {
62+ const ws = await getWorkspace();
63+ const db = getDb();
64+ const [k] = await db.select().from(apiKeys).where(and(eq(apiKeys.id, keyId), eq(apiKeys.organizationId, ws.organization.id))).limit(1);
65+ if (!k) return { ok: false, error: "Key not found" };
66+ if (k.revokedAt) return { ok: true };
67+ await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, keyId));
68+ await internalApi.invalidateKey(k.keyHash);
69+ const meta = await requestMeta();
70+ await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "api_key.revoked", target: keyId, metadata: { name: k.name, prefix: k.keyPrefix }, ipAddress: meta.ip, userAgent: meta.userAgent });
71+ getEmailService()
72+ .sendApiKeyRevoked(ws.user.email, { keyName: k.name, prefix: k.keyPrefix })
73+ .catch(() => {});
74+ revalidatePath("/dashboard", "layout");
75+ return { ok: true };
76+}
77+
78+/** Rotate: create a new key with the same settings, revoke the old one after a short grace period (immediately in V1). */
79+export async function rotateApiKey(keyId: string): Promise<ActionResult<CreatedKey>> {
80+ const ws = await getWorkspace();
81+ const db = getDb();
82+ const [k] = await db.select().from(apiKeys).where(and(eq(apiKeys.id, keyId), eq(apiKeys.organizationId, ws.organization.id))).limit(1);
83+ if (!k) return { ok: false, error: "Key not found" };
84+ const key = generateApiKey(k.mode as "live" | "test");
85+ const id = newId("key");
86+ await db.insert(apiKeys).values({
87+ id,
88+ organizationId: ws.organization.id,
89+ projectId: k.projectId,
90+ createdByUserId: ws.user.id,
91+ name: k.name,
92+ keyHash: key.hash,
93+ keyPrefix: key.prefix,
94+ last4: key.last4,
95+ mode: k.mode,
96+ scopes: k.scopes,
97+ expiresAt: k.expiresAt,
98+ rotatedFromId: k.id,
99+ });
100+ await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, k.id));
101+ await internalApi.invalidateKey(k.keyHash);
102+ const meta = await requestMeta();
103+ await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "api_key.rotated", target: id, metadata: { from: k.id, name: k.name }, ipAddress: meta.ip, userAgent: meta.userAgent });
104+ revalidatePath("/dashboard", "layout");
105+ return { ok: true, data: { id, plaintext: key.plaintext, prefix: key.prefix, name: k.name, projectId: k.projectId } };
106+}
added apps/web/src/actions/legal.ts +24 −0
@@ -0,0 +1,24 @@
1+"use server";
2+
3+import { getDb, legalAcceptances } from "@fetcha/db";
4+import { newId } from "@fetcha/core";
5+import { getUser, requestMeta } from "@/lib/session";
6+import { LEGAL_VERSIONS } from "@/lib/legal-versions";
7+
8+
9+/** Record acceptance of Terms + Privacy (+ AUP) for the current user with version, timestamp and IP. */
10+export async function recordLegalAcceptance(): Promise<void> {
11+ const user = await getUser();
12+ if (!user) return;
13+ const meta = await requestMeta();
14+ const db = getDb();
15+ await db.insert(legalAcceptances).values(
16+ (Object.keys(LEGAL_VERSIONS) as Array<keyof typeof LEGAL_VERSIONS>).map((doc) => ({
17+ id: newId("evt"),
18+ userId: user.id,
19+ document: doc,
20+ version: LEGAL_VERSIONS[doc],
21+ ipAddress: meta.ip,
22+ })),
23+ );
24+}
added apps/web/src/actions/playground.ts +68 −0
@@ -0,0 +1,68 @@
1+"use server";
2+
3+import { PLAN_LIMITS, fetchRequestSchema, type FetchResponseBody, type Plan } from "@fetcha/core";
4+import { getWorkspace } from "@/lib/session";
5+import { internalApi, InternalApiError } from "@/lib/api";
6+
7+export interface PlaygroundError {
8+ code: string;
9+ message: string;
10+ requestId: string | null;
11+ details?: Record<string, unknown>;
12+}
13+
14+export type PlaygroundResult = { ok: true; data: FetchResponseBody; requestId: string } | { ok: false; error: PlaygroundError };
15+
16+/**
17+ * Run a request from the dashboard Playground. Validates with the shared schema, clamps the timeout
18+ * to the plan maximum and forwards to the API service (source = "playground", scoped to the current
19+ * project). Never throws to the client: every failure is returned as `{ ok: false, error }`.
20+ */
21+export async function runPlayground(input: unknown): Promise<PlaygroundResult> {
22+ // Outside the try/catch: an unauthenticated caller must still be redirected to /login.
23+ const ws = await getWorkspace();
24+
25+ try {
26+ const parsed = fetchRequestSchema.safeParse(input);
27+ if (!parsed.success) {
28+ const issues = parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }));
29+ const first = issues[0];
30+ return {
31+ ok: false,
32+ error: {
33+ code: "INVALID_REQUEST",
34+ message: first ? `${first.path ? `${first.path}: ` : ""}${first.message}` : "The request is invalid.",
35+ requestId: null,
36+ details: { issues },
37+ },
38+ };
39+ }
40+
41+ const request = parsed.data;
42+ const plan = (ws.organization.plan as Plan) in PLAN_LIMITS ? (ws.organization.plan as Plan) : "free";
43+ const limits = PLAN_LIMITS[plan];
44+ if (request.timeout > limits.max_timeout_ms) request.timeout = limits.max_timeout_ms;
45+ if (request.retries !== undefined && request.retries > limits.max_retries) request.retries = limits.max_retries;
46+ if (request.network !== "auto" && !limits.networks.includes(request.network)) {
47+ return {
48+ ok: false,
49+ error: { code: "NETWORK_UNAVAILABLE", message: `The "${request.network}" network is not included in the ${limits.label} plan.`, requestId: null },
50+ };
51+ }
52+ if (request.browser) {
53+ return { ok: false, error: { code: "BROWSER_UNAVAILABLE", message: "Managed browser execution is not yet available.", requestId: null } };
54+ }
55+
56+ const data = (await internalApi.playgroundFetch(ws.project.id, ws.user.id, request)) as FetchResponseBody;
57+ if (!data || typeof data !== "object" || typeof data.request_id !== "string") {
58+ return { ok: false, error: { code: "INTERNAL_ERROR", message: "The API returned an unexpected response.", requestId: null } };
59+ }
60+ return { ok: true, data, requestId: data.request_id };
61+ } catch (e) {
62+ if (e instanceof InternalApiError) {
63+ return { ok: false, error: { code: e.code, message: e.message, requestId: e.requestId, details: e.details } };
64+ }
65+ console.error("[playground] unexpected error", e);
66+ return { ok: false, error: { code: "INTERNAL_ERROR", message: "An internal error occurred. Please try again.", requestId: null } };
67+ }
68+}
added apps/web/src/actions/projects.ts +112 −0
@@ -0,0 +1,112 @@
1+"use server";
2+
3+import { cookies } from "next/headers";
4+import { revalidatePath } from "next/cache";
5+import { redirect } from "next/navigation";
6+import { z } from "zod";
7+import { getDb, projects, eq, and } from "@fetcha/db";
8+import { newId } from "@fetcha/core";
9+import { getWorkspace, PROJECT_COOKIE, requestMeta } from "@/lib/session";
10+import { writeAudit } from "@/lib/audit";
11+import { slugify } from "@/lib/utils";
12+
13+export type ActionResult<T = undefined> = { ok: true; data?: T } | { ok: false; error: string; field?: string };
14+
15+const projectSchema = z.object({
16+ name: z.string().trim().min(2, "Name must be at least 2 characters").max(64),
17+ description: z.string().trim().max(280).optional().or(z.literal("")),
18+ environment: z.enum(["production", "development"]).default("production"),
19+ defaultCountry: z.string().trim().length(2).toUpperCase().optional().or(z.literal("")),
20+ defaultNetwork: z.enum(["auto", "datacenter", "residential", "isp", "mobile"]).default("auto"),
21+ logLevel: z.enum(["none", "metadata", "headers", "full"]).default("metadata"),
22+ softLimitUsd: z.coerce.number().min(0).optional().or(z.literal("")),
23+ hardLimitUsd: z.coerce.number().min(0).optional().or(z.literal("")),
24+ monthlyRequestLimit: z.coerce.number().int().min(0).optional().or(z.literal("")),
25+});
26+
27+function num(v: number | "" | undefined): number | null {
28+ return v === "" || v === undefined || Number.isNaN(v) ? null : v;
29+}
30+
31+export async function createProject(formData: FormData): Promise<ActionResult<{ id: string }>> {
32+ const ws = await getWorkspace();
33+ const parsed = projectSchema.safeParse(Object.fromEntries(formData));
34+ if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input", field: String(parsed.error.issues[0]?.path[0] ?? "") };
35+ const d = parsed.data;
36+ const db = getDb();
37+ const id = newId("proj");
38+ let slug = slugify(d.name);
39+ const existing = await db.select({ id: projects.id }).from(projects).where(and(eq(projects.organizationId, ws.organization.id), eq(projects.slug, slug)));
40+ if (existing.length) slug = `${slug}-${id.slice(-4)}`;
41+ await db.insert(projects).values({
42+ id,
43+ organizationId: ws.organization.id,
44+ name: d.name,
45+ slug,
46+ description: d.description || null,
47+ environment: d.environment,
48+ defaultCountry: d.defaultCountry || null,
49+ defaultNetwork: d.defaultNetwork,
50+ logLevel: d.logLevel,
51+ softLimitUsd: num(d.softLimitUsd),
52+ hardLimitUsd: num(d.hardLimitUsd),
53+ monthlyRequestLimit: num(d.monthlyRequestLimit),
54+ });
55+ const meta = await requestMeta();
56+ await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "project.created", target: id, metadata: { name: d.name }, ipAddress: meta.ip, userAgent: meta.userAgent });
57+ (await cookies()).set(PROJECT_COOKIE, id, { path: "/", httpOnly: true, sameSite: "lax", maxAge: 60 * 60 * 24 * 365 });
58+ revalidatePath("/dashboard", "layout");
59+ return { ok: true, data: { id } };
60+}
61+
62+export async function updateProject(projectId: string, formData: FormData): Promise<ActionResult> {
63+ const ws = await getWorkspace();
64+ const parsed = projectSchema.safeParse(Object.fromEntries(formData));
65+ if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input", field: String(parsed.error.issues[0]?.path[0] ?? "") };
66+ const d = parsed.data;
67+ const db = getDb();
68+ const [p] = await db.select().from(projects).where(and(eq(projects.id, projectId), eq(projects.organizationId, ws.organization.id))).limit(1);
69+ if (!p) return { ok: false, error: "Project not found" };
70+ await db
71+ .update(projects)
72+ .set({
73+ name: d.name,
74+ description: d.description || null,
75+ environment: d.environment,
76+ defaultCountry: d.defaultCountry || null,
77+ defaultNetwork: d.defaultNetwork,
78+ logLevel: d.logLevel,
79+ softLimitUsd: num(d.softLimitUsd),
80+ hardLimitUsd: num(d.hardLimitUsd),
81+ monthlyRequestLimit: num(d.monthlyRequestLimit),
82+ updatedAt: new Date(),
83+ })
84+ .where(eq(projects.id, projectId));
85+ const meta = await requestMeta();
86+ await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "project.updated", target: projectId, ipAddress: meta.ip, userAgent: meta.userAgent });
87+ revalidatePath("/dashboard", "layout");
88+ return { ok: true };
89+}
90+
91+export async function archiveProject(projectId: string): Promise<ActionResult> {
92+ const ws = await getWorkspace();
93+ if (ws.projects.length <= 1) return { ok: false, error: "You need at least one active project." };
94+ const db = getDb();
95+ const [p] = await db.select().from(projects).where(and(eq(projects.id, projectId), eq(projects.organizationId, ws.organization.id))).limit(1);
96+ if (!p) return { ok: false, error: "Project not found" };
97+ await db.update(projects).set({ archivedAt: new Date(), updatedAt: new Date() }).where(eq(projects.id, projectId));
98+ const meta = await requestMeta();
99+ await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "project.archived", target: projectId, ipAddress: meta.ip, userAgent: meta.userAgent });
100+ const store = await cookies();
101+ if (store.get(PROJECT_COOKIE)?.value === projectId) store.delete(PROJECT_COOKIE);
102+ revalidatePath("/dashboard", "layout");
103+ return { ok: true };
104+}
105+
106+export async function selectProject(projectId: string, redirectTo?: string): Promise<void> {
107+ const ws = await getWorkspace();
108+ if (!ws.projects.some((p) => p.id === projectId)) return;
109+ (await cookies()).set(PROJECT_COOKIE, projectId, { path: "/", httpOnly: true, sameSite: "lax", maxAge: 60 * 60 * 24 * 365 });
110+ revalidatePath("/dashboard", "layout");
111+ if (redirectTo) redirect(redirectTo);
112+}
added apps/web/src/actions/sessions.ts +77 −0
@@ -0,0 +1,77 @@
1+"use server";
2+
3+import { revalidatePath } from "next/cache";
4+import { z } from "zod";
5+import { COUNTRIES } from "@fetcha/core";
6+import { getWorkspace } from "@/lib/session";
7+import { internalApi, InternalApiError } from "@/lib/api";
8+import type { ActionResult } from "./projects";
9+
10+/** Session as returned by the Fetcha API (`POST /v1/sessions`). Provider details are never included. */
11+export interface DashboardSession {
12+ id: string;
13+ label: string | null;
14+ status: string;
15+ network: string;
16+ country: string | null;
17+ region: string | null;
18+ city: string | null;
19+ request_count: number;
20+ last_used_at: string | null;
21+ expires_at: string;
22+ created_at: string;
23+}
24+
25+const createSchema = z.object({
26+ country: z
27+ .string()
28+ .trim()
29+ .length(2)
30+ .toUpperCase()
31+ .refine((c) => c in COUNTRIES, "Unsupported country")
32+ .optional(),
33+ region: z.string().trim().max(64).optional(),
34+ city: z.string().trim().max(128).optional(),
35+ // Only `auto` and `residential` are live in V1; the API rejects the others with NETWORK_UNAVAILABLE.
36+ network: z.enum(["auto", "residential", "datacenter", "isp", "mobile"]).default("auto"),
37+ ttl: z.coerce.number().int().min(60).max(1800).default(600),
38+ label: z.string().trim().max(128).optional(),
39+});
40+
41+export type CreateSessionInput = z.input<typeof createSchema>;
42+
43+function clean<T extends Record<string, unknown>>(o: T): Partial<T> {
44+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== "")) as Partial<T>;
45+}
46+
47+export async function createDashboardSession(input: CreateSessionInput): Promise<ActionResult<DashboardSession>> {
48+ const ws = await getWorkspace();
49+ const parsed = createSchema.safeParse(clean(input as Record<string, unknown>));
50+ if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input", field: String(parsed.error.issues[0]?.path[0] ?? "") };
51+ try {
52+ const session = (await internalApi.createSession(ws.project.id, ws.user.id, clean(parsed.data))) as DashboardSession;
53+ revalidatePath("/dashboard/sessions");
54+ revalidatePath("/dashboard");
55+ return { ok: true, data: session };
56+ } catch (e) {
57+ if (e instanceof InternalApiError) return { ok: false, error: e.code === "NETWORK_UNAVAILABLE" ? "That network class is not available yet. Use auto or residential." : e.message };
58+ return { ok: false, error: "Could not create the session. Please try again." };
59+ }
60+}
61+
62+export async function closeDashboardSession(id: string): Promise<ActionResult> {
63+ const ws = await getWorkspace();
64+ if (!/^sess_[A-Za-z0-9]{4,64}$/.test(id)) return { ok: false, error: "Invalid session id" };
65+ try {
66+ await internalApi.closeSession(ws.project.id, ws.user.id, id);
67+ revalidatePath("/dashboard/sessions");
68+ revalidatePath("/dashboard");
69+ return { ok: true };
70+ } catch (e) {
71+ if (e instanceof InternalApiError) {
72+ if (e.code === "SESSION_NOT_FOUND") return { ok: false, error: "Session not found in this project." };
73+ return { ok: false, error: e.message };
74+ }
75+ return { ok: false, error: "Could not close the session. Please try again." };
76+ }
77+}
added apps/web/src/app/(auth)/forgot-password/page.tsx +47 −0
@@ -0,0 +1,47 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { authClient } from "@/lib/auth-client";
5+import { AuthCard } from "@/components/auth/auth-card";
6+import { Button } from "@/components/ui/button";
7+import { Input } from "@/components/ui/input";
8+import { Field, Label } from "@/components/ui/label";
9+import { Alert } from "@/components/ui/alert";
10+
11+export default function ForgotPasswordPage() {
12+ const [email, setEmail] = React.useState("");
13+ const [sent, setSent] = React.useState(false);
14+ const [loading, setLoading] = React.useState(false);
15+ const [error, setError] = React.useState<string | null>(null);
16+
17+ async function onSubmit(e: React.FormEvent) {
18+ e.preventDefault();
19+ setLoading(true);
20+ setError(null);
21+ const { error } = await authClient.requestPasswordReset({ email: email.trim(), redirectTo: "/reset-password" });
22+ setLoading(false);
23+ if (error && error.status === 429) return setError("Too many attempts. Wait a minute and try again.");
24+ setSent(true); // Always confirm to avoid leaking whether an account exists.
25+ }
26+
27+ return (
28+ <AuthCard
29+ title="Reset your password"
30+ description="Enter your account email and we will send a link to choose a new password."
31+ footer={<Link href="/login" className="font-medium text-fg underline-offset-4 hover:underline">Back to log in</Link>}
32+ >
33+ {sent ? (
34+ <Alert variant="success" title="Check your inbox">If an account exists for {email}, a reset link is on its way. It expires in one hour.</Alert>
35+ ) : (
36+ <form onSubmit={onSubmit} className="grid gap-4" noValidate>
37+ {error ? <Alert variant="danger">{error}</Alert> : null}
38+ <Field>
39+ <Label htmlFor="email">Email</Label>
40+ <Input id="email" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@company.com" autoFocus />
41+ </Field>
42+ <Button type="submit" size="lg" loading={loading} className="w-full">Send reset link</Button>
43+ </form>
44+ )}
45+ </AuthCard>
46+ );
47+}
added apps/web/src/app/(auth)/layout.tsx +24 −0
@@ -0,0 +1,24 @@
1+import Link from "next/link";
2+import { Logo } from "@/components/ui/logo";
3+import { ThemeToggle } from "@/components/ui/theme";
4+
5+export default function AuthLayout({ children }: { children: React.ReactNode }) {
6+ return (
7+ <div className="relative flex min-h-dvh flex-col">
8+ <div className="absolute inset-0 -z-10 grid-bg opacity-60" aria-hidden />
9+ <header className="container-page flex h-16 items-center justify-between">
10+ <Logo />
11+ <div className="flex items-center gap-2 text-[13px] text-fg-muted">
12+ <Link href="/docs" className="hover:text-fg">Docs</Link>
13+ <ThemeToggle />
14+ </div>
15+ </header>
16+ <main className="flex flex-1 items-start justify-center px-4 pb-16 pt-6 sm:pt-12">
17+ <div className="w-full max-w-[400px] animate-fade-up">{children}</div>
18+ </main>
19+ <footer className="container-page pb-6 text-center text-[12px] text-fg-subtle">
20+ <Link href="/legal/terms" className="hover:text-fg">Terms</Link> · <Link href="/legal/privacy" className="hover:text-fg">Privacy</Link> · <a href="mailto:support@fetcha.co" className="hover:text-fg">support@fetcha.co</a>
21+ </footer>
22+ </div>
23+ );
24+}
added apps/web/src/app/(auth)/login/login-form.tsx +74 −0
@@ -0,0 +1,74 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { useRouter } from "next/navigation";
5+import { authClient } from "@/lib/auth-client";
6+import { AuthCard } from "@/components/auth/auth-card";
7+import { PasswordInput } from "@/components/auth/password-input";
8+import { Button } from "@/components/ui/button";
9+import { Input } from "@/components/ui/input";
10+import { Field, FieldError, Label } from "@/components/ui/label";
11+import { Alert } from "@/components/ui/alert";
12+
13+export function LoginForm({ next, verified, reset }: { next?: string; verified?: boolean; reset?: boolean }) {
14+ const router = useRouter();
15+ const [email, setEmail] = React.useState("");
16+ const [password, setPassword] = React.useState("");
17+ const [remember, setRemember] = React.useState(true);
18+ const [error, setError] = React.useState<string | null>(null);
19+ const [loading, setLoading] = React.useState(false);
20+
21+ async function onSubmit(e: React.FormEvent) {
22+ e.preventDefault();
23+ setError(null);
24+ setLoading(true);
25+ const { error } = await authClient.signIn.email({ email: email.trim(), password, rememberMe: remember, callbackURL: next && next.startsWith("/") ? next : "/dashboard" });
26+ setLoading(false);
27+ if (error) {
28+ setError(error.status === 429 ? "Too many attempts. Wait a minute and try again." : error.message ?? "Invalid email or password.");
29+ return;
30+ }
31+ router.push(next && next.startsWith("/") ? next : "/dashboard");
32+ router.refresh();
33+ }
34+
35+ return (
36+ <AuthCard
37+ title="Welcome back"
38+ description="Log in to your Fetcha account."
39+ footer={
40+ <>
41+ New to Fetcha?{" "}
42+ <Link href={`/signup${next ? `?next=${encodeURIComponent(next)}` : ""}`} className="font-medium text-fg underline-offset-4 hover:underline">
43+ Create an account
44+ </Link>
45+ </>
46+ }
47+ >
48+ <form onSubmit={onSubmit} className="grid gap-4" noValidate>
49+ {verified ? <Alert variant="success">Your email is verified. Log in to continue.</Alert> : null}
50+ {reset ? <Alert variant="success">Your password was updated. Log in with the new one.</Alert> : null}
51+ {error ? <Alert variant="danger">{error}</Alert> : null}
52+ <Field>
53+ <Label htmlFor="email">Email</Label>
54+ <Input id="email" name="email" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@company.com" autoFocus />
55+ </Field>
56+ <Field>
57+ <div className="flex items-center justify-between">
58+ <Label htmlFor="password">Password</Label>
59+ <Link href="/forgot-password" className="text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">Forgot password?</Link>
60+ </div>
61+ <PasswordInput id="password" name="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} />
62+ <FieldError />
63+ </Field>
64+ <label className="flex items-center gap-2 text-[13px] text-fg-muted">
65+ <input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} className="size-4 rounded border-border accent-[var(--accent)]" />
66+ Remember this device
67+ </label>
68+ <Button type="submit" size="lg" loading={loading} className="mt-1 w-full">
69+ Log in
70+ </Button>
71+ </form>
72+ </AuthCard>
73+ );
74+}
added apps/web/src/app/(auth)/login/page.tsx +13 −0
@@ -0,0 +1,13 @@
1+import type { Metadata } from "next";
2+import { redirect } from "next/navigation";
3+import { getUser } from "@/lib/session";
4+import { LoginForm } from "./login-form";
5+
6+export const metadata: Metadata = { title: "Log in" };
7+
8+export default async function LoginPage({ searchParams }: { searchParams: Promise<{ next?: string; verified?: string; reset?: string }> }) {
9+ const sp = await searchParams;
10+ const user = await getUser();
11+ if (user) redirect(sp.next && sp.next.startsWith("/") ? sp.next : "/dashboard");
12+ return <LoginForm next={sp.next} verified={sp.verified === "1"} reset={sp.reset === "1"} />;
13+}
added apps/web/src/app/(auth)/reset-password/page.tsx +67 −0
@@ -0,0 +1,67 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { useRouter, useSearchParams } from "next/navigation";
5+import { authClient } from "@/lib/auth-client";
6+import { AuthCard } from "@/components/auth/auth-card";
7+import { PasswordInput, PasswordRequirements, passwordStrong } from "@/components/auth/password-input";
8+import { Button } from "@/components/ui/button";
9+import { Field, FieldError, Label } from "@/components/ui/label";
10+import { Alert } from "@/components/ui/alert";
11+
12+function ResetForm() {
13+ const router = useRouter();
14+ const params = useSearchParams();
15+ const token = params.get("token");
16+ const invalid = params.get("error") === "INVALID_TOKEN" || !token;
17+ const [password, setPassword] = React.useState("");
18+ const [confirm, setConfirm] = React.useState("");
19+ const [error, setError] = React.useState<string | null>(null);
20+ const [loading, setLoading] = React.useState(false);
21+
22+ async function onSubmit(e: React.FormEvent) {
23+ e.preventDefault();
24+ if (!passwordStrong(password)) return setError("Choose a stronger password.");
25+ if (password !== confirm) return setError("Passwords do not match.");
26+ setLoading(true);
27+ const { error } = await authClient.resetPassword({ newPassword: password, token: token! });
28+ setLoading(false);
29+ if (error) return setError(error.message ?? "This link is invalid or expired.");
30+ router.push("/login?reset=1");
31+ }
32+
33+ if (invalid) {
34+ return (
35+ <AuthCard title="Link expired" description="This password reset link is invalid or has expired." footer={<Link href="/forgot-password" className="font-medium text-fg underline-offset-4 hover:underline">Request a new link</Link>}>
36+ <Alert variant="warning">Reset links are valid for one hour and can only be used once.</Alert>
37+ </AuthCard>
38+ );
39+ }
40+
41+ return (
42+ <AuthCard title="Choose a new password" description="All existing sessions will be signed out.">
43+ <form onSubmit={onSubmit} className="grid gap-4" noValidate>
44+ {error ? <Alert variant="danger">{error}</Alert> : null}
45+ <Field>
46+ <Label htmlFor="password">New password</Label>
47+ <PasswordInput id="password" autoComplete="new-password" required value={password} onChange={(e) => setPassword(e.target.value)} autoFocus />
48+ <PasswordRequirements password={password} />
49+ </Field>
50+ <Field>
51+ <Label htmlFor="confirm">Confirm password</Label>
52+ <PasswordInput id="confirm" autoComplete="new-password" required value={confirm} onChange={(e) => setConfirm(e.target.value)} />
53+ <FieldError>{confirm && confirm !== password ? "Passwords do not match." : null}</FieldError>
54+ </Field>
55+ <Button type="submit" size="lg" loading={loading} className="w-full">Update password</Button>
56+ </form>
57+ </AuthCard>
58+ );
59+}
60+
61+export default function ResetPasswordPage() {
62+ return (
63+ <React.Suspense fallback={null}>
64+ <ResetForm />
65+ </React.Suspense>
66+ );
67+}
added apps/web/src/app/(auth)/signup/page.tsx +13 −0
@@ -0,0 +1,13 @@
1+import type { Metadata } from "next";
2+import { redirect } from "next/navigation";
3+import { getUser } from "@/lib/session";
4+import { SignupForm } from "./signup-form";
5+
6+export const metadata: Metadata = { title: "Create account" };
7+
8+export default async function SignupPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
9+ const sp = await searchParams;
10+ const user = await getUser();
11+ if (user) redirect("/dashboard");
12+ return <SignupForm next={sp.next} />;
13+}
added apps/web/src/app/(auth)/signup/signup-form.tsx +111 −0
@@ -0,0 +1,111 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { useRouter } from "next/navigation";
5+import { MailCheck } from "lucide-react";
6+import { authClient } from "@/lib/auth-client";
7+import { recordLegalAcceptance } from "@/actions/legal";
8+import { AuthCard } from "@/components/auth/auth-card";
9+import { PasswordInput, PasswordRequirements, passwordStrong } from "@/components/auth/password-input";
10+import { Button } from "@/components/ui/button";
11+import { Input } from "@/components/ui/input";
12+import { Field, FieldError, Label } from "@/components/ui/label";
13+import { Alert } from "@/components/ui/alert";
14+
15+export function SignupForm({ next }: { next?: string }) {
16+ const router = useRouter();
17+ const [name, setName] = React.useState("");
18+ const [email, setEmail] = React.useState("");
19+ const [password, setPassword] = React.useState("");
20+ const [confirm, setConfirm] = React.useState("");
21+ const [agree, setAgree] = React.useState(false);
22+ const [error, setError] = React.useState<string | null>(null);
23+ const [loading, setLoading] = React.useState(false);
24+ const [done, setDone] = React.useState(false);
25+
26+ const mismatch = confirm.length > 0 && confirm !== password;
27+
28+ async function onSubmit(e: React.FormEvent) {
29+ e.preventDefault();
30+ setError(null);
31+ if (!passwordStrong(password)) return setError("Choose a stronger password.");
32+ if (password !== confirm) return setError("Passwords do not match.");
33+ if (!agree) return setError("You must accept the Terms of Service and Privacy Policy.");
34+ setLoading(true);
35+ const { error } = await authClient.signUp.email({ name: name.trim() || email.split("@")[0]!, email: email.trim(), password, callbackURL: "/dashboard?verified=1" });
36+ if (error) {
37+ setLoading(false);
38+ setError(error.status === 422 || /exist/i.test(error.message ?? "") ? "An account with this email already exists. Try logging in." : error.message ?? "Could not create the account.");
39+ return;
40+ }
41+ await recordLegalAcceptance().catch(() => {});
42+ setLoading(false);
43+ setDone(true);
44+ setTimeout(() => {
45+ router.push(next && next.startsWith("/") ? next : "/dashboard");
46+ router.refresh();
47+ }, 2500);
48+ }
49+
50+ if (done) {
51+ return (
52+ <AuthCard title="Check your inbox" description={<>We sent a verification link to <strong className="text-fg">{email}</strong>. Verify it to unlock production API access — the Playground is available right away.</>}>
53+ <div className="flex items-center gap-3 rounded-lg border border-border bg-bg-subtle p-4 text-[13.5px]">
54+ <MailCheck className="size-5 text-success" />
55+ <span>Redirecting you to the dashboard…</span>
56+ </div>
57+ <Button asChild className="mt-4 w-full" variant="outline">
58+ <Link href="/dashboard">Open the dashboard now</Link>
59+ </Button>
60+ </AuthCard>
61+ );
62+ }
63+
64+ return (
65+ <AuthCard
66+ title="Create your account"
67+ description="Free plan included: 1,000 requests a month, no credit card."
68+ footer={
69+ <>
70+ Already have an account?{" "}
71+ <Link href="/login" className="font-medium text-fg underline-offset-4 hover:underline">
72+ Log in
73+ </Link>
74+ </>
75+ }
76+ >
77+ <form onSubmit={onSubmit} className="grid gap-4" noValidate>
78+ {error ? <Alert variant="danger">{error}</Alert> : null}
79+ <Field>
80+ <Label htmlFor="name">Name <span className="font-normal text-fg-subtle">(optional)</span></Label>
81+ <Input id="name" name="name" autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ada Lovelace" />
82+ </Field>
83+ <Field>
84+ <Label htmlFor="email">Work email</Label>
85+ <Input id="email" name="email" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@company.com" />
86+ </Field>
87+ <Field>
88+ <Label htmlFor="password">Password</Label>
89+ <PasswordInput id="password" name="password" autoComplete="new-password" required value={password} onChange={(e) => setPassword(e.target.value)} />
90+ <PasswordRequirements password={password} />
91+ </Field>
92+ <Field>
93+ <Label htmlFor="confirm">Confirm password</Label>
94+ <PasswordInput id="confirm" name="confirm" autoComplete="new-password" required value={confirm} onChange={(e) => setConfirm(e.target.value)} aria-invalid={mismatch || undefined} />
95+ <FieldError>{mismatch ? "Passwords do not match." : null}</FieldError>
96+ </Field>
97+ <label className="flex items-start gap-2.5 text-[13px] text-fg-muted leading-snug">
98+ <input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} className="mt-0.5 size-4 shrink-0 rounded border-border accent-[var(--accent)]" required />
99+ <span>
100+ I agree to the{" "}
101+ <Link href="/legal/terms" target="_blank" className="text-fg underline-offset-4 hover:underline">Terms of Service</Link> and{" "}
102+ <Link href="/legal/privacy" target="_blank" className="text-fg underline-offset-4 hover:underline">Privacy Policy</Link>.
103+ </span>
104+ </label>
105+ <Button type="submit" size="lg" loading={loading} className="mt-1 w-full" disabled={!agree}>
106+ Create account
107+ </Button>
108+ </form>
109+ </AuthCard>
110+ );
111+}
added apps/web/src/app/(auth)/verify-email/page.tsx +36 −0
@@ -0,0 +1,36 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { MailCheck } from "lucide-react";
4+import { getUser } from "@/lib/session";
5+import { AuthCard } from "@/components/auth/auth-card";
6+import { Button } from "@/components/ui/button";
7+import { ResendVerificationButton } from "@/components/dashboard/resend-verification";
8+import { Alert } from "@/components/ui/alert";
9+
10+export const metadata: Metadata = { title: "Verify your email" };
11+
12+export default async function VerifyEmailPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
13+ const sp = await searchParams;
14+ const user = await getUser();
15+ if (user?.emailVerified) {
16+ return (
17+ <AuthCard title="Email verified" description="Your account is fully activated.">
18+ <div className="flex items-center gap-3 rounded-lg border border-border bg-bg-subtle p-4 text-[13.5px]"><MailCheck className="size-5 text-success" /> Production API access is unlocked.</div>
19+ <Button asChild className="mt-4 w-full"><Link href="/dashboard">Go to the dashboard</Link></Button>
20+ </AuthCard>
21+ );
22+ }
23+ return (
24+ <AuthCard title="Verify your email" description={user ? <>We sent a link to <strong className="text-fg">{user.email}</strong>. Open it to activate production API access.</> : "Log in to resend the verification email."}>
25+ {sp.error ? <Alert variant="warning" className="mb-4">The verification link is invalid or expired. Request a new one below.</Alert> : null}
26+ {user ? (
27+ <div className="flex flex-col gap-2">
28+ <ResendVerificationButton email={user.email} variant="primary" size="default" />
29+ <Button asChild variant="outline"><Link href="/dashboard">Continue to the dashboard</Link></Button>
30+ </div>
31+ ) : (
32+ <Button asChild className="w-full"><Link href="/login">Log in</Link></Button>
33+ )}
34+ </AuthCard>
35+ );
36+}
added apps/web/src/app/(marketing)/changelog/page.tsx +114 −0
@@ -0,0 +1,114 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { Badge } from "@/components/ui/badge";
4+import { Button } from "@/components/ui/button";
5+import { formatDateOnly } from "@/lib/format";
6+import { cn } from "@/lib/utils";
7+
8+export const metadata: Metadata = {
9+ title: "Changelog",
10+ description: "Release notes for the Fetcha web access API, dashboard and SDKs. What shipped, what changed, and what is not available yet.",
11+ alternates: { canonical: "/changelog" },
12+};
13+
14+type ChangeKind = "added" | "changed" | "fixed" | "not-yet";
15+
16+type Entry = {
17+ date: string; // ISO date
18+ version: string;
19+ title: string;
20+ tag?: "preview" | "stable" | "beta";
21+ summary: string;
22+ changes: Array<{ kind: ChangeKind; text: string }>;
23+};
24+
25+const ENTRIES: Entry[] = [
26+ {
27+ date: "2026-09-07",
28+ version: "v0.1.0",
29+ title: "Public preview",
30+ tag: "preview",
31+ summary: "First public release of Fetcha. One endpoint, automatic routing over the residential network, sticky sessions, and a dashboard to try requests and read logs. Billing checkout is intentionally not part of this release; every account runs on the Free plan.",
32+ changes: [
33+ { kind: "added", text: "Fetch API: POST /v1/fetch with country, region and city targeting, custom headers, cookies, body, timeout, format (html, text, json, raw), redirect controls and per-request retries." },
34+ { kind: "added", text: "Automatic routing (network: \"auto\"): weighted per-domain scoring, circuit breakers and cheap-to-premium escalation with a fresh IP on every retry." },
35+ { kind: "added", text: "Residential network class, live in 45 target countries. auto resolves to residential during the preview." },
36+ { kind: "added", text: "Sticky sessions: POST /v1/sessions with TTL 60–1,800 s, labels, Idempotency-Key support; list, inspect and close sessions." },
37+ { kind: "added", text: "Playground in the dashboard: run real requests, inspect attempts and timings, copy generated cURL, JavaScript and Python." },
38+ { kind: "added", text: "Request logs with per-request id (X-Fetcha-Request-ID), status, network, attempts, latency and bytes. Sensitive headers redacted before storage." },
39+ { kind: "added", text: "Usage metering: monthly requests and residential bandwidth per project, GET /v1/usage and GET /v1/me." },
40+ { kind: "added", text: "SDK source for JavaScript/TypeScript (@fetcha/sdk) and Python (fetcha). Install from the repository until the packages are published to npm and PyPI." },
41+ { kind: "added", text: "Documentation: quickstart, authentication, fetch reference, networks, geolocation, sessions, errors, retries, rate limits, SDKs and examples." },
42+ { kind: "added", text: "Public status page with 24-hour success rate and 30-day history, and this changelog." },
43+ { kind: "not-yet", text: "Browser execution (browser: true returns BROWSER_UNAVAILABLE), structured extraction (/v1/extract), datacenter, ISP and mobile network classes." },
44+ { kind: "not-yet", text: "Billing checkout and self-serve upgrades (email sales@fetcha.co), teams and invitations, webhook delivery, OAuth login, 2FA and passkeys, CLI." },
45+ ],
46+ },
47+];
48+
49+const KIND_META: Record<ChangeKind, { label: string; variant: "success" | "info" | "warning" | "outline" }> = {
50+ added: { label: "Added", variant: "success" },
51+ changed: { label: "Changed", variant: "info" },
52+ fixed: { label: "Fixed", variant: "warning" },
53+ "not-yet": { label: "Not yet", variant: "outline" },
54+};
55+
56+export default function ChangelogPage() {
57+ return (
58+ <div className="container-page py-14 sm:py-20">
59+ <header className="max-w-2xl">
60+ <p className="text-[11.5px] font-semibold uppercase tracking-[0.12em] text-accent">Changelog</p>
61+ <h1 className="mt-2 text-[34px] font-semibold leading-[1.1] tracking-tight sm:text-[44px]">What shipped, and what has not.</h1>
62+ <p className="mt-4 text-[15.5px] leading-relaxed text-fg-muted">
63+ Release notes for the API, dashboard and SDKs. We list unavailable features explicitly so you never build against something that does not exist yet. Follow the <Link href="/status" className="text-accent underline-offset-4 hover:underline">status page</Link> for incidents.
64+ </p>
65+ </header>
66+
67+ <ol className="mt-14 relative" aria-label="Releases">
68+ <div className="absolute left-[7px] top-2 bottom-2 w-px bg-border sm:left-[151px]" aria-hidden />
69+ {ENTRIES.map((e) => (
70+ <li key={e.version} className="relative grid gap-4 pb-16 last:pb-0 sm:grid-cols-[120px_minmax(0,1fr)] sm:gap-12">
71+ <div className="flex items-center gap-3 pl-6 sm:block sm:pl-0 sm:text-right">
72+ <time dateTime={e.date} className="font-mono text-[13px] tabular text-fg-muted">
73+ {formatDateOnly(e.date)}
74+ </time>
75+ <span className="absolute left-0 top-[7px] size-[15px] rounded-full border-2 border-accent bg-bg sm:left-[144px]" aria-hidden />
76+ </div>
77+ <article className="min-w-0 pl-6 sm:pl-0" aria-labelledby={`rel-${e.version}`}>
78+ <div className="flex flex-wrap items-center gap-2">
79+ <Badge variant="solid" className="font-mono">
80+ {e.version}
81+ </Badge>
82+ {e.tag ? <Badge variant={e.tag === "stable" ? "success" : "accent"}>{e.tag === "preview" ? "Public preview" : e.tag === "beta" ? "Beta" : "Stable"}</Badge> : null}
83+ </div>
84+ <h2 id={`rel-${e.version}`} className="mt-3 text-[24px] font-semibold tracking-tight">
85+ {e.title}
86+ </h2>
87+ <p className="mt-2 max-w-2xl text-[14.5px] leading-relaxed text-fg-muted">{e.summary}</p>
88+ <ul className="mt-6 divide-y divide-border rounded-lg border border-border">
89+ {e.changes.map((c, i) => (
90+ <li key={i} className={cn("flex items-start gap-3 px-4 py-3 text-[14px] leading-relaxed", c.kind === "not-yet" && "bg-bg-subtle/60 text-fg-muted")}>
91+ <Badge variant={KIND_META[c.kind].variant} className="mt-0.5 w-[74px] shrink-0 justify-center">
92+ {KIND_META[c.kind].label}
93+ </Badge>
94+ <span className="min-w-0 text-fg [&_code]:font-mono">{c.text}</span>
95+ </li>
96+ ))}
97+ </ul>
98+ </article>
99+ </li>
100+ ))}
101+ </ol>
102+
103+ <section className="mt-20 flex flex-col items-start justify-between gap-4 rounded-lg border border-border bg-bg-subtle/60 p-6 sm:flex-row sm:items-center">
104+ <div>
105+ <h2 className="text-[16px] font-semibold tracking-tight">Missing something you need?</h2>
106+ <p className="mt-1 text-[13.5px] text-fg-muted">Roadmap priorities follow real usage. Tell us what would unblock you.</p>
107+ </div>
108+ <Button asChild variant="outline">
109+ <a href="mailto:support@fetcha.co?subject=Fetcha%20feature%20request">Send feedback</a>
110+ </Button>
111+ </section>
112+ </div>
113+ );
114+}
added apps/web/src/app/(marketing)/docs/authentication/page.tsx +172 −0
@@ -0,0 +1,172 @@
1+import type { Metadata } from "next";
2+import { CodeBlock } from "@/components/ui/code-block";
3+import { DocPage } from "@/components/docs/doc-page";
4+import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
5+import { Callout } from "@/components/docs/callout";
6+import { Endpoint } from "@/components/docs/endpoint";
7+import { CodeTabs } from "@/components/docs/code-tabs";
8+import { ResponseExample } from "@/components/docs/response-example";
9+import { apiTabs } from "@/components/docs/snippets";
10+
11+export const metadata: Metadata = {
12+ title: "Authentication",
13+ description: "API keys, Bearer and X-API-Key headers, scopes, rotation, revocation and the email verification requirement.",
14+};
15+
16+export default function AuthenticationPage() {
17+ return (
18+ <DocPage path="/docs/authentication" title="Authentication" description="Every request to the Fetcha API is authenticated with a project-scoped API key sent in a header. Keys carry scopes, a mode and an optional expiry." status="Stable">
19+ <H2>API keys</H2>
20+ <P>
21+ Keys are created in the dashboard under a project. A key belongs to exactly one project and one organization; the requests it makes are logged, counted and billed against them. The
22+ plaintext has a recognisable prefix so you can tell keys apart in configuration:
23+ </P>
24+ <Table>
25+ <THead>
26+ <Tr>
27+ <Th>Prefix</Th>
28+ <Th>Mode</Th>
29+ <Th>Meaning</Th>
30+ </Tr>
31+ </THead>
32+ <TBody>
33+ <Tr>
34+ <Td mono>fch_live_…</Td>
35+ <Td>live</Td>
36+ <Td>Production key. Use it in deployed services.</Td>
37+ </Tr>
38+ <Tr>
39+ <Td mono>fch_test_…</Td>
40+ <Td>test</Td>
41+ <Td>
42+ Marked as a test key in logs and in <Code>GET /v1/me</Code>. Behaves like a live key today: requests are real and count toward your plan.
43+ </Td>
44+ </Tr>
45+ </TBody>
46+ </Table>
47+ <P>
48+ A key is between 20 and 128 characters. Anything else, or a token without one of the two prefixes, is rejected with <Code>401 INVALID_API_KEY</Code> before the database is consulted.
49+ </P>
50+
51+ <H3>Shown once, hashed at rest</H3>
52+ <P>
53+ The plaintext is displayed a single time when the key is created (or rotated). Fetcha persists only a SHA-256 hash together with the non-secret prefix and last four characters used to
54+ identify the key in the dashboard. There is no way to retrieve a lost key; rotate it instead.
55+ </P>
56+
57+ <H2>Sending the key</H2>
58+ <P>Use the standard Bearer scheme. This is what the SDKs send.</P>
59+ <CodeBlock lang="text" code={`Authorization: Bearer fch_live_…`} />
60+ <P>
61+ If your HTTP client cannot set <Code>Authorization</Code>, send the key in <Code>X-API-Key</Code> instead. When both headers are present, <Code>X-API-Key</Code> takes precedence.
62+ </P>
63+ <CodeBlock lang="text" code={`X-API-Key: fch_live_…`} />
64+ <Callout variant="warning" title="Server-side only">
65+ Never ship a key in a browser bundle or mobile app: anyone who can read it can spend your quota. Call Fetcha from your backend. The API allows cross-origin requests so that the dashboard
66+ can talk to it, not so that keys can live in the browser.
67+ </Callout>
68+
69+ <H2>Scopes</H2>
70+ <P>
71+ Each key has a list of scopes chosen at creation. A request that needs a scope the key lacks is refused with <Code>403 FORBIDDEN</Code> and a message naming the missing scope.
72+ </P>
73+ <Table>
74+ <THead>
75+ <Tr>
76+ <Th>Scope</Th>
77+ <Th>Grants</Th>
78+ <Th>Status</Th>
79+ </Tr>
80+ </THead>
81+ <TBody>
82+ <Tr>
83+ <Td mono>fetch:execute</Td>
84+ <Td>
85+ <Code>POST /v1/fetch</Code>
86+ </Td>
87+ <Td>Live</Td>
88+ </Tr>
89+ <Tr>
90+ <Td mono>sessions:write</Td>
91+ <Td>
92+ <Code>POST /v1/sessions</Code>, <Code>DELETE /v1/sessions/:id</Code>. Listing and reading sessions requires no scope.
93+ </Td>
94+ <Td>Live</Td>
95+ </Tr>
96+ <Tr>
97+ <Td mono>usage:read</Td>
98+ <Td>
99+ <Code>GET /v1/usage</Code>
100+ </Td>
101+ <Td>Live</Td>
102+ </Tr>
103+ <Tr>
104+ <Td mono>browser:use</Td>
105+ <Td>
106+ Reserved for the managed browser (<A href="/docs/browser">coming soon</A>). Can be granted today but unlocks nothing yet.
107+ </Td>
108+ <Td>Reserved</Td>
109+ </Tr>
110+ </TBody>
111+ </Table>
112+ <P>
113+ <Code>GET /v1/me</Code> and <Code>GET /v1/sessions</Code> only require a valid key.
114+ </P>
115+
116+ <H2>Email verification</H2>
117+ <P>
118+ API keys work only once the organization owner&apos;s email address is verified. Until then every authenticated call, including <Code>/v1/me</Code>, fails with{" "}
119+ <Code>403 EMAIL_NOT_VERIFIED</Code>. Resend the verification email from the dashboard if you did not receive it. The dashboard Playground is exempt so you can evaluate the product before
120+ verifying.
121+ </P>
122+ <ResponseExample
123+ status={403}
124+ statusText="Forbidden"
125+ body={{ error: { code: "EMAIL_NOT_VERIFIED", message: "Verify your email address before using the production API.", request_id: "req_7f2c1a9e0d4b6h3k" } }}
126+ />
127+
128+ <H2>Rotation and revocation</H2>
129+ <Ul>
130+ <Li>
131+ <Strong>Rotate</Strong> replaces the secret of an existing key and shows the new plaintext once. The old plaintext stops working immediately.
132+ </Li>
133+ <Li>
134+ <Strong>Revoke</Strong> disables a key permanently. Requests made with it return <Code>401 INVALID_API_KEY</Code> with the message <Code>This API key was revoked.</Code>
135+ </Li>
136+ <Li>
137+ <Strong>Expiry</Strong> is optional and set in days at creation. Expired keys return <Code>401 INVALID_API_KEY</Code> with <Code>This API key has expired.</Code>
138+ </Li>
139+ </Ul>
140+ <P>
141+ Authenticated principals are cached for up to 30 seconds per API process. The dashboard invalidates the cache when you rotate or revoke, so changes normally apply at once; in the rare case
142+ the invalidation is lost, a stale key stops working within 30 seconds.
143+ </P>
144+ <P>
145+ Other conditions that block a key even when it is valid: the project was archived (<Code>403 FORBIDDEN</Code>), the organization was suspended (<Code>403 FORBIDDEN</Code>, contact{" "}
146+ <A href="mailto:support@fetcha.co">support@fetcha.co</A>).
147+ </P>
148+
149+ <H2>Checking a key</H2>
150+ <P>
151+ <Code>GET /v1/me</Code> validates the key and returns the project, organization and key metadata it resolves to. It is the cheapest call you can make and a good health check for your
152+ configuration.
153+ </P>
154+ <Endpoint method="GET" path="/v1/me" scope={null} status="Live" />
155+ <CodeTabs tabs={apiTabs({ method: "GET", path: "/v1/me" })} />
156+ <ResponseExample
157+ status={200}
158+ body={{
159+ project: { id: "proj_2k8d1m3p9q4r7s6t", name: "Default" },
160+ organization: { id: "org_9a1b2c3d4e5f6g7h", name: "Acme Data", plan: "developer" },
161+ key: { id: "key_5t6y7u8i9o0p1a2s", name: "backend", mode: "live", scopes: ["fetch:execute", "sessions:write", "usage:read"] },
162+ }}
163+ />
164+
165+ <H2>Logging and redaction</H2>
166+ <P>
167+ Fetcha never logs your API key. On the request log, <Code>Authorization</Code>, <Code>Cookie</Code>, <Code>Set-Cookie</Code>, <Code>X-API-Key</Code> and similar headers that you send to
168+ targets are stored as <Code>[redacted]</Code> when your project log level includes headers. Response bodies are not stored by default.
169+ </P>
170+ </DocPage>
171+ );
172+}
added apps/web/src/app/(marketing)/docs/browser/page.tsx +133 −0
@@ -0,0 +1,133 @@
1+import type { Metadata } from "next";
2+import { CodeBlock } from "@/components/ui/code-block";
3+import { DocPage } from "@/components/docs/doc-page";
4+import { A, Code, H2, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
5+import { Callout, ComingSoon } from "@/components/docs/callout";
6+import { Endpoint } from "@/components/docs/endpoint";
7+import { ResponseExample } from "@/components/docs/response-example";
8+
9+export const metadata: Metadata = {
10+ title: "Browser",
11+ description: "Managed browser rendering is coming soon. This page describes the planned browser: true option and POST /v1/browser actions, and what the API returns today.",
12+};
13+
14+const ACTIONS: Array<[string, string, string]> = [
15+ ["goto", "{ url }", "Navigate to a URL and wait for the load event."],
16+ ["click", "{ selector }", "Click the first element matching a CSS selector."],
17+ ["type", "{ selector, text, delay? }", "Focus an element and type text, optionally with a per-key delay."],
18+ ["scroll", "{ selector? | y? }", "Scroll an element into view, or scroll the page by a number of pixels."],
19+ ["wait", "{ selector? | ms? }", "Wait for a selector to appear, or for a fixed duration."],
20+ ["evaluate", "{ script }", "Run JavaScript in the page and capture its return value."],
21+ ["screenshot", "{ full_page?, selector? }", "Capture a PNG of the viewport, the full page or one element."],
22+ ["extract", "{ schema }", "Return structured data from the rendered DOM (shares the Extraction schema format)."],
23+];
24+
25+export default function BrowserPage() {
26+ return (
27+ <DocPage path="/docs/browser" eyebrow="Core API" title="Browser" description="Render JavaScript-heavy pages and drive multi-step interactions in a managed browser that inherits Fetcha's routing, geography and sessions." status="Coming soon">
28+ <ComingSoon title="Managed browser is not yet available">
29+ Nothing on this page is callable today. <Code>browser: true</Code> on <Code>/v1/fetch</Code> returns <Code>400 BROWSER_UNAVAILABLE</Code> and <Code>POST /v1/browser</Code> returns the same
30+ code. The design below is what we are building; field names may change before launch. Follow the <A href="/changelog">changelog</A> for the release.
31+ </ComingSoon>
32+
33+ <H2>Current behaviour</H2>
34+ <P>
35+ The API already reserves the surface so that clients can be written against it. Today both entry points fail fast, before any network activity, and the request is not counted as an
36+ attempt:
37+ </P>
38+ <Endpoint method="POST" path="/v1/fetch { browser: true }" status="Coming soon" />
39+ <ResponseExample status={400} statusText="Bad Request" body={{ error: { code: "BROWSER_UNAVAILABLE", message: "Managed browser execution is not yet available.", request_id: "req_5d6e7f8g9h0i1j2k" } }} />
40+ <Endpoint method="POST" path="/v1/browser" status="Coming soon" />
41+ <ResponseExample
42+ status={400}
43+ statusText="Bad Request"
44+ body={{ error: { code: "BROWSER_UNAVAILABLE", message: "Browser actions are not yet available. Follow the changelog at https://www.fetcha.co/changelog.", request_id: "req_5d6e7f8g9h0i1j2k" } }}
45+ />
46+ <P>
47+ The fetch schema already accepts the companion fields <Code>javascript</Code>, <Code>wait_for</Code> and <Code>wait_ms</Code>; they are validated and ignored. The <Code>browser:use</Code> API
48+ key scope exists and can be granted, but unlocks nothing yet.
49+ </P>
50+
51+ <H2>Planned: rendered fetch</H2>
52+ <P>
53+ The simplest form will be a flag on the existing fetch request. Fetcha will open the URL in a headless browser routed through the same network class, geography and session as a normal fetch,
54+ wait for the page to settle, and return the rendered HTML in the usual response document.
55+ </P>
56+ <CodeBlock
57+ lang="json"
58+ title="Planned request"
59+ code={JSON.stringify({ url: "https://app.example.com/dashboard", browser: true, wait_for: "table.results", wait_ms: 500, country: "CA", session: "sess_…", format: "text" }, null, 2)}
60+ />
61+ <Ul>
62+ <Li>
63+ <Code>wait_for</Code>: CSS selector that must be present before the DOM is captured.
64+ </Li>
65+ <Li>
66+ <Code>wait_ms</Code>: extra settle time after load or after the selector appears (0–30,000 ms).
67+ </Li>
68+ <Li>
69+ <Code>javascript</Code>: set to <Code>false</Code> to render with scripting disabled.
70+ </Li>
71+ <Li>
72+ Response: same shape as today, with <Code>content</Code> holding the post-render DOM and <Code>metadata.timing</Code> gaining a browser phase.
73+ </Li>
74+ <Li>
75+ Errors: <Code>504 BROWSER_TIMEOUT</Code> when the page does not settle within <Code>timeout</Code>. The code is already defined.
76+ </Li>
77+ </Ul>
78+
79+ <H2>Planned: browser actions</H2>
80+ <P>
81+ For interactions that need several steps, <Code>POST /v1/browser</Code> will accept an ordered list of actions executed in one browser context. Each action returns its own result and the
82+ final response includes the DOM, cookies and any screenshots.
83+ </P>
84+ <Table>
85+ <THead>
86+ <Tr>
87+ <Th>Action</Th>
88+ <Th>Parameters</Th>
89+ <Th>Description</Th>
90+ </Tr>
91+ </THead>
92+ <TBody>
93+ {ACTIONS.map(([name, params, desc]) => (
94+ <Tr key={name}>
95+ <Td mono>{name}</Td>
96+ <Td mono>{params}</Td>
97+ <Td>{desc}</Td>
98+ </Tr>
99+ ))}
100+ </TBody>
101+ </Table>
102+ <CodeBlock
103+ lang="json"
104+ title="Planned request"
105+ code={JSON.stringify(
106+ {
107+ url: "https://www.example.ca/login",
108+ country: "CA",
109+ session: "sess_8f2k1m9d3p7q4r6s",
110+ actions: [
111+ { type: "type", selector: "#email", text: "me@example.com" },
112+ { type: "type", selector: "#password", text: "•••••••" },
113+ { type: "click", selector: "button[type=submit]" },
114+ { type: "wait", selector: ".account-summary" },
115+ { type: "screenshot", full_page: false },
116+ { type: "extract", schema: { balance: "string", plan: "string" } },
117+ ],
118+ },
119+ null,
120+ 2,
121+ )}
122+ />
123+ <Callout variant="info" title="Pricing and limits">
124+ Browser requests will be priced separately from plain fetches and subject to their own concurrency limit; they require the <Code>browser:use</Code> scope. Details will be published with
125+ the launch. Until then, sites that need JavaScript are best handled by calling their underlying JSON endpoints with <Code>{`format: "json"`}</Code>; see{" "}
126+ <Strong>
127+ <A href="/docs/examples">Examples</A>
128+ </Strong>
129+ .
130+ </Callout>
131+ </DocPage>
132+ );
133+}
added apps/web/src/app/(marketing)/docs/errors/page.tsx +243 −0
@@ -0,0 +1,243 @@
1+import type { Metadata } from "next";
2+import { ERROR_CODES, ERROR_HTTP_STATUS, ERROR_MESSAGES, type ErrorCode } from "@fetcha/core";
3+import { CodeBlock } from "@/components/ui/code-block";
4+import { DocPage } from "@/components/docs/doc-page";
5+import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
6+import { Callout } from "@/components/docs/callout";
7+import { ParamTable } from "@/components/docs/param-table";
8+import { ResponseExample } from "@/components/docs/response-example";
9+import { CodeTabs } from "@/components/docs/code-tabs";
10+
11+export const metadata: Metadata = {
12+ title: "Errors",
13+ description: "Error envelope, every error code with its HTTP status and meaning, validation details, Retry-After, request IDs and error handling in the SDKs.",
14+};
15+
16+/** Extra guidance per code, beyond the canonical message. */
17+const NOTES: Record<ErrorCode, { retry: "no" | "after-fix" | "backoff" | "later"; note: string }> = {
18+ INVALID_API_KEY: { retry: "after-fix", note: "Missing header, malformed token, wrong prefix, revoked or expired key. Check the message for the exact reason." },
19+ EMAIL_NOT_VERIFIED: { retry: "after-fix", note: "Verify the organization owner's email address in the dashboard, then retry." },
20+ RATE_LIMITED: { retry: "backoff", note: "A sliding-window limit was hit. Honour the Retry-After header (seconds); details.retry_after_ms has the precise value." },
21+ CONCURRENCY_LIMIT: { retry: "backoff", note: "Too many in-flight requests for the organization. details.limit is your plan's concurrency. Queue and retry when a request finishes." },
22+ INVALID_REQUEST: { retry: "after-fix", note: "Schema violation, unknown field, malformed JSON, wrong Content-Type or body over 4 MB. details.issues lists each problem." },
23+ URL_NOT_ALLOWED: { retry: "no", note: "Non-http scheme, credentials in URL, or a host that is local, private, link-local, metadata or internal — on the initial URL or a redirect hop." },
24+ TARGET_TIMEOUT: { retry: "later", note: "The request budget (timeout) was exhausted across all attempts. Raise timeout within your plan cap, or retry later." },
25+ TARGET_BLOCKED: { retry: "later", note: "Recorded in the request log when every attempt was blocked. On the API this surfaces as a 200 with success:false and the last blocked page." },
26+ TARGET_UNAVAILABLE: { retry: "later", note: "DNS resolution failed, no address records, or the connection was refused on every attempt." },
27+ PROVIDER_UNAVAILABLE: { retry: "backoff", note: "No route is available for an auto request (all circuits open or nothing configured for the geography). Transient; retry with backoff." },
28+ NETWORK_UNAVAILABLE: { retry: "after-fix", note: "Explicit class not in your plan, not live yet, or without a sticky-capable route (sessions). Use auto or residential." },
29+ BROWSER_UNAVAILABLE: { retry: "no", note: "browser: true or POST /v1/browser. Managed browser is not yet available." },
30+ BROWSER_TIMEOUT: { retry: "later", note: "Reserved for the managed browser. Not emitted today." },
31+ RESPONSE_TOO_LARGE: { retry: "after-fix", note: "Body exceeded max_response_bytes or the 20 MB platform cap. Not retried across routes." },
32+ TOO_MANY_REDIRECTS: { retry: "after-fix", note: "More than max_redirects hops. Not retried across routes." },
33+ INSUFFICIENT_CREDITS: { retry: "after-fix", note: "Reserved for prepaid balances. Not emitted today." },
34+ USAGE_LIMIT_REACHED: { retry: "after-fix", note: "Monthly plan quota, project request limit, or a hard spending limit (organization or project). details tells which; see Rate limits." },
35+ SESSION_NOT_FOUND: { retry: "after-fix", note: "Unknown session id, or a session that belongs to another project." },
36+ SESSION_EXPIRED: { retry: "after-fix", note: "Session past expires_at or closed. Create a new one." },
37+ NOT_FOUND: { retry: "no", note: "Unknown route or resource. The message names the route." },
38+ FORBIDDEN: { retry: "after-fix", note: "Key lacks the required scope, project archived, or organization/account suspended." },
39+ INTERNAL_ERROR: { retry: "backoff", note: "Unexpected failure inside Fetcha. Retry with backoff; if it persists, send us the request_id." },
40+};
41+
42+const RETRY_LABEL: Record<(typeof NOTES)[ErrorCode]["retry"], string> = {
43+ no: "Do not retry",
44+ "after-fix": "Fix, then retry",
45+ backoff: "Retry with backoff",
46+ later: "Retry later",
47+};
48+
49+const GROUPS: Array<{ title: string; codes: ErrorCode[] }> = [
50+ { title: "Authentication and authorization", codes: ["INVALID_API_KEY", "EMAIL_NOT_VERIFIED", "FORBIDDEN"] },
51+ { title: "Request validation", codes: ["INVALID_REQUEST", "URL_NOT_ALLOWED", "NETWORK_UNAVAILABLE", "BROWSER_UNAVAILABLE", "NOT_FOUND"] },
52+ { title: "Limits and quotas", codes: ["RATE_LIMITED", "CONCURRENCY_LIMIT", "USAGE_LIMIT_REACHED", "INSUFFICIENT_CREDITS"] },
53+ { title: "Target and network", codes: ["TARGET_TIMEOUT", "TARGET_BLOCKED", "TARGET_UNAVAILABLE", "PROVIDER_UNAVAILABLE", "RESPONSE_TOO_LARGE", "TOO_MANY_REDIRECTS", "BROWSER_TIMEOUT"] },
54+ { title: "Sessions", codes: ["SESSION_NOT_FOUND", "SESSION_EXPIRED"] },
55+ { title: "Server", codes: ["INTERNAL_ERROR"] },
56+];
57+
58+const JS_HANDLING = `import { Fetcha, FetchaError } from "@fetcha/sdk";
59+
60+const fetcha = new Fetcha({ apiKey: process.env.FETCHA_API_KEY! });
61+
62+try {
63+ const r = await fetcha.fetch({ url: "https://example.com", country: "CA" });
64+ if (!r.success) {
65+ // Target answered but with an error or a block page: r.status, r.metadata.attempts
66+ console.warn("target status", r.status, "after", r.metadata.attempts, "attempts");
67+ }
68+} catch (e) {
69+ if (e instanceof FetchaError) {
70+ // e.code, e.status (HTTP), e.message, e.requestId
71+ switch (e.code) {
72+ case "RATE_LIMITED":
73+ case "CONCURRENCY_LIMIT":
74+ case "PROVIDER_UNAVAILABLE":
75+ // back off and retry
76+ break;
77+ case "INVALID_REQUEST":
78+ // fix the request; details are in e.message
79+ break;
80+ default:
81+ console.error(\`\${e.code} (\${e.status}) \${e.message} — request \${e.requestId}\`);
82+ }
83+ } else {
84+ throw e; // network failure reaching Fetcha, JSON parse error, abort
85+ }
86+}`;
87+
88+const PY_HANDLING = `from fetcha import Fetcha, FetchaError
89+
90+client = Fetcha(api_key=os.environ["FETCHA_API_KEY"])
91+
92+try:
93+ r = client.fetch("https://example.com", country="CA")
94+ if not r.success:
95+ # Target answered but with an error or a block page
96+ print("target status", r.status, "after", r.metadata["attempts"], "attempts")
97+except FetchaError as e:
98+ # e.code, e.status (HTTP), e.message, e.request_id
99+ if e.code in ("RATE_LIMITED", "CONCURRENCY_LIMIT", "PROVIDER_UNAVAILABLE"):
100+ ... # back off and retry
101+ elif e.code == "INVALID_REQUEST":
102+ ... # fix the request
103+ else:
104+ print(f"{e.code} ({e.status}) {e.message} — request {e.request_id}")`;
105+
106+export default function ErrorsPage() {
107+ const all: ErrorCode[] = [...ERROR_CODES];
108+ return (
109+ <DocPage path="/docs/errors" eyebrow="Reliability" title="Errors" description="Fetcha uses conventional HTTP status codes and a single JSON envelope for every error. Each error carries a stable machine-readable code and the request ID." status="Stable">
110+ <H2>Error envelope</H2>
111+ <P>
112+ Every non-2xx response from the API has this shape. <Code>code</Code> is stable and meant for programmatic handling; <Code>message</Code> is human-readable and may change;{" "}
113+ <Code>details</Code> is present only for some codes.
114+ </P>
115+ <ResponseExample
116+ status={429}
117+ statusText="Too Many Requests"
118+ body={{ error: { code: "RATE_LIMITED", message: "Too many requests. Slow down or upgrade your plan.", request_id: "req_4e5f6g7h8i9j0k1l", details: { retry_after_ms: 640 } } }}
119+ />
120+ <ParamTable
121+ showDefault={false}
122+ rows={[
123+ { name: "error.code", type: "string", description: <>One of the {all.length} codes below.</> },
124+ { name: "error.message", type: "string", description: <>Explanation for humans. Often more specific than the default message (for example which scope is missing or which plan lacks a network).</> },
125+ { name: "error.request_id", type: "string | null", description: <>The request identifier, also in the <code>X-Fetcha-Request-ID</code> header.</> },
126+ { name: "error.details", type: "object", description: <>Optional structured context: <code>issues</code> for validation, <code>retry_after_ms</code> for rate limits, <code>limit</code> for concurrency, <code>limit</code>/<code>used</code> or <code>limit_usd</code>/<code>spent_usd</code> for quotas.</> },
127+ ]}
128+ />
129+ <Callout variant="info" title="A 200 is not always a success">
130+ <Code>/v1/fetch</Code> returns <Code>200</Code> whenever Fetcha obtained a response from the target, including a 404, a 500 or a block page. Check <Code>success</Code> and <Code>status</Code>{" "}
131+ in the body. Error envelopes are used only when Fetcha itself could not complete the request. See <A href="/docs/fetch#blocked-targets">Blocked targets</A>.
132+ </Callout>
133+
134+ <H2>Error codes</H2>
135+ <P>The status, code and default message come straight from the API&apos;s error catalogue. The last two columns are guidance.</P>
136+ {GROUPS.map((g) => (
137+ <div key={g.title}>
138+ <H3>{g.title}</H3>
139+ <Table dense>
140+ <THead>
141+ <Tr>
142+ <Th>Status</Th>
143+ <Th>Code</Th>
144+ <Th>Default message and notes</Th>
145+ <Th>Retry</Th>
146+ </Tr>
147+ </THead>
148+ <TBody>
149+ {g.codes.map((code) => (
150+ <Tr key={code}>
151+ <Td mono className="whitespace-nowrap">
152+ {ERROR_HTTP_STATUS[code]}
153+ </Td>
154+ <Td mono className="whitespace-nowrap">
155+ {code}
156+ </Td>
157+ <Td>
158+ <div className="text-fg">{ERROR_MESSAGES[code]}</div>
159+ <div className="mt-0.5 text-[12.5px] text-fg-muted">{NOTES[code].note}</div>
160+ </Td>
161+ <Td className="whitespace-nowrap text-[12.5px]">{RETRY_LABEL[NOTES[code].retry]}</Td>
162+ </Tr>
163+ ))}
164+ </TBody>
165+ </Table>
166+ </div>
167+ ))}
168+
169+ <H2>Validation details</H2>
170+ <P>
171+ <Code>INVALID_REQUEST</Code> raised by schema validation includes <Code>details.issues</Code>, an array with one entry per problem. <Code>path</Code> is the dotted path of the offending field
172+ (empty for unknown top-level keys), <Code>message</Code> explains the constraint.
173+ </P>
174+ <ResponseExample
175+ status={400}
176+ statusText="Bad Request"
177+ body={{
178+ error: {
179+ code: "INVALID_REQUEST",
180+ message: "The request body is invalid.",
181+ request_id: "req_9c1d2e3f4a5b6c7d",
182+ details: {
183+ issues: [
184+ { path: "url", message: "String must contain at least 1 character(s)" },
185+ { path: "country", message: "String must contain exactly 2 character(s)" },
186+ { path: "max_redirects", message: "Number must be less than or equal to 20" },
187+ { path: "", message: "Unrecognized key(s) in object: 'proxy'" },
188+ ],
189+ },
190+ },
191+ }}
192+ />
193+ <P>Other request-level failures reuse the same code with a specific message and no <Code>issues</Code>:</P>
194+ <Ul>
195+ <Li>
196+ <Code>Malformed JSON body.</Code>
197+ </Li>
198+ <Li>
199+ <Code>Send a JSON body with Content-Type: application/json.</Code>
200+ </Li>
201+ <Li>
202+ <Code>Request body too large.</Code> (HTTP 413)
203+ </Li>
204+ </Ul>
205+
206+ <H2>Retry-After</H2>
207+ <P>
208+ <Code>RATE_LIMITED</Code> responses include a <Code>Retry-After</Code> header in whole seconds (rounded up from <Code>details.retry_after_ms</Code>). Honour it. <Code>CONCURRENCY_LIMIT</Code>{" "}
209+ does not carry the header because the right moment to retry is when one of your own in-flight requests completes.
210+ </P>
211+ <CodeBlock
212+ lang="text"
213+ code={`HTTP/1.1 429 Too Many Requests
214+Retry-After: 1
215+X-Fetcha-Request-ID: req_4e5f6g7h8i9j0k1l
216+Content-Type: application/json; charset=utf-8`}
217+ />
218+
219+ <H2>Request IDs and support</H2>
220+ <P>
221+ Every response has an <Code>X-Fetcha-Request-ID</Code>; error envelopes repeat it in <Code>error.request_id</Code>. Log it alongside your own correlation id. When you contact{" "}
222+ <A href="mailto:support@fetcha.co">support@fetcha.co</A>, include the request id, the approximate time (UTC) and the target domain: with the id we can see every attempt, the route used, the
223+ outcome and the timing, without you sharing any content.
224+ </P>
225+ <P>
226+ <Strong>Retention:</Strong> request metadata is kept for the retention period of your plan (3 to 365 days). Response bodies are not stored, and sensitive request headers are redacted before
227+ logging.
228+ </P>
229+
230+ <H2>Handling errors in the SDKs</H2>
231+ <P>
232+ Both SDKs raise a <Code>FetchaError</Code> for any non-2xx response, exposing <Code>code</Code>, <Code>status</Code>, <Code>message</Code> and the request id. A fetch that reached the target
233+ never throws: inspect <Code>success</Code> instead. See <A href="/docs/sdks">SDKs</A>.
234+ </P>
235+ <CodeTabs
236+ tabs={[
237+ { label: "JavaScript", lang: "typescript", code: JS_HANDLING },
238+ { label: "Python", lang: "python", code: PY_HANDLING },
239+ ]}
240+ />
241+ </DocPage>
242+ );
243+}
added apps/web/src/app/(marketing)/docs/examples/page.tsx +448 −0
@@ -0,0 +1,448 @@
1+import type { Metadata } from "next";
2+import { DocPage } from "@/components/docs/doc-page";
3+import { A, Code, H2, Li, P, Strong, Ul } from "@/components/docs/prose";
4+import { Callout } from "@/components/docs/callout";
5+import { CodeTabs } from "@/components/docs/code-tabs";
6+
7+export const metadata: Metadata = {
8+ title: "Examples",
9+ description: "Eight practical recipes: text extraction, JSON APIs through a Canadian residential IP, sticky login flows, POST bodies, headers and cookies, handling blocks, concurrency and debugging.",
10+};
11+
12+const R1_JS = `import { Fetcha } from "@fetcha/sdk";
13+const fetcha = new Fetcha({ apiKey: process.env.FETCHA_API_KEY! });
14+
15+const r = await fetcha.fetch({
16+ url: "https://www.example.ca/products/42",
17+ country: "CA",
18+ format: "text",
19+});
20+
21+if (!r.success) throw new Error(\`target answered \${r.status} after \${r.metadata.attempts} attempts\`);
22+console.log(r.text); // readable text: scripts, styles and tags removed
23+console.log(r.headers["last-modified"]);`;
24+
25+const R1_PY = `from fetcha import Fetcha
26+client = Fetcha(api_key=os.environ["FETCHA_API_KEY"])
27+
28+r = client.fetch("https://www.example.ca/products/42", country="CA", format="text")
29+
30+if not r.success:
31+ raise RuntimeError(f"target answered {r.status} after {r.metadata['attempts']} attempts")
32+print(r.text) # readable text
33+print(r.headers.get("last-modified"))`;
34+
35+const R2_JS = `type Listing = { id: string; price: number; currency: string };
36+
37+const data = await fetcha.json<{ items: Listing[] }>(
38+ "https://api.example.ca/v2/listings?city=montreal&page=1",
39+ { country: "CA", network: "residential", headers: { Accept: "application/json" } },
40+);
41+
42+for (const item of data.items) console.log(item.id, item.price, item.currency);`;
43+
44+const R2_PY = `data = client.json(
45+ "https://api.example.ca/v2/listings?city=montreal&page=1",
46+ country="CA",
47+ network="residential",
48+ headers={"Accept": "application/json"},
49+)
50+for item in data["items"]:
51+ print(item["id"], item["price"], item["currency"])`;
52+
53+const R3_JS = `// 1. One session for the whole flow (15 minutes, Canadian exit)
54+const session = await fetcha.sessions.create({ country: "CA", ttl: 900, label: "login:user-42" }, "login:user-42");
55+
56+// 2. Log in. No retries: a replayed login POST could lock the account.
57+const login = await fetcha.fetch({
58+ url: "https://www.example.ca/login",
59+ method: "POST",
60+ session: session.id,
61+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
62+ body: "email=me%40example.com&password=secret",
63+ retries: 0,
64+});
65+if (!login.success) throw new Error(\`login failed with \${login.status}\`);
66+
67+// 3. Carry the cookies forward and read the protected page with the same identity
68+const jar = Object.fromEntries(login.cookies.map((c) => [c.name, c.value]));
69+const account = await fetcha.fetch({
70+ url: "https://www.example.ca/account",
71+ session: session.id,
72+ cookies: jar,
73+ format: "text",
74+});
75+console.log(account.text);
76+
77+await fetcha.sessions.close(session.id);`;
78+
79+const R3_PY = `# 1. One session for the whole flow (15 minutes, Canadian exit)
80+session = client.sessions.create(country="CA", ttl=900, label="login:user-42")
81+
82+# 2. Log in. No retries: a replayed login POST could lock the account.
83+login = client.fetch(
84+ "https://www.example.ca/login",
85+ method="POST",
86+ session=session.id,
87+ headers={"Content-Type": "application/x-www-form-urlencoded"},
88+ body="email=me%40example.com&password=secret",
89+ retries=0,
90+)
91+if not login.success:
92+ raise RuntimeError(f"login failed with {login.status}")
93+
94+# 3. Carry the cookies forward and read the protected page with the same identity
95+jar = {c["name"]: c["value"] for c in login.cookies}
96+account = client.fetch("https://www.example.ca/account", session=session.id, cookies=jar, format="text")
97+print(account.text)
98+
99+client.sessions.close(session.id)`;
100+
101+const R4_JS = `const r = await fetcha.fetch({
102+ url: "https://api.example.com/v1/search",
103+ method: "POST",
104+ // Objects are JSON-serialised; Content-Type defaults to application/json
105+ body: { query: "standing desk", filters: { in_stock: true, max_price: 500 }, page: 1 },
106+ format: "json",
107+ retries: 0, // POST: decide about retries yourself
108+});
109+
110+if (r.success && r.json !== undefined) {
111+ console.log((r.json as { total: number }).total);
112+} else {
113+ console.log("status", r.status, "body", r.content?.slice(0, 300));
114+}`;
115+
116+const R4_PY = `r = client.fetch(
117+ "https://api.example.com/v1/search",
118+ method="POST",
119+ # dicts are JSON-serialised; Content-Type defaults to application/json
120+ body={"query": "standing desk", "filters": {"in_stock": True, "max_price": 500}, "page": 1},
121+ format="json",
122+ retries=0, # POST: decide about retries yourself
123+)
124+
125+if r.success and r.json is not None:
126+ print(r.json["total"])
127+else:
128+ print("status", r.status, "body", (r.content or "")[:300])`;
129+
130+const R5_JS = `const r = await fetcha.fetch({
131+ url: "https://www.example.com/pricing",
132+ headers: {
133+ // Overrides Fetcha's default desktop Chrome UA
134+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36",
135+ Referer: "https://www.google.com/",
136+ "X-Requested-With": "XMLHttpRequest",
137+ },
138+ // Sent as a single Cookie header, appended to any Cookie header above
139+ cookies: { currency: "CAD", consent: "1" },
140+ locale: "fr-CA", // Accept-Language
141+ device: "mobile", // iPhone UA unless you set User-Agent yourself (as above)
142+});
143+
144+console.log(r.status, r.cookies); // cookies the target set in return`;
145+
146+const R5_PY = `r = client.fetch(
147+ "https://www.example.com/pricing",
148+ headers={
149+ # Overrides Fetcha's default desktop Chrome UA
150+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36",
151+ "Referer": "https://www.google.com/",
152+ "X-Requested-With": "XMLHttpRequest",
153+ },
154+ # Sent as a single Cookie header, appended to any Cookie header above
155+ cookies={"currency": "CAD", "consent": "1"},
156+ locale="fr-CA", # Accept-Language
157+ device="mobile", # iPhone UA unless you set User-Agent yourself (as above)
158+)
159+print(r.status, r.cookies) # cookies the target set in return`;
160+
161+const R6_JS = `import { Fetcha, FetchaError } from "@fetcha/sdk";
162+
163+async function fetchOrExplain(url: string) {
164+ try {
165+ const r = await fetcha.fetch({ url, country: "US", debug: true });
166+
167+ if (r.success) return r;
168+
169+ // Fetcha reached the target but every attempt was blocked (or the origin returned an error).
170+ const blocked = r.metadata.attempts > 1 || [403, 429].includes(r.status);
171+ if (blocked) {
172+ console.warn(\`blocked: status \${r.status} after \${r.metadata.attempts} attempts\`, r.metadata.debug);
173+ // Options: retry later, try another country, or a fresh session.
174+ return await fetcha.fetch({ url, country: "CA" });
175+ }
176+ console.warn(\`origin error \${r.status}\`); // plain 404/500: not a block, not retried
177+ return r;
178+ } catch (e) {
179+ if (e instanceof FetchaError && e.code === "TARGET_TIMEOUT") {
180+ console.warn("timed out across all attempts", e.requestId);
181+ return null;
182+ }
183+ throw e;
184+ }
185+}`;
186+
187+const R6_PY = `from fetcha import FetchaError
188+
189+def fetch_or_explain(url: str):
190+ try:
191+ r = client.fetch(url, country="US", debug=True)
192+ except FetchaError as e:
193+ if e.code == "TARGET_TIMEOUT":
194+ print("timed out across all attempts", e.request_id)
195+ return None
196+ raise
197+
198+ if r.success:
199+ return r
200+
201+ # Fetcha reached the target but every attempt was blocked (or the origin returned an error).
202+ blocked = r.metadata["attempts"] > 1 or r.status in (403, 429)
203+ if blocked:
204+ print(f"blocked: status {r.status} after {r.metadata['attempts']} attempts", r.metadata.get("debug"))
205+ # Options: retry later, try another country, or a fresh session.
206+ return client.fetch(url, country="CA")
207+ print(f"origin error {r.status}") # plain 404/500: not a block, not retried
208+ return r`;
209+
210+const R7_JS = `// Plan concurrency: Free 5, Developer 25, Growth 100, Business 500.
211+// Read it once from the API instead of hardcoding it.
212+const usage = (await fetcha.usage.current()) as { plan: { concurrency: number } };
213+const limit = Math.max(1, usage.plan.concurrency - 1); // leave one slot for other workers
214+
215+async function mapWithConcurrency<T, R>(items: T[], n: number, fn: (t: T) => Promise<R>): Promise<R[]> {
216+ const out: R[] = new Array(items.length);
217+ let i = 0;
218+ await Promise.all(
219+ Array.from({ length: n }, async () => {
220+ while (i < items.length) {
221+ const idx = i++;
222+ out[idx] = await fn(items[idx]!);
223+ }
224+ }),
225+ );
226+ return out;
227+}
228+
229+const urls = ["https://example.com/1", "https://example.com/2" /* … */];
230+const results = await mapWithConcurrency(urls, limit, async (url) => {
231+ try {
232+ return await fetcha.fetch({ url, country: "CA", format: "text" });
233+ } catch (e) {
234+ if (e instanceof FetchaError && e.code === "CONCURRENCY_LIMIT") {
235+ // Another process is using slots: wait briefly and try once more
236+ await new Promise((r) => setTimeout(r, 500));
237+ return await fetcha.fetch({ url, country: "CA", format: "text" });
238+ }
239+ throw e;
240+ }
241+});`;
242+
243+const R7_PY = `from concurrent.futures import ThreadPoolExecutor
244+import time
245+from fetcha import FetchaError
246+
247+# Plan concurrency: Free 5, Developer 25, Growth 100, Business 500.
248+limit = max(1, client.usage()["plan"]["concurrency"] - 1) # leave one slot for other workers
249+
250+def one(url: str):
251+ for attempt in range(2):
252+ try:
253+ return client.fetch(url, country="CA", format="text")
254+ except FetchaError as e:
255+ if e.code == "CONCURRENCY_LIMIT" and attempt == 0:
256+ time.sleep(0.5) # another process is using slots
257+ continue
258+ raise
259+
260+urls = ["https://example.com/1", "https://example.com/2"] # …
261+with ThreadPoolExecutor(max_workers=limit) as pool:
262+ results = list(pool.map(one, urls))`;
263+
264+const R8_JS = `const r = await fetcha.fetch({ url: "https://www.example.com/", country: "US", debug: true });
265+
266+const m = r.metadata;
267+console.table({
268+ network: m.network, // class that served the final attempt
269+ country: m.country,
270+ attempts: m.attempts, // > 1 means a retry or escalation happened
271+ duration_ms: m.duration_ms, // whole request, all attempts
272+ bytes: m.bytes, // all attempts; basis for bandwidth pricing
273+ origin_ms: m.timing?.origin_ms, // time to first byte on the final attempt
274+ processing_ms: m.timing?.processing_ms,
275+});
276+
277+// One line per attempt: route alias, outcome, target status, duration
278+for (const a of (m.debug as { attempts: Array<Record<string, unknown>> } | undefined)?.attempts ?? []) {
279+ console.log(a.provider, a.network, a.outcome, a.status, \`\${a.duration_ms}ms\`);
280+}
281+// e.g. network-a residential blocked 403 1210ms
282+// network-b residential success 200 1888ms
283+
284+console.log("request id for support:", r.request_id);`;
285+
286+const R8_PY = `r = client.fetch("https://www.example.com/", country="US", debug=True)
287+
288+m = r.metadata
289+print({
290+ "network": m["network"], # class that served the final attempt
291+ "country": m["country"],
292+ "attempts": m["attempts"], # > 1 means a retry or escalation happened
293+ "duration_ms": m["duration_ms"], # whole request, all attempts
294+ "bytes": m["bytes"], # all attempts; basis for bandwidth pricing
295+ "origin_ms": m["timing"]["origin_ms"],
296+ "processing_ms": m["timing"]["processing_ms"],
297+})
298+
299+# One line per attempt: route alias, outcome, target status, duration
300+for a in m.get("debug", {}).get("attempts", []):
301+ print(a["provider"], a["network"], a["outcome"], a["status"], f"{a['duration_ms']}ms")
302+# e.g. network-a residential blocked 403 1210ms
303+# network-b residential success 200 1888ms
304+
305+print("request id for support:", r.request_id)`;
306+
307+function Recipe({ title, id, intro, js, py, children }: { title: string; id: string; intro: React.ReactNode; js: string; py: string; children?: React.ReactNode }) {
308+ return (
309+ <section>
310+ <H2 id={id}>{title}</H2>
311+ <P>{intro}</P>
312+ <CodeTabs
313+ tabs={[
314+ { label: "JavaScript", lang: "typescript", code: js },
315+ { label: "Python", lang: "python", code: py },
316+ ]}
317+ />
318+ {children}
319+ </section>
320+ );
321+}
322+
323+export default function ExamplesPage() {
324+ return (
325+ <DocPage path="/docs/examples" eyebrow="Tools" title="Examples" description="Practical recipes using the JavaScript and Python SDKs. Each one is complete: copy it, set FETCHA_API_KEY and run." status="Stable">
326+ <P>
327+ The snippets assume a client created as in the <A href="/docs/sdks">SDKs</A> page: <Code>const fetcha = new Fetcha({"{ apiKey }"})</Code> or <Code>client = Fetcha(api_key=…)</Code>. The
328+ SDKs are installed from source for now.
329+ </P>
330+
331+ <Recipe
332+ id="fetch-a-product-page-as-text"
333+ title="1. Fetch a product page as text"
334+ intro={
335+ <>
336+ The most common call. <Code>{`format: "text"`}</Code> strips scripts, styles and markup and returns readable text in <Code>text</Code>, which is what you want for indexing, summarising or
337+ feeding a model.
338+ </>
339+ }
340+ js={R1_JS}
341+ py={R1_PY}
342+ />
343+
344+ <Recipe
345+ id="json-api-through-a-canadian-residential-ip"
346+ title="2. JSON API through a Canadian residential IP"
347+ intro={
348+ <>
349+ Many sites load their data from a JSON endpoint. Call it directly with <Code>{`format: "json"`}</Code> and an explicit <Code>country</Code>; the <Code>json</Code> helper returns the parsed
350+ value. Pinning <Code>{`network: "residential"`}</Code> is optional today since <Code>auto</Code> resolves to it, but makes the intent explicit.
351+ </>
352+ }
353+ js={R2_JS}
354+ py={R2_PY}
355+ >
356+ <Callout variant="info">
357+ If the endpoint returns something that is not JSON (an HTML error page, for instance), <Code>json</Code> is <Code>undefined</Code>/<Code>None</Code>. Use <Code>fetch</Code> instead of{" "}
358+ <Code>json</Code> when you need <Code>status</Code> and <Code>content</Code> to diagnose it.
359+ </Callout>
360+ </Recipe>
361+
362+ <Recipe
363+ id="sticky-session-login-flow"
364+ title="3. Sticky session login flow"
365+ intro={
366+ <>
367+ Two requests that must look like one visitor: a POST to log in, then a GET of a protected page. A <A href="/docs/sessions">session</A> keeps the exit IP stable; the cookies from the
368+ first response are replayed in the second. The login uses <Code>retries: 0</Code> because replaying a credential POST is rarely what you want.
369+ </>
370+ }
371+ js={R3_JS}
372+ py={R3_PY}
373+ />
374+
375+ <Recipe
376+ id="post-with-a-json-body"
377+ title="4. POST with a JSON body"
378+ intro={
379+ <>
380+ Pass an object as <Code>body</Code> and Fetcha serialises it as JSON with <Code>Content-Type: application/json</Code>. Pass a string to send anything else (form-encoded, XML, GraphQL) and
381+ set the content type yourself. Bodies are ignored for <Code>GET</Code> and <Code>HEAD</Code>.
382+ </>
383+ }
384+ js={R4_JS}
385+ py={R4_PY}
386+ />
387+
388+ <Recipe
389+ id="custom-headers-and-cookies"
390+ title="5. Custom headers and cookies"
391+ intro={
392+ <>
393+ Your <Code>headers</Code> override Fetcha&apos;s defaults one by one. <Code>cookies</Code> is a convenience over the <Code>Cookie</Code> header, <Code>locale</Code> sets{" "}
394+ <Code>Accept-Language</Code>, and <Code>{`device: "mobile"`}</Code> switches to an iPhone User-Agent unless you provide your own.
395+ </>
396+ }
397+ js={R5_JS}
398+ py={R5_PY}
399+ />
400+
401+ <Recipe
402+ id="handling-target-blocked"
403+ title="6. Handling TARGET_BLOCKED"
404+ intro={
405+ <>
406+ A fully blocked target is <Strong>not</Strong> an exception: Fetcha returns <Code>200</Code> with <Code>success: false</Code>, the last blocked page and <Code>metadata.attempts</Code>{" "}
407+ greater than one (the request log records it as <Code>TARGET_BLOCKED</Code>). Distinguish it from a plain origin error, then decide: retry later, change geography or open a fresh session.
408+ </>
409+ }
410+ js={R6_JS}
411+ py={R6_PY}
412+ />
413+
414+ <Recipe
415+ id="concurrency-within-plan-limits"
416+ title="7. Concurrency within plan limits"
417+ intro={
418+ <>
419+ Concurrency is enforced per organization (5 / 25 / 100 / 500 in-flight requests depending on plan). Run a worker pool sized just below the limit instead of firing everything with a bare{" "}
420+ <Code>Promise.all</Code>, and treat <Code>CONCURRENCY_LIMIT</Code> as &ldquo;wait for a slot&rdquo; rather than a failure. See <A href="/docs/rate-limits">Rate limits</A>.
421+ </>
422+ }
423+ js={R7_JS}
424+ py={R7_PY}
425+ >
426+ <Ul>
427+ <Li>The plan concurrency comes from <Code>GET /v1/usage</Code>, so the pool adapts when you upgrade.</Li>
428+ <Li>
429+ If several services share one organization, split the budget between them or centralise fetching behind a queue.
430+ </Li>
431+ </Ul>
432+ </Recipe>
433+
434+ <Recipe
435+ id="using-debug-metadata"
436+ title="8. Using debug metadata"
437+ intro={
438+ <>
439+ <Code>debug: true</Code> adds a per-attempt trace to <Code>metadata.debug.attempts</Code>. Combined with <Code>timing</Code>, <Code>bytes</Code> and <Code>attempts</Code> it explains
440+ why a request was slow or expensive, and <Code>request_id</Code> lets support see the same trace on their side.
441+ </>
442+ }
443+ js={R8_JS}
444+ py={R8_PY}
445+ />
446+ </DocPage>
447+ );
448+}
added apps/web/src/app/(marketing)/docs/extraction/page.tsx +99 −0
@@ -0,0 +1,99 @@
1+import type { Metadata } from "next";
2+import { CodeBlock } from "@/components/ui/code-block";
3+import { DocPage } from "@/components/docs/doc-page";
4+import { A, Code, H2, Li, P, Ul } from "@/components/docs/prose";
5+import { Callout, ComingSoon } from "@/components/docs/callout";
6+import { Endpoint } from "@/components/docs/endpoint";
7+import { ResponseExample } from "@/components/docs/response-example";
8+
9+export const metadata: Metadata = {
10+ title: "Extraction",
11+ description: "Structured data extraction (POST /v1/extract) is coming soon. This page describes the planned schema format and what the endpoint returns today.",
12+};
13+
14+export default function ExtractionPage() {
15+ return (
16+ <DocPage path="/docs/extraction" eyebrow="Core API" title="Extraction" description="Turn a page into structured JSON by describing the fields you want. Extraction will run on top of a normal fetch, so routing, geography and sessions apply unchanged." status="Coming soon">
17+ <ComingSoon title="Structured extraction is not yet available">
18+ <Code>POST /v1/extract</Code> exists as a reserved route but performs no extraction. Everything below the &ldquo;Current behaviour&rdquo; section is design intent, published so you can
19+ plan integrations; names and shapes may change before launch.
20+ </ComingSoon>
21+
22+ <H2>Current behaviour</H2>
23+ <Endpoint method="POST" path="/v1/extract" status="Coming soon" />
24+ <P>Any call, with any body, returns a 400 with an explanatory message. No fetch is performed and nothing is counted against your quota.</P>
25+ <ResponseExample
26+ status={400}
27+ statusText="Bad Request"
28+ body={{ error: { code: "INVALID_REQUEST", message: "Structured extraction is not yet available on this plan. Follow the changelog at https://www.fetcha.co/changelog.", request_id: "req_3c4d5e6f7g8h9i0j" } }}
29+ />
30+ <P>
31+ In the meantime, the pragmatic path is <Code>{`format: "text"`}</Code> for readable content, or <Code>{`format: "json"`}</Code> against the JSON endpoints most modern sites expose. See the{" "}
32+ <A href="/docs/examples">Examples</A>.
33+ </P>
34+
35+ <H2>Planned request</H2>
36+ <P>
37+ The request will accept every <Code>/v1/fetch</Code> field plus a <Code>schema</Code> describing the output. Field types are expressed as short strings; arrays and nested objects are
38+ supported. Optional natural-language <Code>hints</Code> help disambiguate fields.
39+ </P>
40+ <CodeBlock
41+ lang="json"
42+ title="Planned request"
43+ code={JSON.stringify(
44+ {
45+ url: "https://www.example.ca/products/42",
46+ country: "CA",
47+ schema: {
48+ title: "string",
49+ price: "number",
50+ currency: "string",
51+ in_stock: "boolean",
52+ images: ["string"],
53+ specs: [{ name: "string", value: "string" }],
54+ },
55+ hints: { price: "The current sale price, not the crossed-out list price." },
56+ },
57+ null,
58+ 2,
59+ )}
60+ />
61+ <CodeBlock
62+ lang="json"
63+ title="Planned response"
64+ code={JSON.stringify(
65+ {
66+ request_id: "req_…",
67+ success: true,
68+ status: 200,
69+ url: "https://www.example.ca/products/42",
70+ final_url: "https://www.example.ca/products/42",
71+ data: {
72+ title: "Example Product 42",
73+ price: 129.0,
74+ currency: "CAD",
75+ in_stock: true,
76+ images: ["https://cdn.example.ca/p/42-1.jpg", "https://cdn.example.ca/p/42-2.jpg"],
77+ specs: [{ name: "Weight", value: "1.2 kg" }],
78+ },
79+ metadata: { network: "residential", country: "CA", attempts: 1, duration_ms: 2140, bytes: 61233, session: null, cached: false, extraction_ms: 880 },
80+ },
81+ null,
82+ 2,
83+ )}
84+ />
85+ <Ul>
86+ <Li>
87+ <Code>data</Code> follows your schema exactly; missing values are <Code>null</Code> rather than omitted.
88+ </Li>
89+ <Li>
90+ The fetch part behaves like <Code>/v1/fetch</Code>: blocked targets return <Code>success: false</Code> with no <Code>data</Code>, and the usual error codes apply.
91+ </Li>
92+ <Li>Extraction will be priced per successful extraction on top of the underlying fetch. Plan availability will be announced at launch.</Li>
93+ </Ul>
94+ <Callout variant="info" title="Tell us what you need">
95+ If you have a concrete extraction use case, email <A href="mailto:hello@fetcha.co">hello@fetcha.co</A> with an example URL and the fields you want. Early feedback shapes the schema.
96+ </Callout>
97+ </DocPage>
98+ );
99+}
added apps/web/src/app/(marketing)/docs/fetch/page.tsx +341 −0
@@ -0,0 +1,341 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { Download } from "lucide-react";
4+import { Button } from "@/components/ui/button";
5+import { CodeBlock } from "@/components/ui/code-block";
6+import { DocPage } from "@/components/docs/doc-page";
7+import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
8+import { Callout } from "@/components/docs/callout";
9+import { Endpoint } from "@/components/docs/endpoint";
10+import { ParamTable } from "@/components/docs/param-table";
11+import { CodeTabs } from "@/components/docs/code-tabs";
12+import { ResponseExample } from "@/components/docs/response-example";
13+import { fetchTabs } from "@/components/docs/snippets";
14+
15+export const metadata: Metadata = {
16+ title: "Fetch API",
17+ description: "Complete reference for POST /v1/fetch: every request field, the response shape, formats, redirects, size limits and error behaviour.",
18+};
19+
20+const EXAMPLE_BODY = {
21+ url: "https://example.com/products/42",
22+ country: "CA",
23+ region: "QC",
24+ format: "text",
25+ timeout: 20000,
26+ headers: { Accept: "text/html" },
27+ retries: 2,
28+ debug: true,
29+};
30+
31+const SUCCESS = {
32+ request_id: "req_k3j9d0f2a8b1c7e4",
33+ success: true,
34+ status: 200,
35+ url: "https://example.com/products/42",
36+ final_url: "https://example.com/products/42",
37+ content: null,
38+ content_type: "text/html; charset=utf-8",
39+ headers: {
40+ "content-type": "text/html; charset=utf-8",
41+ "cache-control": "private, max-age=0",
42+ "set-cookie": "sid=3f9a…; Path=/; Domain=example.com; HttpOnly",
43+ },
44+ cookies: [{ name: "sid", value: "3f9a…", domain: "example.com", path: "/" }],
45+ text: "Example Product 42\nCA$ 129.00\nIn stock — ships from Montréal…",
46+ metadata: {
47+ network: "residential",
48+ country: "CA",
49+ attempts: 1,
50+ duration_ms: 1184,
51+ bytes: 48211,
52+ session: null,
53+ cached: false,
54+ timing: { dns_ms: 21, proxy_connect_ms: 0, tls_ms: 0, origin_ms: 934, processing_ms: 12, total_ms: 1184 },
55+ debug: { attempts: [{ provider: "network-a", network: "residential", country: "CA", outcome: "success", status: 200, duration_ms: 1102 }] },
56+ },
57+};
58+
59+const BLOCKED = {
60+ request_id: "req_2b7e4c9a1d0f8g6h",
61+ success: false,
62+ status: 403,
63+ url: "https://shop.example.net/",
64+ final_url: "https://shop.example.net/",
65+ content: "<html><head><title>Access Denied</title></head><body>…</body></html>",
66+ content_type: "text/html",
67+ headers: { "content-type": "text/html", server: "AkamaiGHost" },
68+ cookies: [],
69+ metadata: {
70+ network: "residential",
71+ country: "US",
72+ attempts: 3,
73+ duration_ms: 6410,
74+ bytes: 9120,
75+ session: null,
76+ cached: false,
77+ timing: { dns_ms: 18, proxy_connect_ms: 0, tls_ms: 0, origin_ms: 1710, processing_ms: 4, total_ms: 6410 },
78+ },
79+};
80+
81+const INVALID = {
82+ error: {
83+ code: "INVALID_REQUEST",
84+ message: "The request body is invalid.",
85+ request_id: "req_9c1d2e3f4a5b6c7d",
86+ details: {
87+ issues: [
88+ { path: "timeout", message: "Number must be greater than or equal to 1000" },
89+ { path: "format", message: "Invalid enum value. Expected 'html' | 'text' | 'json' | 'raw', received 'markdown'" },
90+ ],
91+ },
92+ },
93+};
94+
95+export default function FetchApiPage() {
96+ return (
97+ <DocPage path="/docs/fetch" eyebrow="Core API" title="Fetch API" description="POST /v1/fetch retrieves a URL through the routing engine and returns the body, headers, cookies and routing metadata in one JSON document." status="Stable">
98+ <Endpoint method="POST" path="/v1/fetch" scope="fetch:execute" status="Live" />
99+ <div className="my-5 flex flex-wrap items-center gap-3 text-[13px] text-fg-muted">
100+ <Button asChild variant="outline" size="sm">
101+ <Link href="/docs/openapi.json" prefetch={false}>
102+ <Download className="size-3.5" aria-hidden />
103+ Download OpenAPI 3.1
104+ </Link>
105+ </Button>
106+ <span>Machine-readable description of the public endpoints (fetch, sessions, me, usage).</span>
107+ </div>
108+
109+ <H2>Request</H2>
110+ <P>
111+ Send a JSON object with <Code>Content-Type: application/json</Code>. The schema is <Strong>strict</Strong>: unknown fields cause <Code>400 INVALID_REQUEST</Code> rather than being ignored. The
112+ request body itself is limited to 4 MB.
113+ </P>
114+ <ParamTable
115+ rows={[
116+ { name: "url", type: "string", required: true, constraints: "1–8,192 chars, http or https", description: <>Absolute URL to fetch. Credentials in the URL, private/internal hosts and non-http schemes are refused with <code>URL_NOT_ALLOWED</code>.</> },
117+ { name: "method", type: '"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"', default: '"GET"', description: <>HTTP method sent to the target.</> },
118+ { name: "headers", type: "object<string, string>", constraints: "≤ 64 entries; name ≤ 256, value ≤ 8,192 chars", description: <>Headers forwarded to the target. They override Fetcha&apos;s defaults (a desktop Chrome <code>User-Agent</code>, <code>Accept</code>, <code>Accept-Language: en-US,en;q=0.9</code>, <code>Accept-Encoding</code>). Hop-by-hop headers and <code>Host</code> are dropped.</> },
119+ { name: "cookies", type: "object<string, string>", constraints: "name ≤ 256, value ≤ 4,096 chars", description: <>Cookies sent with the request. They are serialised into a <code>Cookie</code> header and appended to any <code>Cookie</code> header you also set.</> },
120+ { name: "body", type: "string | object", constraints: "string ≤ 2,000,000 chars", description: <>Request body. Strings are sent verbatim; objects are JSON-serialised. If you do not set <code>Content-Type</code>, it defaults to <code>application/json</code>. Ignored for <code>GET</code> and <code>HEAD</code>.</> },
121+ { name: "timeout", type: "integer (ms)", default: "30000", constraints: "1,000–120,000; silently capped to your plan maximum", description: <>Overall deadline for the whole request, including every retry. Free 30 s, Developer 60 s, Growth 90 s, Business and Enterprise 120 s. When fewer than 500 ms remain, Fetcha stops retrying and returns <code>TARGET_TIMEOUT</code>.</> },
122+ { name: "country", type: "string", constraints: "exactly 2 chars, upper-cased", description: <>ISO 3166-1 alpha-2 country of the exit IP, e.g. <code>{`"CA"`}</code>. See <Link href="/docs/geolocation" className="text-accent underline underline-offset-2">Geolocation</Link>.</> },
123+ { name: "region", type: "string", constraints: "≤ 64 chars", description: <>State or province. US states and Canadian provinces accept two-letter codes (<code>{`"QC"`}</code>, <code>{`"NY"`}</code>) or full names; other values are slugified.</> },
124+ { name: "city", type: "string", constraints: "≤ 128 chars", description: <>City name, slugified (<code>{`"Quebec"`}</code> → <code>quebec</code>, <code>{`"New York"`}</code> → <code>new_york</code>).</> },
125+ { name: "network", type: '"auto" | "datacenter" | "residential" | "isp" | "mobile"', default: '"auto"', description: <>Network class. <code>auto</code> lets the engine choose and escalate. Only <code>residential</code> is live today; explicitly requesting another class returns <code>NETWORK_UNAVAILABLE</code>. See <Link href="/docs/networks" className="text-accent underline underline-offset-2">Network selection</Link>.</> },
126+ { name: "session", type: "string", constraints: "≤ 64 chars", description: <>Id of a session created with <code>POST /v1/sessions</code> (<code>sess_…</code>). Pins the exit identity, network and default country for this request. Unknown ids return <code>SESSION_NOT_FOUND</code>; expired or closed ones return <code>SESSION_EXPIRED</code>.</> },
127+ { name: "format", type: '"html" | "text" | "json" | "raw"', default: '"html"', description: <>Controls the response. <code>html</code> and <code>raw</code> return the body in <code>content</code>. <code>text</code> returns readable text in <code>text</code> (scripts, styles and tags removed) and sets <code>content</code> to <code>null</code>. <code>json</code> returns the body in <code>content</code> and, when it parses, the parsed value in <code>json</code>.</> },
128+ { name: "follow_redirects", type: "boolean", default: "true", description: <>Follow 301/302/303/307/308 responses. When <code>false</code>, the redirect response is returned as-is (<code>success</code> is <code>true</code> for 3xx).</> },
129+ { name: "max_redirects", type: "integer", default: "10", constraints: "0–20", description: <>Maximum hops before <code>TOO_MANY_REDIRECTS</code>.</> },
130+ { name: "max_response_bytes", type: "integer", constraints: "1,024–50,000,000", description: <>Lower the response size cap for this request. The platform limit is 20 MB; values above it are reduced to 20 MB. Exceeding the cap returns <code>RESPONSE_TOO_LARGE</code>.</> },
131+ { name: "retries", type: "integer", default: "plan maximum", constraints: "0–5, capped by plan", description: <>Number of additional attempts after the first. Free 2, Developer 3, Growth 4, Business and Enterprise 5. See <Link href="/docs/retries" className="text-accent underline underline-offset-2">Retries</Link>.</> },
132+ { name: "device", type: '"desktop" | "mobile" | "tablet"', description: <><code>mobile</code> sets an iPhone Safari <code>User-Agent</code> unless you provide your own. <code>desktop</code> is the default behaviour. <code>tablet</code> is accepted but currently changes nothing.</> },
133+ { name: "locale", type: "string", constraints: "≤ 16 chars", description: <>Sets the <code>Accept-Language</code> header, e.g. <code>{`"fr-CA"`}</code>.</> },
134+ { name: "debug", type: "boolean", default: "false", description: <>Adds <code>metadata.debug.attempts</code> with one entry per attempt (route alias, network, country, outcome, status, duration).</> },
135+ { name: "browser", type: "boolean", default: "false", description: <>Managed browser rendering. Not available yet: <code>true</code> returns <code>400 BROWSER_UNAVAILABLE</code> before any network activity.</> },
136+ { name: "javascript", type: "boolean", reserved: true, description: <>Reserved for the managed browser. Accepted, ignored.</> },
137+ { name: "wait_for", type: "string", reserved: true, constraints: "≤ 512 chars", description: <>Reserved for the managed browser (CSS selector to wait for). Accepted, ignored.</> },
138+ { name: "wait_ms", type: "integer", reserved: true, constraints: "0–30,000", description: <>Reserved for the managed browser. Accepted, ignored.</> },
139+ { name: "cache", type: "{ enabled?: boolean, ttl?: integer }", reserved: true, constraints: "ttl 1–86,400 s", description: <>Reserved for response caching. Accepted, ignored; <code>metadata.cached</code> is always <code>false</code> today.</> },
140+ ]}
141+ />
142+
143+ <H3>Example</H3>
144+ <P>Fetch a product page as readable text through a Canadian residential exit in Québec, with a 20-second budget and debug metadata.</P>
145+ <CodeTabs
146+ tabs={fetchTabs(EXAMPLE_BODY, {
147+ javascript: `console.log(data.success, data.status, data.metadata.attempts);\nconsole.log(data.text);`,
148+ python: `print(data["success"], data["status"], data["metadata"]["attempts"])\nprint(data["text"])`,
149+ go: `\tfmt.Println(data["success"], data["status"])\n\tfmt.Println(data["text"])`,
150+ php: `echo $data['text'];`,
151+ ruby: `puts data["text"]`,
152+ csharp: `Console.WriteLine(doc.RootElement.GetProperty("text").GetString());`,
153+ })}
154+ />
155+
156+ <H2>Response</H2>
157+ <P>
158+ Whenever Fetcha obtains a response from the target, the API answers <Code>200 OK</Code> with the document below, even if the target replied with an error status. The HTTP status of the
159+ API call therefore means &ldquo;Fetcha did its job&rdquo;; the <Code>success</Code> and <Code>status</Code> fields tell you what the target did.
160+ </P>
161+ <ResponseExample status={200} body={SUCCESS} />
162+ <ParamTable
163+ showDefault={false}
164+ rows={[
165+ { name: "request_id", type: "string", description: <>Identifier of this request (<code>req_…</code>). Also sent as the <code>X-Fetcha-Request-ID</code> header.</> },
166+ { name: "success", type: "boolean", description: <><code>true</code> when the final response has a 2xx or 3xx status and was not classified as a block page. <code>false</code> for 4xx/5xx and for targets that blocked every attempt.</> },
167+ { name: "status", type: "integer", description: <>HTTP status returned by the target on the final attempt.</> },
168+ { name: "url", type: "string", description: <>The URL you requested.</> },
169+ { name: "final_url", type: "string", description: <>URL after redirects.</> },
170+ { name: "content", type: "string | null", description: <>Response body for <code>html</code>, <code>raw</code> and <code>json</code> formats; <code>null</code> for <code>text</code>. Text-like content types (<code>text/*</code>, JSON, XML, JavaScript, form-encoded) are returned as UTF-8 text; everything else (images, PDFs, archives) is returned <strong>base64-encoded</strong>.</> },
171+ { name: "content_type", type: "string | null", description: <>The target&apos;s <code>Content-Type</code> header.</> },
172+ { name: "headers", type: "object<string, string>", description: <>Response headers from the target with lower-cased names. Multiple values are joined with <code>{`", "`}</code>. <code>content-encoding</code> is removed because Fetcha decompresses gzip, deflate, brotli and zstd bodies for you.</> },
173+ { name: "cookies", type: "array", description: <>Cookies parsed from <code>Set-Cookie</code>: <code>{`{ name, value, domain?, path? }`}</code>. Replay them via the <code>cookies</code> request field.</> },
174+ { name: "text", type: "string | null", description: <>Present only for <code>{`format: "text"`}</code>. Readable text extracted from HTML; <code>null</code> when the body was binary.</> },
175+ { name: "json", type: "any", description: <>Present only for <code>{`format: "json"`}</code> and only when the body parsed as JSON. Absent otherwise; check <code>content</code> in that case.</> },
176+ { name: "metadata", type: "object", description: <>Routing information, see below.</> },
177+ ]}
178+ />
179+
180+ <H3>metadata</H3>
181+ <ParamTable
182+ showDefault={false}
183+ rows={[
184+ { name: "network", type: '"datacenter" | "residential" | "isp" | "mobile"', description: <>Concrete network class that served the final attempt. Never <code>auto</code>.</> },
185+ { name: "country", type: "string | null", description: <>Country targeted by the final attempt (the request&apos;s <code>country</code>, or the session&apos;s). <code>null</code> when no geography was requested.</> },
186+ { name: "attempts", type: "integer", description: <>Number of attempts made, including the successful one. Greater than 1 means Fetcha retried or escalated.</> },
187+ { name: "duration_ms", type: "integer", description: <>Wall-clock time of the whole request inside Fetcha, all attempts included.</> },
188+ { name: "bytes", type: "integer", description: <>Bytes transferred across <em>all</em> attempts (request and response). This is the quantity used for bandwidth pricing on premium networks.</> },
189+ { name: "session", type: "string | null", description: <>Session id used, if any.</> },
190+ { name: "cached", type: "boolean", description: <>Always <code>false</code> today (caching is reserved).</> },
191+ { name: "timing", type: "object", description: <>Breakdown of the final attempt, see below.</> },
192+ { name: "debug", type: "object", description: <>Only with <code>debug: true</code>. <code>attempts[]</code> of <code>{`{ provider, network, country, outcome, status, duration_ms }`}</code>. <code>provider</code> is a neutral route alias (<code>network-a</code>, <code>network-b</code>, …); <code>outcome</code> is one of <code>success</code>, <code>blocked</code>, <code>timeout</code>, <code>error</code>, <code>provider_error</code>, <code>too_large</code>.</> },
193+ ]}
194+ />
195+
196+ <H3>metadata.timing</H3>
197+ <P>All values are integers in milliseconds and describe the final attempt (except <Code>total_ms</Code>).</P>
198+ <Table>
199+ <THead>
200+ <Tr>
201+ <Th>Field</Th>
202+ <Th>Meaning</Th>
203+ </Tr>
204+ </THead>
205+ <TBody>
206+ <Tr>
207+ <Td mono>dns_ms</Td>
208+ <Td>Time Fetcha spent resolving the target hostname to validate it against the URL policy (SSRF protection). This happens once per request, before routing.</Td>
209+ </Tr>
210+ <Tr>
211+ <Td mono>proxy_connect_ms</Td>
212+ <Td>
213+ Time to establish the connection to the network. <Strong>Reported as 0 in the current build</Strong>; connection time is included in <Code>origin_ms</Code>.
214+ </Td>
215+ </Tr>
216+ <Tr>
217+ <Td mono>tls_ms</Td>
218+ <Td>
219+ TLS handshake time. <Strong>Reported as 0 in the current build</Strong>; included in <Code>origin_ms</Code>.
220+ </Td>
221+ </Tr>
222+ <Tr>
223+ <Td mono>origin_ms</Td>
224+ <Td>Time from sending the request until the first byte of the final response (connection, handshake, upstream routing and origin processing), summed across redirect hops of that attempt.</Td>
225+ </Tr>
226+ <Tr>
227+ <Td mono>processing_ms</Td>
228+ <Td>Time Fetcha spent decompressing the body and converting it to the requested format.</Td>
229+ </Tr>
230+ <Tr>
231+ <Td mono>total_ms</Td>
232+ <Td>
233+ End-to-end duration inside Fetcha for the whole request, identical to <Code>metadata.duration_ms</Code>.
234+ </Td>
235+ </Tr>
236+ </TBody>
237+ </Table>
238+
239+ <H2>Blocked targets</H2>
240+ <P>
241+ A block is any response Fetcha classifies as anti-bot interference: HTTP 403, 407, 429 or 999, a 503 challenge page, captcha markup, known anti-bot markers in the first 20 KB, or a WAF
242+ server signature on a 4xx/5xx. Blocks trigger escalation. If <Strong>every</Strong> attempt is blocked, Fetcha still returns <Code>200</Code> with the last blocked page so you can inspect
243+ it, but with <Code>success: false</Code>. The request log records it as <Code>TARGET_BLOCKED</Code>.
244+ </P>
245+ <ResponseExample status={200} title="200 OK · success: false" body={BLOCKED} />
246+ <Callout variant="info" title="Non-blocked errors are returned immediately">
247+ A plain 404 or 500 from the origin is not a block. It is returned after a single attempt with <Code>success: false</Code> and the origin status, without retrying.
248+ </Callout>
249+
250+ <H2>Redirects</H2>
251+ <Ul>
252+ <Li>
253+ 301, 302, 303, 307 and 308 with a <Code>Location</Code> header are followed while <Code>follow_redirects</Code> is <Code>true</Code>, up to <Code>max_redirects</Code> hops.
254+ </Li>
255+ <Li>
256+ <Strong>Every hop is validated</Strong> against the same URL policy as the original URL. A redirect to a private or internal address fails the request with <Code>URL_NOT_ALLOWED</Code>{" "}
257+ and is not retried.
258+ </Li>
259+ <Li>
260+ A 303, or a 301/302 following a <Code>POST</Code>, switches the next hop to <Code>GET</Code> and drops the body, matching browser behaviour.
261+ </Li>
262+ <Li>
263+ Exceeding <Code>max_redirects</Code> fails with <Code>502 TOO_MANY_REDIRECTS</Code>, without retrying.
264+ </Li>
265+ </Ul>
266+
267+ <H2>Size limits</H2>
268+ <Ul>
269+ <Li>
270+ Response bodies are capped at <Strong>20 MB</Strong>. You can lower the cap per request with <Code>max_response_bytes</Code>; you cannot raise it above 20 MB. Larger bodies fail with{" "}
271+ <Code>502 RESPONSE_TOO_LARGE</Code>.
272+ </Li>
273+ <Li>Request bodies to the API are capped at 4 MB (413, reported as <Code>INVALID_REQUEST</Code>).</Li>
274+ <Li>
275+ <Code>body</Code> strings are limited to 2,000,000 characters by the schema.
276+ </Li>
277+ </Ul>
278+
279+ <H2>Content types</H2>
280+ <P>
281+ Fetcha does not transform binary content. When the target&apos;s <Code>Content-Type</Code> is not text-like (<Code>text/*</Code>, JSON, XML, JavaScript or form-encoded), <Code>content</Code>{" "}
282+ contains the body encoded as base64 and <Code>text</Code> is <Code>null</Code>. Decode it with your language&apos;s base64 helper; <Code>content_type</Code> tells you what it is.
283+ </P>
284+ <CodeBlock
285+ lang="javascript"
286+ code={`const r = await fetcha.fetch({ url: "https://example.com/report.pdf", format: "raw" });
287+if (r.content_type?.startsWith("application/pdf")) {
288+ const pdf = Buffer.from(r.content!, "base64");
289+ await fs.promises.writeFile("report.pdf", pdf);
290+}`}
291+ />
292+
293+ <H2>Errors</H2>
294+ <P>
295+ When Fetcha cannot produce a response document, the API returns a non-200 status with an error envelope. Validation failures list every offending field in <Code>details.issues</Code>.
296+ The full catalogue is on the <A href="/docs/errors">Errors</A> page.
297+ </P>
298+ <ResponseExample status={400} statusText="Bad Request" body={INVALID} />
299+ <Table dense>
300+ <THead>
301+ <Tr>
302+ <Th>Situation</Th>
303+ <Th>Result</Th>
304+ </Tr>
305+ </THead>
306+ <TBody>
307+ <Tr>
308+ <Td>Invalid or unknown field, malformed JSON, wrong content type, body over 4 MB</Td>
309+ <Td mono>400 INVALID_REQUEST</Td>
310+ </Tr>
311+ <Tr>
312+ <Td>Private, internal or non-http URL (initial or via redirect)</Td>
313+ <Td mono>400 URL_NOT_ALLOWED</Td>
314+ </Tr>
315+ <Tr>
316+ <Td>Network class not in your plan or not live</Td>
317+ <Td mono>400 NETWORK_UNAVAILABLE</Td>
318+ </Tr>
319+ <Tr>
320+ <Td>
321+ <Code>browser: true</Code>
322+ </Td>
323+ <Td mono>400 BROWSER_UNAVAILABLE</Td>
324+ </Tr>
325+ <Tr>
326+ <Td>Timeout exhausted across all attempts</Td>
327+ <Td mono>504 TARGET_TIMEOUT</Td>
328+ </Tr>
329+ <Tr>
330+ <Td>DNS failure or connection refused on every attempt</Td>
331+ <Td mono>502 TARGET_UNAVAILABLE</Td>
332+ </Tr>
333+ <Tr>
334+ <Td>No route available at all</Td>
335+ <Td mono>503 PROVIDER_UNAVAILABLE</Td>
336+ </Tr>
337+ </TBody>
338+ </Table>
339+ </DocPage>
340+ );
341+}
added apps/web/src/app/(marketing)/docs/geolocation/page.tsx +162 −0
@@ -0,0 +1,162 @@
1+import type { Metadata } from "next";
2+import { COUNTRIES } from "@fetcha/core";
3+import { DocPage } from "@/components/docs/doc-page";
4+import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
5+import { Callout } from "@/components/docs/callout";
6+import { CodeTabs } from "@/components/docs/code-tabs";
7+import { ParamTable } from "@/components/docs/param-table";
8+import { ResponseExample } from "@/components/docs/response-example";
9+import { fetchTabs } from "@/components/docs/snippets";
10+
11+export const metadata: Metadata = {
12+ title: "Geolocation",
13+ description: "Target a country, region and city for the exit IP: accepted values, normalisation rules and behaviour when a location is unavailable.",
14+};
15+
16+const CA_REGIONS: Array<[string, string, string]> = [
17+ ["QC", "Quebec", "quebec"],
18+ ["ON", "Ontario", "ontario"],
19+ ["BC", "British Columbia", "british_columbia"],
20+ ["AB", "Alberta", "alberta"],
21+ ["MB", "Manitoba", "manitoba"],
22+ ["SK", "Saskatchewan", "saskatchewan"],
23+ ["NS", "Nova Scotia", "nova_scotia"],
24+ ["NB", "New Brunswick", "new_brunswick"],
25+ ["NL", "Newfoundland and Labrador", "newfoundland_and_labrador"],
26+ ["PE / PEI", "Prince Edward Island", "prince_edward_island"],
27+ ["YT", "Yukon", "yukon"],
28+ ["NT", "Northwest Territories", "northwest_territories"],
29+ ["NU", "Nunavut", "nunavut"],
30+];
31+
32+const US_SAMPLE: Array<[string, string]> = [
33+ ["CA", "california"],
34+ ["NY", "new_york"],
35+ ["TX", "texas"],
36+ ["FL", "florida"],
37+ ["WA", "washington"],
38+ ["IL", "illinois"],
39+ ["MA", "massachusetts"],
40+ ["NJ", "new_jersey"],
41+ ["NC", "north_carolina"],
42+ ["DC", "district_of_columbia"],
43+];
44+
45+export default function GeolocationPage() {
46+ const countries = Object.entries(COUNTRIES);
47+ return (
48+ <DocPage path="/docs/geolocation" eyebrow="Core API" title="Geolocation" description="Choose where your request appears to come from. Country is the primary control; region and city refine it where the network has coverage." status="Stable">
49+ <H2>Fields</H2>
50+ <P>
51+ The three geography fields are shared by <Code>POST /v1/fetch</Code> and <Code>POST /v1/sessions</Code>. All are optional. When none is set, the network picks any exit and{" "}
52+ <Code>metadata.country</Code> is <Code>null</Code>.
53+ </P>
54+ <ParamTable
55+ showDefault={false}
56+ rows={[
57+ { name: "country", type: "string", constraints: "exactly 2 characters", description: <>ISO 3166-1 alpha-2 code. Case-insensitive; normalised to upper case (<code>{`"ca"`}</code> → <code>{`"CA"`}</code>). Any other length fails validation with <code>INVALID_REQUEST</code>.</> },
58+ { name: "region", type: "string", constraints: "≤ 64 characters", description: <>State, province or territory. For <code>US</code> and <code>CA</code>, two-letter postal codes and full names are recognised and normalised to a canonical slug. For every other country the value is slugified as-is.</> },
59+ { name: "city", type: "string", constraints: "≤ 128 characters", description: <>City name, slugified: accents removed, lower-cased, non-alphanumeric runs replaced by <code>_</code>. <code>{`"Québec"`}</code> → <code>quebec</code>, <code>{`"Saint-Jean-sur-Richelieu"`}</code> → <code>saint_jean_sur_richelieu</code>.</> },
60+ ]}
61+ />
62+
63+ <H2>Example</H2>
64+ <P>Fetch through a residential exit in Québec City, Canada.</P>
65+ <CodeTabs
66+ tabs={fetchTabs(
67+ { url: "https://www.example.ca/", country: "CA", region: "QC", city: "Quebec" },
68+ {
69+ javascript: `console.log(data.metadata.country); // "CA"`,
70+ python: `print(data["metadata"]["country"]) # "CA"`,
71+ },
72+ )}
73+ />
74+ <ResponseExample
75+ status={200}
76+ title="200 OK · metadata excerpt"
77+ body={{ metadata: { network: "residential", country: "CA", attempts: 1, duration_ms: 1290, bytes: 61233, session: null, cached: false } }}
78+ />
79+
80+ <H2>Normalisation</H2>
81+ <P>
82+ Fetcha converts what you send into a canonical internal form before handing it to the network, so the same location can be written several ways. The tables below show the recognised
83+ aliases for Canada and a sample for the United States; every US state, plus <Code>DC</Code>, is supported.
84+ </P>
85+ <H3>Canadian provinces and territories</H3>
86+ <Table dense>
87+ <THead>
88+ <Tr>
89+ <Th>Code</Th>
90+ <Th>Name</Th>
91+ <Th>Normalised value</Th>
92+ </Tr>
93+ </THead>
94+ <TBody>
95+ {CA_REGIONS.map(([code, name, slug]) => (
96+ <Tr key={slug}>
97+ <Td mono>{code}</Td>
98+ <Td>{name}</Td>
99+ <Td mono>{slug}</Td>
100+ </Tr>
101+ ))}
102+ </TBody>
103+ </Table>
104+ <H3>US states (sample)</H3>
105+ <Table dense>
106+ <THead>
107+ <Tr>
108+ <Th>Code</Th>
109+ <Th>Normalised value</Th>
110+ </Tr>
111+ </THead>
112+ <TBody>
113+ {US_SAMPLE.map(([code, slug]) => (
114+ <Tr key={code}>
115+ <Td mono>{code}</Td>
116+ <Td mono>{slug}</Td>
117+ </Tr>
118+ ))}
119+ </TBody>
120+ </Table>
121+ <P>
122+ Full names are accepted for both countries (<Code>{`"Ontario"`}</Code>, <Code>{`"British Columbia"`}</Code>, <Code>{`"Québec"`}</Code>). Only the two-letter postal code is accepted for US states; full US state
123+ names fall through to plain slugification, which still yields the same slug for single-word states (<Code>{`"texas"`}</Code>) but not for multi-word ones.
124+ </P>
125+
126+ <H2>Countries</H2>
127+ <P>
128+ The API accepts any two-letter code. The following {countries.length} countries are the ones surfaced in the dashboard pickers; coverage of others depends on the network and is best confirmed
129+ with a test request.
130+ </P>
131+ <div className="my-5 grid grid-cols-2 gap-x-6 gap-y-1 rounded-lg border border-border p-4 text-[13px] sm:grid-cols-3">
132+ {countries.map(([code, name]) => (
133+ <div key={code} className="flex items-baseline gap-2">
134+ <code className="font-mono text-[12px] text-fg">{code}</code>
135+ <span className="truncate text-fg-muted">{name}</span>
136+ </div>
137+ ))}
138+ </div>
139+
140+ <H2>When a location is unavailable</H2>
141+ <Ul>
142+ <Li>
143+ <Strong>No route can serve the geography.</Strong> If none of the eligible routes supports the requested country, the request fails before any attempt: <Code>503 PROVIDER_UNAVAILABLE</Code>{" "}
144+ in <Code>auto</Code> mode, <Code>400 NETWORK_UNAVAILABLE</Code> with an explicit class. For sessions, creation fails with <Code>NETWORK_UNAVAILABLE</Code>.
145+ </Li>
146+ <Li>
147+ <Strong>Region or city has thin coverage.</Strong> Country is the level the routing engine guarantees when it selects a route. Region and city are forwarded to the network as targeting
148+ hints; the network serves the closest exit it has. Fetcha does not currently verify the exit location against an independent geo-IP database, so if the exact city matters to your use
149+ case, confirm it with a request to a geo-echo endpoint of your choice.
150+ </Li>
151+ <Li>
152+ <Strong>Sessions inherit geography.</Strong> When a fetch carries a <Code>session</Code> and omits <Code>country</Code>, the session&apos;s country is used. See{" "}
153+ <A href="/docs/sessions">Sessions</A>.
154+ </Li>
155+ </Ul>
156+ <Callout variant="info" title="metadata.country echoes your request">
157+ <Code>metadata.country</Code> reports the country that was targeted for the final attempt (yours or the session&apos;s), not a measured location. It is <Code>null</Code> when you did not ask
158+ for one.
159+ </Callout>
160+ </DocPage>
161+ );
162+}
added apps/web/src/app/(marketing)/docs/layout.tsx +25 −0
@@ -0,0 +1,25 @@
1+import type { Metadata } from "next";
2+import { DocsSidebar, DocsMobileNav } from "@/components/docs/sidebar";
3+import { DocsToc } from "@/components/docs/toc";
4+
5+export const metadata: Metadata = {
6+ title: { default: "Documentation", template: "%s · Fetcha Docs" },
7+ description: "Developer documentation for the Fetcha web access API: fetch, sessions, network selection, geolocation, errors, retries, rate limits and SDKs.",
8+};
9+
10+export default function DocsLayout({ children }: { children: React.ReactNode }) {
11+ return (
12+ <div className="container-page">
13+ <div className="lg:grid lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-10 xl:grid-cols-[13.5rem_minmax(0,1fr)_12rem] xl:gap-12">
14+ <DocsSidebar />
15+ <div className="min-w-0">
16+ <DocsMobileNav />
17+ <div className="mx-auto max-w-3xl py-8 lg:py-10">{children}</div>
18+ </div>
19+ <aside className="sticky top-14 hidden max-h-[calc(100dvh-3.5rem)] overflow-y-auto py-10 scrollbar-thin xl:block">
20+ <DocsToc />
21+ </aside>
22+ </div>
23+ </div>
24+ );
25+}
added apps/web/src/app/(marketing)/docs/networks/page.tsx +228 −0
@@ -0,0 +1,228 @@
1+import type { Metadata } from "next";
2+import { PLAN_LIMITS, PLANS } from "@fetcha/core";
3+import { CodeBlock } from "@/components/ui/code-block";
4+import { Badge } from "@/components/ui/badge";
5+import { DocPage } from "@/components/docs/doc-page";
6+import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
7+import { Callout } from "@/components/docs/callout";
8+import { CodeTabs } from "@/components/docs/code-tabs";
9+import { ResponseExample } from "@/components/docs/response-example";
10+import { fetchTabs } from "@/components/docs/snippets";
11+
12+export const metadata: Metadata = {
13+ title: "Network Selection",
14+ description: "Network classes, what auto does, how routes are scored, per-domain intelligence, escalation, circuit breakers and plan gating.",
15+};
16+
17+const CLASSES: Array<{ name: string; live: boolean; desc: string }> = [
18+ { name: "auto", live: true, desc: "Let the engine choose. Cheapest class first, escalating to more reliable classes on blocks. Resolves to residential today." },
19+ { name: "residential", live: true, desc: "Exit IPs assigned by consumer ISPs to households. Highest acceptance on sites that filter datacenter traffic. Geo-targetable. Billed per GB on paid plans." },
20+ { name: "datacenter", live: false, desc: "Exit IPs from hosting providers. Fastest and cheapest, most often blocked. Not live yet." },
21+ { name: "isp", live: false, desc: "Static IPs registered to consumer ISPs but hosted in datacenters. Residential reputation with datacenter speed. Not live yet." },
22+ { name: "mobile", live: false, desc: "Exit IPs from cellular carriers. Highest acceptance, highest cost. Not live yet." },
23+];
24+
25+const ALL_NETWORKS = ["datacenter", "residential", "isp", "mobile"] as const;
26+
27+export default function NetworksPage() {
28+ return (
29+ <DocPage path="/docs/networks" eyebrow="Core API" title="Network selection" description="Fetcha routes requests across classes of networks rather than individual proxies. You pick a class, or let auto pick for you, and the engine handles scoring, escalation and health." status="Stable">
30+ <H2>Network classes</H2>
31+ <P>
32+ The <Code>network</Code> field of a fetch request accepts one of five values. Classes describe the <Strong>kind</Strong> of exit IP; the upstream networks behind each class are managed by
33+ Fetcha and never exposed.
34+ </P>
35+ <Table>
36+ <THead>
37+ <Tr>
38+ <Th>Value</Th>
39+ <Th>Status</Th>
40+ <Th>Description</Th>
41+ </Tr>
42+ </THead>
43+ <TBody>
44+ {CLASSES.map((c) => (
45+ <Tr key={c.name}>
46+ <Td mono>{c.name}</Td>
47+ <Td>
48+ <Badge variant={c.live ? "success" : "warning"} dot>
49+ {c.live ? "Live" : "Coming soon"}
50+ </Badge>
51+ </Td>
52+ <Td>{c.desc}</Td>
53+ </Tr>
54+ ))}
55+ </TBody>
56+ </Table>
57+ <Callout variant="warning" title="What happens today">
58+ Only the <Code>residential</Code> class has live capacity. <Code>auto</Code> therefore resolves to residential and <Code>metadata.network</Code> reports <Code>{`"residential"`}</Code>. Requesting{" "}
59+ <Code>datacenter</Code>, <Code>isp</Code> or <Code>mobile</Code> explicitly returns <Code>400 NETWORK_UNAVAILABLE</Code>: either because the class is not part of your plan, or because it has no
60+ live route yet. When those classes launch, <Code>auto</Code> will start using them without any change on your side.
61+ </Callout>
62+
63+ <H2>What auto does</H2>
64+ <P>
65+ <Code>auto</Code> is the default and the recommended setting. For each request the routing engine builds an ordered list of candidate routes (a route is one upstream network serving one class),
66+ then executes them in order until one succeeds or the retry budget is spent.
67+ </P>
68+ <H3>1. Eligible classes</H3>
69+ <P>
70+ The engine starts from the escalation order <Code>datacenter → isp → residential → mobile</Code>, keeps only the classes included in your plan, and drops classes that have no configured,
71+ healthy route right now. If the domain has an admin-pinned <Code>force_network</Code> policy, only that class is considered.
72+ </P>
73+ <H3>2. Scoring</H3>
74+ <P>Every eligible route receives a score between 0 and 1, computed as a weighted sum of six signals:</P>
75+ <Table>
76+ <THead>
77+ <Tr>
78+ <Th>Weight</Th>
79+ <Th>Signal</Th>
80+ <Th>How it is measured</Th>
81+ </Tr>
82+ </THead>
83+ <TBody>
84+ <Tr>
85+ <Td mono>35 %</Td>
86+ <Td>Historical success</Td>
87+ <Td>
88+ Success rate of this route on this domain, Bayesian-smoothed toward a per-class prior (datacenter 70 %, isp 85 %, residential 93 %, mobile 95 %) so new domains start with sensible
89+ expectations.
90+ </Td>
91+ </Tr>
92+ <Tr>
93+ <Td mono>20 %</Td>
94+ <Td>Cost</Td>
95+ <Td>Price per GB of the route, normalised against the most expensive class (about $15/GB).</Td>
96+ </Tr>
97+ <Tr>
98+ <Td mono>15 %</Td>
99+ <Td>Latency</Td>
100+ <Td>Average latency observed on this domain (300 ms scores 1.0, 5 s scores 0). Without history, datacenter assumes 500 ms and other classes 1,200 ms.</Td>
101+ </Tr>
102+ <Tr>
103+ <Td mono>15 %</Td>
104+ <Td>Network health</Td>
105+ <Td>Health factor from the route&apos;s circuit breaker: 1.0 when closed and clean, degraded by recent failure ratio, 0.3 while half-open, 0 while open.</Td>
106+ </Tr>
107+ <Tr>
108+ <Td mono>10 %</Td>
109+ <Td>Geography</Td>
110+ <Td>Whether the route can serve the requested country, region and city.</Td>
111+ </Tr>
112+ <Tr>
113+ <Td mono>5 %</Td>
114+ <Td>Session stability</Td>
115+ <Td>Whether the route supports sticky sessions when the request carries one.</Td>
116+ </Tr>
117+ </TBody>
118+ </Table>
119+ <H3>3. Ordering and escalation</H3>
120+ <Ul>
121+ <Li>
122+ In <Code>auto</Code> mode, candidates are grouped by class in escalation order (cheap first) and sorted by score inside each class. Distinct routes are preferred before repeating one, so a
123+ retry usually changes both the exit IP and the route.
124+ </Li>
125+ <Li>
126+ <Strong>Per-domain intelligence:</Strong> if the domain profile shows a cheaper class being blocked more than 60 % of the time over at least 5 attempts, that class is skipped entirely for
127+ the domain. Residential is never skipped by this rule.
128+ </Li>
129+ <Li>
130+ The list is cut to your attempt budget: <Code>retries + 1</Code>, bounded by the plan maximum. See <A href="/docs/retries">Retries</A>.
131+ </Li>
132+ <Li>
133+ With an explicit class (for example <Code>{`"network": "residential"`}</Code>), candidates are simply sorted by score; there is no cross-class escalation.
134+ </Li>
135+ </Ul>
136+ <H3>4. Learning</H3>
137+ <P>
138+ After every attempt, the domain profile is updated with the outcome (success, block, latency, cost). Profiles are shared across all Fetcha customers, so a site that starts blocking a class is
139+ learned once and avoided for everyone. Only aggregate statistics are stored; never your request or response content.
140+ </P>
141+
142+ <H2>Circuit breakers</H2>
143+ <P>
144+ Each route has an in-memory circuit breaker so that an upstream incident does not turn into a wave of failed requests:
145+ </P>
146+ <Ul>
147+ <Li>Outcomes are tracked in a rolling 120-second window.</Li>
148+ <Li>
149+ The circuit <Strong>opens</Strong> when at least 8 samples are present and 60 % or more of them failed. An open route is excluded from routing and scores 0 on the health signal.
150+ </Li>
151+ <Li>After a 60-second cooldown the circuit becomes half-open: one request probes the route; success closes the circuit, failure reopens it.</Li>
152+ <Li>
153+ Only network-side failures count (upstream authentication or gateway errors, connection failures, 5xx from the gateway). A target that blocks or times out does not penalise the route.
154+ </Li>
155+ </Ul>
156+ <P>
157+ When every candidate route is open or unconfigured, <Code>auto</Code> requests fail fast with <Code>503 PROVIDER_UNAVAILABLE</Code> and explicit-class requests with{" "}
158+ <Code>400 NETWORK_UNAVAILABLE</Code>. Neither counts as a target failure and both are safe to retry after a short pause.
159+ </P>
160+
161+ <H2>Plan gating</H2>
162+ <P>
163+ Each plan includes a set of network classes. Requesting a class outside your plan is refused before any attempt. The table reflects the plan definitions; remember that only residential is
164+ live regardless of plan.
165+ </P>
166+ <Table>
167+ <THead>
168+ <Tr>
169+ <Th>Plan</Th>
170+ {ALL_NETWORKS.map((n) => (
171+ <Th key={n} className="text-center">
172+ {n}
173+ </Th>
174+ ))}
175+ </Tr>
176+ </THead>
177+ <TBody>
178+ {PLANS.map((p) => (
179+ <Tr key={p}>
180+ <Td className="font-medium text-fg">{PLAN_LIMITS[p].label}</Td>
181+ {ALL_NETWORKS.map((n) => (
182+ <Td key={n} className="text-center">
183+ {PLAN_LIMITS[p].networks.includes(n) ? <span className="text-success">Included</span> : <span className="text-fg-subtle">&mdash;</span>}
184+ </Td>
185+ ))}
186+ </Tr>
187+ ))}
188+ </TBody>
189+ </Table>
190+
191+ <H2>Choosing a class</H2>
192+ <P>
193+ Prefer <Code>auto</Code>. Pin <Code>residential</Code> when you need to guarantee that no cheaper class is ever tried on a sensitive target, or when you want bandwidth cost to be fully
194+ predictable. Read the served class from <Code>metadata.network</Code> and, with <Code>debug: true</Code>, the per-attempt route aliases from <Code>metadata.debug.attempts</Code>.
195+ </P>
196+ <CodeTabs
197+ tabs={fetchTabs(
198+ { url: "https://example.com", network: "residential", country: "US", debug: true },
199+ {
200+ javascript: `console.log(data.metadata.network, data.metadata.debug.attempts);`,
201+ python: `print(data["metadata"]["network"], data["metadata"]["debug"]["attempts"])`,
202+ },
203+ ["curl", "javascript", "python"],
204+ )}
205+ />
206+ <ResponseExample
207+ status={400}
208+ statusText="Bad Request"
209+ title="400 Bad Request · explicit class not available"
210+ body={{ error: { code: "NETWORK_UNAVAILABLE", message: 'The "mobile" network is not included in the Developer plan.', request_id: "req_0a1b2c3d4e5f6g7h" } }}
211+ />
212+ <CodeBlock
213+ lang="json"
214+ title="metadata.debug.attempts — escalation across two routes"
215+ code={JSON.stringify(
216+ {
217+ attempts: [
218+ { provider: "network-a", network: "residential", country: "US", outcome: "blocked", status: 403, duration_ms: 1320 },
219+ { provider: "network-b", network: "residential", country: "US", outcome: "success", status: 200, duration_ms: 1710 },
220+ ],
221+ },
222+ null,
223+ 2,
224+ )}
225+ />
226+ </DocPage>
227+ );
228+}
added apps/web/src/app/(marketing)/docs/openapi.json/route.ts +442 −0
@@ -0,0 +1,442 @@
1+import { ERROR_CODES, ERROR_HTTP_STATUS, ERROR_MESSAGES, HTTP_METHODS, NETWORK_CLASSES, OUTPUT_FORMATS, DEVICES, PLAN_LIMITS } from "@fetcha/core";
2+
3+/**
4+ * OpenAPI 3.1 description of the public Fetcha API, derived by hand from `fetchRequestSchema`,
5+ * `sessionCreateSchema`, the error catalogue and the route handlers. Served at /docs/openapi.json.
6+ */
7+
8+const BASE_URL = "https://www.fetcha.co";
9+
10+const errorSchema = {
11+ type: "object",
12+ required: ["error"],
13+ properties: {
14+ error: {
15+ type: "object",
16+ required: ["code", "message", "request_id"],
17+ properties: {
18+ code: { type: "string", enum: [...ERROR_CODES], description: "Stable machine-readable error code." },
19+ message: { type: "string", description: "Human-readable explanation. May be more specific than the default message." },
20+ request_id: { type: ["string", "null"], description: "Request identifier, also sent as X-Fetcha-Request-ID." },
21+ details: {
22+ type: "object",
23+ additionalProperties: true,
24+ description: "Optional context: `issues` (INVALID_REQUEST), `retry_after_ms` (RATE_LIMITED), `limit` (CONCURRENCY_LIMIT), `limit`/`used` or `limit_usd`/`spent_usd` (USAGE_LIMIT_REACHED).",
25+ },
26+ },
27+ },
28+ },
29+} as const;
30+
31+function errorResponse(codes: string[]) {
32+ return {
33+ description: `Error. Possible codes: ${codes.join(", ")}.`,
34+ headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } },
35+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
36+ };
37+}
38+
39+const byStatus = (codes: string[]) => {
40+ const out: Record<string, unknown> = {};
41+ const groups = new Map<number, string[]>();
42+ for (const c of codes) {
43+ const s = ERROR_HTTP_STATUS[c as keyof typeof ERROR_HTTP_STATUS];
44+ groups.set(s, [...(groups.get(s) ?? []), c]);
45+ }
46+ for (const [status, list] of [...groups.entries()].sort((a, b) => a[0] - b[0])) out[String(status)] = errorResponse(list);
47+ return out;
48+};
49+
50+const AUTH_ERRORS = ["INVALID_API_KEY", "EMAIL_NOT_VERIFIED", "FORBIDDEN"];
51+
52+const geoProps = {
53+ country: { type: "string", minLength: 2, maxLength: 2, description: "ISO 3166-1 alpha-2 country code of the exit IP (case-insensitive, normalised to upper case).", example: "CA" },
54+ region: { type: "string", maxLength: 64, description: "State or province. US states and Canadian provinces accept two-letter codes or names; other values are slugified.", example: "QC" },
55+ city: { type: "string", maxLength: 128, description: "City name, slugified.", example: "Quebec" },
56+} as const;
57+
58+const fetchRequest = {
59+ type: "object",
60+ additionalProperties: false,
61+ required: ["url"],
62+ properties: {
63+ url: { type: "string", minLength: 1, maxLength: 8192, format: "uri", description: "Absolute http(s) URL. Private, internal and non-http targets are refused (URL_NOT_ALLOWED)." },
64+ method: { type: "string", enum: [...HTTP_METHODS], default: "GET" },
65+ headers: { type: "object", additionalProperties: { type: "string", maxLength: 8192 }, maxProperties: 64, description: "Headers forwarded to the target; override Fetcha defaults." },
66+ cookies: { type: "object", additionalProperties: { type: "string", maxLength: 4096 }, description: "Cookies serialised into the Cookie header." },
67+ body: { oneOf: [{ type: "string", maxLength: 2_000_000 }, { type: "object", additionalProperties: true }], description: "Request body. Objects are JSON-serialised. Content-Type defaults to application/json. Ignored for GET/HEAD." },
68+ timeout: { type: "integer", minimum: 1000, maximum: 120_000, default: 30_000, description: "Overall deadline in ms for all attempts; capped to the plan maximum (Free 30 s, Developer 60 s, Growth 90 s, Business/Enterprise 120 s)." },
69+ ...geoProps,
70+ network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto", description: "Network class. Only `residential` is live; `auto` resolves to it. Other explicit classes return NETWORK_UNAVAILABLE." },
71+ session: { type: "string", maxLength: 64, description: "Session id (sess_…) from POST /v1/sessions." },
72+ browser: { type: "boolean", default: false, description: "Managed browser. Not available yet: true returns BROWSER_UNAVAILABLE." },
73+ javascript: { type: "boolean", description: "Reserved for the managed browser. Accepted, ignored." },
74+ wait_for: { type: "string", maxLength: 512, description: "Reserved for the managed browser. Accepted, ignored." },
75+ wait_ms: { type: "integer", minimum: 0, maximum: 30_000, description: "Reserved for the managed browser. Accepted, ignored." },
76+ device: { type: "string", enum: [...DEVICES], description: "`mobile` sets an iPhone User-Agent unless one is provided. `tablet` currently has no effect." },
77+ locale: { type: "string", maxLength: 16, description: "Sets the Accept-Language header.", example: "fr-CA" },
78+ format: { type: "string", enum: [...OUTPUT_FORMATS], default: "html", description: "html/raw: body in `content`; text: readable text in `text` (content null); json: body in `content` and parsed value in `json`." },
79+ follow_redirects: { type: "boolean", default: true },
80+ max_redirects: { type: "integer", minimum: 0, maximum: 20, default: 10 },
81+ max_response_bytes: { type: "integer", minimum: 1024, maximum: 50_000_000, description: "Lower the response size cap for this request. Effective cap is min(value, 20 MB)." },
82+ cache: {
83+ type: "object",
84+ additionalProperties: false,
85+ properties: { enabled: { type: "boolean", default: false }, ttl: { type: "integer", minimum: 1, maximum: 86_400, default: 300 } },
86+ description: "Reserved. Accepted, ignored; metadata.cached is always false.",
87+ },
88+ retries: { type: "integer", minimum: 0, maximum: 5, description: "Additional attempts after the first. Defaults to and is capped by the plan maximum (Free 2, Developer 3, Growth 4, Business/Enterprise 5)." },
89+ debug: { type: "boolean", default: false, description: "Include metadata.debug.attempts." },
90+ },
91+} as const;
92+
93+const timing = {
94+ type: "object",
95+ required: ["dns_ms", "proxy_connect_ms", "tls_ms", "origin_ms", "processing_ms", "total_ms"],
96+ properties: {
97+ dns_ms: { type: "integer", description: "Hostname resolution for URL policy validation." },
98+ proxy_connect_ms: { type: "integer", description: "Reported as 0 in the current build (included in origin_ms)." },
99+ tls_ms: { type: "integer", description: "Reported as 0 in the current build (included in origin_ms)." },
100+ origin_ms: { type: "integer", description: "Time to first byte on the final attempt, including connection and upstream routing." },
101+ processing_ms: { type: "integer", description: "Decompression and format conversion." },
102+ total_ms: { type: "integer", description: "Whole request, all attempts." },
103+ },
104+} as const;
105+
106+const fetchResponse = {
107+ type: "object",
108+ required: ["request_id", "success", "status", "url", "final_url", "content", "content_type", "headers", "cookies", "metadata"],
109+ properties: {
110+ request_id: { type: "string", example: "req_k3j9d0f2a8b1c7e4" },
111+ success: { type: "boolean", description: "true for a 2xx/3xx final response that was not classified as a block page." },
112+ status: { type: "integer", description: "HTTP status returned by the target on the final attempt." },
113+ url: { type: "string" },
114+ final_url: { type: "string", description: "URL after redirects." },
115+ content: { type: ["string", "null"], description: "Body for html/raw/json formats (null for text). Binary content types are base64-encoded." },
116+ content_type: { type: ["string", "null"] },
117+ headers: { type: "object", additionalProperties: { type: "string" }, description: "Target response headers, lower-cased names, content-encoding removed." },
118+ cookies: {
119+ type: "array",
120+ items: { type: "object", required: ["name", "value"], properties: { name: { type: "string" }, value: { type: "string" }, domain: { type: "string" }, path: { type: "string" } } },
121+ },
122+ text: { type: ["string", "null"], description: "Only for format=text." },
123+ json: { description: "Only for format=json when the body parsed as JSON." },
124+ metadata: {
125+ type: "object",
126+ required: ["network", "country", "attempts", "duration_ms", "bytes", "session", "cached"],
127+ properties: {
128+ network: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] },
129+ country: { type: ["string", "null"] },
130+ attempts: { type: "integer", minimum: 1 },
131+ duration_ms: { type: "integer" },
132+ bytes: { type: "integer", description: "Bytes transferred across all attempts." },
133+ session: { type: ["string", "null"] },
134+ cached: { type: "boolean", description: "Always false today." },
135+ timing: { $ref: "#/components/schemas/Timing" },
136+ debug: {
137+ type: "object",
138+ properties: {
139+ attempts: {
140+ type: "array",
141+ items: {
142+ type: "object",
143+ properties: {
144+ provider: { type: "string", description: "Neutral route alias (network-a, network-b, …)." },
145+ network: { type: "string" },
146+ country: { type: ["string", "null"] },
147+ outcome: { type: "string", enum: ["success", "blocked", "timeout", "error", "provider_error", "too_large"] },
148+ status: { type: ["integer", "null"] },
149+ duration_ms: { type: "integer" },
150+ },
151+ },
152+ },
153+ },
154+ },
155+ },
156+ },
157+ },
158+} as const;
159+
160+const sessionCreate = {
161+ type: "object",
162+ additionalProperties: false,
163+ properties: {
164+ ...geoProps,
165+ network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto" },
166+ ttl: { type: "integer", minimum: 60, maximum: 1800, default: 600, description: "Lifetime in seconds, fixed at creation." },
167+ label: { type: "string", maxLength: 128 },
168+ },
169+} as const;
170+
171+const session = {
172+ type: "object",
173+ required: ["id", "label", "status", "network", "country", "region", "city", "request_count", "last_used_at", "expires_at", "created_at"],
174+ properties: {
175+ id: { type: "string", example: "sess_8f2k1m9d3p7q4r6s" },
176+ label: { type: ["string", "null"] },
177+ status: { type: "string", enum: ["active", "expired", "closed"] },
178+ network: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] },
179+ country: { type: ["string", "null"] },
180+ region: { type: ["string", "null"], description: "Normalised slug." },
181+ city: { type: ["string", "null"], description: "Normalised slug." },
182+ request_count: { type: "integer" },
183+ last_used_at: { type: ["string", "null"], format: "date-time" },
184+ expires_at: { type: "string", format: "date-time" },
185+ created_at: { type: "string", format: "date-time" },
186+ },
187+} as const;
188+
189+const plans = Object.values(PLAN_LIMITS).map((p) => `${p.label}: ${p.concurrency} concurrent, ${p.monthly_requests >= Number.MAX_SAFE_INTEGER ? "unlimited" : p.monthly_requests.toLocaleString("en-US")} requests/month, ${p.max_timeout_ms / 1000} s max timeout, ${p.max_retries} max retries`);
190+
191+const document = {
192+ openapi: "3.1.0",
193+ info: {
194+ title: "Fetcha API",
195+ version: "0.1.0",
196+ summary: "Intelligent Web Access Infrastructure",
197+ description: [
198+ "One API to fetch web pages through Fetcha's routing engine. Authenticate with `Authorization: Bearer fch_live_…` (or `X-API-Key`).",
199+ "",
200+ "Every response carries `X-Fetcha-Request-ID`. Errors use a single envelope `{ error: { code, message, request_id, details? } }`.",
201+ "",
202+ "Plans: " + plans.join("; ") + ".",
203+ "",
204+ "Not yet available: managed browser (`browser: true`, `POST /v1/browser`), structured extraction (`POST /v1/extract`), webhooks, datacenter/isp/mobile network classes.",
205+ ].join("\n"),
206+ contact: { name: "Fetcha support", email: "support@fetcha.co", url: `${BASE_URL}/docs` },
207+ termsOfService: `${BASE_URL}/legal/terms`,
208+ },
209+ servers: [{ url: BASE_URL }],
210+ externalDocs: { url: `${BASE_URL}/docs`, description: "Fetcha documentation" },
211+ tags: [
212+ { name: "Fetch", description: "Retrieve URLs through the routing engine." },
213+ { name: "Sessions", description: "Sticky exit identities." },
214+ { name: "Account", description: "Key introspection and usage." },
215+ { name: "Health", description: "Public health probes (no authentication)." },
216+ ],
217+ security: [{ bearerAuth: [] }, { apiKeyHeader: [] }],
218+ paths: {
219+ "/v1/fetch": {
220+ post: {
221+ tags: ["Fetch"],
222+ operationId: "fetch",
223+ summary: "Fetch a URL",
224+ description:
225+ "Fetches the URL through the best available route, retrying and escalating on blocks within the request's single `timeout`. Returns 200 whenever the target answered, including 4xx/5xx and fully blocked targets (`success: false`). Requires scope `fetch:execute`.",
226+ requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/FetchRequest" }, example: { url: "https://example.com", country: "CA", format: "text" } } } },
227+ responses: {
228+ "200": {
229+ description: "Fetcha obtained a response from the target. Inspect `success` and `status`.",
230+ headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } },
231+ content: { "application/json": { schema: { $ref: "#/components/schemas/FetchResponse" } } },
232+ },
233+ ...byStatus([
234+ ...AUTH_ERRORS,
235+ "INVALID_REQUEST",
236+ "URL_NOT_ALLOWED",
237+ "NETWORK_UNAVAILABLE",
238+ "BROWSER_UNAVAILABLE",
239+ "USAGE_LIMIT_REACHED",
240+ "SESSION_NOT_FOUND",
241+ "SESSION_EXPIRED",
242+ "RATE_LIMITED",
243+ "CONCURRENCY_LIMIT",
244+ "INTERNAL_ERROR",
245+ "TARGET_UNAVAILABLE",
246+ "RESPONSE_TOO_LARGE",
247+ "TOO_MANY_REDIRECTS",
248+ "PROVIDER_UNAVAILABLE",
249+ "TARGET_TIMEOUT",
250+ ]),
251+ },
252+ },
253+ },
254+ "/v1/sessions": {
255+ post: {
256+ tags: ["Sessions"],
257+ operationId: "createSession",
258+ summary: "Create a session",
259+ description: "Creates a sticky session pinned to one route. Supports the `Idempotency-Key` header (24 h, per project). Requires scope `sessions:write`.",
260+ parameters: [{ name: "Idempotency-Key", in: "header", required: false, schema: { type: "string" }, description: "Return the session previously created with the same key instead of creating a new one." }],
261+ requestBody: { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/SessionCreate" }, example: { country: "CA", region: "QC", ttl: 900, label: "checkout-user-42" } } } },
262+ responses: {
263+ "200": { description: "Session created (or existing session returned for a known Idempotency-Key).", content: { "application/json": { schema: { $ref: "#/components/schemas/Session" } } } },
264+ ...byStatus([...AUTH_ERRORS, "INVALID_REQUEST", "NETWORK_UNAVAILABLE", "INTERNAL_ERROR"]),
265+ },
266+ },
267+ get: {
268+ tags: ["Sessions"],
269+ operationId: "listSessions",
270+ summary: "List sessions",
271+ description: "The 100 most recent sessions of the project, newest first, including expired and closed ones.",
272+ responses: {
273+ "200": { description: "Sessions.", content: { "application/json": { schema: { type: "object", required: ["data"], properties: { data: { type: "array", items: { $ref: "#/components/schemas/Session" } } } } } } },
274+ ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]),
275+ },
276+ },
277+ },
278+ "/v1/sessions/{id}": {
279+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" }, example: "sess_8f2k1m9d3p7q4r6s" }],
280+ get: {
281+ tags: ["Sessions"],
282+ operationId: "getSession",
283+ summary: "Get a session",
284+ responses: {
285+ "200": { description: "Session.", content: { "application/json": { schema: { $ref: "#/components/schemas/Session" } } } },
286+ ...byStatus([...AUTH_ERRORS, "SESSION_NOT_FOUND", "INTERNAL_ERROR"]),
287+ },
288+ },
289+ delete: {
290+ tags: ["Sessions"],
291+ operationId: "closeSession",
292+ summary: "Close a session",
293+ description: "Closes the session immediately. Requires scope `sessions:write`.",
294+ responses: {
295+ "200": { description: "Closed.", content: { "application/json": { schema: { type: "object", required: ["id", "status"], properties: { id: { type: "string" }, status: { type: "string", const: "closed" } } } } } },
296+ ...byStatus([...AUTH_ERRORS, "SESSION_NOT_FOUND", "INTERNAL_ERROR"]),
297+ },
298+ },
299+ },
300+ "/v1/me": {
301+ get: {
302+ tags: ["Account"],
303+ operationId: "me",
304+ summary: "Introspect the API key",
305+ description: "Validates the key and returns the project, organization and key metadata. No scope required.",
306+ responses: {
307+ "200": {
308+ description: "Principal.",
309+ content: {
310+ "application/json": {
311+ schema: {
312+ type: "object",
313+ required: ["project", "organization", "key"],
314+ properties: {
315+ project: { type: "object", properties: { id: { type: "string" }, name: { type: "string" } } },
316+ organization: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, plan: { type: "string", enum: Object.keys(PLAN_LIMITS) } } },
317+ key: {
318+ type: "object",
319+ properties: {
320+ id: { type: "string" },
321+ name: { type: "string" },
322+ mode: { type: "string", enum: ["live", "test"] },
323+ scopes: { type: "array", items: { type: "string", enum: ["fetch:execute", "browser:use", "sessions:write", "usage:read"] } },
324+ },
325+ },
326+ },
327+ },
328+ },
329+ },
330+ },
331+ ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]),
332+ },
333+ },
334+ },
335+ "/v1/usage": {
336+ get: {
337+ tags: ["Account"],
338+ operationId: "usage",
339+ summary: "Monthly usage",
340+ description: "Usage for the current calendar month (UTC) at organization and project level. Requires scope `usage:read`.",
341+ responses: {
342+ "200": {
343+ description: "Usage summary.",
344+ content: {
345+ "application/json": {
346+ schema: {
347+ type: "object",
348+ required: ["period_start", "plan", "organization", "project", "remaining_requests"],
349+ properties: {
350+ period_start: { type: "string", format: "date-time" },
351+ plan: { type: "object", properties: { id: { type: "string" }, label: { type: "string" }, monthly_requests: { type: "integer" }, concurrency: { type: "integer" } } },
352+ organization: { type: "object", properties: { requests: { type: "number" }, spend_usd: { type: "number" } } },
353+ project: {
354+ type: "object",
355+ properties: {
356+ requests: { type: "number" },
357+ spend_usd: { type: "number" },
358+ successful_requests: { type: "integer" },
359+ success_rate: { type: ["number", "null"], description: "Percentage with one decimal; null without requests." },
360+ bandwidth_bytes: { type: "number" },
361+ latency_p50_ms: { type: "integer" },
362+ latency_p95_ms: { type: "integer" },
363+ },
364+ },
365+ remaining_requests: { type: "integer" },
366+ },
367+ },
368+ },
369+ },
370+ },
371+ ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]),
372+ },
373+ },
374+ },
375+ "/api/health": {
376+ get: {
377+ tags: ["Health"],
378+ operationId: "health",
379+ summary: "Liveness",
380+ security: [],
381+ responses: { "200": { description: "OK.", content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", const: "ok" }, version: { type: "string" }, time: { type: "string", format: "date-time" } } } } } } },
382+ },
383+ },
384+ "/api/ready": {
385+ get: {
386+ tags: ["Health"],
387+ operationId: "ready",
388+ summary: "Readiness",
389+ security: [],
390+ responses: {
391+ "200": {
392+ description: "Ready.",
393+ content: {
394+ "application/json": {
395+ schema: {
396+ type: "object",
397+ properties: {
398+ status: { type: "string", enum: ["ready", "degraded"] },
399+ checks: { type: "object", properties: { database: { type: "boolean" }, cache: { type: "boolean" }, providers: { type: "boolean" } } },
400+ available_networks: { type: "array", items: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] } },
401+ },
402+ },
403+ },
404+ },
405+ },
406+ "503": { description: "Degraded (same body with status `degraded`)." },
407+ },
408+ },
409+ },
410+ },
411+ components: {
412+ securitySchemes: {
413+ bearerAuth: { type: "http", scheme: "bearer", description: "`Authorization: Bearer fch_live_…` or `fch_test_…`." },
414+ apiKeyHeader: { type: "apiKey", in: "header", name: "X-API-Key", description: "Alternative to the Authorization header. Takes precedence when both are present." },
415+ },
416+ headers: {
417+ "X-Fetcha-Request-ID": { schema: { type: "string" }, description: "Request identifier (req_…). Quote it to support." },
418+ },
419+ schemas: {
420+ Error: errorSchema,
421+ FetchRequest: fetchRequest,
422+ FetchResponse: fetchResponse,
423+ Timing: timing,
424+ SessionCreate: sessionCreate,
425+ Session: session,
426+ },
427+ },
428+ "x-error-catalogue": Object.fromEntries(ERROR_CODES.map((c) => [c, { status: ERROR_HTTP_STATUS[c], message: ERROR_MESSAGES[c] }])),
429+};
430+
431+export const dynamic = "force-static";
432+
433+export function GET() {
434+ return new Response(JSON.stringify(document, null, 2), {
435+ status: 200,
436+ headers: {
437+ "content-type": "application/json; charset=utf-8",
438+ "content-disposition": 'inline; filename="fetcha-openapi.json"',
439+ "cache-control": "public, max-age=3600",
440+ },
441+ });
442+}
added apps/web/src/app/(marketing)/docs/page.tsx +176 −0
@@ -0,0 +1,176 @@
1+import type { Metadata } from "next";
2+import { CodeBlock } from "@/components/ui/code-block";
3+import { DocPage } from "@/components/docs/doc-page";
4+import { A, CardGrid, Code, H2, H3, Lead, Li, LinkCard, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
5+import { Callout } from "@/components/docs/callout";
6+import { Badge } from "@/components/ui/badge";
7+
8+export const metadata: Metadata = {
9+ title: "Introduction",
10+ description: "What Fetcha is, how to think about it, what is live today and what is coming next.",
11+};
12+
13+const LIVE: Array<[string, string]> = [
14+ ["POST /v1/fetch", "Fetch any public http(s) URL through the routing engine. HTML, text, JSON or raw output."],
15+ ["Residential network + auto", "The residential class is live in every plan. auto currently resolves to residential."],
16+ ["Geolocation", "Country targeting (ISO 3166-1 alpha-2), with region and city hints."],
17+ ["Sessions", "Sticky exit identity for 60 to 1,800 seconds via /v1/sessions."],
18+ ["Retries & escalation", "Automatic retry with a new exit IP, block-page detection, per-domain intelligence, circuit breakers."],
19+ ["Account endpoints", "GET /v1/me and GET /v1/usage for key introspection and monthly usage."],
20+ ["Dashboard", "Projects, API keys, request logs with request IDs, usage and a Playground."],
21+];
22+
23+const SOON: Array<[string, string]> = [
24+ ["Managed browser", "browser: true and POST /v1/browser. Today browser: true returns BROWSER_UNAVAILABLE."],
25+ ["Structured extraction", "POST /v1/extract. Today it returns INVALID_REQUEST with an explanatory message."],
26+ ["Datacenter, ISP and mobile classes", "Requesting them explicitly returns NETWORK_UNAVAILABLE until they launch."],
27+ ["Webhooks", "Event delivery signed with HMAC-SHA256. Not configurable yet."],
28+ ["Self-serve billing", "Plan upgrades and checkout. Contact sales@fetcha.co in the meantime."],
29+ ["CLI", "A command-line client. Not available."],
30+ ["Teams & invitations", "Multi-member organizations."],
31+ ["Published SDK packages", "@fetcha/sdk on npm and fetcha on PyPI. Both exist as source in the repository today."],
32+];
33+
34+export default function IntroductionPage() {
35+ return (
36+ <DocPage
37+ path="/docs"
38+ title="Introduction"
39+ description="Fetcha is Intelligent Web Access Infrastructure: one HTTP API that fetches web pages for you and figures out which network, exit location and retry strategy each target needs."
40+ status="Stable"
41+ >
42+ <Lead>
43+ The mental model is deliberately simple: <Strong>you give Fetcha a URL, Fetcha figures out how to access it.</Strong> You do not manage proxies, rotate IPs, detect block pages or
44+ tune retry loops. You send one request and receive the page, the final URL, headers, cookies and metadata describing how the request was served.
45+ </Lead>
46+
47+ <H2>How it works</H2>
48+ <P>
49+ Every call to <Code>POST /v1/fetch</Code> goes through the same pipeline:
50+ </P>
51+ <Ul>
52+ <Li>
53+ <Strong>Validate.</Strong> The body is checked against a strict schema; unknown fields are rejected. Plan limits are applied (timeout is capped, network classes are gated).
54+ </Li>
55+ <Li>
56+ <Strong>Protect.</Strong> Only <Code>http</Code> and <Code>https</Code> are allowed. Localhost, private, link-local, cloud-metadata and internal hostnames are refused, and every redirect hop is
57+ re-validated before it is followed.
58+ </Li>
59+ <Li>
60+ <Strong>Route.</Strong> The routing engine scores the available routes for the target domain using historical success, cost, latency, network health, geography and session stability, then
61+ orders them from cheapest to most reliable.
62+ </Li>
63+ <Li>
64+ <Strong>Execute and escalate.</Strong> The request is sent through the best route. If the response looks like a block page (403, 429, captcha, anti-bot challenge, WAF page), Fetcha retries with
65+ a fresh exit identity or an alternate route within your plan&apos;s retry budget and the request&apos;s single overall timeout.
66+ </Li>
67+ <Li>
68+ <Strong>Learn.</Strong> Each attempt updates a per-domain profile so future requests to the same site start from the route most likely to succeed.
69+ </Li>
70+ </Ul>
71+
72+ <H2>Base URL</H2>
73+ <P>The API is served on the same domain as the website. There is no separate API host to configure.</P>
74+ <CodeBlock code={`https://www.fetcha.co`} lang="text" copy />
75+ <P>
76+ All endpoints are versioned under <Code>/v1</Code>. Requests and responses are JSON (<Code>Content-Type: application/json</Code>). Health probes are public at{" "}
77+ <Code>/api/health</Code> and <Code>/api/ready</Code>.
78+ </P>
79+
80+ <H2>Your first request</H2>
81+ <P>
82+ Authenticate with a Bearer API key and post a URL. A minimal call looks like this; see the <A href="/docs/quickstart">Quickstart</A> for the full walkthrough and the{" "}
83+ <A href="/docs/fetch">Fetch API</A> reference for every field.
84+ </P>
85+ <CodeBlock
86+ lang="bash"
87+ code={`curl https://www.fetcha.co/v1/fetch \\
88+ -X POST \\
89+ -H "Authorization: Bearer $FETCHA_API_KEY" \\
90+ -H "Content-Type: application/json" \\
91+ -d '{"url": "https://example.com", "country": "CA", "format": "text"}'`}
92+ />
93+
94+ <H2>Request IDs</H2>
95+ <P>
96+ Every response, successful or not, carries an <Code>X-Fetcha-Request-ID</Code> header of the form <Code>req_…</Code>. Fetch responses repeat it as <Code>request_id</Code> in the body, and
97+ error envelopes include it as <Code>error.request_id</Code>. The same identifier appears in your dashboard request log. Quote it when you write to{" "}
98+ <A href="mailto:support@fetcha.co">support@fetcha.co</A>: it lets us find the exact attempt sequence for your call.
99+ </P>
100+ <CodeBlock
101+ lang="text"
102+ code={`HTTP/1.1 200 OK
103+Content-Type: application/json; charset=utf-8
104+X-Fetcha-Request-ID: req_k3j9d0f2a8b1c7e4
105+X-Fetcha-Version: 0.1.0
106+Cache-Control: no-store`}
107+ />
108+
109+ <H2>What is live today</H2>
110+ <P>
111+ Fetcha is in its first public version. The table below is the authoritative list of what you can call right now; the section after it lists what is planned. The docs never describe a
112+ capability as working unless it is.
113+ </P>
114+ <Table>
115+ <THead>
116+ <Tr>
117+ <Th>Capability</Th>
118+ <Th>Status</Th>
119+ <Th>Notes</Th>
120+ </Tr>
121+ </THead>
122+ <TBody>
123+ {LIVE.map(([name, note]) => (
124+ <Tr key={name}>
125+ <Td mono>{name}</Td>
126+ <Td>
127+ <Badge variant="success" dot>
128+ Live
129+ </Badge>
130+ </Td>
131+ <Td>{note}</Td>
132+ </Tr>
133+ ))}
134+ </TBody>
135+ </Table>
136+
137+ <H3>Coming soon</H3>
138+ <Table>
139+ <THead>
140+ <Tr>
141+ <Th>Capability</Th>
142+ <Th>Status</Th>
143+ <Th>Current behaviour</Th>
144+ </Tr>
145+ </THead>
146+ <TBody>
147+ {SOON.map(([name, note]) => (
148+ <Tr key={name}>
149+ <Td mono>{name}</Td>
150+ <Td>
151+ <Badge variant="warning" dot>
152+ Coming soon
153+ </Badge>
154+ </Td>
155+ <Td>{note}</Td>
156+ </Tr>
157+ ))}
158+ </TBody>
159+ </Table>
160+ <Callout variant="info" title="Network classes">
161+ Fetcha exposes <Code>network</Code> classes (<Code>auto</Code>, <Code>datacenter</Code>, <Code>residential</Code>, <Code>isp</Code>, <Code>mobile</Code>) rather than the identity of the upstream
162+ networks behind them. Debug metadata refers to routes by neutral aliases such as <Code>network-a</Code>.
163+ </Callout>
164+
165+ <H2>Where to go next</H2>
166+ <CardGrid>
167+ <LinkCard href="/docs/quickstart" title="Quickstart" description="Create a key and make your first request in under five minutes." />
168+ <LinkCard href="/docs/fetch" title="Fetch API reference" description="Every request field, the response shape, size limits and redirect rules." />
169+ <LinkCard href="/docs/networks" title="Network selection" description="What auto does, how routes are scored and what each class means." />
170+ <LinkCard href="/docs/errors" title="Errors" description="All error codes with HTTP status, meaning and how to recover." />
171+ <LinkCard href="/docs/sdks" title="SDKs" description="JavaScript/TypeScript and Python clients, installed from source." />
172+ <LinkCard href="/docs/examples" title="Examples" description="Eight recipes covering sessions, POST bodies, blocks, concurrency and debugging." />
173+ </CardGrid>
174+ </DocPage>
175+ );
176+}
added apps/web/src/app/(marketing)/docs/quickstart/page.tsx +170 −0
@@ -0,0 +1,170 @@
1+import type { Metadata } from "next";
2+import { CodeBlock } from "@/components/ui/code-block";
3+import { DocPage } from "@/components/docs/doc-page";
4+import { A, Code, H2, Li, P, Step, Steps, Strong, Ul } from "@/components/docs/prose";
5+import { Callout } from "@/components/docs/callout";
6+import { CodeTabs } from "@/components/docs/code-tabs";
7+import { ResponseExample } from "@/components/docs/response-example";
8+
9+export const metadata: Metadata = {
10+ title: "Quickstart",
11+ description: "Create an account, generate an API key and make your first Fetcha request in cURL, JavaScript or Python.",
12+};
13+
14+const FIRST_REQUEST_CURL = `curl https://www.fetcha.co/v1/fetch -X POST -H "Authorization: Bearer $FETCHA_API_KEY" -H "Content-Type: application/json" -d '{"url": "https://example.com"}'`;
15+
16+const FIRST_REQUEST_JS = `const res = await fetch("https://www.fetcha.co/v1/fetch", {
17+ method: "POST",
18+ headers: {
19+ Authorization: \`Bearer \${process.env.FETCHA_API_KEY}\`,
20+ "Content-Type": "application/json",
21+ },
22+ body: JSON.stringify({ url: "https://example.com" }),
23+});
24+
25+const data = await res.json();
26+if (!res.ok) throw new Error(\`\${data.error.code}: \${data.error.message}\`);
27+
28+console.log(data.status); // 200
29+console.log(data.metadata.network); // "residential"
30+console.log(data.content.slice(0, 200));`;
31+
32+const FIRST_REQUEST_PY = `import os
33+import requests
34+
35+res = requests.post(
36+ "https://www.fetcha.co/v1/fetch",
37+ headers={
38+ "Authorization": f"Bearer {os.environ['FETCHA_API_KEY']}",
39+ "Content-Type": "application/json",
40+ },
41+ json={"url": "https://example.com"},
42+)
43+data = res.json()
44+if res.status_code >= 400:
45+ raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
46+
47+print(data["status"]) # 200
48+print(data["metadata"]["network"]) # "residential"
49+print(data["content"][:200])`;
50+
51+const RESPONSE = {
52+ request_id: "req_k3j9d0f2a8b1c7e4",
53+ success: true,
54+ status: 200,
55+ url: "https://example.com",
56+ final_url: "https://example.com/",
57+ content: "<!doctype html>\n<html>\n<head>\n <title>Example Domain</title>…",
58+ content_type: "text/html; charset=UTF-8",
59+ headers: {
60+ "content-type": "text/html; charset=UTF-8",
61+ "cache-control": "max-age=604800",
62+ date: "Mon, 07 Sep 2026 14:02:11 GMT",
63+ },
64+ cookies: [],
65+ metadata: {
66+ network: "residential",
67+ country: null,
68+ attempts: 1,
69+ duration_ms: 842,
70+ bytes: 2331,
71+ session: null,
72+ cached: false,
73+ timing: { dns_ms: 14, proxy_connect_ms: 0, tls_ms: 0, origin_ms: 611, processing_ms: 3, total_ms: 842 },
74+ },
75+};
76+
77+export default function QuickstartPage() {
78+ return (
79+ <DocPage path="/docs/quickstart" title="Quickstart" description="From zero to a fetched page in five steps. You need a Fetcha account, a verified email address and a terminal." status="Stable">
80+ <Steps>
81+ <Step title="Create an account">
82+ <P>
83+ Sign up at <A href="/signup">fetcha.co/signup</A> with your email address and a password. A personal organization (<Code>Your name&apos;s workspace</Code>) and a first project named{" "}
84+ <Code>Default</Code> are created automatically; you can rename them later in the dashboard.
85+ </P>
86+ </Step>
87+ <Step title="Verify your email">
88+ <P>
89+ Open the verification link we send you. Until the email is verified, every API call returns <Code>403 EMAIL_NOT_VERIFIED</Code>. The dashboard Playground works before verification so
90+ you can explore, but real API keys do not.
91+ </P>
92+ </Step>
93+ <Step title="Create an API key">
94+ <P>
95+ In the dashboard, open your project, go to <Strong>API keys</Strong> and create a key. Choose <Code>live</Code> mode (keys start with <Code>fch_live_</Code>) and keep the default scopes{" "}
96+ <Code>fetch:execute</Code>, <Code>sessions:write</Code> and <Code>usage:read</Code>.
97+ </P>
98+ <Callout variant="warning" title="The key is shown once">
99+ Fetcha stores only a SHA-256 hash of the key. Copy it immediately and put it in an environment variable. If you lose it, rotate the key to get a new one.
100+ </Callout>
101+ <CodeBlock lang="bash" code={`export FETCHA_API_KEY="fch_live_..."`} />
102+ </Step>
103+ <Step title="Make your first request">
104+ <P>
105+ Post a URL to <Code>/v1/fetch</Code>. This exact one-liner is the smallest valid request: no country, no network, default <Code>html</Code> format, 30-second timeout.
106+ </P>
107+ <CodeBlock lang="bash" code={FIRST_REQUEST_CURL} title="Terminal" />
108+ <P>The same request from application code:</P>
109+ <CodeTabs
110+ tabs={[
111+ { label: "cURL", lang: "bash", code: FIRST_REQUEST_CURL },
112+ { label: "JavaScript", lang: "javascript", code: FIRST_REQUEST_JS },
113+ { label: "Python", lang: "python", code: FIRST_REQUEST_PY },
114+ ]}
115+ />
116+ </Step>
117+ <Step title="Read the response">
118+ <P>
119+ A fetch returns HTTP <Code>200</Code> whenever Fetcha reached the target, even if the target itself answered with an error. Look at <Code>success</Code> and <Code>status</Code>{" "}
120+ to know how the origin responded, and at <Code>metadata</Code> to see how the request was routed.
121+ </P>
122+ <ResponseExample status={200} body={RESPONSE} />
123+ <Ul>
124+ <Li>
125+ <Code>success</Code> is <Code>true</Code> when the origin answered with a 2xx or 3xx status and the page did not look like a block. A blocked target returns{" "}
126+ <Code>success: false</Code> with the origin&apos;s status (for example 403) and <Code>metadata.attempts</Code> greater than one.
127+ </Li>
128+ <Li>
129+ <Code>content</Code> holds the body as a string for <Code>html</Code>, <Code>json</Code> and <Code>raw</Code> formats. Ask for <Code>{`"format": "text"`}</Code> to receive a readable{" "}
130+ <Code>text</Code> field instead, or <Code>{`"format": "json"`}</Code> to get a parsed <Code>json</Code> field.
131+ </Li>
132+ <Li>
133+ <Code>metadata.network</Code> is the concrete network class that served the request (<Code>residential</Code> today). <Code>metadata.country</Code> echoes the country you targeted, or{" "}
134+ <Code>null</Code>.
135+ </Li>
136+ <Li>
137+ <Code>request_id</Code> is what you quote to support. It also appears in the <Code>X-Fetcha-Request-ID</Code> response header.
138+ </Li>
139+ </Ul>
140+ </Step>
141+ <Step title="Open the Playground">
142+ <P>
143+ The dashboard <A href="/dashboard/playground">Playground</A> runs the same pipeline as the API against your current project, without needing a key in your terminal. Use it to try
144+ countries, formats and sessions, inspect timing, and copy the generated code. Requests made there are counted in your usage and appear in the request log with source{" "}
145+ <Code>playground</Code>.
146+ </P>
147+ </Step>
148+ </Steps>
149+
150+ <H2>Next steps</H2>
151+ <Ul>
152+ <Li>
153+ Target a country with <Code>{`"country": "CA"`}</Code>: see <A href="/docs/geolocation">Geolocation</A>.
154+ </Li>
155+ <Li>
156+ Keep the same exit IP across several requests: see <A href="/docs/sessions">Sessions</A>.
157+ </Li>
158+ <Li>
159+ Understand every field and limit: see the <A href="/docs/fetch">Fetch API reference</A>.
160+ </Li>
161+ <Li>
162+ Handle failures properly: see <A href="/docs/errors">Errors</A> and <A href="/docs/retries">Retries</A>.
163+ </Li>
164+ <Li>
165+ Prefer a typed client: see <A href="/docs/sdks">SDKs</A> (JavaScript/TypeScript and Python, installed from source for now).
166+ </Li>
167+ </Ul>
168+ </DocPage>
169+ );
170+}
added apps/web/src/app/(marketing)/docs/rate-limits/page.tsx +224 −0
@@ -0,0 +1,224 @@
1+import type { Metadata } from "next";
2+import { PLAN_LIMITS, PLANS } from "@fetcha/core";
3+import { CodeBlock } from "@/components/ui/code-block";
4+import { DocPage } from "@/components/docs/doc-page";
5+import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
6+import { Callout } from "@/components/docs/callout";
7+import { Endpoint } from "@/components/docs/endpoint";
8+import { CodeTabs } from "@/components/docs/code-tabs";
9+import { ResponseExample } from "@/components/docs/response-example";
10+import { apiTabs } from "@/components/docs/snippets";
11+
12+export const metadata: Metadata = {
13+ title: "Rate Limits",
14+ description: "Concurrency per plan, sliding-window rate limits, RATE_LIMITED vs CONCURRENCY_LIMIT, monthly quotas, spending limits, USAGE_LIMIT_REACHED and response headers.",
15+};
16+
17+const fmt = (n: number) => (n >= Number.MAX_SAFE_INTEGER ? "Unlimited" : n.toLocaleString("en-US"));
18+
19+export default function RateLimitsPage() {
20+ return (
21+ <DocPage path="/docs/rate-limits" eyebrow="Reliability" title="Rate limits" description="Fetcha enforces three kinds of limits: how many requests run at once, how fast you can submit them, and how many you can make per month. All are derived from your plan." status="Stable">
22+ <H2>Concurrency</H2>
23+ <P>
24+ Concurrency is the number of fetch requests in flight at the same time, counted per <Strong>organization</Strong> (all projects and keys together). It is the limit that matters most in
25+ practice: a fetch takes a second or more, so sustained throughput is roughly concurrency divided by average latency.
26+ </P>
27+ <Table>
28+ <THead>
29+ <Tr>
30+ <Th>Plan</Th>
31+ <Th>Concurrent requests</Th>
32+ <Th>Monthly requests</Th>
33+ <Th>Log retention</Th>
34+ </Tr>
35+ </THead>
36+ <TBody>
37+ {PLANS.map((p) => (
38+ <Tr key={p}>
39+ <Td className="font-medium text-fg">{PLAN_LIMITS[p].label}</Td>
40+ <Td mono>
41+ {PLAN_LIMITS[p].concurrency.toLocaleString("en-US")}
42+ {p === "enterprise" ? <span className="ml-1 font-sans text-fg-subtle">(custom on request)</span> : null}
43+ </Td>
44+ <Td mono>{fmt(PLAN_LIMITS[p].monthly_requests)}</Td>
45+ <Td mono>{PLAN_LIMITS[p].retention_days} days</Td>
46+ </Tr>
47+ ))}
48+ </TBody>
49+ </Table>
50+ <P>
51+ When a request would exceed the limit it is refused immediately with <Code>429 CONCURRENCY_LIMIT</Code>; nothing is queued on Fetcha&apos;s side. A slot is released as soon as a request
52+ completes (successfully or not). As a safety net, a slot that is somehow never released expires after 150 seconds.
53+ </P>
54+ <ResponseExample status={429} statusText="Too Many Requests" body={{ error: { code: "CONCURRENCY_LIMIT", message: "Your plan allows 25 concurrent requests.", request_id: "req_1l2m3n4o5p6q7r8s", details: { limit: 25 } } }} />
55+
56+ <H2>Request rate</H2>
57+ <P>
58+ Independently of concurrency, submissions are metered with sliding windows. They are set well above what the concurrency limit lets you sustain, so they only bite on bursts or runaway
59+ loops. Three windows are checked on every fetch:
60+ </P>
61+ <Table>
62+ <THead>
63+ <Tr>
64+ <Th>Window</Th>
65+ <Th>Scope</Th>
66+ <Th>Limit</Th>
67+ </Tr>
68+ </THead>
69+ <TBody>
70+ <Tr>
71+ <Td>60 seconds</Td>
72+ <Td>Organization (sustained)</Td>
73+ <Td>
74+ <Code>max(60, concurrency × 60)</Code> requests per minute
75+ </Td>
76+ </Tr>
77+ <Tr>
78+ <Td>1 second</Td>
79+ <Td>API key (burst)</Td>
80+ <Td>
81+ <Code>max(10, concurrency × 2)</Code> requests per second
82+ </Td>
83+ </Tr>
84+ <Tr>
85+ <Td>1 second</Td>
86+ <Td>Client IP (abuse)</Td>
87+ <Td>200 requests per second</Td>
88+ </Tr>
89+ </TBody>
90+ </Table>
91+ <H3>Resulting values per plan</H3>
92+ <Table dense>
93+ <THead>
94+ <Tr>
95+ <Th>Plan</Th>
96+ <Th>Per organization / minute</Th>
97+ <Th>Per key / second</Th>
98+ </Tr>
99+ </THead>
100+ <TBody>
101+ {PLANS.map((p) => {
102+ const c = PLAN_LIMITS[p].concurrency;
103+ return (
104+ <Tr key={p}>
105+ <Td className="font-medium text-fg">{PLAN_LIMITS[p].label}</Td>
106+ <Td mono>{Math.max(60, c * 60).toLocaleString("en-US")}</Td>
107+ <Td mono>{Math.max(10, c * 2).toLocaleString("en-US")}</Td>
108+ </Tr>
109+ );
110+ })}
111+ </TBody>
112+ </Table>
113+ <P>
114+ Exceeding any window returns <Code>429 RATE_LIMITED</Code> with a <Code>Retry-After</Code> header (seconds, rounded up) and <Code>details.retry_after_ms</Code>.
115+ </P>
116+ <ResponseExample status={429} statusText="Too Many Requests" body={{ error: { code: "RATE_LIMITED", message: "Too many requests. Slow down or upgrade your plan.", request_id: "req_4e5f6g7h8i9j0k1l", details: { retry_after_ms: 640 } } }} />
117+
118+ <H3>RATE_LIMITED vs CONCURRENCY_LIMIT</H3>
119+ <Ul>
120+ <Li>
121+ <Code>RATE_LIMITED</Code>: you submitted too many requests in a short window. Wait <Code>Retry-After</Code> seconds, then continue. Smooth your submission rate.
122+ </Li>
123+ <Li>
124+ <Code>CONCURRENCY_LIMIT</Code>: too many requests are running right now. Do not sleep a fixed time; retry when one of your in-flight requests finishes. Use a semaphore or worker pool sized
125+ to your plan (see the <A href="/docs/examples#concurrency-within-plan-limits">concurrency example</A>).
126+ </Li>
127+ </Ul>
128+
129+ <H2>Monthly quota</H2>
130+ <P>
131+ Each plan includes a number of requests per calendar month (UTC). Usage is counted per organization across all projects and keys. <Strong>Every request that reaches the execution
132+ pipeline counts</Strong>, including those that end blocked or failed; requests refused earlier (authentication, validation, rate limits, session errors) do not. Free and Enterprise have no
133+ overage; paid plans continue past the included volume only once self-serve billing is available.
134+ </P>
135+ <P>
136+ When the quota is exhausted, fetches return <Code>402 USAGE_LIMIT_REACHED</Code> until the first day of the next month. Usage counters are cached for up to 20 seconds, so enforcement can
137+ lag a few requests behind the exact limit.
138+ </P>
139+ <ResponseExample
140+ status={402}
141+ statusText="Payment Required"
142+ body={{ error: { code: "USAGE_LIMIT_REACHED", message: "Monthly request quota of 1,000 reached for the Free plan.", request_id: "req_8s9t0u1v2w3x4y5z", details: { limit: 1000, used: 1000 } } }}
143+ />
144+
145+ <H2>Spending and project limits</H2>
146+ <P>Beyond the plan quota, you can cap usage yourself in the dashboard. All of these produce the same <Code>USAGE_LIMIT_REACHED</Code> code with a specific message and details:</P>
147+ <Table dense>
148+ <THead>
149+ <Tr>
150+ <Th>Limit</Th>
151+ <Th>Scope</Th>
152+ <Th>Effect when reached</Th>
153+ <Th>details</Th>
154+ </Tr>
155+ </THead>
156+ <TBody>
157+ <Tr>
158+ <Td>Monthly request limit</Td>
159+ <Td>Project</Td>
160+ <Td>Fetches in that project are refused</Td>
161+ <Td mono>{`{ limit, used }`}</Td>
162+ </Tr>
163+ <Tr>
164+ <Td>Hard spending limit (USD)</Td>
165+ <Td>Organization</Td>
166+ <Td>All fetches are refused</Td>
167+ <Td mono>{`{ limit_usd, spent_usd }`}</Td>
168+ </Tr>
169+ <Tr>
170+ <Td>Hard spending limit (USD)</Td>
171+ <Td>Project</Td>
172+ <Td>Fetches in that project are refused</Td>
173+ <Td mono>{`{ limit_usd, spent_usd }`}</Td>
174+ </Tr>
175+ <Tr>
176+ <Td>Soft spending limit (USD)</Td>
177+ <Td>Organization or project</Td>
178+ <Td>Alert only; traffic continues</Td>
179+ <Td>&mdash;</Td>
180+ </Tr>
181+ </TBody>
182+ </Table>
183+ <Callout variant="info" title="Spend is computed from your plan's prices">
184+ Spend is the customer price of your usage (per-request overage plus residential bandwidth at your plan rate), accumulated over the calendar month. It is what <Code>GET /v1/usage</Code>{" "}
185+ reports as <Code>spend_usd</Code>.
186+ </Callout>
187+
188+ <H2>Checking your usage</H2>
189+ <Endpoint method="GET" path="/v1/usage" scope="usage:read" status="Live" />
190+ <CodeTabs tabs={apiTabs({ method: "GET", path: "/v1/usage" }, ["curl", "javascript", "python"])} />
191+ <ResponseExample
192+ status={200}
193+ body={{
194+ period_start: "2026-09-01T00:00:00.000Z",
195+ plan: { id: "developer", label: "Developer", monthly_requests: 50000, concurrency: 25 },
196+ organization: { requests: 12840, spend_usd: 3.2115 },
197+ project: { requests: 9911, spend_usd: 2.4402, successful_requests: 9640, success_rate: 97.3, bandwidth_bytes: 3892214411, latency_p50_ms: 1180, latency_p95_ms: 4210 },
198+ remaining_requests: 37160,
199+ }}
200+ />
201+ <Ul>
202+ <Li>
203+ <Code>organization</Code> covers every project; <Code>project</Code> is the key&apos;s project only. Quotas apply to the organization figures.
204+ </Li>
205+ <Li>
206+ <Code>remaining_requests</Code> is the plan quota minus organization requests this month, never negative.
207+ </Li>
208+ <Li>
209+ <Code>success_rate</Code> is a percentage with one decimal, or <Code>null</Code> when the project made no requests this month.
210+ </Li>
211+ </Ul>
212+
213+ <H2>Response headers</H2>
214+ <P>Fetcha currently sets these headers on every API response. There are no <Code>X-RateLimit-*</Code> counters yet; use <Code>GET /v1/usage</Code> for remaining quota.</P>
215+ <CodeBlock
216+ lang="text"
217+ code={`X-Fetcha-Request-ID: req_… # always; quote it to support
218+X-Fetcha-Version: 0.1.0 # API version
219+Cache-Control: no-store # responses are never cacheable
220+Retry-After: <seconds> # only on 429 RATE_LIMITED`}
221+ />
222+ </DocPage>
223+ );
224+}
added apps/web/src/app/(marketing)/docs/retries/page.tsx +225 −0
@@ -0,0 +1,225 @@
1+import type { Metadata } from "next";
2+import { PLAN_LIMITS, PLANS } from "@fetcha/core";
3+import { CodeBlock } from "@/components/ui/code-block";
4+import { DocPage } from "@/components/docs/doc-page";
5+import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
6+import { Callout } from "@/components/docs/callout";
7+import { CodeTabs } from "@/components/docs/code-tabs";
8+import { ResponseExample } from "@/components/docs/response-example";
9+import { fetchTabs } from "@/components/docs/snippets";
10+
11+export const metadata: Metadata = {
12+ title: "Retries",
13+ description: "How Fetcha retries and escalates: what triggers a retry, the retries option and plan maximums, the shared timeout, attempt metadata, cost implications and idempotency.",
14+};
15+
16+export default function RetriesPage() {
17+ return (
18+ <DocPage path="/docs/retries" eyebrow="Reliability" title="Retries" description="Retries are built into every fetch. When a target blocks or a route fails, Fetcha tries again with a new exit identity and, when it helps, a different route, all within a single request and a single timeout." status="Stable">
19+ <H2>How a retry works</H2>
20+ <P>
21+ Before executing, the routing engine produces an ordered list of candidate routes for the request (see <A href="/docs/networks">Network selection</A>). Fetcha walks that list:
22+ </P>
23+ <Ul>
24+ <Li>
25+ <Strong>New IP.</Strong> Each attempt asks the network for a fresh exit identity, so a second attempt on the same route already comes from a different address. The exception is a request
26+ pinned to a <A href="/docs/sessions">session</A>, which deliberately keeps its identity.
27+ </Li>
28+ <Li>
29+ <Strong>Alternate route.</Strong> Distinct routes are preferred before repeating one. If several routes serve the same class, the second attempt typically goes through another one.
30+ </Li>
31+ <Li>
32+ <Strong>Escalation.</Strong> In <Code>auto</Code> mode, routes are ordered by class from cheapest to most reliable, so retries climb toward premium classes as they launch. With a single
33+ live class today, escalation happens between routes inside that class.
34+ </Li>
35+ <Li>
36+ <Strong>Learning.</Strong> Every attempt, successful or not, updates the domain profile and the route&apos;s circuit breaker.
37+ </Li>
38+ </Ul>
39+
40+ <H3>What triggers a retry</H3>
41+ <Table dense>
42+ <THead>
43+ <Tr>
44+ <Th>Outcome of an attempt</Th>
45+ <Th>Retried?</Th>
46+ <Th>If all attempts fail</Th>
47+ </Tr>
48+ </THead>
49+ <TBody>
50+ <Tr>
51+ <Td>Block page: HTTP 403, 407, 429 or 999; 503 challenge; captcha, anti-bot or WAF signatures</Td>
52+ <Td className="text-success">Yes</Td>
53+ <Td>
54+ <Code>200</Code> with <Code>success: false</Code> and the last blocked page
55+ </Td>
56+ </Tr>
57+ <Tr>
58+ <Td>Timeout waiting for the target</Td>
59+ <Td className="text-success">Yes, while budget remains</Td>
60+ <Td mono>504 TARGET_TIMEOUT</Td>
61+ </Tr>
62+ <Tr>
63+ <Td>Connection failure to the target</Td>
64+ <Td className="text-success">Yes</Td>
65+ <Td mono>502 TARGET_UNAVAILABLE</Td>
66+ </Tr>
67+ <Tr>
68+ <Td>Network-side failure (gateway error, upstream auth, 5xx from the route)</Td>
69+ <Td className="text-success">Yes</Td>
70+ <Td mono>503 PROVIDER_UNAVAILABLE</Td>
71+ </Tr>
72+ <Tr>
73+ <Td>2xx/3xx response that is not a block</Td>
74+ <Td>No, returned immediately</Td>
75+ <Td>&mdash;</Td>
76+ </Tr>
77+ <Tr>
78+ <Td>4xx/5xx response that is not a block (plain 404, 500, …)</Td>
79+ <Td>No, returned immediately with <Code>success: false</Code></Td>
80+ <Td>&mdash;</Td>
81+ </Tr>
82+ <Tr>
83+ <Td>Response larger than the size cap</Td>
84+ <Td className="text-danger">No</Td>
85+ <Td mono>502 RESPONSE_TOO_LARGE</Td>
86+ </Tr>
87+ <Tr>
88+ <Td>
89+ More than <Code>max_redirects</Code> hops
90+ </Td>
91+ <Td className="text-danger">No</Td>
92+ <Td mono>502 TOO_MANY_REDIRECTS</Td>
93+ </Tr>
94+ <Tr>
95+ <Td>Redirect to a private or internal host</Td>
96+ <Td className="text-danger">No</Td>
97+ <Td mono>400 URL_NOT_ALLOWED</Td>
98+ </Tr>
99+ </TBody>
100+ </Table>
101+
102+ <H2>The retries option</H2>
103+ <P>
104+ <Code>retries</Code> is the number of <Strong>additional</Strong> attempts after the first; total attempts are <Code>retries + 1</Code>. Omit it to use your plan&apos;s maximum. Values above
105+ the plan maximum are reduced silently; the schema accepts 0 to 5.
106+ </P>
107+ <Table>
108+ <THead>
109+ <Tr>
110+ <Th>Plan</Th>
111+ <Th>Max retries</Th>
112+ <Th>Max attempts</Th>
113+ <Th>Max timeout</Th>
114+ </Tr>
115+ </THead>
116+ <TBody>
117+ {PLANS.map((p) => (
118+ <Tr key={p}>
119+ <Td className="font-medium text-fg">{PLAN_LIMITS[p].label}</Td>
120+ <Td mono>{PLAN_LIMITS[p].max_retries}</Td>
121+ <Td mono>{PLAN_LIMITS[p].max_retries + 1}</Td>
122+ <Td mono>{PLAN_LIMITS[p].max_timeout_ms / 1000} s</Td>
123+ </Tr>
124+ ))}
125+ </TBody>
126+ </Table>
127+ <P>
128+ The candidate list is also bounded by how many distinct routes exist for the request. When there are fewer routes than attempts, the engine repeats the best route (with a new IP each time),
129+ so <Code>metadata.attempts</Code> may come out lower than <Code>retries + 1</Code> even on a fully blocked target.
130+ </P>
131+ <CodeTabs tabs={fetchTabs({ url: "https://shop.example.net/", retries: 0 }, { javascript: `console.log(data.metadata.attempts); // always 1 with retries: 0`, python: `print(data["metadata"]["attempts"]) # always 1 with retries: 0` }, ["curl", "javascript", "python"])} />
132+
133+ <H2>One timeout for everything</H2>
134+ <P>
135+ <Code>timeout</Code> is the budget for the <Strong>whole</Strong> request, not per attempt. Each attempt receives what is left; when fewer than 500 ms remain, Fetcha stops and returns{" "}
136+ <Code>TARGET_TIMEOUT</Code>. If you rely on several attempts against slow targets, size <Code>timeout</Code> accordingly (up to your plan cap) rather than raising <Code>retries</Code>.
137+ </P>
138+
139+ <H2>Attempts in metadata</H2>
140+ <P>
141+ <Code>metadata.attempts</Code> counts every attempt including the final one. <Code>metadata.duration_ms</Code> and <Code>metadata.bytes</Code> cover all of them. With <Code>debug: true</Code>,{" "}
142+ <Code>metadata.debug.attempts</Code> lists each attempt with the route alias, network, country, outcome, target status and duration.
143+ </P>
144+ <ResponseExample
145+ status={200}
146+ title="200 OK · metadata after one block and one success"
147+ body={{
148+ metadata: {
149+ network: "residential",
150+ country: "US",
151+ attempts: 2,
152+ duration_ms: 3140,
153+ bytes: 71822,
154+ session: null,
155+ cached: false,
156+ timing: { dns_ms: 17, proxy_connect_ms: 0, tls_ms: 0, origin_ms: 1402, processing_ms: 9, total_ms: 3140 },
157+ debug: {
158+ attempts: [
159+ { provider: "network-a", network: "residential", country: "US", outcome: "blocked", status: 403, duration_ms: 1210 },
160+ { provider: "network-b", network: "residential", country: "US", outcome: "success", status: 200, duration_ms: 1888 },
161+ ],
162+ },
163+ },
164+ }}
165+ />
166+
167+ <H2>Cost implications</H2>
168+ <Ul>
169+ <Li>
170+ <Strong>Bandwidth is counted for every attempt.</Strong> <Code>metadata.bytes</Code> sums request and response bytes across attempts, and on paid plans residential bandwidth is billed
171+ per GB on that total. A block page is usually small, but a large page that gets blocked after being fully downloaded costs its size.
172+ </Li>
173+ <Li>
174+ <Strong>The per-request fee applies only to successful requests.</Strong> On plans with an overage rate, a request that ends with <Code>success: false</Code> or an error carries no
175+ request fee.
176+ </Li>
177+ <Li>
178+ <Strong>Every request counts toward the monthly quota</Strong>, including failed and blocked ones. This is an abuse control; see <A href="/docs/rate-limits">Rate limits</A>.
179+ </Li>
180+ <Li>
181+ Requests refused before any attempt (validation, limits, <Code>URL_NOT_ALLOWED</Code>, session errors) transfer no bytes and cost nothing beyond the quota count.
182+ </Li>
183+ </Ul>
184+
185+ <H2>Idempotency for POST and other writes</H2>
186+ <Callout variant="warning" title="Retries replay the request">
187+ A retry sends the <Strong>same method and body</Strong> again. If your <Code>POST</Code> creates an order, sends a message or otherwise has side effects on the target, an attempt that was
188+ processed by the origin but answered with something Fetcha classifies as a block (a 429 for instance) would be replayed.
189+ </Callout>
190+ <Ul>
191+ <Li>
192+ Set <Code>{`"retries": 0`}</Code> for non-idempotent writes and implement your own retry decision based on <Code>status</Code>.
193+ </Li>
194+ <Li>Where the target supports it, include an idempotency token in your body or headers so replays are harmless.</Li>
195+ <Li>
196+ Remember that a 301/302 after a POST, and any 303, is followed as a <Code>GET</Code> without the body (standard browser behaviour).
197+ </Li>
198+ <Li>
199+ For <Code>POST /v1/sessions</Code>, use the <Code>Idempotency-Key</Code> header to make session creation safe to retry on your side.
200+ </Li>
201+ </Ul>
202+
203+ <H2>Retrying on your side</H2>
204+ <P>
205+ Fetcha already retries target-side problems. Client-side retries are still appropriate for <Code>RATE_LIMITED</Code> (honour <Code>Retry-After</Code>), <Code>CONCURRENCY_LIMIT</Code> (when a
206+ slot frees up), <Code>PROVIDER_UNAVAILABLE</Code> and <Code>INTERNAL_ERROR</Code> (exponential backoff), and for a <Code>success: false</Code> block you may want to retry later with a different
207+ country or a fresh session.
208+ </P>
209+ <CodeBlock
210+ lang="typescript"
211+ code={`async function fetchWithBackoff(opts: FetchOptions, maxTries = 4) {
212+ for (let i = 0; ; i++) {
213+ try {
214+ return await fetcha.fetch(opts);
215+ } catch (e) {
216+ const transient = e instanceof FetchaError && ["RATE_LIMITED", "CONCURRENCY_LIMIT", "PROVIDER_UNAVAILABLE", "INTERNAL_ERROR"].includes(e.code);
217+ if (!transient || i + 1 >= maxTries) throw e;
218+ await new Promise((r) => setTimeout(r, Math.min(8000, 500 * 2 ** i) + Math.random() * 250));
219+ }
220+ }
221+}`}
222+ />
223+ </DocPage>
224+ );
225+}
added apps/web/src/app/(marketing)/docs/sdks/page.tsx +268 −0
@@ -0,0 +1,268 @@
1+import type { Metadata } from "next";
2+import { CodeBlock } from "@/components/ui/code-block";
3+import { DocPage } from "@/components/docs/doc-page";
4+import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
5+import { Callout } from "@/components/docs/callout";
6+import { ParamTable } from "@/components/docs/param-table";
7+import { CodeTabs } from "@/components/docs/code-tabs";
8+
9+export const metadata: Metadata = {
10+ title: "SDKs",
11+ description: "Official JavaScript/TypeScript and Python clients for Fetcha: installation from source, configuration, fetch/text/json helpers, sessions, usage, error classes.",
12+};
13+
14+const JS_INSTALL = `# From a checkout of the Fetcha repository, inside your project:
15+pnpm add ./packages/sdk # or: npm install ./packages/sdk
16+
17+# Or simply copy the single source file into your codebase:
18+cp packages/sdk/src/index.ts src/lib/fetcha.ts`;
19+
20+const PY_INSTALL = `# From a checkout of the Fetcha repository:
21+pip install ./sdk-python
22+
23+# Requirements: Python >= 3.9, httpx >= 0.27 (installed automatically)`;
24+
25+const JS_USAGE = `import { Fetcha, FetchaError } from "@fetcha/sdk";
26+
27+const fetcha = new Fetcha({ apiKey: process.env.FETCHA_API_KEY! });
28+
29+// Full control: every /v1/fetch field is available
30+const r = await fetcha.fetch({ url: "https://example.com/products/42", country: "CA", format: "html", retries: 2 });
31+console.log(r.success, r.status, r.metadata.network, r.metadata.attempts);
32+console.log(r.content?.slice(0, 200));
33+
34+// Readable text (format: "text")
35+const text = await fetcha.text("https://example.com/about", { country: "CA" });
36+
37+// Parsed JSON (format: "json"); the generic sets the return type
38+type Price = { amount: number; currency: string };
39+const price = await fetcha.json<Price>("https://api.example.com/v1/price?sku=42");`;
40+
41+const PY_USAGE = `from fetcha import Fetcha, FetchaError
42+
43+client = Fetcha(api_key=os.environ["FETCHA_API_KEY"])
44+
45+# Full control: every /v1/fetch field is a keyword argument
46+r = client.fetch("https://example.com/products/42", country="CA", format="html", retries=2)
47+print(r.success, r.status, r.metadata["network"], r.metadata["attempts"])
48+print(r.content[:200])
49+
50+# Readable text (format="text")
51+text = client.text("https://example.com/about", country="CA")
52+
53+# Parsed JSON (format="json")
54+price = client.json("https://api.example.com/v1/price?sku=42")
55+
56+# The client owns an httpx.Client; close it when done (or use it as a context manager)
57+with Fetcha(api_key=os.environ["FETCHA_API_KEY"]) as c:
58+ print(c.me()["project"]["name"])`;
59+
60+const JS_SESSIONS = `// Create (optionally idempotent), use, inspect, list, close
61+const s = await fetcha.sessions.create({ country: "CA", ttl: 900, label: "user-42" }, "user-42-login");
62+const page = await fetcha.fetch({ url: "https://www.example.ca/account", session: s.id, cookies: { sid: "…" } });
63+
64+const same = await fetcha.sessions.get(s.id); // { id, status, network, country, expires_at, created_at, … }
65+const all = await fetcha.sessions.list(); // { data: Session[] }
66+await fetcha.sessions.close(s.id); // { id, status: "closed" }`;
67+
68+const PY_SESSIONS = `# Create, use, inspect, list, close
69+s = client.sessions.create(country="CA", ttl=900, label="user-42")
70+page = client.fetch("https://www.example.ca/account", session=s.id, cookies={"sid": "…"})
71+
72+same = client.sessions.get(s.id) # Session dataclass; .raw has the full payload
73+all_sessions = client.sessions.list() # list[Session]
74+client.sessions.close(s.id) # {"id": ..., "status": "closed"}`;
75+
76+const JS_ACCOUNT = `const me = await fetcha.me();
77+// { project: { id, name }, organization: { id, name, plan }, key: { id, name, scopes } }
78+
79+const usage = await fetcha.usage.current();
80+// { period_start, plan, organization, project, remaining_requests } (see Rate limits)`;
81+
82+const PY_ACCOUNT = `me = client.me()
83+# {"project": {...}, "organization": {..., "plan": "developer"}, "key": {..., "scopes": [...]}}
84+
85+usage = client.usage()
86+# {"period_start": ..., "plan": ..., "organization": ..., "project": ..., "remaining_requests": ...}`;
87+
88+const JS_ERRORS = `try {
89+ await fetcha.fetch({ url: "http://169.254.169.254/latest/meta-data/" });
90+} catch (e) {
91+ if (e instanceof FetchaError) {
92+ e.code; // "URL_NOT_ALLOWED"
93+ e.status; // 400
94+ e.message; // "Requests to local, private or internal hosts are not allowed."
95+ e.requestId; // "req_…" | null
96+ }
97+}`;
98+
99+const PY_ERRORS = `try:
100+ client.fetch("http://169.254.169.254/latest/meta-data/")
101+except FetchaError as e:
102+ e.code # "URL_NOT_ALLOWED"
103+ e.status # 400
104+ e.message # "Requests to local, private or internal hosts are not allowed."
105+ e.request_id # "req_…" or None
106+ str(e) # "URL_NOT_ALLOWED: Requests to local, private or internal hosts are not allowed."`;
107+
108+export default function SdksPage() {
109+ return (
110+ <DocPage path="/docs/sdks" eyebrow="Tools" title="SDKs" description="Thin, typed clients over the REST API for JavaScript/TypeScript and Python. They add no behaviour of their own: every option maps 1:1 to a request field, and every error maps to an API error code." status="Beta">
111+ <Callout variant="warning" title="Not yet published to npm or PyPI">
112+ Both SDKs exist as source in the Fetcha repository (<Code>packages/sdk</Code> and <Code>sdk-python</Code>) and are installed from there until the packages are published. The CLI is not
113+ available yet. Package names are reserved: <Code>@fetcha/sdk</Code> on npm and <Code>fetcha</Code> on PyPI.
114+ </Callout>
115+
116+ <Table>
117+ <THead>
118+ <Tr>
119+ <Th>SDK</Th>
120+ <Th>Package</Th>
121+ <Th>Version</Th>
122+ <Th>Dependencies</Th>
123+ <Th>Runtime</Th>
124+ </Tr>
125+ </THead>
126+ <TBody>
127+ <Tr>
128+ <Td className="font-medium text-fg">JavaScript / TypeScript</Td>
129+ <Td mono>@fetcha/sdk</Td>
130+ <Td mono>0.1.0</Td>
131+ <Td>None (uses the global <Code>fetch</Code>)</Td>
132+ <Td>Node 18+, Bun, Deno, edge runtimes; ESM; ships TypeScript source</Td>
133+ </Tr>
134+ <Tr>
135+ <Td className="font-medium text-fg">Python</Td>
136+ <Td mono>fetcha</Td>
137+ <Td mono>0.1.0</Td>
138+ <Td>
139+ <Code>httpx&gt;=0.27</Code>
140+ </Td>
141+ <Td>Python 3.9+; synchronous client</Td>
142+ </Tr>
143+ </TBody>
144+ </Table>
145+ <P>
146+ Requests made through the SDKs are tagged with source <Code>sdk</Code> in your request log (the SDKs send a <Code>fetcha-sdk-js/…</Code> or <Code>fetcha-sdk-python/…</Code> User-Agent
147+ to the Fetcha API; this does not affect the User-Agent sent to targets).
148+ </P>
149+
150+ <H2>Installation</H2>
151+ <CodeTabs
152+ tabs={[
153+ { label: "JavaScript", lang: "bash", code: JS_INSTALL },
154+ { label: "Python", lang: "bash", code: PY_INSTALL },
155+ ]}
156+ />
157+ <P>
158+ The JavaScript package&apos;s entry point is its TypeScript source (<Code>src/index.ts</Code>), so use it from a TypeScript-aware toolchain (tsx, Bun, Deno, Vite, Next.js, esbuild) or copy the
159+ file into your own build. It has zero runtime dependencies.
160+ </P>
161+
162+ <H2>Configuration</H2>
163+ <H3>JavaScript</H3>
164+ <ParamTable
165+ rows={[
166+ { name: "apiKey", type: "string", required: true, description: <>Your <code>fch_live_…</code> or <code>fch_test_…</code> key. The constructor throws if it is empty.</> },
167+ { name: "baseUrl", type: "string", default: '"https://www.fetcha.co"', description: <>API origin. Trailing slash is removed.</> },
168+ { name: "clientTimeout", type: "number (ms)", default: "150000", description: <>Client-side abort applied on top of the API&apos;s own <code>timeout</code>. Keep it above the largest <code>timeout</code> you send.</> },
169+ { name: "fetch", type: "typeof fetch", default: "globalThis.fetch", description: <>Custom fetch implementation (proxies, instrumentation, tests).</> },
170+ ]}
171+ />
172+ <H3>Python</H3>
173+ <ParamTable
174+ rows={[
175+ { name: "api_key", type: "str", required: true, description: <>Your API key. <code>ValueError</code> if empty.</> },
176+ { name: "base_url", type: "str", default: '"https://www.fetcha.co"', description: <>API origin.</> },
177+ { name: "timeout", type: "float (seconds)", default: "150.0", description: <>httpx timeout for the HTTP call to Fetcha.</> },
178+ ]}
179+ />
180+
181+ <H2>Fetching</H2>
182+ <P>
183+ <Code>fetch</Code> takes the same fields as <A href="/docs/fetch">POST /v1/fetch</A> and returns the response document unchanged. <Code>text</Code> and <Code>json</Code> are shortcuts that set{" "}
184+ <Code>format</Code> and unwrap the corresponding field.
185+ </P>
186+ <CodeTabs
187+ tabs={[
188+ { label: "JavaScript", lang: "typescript", code: JS_USAGE },
189+ { label: "Python", lang: "python", code: PY_USAGE },
190+ ]}
191+ />
192+ <Ul>
193+ <Li>
194+ JavaScript: <Code>fetch(options): Promise&lt;FetchResult&gt;</Code>, <Code>text(url, options?)</Code> returns <Code>string</Code> (empty when the body was binary),{" "}
195+ <Code>json&lt;T&gt;(url, options?)</Code> returns <Code>T</Code> (<Code>undefined</Code> when the body did not parse).
196+ </Li>
197+ <Li>
198+ Python: <Code>fetch(url, **options)</Code> returns a <Code>FetchResult</Code> dataclass (<Code>request_id</Code>, <Code>success</Code>, <Code>status</Code>, <Code>url</Code>,{" "}
199+ <Code>final_url</Code>, <Code>content</Code>, <Code>content_type</Code>, <Code>headers</Code>, <Code>cookies</Code>, <Code>metadata</Code>, <Code>text</Code>, <Code>json</Code>, plus{" "}
200+ <Code>raw</Code> with the untouched payload). <Code>text()</Code> returns <Code>str</Code>, <Code>json()</Code> returns the parsed value or <Code>None</Code>.
201+ </Li>
202+ <Li>
203+ A fetch that reached the target never raises, even when <Code>success</Code> is <Code>false</Code>. Check it.
204+ </Li>
205+ </Ul>
206+
207+ <H2>Sessions</H2>
208+ <CodeTabs
209+ tabs={[
210+ { label: "JavaScript", lang: "typescript", code: JS_SESSIONS },
211+ { label: "Python", lang: "python", code: PY_SESSIONS },
212+ ]}
213+ />
214+ <P>
215+ The JavaScript <Code>sessions.create(options, idempotencyKey?)</Code> second argument sets the <Code>Idempotency-Key</Code> header. The Python client does not expose the header yet; pass
216+ your own de-duplication logic or call the REST endpoint directly for idempotent creation. See <A href="/docs/sessions">Sessions</A>.
217+ </P>
218+
219+ <H2>Account and usage</H2>
220+ <CodeTabs
221+ tabs={[
222+ { label: "JavaScript", lang: "typescript", code: JS_ACCOUNT },
223+ { label: "Python", lang: "python", code: PY_ACCOUNT },
224+ ]}
225+ />
226+
227+ <H2>Errors</H2>
228+ <P>
229+ Any non-2xx response is raised as <Code>FetchaError</Code> with the API&apos;s <Code>code</Code>, the HTTP <Code>status</Code>, the <Code>message</Code> and the request id. If the response
230+ body is not a Fetcha envelope (for example a gateway error), <Code>code</Code> is <Code>{`"HTTP_ERROR"`}</Code>. Transport failures (DNS, connection reset, client-side timeout) surface as the
231+ runtime&apos;s native error, not as <Code>FetchaError</Code>.
232+ </P>
233+ <CodeTabs
234+ tabs={[
235+ { label: "JavaScript", lang: "typescript", code: JS_ERRORS },
236+ { label: "Python", lang: "python", code: PY_ERRORS },
237+ ]}
238+ />
239+
240+ <H2>Types</H2>
241+ <P>The JavaScript SDK exports the following types; they mirror the API exactly.</P>
242+ <CodeBlock
243+ lang="typescript"
244+ code={`import type {
245+ FetchOptions, // request fields of POST /v1/fetch
246+ FetchResult, // response document
247+ FetchaNetwork, // "auto" | "datacenter" | "residential" | "isp" | "mobile"
248+ FetchaFormat, // "html" | "text" | "json" | "raw"
249+ SessionOptions, // body of POST /v1/sessions
250+ Session, // session object
251+ FetchaClientOptions, // constructor options
252+} from "@fetcha/sdk";`}
253+ />
254+
255+ <H2>Other languages</H2>
256+ <P>
257+ There is no official SDK for Go, PHP, Ruby, Java or C# yet. The REST API is small enough that the raw-HTTP samples in this documentation (see the language tabs on the{" "}
258+ <A href="/docs/fetch">Fetch API</A> page) are a complete integration. If you publish a community client, tell us at <A href="mailto:hello@fetcha.co">hello@fetcha.co</A> and we will link it
259+ here.
260+ </P>
261+ <H3>CLI</H3>
262+ <P>
263+ A <Code>fetcha</Code> command-line tool is planned but <Strong>not available</Strong>. Until then, cURL with an exported <Code>FETCHA_API_KEY</Code> covers interactive use; see the{" "}
264+ <A href="/docs/quickstart">Quickstart</A>.
265+ </P>
266+ </DocPage>
267+ );
268+}
added apps/web/src/app/(marketing)/docs/sessions/page.tsx +182 −0
@@ -0,0 +1,182 @@
1+import type { Metadata } from "next";
2+import { CodeBlock } from "@/components/ui/code-block";
3+import { DocPage } from "@/components/docs/doc-page";
4+import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
5+import { Callout } from "@/components/docs/callout";
6+import { Endpoint } from "@/components/docs/endpoint";
7+import { ParamTable } from "@/components/docs/param-table";
8+import { CodeTabs } from "@/components/docs/code-tabs";
9+import { ResponseExample } from "@/components/docs/response-example";
10+import { apiTabs, fetchTabs } from "@/components/docs/snippets";
11+
12+export const metadata: Metadata = {
13+ title: "Sessions",
14+ description: "Sticky sessions keep the same exit identity across requests: create, use, inspect and close them, with TTL, geography, idempotency and expiry rules.",
15+};
16+
17+const SESSION = {
18+ id: "sess_8f2k1m9d3p7q4r6s",
19+ label: "checkout-user-42",
20+ status: "active",
21+ network: "residential",
22+ country: "CA",
23+ region: "quebec",
24+ city: null,
25+ request_count: 0,
26+ last_used_at: null,
27+ expires_at: "2026-09-07T14:32:11.000Z",
28+ created_at: "2026-09-07T14:17:11.000Z",
29+};
30+
31+export default function SessionsPage() {
32+ return (
33+ <DocPage path="/docs/sessions" eyebrow="Core API" title="Sessions" description="A session pins a stable exit identity for a period of time so that a sequence of requests looks like one visitor. Use it for login flows, paginated listings and any site that ties state to the client IP." status="Stable">
34+ <H2>What a session preserves</H2>
35+ <Ul>
36+ <Li>
37+ <Strong>Exit identity.</Strong> All requests that reference the session are routed through the same network route with the same sticky key, so the target sees the same IP for the
38+ session&apos;s lifetime, as long as the network keeps that exit available.
39+ </Li>
40+ <Li>
41+ <Strong>Network class and geography.</Strong> The class chosen at creation and the country you asked for are applied to every request that omits them.
42+ </Li>
43+ <Li>
44+ <Strong>Cookies you pass.</Strong> Fetcha does not maintain a cookie jar. Read <Code>cookies</Code> from a response and send them back in the next request&apos;s <Code>cookies</Code>{" "}
45+ field. Because the IP is stable, the target accepts them as belonging to the same visitor.
46+ </Li>
47+ </Ul>
48+ <Callout variant="info" title="Retries inside a session">
49+ Normally each retry uses a fresh exit IP. When a request is pinned to a session, retries keep the session&apos;s identity so the target never sees the visitor jump between IPs. If the
50+ session&apos;s exit is being blocked, close the session and create a new one.
51+ </Callout>
52+
53+ <H2>Create a session</H2>
54+ <Endpoint method="POST" path="/v1/sessions" scope="sessions:write" status="Live" />
55+ <ParamTable
56+ rows={[
57+ { name: "country", type: "string", constraints: "exactly 2 chars", description: <>ISO 3166-1 alpha-2 country for the exit. See <A href="/docs/geolocation">Geolocation</A>.</> },
58+ { name: "region", type: "string", constraints: "≤ 64 chars", description: <>State or province hint.</> },
59+ { name: "city", type: "string", constraints: "≤ 128 chars", description: <>City hint.</> },
60+ { name: "network", type: '"auto" | "datacenter" | "residential" | "isp" | "mobile"', default: '"auto"', description: <>Network class. The concrete class picked by the engine is returned in <code>network</code>. Classes outside your plan or without a sticky-capable route return <code>NETWORK_UNAVAILABLE</code>.</> },
61+ { name: "ttl", type: "integer (seconds)", default: "600", constraints: "60–1,800", description: <>Lifetime of the session. It is fixed at creation and is <strong>not</strong> extended by use.</> },
62+ { name: "label", type: "string", constraints: "≤ 128 chars", description: <>Free-form label for your own bookkeeping, shown in the dashboard.</> },
63+ ]}
64+ />
65+ <P>
66+ The body may be empty (<Code>{"{}"}</Code>): you then get a 10-minute session on the best sticky-capable route with no geography constraint. The schema is strict; unknown fields fail
67+ with <Code>INVALID_REQUEST</Code>.
68+ </P>
69+ <CodeTabs tabs={apiTabs({ method: "POST", path: "/v1/sessions", body: { country: "CA", region: "QC", ttl: 900, label: "checkout-user-42" }, idempotencyKey: "checkout-user-42-2026-09-07" })} />
70+ <ResponseExample status={200} body={SESSION} />
71+ <ParamTable
72+ showDefault={false}
73+ rows={[
74+ { name: "id", type: "string", description: <>Session identifier (<code>sess_…</code>). Pass it as <code>session</code> in fetch requests.</> },
75+ { name: "label", type: "string | null", description: <>Your label.</> },
76+ { name: "status", type: '"active" | "expired" | "closed"', description: <><code>expired</code> is derived from <code>expires_at</code>; <code>closed</code> means you deleted it.</> },
77+ { name: "network", type: "string", description: <>Concrete class serving the session.</> },
78+ { name: "country / region / city", type: "string | null", description: <>Normalised geography (region and city as slugs).</> },
79+ { name: "request_count", type: "integer", description: <>Number of fetch requests that used the session.</> },
80+ { name: "last_used_at", type: "string | null", description: <>ISO 8601 timestamp of the last fetch, or <code>null</code>.</> },
81+ { name: "expires_at", type: "string", description: <>ISO 8601 expiry, <code>created_at + ttl</code>.</> },
82+ { name: "created_at", type: "string", description: <>ISO 8601 creation time.</> },
83+ ]}
84+ />
85+
86+ <H3>Idempotency-Key</H3>
87+ <P>
88+ Send an <Code>Idempotency-Key</Code> header with the create request to make it safe to retry. If Fetcha has already created a session for that key in your project within the last 24
89+ hours, it returns the existing session instead of creating a new one. Keys are scoped to the project; any string works, though a value derived from your own entity (user id, job id) is
90+ the most useful.
91+ </P>
92+
93+ <H2>Use a session in a fetch</H2>
94+ <P>
95+ Reference the id in the <Code>session</Code> field. If you omit <Code>country</Code>, the session&apos;s country applies; if <Code>network</Code> is <Code>auto</Code>, the session&apos;s class
96+ applies. <Code>metadata.session</Code> echoes the id.
97+ </P>
98+ <CodeTabs
99+ tabs={fetchTabs(
100+ { url: "https://www.example.ca/account", session: "sess_8f2k1m9d3p7q4r6s", cookies: { sid: "3f9a…" } },
101+ {
102+ javascript: `console.log(data.metadata.session, data.metadata.country); // "sess_8f2k1m9d3p7q4r6s" "CA"`,
103+ python: `print(data["metadata"]["session"], data["metadata"]["country"])`,
104+ },
105+ )}
106+ />
107+ <Table dense>
108+ <THead>
109+ <Tr>
110+ <Th>Condition</Th>
111+ <Th>Result</Th>
112+ </Tr>
113+ </THead>
114+ <TBody>
115+ <Tr>
116+ <Td>Id does not exist, or belongs to another project</Td>
117+ <Td mono>404 SESSION_NOT_FOUND</Td>
118+ </Tr>
119+ <Tr>
120+ <Td>
121+ Session is past <Code>expires_at</Code>, or was closed
122+ </Td>
123+ <Td mono>410 SESSION_EXPIRED</Td>
124+ </Tr>
125+ </TBody>
126+ </Table>
127+ <P>
128+ Both are raised before any network activity, so they cost nothing and do not consume an attempt. Pass the same geography as the session or omit it; requesting a different country on a
129+ pinned session is allowed but defeats the purpose.
130+ </P>
131+
132+ <H2>List sessions</H2>
133+ <Endpoint method="GET" path="/v1/sessions" scope={null} status="Live" />
134+ <P>Returns the 100 most recent sessions of the project, newest first, including expired and closed ones.</P>
135+ <CodeTabs tabs={apiTabs({ method: "GET", path: "/v1/sessions" }, ["curl", "javascript", "python"])} />
136+ <ResponseExample status={200} body={{ data: [SESSION] }} />
137+
138+ <H2>Get a session</H2>
139+ <Endpoint method="GET" path="/v1/sessions/:id" scope={null} status="Live" />
140+ <CodeTabs tabs={apiTabs({ method: "GET", path: "/v1/sessions/sess_8f2k1m9d3p7q4r6s" }, ["curl", "javascript", "python"])} />
141+ <P>
142+ Returns the same object as creation, with a live <Code>status</Code>. Unknown ids return <Code>404 SESSION_NOT_FOUND</Code>.
143+ </P>
144+
145+ <H2>Close a session</H2>
146+ <Endpoint method="DELETE" path="/v1/sessions/:id" scope="sessions:write" status="Live" />
147+ <CodeTabs tabs={apiTabs({ method: "DELETE", path: "/v1/sessions/sess_8f2k1m9d3p7q4r6s" }, ["curl", "javascript", "python"])} />
148+ <ResponseExample status={200} body={{ id: "sess_8f2k1m9d3p7q4r6s", status: "closed" }} />
149+ <P>
150+ Closing is immediate and irreversible; later fetches with the id return <Code>SESSION_EXPIRED</Code>. Sessions you do not close simply expire at <Code>expires_at</Code>. There is no per-plan
151+ limit on the number of sessions.
152+ </P>
153+
154+ <H2>Two-step login example</H2>
155+ <P>
156+ A typical flow: create a session, POST credentials, keep the returned cookies, then GET the protected page with the same session and cookies. The SDK version is on the{" "}
157+ <A href="/docs/examples">Examples</A> page.
158+ </P>
159+ <CodeBlock
160+ lang="bash"
161+ title="Terminal"
162+ code={`# 1. Create a Canadian session for 15 minutes
163+SESSION=$(curl -s https://www.fetcha.co/v1/sessions -X POST \\
164+ -H "Authorization: Bearer $FETCHA_API_KEY" -H "Content-Type: application/json" \\
165+ -d '{"country": "CA", "ttl": 900}' | jq -r .id)
166+
167+# 2. Log in (cookies come back in .cookies)
168+curl -s https://www.fetcha.co/v1/fetch -X POST \\
169+ -H "Authorization: Bearer $FETCHA_API_KEY" -H "Content-Type: application/json" \\
170+ -d "{\\"url\\": \\"https://www.example.ca/login\\", \\"method\\": \\"POST\\", \\"session\\": \\"$SESSION\\",
171+ \\"headers\\": {\\"Content-Type\\": \\"application/x-www-form-urlencoded\\"},
172+ \\"body\\": \\"user=me&pass=secret\\"}" | jq .cookies
173+
174+# 3. Fetch the account page with the same session and the returned cookies
175+curl -s https://www.fetcha.co/v1/fetch -X POST \\
176+ -H "Authorization: Bearer $FETCHA_API_KEY" -H "Content-Type: application/json" \\
177+ -d "{\\"url\\": \\"https://www.example.ca/account\\", \\"session\\": \\"$SESSION\\", \\"format\\": \\"text\\",
178+ \\"cookies\\": {\\"sid\\": \\"3f9a…\\"}}" | jq -r .text`}
179+ />
180+ </DocPage>
181+ );
182+}
added apps/web/src/app/(marketing)/docs/webhooks/page.tsx +148 −0
@@ -0,0 +1,148 @@
1+import type { Metadata } from "next";
2+import { CodeBlock } from "@/components/ui/code-block";
3+import { DocPage } from "@/components/docs/doc-page";
4+import { A, Code, H2, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";
5+import { Callout, ComingSoon } from "@/components/docs/callout";
6+import { CodeTabs } from "@/components/docs/code-tabs";
7+
8+export const metadata: Metadata = {
9+ title: "Webhooks",
10+ description: "Webhooks are coming soon. Planned events (usage thresholds, failed requests, billing, API keys), delivery format and the HMAC-SHA256 signature scheme.",
11+};
12+
13+const EVENTS: Array<[string, string]> = [
14+ ["usage.threshold", "Monthly requests or spend crossed a threshold you configure (for example 80 % and 100 % of the plan quota, or a soft spending limit)."],
15+ ["request.failed", "A fetch ended in an error or a fully blocked target. Batched to avoid floods; includes request_id, domain, error code and attempts."],
16+ ["billing.invoice.created", "An invoice was issued for the organization."],
17+ ["billing.payment.succeeded", "A payment was collected."],
18+ ["billing.payment.failed", "A payment attempt failed; includes the retry schedule."],
19+ ["billing.plan.changed", "The organization's plan changed."],
20+ ["api_key.created", "A key was created in one of your projects."],
21+ ["api_key.rotated", "A key's secret was rotated."],
22+ ["api_key.revoked", "A key was revoked."],
23+ ["api_key.expiring", "A key expires within 7 days."],
24+];
25+
26+const PAYLOAD = {
27+ id: "evt_7h8i9j0k1l2m3n4o",
28+ type: "usage.threshold",
29+ created_at: "2026-09-07T15:04:00.000Z",
30+ organization_id: "org_9a1b2c3d4e5f6g7h",
31+ project_id: "proj_2k8d1m3p9q4r7s6t",
32+ data: {
33+ metric: "requests",
34+ threshold: 0.8,
35+ used: 40_012,
36+ limit: 50_000,
37+ period_start: "2026-09-01T00:00:00.000Z",
38+ },
39+};
40+
41+const VERIFY_JS = `import { createHmac, timingSafeEqual } from "node:crypto";
42+
43+export function verifyFetchaSignature(rawBody: string, header: string, secret: string, toleranceSec = 300) {
44+ // Header format: t=<unix seconds>,v1=<hex hmac>
45+ const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]));
46+ const t = Number(parts.t);
47+ if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
48+ const expected = createHmac("sha256", secret).update(\`\${t}.\${rawBody}\`).digest("hex");
49+ const a = Buffer.from(expected, "hex");
50+ const b = Buffer.from(parts.v1 ?? "", "hex");
51+ return a.length === b.length && timingSafeEqual(a, b);
52+}`;
53+
54+const VERIFY_PY = `import hmac, hashlib, time
55+
56+def verify_fetcha_signature(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool:
57+ # Header format: t=<unix seconds>,v1=<hex hmac>
58+ parts = dict(kv.split("=", 1) for kv in header.split(","))
59+ t = int(parts.get("t", "0"))
60+ if not t or abs(time.time() - t) > tolerance_sec:
61+ return False
62+ expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
63+ return hmac.compare_digest(expected, parts.get("v1", ""))`;
64+
65+export default function WebhooksPage() {
66+ return (
67+ <DocPage path="/docs/webhooks" eyebrow="Reliability" title="Webhooks" description="Receive HTTP notifications when something happens in your account: usage thresholds, failed requests, billing and API key events." status="Coming soon">
68+ <ComingSoon title="Coming soon — configuration UI not yet available">
69+ Webhook delivery is not live. There is no way to register an endpoint yet, no events are sent, and the details on this page are the planned design published for early integration work.
70+ The event names, payload shape and signature scheme may change before launch. Watch the <A href="/changelog">changelog</A>.
71+ </ComingSoon>
72+
73+ <H2>Planned events</H2>
74+ <Table>
75+ <THead>
76+ <Tr>
77+ <Th>Event</Th>
78+ <Th>When it fires</Th>
79+ </Tr>
80+ </THead>
81+ <TBody>
82+ {EVENTS.map(([name, desc]) => (
83+ <Tr key={name}>
84+ <Td mono className="whitespace-nowrap">
85+ {name}
86+ </Td>
87+ <Td>{desc}</Td>
88+ </Tr>
89+ ))}
90+ </TBody>
91+ </Table>
92+
93+ <H2>Delivery</H2>
94+ <Ul>
95+ <Li>
96+ Events will be delivered as <Code>POST</Code> requests with a JSON body to the HTTPS URL you register per organization, with the event types you subscribe to.
97+ </Li>
98+ <Li>
99+ Your endpoint should respond with a 2xx within 10 seconds. Non-2xx responses and timeouts are retried with exponential backoff for up to 24 hours, then recorded as failed in the
100+ dashboard.
101+ </Li>
102+ <Li>
103+ Deliveries are <Strong>at least once</Strong>: use <Code>id</Code> to deduplicate.
104+ </Li>
105+ <Li>
106+ Payloads never include page content, cookies or API key secrets, only identifiers and metrics.
107+ </Li>
108+ </Ul>
109+ <CodeBlock lang="json" title="Planned payload" code={JSON.stringify(PAYLOAD, null, 2)} />
110+
111+ <H2>Signature</H2>
112+ <P>
113+ Each delivery will carry an <Code>X-Fetcha-Signature</Code> header so you can verify that the payload came from Fetcha and was not altered. The scheme is HMAC-SHA256 over a timestamp and the
114+ raw request body, keyed with the endpoint&apos;s secret shown once at creation:
115+ </P>
116+ <CodeBlock
117+ lang="text"
118+ code={`X-Fetcha-Signature: t=1788623040,v1=5f1c9c0a5b4e7d2b8a3f6e9d0c1b2a3f4e5d6c7b8a9f0e1d2c3b4a5f6e7d8c9b
119+X-Fetcha-Event: usage.threshold
120+X-Fetcha-Delivery: whd_1a2b3c4d5e6f7g8h
121+
122+signed_payload = "<t>" + "." + <raw body>
123+v1 = hex( HMAC_SHA256( secret, signed_payload ) )`}
124+ />
125+ <Ul>
126+ <Li>
127+ Compute the HMAC over the <Strong>raw</Strong> bytes you received, before any JSON parsing or re-serialisation.
128+ </Li>
129+ <Li>Reject deliveries whose timestamp is more than five minutes old to prevent replay.</Li>
130+ <Li>Compare signatures with a constant-time function.</Li>
131+ <Li>
132+ During secret rotation both the old and the new secret will be valid for 24 hours; the header may then contain two <Code>v1</Code> values.
133+ </Li>
134+ </Ul>
135+ <CodeTabs
136+ tabs={[
137+ { label: "JavaScript", lang: "typescript", code: VERIFY_JS },
138+ { label: "Python", lang: "python", code: VERIFY_PY },
139+ ]}
140+ />
141+
142+ <Callout variant="info" title="What you can do today">
143+ Poll <Code>GET /v1/usage</Code> for <Code>remaining_requests</Code> and spend, and watch the request log in the dashboard. Hard spending limits and project request limits already stop
144+ traffic when reached (see <A href="/docs/rate-limits">Rate limits</A>), which covers the most important protection webhooks would otherwise provide.
145+ </Callout>
146+ </DocPage>
147+ );
148+}
added apps/web/src/app/(marketing)/layout.tsx +14 −0
@@ -0,0 +1,14 @@
1+import { MarketingNav } from "@/components/marketing/nav";
2+import { MarketingFooter } from "@/components/marketing/footer";
3+import { getUser } from "@/lib/session";
4+
5+export default async function MarketingLayout({ children }: { children: React.ReactNode }) {
6+ const user = await getUser().catch(() => null);
7+ return (
8+ <>
9+ <MarketingNav signedIn={Boolean(user)} />
10+ <main className="flex-1">{children}</main>
11+ <MarketingFooter />
12+ </>
13+ );
14+}
added apps/web/src/app/(marketing)/legal/acceptable-use/page.tsx +141 −0
@@ -0,0 +1,141 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { LegalLayout, type LegalSection } from "@/components/marketing/legal-layout";
4+
5+export const metadata: Metadata = {
6+ title: "Acceptable Use Policy",
7+ description: "What you may and may not do with the Fetcha web access API, how we monitor for abuse, and what happens when this policy is violated.",
8+ alternates: { canonical: "/legal/acceptable-use" },
9+};
10+
11+const sections: LegalSection[] = [
12+ {
13+ id: "purpose",
14+ title: "Purpose and scope",
15+ body: (
16+ <>
17+ <p>
18+ Fetcha gives developers reliable, programmatic access to the public web through shared network infrastructure. Because that infrastructure is shared with other customers and with the residents and businesses whose connections form the upstream networks, misuse by one customer harms everyone. This Acceptable Use Policy (<strong>“AUP”</strong>) sets the rules. It is part of the <Link href="/legal/terms">Terms of Service</Link> and applies to every request you send, every API key you hold, every person who uses your account, and every integration you build on the Services.
19+ </p>
20+ <p>The list of prohibited uses below is not exhaustive. If an activity is unlawful, harms third parties, or would be embarrassing to explain to the operator of the website you are accessing, assume it is prohibited. When in doubt, ask <a href="mailto:legal@fetcha.co">legal@fetcha.co</a> before you build.</p>
21+ </>
22+ ),
23+ },
24+ {
25+ id: "your-responsibility",
26+ title: "Your responsibility",
27+ body: (
28+ <>
29+ <p>You, not Fetcha, decide which URLs to request and what to do with the content. You are responsible for ensuring that each request and each downstream use is lawful in every jurisdiction that applies to you, to the target website and to any individuals whose data is involved. This includes, without limitation, data-protection and privacy law, copyright and database-right law, computer-misuse and anti-hacking statutes, consumer-protection law, anti-spam law, export controls and sanctions.</p>
30+ <p>You are also responsible for respecting the contractual terms and technical signals of the websites you access. Fetcha does not evaluate third-party terms of service or <code>robots.txt</code> on your behalf and does not warrant that any access is permitted. Where a website requires authorisation, you must have it.</p>
31+ </>
32+ ),
33+ },
34+ {
35+ id: "prohibited",
36+ title: "Prohibited uses",
37+ body: (
38+ <>
39+ <p>You must not use, or attempt to use, the Services to:</p>
40+ <ol>
41+ <li>
42+ <strong>Access systems without authorisation.</strong> Probe, scan, log in to or retrieve data from any computer, network, account or non-public resource that you are not expressly authorised to access, or exceed the scope of an authorisation you do have.
43+ </li>
44+ <li>
45+ <strong>Perform credential stuffing or brute force.</strong> Test stolen, leaked, guessed or generated usernames, passwords, tokens, session identifiers, gift-card numbers, coupon codes or similar credentials against any service, or otherwise attempt to take over accounts.
46+ </li>
47+ <li>
48+ <strong>Flood, overload or attack.</strong> Conduct denial-of-service or distributed denial-of-service attacks, deliberately send traffic at volumes or rates intended to degrade a target, exhaust inventory, skew metrics or interfere with the normal operation of any website, API or network.
49+ </li>
50+ <li>
51+ <strong>Bypass third-party paywalls or authentication.</strong> Circumvent login walls, paywalls, subscription checks, CAPTCHAs that protect authenticated areas, licence checks, digital-rights-management or other access controls of a third party in order to obtain content or services you are not entitled to.
52+ </li>
53+ <li>
54+ <strong>Collect personal data unlawfully.</strong> Harvest names, contact details, identifiers, images, location data or other personal information without a lawful basis and the notices required by applicable law; build profiles of individuals for surveillance, stalking, doxxing or discrimination; or scrape data that a website makes available only to authenticated users of its own service.
55+ </li>
56+ <li>
57+ <strong>Handle child sexual abuse material (CSAM).</strong> Access, retrieve, store, distribute or facilitate access to any sexual content involving minors, or any content that sexualises minors. We report such activity to the Canadian Centre for Child Protection and to law enforcement without notice.
58+ </li>
59+ <li>
60+ <strong>Commit or facilitate fraud.</strong> Engage in payment fraud, ad fraud, click fraud, fake reviews, fake account creation, ticket or sneaker “botting” in violation of a seller’s terms, affiliate-link hijacking, phishing, impersonation or any deceptive practice.
61+ </li>
62+ <li>
63+ <strong>Distribute malware or malicious content.</strong> Host, retrieve, relay or deliver viruses, ransomware, spyware, exploit kits, command-and-control traffic or content designed to compromise devices or data.
64+ </li>
65+ <li>
66+ <strong>Target Fetcha’s own infrastructure.</strong> Request <code>fetcha.co</code> domains, our API endpoints, dashboard, internal services or the infrastructure of our network partners; attempt to discover partner identities or internal addresses; circumvent rate limits, quotas, SSRF protections or billing; or use the Services to test their security without written permission.
67+ </li>
68+ <li>
69+ <strong>Resell the Services as an anonymous proxy.</strong> Offer, package or expose Fetcha as a general-purpose, open or anonymous proxy, VPN or “unblocker” to third parties; run a public relay on top of the API; or otherwise provide access to persons whose identity and purpose you do not know and are not responsible for. Building your own product that uses Fetcha behind the scenes for a defined purpose is fine; passing raw proxy capability to strangers is not.
70+ </li>
71+ </ol>
72+ <p>In addition, you must not use the Services to send unsolicited bulk messages, to violate sanctions or export-control law, to infringe intellectual-property rights at scale, to interfere with elections or public-health information, or to engage in any activity that is illegal where you or the target are located.</p>
73+ </>
74+ ),
75+ },
76+ {
77+ id: "technical-rules",
78+ title: "Technical rules",
79+ body: (
80+ <>
81+ <ul>
82+ <li>Only <code>http</code> and <code>https</code> URLs are accepted. Requests to localhost, private, link-local, metadata and internal addresses are refused, and every redirect is re-validated (<code>URL_NOT_ALLOWED</code>). Do not try to evade these checks.</li>
83+ <li>Respect your plan’s concurrency, timeout and retry limits. Do not spread a single workload across multiple accounts to evade limits or quotas.</li>
84+ <li>Keep API keys confidential. Do not embed live keys in client-side code, public repositories or shared images.</li>
85+ <li>Use realistic request rates against any single target. Sustained high-frequency hammering of one domain is a signal of abuse even when the content is public.</li>
86+ <li>Do not attempt to identify, enumerate or contact the individuals or organisations that provide upstream network capacity.</li>
87+ </ul>
88+ </>
89+ ),
90+ },
91+ {
92+ id: "monitoring",
93+ title: "Monitoring",
94+ body: (
95+ <>
96+ <p>We do not read the content you retrieve, and we do not store response bodies by default. To enforce this AUP and protect the network we do monitor <strong>request metadata</strong>: target domains and URL patterns, request rates and volumes, error and block rates, network classes and geographies, session behaviour, and signals from our upstream partners and from website operators. Automated systems flag anomalies (for example, login-endpoint hammering or credential-shaped payloads) and may throttle traffic in real time; flagged accounts are reviewed by a person.</p>
97+ <p>We may also act on complaints from website operators, network partners, individuals or authorities. When we investigate, we may look at the request records of the account concerned, including debug attempts where you enabled them, for the retention period of your plan.</p>
98+ </>
99+ ),
100+ },
101+ {
102+ id: "enforcement",
103+ title: "Suspension and enforcement",
104+ body: (
105+ <>
106+ <p>Depending on severity, we may take one or more of the following steps, with or without prior notice:</p>
107+ <ul>
108+ <li>block specific domains, URL patterns or request shapes for your account;</li>
109+ <li>throttle or reduce your concurrency, quota or network access;</li>
110+ <li>suspend individual API keys, projects or the whole account pending investigation;</li>
111+ <li>terminate the account and forfeit prepaid fees for the period, where the violation is serious or repeated;</li>
112+ <li>preserve relevant records beyond the normal retention period as required for an investigation or legal process.</li>
113+ </ul>
114+ <p>Where the situation allows, we will tell you what we observed and give you a chance to explain or fix it. For activity involving CSAM, active attacks, fraud or imminent harm, we act first and notify afterwards, if at all. Fetcha is not liable for any loss resulting from enforcement of this AUP taken in good faith.</p>
115+ </>
116+ ),
117+ },
118+ {
119+ id: "reporting",
120+ title: "Reporting",
121+ body: (
122+ <>
123+ <p>
124+ <strong>To us.</strong> If you are a website operator, network participant or individual and believe Fetcha traffic is violating this policy, write to <a href="mailto:legal@fetcha.co">legal@fetcha.co</a> with timestamps, target URLs and, if you have them, source IPs or Fetcha request ids. We acknowledge reports within two business days and act on substantiated reports promptly. Customers who discover misuse of their own keys must report it to <a href="mailto:support@fetcha.co">support@fetcha.co</a> immediately and rotate the keys.
125+ </p>
126+ <p>
127+ <strong>To authorities.</strong> We cooperate with lawful requests from law enforcement and regulators. We report apparent CSAM to the appropriate authorities as required by Canadian law. Where we believe in good faith that the Services are being used to commit a crime that threatens people or critical infrastructure, we may report it proactively, and we may share the metadata necessary for the report.
128+ </p>
129+ </>
130+ ),
131+ },
132+ {
133+ id: "changes",
134+ title: "Changes",
135+ body: <p>We may update this AUP as new abuse patterns emerge or the law changes. The version and effective date appear at the top of this page; material changes are announced by email or dashboard notice. Continued use after the effective date is acceptance. Questions: <a href="mailto:legal@fetcha.co">legal@fetcha.co</a>.</p>,
136+ },
137+];
138+
139+export default function AcceptableUsePage() {
140+ return <LegalLayout title="Acceptable Use Policy" current="/legal/acceptable-use" summary="Fetcha is shared infrastructure. This policy lists what you may not do with it — no unauthorised access, credential stuffing, flooding, paywall or login bypass, unlawful data collection, CSAM, fraud, malware, attacks on Fetcha itself or anonymous-proxy resale — and explains how we monitor, suspend and report." sections={sections} />;
141+}
added apps/web/src/app/(marketing)/legal/cookies/page.tsx +127 −0
@@ -0,0 +1,127 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { LegalLayout, type LegalSection } from "@/components/marketing/legal-layout";
4+
5+export const metadata: Metadata = {
6+ title: "Cookie Policy",
7+ description: "The cookies and browser storage Fetcha uses on fetcha.co and in the dashboard, why each one exists, and how to control them.",
8+ alternates: { canonical: "/legal/cookies" },
9+};
10+
11+const sections: LegalSection[] = [
12+ {
13+ id: "overview",
14+ title: "Overview",
15+ body: (
16+ <>
17+ <p>
18+ This Cookie Policy explains what cookies and similar browser storage Fetcha, operated from Québec, Canada, uses on <code>fetcha.co</code> (the marketing site, documentation and status pages) and in the dashboard at <code>fetcha.co/dashboard</code>. It complements the <Link href="/legal/privacy">Privacy Policy</Link>, which describes how we handle personal information more generally.
19+ </p>
20+ <p>
21+ The short version: <strong>we use only strictly necessary cookies</strong>. There are no advertising cookies, no third-party analytics, no social-media pixels and no cross-site tracking anywhere on our properties. Because nothing we set requires consent under Québec’s Law 25, PIPEDA or the EU ePrivacy rules, we do not show a cookie banner.
22+ </p>
23+ </>
24+ ),
25+ },
26+ {
27+ id: "what-are-cookies",
28+ title: "What cookies and similar technologies are",
29+ body: (
30+ <>
31+ <p>A cookie is a small text file that a website asks your browser to store and to send back on later visits. Cookies can be <strong>first-party</strong> (set by the site you are visiting) or <strong>third-party</strong> (set by another domain), and <strong>session</strong> (deleted when you close the browser or after a short inactivity period) or <strong>persistent</strong> (kept until an expiry date). Websites can also store small amounts of data in the browser’s <code>localStorage</code>, which behaves like a persistent cookie but is never sent to the server automatically.</p>
32+ <p>Fetcha sets only first-party cookies and one first-party <code>localStorage</code> key, all described below.</p>
33+ </>
34+ ),
35+ },
36+ {
37+ id: "cookies-we-use",
38+ title: "Cookies and storage we use",
39+ body: (
40+ <>
41+ <table>
42+ <thead>
43+ <tr>
44+ <th>Name</th>
45+ <th>Type</th>
46+ <th>Purpose</th>
47+ <th>Duration</th>
48+ </tr>
49+ </thead>
50+ <tbody>
51+ <tr>
52+ <td><code>fetcha*</code> (session cookies, e.g. <code>fetcha.session_token</code>)</td>
53+ <td>Strictly necessary · first-party · HttpOnly, Secure, SameSite=Lax</td>
54+ <td>Keeps you signed in to the dashboard, identifies your session on the server, and protects forms and server actions against cross-site request forgery. Set only after you sign in.</td>
55+ <td>Until sign-out, or after a period of inactivity; renewed while you use the dashboard.</td>
56+ </tr>
57+ <tr>
58+ <td><code>fetcha_project</code></td>
59+ <td>Strictly necessary (preference) · first-party</td>
60+ <td>Remembers which project you last selected so the dashboard opens on the right project. Contains only a project identifier.</td>
61+ <td>Up to 1 year.</td>
62+ </tr>
63+ <tr>
64+ <td><code>theme</code> (localStorage)</td>
65+ <td>Preference · first-party · not sent to server</td>
66+ <td>Remembers your light, dark or system colour-scheme choice so the page does not flash the wrong theme on load.</td>
67+ <td>Until you clear browser storage.</td>
68+ </tr>
69+ </tbody>
70+ </table>
71+ <p>Signed-out visitors to the marketing site, documentation, pricing, changelog and status pages receive no cookies at all, apart from the theme preference if you toggle it.</p>
72+ </>
73+ ),
74+ },
75+ {
76+ id: "third-parties",
77+ title: "Third parties",
78+ body: (
79+ <>
80+ <p>We do not embed third-party scripts that set cookies. Fonts, icons and styles are served from our own domain. We do not use Google Analytics, Meta Pixel, LinkedIn Insight, session-replay tools or advertising networks. Aggregate traffic statistics we may look at are derived from our own server logs and do not rely on cookies.</p>
81+ <p>When self-serve checkout launches, the payment page may load a script from our payment processor to collect card details securely; that processor may set its own strictly necessary fraud-prevention cookies on its own domain during the payment step. We will update this policy before that happens.</p>
82+ <p>Links from our documentation to external sites (for example package registries or standards bodies) lead to sites with their own cookie practices, which we do not control.</p>
83+ </>
84+ ),
85+ },
86+ {
87+ id: "api-and-sdks",
88+ title: "The API, the SDKs and target websites",
89+ body: (
90+ <>
91+ <p>Calls to <code>https://www.fetcha.co/v1</code> are authenticated with a Bearer API key in a request header, not with cookies, and the API sets no cookies on your client.</p>
92+ <p>
93+ When you use the Fetch API to retrieve a third-party website, that website may set cookies in its response. Fetcha returns those cookies to you in the <code>cookies</code> field of the response and, if you use a sticky session, may replay cookies you send within that session so that multi-step flows work. Those cookies belong to the target website and to your application; Fetcha does not store them beyond the life of the request and the session, and they are redacted from our request logs. How you handle them is governed by your own privacy obligations and the <Link href="/legal/acceptable-use">Acceptable Use Policy</Link>.
94+ </p>
95+ </>
96+ ),
97+ },
98+ {
99+ id: "control",
100+ title: "How to control cookies",
101+ body: (
102+ <>
103+ <p>You can view, block and delete cookies in your browser settings. Common locations:</p>
104+ <ul>
105+ <li><strong>Chrome</strong>: Settings → Privacy and security → Third-party cookies / See all site data and permissions.</li>
106+ <li><strong>Safari</strong>: Settings → Privacy → Manage Website Data.</li>
107+ <li><strong>Firefox</strong>: Settings → Privacy &amp; Security → Cookies and Site Data.</li>
108+ <li><strong>Edge</strong>: Settings → Cookies and site permissions → Manage and delete cookies and site data.</li>
109+ </ul>
110+ <p>Because our cookies are strictly necessary, blocking them has consequences: without the <code>fetcha*</code> session cookie you cannot sign in to the dashboard, and without the project and theme preferences the dashboard will fall back to defaults on each visit. The API itself is unaffected by browser cookie settings. Signing out deletes the session cookie; clearing site data for <code>fetcha.co</code> removes everything listed above.</p>
111+ </>
112+ ),
113+ },
114+ {
115+ id: "changes",
116+ title: "Changes and contact",
117+ body: (
118+ <>
119+ <p>If we add a cookie, change a purpose or introduce any technology that requires consent, we will update this page, change the version and effective date at the top, and, where consent is required, ask for it before setting anything. Questions about cookies or browser storage: <a href="mailto:privacy@fetcha.co">privacy@fetcha.co</a>.</p>
120+ </>
121+ ),
122+ },
123+];
124+
125+export default function CookiesPage() {
126+ return <LegalLayout title="Cookie Policy" current="/legal/cookies" summary="Fetcha uses only strictly necessary first-party cookies: a fetcha* session cookie when you sign in, a project preference and a theme preference. No advertising, analytics or third-party tracking, so no banner." sections={sections} />;
127+}
added apps/web/src/app/(marketing)/legal/dpa/page.tsx +178 −0
@@ -0,0 +1,178 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { LegalLayout, type LegalSection } from "@/components/marketing/legal-layout";
4+
5+export const metadata: Metadata = {
6+ title: "Data Processing Addendum",
7+ description: "Fetcha's Data Processing Addendum: roles, sub-processors, security measures, breach notification and international transfers for personal data handled through the web access API.",
8+ alternates: { canonical: "/legal/dpa" },
9+};
10+
11+const sections: LegalSection[] = [
12+ {
13+ id: "introduction",
14+ title: "Introduction and application",
15+ body: (
16+ <>
17+ <p>
18+ This Data Processing Addendum (<strong>“DPA”</strong>) forms part of the <Link href="/legal/terms">Terms of Service</Link> or other agreement (the <strong>“Agreement”</strong>) between Fetcha, operated from Québec, Canada (<strong>“Fetcha”</strong>), and the customer identified in the account (<strong>“Customer”</strong>). It applies whenever Fetcha processes personal data on Customer’s behalf in the course of providing the Services, and it reflects the requirements of Québec’s Law 25, Canada’s PIPEDA, the EU General Data Protection Regulation (<strong>“GDPR”</strong>), the UK GDPR and the Swiss FADP (together, <strong>“Data Protection Law”</strong>).
19+ </p>
20+ <p>This DPA is accepted automatically by all customers as part of the Agreement. Customers who need a signed copy, or who require the EU Standard Contractual Clauses or UK Addendum executed, can request them at <a href="mailto:legal@fetcha.co">legal@fetcha.co</a>. Terms such as “controller”, “processor”, “personal data”, “processing”, “data subject” and “personal data breach” have the meanings given in Data Protection Law.</p>
21+ </>
22+ ),
23+ },
24+ {
25+ id: "roles",
26+ title: "Roles of the parties",
27+ body: (
28+ <>
29+ <p>Two categories of data are handled by the Services, and the parties act in different capacities for each.</p>
30+ <h3>Transiting content: Fetcha as processor</h3>
31+ <p>When Customer instructs Fetcha to retrieve a URL, the request parameters Customer supplies (URL, headers, cookies, body) and the content returned by the target website (<strong>“Transiting Content”</strong>) may include personal data. For Transiting Content, <strong>Customer is the controller</strong> (or a processor acting for its own controller) and <strong>Fetcha is a processor</strong>, acting only on Customer’s documented instructions, which are given through the API and dashboard. Fetcha does not determine which websites are accessed, which data is collected or for what purpose, and does not retain response bodies by default.</p>
32+ <h3>Account data: Fetcha as controller</h3>
33+ <p>Personal data about Customer’s personnel and the operation of the account (names, email addresses, credentials, audit logs, request metadata, billing information) is processed by Fetcha as an <strong>independent controller</strong> to provide, secure and bill the Services, as described in the <Link href="/legal/privacy">Privacy Policy</Link>. This DPA does not govern that processing except where expressly stated.</p>
34+ </>
35+ ),
36+ },
37+ {
38+ id: "details",
39+ title: "Details of processing",
40+ body: (
41+ <>
42+ <table>
43+ <thead>
44+ <tr>
45+ <th>Item</th>
46+ <th>Description</th>
47+ </tr>
48+ </thead>
49+ <tbody>
50+ <tr><td>Subject matter</td><td>Retrieval of web resources designated by Customer through shared network infrastructure.</td></tr>
51+ <tr><td>Duration</td><td>The term of the Agreement. Transiting Content is processed only for the duration of each request; request metadata is retained for the plan’s retention period (3, 7, 30 or 90 days, or as agreed for Enterprise).</td></tr>
52+ <tr><td>Nature and purpose</td><td>Transmission, routing, retrying and returning of HTTP requests and responses; redaction of sensitive headers; logging of request metadata for Customer’s own use, billing and security.</td></tr>
53+ <tr><td>Types of personal data</td><td>Determined by Customer. May include any personal data present in requested URLs, headers, cookies, request bodies or in the content of target pages (for example names, contact details, identifiers, online identifiers, IP addresses).</td></tr>
54+ <tr><td>Categories of data subjects</td><td>Determined by Customer. Typically users or publishers of the websites Customer accesses, and Customer’s own end users where their data appears in requests.</td></tr>
55+ <tr><td>Special categories</td><td>Not intended. Customer must not direct the Services at special-category data without a documented lawful basis and prior written notice to Fetcha.</td></tr>
56+ </tbody>
57+ </table>
58+ </>
59+ ),
60+ },
61+ {
62+ id: "customer-obligations",
63+ title: "Customer obligations",
64+ body: (
65+ <>
66+ <p>Customer is responsible for the lawfulness of the processing it instructs, including having a valid legal basis, providing any required notices to data subjects, honouring data-subject rights, and ensuring that access to each target website and use of its content complies with Data Protection Law, the website’s terms and the <Link href="/legal/acceptable-use">Acceptable Use Policy</Link>. Customer’s instructions must be lawful; Fetcha will inform Customer if, in its opinion, an instruction infringes Data Protection Law, and may suspend processing of that instruction.</p>
67+ <p>Customer will not use the Services to collect personal data at scale about identifiable individuals without a documented assessment of necessity and proportionality, and will conduct any data protection impact assessment that Data Protection Law requires. Fetcha will provide reasonable information to assist.</p>
68+ </>
69+ ),
70+ },
71+ {
72+ id: "fetcha-obligations",
73+ title: "Fetcha obligations as processor",
74+ body: (
75+ <>
76+ <p>With respect to Transiting Content, Fetcha will:</p>
77+ <ul>
78+ <li>process personal data only on Customer’s documented instructions (the API call and its parameters constitute the instruction), unless required by law, in which case Fetcha will inform Customer before processing unless legally prohibited;</li>
79+ <li>not retain response bodies beyond completion of the request, except transiently in processing memory, and not use Transiting Content for any purpose of its own, including model training or analytics on content;</li>
80+ <li>ensure that personnel authorised to process personal data are bound by confidentiality obligations and receive appropriate training, and limit access to what is strictly necessary for operations and support;</li>
81+ <li>implement the technical and organisational measures described in section 7;</li>
82+ <li>engage sub-processors only under the conditions of section 6;</li>
83+ <li>assist Customer, taking into account the nature of the processing, in responding to data-subject requests and in meeting its obligations regarding security, breach notification, impact assessments and prior consultation;</li>
84+ <li>at the end of the Agreement, delete personal data in accordance with section 10;</li>
85+ <li>make available the information necessary to demonstrate compliance and allow audits as set out in section 9.</li>
86+ </ul>
87+ </>
88+ ),
89+ },
90+ {
91+ id: "sub-processors",
92+ title: "Sub-processors",
93+ body: (
94+ <>
95+ <p>Customer gives general written authorisation for Fetcha to engage sub-processors in the following categories:</p>
96+ <ul>
97+ <li><strong>Hosting and infrastructure providers</strong> (compute, database, object storage, content delivery), located in Canada;</li>
98+ <li><strong>Upstream network partners</strong> that provide exit capacity for the datacenter, residential, ISP and mobile network classes. These partners transmit request traffic between Fetcha and the target website. They receive the target URL and traffic content in transit but not Customer’s identity, API key or Fetcha request id. Partner identities are confidential; a list is available to Customer under a non-disclosure agreement on request;</li>
99+ <li><strong>Transactional email delivery</strong> (account data only, not Transiting Content);</li>
100+ <li><strong>Payment processing</strong> (account data only, when checkout launches).</li>
101+ </ul>
102+ <p>Fetcha imposes data-protection obligations on each sub-processor that are no less protective than those in this DPA, and remains liable for their performance. Fetcha will notify Customer of any intended addition or replacement of a sub-processor category or of a partner handling Transiting Content at least 30 days in advance by email or dashboard notice. Customer may object on reasonable data-protection grounds within that period; if the parties cannot resolve the objection, Customer may terminate the affected Services and receive a pro-rated refund of prepaid fees.</p>
103+ </>
104+ ),
105+ },
106+ {
107+ id: "security",
108+ title: "Security measures",
109+ body: (
110+ <>
111+ <p>Fetcha maintains technical and organisational measures appropriate to the risk, including:</p>
112+ <ul>
113+ <li><strong>Encryption</strong>: TLS 1.2 or higher for all connections to the API and dashboard and between Fetcha components; encryption of databases and backups at rest.</li>
114+ <li><strong>Credential protection</strong>: passwords and API keys stored only as salted hashes; keys displayed in full only at creation; scoped keys and optional expiry; rotation and revocation in the dashboard.</li>
115+ <li><strong>Data minimisation</strong>: response bodies not stored by default; <code>Authorization</code>, <code>Cookie</code> and similar headers redacted before any log is written; debug attempt data retained only when Customer enables it and only for the plan retention period.</li>
116+ <li><strong>Network controls</strong>: SSRF protection blocking private, link-local, metadata and internal hosts with re-validation on every redirect; segmentation between the public API, routing engine and databases; rate limiting, concurrency limits and circuit breakers.</li>
117+ <li><strong>Access control</strong>: role-based access, least privilege, multi-factor authentication for administrative systems, audit logging of administrative actions, prompt de-provisioning of departing personnel.</li>
118+ <li><strong>Operations</strong>: monitored health checks and status page, vulnerability and dependency monitoring, tested backups with 35-day rotation, documented incident-response procedure.</li>
119+ <li><strong>Retention</strong>: automatic deletion of request metadata at the end of the plan retention window; deletion of account data within 30 days of account deletion after a 7-day grace period.</li>
120+ </ul>
121+ <p>Fetcha may update these measures provided the overall level of protection is not reduced. A more detailed security overview is available to Enterprise customers on request.</p>
122+ </>
123+ ),
124+ },
125+ {
126+ id: "breach",
127+ title: "Personal data breach notification",
128+ body: (
129+ <>
130+ <p>Fetcha will notify Customer <strong>without undue delay, and in any event within 72 hours</strong>, after becoming aware of a personal data breach affecting Transiting Content or Customer’s account data. The notification, sent to the account owner’s email address and any security contact Customer has registered, will describe, to the extent known, the nature of the breach, the categories and approximate number of data subjects and records concerned, the likely consequences, the measures taken or proposed, and a point of contact. Information may be provided in phases as the investigation progresses.</p>
131+ <p>Fetcha will cooperate reasonably with Customer’s own notifications to authorities and data subjects and will not name Customer in any public statement about a breach without consent, unless required by law. Fetcha’s notification is not an admission of fault or liability.</p>
132+ </>
133+ ),
134+ },
135+ {
136+ id: "audits",
137+ title: "Audits and assistance",
138+ body: (
139+ <>
140+ <p>On written request no more than once per year (or more often after a breach or at the request of a supervisory authority), Fetcha will make available information reasonably necessary to demonstrate compliance with this DPA, such as security documentation, sub-processor lists under NDA, and summaries of internal or third-party assessments. If this information is insufficient, Customer or an independent auditor bound by confidentiality may conduct an audit at Customer’s expense, during business hours, on at least 30 days’ notice, in a manner that does not disrupt Fetcha’s operations or compromise the confidentiality of other customers or network partners.</p>
141+ <p>If a data subject contacts Fetcha directly about Transiting Content, Fetcha will, where the data subject can be associated with Customer, redirect the request to Customer and will not respond substantively except as required by law. Requests for assistance beyond what is described in this DPA may be subject to reasonable fees.</p>
142+ </>
143+ ),
144+ },
145+ {
146+ id: "deletion",
147+ title: "Return and deletion",
148+ body: (
149+ <>
150+ <p>Because Transiting Content is not retained, there is generally nothing to return at the end of the Agreement. Request metadata can be exported by Customer from the dashboard at any time during the retention window. On termination or deletion of the account, Fetcha will delete remaining request metadata and account data within 30 days, after a 7-day grace period during which Customer may cancel the deletion, except for data that must be retained under applicable law (such as invoicing records), which will remain protected under this DPA and be deleted when the legal retention period ends. Encrypted backups are overwritten within 35 days.</p>
151+ </>
152+ ),
153+ },
154+ {
155+ id: "transfers",
156+ title: "International transfers",
157+ body: (
158+ <>
159+ <p>Fetcha stores account data and request metadata in Canada, a jurisdiction recognised by the European Commission as providing adequate protection. Transiting Content is, by the nature of the Services, transmitted through the geography Customer selects for each request and through the location of the target website; Customer determines those locations by its instructions.</p>
160+ <p>Where Fetcha or a sub-processor transfers personal data from the EEA, the UK or Switzerland to a country without an adequacy decision, the transfer is protected by the EU Standard Contractual Clauses (Module Two, controller-to-processor, or Module Three, processor-to-processor, as applicable), the UK International Data Transfer Addendum, and the Swiss amendments, which are incorporated by reference and which Fetcha will execute on request. Fetcha carries out the assessment required by Québec’s Law 25 before communicating personal information outside Québec.</p>
161+ </>
162+ ),
163+ },
164+ {
165+ id: "general",
166+ title: "Liability, precedence and changes",
167+ body: (
168+ <>
169+ <p>Each party’s liability under this DPA is subject to the limitations and exclusions in the Agreement, except to the extent Data Protection Law prohibits such limitation. In the event of conflict, this DPA prevails over the Agreement with respect to processing of personal data, and the Standard Contractual Clauses prevail over this DPA where they apply. Fetcha may update this DPA to reflect changes in law or the Services; the version and effective date appear at the top of this page, and material changes are notified at least 30 days in advance. This DPA is governed by the law of the Agreement (Québec, Canada), except where the Standard Contractual Clauses require otherwise.</p>
170+ <p>Contact for all matters under this DPA: <a href="mailto:privacy@fetcha.co">privacy@fetcha.co</a> (privacy officer) and <a href="mailto:legal@fetcha.co">legal@fetcha.co</a> (contracts).</p>
171+ </>
172+ ),
173+ },
174+];
175+
176+export default function DpaPage() {
177+ return <LegalLayout title="Data Processing Addendum" current="/legal/dpa" summary="How Fetcha processes personal data on your behalf. Fetcha is a processor for content transiting the API and a controller for account data; sub-processors are hosting, unnamed upstream network partners and email delivery; breaches are notified within 72 hours; data stays in Canada with SCCs where needed." sections={sections} />;
178+}
added apps/web/src/app/(marketing)/legal/privacy/page.tsx +172 −0
@@ -0,0 +1,172 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { LegalLayout, type LegalSection } from "@/components/marketing/legal-layout";
4+
5+export const metadata: Metadata = {
6+ title: "Privacy Policy",
7+ description: "How Fetcha collects, uses, retains and protects personal information for account holders and for content transiting the web access API.",
8+ alternates: { canonical: "/legal/privacy" },
9+};
10+
11+const sections: LegalSection[] = [
12+ {
13+ id: "scope",
14+ title: "Scope and who we are",
15+ body: (
16+ <>
17+ <p>
18+ This Privacy Policy explains how Fetcha (<strong>“we”</strong>, <strong>“us”</strong>), operated from the Province of Québec, Canada, collects, uses, discloses and protects personal information when you visit <code>fetcha.co</code>, create an account, use the dashboard or call the API (together, the <strong>“Services”</strong>). It is written to meet Québec’s Act respecting the protection of personal information in the private sector (Law 25), Canada’s PIPEDA, and, for customers and end users in the European Economic Area, the United Kingdom and Switzerland, the GDPR and UK GDPR.
19+ </p>
20+ <p>
21+ Two kinds of data flow through Fetcha, and we treat them differently. <strong>Account data</strong> is information about you and your organisation; for it, Fetcha is the controller (in Québec terms, the enterprise responsible). <strong>Transiting content</strong> is the web content you instruct us to retrieve on your behalf; for it, you are the controller and Fetcha acts as a processor under the <Link href="/legal/dpa">Data Processing Addendum</Link>. This policy focuses on account data and on the metadata we keep about requests.
22+ </p>
23+ <p>
24+ Our person in charge of the protection of personal information can be reached at <a href="mailto:privacy@fetcha.co">privacy@fetcha.co</a>.
25+ </p>
26+ </>
27+ ),
28+ },
29+ {
30+ id: "data-we-collect",
31+ title: "Information we collect",
32+ body: (
33+ <>
34+ <h3>Account and organisation data</h3>
35+ <p>When you sign up we collect your name, email address, a hashed password, the organisation and project names you choose, plan and billing status, and your acceptance of our legal documents (document version, timestamp, IP address). If you contact support we keep the correspondence.</p>
36+ <h3>Usage and security metadata</h3>
37+ <p>We log sign-ins and security-relevant actions (key creation, rotation, revocation, settings changes) with timestamp, IP address and user agent. We record how you use the dashboard at the level of pages and actions, not keystrokes.</p>
38+ <h3>Request logs (metadata only)</h3>
39+ <p>
40+ For every API call we store a request record: request id, project, API key id, source (API, SDK or playground), the requested URL and final URL, the target domain, HTTP method, requested and resolved network class, target country/region/city, session id, format, outcome and HTTP status, error code, number of attempts, latency, byte counts, billed price, the client IP and user agent that made the call, and timing data. <strong>Sensitive headers such as <code>Authorization</code> and <code>Cookie</code> are redacted before storage.</strong> Custom headers and request bodies you send are not logged unless you enable debug mode on a request, in which case attempt-level diagnostics are kept for the same retention period.
41+ </p>
42+ <h3>Target content</h3>
43+ <p><strong>We do not retain the response bodies we retrieve for you by default.</strong> Content is streamed back to you and discarded from memory once the response completes. Transient copies may exist for seconds in processing buffers and, where you use the Playground, in your browser.</p>
44+ <h3>Information we do not collect</h3>
45+ <p>We do not collect payment card numbers (checkout is not yet live; when it launches, cards will be handled directly by a payment processor). We do not use advertising trackers or third-party analytics cookies on the marketing site or dashboard.</p>
46+ </>
47+ ),
48+ },
49+ {
50+ id: "how-we-use",
51+ title: "How we use information",
52+ body: (
53+ <>
54+ <ul>
55+ <li><strong>To provide the Services</strong>: authenticate you, route and execute requests, enforce plan limits, show you logs and usage, and support you.</li>
56+ <li><strong>To bill</strong>: meter requests and bandwidth, produce invoices and usage summaries.</li>
57+ <li><strong>To secure and protect</strong>: detect credential compromise, abuse, fraud and violations of the <Link href="/legal/acceptable-use">Acceptable Use Policy</Link>; apply rate limits and circuit breakers; investigate incidents.</li>
58+ <li><strong>To improve routing</strong>: compute per-domain and per-network success, latency and cost statistics. These statistics are aggregated and are not tied to individual end users of the websites you access.</li>
59+ <li><strong>To communicate</strong>: send transactional emails (verification, security alerts, invoices, incident notices, changes to legal documents). We send product news only with your consent and you can opt out at any time.</li>
60+ <li><strong>To comply with law</strong> and to establish or defend legal claims.</li>
61+ </ul>
62+ <p>Legal bases under the GDPR, where it applies: performance of a contract (providing the Services), legitimate interests (security, abuse prevention, improving the Services), consent (marketing), and legal obligation (tax and accounting). We do not sell personal information and we do not use it for automated decisions that produce legal or similarly significant effects on you.</p>
63+ </>
64+ ),
65+ },
66+ {
67+ id: "retention",
68+ title: "Retention",
69+ body: (
70+ <>
71+ <p>We keep request logs for the retention period of your plan, then delete them automatically:</p>
72+ <table>
73+ <thead>
74+ <tr>
75+ <th>Plan</th>
76+ <th>Request log retention</th>
77+ </tr>
78+ </thead>
79+ <tbody>
80+ <tr><td>Free</td><td>3 days</td></tr>
81+ <tr><td>Developer</td><td>7 days</td></tr>
82+ <tr><td>Growth</td><td>30 days</td></tr>
83+ <tr><td>Business</td><td>90 days</td></tr>
84+ <tr><td>Enterprise</td><td>Configurable by agreement</td></tr>
85+ </tbody>
86+ </table>
87+ <p>Aggregated usage counters (monthly requests and bandwidth per project) are kept for the life of the account plus the period needed for invoicing and tax records (up to 7 years, as required by Canadian law). Security and audit logs are kept for 12 months. Account data is kept while your account is active and deleted within 30 days after account deletion, subject to the 7-day grace period below and to legal holds. Backups are encrypted and rotate out within 35 days.</p>
88+ </>
89+ ),
90+ },
91+ {
92+ id: "sharing",
93+ title: "Disclosure and processors",
94+ body: (
95+ <>
96+ <p>We share personal information only with service providers that process it on our instructions under written contracts, and only as needed to run the Services:</p>
97+ <ul>
98+ <li><strong>Hosting and infrastructure</strong>: servers, databases and object storage that run the API and dashboard, located in Canada.</li>
99+ <li><strong>Email delivery</strong>: a transactional email provider that sends verification, security and billing messages to your address.</li>
100+ <li><strong>Upstream network partners</strong>: the proxy networks through which requests exit. They see the target URL, the exit geography and the request traffic itself, but they do not receive your account identity, API key or Fetcha request id. We do not name partners publicly; a current list is available to Enterprise customers under NDA and on request at <a href="mailto:privacy@fetcha.co">privacy@fetcha.co</a>.</li>
101+ <li><strong>Payments</strong> (when checkout launches): a payment processor that handles card data directly; we receive only tokens and billing status.</li>
102+ </ul>
103+ <p>We may also disclose information when required by law, court order or lawful request from a public authority, to protect the rights, safety or property of Fetcha, our customers or the public, to enforce our terms, or in connection with a merger, acquisition or sale of assets, in which case this policy continues to apply to the transferred data. We will tell you about legal requests concerning your account unless prohibited.</p>
104+ </>
105+ ),
106+ },
107+ {
108+ id: "transfers",
109+ title: "International transfers",
110+ body: (
111+ <>
112+ <p>Account data and request logs are stored in Canada. Canada is recognised by the European Commission as providing adequate protection for personal data transferred from the EEA under PIPEDA. Some processors (for example email delivery) may process data in the United States; those transfers are covered by contractual safeguards, including standard contractual clauses where required.</p>
113+ <p>By design, transiting content leaves and enters the geography you specify in each request: if you request an exit in Germany, the target website is contacted from Germany. Before communicating personal information outside Québec we carry out the privacy impact assessment required by Law 25.</p>
114+ </>
115+ ),
116+ },
117+ {
118+ id: "your-rights",
119+ title: "Your rights and choices",
120+ body: (
121+ <>
122+ <p>Depending on where you live, you have rights to <strong>access</strong> the personal information we hold about you, to <strong>correct</strong> it, to <strong>export</strong> it in a structured, commonly used format, to <strong>delete</strong> it, to <strong>object</strong> to or <strong>restrict</strong> certain processing, to withdraw consent, and to lodge a complaint with a supervisory authority (in Québec, the Commission d’accès à l’information; in Canada, the Office of the Privacy Commissioner; in the EU, your national data-protection authority).</p>
123+ <p>Most of these actions are self-serve in the dashboard: edit your profile and organisation, export request logs, revoke sessions and keys, and delete your account. <strong>When you delete your account, API access stops immediately and we hold your data for a grace period of 7 days</strong>, during which you can contact us to cancel the deletion; after that, data is deleted as described in Retention. For other requests, email <a href="mailto:privacy@fetcha.co">privacy@fetcha.co</a> from the address on your account. We respond within 30 days, and we may ask for information needed to verify your identity.</p>
124+ <p>If you are an end user of a website that a Fetcha customer accessed, we typically hold no information that identifies you; please direct requests to the customer, who is the controller. We will assist customers with such requests under the DPA.</p>
125+ </>
126+ ),
127+ },
128+ {
129+ id: "cookies",
130+ title: "Cookies",
131+ body: (
132+ <>
133+ <p>We use only cookies that are strictly necessary to operate the Services:</p>
134+ <ul>
135+ <li><code>fetcha*</code> session cookies, set when you sign in, to keep you authenticated and protect against cross-site request forgery. They are <code>HttpOnly</code>, <code>Secure</code>, and expire when your session ends or after a period of inactivity.</li>
136+ <li>A <strong>theme</strong> cookie or local-storage key that remembers your light/dark preference.</li>
137+ <li>A <strong>project</strong> cookie that remembers which project you last selected in the dashboard.</li>
138+ </ul>
139+ <p>We do not set advertising, analytics or cross-site tracking cookies. Because these cookies are essential, no consent banner is shown; you can block them in your browser, but the dashboard will not work without the session cookie. See the <Link href="/legal/cookies">Cookie Policy</Link> for details.</p>
140+ </>
141+ ),
142+ },
143+ {
144+ id: "security",
145+ title: "Security",
146+ body: (
147+ <>
148+ <p>We protect personal information with measures appropriate to its sensitivity: TLS for all connections, encryption of data at rest, hashed passwords and API keys (we store only a hash and the last characters of a key), role-based access limited to personnel who need it, audit logging of administrative actions, network segmentation between the API, routing engine and databases, and secure development practices including dependency monitoring. Request logs redact sensitive headers before they are written.</p>
149+ <p>No system is perfectly secure. If we learn of a breach of security safeguards that presents a risk of serious harm, we will notify affected customers and the relevant authorities without undue delay, and in any event within the timeframes required by law (72 hours for customers under the DPA).</p>
150+ </>
151+ ),
152+ },
153+ {
154+ id: "children",
155+ title: "Children",
156+ body: <p>The Services are intended for businesses and adult developers. We do not knowingly collect personal information from anyone under 18. If you believe a minor has created an account, contact <a href="mailto:privacy@fetcha.co">privacy@fetcha.co</a> and we will delete it.</p>,
157+ },
158+ {
159+ id: "changes",
160+ title: "Changes to this policy",
161+ body: (
162+ <>
163+ <p>We may update this policy as the Services evolve or the law changes. The version and effective date appear at the top of this page. For material changes we will notify account owners by email at least 30 days before they take effect. Continued use of the Services after that date means you accept the updated policy.</p>
164+ <p>Questions or concerns: <a href="mailto:privacy@fetcha.co">privacy@fetcha.co</a>. Postal correspondence can be requested at that address.</p>
165+ </>
166+ ),
167+ },
168+];
169+
170+export default function PrivacyPage() {
171+ return <LegalLayout title="Privacy Policy" current="/legal/privacy" summary="What we collect about you and about the requests you send, how long we keep it, who processes it, and how you exercise your rights. Target content is not retained by default; request metadata is kept 3 to 90 days depending on your plan, with sensitive headers redacted." sections={sections} />;
172+}
added apps/web/src/app/(marketing)/legal/terms/page.tsx +177 −0
@@ -0,0 +1,177 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { LegalLayout, type LegalSection } from "@/components/marketing/legal-layout";
4+
5+export const metadata: Metadata = {
6+ title: "Terms of Service",
7+ description: "The terms that govern your use of the Fetcha web access API, dashboard and SDKs.",
8+ alternates: { canonical: "/legal/terms" },
9+};
10+
11+const sections: LegalSection[] = [
12+ {
13+ id: "agreement",
14+ title: "Agreement and parties",
15+ body: (
16+ <>
17+ <p>
18+ These Terms of Service (the <strong>“Terms”</strong>) are a binding agreement between you, the individual or legal entity that creates an account or uses the Services (<strong>“you”</strong> or <strong>“Customer”</strong>), and Fetcha, a business operated from the Province of Québec, Canada (<strong>“Fetcha”</strong>, <strong>“we”</strong>, <strong>“us”</strong>). The <strong>“Services”</strong> means the Fetcha web access API available at <code>https://www.fetcha.co/v1</code>, the web dashboard, the software development kits, the documentation and any related tooling we make available.
19+ </p>
20+ <p>
21+ By creating an account, clicking “I agree”, using an API key or otherwise accessing the Services, you accept these Terms, the <Link href="/legal/acceptable-use">Acceptable Use Policy</Link>, the <Link href="/legal/privacy">Privacy Policy</Link> and, where it applies, the <Link href="/legal/dpa">Data Processing Addendum</Link>, all of which are incorporated by reference. If you are accepting on behalf of a company, you represent that you have authority to bind that company, and “you” refers to the company.
22+ </p>
23+ <p>If you do not agree to these Terms, you must not use the Services. If you have signed a separate written agreement with Fetcha that expressly supersedes these Terms, that agreement governs to the extent of any conflict.</p>
24+ </>
25+ ),
26+ },
27+ {
28+ id: "the-services",
29+ title: "The Services and what they are not",
30+ body: (
31+ <>
32+ <p>
33+ Fetcha provides infrastructure that retrieves publicly reachable web resources on your instruction. When you call the API with a URL, we route the request through one or more upstream network partners and return the response to you. We select routes automatically according to success history, cost, latency, network health, geography and session stability, and we may retry a request through a different route. Feature availability is described in the documentation and the <Link href="/changelog">changelog</Link>; features marked “coming soon” or “not available” are not part of the Services until we announce them.
34+ </p>
35+ <p>
36+ The Services are a tool. <strong>You decide which URLs to request, how often, and what you do with the content.</strong> Fetcha does not review, curate, endorse or take ownership of any content you retrieve, and does not grant you any right to that content. Your right to access any third-party website, and to use what you retrieve, depends on that website’s terms, applicable law and your own contractual arrangements, not on these Terms.
37+ </p>
38+ <p>We may modify the Services, including adding, changing or retiring features, network classes, limits or endpoints. Where a change materially reduces core functionality you are paying for, we will give at least 30 days’ notice by email or in the dashboard.</p>
39+ </>
40+ ),
41+ },
42+ {
43+ id: "accounts",
44+ title: "Accounts, API keys and security",
45+ body: (
46+ <>
47+ <p>You must be at least 18 years old and provide accurate registration information, including a working email address that you control. You must verify your email address before making API requests. You are responsible for keeping your credentials confidential, including passwords and API keys (<code>fch_live_…</code>, <code>fch_test_…</code>).</p>
48+ <p>API keys are shown in full only once, at creation. Anything done with your keys is deemed done by you. If you believe a key has been exposed, revoke or rotate it immediately from the dashboard and notify <a href="mailto:support@fetcha.co">support@fetcha.co</a>. You may not share a single account between separate organisations, sell or lease access to your keys, or use another customer’s keys.</p>
49+ <p>We may require you to update security-relevant settings, and we may suspend keys that show signs of compromise or abuse, with notice where practicable.</p>
50+ </>
51+ ),
52+ },
53+ {
54+ id: "acceptable-use",
55+ title: "Acceptable use",
56+ body: (
57+ <>
58+ <p>
59+ Your use of the Services must comply with the <Link href="/legal/acceptable-use">Acceptable Use Policy</Link>, which is part of these Terms. In summary, you may not use Fetcha to access systems without authorisation, to attack, flood or overload any system, to bypass third-party authentication or paywalls, to collect personal data unlawfully, to distribute malware, to commit fraud, to handle child sexual abuse material, to target Fetcha’s own infrastructure, or to resell the Services as an anonymous proxy.
60+ </p>
61+ <p>You are solely responsible for ensuring that each request you send, and each use you make of the retrieved content, is lawful in every relevant jurisdiction, including compliance with applicable data-protection, intellectual-property, computer-misuse, consumer-protection and export-control laws, and with the terms of the websites you access.</p>
62+ <p>We monitor aggregate usage metadata to enforce this section. We may throttle, suspend or terminate access, delete offending data and report to law enforcement where we believe in good faith that these Terms or the law are being violated.</p>
63+ </>
64+ ),
65+ },
66+ {
67+ id: "plans-billing",
68+ title: "Plans, fees and billing",
69+ body: (
70+ <>
71+ <p>
72+ The Services are offered on the plans described on the <Link href="/pricing">pricing page</Link>. Each plan includes a monthly request quota, a residential-bandwidth allowance and operational limits (concurrency, timeout, retries, log retention). Usage above the included amounts is charged at the overage rates of your plan. One completed call to <code>/v1/fetch</code> is one request regardless of the number of internal retries; requests rejected before upstream work (invalid input, disallowed URL, quota exceeded) are not counted.
73+ </p>
74+ <p>
75+ <strong>During the public preview, self-serve checkout is not available.</strong> All accounts are on the Free plan unless we provision a paid plan for you in writing. Where a paid plan has been provisioned, fees are invoiced in United States dollars, are due within 15 days of the invoice date unless otherwise agreed, and are exclusive of taxes, which you are responsible for (except taxes on Fetcha’s income). Fees are non-refundable except where required by law or expressly stated in these Terms.
76+ </p>
77+ <p>We may change prices with at least 30 days’ notice; changes apply from your next billing period. We may suspend the Services for accounts with overdue amounts after notice. Free plans may be subject to fair-use limits, and we may reclaim inactive free accounts after 12 months of inactivity following email notice.</p>
78+ </>
79+ ),
80+ },
81+ {
82+ id: "customer-data",
83+ title: "Your data and our data",
84+ body: (
85+ <>
86+ <p>
87+ <strong>Customer Content</strong> means the URLs, parameters, headers, cookies and request bodies you send us, and the responses we return to you. You retain all rights in Customer Content. You grant Fetcha a limited licence to process Customer Content solely to provide, secure and improve the Services, and to comply with law. We do not store response bodies by default; we store request metadata (such as domain, status, network class, timing and byte counts) for the retention period of your plan, with sensitive headers redacted, as detailed in the <Link href="/legal/privacy">Privacy Policy</Link>.
88+ </p>
89+ <p>
90+ <strong>Usage Data</strong> means aggregated and de-identified information about how the Services perform (for example, success rates per domain and network class). We may use Usage Data to operate and improve routing and for statistics, provided it does not identify you or your end users.
91+ </p>
92+ <p>Where you use the Services to process personal data on behalf of others, the <Link href="/legal/dpa">Data Processing Addendum</Link> applies and you are responsible for having a lawful basis and for giving any required notices.</p>
93+ </>
94+ ),
95+ },
96+ {
97+ id: "ip",
98+ title: "Intellectual property",
99+ body: (
100+ <>
101+ <p>Fetcha and its licensors own the Services, including the API, dashboard, routing logic, documentation, SDKs, trademarks and all related intellectual property. Subject to these Terms, we grant you a non-exclusive, non-transferable, revocable licence to use the Services for your internal business purposes. SDK source code is licensed under the open-source licence stated in its repository; that licence governs the SDK code, and these Terms govern your use of the API it calls.</p>
102+ <p>You may not copy, modify, reverse-engineer or create derivative works of the Services (except to the extent the law prohibits this restriction), remove proprietary notices, or use Fetcha’s name or marks without written permission, except to describe factually that your product uses Fetcha.</p>
103+ <p>If you send us suggestions or feedback, you grant us a perpetual, royalty-free licence to use them without obligation to you.</p>
104+ </>
105+ ),
106+ },
107+ {
108+ id: "third-parties",
109+ title: "Third-party websites and network partners",
110+ body: (
111+ <>
112+ <p>Fetcha does not control the websites you access and makes no representation that any website will be reachable, will return any particular content, or permits automated access. Website operators may block, rate-limit or serve altered content to automated traffic; a blocked request is a normal, billable outcome of the Services and not a defect.</p>
113+ <p>We rely on upstream network partners to provide exit capacity. We select and monitor partners, but we do not disclose their identities, and their availability may vary by region. Network classes other than residential are not yet available. Nothing in these Terms creates any relationship between you and our partners.</p>
114+ </>
115+ ),
116+ },
117+ {
118+ id: "warranties",
119+ title: "Warranties and disclaimers",
120+ body: (
121+ <>
122+ <p>We will provide the Services with reasonable skill and care and will use commercially reasonable efforts to keep the API available. Current availability and incident history are published on the <Link href="/status">status page</Link>. The public preview is provided without a service-level commitment; Enterprise agreements may include one.</p>
123+ <p>
124+ <strong>To the maximum extent permitted by law, the Services are provided “as is” and “as available”. We disclaim all other warranties, express or implied, including warranties of merchantability, fitness for a particular purpose, non-infringement, accuracy and uninterrupted or error-free operation.</strong> We do not warrant that any website can be accessed, that any content is accurate, complete or lawful to use, or that the Services meet your regulatory requirements. Consumer-protection laws in your jurisdiction (including Québec’s Consumer Protection Act, where applicable) may grant rights that cannot be excluded; nothing in these Terms limits those rights.
125+ </p>
126+ </>
127+ ),
128+ },
129+ {
130+ id: "liability",
131+ title: "Limitation of liability and indemnity",
132+ body: (
133+ <>
134+ <p>
135+ <strong>To the maximum extent permitted by law, neither party will be liable for any indirect, incidental, special, consequential or punitive damages, or for loss of profits, revenue, data or goodwill, arising out of or related to these Terms, even if advised of the possibility.</strong> Fetcha’s total aggregate liability arising out of or related to these Terms will not exceed the greater of (a) the fees you paid to Fetcha in the 12 months preceding the event giving rise to the claim and (b) CAD 100. These limits do not apply to liability that cannot be limited by law, including for gross negligence, intentional fault or bodily injury.
136+ </p>
137+ <p>You will defend, indemnify and hold harmless Fetcha and its officers, employees and contractors from any third-party claim, loss, cost or liability (including reasonable legal fees) arising from Customer Content, your use of the Services, your violation of these Terms or the Acceptable Use Policy, or your violation of any law or third-party right, including the terms of any website you access. We will notify you promptly of any such claim and cooperate reasonably at your expense.</p>
138+ </>
139+ ),
140+ },
141+ {
142+ id: "term-termination",
143+ title: "Term, suspension and termination",
144+ body: (
145+ <>
146+ <p>These Terms apply from your first use of the Services until terminated. You may terminate at any time by deleting your account from the dashboard or by writing to <a href="mailto:support@fetcha.co">support@fetcha.co</a>. Paid subscriptions end at the close of the current billing period.</p>
147+ <p>We may suspend or terminate your access immediately if you materially breach these Terms or the Acceptable Use Policy, if your use creates a security or legal risk to Fetcha, our partners or third parties, if payment is overdue after notice, or if required by law. Where reasonable, we will notify you and give you a chance to cure. We may terminate for convenience with 30 days’ notice, in which case we will refund any prepaid fees for the unused period.</p>
148+ <p>On termination, your API keys stop working and, after a grace period of 7 days during which you may export request logs, we delete account data as described in the Privacy Policy. Sections that by their nature should survive (including fees owed, ownership, disclaimers, limitation of liability, indemnity and governing law) survive termination.</p>
149+ </>
150+ ),
151+ },
152+ {
153+ id: "law",
154+ title: "Governing law and disputes",
155+ body: (
156+ <>
157+ <p>These Terms are governed by the laws of the Province of Québec and the federal laws of Canada applicable therein, without regard to conflict-of-law rules. The United Nations Convention on Contracts for the International Sale of Goods does not apply. Subject to any mandatory consumer rights, the courts sitting in the judicial district of Montréal, Québec, have exclusive jurisdiction over any dispute arising out of these Terms, and each party consents to that jurisdiction.</p>
158+ <p>Before filing a claim, each party agrees to try in good faith to resolve the dispute by contacting the other (for Fetcha, <a href="mailto:legal@fetcha.co">legal@fetcha.co</a>) and allowing 30 days for discussion. The parties acknowledge that they have expressly requested that these Terms and all related documents be drawn up in English. <em>Les parties reconnaissent avoir expressément demandé que les présentes conditions et tous les documents connexes soient rédigés en anglais.</em></p>
159+ </>
160+ ),
161+ },
162+ {
163+ id: "general",
164+ title: "General provisions",
165+ body: (
166+ <>
167+ <p>These Terms, together with the documents incorporated by reference and any order form, are the entire agreement between the parties about the Services and supersede prior agreements on the subject. If any provision is unenforceable, it will be enforced to the maximum extent permitted and the remainder stays in force. A failure to enforce a right is not a waiver.</p>
168+ <p>You may not assign these Terms without our written consent, except to a successor in a merger or sale of substantially all assets, with notice. We may assign these Terms to an affiliate or successor. Neither party is liable for delays caused by events beyond its reasonable control, including upstream network failures, denial-of-service attacks, or actions of third-party website operators. Notices to Fetcha must be sent to <a href="mailto:legal@fetcha.co">legal@fetcha.co</a>; notices to you will be sent to the email address on your account.</p>
169+ <p>We may update these Terms. The current version and its effective date appear at the top of this page. Material changes will be announced by email or dashboard notice at least 30 days in advance; continued use after the effective date constitutes acceptance.</p>
170+ </>
171+ ),
172+ },
173+];
174+
175+export default function TermsPage() {
176+ return <LegalLayout title="Terms of Service" current="/legal/terms" summary="These Terms govern access to the Fetcha web access API, dashboard and SDKs. In short: you own what you fetch and are responsible for how you use it; we operate the routing infrastructure, bill per request and bandwidth, and may suspend abuse. Québec law applies." sections={sections} />;
177+}
added apps/web/src/app/(marketing)/page.tsx +361 −0
@@ -0,0 +1,361 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import {
4+ Activity,
5+ ArrowRight,
6+ Braces,
7+ Building2,
8+ Check,
9+ Clock,
10+ EyeOff,
11+ Fingerprint,
12+ Globe,
13+ Layers,
14+ MapPin,
15+ MonitorSmartphone,
16+ RefreshCw,
17+ ShieldCheck,
18+ Terminal,
19+ Waypoints,
20+} from "lucide-react";
21+import { Button } from "@/components/ui/button";
22+import { Badge } from "@/components/ui/badge";
23+import { CodeBlock } from "@/components/ui/code-block";
24+import { HeroCodeTabs } from "@/components/marketing/hero-code-tabs";
25+import { ArchitectureDiagram } from "@/components/marketing/architecture-diagram";
26+import { Reveal } from "@/components/marketing/reveal";
27+import { cn } from "@/lib/utils";
28+
29+export const metadata: Metadata = {
30+ title: { absolute: "Fetcha — The web, accessible through one API." },
31+ description:
32+ "Fetcha intelligently routes every request across premium proxy networks, sessions and browser infrastructure to deliver reliable web access at scale. 1,000 free requests a month, no credit card.",
33+ alternates: { canonical: "/" },
34+};
35+
36+/* ------------------------------------------------------------------ */
37+/* Data */
38+/* ------------------------------------------------------------------ */
39+
40+const SCORE_WEIGHTS = [
41+ { label: "Historical success", weight: 35, tone: "bg-accent" },
42+ { label: "Cost", weight: 20, tone: "bg-accent/80" },
43+ { label: "Latency", weight: 15, tone: "bg-accent/65" },
44+ { label: "Network health", weight: 15, tone: "bg-accent/50" },
45+ { label: "Geography", weight: 10, tone: "bg-accent/35" },
46+ { label: "Session stability", weight: 5, tone: "bg-accent/20" },
47+] as const;
48+
49+const ESCALATION = [
50+ { step: "Attempt 1", title: "Best-scored route", body: "The cheapest route with a strong success history for this domain, from the requested geography.", cost: "~$0.0005", state: "live" },
51+ { step: "Attempt 2", title: "Alternate route, fresh IP", body: "Blocked or timed out? Fetcha rotates the exit IP and switches to an alternate route in the same class.", cost: "~$0.0009", state: "live" },
52+ { step: "Attempt 3", title: "Premium network", body: "Escalates to a premium residential route pinned to the target country, with tuned headers and pacing.", cost: "~$0.002", state: "live" },
53+ { step: "Attempt 4", title: "Browser execution", body: "For JavaScript-heavy targets, a managed headless browser renders the page. Not available yet.", cost: "coming soon", state: "soon" },
54+] as const;
55+
56+const FEATURES: Array<{ icon: React.ComponentType<{ className?: string }>; title: string; body: string; soon?: boolean; detail?: string }> = [
57+ { icon: Globe, title: "Global proxy network", body: "Residential exits in 45 target countries, aggregated behind one endpoint. Fetcha manages upstream capacity, so you never juggle vendors or IP pools.", detail: "residential · live" },
58+ { icon: Layers, title: "Session management", body: "Sticky sessions keep the same exit IP for up to 30 minutes. Create one with POST /v1/sessions and pass its id on every follow-up request.", detail: "ttl 60–1800 s" },
59+ { icon: MapPin, title: "Geo targeting", body: "Pin requests to a country, region or city with ISO codes. Prices, availability and search results as your users actually see them.", detail: "country · region · city" },
60+ { icon: RefreshCw, title: "Automatic retries", body: "Blocks, CAPTCHAs and timeouts trigger retries on a new IP or alternate network, within your plan's retry budget and timeout.", detail: "up to 5 retries" },
61+ { icon: Terminal, title: "Developer playground", body: "Run real requests from the dashboard, inspect attempts and timings, then copy production-ready code for your language.", detail: "dashboard" },
62+ { icon: Activity, title: "Observability", body: "Every call carries a request id. Logs show status, network class, attempts, latency and bytes, with sensitive headers redacted.", detail: "X-Fetcha-Request-ID" },
63+ { icon: MonitorSmartphone, title: "Browser execution", body: "Render JavaScript-heavy pages in a managed headless browser with the same routing and geo controls. Requests with browser: true currently return BROWSER_UNAVAILABLE.", soon: true },
64+ { icon: Braces, title: "Structured extraction", body: "Turn fetched pages into typed JSON with a schema you define. The /v1/extract endpoint is on the roadmap and not yet callable.", soon: true },
65+ { icon: Building2, title: "Enterprise scale", body: "Dedicated concurrency, 90-day and longer retention, custom limits, invoicing and a DPA. Talk to us for volume above 5M requests a month.", detail: "sales@fetcha.co" },
66+];
67+
68+const STEPS = [
69+ { title: "Sign up", body: "Email and password. No card." },
70+ { title: "Verify", body: "Confirm your email address." },
71+ { title: "Create a key", body: "Scoped fch_live_ or fch_test_ keys." },
72+ { title: "Open the Playground", body: "Try a URL and a country." },
73+ { title: "Run", body: "Inspect attempts, timing, bytes." },
74+ { title: "Copy code", body: "Generated cURL, JavaScript or Python." },
75+];
76+
77+const SECURITY = [
78+ { icon: ShieldCheck, title: "SSRF protection", body: "Only http and https. Localhost, private ranges, link-local, metadata and internal hosts are refused, and every redirect is re-validated." },
79+ { icon: EyeOff, title: "Redacted logs", body: "Authorization, Cookie and other sensitive headers are stripped before anything is written. Response bodies are not stored by default." },
80+ { icon: Fingerprint, title: "Request IDs", body: "A unique req_ id on every response header and every log line, so support and audits point at exactly one call." },
81+ { icon: Clock, title: "Retention by plan", body: "Request metadata is kept 3, 7, 30 or 90 days depending on your plan, then deleted. Enterprise retention is configurable." },
82+];
83+
84+const SESSION_SNIPPET = `# 1. Open a sticky session pinned to Canada
85+curl -X POST https://www.fetcha.co/v1/sessions \\
86+ -H "Authorization: Bearer fch_live_..." \\
87+ -d '{ "country": "CA", "ttl": 900, "label": "checkout-flow" }'
88+# → { "id": "sess_01j8x5...", "status": "active", "expires_at": "..." }
89+
90+# 2. Reuse the same exit IP across requests
91+curl -X POST https://www.fetcha.co/v1/fetch \\
92+ -H "Authorization: Bearer fch_live_..." \\
93+ -d '{ "url": "https://shop.example/cart", "session": "sess_01j8x5..." }'`;
94+
95+/* ------------------------------------------------------------------ */
96+/* Small presentational helpers */
97+/* ------------------------------------------------------------------ */
98+
99+function Eyebrow({ children, className }: { children: React.ReactNode; className?: string }) {
100+ return <p className={cn("text-[11.5px] font-semibold uppercase tracking-[0.12em] text-accent", className)}>{children}</p>;
101+}
102+
103+function SectionHeading({ eyebrow, title, body, id, align = "left" }: { eyebrow: string; title: string; body?: string; id?: string; align?: "left" | "center" }) {
104+ return (
105+ <Reveal className={cn("max-w-2xl", align === "center" && "mx-auto text-center")}>
106+ <Eyebrow>{eyebrow}</Eyebrow>
107+ <h2 id={id} className="mt-2 text-[28px] font-semibold leading-[1.15] tracking-tight sm:text-[34px]">
108+ {title}
109+ </h2>
110+ {body ? <p className="mt-3 text-[15.5px] leading-relaxed text-fg-muted">{body}</p> : null}
111+ </Reveal>
112+ );
113+}
114+
115+/* ------------------------------------------------------------------ */
116+/* Page */
117+/* ------------------------------------------------------------------ */
118+
119+export default function HomePage() {
120+ return (
121+ <>
122+ {/* ---------------------------------------------------------------- Hero */}
123+ <section className="relative overflow-hidden border-b border-border" aria-labelledby="hero-title">
124+ <div className="absolute inset-x-0 top-0 -z-10 h-[520px] grid-bg" aria-hidden />
125+ <div className="container-page grid items-center gap-12 py-16 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)] lg:gap-14 lg:py-24">
126+ <div className="animate-fade-up">
127+ <Eyebrow>Intelligent web access infrastructure</Eyebrow>
128+ <h1 id="hero-title" className="mt-4 text-[38px] font-semibold leading-[1.05] tracking-[-0.03em] sm:text-[52px] lg:text-[56px]">
129+ The web, accessible through one API.
130+ </h1>
131+ <p className="mt-5 max-w-xl text-[16.5px] leading-relaxed text-fg-muted sm:text-[17.5px]">
132+ Fetcha intelligently routes every request across premium proxy networks, sessions and browser infrastructure to deliver reliable web access at scale.
133+ </p>
134+ <div className="mt-8 flex flex-wrap items-center gap-3">
135+ <Button asChild size="lg">
136+ <Link href="/signup">
137+ Start building <ArrowRight aria-hidden />
138+ </Link>
139+ </Button>
140+ <Button asChild size="lg" variant="outline">
141+ <Link href="/docs">View documentation</Link>
142+ </Button>
143+ </div>
144+ <dl className="mt-10 grid max-w-md grid-cols-3 gap-4 border-t border-border pt-6">
145+ {[
146+ ["1,000", "free requests / mo"],
147+ ["45", "target countries"],
148+ ["1 call", "to get a page"],
149+ ].map(([v, l]) => (
150+ <div key={l}>
151+ <dt className="sr-only">{l}</dt>
152+ <dd className="font-mono text-[20px] font-semibold tabular tracking-tight text-fg">{v}</dd>
153+ <dd className="mt-0.5 text-[12.5px] text-fg-muted">{l}</dd>
154+ </div>
155+ ))}
156+ </dl>
157+ </div>
158+ <div className="min-w-0 animate-fade-up [animation-delay:120ms]">
159+ <HeroCodeTabs />
160+ </div>
161+ </div>
162+ </section>
163+
164+ {/* ------------------------------------------------------------ Platform */}
165+ <section id="platform" className="scroll-mt-16 border-b border-border py-20 lg:py-28" aria-labelledby="platform-title">
166+ <div className="container-page">
167+ <SectionHeading eyebrow="Platform" id="platform-title" title="One request in. The right route out." body="You send a URL. Fetcha authenticates, validates and scores every available route for that domain, then fetches through the network class most likely to succeed at the lowest cost." />
168+ <Reveal className="mt-12" delay={0.05}>
169+ <ArchitectureDiagram />
170+ </Reveal>
171+ </div>
172+ </section>
173+
174+ {/* -------------------------------------------------------- Routing split */}
175+ <section className="border-b border-border py-20 lg:py-28" aria-labelledby="routing-title">
176+ <div className="container-page grid gap-12 lg:grid-cols-2 lg:items-center lg:gap-20">
177+ <SectionHeading eyebrow="Intelligent routing" id="routing-title" title="Every route gets a score. The best one gets your request." body="Fetcha keeps a profile for every domain it has seen: which routes succeeded, how fast, at what price, from where. In auto mode each request is scored on six weighted signals and sent down the winning path. Circuit breakers take unhealthy routes out of rotation automatically." />
178+ <Reveal delay={0.05}>
179+ <figure className="rounded-lg border border-border bg-bg-elevated p-5 shadow-xs sm:p-6">
180+ <figcaption className="flex items-center justify-between text-[12px] text-fg-subtle">
181+ <span className="font-medium uppercase tracking-wide">Routing score weights</span>
182+ <span className="font-mono tabular">Σ = 100</span>
183+ </figcaption>
184+ <div className="mt-4 flex h-3 w-full overflow-hidden rounded-sm bg-bg-muted" role="img" aria-label="Score weights: historical success 35%, cost 20%, latency 15%, network health 15%, geography 10%, session stability 5%">
185+ {SCORE_WEIGHTS.map((w) => (
186+ <div key={w.label} className={cn("h-full border-r border-bg last:border-r-0", w.tone)} style={{ width: `${w.weight}%` }} />
187+ ))}
188+ </div>
189+ <ol className="mt-5 grid gap-2.5 sm:grid-cols-2">
190+ {SCORE_WEIGHTS.map((w) => (
191+ <li key={w.label} className="flex items-center justify-between gap-3 text-[13.5px]">
192+ <span className="flex items-center gap-2.5 text-fg-muted">
193+ <span className={cn("size-2.5 rounded-[3px]", w.tone)} aria-hidden />
194+ {w.label}
195+ </span>
196+ <span className="font-mono tabular font-medium text-fg">{w.weight}%</span>
197+ </li>
198+ ))}
199+ </ol>
200+ <p className="mt-5 border-t border-border pt-4 text-[12.5px] leading-relaxed text-fg-subtle">
201+ Scores are recomputed continuously from your own traffic and platform-wide health. Force a class with <code className="font-mono text-fg-muted">{'network: "residential"'}</code> when you need to.
202+ </p>
203+ </figure>
204+ </Reveal>
205+ </div>
206+ </section>
207+
208+ {/* ----------------------------------------------------- Escalation split */}
209+ <section className="border-b border-border bg-bg-subtle/50 py-20 lg:py-28" aria-labelledby="escalation-title">
210+ <div className="container-page grid gap-12 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:items-start lg:gap-20">
211+ <Reveal>
212+ <ol className="relative border-l border-border pl-6" aria-label="How a request escalates">
213+ {ESCALATION.map((e, i) => (
214+ <li key={e.step} className={cn("relative pb-8 last:pb-0", e.state === "soon" && "opacity-80")}>
215+ <span className={cn("absolute -left-[31px] top-0.5 flex size-[22px] items-center justify-center rounded-full border bg-bg text-[11px] font-mono font-medium tabular", e.state === "soon" ? "border-border-strong text-fg-subtle" : "border-accent text-accent")} aria-hidden>
216+ {i + 1}
217+ </span>
218+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
219+ <span className="font-mono text-[11.5px] uppercase tracking-wide text-fg-subtle">{e.step}</span>
220+ <h3 className="text-[15.5px] font-semibold tracking-tight">{e.title}</h3>
221+ {e.state === "soon" ? <Badge variant="outline">Coming soon</Badge> : null}
222+ </div>
223+ <p className="mt-1.5 max-w-lg text-[14px] leading-relaxed text-fg-muted">{e.body}</p>
224+ <p className="mt-2 font-mono text-[12.5px] tabular text-fg-subtle">
225+ Illustrative cost: <span className={e.state === "soon" ? "" : "text-fg"}>{e.cost}</span>
226+ </p>
227+ </li>
228+ ))}
229+ </ol>
230+ </Reveal>
231+ <div className="lg:sticky lg:top-24">
232+ <SectionHeading eyebrow="Escalation" id="escalation-title" title="Cheap first. Premium only when it earns its keep." body="A block is not a failure, it is a signal. Fetcha starts on the most economical route and escalates step by step, so you pay premium bandwidth only for the requests that need it. A blocked target still returns 200 with success: false, the upstream status and the attempt count, never a silent retry loop." />
233+ <Reveal delay={0.1} className="mt-6">
234+ <p className="text-[12.5px] leading-relaxed text-fg-subtle">Costs shown are illustrative averages per request for a typical 200 KB HTML page. Actual billing is per request plus residential bandwidth, per your plan.</p>
235+ </Reveal>
236+ </div>
237+ </div>
238+ </section>
239+
240+ {/* -------------------------------------------------------- Feature grid */}
241+ <section className="border-b border-border py-20 lg:py-28" aria-labelledby="features-title">
242+ <div className="container-page">
243+ <SectionHeading eyebrow="Capabilities" id="features-title" title="Everything between your code and the page." body="Built for engineers who would rather ship features than babysit proxies. What is live is live; what is coming is labelled." />
244+ <ul className="mt-12 grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-2 lg:grid-cols-3" role="list">
245+ {FEATURES.map((f) => (
246+ <li key={f.title} className={cn("relative flex flex-col bg-bg p-6", f.soon && "bg-bg-subtle/60")}>
247+ <div className="flex items-center justify-between">
248+ <f.icon className={cn("size-[18px]", f.soon ? "text-fg-subtle" : "text-accent")} aria-hidden />
249+ {f.soon ? <Badge variant="outline">Coming soon</Badge> : f.detail ? <span className="font-mono text-[11px] text-fg-subtle">{f.detail}</span> : null}
250+ </div>
251+ <h3 className={cn("mt-4 text-[15.5px] font-semibold tracking-tight", f.soon && "text-fg-muted")}>{f.title}</h3>
252+ <p className="mt-1.5 text-[13.5px] leading-relaxed text-fg-muted">{f.body}</p>
253+ </li>
254+ ))}
255+ </ul>
256+ </div>
257+ </section>
258+
259+ {/* ------------------------------------------------------- Sessions split */}
260+ <section className="border-b border-border py-20 lg:py-28" aria-labelledby="sessions-title">
261+ <div className="container-page grid gap-12 lg:grid-cols-2 lg:items-center lg:gap-20">
262+ <div className="order-2 lg:order-1">
263+ <Reveal>
264+ <CodeBlock code={SESSION_SNIPPET} lang="bash" title="sticky-session.sh" />
265+ </Reveal>
266+ </div>
267+ <div className="order-1 lg:order-2">
268+ <SectionHeading eyebrow="Sessions and geo" id="sessions-title" title="Same IP, same country, across a whole flow." body="Multi-step journeys need a consistent identity: log in, paginate, add to cart, read the price. Sticky sessions hold one residential exit for up to 30 minutes, pinned to the country, region or city you choose, and expire on their own when you are done." />
269+ <Reveal delay={0.05} className="mt-6">
270+ <ul className="grid gap-2 text-[14px] text-fg-muted">
271+ {["ISO country, region and city targeting on every request", "Sessions are scoped to your project and visible in the dashboard", "Idempotency-Key supported on session creation", "Expired sessions return SESSION_EXPIRED, never a silent new IP"].map((t) => (
272+ <li key={t} className="flex items-start gap-2.5">
273+ <Check className="mt-0.5 size-4 shrink-0 text-success" aria-hidden />
274+ {t}
275+ </li>
276+ ))}
277+ </ul>
278+ </Reveal>
279+ </div>
280+ </div>
281+ </section>
282+
283+ {/* -------------------------------------------------- Developer experience */}
284+ <section className="border-b border-border bg-bg-subtle/50 py-20 lg:py-28" aria-labelledby="dx-title">
285+ <div className="container-page">
286+ <SectionHeading eyebrow="Developer experience" id="dx-title" title="From sign-up to a working request in a few minutes." align="center" />
287+ <Reveal className="mt-12" delay={0.05}>
288+ <ol className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-3 lg:grid-cols-6">
289+ {STEPS.map((s, i) => (
290+ <li key={s.title} className="flex flex-col gap-2 bg-bg p-5">
291+ <span className="font-mono text-[12px] font-medium tabular text-accent">0{i + 1}</span>
292+ <span className="text-[14.5px] font-semibold tracking-tight">{s.title}</span>
293+ <span className="text-[12.5px] leading-snug text-fg-muted">{s.body}</span>
294+ </li>
295+ ))}
296+ </ol>
297+ </Reveal>
298+ <Reveal className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-[13px] text-fg-muted" delay={0.1}>
299+ <span className="flex items-center gap-2"><Waypoints className="size-4 text-fg-subtle" aria-hidden /> SDKs for JavaScript and Python (install from source until published)</span>
300+ <span className="flex items-center gap-2"><Terminal className="size-4 text-fg-subtle" aria-hidden /> OpenAPI-style reference with every error code</span>
301+ </Reveal>
302+ </div>
303+ </section>
304+
305+ {/* -------------------------------------------------------------- Security */}
306+ <section className="border-b border-border py-16 lg:py-20" aria-labelledby="security-title">
307+ <div className="container-page">
308+ <div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:gap-16">
309+ <Reveal className="lg:w-72 lg:shrink-0">
310+ <Eyebrow>Security and compliance</Eyebrow>
311+ <h2 id="security-title" className="mt-2 text-[24px] font-semibold leading-tight tracking-tight">
312+ Defaults you do not have to think about.
313+ </h2>
314+ <p className="mt-3 text-[14px] leading-relaxed text-fg-muted">
315+ Read the <Link href="/legal/privacy" className="text-accent underline-offset-4 hover:underline">Privacy Policy</Link>, <Link href="/legal/acceptable-use" className="text-accent underline-offset-4 hover:underline">Acceptable Use</Link> and <Link href="/legal/dpa" className="text-accent underline-offset-4 hover:underline">DPA</Link>.
316+ </p>
317+ </Reveal>
318+ <ul className="grid flex-1 gap-6 sm:grid-cols-2">
319+ {SECURITY.map((s, i) => (
320+ <Reveal as="li" key={s.title} delay={0.04 * i} className="flex gap-3.5">
321+ <s.icon className="mt-0.5 size-[18px] shrink-0 text-fg" aria-hidden />
322+ <div>
323+ <h3 className="text-[14.5px] font-semibold tracking-tight">{s.title}</h3>
324+ <p className="mt-1 text-[13.5px] leading-relaxed text-fg-muted">{s.body}</p>
325+ </div>
326+ </Reveal>
327+ ))}
328+ </ul>
329+ </div>
330+ </div>
331+ </section>
332+
333+ {/* -------------------------------------------------------------- Final CTA */}
334+ <section id="about" className="scroll-mt-16 py-20 lg:py-28" aria-labelledby="cta-title">
335+ <div className="container-page">
336+ <Reveal className="relative overflow-hidden rounded-xl border border-border bg-bg-elevated px-6 py-14 text-center shadow-xs sm:px-12">
337+ <div className="absolute inset-0 -z-10 grid-bg opacity-60" aria-hidden />
338+ <Eyebrow>Get started</Eyebrow>
339+ <h2 id="cta-title" className="mx-auto mt-3 max-w-2xl text-[30px] font-semibold leading-[1.1] tracking-tight sm:text-[40px]">
340+ Stop building proxy plumbing. Start fetching.
341+ </h2>
342+ <p className="mx-auto mt-4 max-w-xl text-[15.5px] leading-relaxed text-fg-muted">
343+ Fetcha is built and operated from Québec, Canada, by engineers who spent too many years maintaining scrapers. One endpoint, honest limits, and a public preview you can try today.
344+ </p>
345+ <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
346+ <Button asChild size="lg">
347+ <Link href="/signup">
348+ Start building <ArrowRight aria-hidden />
349+ </Link>
350+ </Button>
351+ <Button asChild size="lg" variant="outline">
352+ <Link href="/pricing">See pricing</Link>
353+ </Button>
354+ </div>
355+ <p className="mt-5 text-[13px] text-fg-subtle">No credit card. 1,000 free requests a month.</p>
356+ </Reveal>
357+ </div>
358+ </section>
359+ </>
360+ );
361+}
added apps/web/src/app/(marketing)/pricing/page.tsx +306 −0
@@ -0,0 +1,306 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { Check, Minus } from "lucide-react";
4+import { PLAN_LIMITS, PLANS, type Plan, type PlanLimits } from "@fetcha/core";
5+import { Button } from "@/components/ui/button";
6+import { Badge } from "@/components/ui/badge";
7+import { Alert } from "@/components/ui/alert";
8+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
9+import { formatNumber, formatUsd } from "@/lib/format";
10+import { cn } from "@/lib/utils";
11+
12+export const metadata: Metadata = {
13+ title: "Pricing",
14+ description: "Simple, usage-based pricing for the Fetcha web access API. Start free with 1,000 requests a month. Developer $29, Growth $149, Business $599, Enterprise custom.",
15+ alternates: { canonical: "/pricing" },
16+};
17+
18+/* ------------------------------------------------------------------ */
19+/* Helpers (pure formatting, no business logic) */
20+/* ------------------------------------------------------------------ */
21+
22+const isEnterprise = (p: PlanLimits) => p.plan === "enterprise";
23+const isFree = (p: PlanLimits) => p.plan === "free";
24+const RECOMMENDED: Plan = "developer";
25+
26+function requests(p: PlanLimits) {
27+ return isEnterprise(p) ? "Custom" : formatNumber(p.monthly_requests);
28+}
29+function price(p: PlanLimits) {
30+ return isEnterprise(p) ? "Custom" : p.price_usd_month === 0 ? "$0" : `$${p.price_usd_month}`;
31+}
32+function seconds(ms: number) {
33+ return `${ms / 1000} s`;
34+}
35+function gb(p: PlanLimits) {
36+ return isEnterprise(p) ? "Custom" : `${p.included_gb} GB`;
37+}
38+function overage(p: PlanLimits) {
39+ if (isEnterprise(p)) return "Custom";
40+ if (isFree(p)) return "Hard stop";
41+ return `${formatUsd(p.overage_per_1k_requests_usd)} / 1k`;
42+}
43+function residentialGb(p: PlanLimits) {
44+ if (isEnterprise(p)) return "Custom";
45+ if (isFree(p)) return "Included only";
46+ return `${formatUsd(p.residential_per_gb_usd)} / GB`;
47+}
48+function retention(p: PlanLimits) {
49+ return isEnterprise(p) ? "Custom (365 d default)" : `${p.retention_days} days`;
50+}
51+function networksLabel(p: PlanLimits) {
52+ return p.networks.map((n) => n[0].toUpperCase() + n.slice(1)).join(", ");
53+}
54+
55+const ROWS: Array<{ label: string; hint?: string; value: (p: PlanLimits) => string }> = [
56+ { label: "Monthly price", value: (p) => (isEnterprise(p) ? "Custom" : `${price(p)} / mo`) },
57+ { label: "Included requests", hint: "Per calendar month", value: requests },
58+ { label: "Concurrency", hint: "Simultaneous in-flight requests", value: (p) => formatNumber(p.concurrency) },
59+ { label: "Max timeout", hint: "Per request", value: (p) => seconds(p.max_timeout_ms) },
60+ { label: "Retries", hint: "Maximum automatic retries per request", value: (p) => String(p.max_retries) },
61+ { label: "Log retention", hint: "Request metadata, then deleted", value: retention },
62+ { label: "Included residential bandwidth", hint: "Per month", value: gb },
63+ { label: "Overage per 1,000 requests", value: overage },
64+ { label: "Residential bandwidth overage", value: residentialGb },
65+ { label: "Network classes on plan", hint: "Residential is live; other classes are coming soon", value: networksLabel },
66+];
67+
68+const FAQ: Array<{ q: string; a: React.ReactNode }> = [
69+ {
70+ q: "How does billing work?",
71+ a: (
72+ <>
73+ Plans are monthly and usage-based: a flat fee includes a request quota and a residential bandwidth allowance; anything above is metered as overage. <strong>Self-serve checkout is not live yet.</strong> Every account today runs on the Free plan. To move to a paid plan, email <a href="mailto:sales@fetcha.co">sales@fetcha.co</a> and we will provision it manually and invoice you. Card checkout will open in the dashboard shortly.
74+ </>
75+ ),
76+ },
77+ {
78+ q: "What counts as a request?",
79+ a: (
80+ <>
81+ One call to <code>POST /v1/fetch</code> is one request, whether it succeeds or the target blocks it. Requests rejected before any upstream work (invalid body, disallowed URL, quota exceeded) are not counted. Session management calls (<code>/v1/sessions</code>), <code>/v1/me</code> and <code>/v1/usage</code> are free.
82+ </>
83+ ),
84+ },
85+ {
86+ q: "Do retries cost extra?",
87+ a: <>No. Automatic retries and route escalation happen inside a single request and are covered by that request. You are billed once per call regardless of how many attempts Fetcha made behind the scenes. Bandwidth consumed by attempts on residential routes does count toward your residential allowance.</>,
88+ },
89+ {
90+ q: "How is bandwidth measured?",
91+ a: <>Residential bandwidth is the sum of bytes transferred through residential exits (request plus response) for the month, rounded to the byte. Each plan includes an allowance; above it, the per-GB rate of your plan applies. Only residential traffic is metered for bandwidth today.</>,
92+ },
93+ {
94+ q: "Is geo targeting included?",
95+ a: <>Yes, on every plan. Country, region and city targeting are part of the request body and do not change the price. Availability of a specific city depends on upstream capacity at the time of the request; when a location is unavailable you receive a clear error rather than a silently different geography.</>,
96+ },
97+ {
98+ q: "How do sessions work and are they billed?",
99+ a: <>A sticky session pins one exit IP for 60 to 1,800 seconds. Creating, listing and closing sessions is free; only the fetches you make through the session count as requests. Session limits scale with your plan’s concurrency.</>,
100+ },
101+ {
102+ q: "Is browser rendering available?",
103+ a: <>Not yet. Sending <code>browser: true</code> returns <code>BROWSER_UNAVAILABLE</code> and is not billed. Browser execution and structured extraction are on the roadmap; they will be priced separately and announced in the <Link href="/changelog">changelog</Link> before launch.</>,
104+ },
105+ {
106+ q: "Can I cancel or downgrade?",
107+ a: <>Yes, at any time, with no minimum term on self-serve plans. Downgrades take effect at the end of the current billing period. Until checkout is live, email <a href="mailto:sales@fetcha.co">sales@fetcha.co</a> and we will process the change within one business day. Your request logs remain accessible for the retention window of your plan.</>,
108+ },
109+];
110+
111+/* ------------------------------------------------------------------ */
112+/* Page */
113+/* ------------------------------------------------------------------ */
114+
115+export default function PricingPage() {
116+ const plans = PLANS.map((k) => PLAN_LIMITS[k]);
117+ return (
118+ <div className="container-page py-14 sm:py-20">
119+ <header className="mx-auto max-w-2xl text-center">
120+ <p className="text-[11.5px] font-semibold uppercase tracking-[0.12em] text-accent">Pricing</p>
121+ <h1 className="mt-2 text-[34px] font-semibold leading-[1.1] tracking-tight sm:text-[44px]">Start free. Pay for what you fetch.</h1>
122+ <p className="mt-4 text-[15.5px] leading-relaxed text-fg-muted">A flat monthly fee with included requests and residential bandwidth, metered overage above. Retries are never billed twice. Prices in USD.</p>
123+ </header>
124+
125+ <Alert variant="info" title="Public preview" className="mx-auto mt-8 max-w-3xl">
126+ Self-serve upgrades and card checkout are not live yet. Every account runs on the Free plan today. To start on a paid plan, email <a className="underline underline-offset-4" href="mailto:sales@fetcha.co">sales@fetcha.co</a>.
127+ </Alert>
128+
129+ {/* Plan columns */}
130+ <section aria-labelledby="plans-title" className="mt-12">
131+ <h2 id="plans-title" className="sr-only">
132+ Plans
133+ </h2>
134+ <ul className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 lg:gap-0 lg:divide-x lg:divide-border lg:overflow-hidden lg:rounded-lg lg:border lg:border-border" role="list">
135+ {plans.map((p) => {
136+ const recommended = p.plan === RECOMMENDED;
137+ return (
138+ <li key={p.plan} className={cn("relative flex flex-col rounded-lg border border-border bg-bg p-5 lg:rounded-none lg:border-0", recommended && "bg-bg-subtle/70")}>
139+ <div className="flex items-center justify-between gap-2">
140+ <h3 className="text-[15px] font-semibold tracking-tight">{p.label}</h3>
141+ {recommended ? <Badge variant="accent">Most popular</Badge> : null}
142+ </div>
143+ <div className="mt-4 flex items-baseline gap-1">
144+ <span className="font-mono text-[30px] font-semibold tabular tracking-tight">{price(p)}</span>
145+ {!isEnterprise(p) ? <span className="text-[13px] text-fg-subtle">/ month</span> : null}
146+ </div>
147+ <p className="mt-1 text-[13px] text-fg-muted">
148+ {isEnterprise(p) ? "Volume above 5M requests, custom terms." : <><span className="font-mono tabular text-fg">{requests(p)}</span> requests / mo</>}
149+ </p>
150+
151+ <dl className="mt-5 space-y-2 border-t border-border pt-4 text-[13px]">
152+ {[
153+ ["Concurrency", formatNumber(p.concurrency)],
154+ ["Timeout", seconds(p.max_timeout_ms)],
155+ ["Retries", String(p.max_retries)],
156+ ["Logs", retention(p)],
157+ ["Residential GB", gb(p)],
158+ ["Overage", overage(p)],
159+ ["Residential $/GB", residentialGb(p)],
160+ ].map(([k, v]) => (
161+ <div key={k} className="flex items-center justify-between gap-3">
162+ <dt className="text-fg-muted">{k}</dt>
163+ <dd className="font-mono tabular text-fg">{v}</dd>
164+ </div>
165+ ))}
166+ </dl>
167+ <p className="mt-3 text-[12px] leading-snug text-fg-subtle">Networks: {networksLabel(p)}</p>
168+
169+ <div className="mt-auto pt-5">
170+ {isEnterprise(p) ? (
171+ <Button asChild variant="outline" className="w-full">
172+ <a href="mailto:sales@fetcha.co?subject=Fetcha%20Enterprise">Contact sales</a>
173+ </Button>
174+ ) : (
175+ <Button asChild variant={recommended ? "primary" : isFree(p) ? "default" : "outline"} className="w-full">
176+ <Link href="/signup">Start free</Link>
177+ </Button>
178+ )}
179+ {!isEnterprise(p) && !isFree(p) ? (
180+ <p className="mt-2 text-center text-[11.5px] leading-snug text-fg-subtle">
181+ Self-serve upgrades open soon — email <a className="underline underline-offset-2 hover:text-fg" href="mailto:sales@fetcha.co">sales@fetcha.co</a>
182+ </p>
183+ ) : null}
184+ {isFree(p) ? <p className="mt-2 text-center text-[11.5px] text-fg-subtle">No credit card required.</p> : null}
185+ </div>
186+ </li>
187+ );
188+ })}
189+ </ul>
190+ </section>
191+
192+ {/* Comparison table */}
193+ <section aria-labelledby="compare-title" className="mt-20">
194+ <div className="mb-5 max-w-2xl">
195+ <h2 id="compare-title" className="text-[24px] font-semibold tracking-tight">
196+ Compare plans
197+ </h2>
198+ <p className="mt-2 text-[14px] text-fg-muted">All limits are enforced by the API and visible in your dashboard. Values come from the same configuration the API uses.</p>
199+ </div>
200+ <div className="rounded-lg border border-border">
201+ <Table>
202+ <TableHeader>
203+ <TableRow className="hover:bg-transparent">
204+ <TableHead className="w-[240px]">Limit</TableHead>
205+ {plans.map((p) => (
206+ <TableHead key={p.plan} className={cn("text-right", p.plan === RECOMMENDED && "text-fg")}>
207+ {p.label}
208+ </TableHead>
209+ ))}
210+ </TableRow>
211+ </TableHeader>
212+ <TableBody>
213+ {ROWS.map((r) => (
214+ <TableRow key={r.label}>
215+ <TableCell>
216+ <div className="font-medium text-fg">{r.label}</div>
217+ {r.hint ? <div className="text-[12px] text-fg-subtle">{r.hint}</div> : null}
218+ </TableCell>
219+ {plans.map((p) => (
220+ <TableCell key={p.plan} className={cn("text-right font-mono tabular text-[13px]", p.plan === RECOMMENDED && "bg-bg-subtle/60")}>
221+ {r.value(p)}
222+ </TableCell>
223+ ))}
224+ </TableRow>
225+ ))}
226+ {[
227+ { label: "Sticky sessions", live: true },
228+ { label: "Geo targeting (country, region, city)", live: true },
229+ { label: "Playground and request logs", live: true },
230+ { label: "SDKs (JavaScript, Python — from source)", live: true },
231+ { label: "Browser execution", live: false },
232+ { label: "Structured extraction", live: false },
233+ { label: "Webhooks", live: false },
234+ { label: "Teams and invitations", live: false },
235+ ].map((f) => (
236+ <TableRow key={f.label}>
237+ <TableCell>
238+ <div className="flex items-center gap-2 font-medium text-fg">
239+ {f.label}
240+ {!f.live ? <Badge variant="outline">Coming soon</Badge> : null}
241+ </div>
242+ </TableCell>
243+ {plans.map((p) => (
244+ <TableCell key={p.plan} className={cn("text-right", p.plan === RECOMMENDED && "bg-bg-subtle/60")}>
245+ {f.live ? (
246+ <>
247+ <Check className="ml-auto size-4 text-success" aria-hidden />
248+ <span className="sr-only">Included</span>
249+ </>
250+ ) : (
251+ <>
252+ <Minus className="ml-auto size-4 text-fg-subtle" aria-hidden />
253+ <span className="sr-only">Not yet available</span>
254+ </>
255+ )}
256+ </TableCell>
257+ ))}
258+ </TableRow>
259+ ))}
260+ </TableBody>
261+ </Table>
262+ </div>
263+ <p className="mt-3 text-[12.5px] text-fg-subtle">Datacenter, ISP and mobile network classes are listed per plan but not yet routable; <code className="font-mono">auto</code> resolves to residential during the public preview.</p>
264+ </section>
265+
266+ {/* FAQ */}
267+ <section aria-labelledby="faq-title" className="mt-20 grid gap-8 lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-16">
268+ <div>
269+ <h2 id="faq-title" className="text-[24px] font-semibold tracking-tight">
270+ Questions
271+ </h2>
272+ <p className="mt-2 text-[14px] text-fg-muted">
273+ Anything else? Write to <a className="text-accent underline-offset-4 hover:underline" href="mailto:support@fetcha.co">support@fetcha.co</a>.
274+ </p>
275+ </div>
276+ <div className="divide-y divide-border rounded-lg border border-border">
277+ {FAQ.map((f) => (
278+ <details key={f.q} className="group px-5 py-4 open:bg-bg-subtle/40">
279+ <summary className="flex cursor-pointer list-none items-center justify-between gap-4 text-[15px] font-medium tracking-tight marker:content-none [&::-webkit-details-marker]:hidden">
280+ {f.q}
281+ <span className="shrink-0 font-mono text-fg-subtle transition-transform group-open:rotate-45" aria-hidden>
282+ +
283+ </span>
284+ </summary>
285+ <div className="mt-3 max-w-2xl text-[14px] leading-relaxed text-fg-muted [&_a]:text-accent [&_a]:underline-offset-4 hover:[&_a]:underline [&_code]:rounded-[4px] [&_code]:border [&_code]:border-border [&_code]:bg-bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-fg [&_strong]:font-semibold [&_strong]:text-fg">{f.a}</div>
286+ </details>
287+ ))}
288+ </div>
289+ </section>
290+
291+ <section className="mt-20 rounded-xl border border-border bg-bg-subtle/60 px-6 py-12 text-center">
292+ <h2 className="text-[26px] font-semibold tracking-tight">Try it on the Free plan today.</h2>
293+ <p className="mx-auto mt-3 max-w-lg text-[14.5px] text-fg-muted">1,000 requests a month, sticky sessions, geo targeting and full request logs. Upgrade when you outgrow it.</p>
294+ <div className="mt-6 flex flex-wrap items-center justify-center gap-3">
295+ <Button asChild size="lg">
296+ <Link href="/signup">Start free</Link>
297+ </Button>
298+ <Button asChild size="lg" variant="outline">
299+ <a href="mailto:sales@fetcha.co">Talk to sales</a>
300+ </Button>
301+ </div>
302+ <p className="mt-4 text-[12.5px] text-fg-subtle">No credit card. 1,000 free requests a month.</p>
303+ </section>
304+ </div>
305+ );
306+}
added apps/web/src/app/(marketing)/status/page.tsx +209 −0
@@ -0,0 +1,209 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { Badge } from "@/components/ui/badge";
4+import { formatDate, formatPercent, timeAgo } from "@/lib/format";
5+import { getStatusSnapshot, type ComponentState, type DayPoint, type Incident } from "@/lib/queries/status";
6+import { cn } from "@/lib/utils";
7+
8+export const dynamic = "force-dynamic";
9+
10+export const metadata: Metadata = {
11+ title: "Status",
12+ description: "Live operational status of the Fetcha API, dashboard, proxy networks and authentication, with 24-hour success rate and 30-day history.",
13+ alternates: { canonical: "/status" },
14+};
15+
16+const STATE_META: Record<ComponentState, { label: string; badge: "success" | "warning" | "danger" | "outline" | "default"; dot: string }> = {
17+ operational: { label: "Operational", badge: "success", dot: "bg-success" },
18+ degraded: { label: "Degraded", badge: "warning", dot: "bg-warning" },
19+ outage: { label: "Major outage", badge: "danger", dot: "bg-danger" },
20+ "not-launched": { label: "Not launched", badge: "outline", dot: "bg-border-strong" },
21+ unknown: { label: "Unknown", badge: "default", dot: "bg-fg-subtle" },
22+};
23+
24+const DAY_TONE: Record<DayPoint["state"], string> = {
25+ operational: "bg-success",
26+ degraded: "bg-warning",
27+ outage: "bg-danger",
28+ "no-data": "bg-bg-muted",
29+ "not-launched": "bg-bg-muted",
30+ unknown: "bg-bg-muted",
31+};
32+
33+function dayLabel(p: DayPoint): string {
34+ const date = new Date(`${p.day}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
35+ if (p.state === "no-data" || p.total === 0) return `${date} · No data`;
36+ return `${date} · ${formatPercent((p.successRate ?? 0) * 100, 2)} success · ${p.total.toLocaleString("en-US")} requests`;
37+}
38+
39+function UptimeBar({ history, name }: { history: DayPoint[]; name: string }) {
40+ const measured = history.filter((p) => p.state !== "no-data" && p.successRate !== null);
41+ const avg = measured.length ? measured.reduce((a, p) => a + (p.successRate ?? 0), 0) / measured.length : null;
42+ return (
43+ <div>
44+ <ul className="flex h-7 items-stretch gap-[3px]" aria-label={`${name}: 30-day history`}>
45+ {history.map((p) => {
46+ const label = dayLabel(p);
47+ return (
48+ <li key={p.day} className={cn("min-w-0 flex-1 rounded-[2px] transition-opacity hover:opacity-80", DAY_TONE[p.state])} title={label}>
49+ <span className="sr-only">{label}</span>
50+ </li>
51+ );
52+ })}
53+ </ul>
54+ <div className="mt-1.5 flex items-center justify-between text-[11.5px] text-fg-subtle">
55+ <span>30 days ago</span>
56+ <span className="font-mono tabular">{avg === null ? "No data" : `${formatPercent(avg * 100, 2)} avg. success`}</span>
57+ <span>Today</span>
58+ </div>
59+ </div>
60+ );
61+}
62+
63+function IncidentItem({ i }: { i: Incident }) {
64+ const sev = i.severity.toLowerCase();
65+ return (
66+ <li className="px-5 py-4">
67+ <div className="flex flex-wrap items-center gap-2">
68+ <Badge variant={i.resolvedAt ? "outline" : sev === "major" || sev === "critical" ? "danger" : "warning"} dot>
69+ {i.resolvedAt ? "Resolved" : sev === "major" || sev === "critical" ? "Major" : "Minor"}
70+ </Badge>
71+ <span className="text-[12px] text-fg-subtle">{i.component}</span>
72+ </div>
73+ <h3 className="mt-1.5 text-[15px] font-semibold tracking-tight">{i.title}</h3>
74+ {i.body ? <p className="mt-1 text-[13.5px] leading-relaxed text-fg-muted">{i.body}</p> : null}
75+ <p className="mt-2 font-mono text-[12px] tabular text-fg-subtle">
76+ Started {formatDate(i.startedAt)}
77+ {i.resolvedAt ? ` · Resolved ${formatDate(i.resolvedAt)}` : ` · Ongoing (${timeAgo(i.startedAt)})`}
78+ </p>
79+ </li>
80+ );
81+}
82+
83+export default async function StatusPage() {
84+ const snap = await getStatusSnapshot();
85+ const overall = STATE_META[snap.overall];
86+ const headline =
87+ snap.overall === "operational" ? "All systems operational" : snap.overall === "degraded" ? "Some systems degraded" : snap.overall === "outage" ? "Major outage in progress" : "Status partially unavailable";
88+
89+ return (
90+ <div className="container-page py-14 sm:py-20">
91+ <header className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
92+ <div>
93+ <p className="text-[11.5px] font-semibold uppercase tracking-[0.12em] text-accent">Status</p>
94+ <h1 className="mt-2 flex items-center gap-3 text-[30px] font-semibold leading-tight tracking-tight sm:text-[36px]">
95+ <span className={cn("size-3 shrink-0 rounded-full", overall.dot, snap.overall === "operational" && "animate-pulse-soft")} aria-hidden />
96+ {headline}
97+ </h1>
98+ <p className="mt-2 text-[13.5px] text-fg-muted">
99+ Computed live from health checks and request outcomes. Generated <time dateTime={snap.generatedAt.toISOString()}>{formatDate(snap.generatedAt)}</time> UTC.
100+ </p>
101+ </div>
102+ <dl className="grid grid-cols-2 gap-6 sm:text-right">
103+ <div>
104+ <dt className="text-[11.5px] uppercase tracking-wide text-fg-subtle">Success rate · 24 h</dt>
105+ <dd className="mt-0.5 font-mono text-[22px] font-semibold tabular tracking-tight">{snap.successRate24h === null ? "—" : formatPercent(snap.successRate24h * 100, 2)}</dd>
106+ </div>
107+ <div>
108+ <dt className="text-[11.5px] uppercase tracking-wide text-fg-subtle">Requests · 24 h</dt>
109+ <dd className="mt-0.5 font-mono text-[22px] font-semibold tabular tracking-tight">{snap.requests24h.toLocaleString("en-US")}</dd>
110+ </div>
111+ </dl>
112+ </header>
113+
114+ {!snap.dbReachable ? (
115+ <div role="status" className="mt-8 rounded-lg border border-warning/30 bg-warning-soft px-4 py-3 text-[13.5px] text-fg">
116+ The status database could not be reached while rendering this page, so some components show as degraded or unknown. This reflects the status page itself, not necessarily the API.
117+ </div>
118+ ) : null}
119+
120+ {snap.openIncidents.length > 0 ? (
121+ <section aria-labelledby="open-incidents" className="mt-10">
122+ <h2 id="open-incidents" className="text-[13px] font-semibold uppercase tracking-wide text-fg-subtle">
123+ Active incidents
124+ </h2>
125+ <ul className="mt-3 divide-y divide-border rounded-lg border border-danger/30 bg-bg-elevated">
126+ {snap.openIncidents.map((i) => (
127+ <IncidentItem key={i.id} i={i} />
128+ ))}
129+ </ul>
130+ </section>
131+ ) : null}
132+
133+ <section aria-labelledby="components-title" className="mt-10">
134+ <h2 id="components-title" className="text-[13px] font-semibold uppercase tracking-wide text-fg-subtle">
135+ Components
136+ </h2>
137+ <ul className="mt-3 divide-y divide-border rounded-lg border border-border bg-bg-elevated">
138+ {snap.components.map((c) => {
139+ const m = STATE_META[c.state];
140+ return (
141+ <li key={c.key} className={cn("px-5 py-5", c.state === "not-launched" && "bg-bg-subtle/50")}>
142+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
143+ <div className="min-w-0">
144+ <h3 className="text-[15.5px] font-semibold tracking-tight">{c.name}</h3>
145+ <p className="mt-0.5 text-[13px] text-fg-muted">{c.description}</p>
146+ {c.detail ? <p className="mt-1 text-[12.5px] text-fg-subtle">{c.detail}</p> : null}
147+ </div>
148+ <Badge variant={m.badge} dot className="shrink-0 self-start">
149+ {m.label}
150+ </Badge>
151+ </div>
152+ <div className="mt-4">
153+ <UptimeBar history={c.history} name={c.name} />
154+ </div>
155+ </li>
156+ );
157+ })}
158+ </ul>
159+ <ul className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[12px] text-fg-subtle" aria-label="Legend">
160+ {[
161+ ["bg-success", "≥ 99% success"],
162+ ["bg-warning", "95–99% or minor incident"],
163+ ["bg-danger", "< 95% or major incident"],
164+ ["bg-bg-muted border border-border", "No data"],
165+ ].map(([tone, label]) => (
166+ <li key={label} className="flex items-center gap-1.5">
167+ <span className={cn("inline-block size-2.5 rounded-[2px]", tone)} aria-hidden />
168+ {label}
169+ </li>
170+ ))}
171+ </ul>
172+ </section>
173+
174+ <section aria-labelledby="history-title" className="mt-14">
175+ <h2 id="history-title" className="text-[13px] font-semibold uppercase tracking-wide text-fg-subtle">
176+ Incident history
177+ </h2>
178+ {snap.resolvedIncidents.length === 0 ? (
179+ <p className="mt-3 rounded-lg border border-dashed border-border px-5 py-8 text-center text-[13.5px] text-fg-muted">No resolved incidents on record.</p>
180+ ) : (
181+ <ul className="mt-3 divide-y divide-border rounded-lg border border-border">
182+ {snap.resolvedIncidents.map((i) => (
183+ <IncidentItem key={i.id} i={i} />
184+ ))}
185+ </ul>
186+ )}
187+ </section>
188+
189+ <section className="mt-14 grid gap-6 rounded-lg border border-border bg-bg-subtle/60 p-6 text-[13.5px] leading-relaxed text-fg-muted sm:grid-cols-3">
190+ <div>
191+ <h2 className="font-semibold text-fg">How this is computed</h2>
192+ <p className="mt-1">Success rate counts completed requests whose outcome depended on the platform; requests rejected for invalid input, disallowed URLs or exhausted quotas are excluded. Network status comes from continuous upstream health checks.</p>
193+ </div>
194+ <div>
195+ <h2 className="font-semibold text-fg">Machine-readable</h2>
196+ <p className="mt-1">
197+ <code className="font-mono text-fg">GET /api/health</code> and <code className="font-mono text-fg">GET /api/ready</code> return JSON you can poll from your own monitoring.
198+ </p>
199+ </div>
200+ <div>
201+ <h2 className="font-semibold text-fg">Report an issue</h2>
202+ <p className="mt-1">
203+ Include the <code className="font-mono text-fg">X-Fetcha-Request-ID</code> from the failing response and email <a className="text-accent underline-offset-4 hover:underline" href="mailto:support@fetcha.co">support@fetcha.co</a>. See the <Link href="/changelog" className="text-accent underline-offset-4 hover:underline">changelog</Link> for releases.
204+ </p>
205+ </div>
206+ </section>
207+ </div>
208+ );
209+}
added apps/web/src/app/admin/abuse/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={0} rows={8} />;
5+}
added apps/web/src/app/admin/abuse/page.tsx +211 −0
@@ -0,0 +1,211 @@
1+import Link from "next/link";
2+import { ShieldAlert, ShieldCheck } from "lucide-react";
3+import { requireAdmin } from "@/lib/session";
4+import { getSuspiciousActivity, listAbuseEvents, str } from "@/lib/queries/admin";
5+import { formatNumber, formatPercent } from "@/lib/format";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { Badge } from "@/components/ui/badge";
8+import { Button } from "@/components/ui/button";
9+import { EmptyState } from "@/components/ui/empty-state";
10+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
11+import { DateCell, Mono, Panel, PlanBadge, numCell } from "@/components/admin/primitives";
12+import { RecordAbuseDialog, ResolveAbuseButton } from "@/components/admin/abuse-actions";
13+import { cn } from "@/lib/utils";
14+
15+export const dynamic = "force-dynamic";
16+
17+function SeverityBadge({ s }: { s: string }) {
18+ const v = s === "critical" ? "danger" : s === "high" ? "danger" : s === "medium" ? "warning" : "default";
19+ return <Badge variant={v}>{s}</Badge>;
20+}
21+
22+export default async function AdminAbusePage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
23+ await requireAdmin();
24+ const sp = await searchParams;
25+ const view = (["all", "open", "resolved"] as const).find((v) => v === str(sp.view)) ?? "open";
26+ const [events, sus] = await Promise.all([listAbuseEvents({ resolved: view }), getSuspiciousActivity()]);
27+ const signals = sus.ssrf.length + sus.limited.length + sus.unverified.length;
28+
29+ return (
30+ <>
31+ <PageHeader eyebrow="Trust & safety" title="Abuse" description="Recorded abuse events plus live heuristics computed from the last 24 hours of traffic." actions={<RecordAbuseDialog />} />
32+
33+ <Panel
34+ title={
35+ <span className="flex items-center gap-2">
36+ Suspicious activity (24h)
37+ {signals ? <Badge variant="warning" dot>{signals} signals</Badge> : <Badge variant="success" dot>clear</Badge>}
38+ </span>
39+ }
40+ description="Heuristics, not verdicts. Investigate from the organization page before acting."
41+ bodyClassName="p-0"
42+ >
43+ <div className="grid divide-y divide-border xl:grid-cols-3 xl:divide-x xl:divide-y-0">
44+ <div>
45+ <h3 className="px-4 pt-3 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">SSRF / disallowed URLs</h3>
46+ <p className="px-4 pb-2 text-[12px] text-fg-muted">Orgs with &gt; 30 % `URL_NOT_ALLOWED` (min. 5 requests).</p>
47+ <Table>
48+ <TableHeader>
49+ <TableRow>
50+ <TableHead>Organization</TableHead>
51+ <TableHead className="text-right">Blocked</TableHead>
52+ <TableHead className="text-right">Ratio</TableHead>
53+ </TableRow>
54+ </TableHeader>
55+ <TableBody>
56+ {sus.ssrf.length === 0 ? (
57+ <TableEmpty colSpan={3}>None.</TableEmpty>
58+ ) : (
59+ sus.ssrf.map((r) => (
60+ <TableRow key={r.organizationId}>
61+ <TableCell>
62+ <Link href={`/admin/organizations/${r.organizationId}`} className="hover:underline">
63+ {r.orgName}
64+ </Link>{" "}
65+ <PlanBadge plan={r.plan} />
66+ </TableCell>
67+ <TableCell className={numCell}>
68+ {r.notAllowed} / {r.total}
69+ </TableCell>
70+ <TableCell className={cn(numCell, "text-danger")}>{formatPercent(r.ratio)}</TableCell>
71+ </TableRow>
72+ ))
73+ )}
74+ </TableBody>
75+ </Table>
76+ </div>
77+ <div>
78+ <h3 className="px-4 pt-3 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Rate / concurrency limits</h3>
79+ <p className="px-4 pb-2 text-[12px] text-fg-muted">Orgs hitting `RATE_LIMITED` or `CONCURRENCY_LIMIT` most.</p>
80+ <Table>
81+ <TableHeader>
82+ <TableRow>
83+ <TableHead>Organization</TableHead>
84+ <TableHead className="text-right">Rate</TableHead>
85+ <TableHead className="text-right">Concurrency</TableHead>
86+ </TableRow>
87+ </TableHeader>
88+ <TableBody>
89+ {sus.limited.length === 0 ? (
90+ <TableEmpty colSpan={3}>None.</TableEmpty>
91+ ) : (
92+ sus.limited.map((r) => (
93+ <TableRow key={r.organizationId}>
94+ <TableCell>
95+ <Link href={`/admin/organizations/${r.organizationId}`} className="hover:underline">
96+ {r.orgName}
97+ </Link>{" "}
98+ <PlanBadge plan={r.plan} />
99+ </TableCell>
100+ <TableCell className={numCell}>{formatNumber(r.rateLimited)}</TableCell>
101+ <TableCell className={numCell}>{formatNumber(r.concurrency)}</TableCell>
102+ </TableRow>
103+ ))
104+ )}
105+ </TableBody>
106+ </Table>
107+ </div>
108+ <div>
109+ <h3 className="px-4 pt-3 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Unverified heavy playground use</h3>
110+ <p className="px-4 pb-2 text-[12px] text-fg-muted">Unverified owners with ≥ 20 playground requests.</p>
111+ <Table>
112+ <TableHeader>
113+ <TableRow>
114+ <TableHead>User</TableHead>
115+ <TableHead className="text-right">Requests</TableHead>
116+ <TableHead className="text-right">Signed up</TableHead>
117+ </TableRow>
118+ </TableHeader>
119+ <TableBody>
120+ {sus.unverified.length === 0 ? (
121+ <TableEmpty colSpan={3}>None.</TableEmpty>
122+ ) : (
123+ sus.unverified.map((r) => (
124+ <TableRow key={r.userId}>
125+ <TableCell>
126+ <Link href={`/admin/users/${r.userId}`} className="hover:underline">
127+ {r.email}
128+ </Link>
129+ </TableCell>
130+ <TableCell className={numCell}>{formatNumber(r.playground)}</TableCell>
131+ <TableCell className="text-right">
132+ <DateCell value={r.createdAt} />
133+ </TableCell>
134+ </TableRow>
135+ ))
136+ )}
137+ </TableBody>
138+ </Table>
139+ </div>
140+ </div>
141+ </Panel>
142+
143+ <Panel
144+ title="Abuse events"
145+ right={
146+ <div className="inline-flex h-7 items-center gap-0.5 rounded-md bg-bg-muted p-0.5 text-[12px]">
147+ {(["open", "resolved", "all"] as const).map((v) => (
148+ <Button key={v} asChild variant={view === v ? "outline" : "ghost"} size="xs" className={cn("h-6 capitalize", view === v && "bg-bg")}>
149+ <Link href={`/admin/abuse?view=${v}`}>{v}</Link>
150+ </Button>
151+ ))}
152+ </div>
153+ }
154+ flush
155+ className="mt-6"
156+ >
157+ {events.length === 0 ? (
158+ <div className="p-4">
159+ <EmptyState icon={view === "open" ? ShieldCheck : ShieldAlert} title={view === "open" ? "No open abuse events" : "No abuse events"} description="Automatic detections (SSRF attempts, prohibited targets, rate abuse) and manual records appear here." compact />
160+ </div>
161+ ) : (
162+ <Table>
163+ <TableHeader>
164+ <TableRow>
165+ <TableHead>When</TableHead>
166+ <TableHead>Kind</TableHead>
167+ <TableHead>Severity</TableHead>
168+ <TableHead>Organization</TableHead>
169+ <TableHead>Project / request</TableHead>
170+ <TableHead>Detail</TableHead>
171+ <TableHead>Status</TableHead>
172+ <TableHead></TableHead>
173+ </TableRow>
174+ </TableHeader>
175+ <TableBody>
176+ {events.map((e) => (
177+ <TableRow key={e.id} className={e.resolved ? "opacity-70" : undefined}>
178+ <TableCell>
179+ <DateCell value={e.createdAt} />
180+ </TableCell>
181+ <TableCell>
182+ <Mono>{e.kind}</Mono>
183+ </TableCell>
184+ <TableCell>
185+ <SeverityBadge s={e.severity} />
186+ </TableCell>
187+ <TableCell>{e.organizationId ? <Link href={`/admin/organizations/${e.organizationId}`} className="hover:underline">{e.orgName ?? e.organizationId}</Link> : <span className="text-fg-subtle">—</span>}</TableCell>
188+ <TableCell>
189+ <div className="font-mono text-[11.5px] text-fg-muted">{e.projectId ?? "—"}</div>
190+ {e.requestId ? (
191+ <Link href={`/admin/requests/${e.requestId}`} className="font-mono text-[11.5px] hover:underline">
192+ {e.requestId}
193+ </Link>
194+ ) : null}
195+ </TableCell>
196+ <TableCell className="max-w-[360px] truncate text-[12.5px]" title={e.detail ?? undefined}>
197+ {e.detail ?? "—"}
198+ </TableCell>
199+ <TableCell>{e.resolved ? <Badge variant="success" dot>resolved</Badge> : <Badge variant="warning" dot>open</Badge>}</TableCell>
200+ <TableCell className="text-right">
201+ <ResolveAbuseButton id={e.id} resolved={e.resolved} />
202+ </TableCell>
203+ </TableRow>
204+ ))}
205+ </TableBody>
206+ </Table>
207+ )}
208+ </Panel>
209+ </>
210+ );
211+}
added apps/web/src/app/admin/billing/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={4} rows={5} />;
5+}
added apps/web/src/app/admin/billing/page.tsx +161 −0
@@ -0,0 +1,161 @@
1+import Link from "next/link";
2+import { CreditCard, Receipt } from "lucide-react";
3+import { requireAdmin } from "@/lib/session";
4+import { getBillingOverview } from "@/lib/queries/admin";
5+import { formatNumber, formatUsd } from "@/lib/format";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { Alert } from "@/components/ui/alert";
8+import { Badge, StatusBadge } from "@/components/ui/badge";
9+import { EmptyState } from "@/components/ui/empty-state";
10+import { Stat, StatGrid } from "@/components/ui/stat";
11+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
12+import { DateCell, MarginText, Mono, Panel, PlanBadge, numCell } from "@/components/admin/primitives";
13+
14+export const dynamic = "force-dynamic";
15+
16+export default async function AdminBillingPage() {
17+ await requireAdmin();
18+ const b = await getBillingOverview();
19+ const paying = b.distribution.filter((d) => d.price > 0).reduce((s, d) => s + d.orgs, 0);
20+
21+ return (
22+ <>
23+ <PageHeader eyebrow="Revenue" title="Billing" description="Plan distribution and MRR estimate from plan list prices. Stripe checkout and invoicing are not connected yet." />
24+
25+ {!b.stripeConfigured ? (
26+ <Alert variant="info" title="Stripe is not connected" className="mb-4">
27+ `STRIPE_SECRET_KEY` is not set. Plans are changed manually from the organization page; no charges are made. Subscriptions and billing events below will populate once the Stripe integration ships.
28+ </Alert>
29+ ) : null}
30+
31+ <StatGrid cols={4} className="mb-6">
32+ <Stat label="MRR estimate" value={formatUsd(b.mrr)} hint="orgs × plan list price" />
33+ <Stat label="Paying organizations" value={formatNumber(paying)} hint={`${b.stripeCustomers} with a Stripe customer id`} />
34+ <Stat label="Usage revenue 30d" value={formatUsd(b.usageRevenue30d)} hint="metered price_usd" />
35+ <Stat label="Usage margin 30d" value={<MarginText value={b.usageRevenue30d - b.usageCost30d} pct={b.usageRevenue30d > 0 ? ((b.usageRevenue30d - b.usageCost30d) / b.usageRevenue30d) * 100 : null} />} hint="vs upstream cost" />
36+ </StatGrid>
37+
38+ <Panel title="Plan distribution" flush>
39+ <Table>
40+ <TableHeader>
41+ <TableRow>
42+ <TableHead>Plan</TableHead>
43+ <TableHead className="text-right">Organizations</TableHead>
44+ <TableHead className="text-right">List price / mo</TableHead>
45+ <TableHead className="text-right">MRR</TableHead>
46+ <TableHead></TableHead>
47+ </TableRow>
48+ </TableHeader>
49+ <TableBody>
50+ {b.distribution.map((d) => (
51+ <TableRow key={d.plan}>
52+ <TableCell>
53+ <PlanBadge plan={d.plan} />
54+ </TableCell>
55+ <TableCell className={numCell}>{formatNumber(d.orgs)}</TableCell>
56+ <TableCell className={numCell}>{d.plan === "enterprise" ? "custom" : formatUsd(d.price)}</TableCell>
57+ <TableCell className={numCell}>{formatUsd(d.mrr)}</TableCell>
58+ <TableCell className="text-right">
59+ <Link href={`/admin/organizations?plan=${d.plan}`} className="text-[12.5px] text-accent hover:underline">
60+ view →
61+ </Link>
62+ </TableCell>
63+ </TableRow>
64+ ))}
65+ {b.unknown.map((u) => (
66+ <TableRow key={u.plan}>
67+ <TableCell>
68+ <Badge variant="danger">{u.plan}</Badge> <span className="text-[12px] text-fg-muted">unknown plan value</span>
69+ </TableCell>
70+ <TableCell className={numCell}>{u.n}</TableCell>
71+ <TableCell className={numCell}>—</TableCell>
72+ <TableCell className={numCell}>—</TableCell>
73+ <TableCell />
74+ </TableRow>
75+ ))}
76+ </TableBody>
77+ </Table>
78+ </Panel>
79+
80+ <div className="mt-6 grid gap-6 xl:grid-cols-2">
81+ <Panel title="Subscriptions" flush>
82+ {b.subs.length === 0 ? (
83+ <div className="p-4">
84+ <EmptyState icon={CreditCard} title="No subscriptions" description="Stripe is not connected yet. Rows appear here when checkout goes live and webhooks create subscriptions." compact />
85+ </div>
86+ ) : (
87+ <Table>
88+ <TableHeader>
89+ <TableRow>
90+ <TableHead>Organization</TableHead>
91+ <TableHead>Plan</TableHead>
92+ <TableHead>Status</TableHead>
93+ <TableHead>Period</TableHead>
94+ <TableHead>Stripe</TableHead>
95+ </TableRow>
96+ </TableHeader>
97+ <TableBody>
98+ {b.subs.map((s) => (
99+ <TableRow key={s.id}>
100+ <TableCell>
101+ <Link href={`/admin/organizations/${s.organizationId}`} className="hover:underline">
102+ {s.orgName ?? s.organizationId}
103+ </Link>
104+ </TableCell>
105+ <TableCell>
106+ <PlanBadge plan={s.plan} />
107+ </TableCell>
108+ <TableCell>
109+ <StatusBadge status={s.status} />
110+ {s.cancelAtPeriodEnd ? <Badge variant="warning" className="ml-1">cancels</Badge> : null}
111+ </TableCell>
112+ <TableCell className="text-[12.5px] text-fg-muted">
113+ <DateCell value={s.currentPeriodStart} relative={false} /> → <DateCell value={s.currentPeriodEnd} relative={false} />
114+ </TableCell>
115+ <TableCell>
116+ <Mono>{s.stripeSubscriptionId ?? "—"}</Mono>
117+ </TableCell>
118+ </TableRow>
119+ ))}
120+ </TableBody>
121+ </Table>
122+ )}
123+ </Panel>
124+ <Panel title="Billing events" flush>
125+ {b.events.length === 0 ? (
126+ <div className="p-4">
127+ <EmptyState icon={Receipt} title="No billing events" description="Stripe webhook events (invoices, payment failures, plan changes) will be listed here once the integration is live." compact />
128+ </div>
129+ ) : (
130+ <Table>
131+ <TableHeader>
132+ <TableRow>
133+ <TableHead>When</TableHead>
134+ <TableHead>Type</TableHead>
135+ <TableHead>Organization</TableHead>
136+ <TableHead>Stripe event</TableHead>
137+ </TableRow>
138+ </TableHeader>
139+ <TableBody>
140+ {b.events.map((e) => (
141+ <TableRow key={e.id}>
142+ <TableCell>
143+ <DateCell value={e.createdAt} relative={false} />
144+ </TableCell>
145+ <TableCell>
146+ <Mono>{e.type}</Mono>
147+ </TableCell>
148+ <TableCell>{e.orgName ?? e.organizationId ?? "—"}</TableCell>
149+ <TableCell>
150+ <Mono>{e.stripeEventId ?? "—"}</Mono>
151+ </TableCell>
152+ </TableRow>
153+ ))}
154+ </TableBody>
155+ </Table>
156+ )}
157+ </Panel>
158+ </div>
159+ </>
160+ );
161+}
added apps/web/src/app/admin/domains/[domain]/page.tsx +165 −0
@@ -0,0 +1,165 @@
1+import Link from "next/link";
2+import { notFound } from "next/navigation";
3+import { ArrowLeft } from "lucide-react";
4+import { requireAdmin } from "@/lib/session";
5+import { CONCRETE_NETWORKS, PROVIDER_IDS, getDomainProfile } from "@/lib/queries/admin";
6+import { formatDate, formatMs, formatNumber, formatPercent, formatUsd } from "@/lib/format";
7+import { PageHeader } from "@/components/ui/page-header";
8+import { Badge, StatusBadge } from "@/components/ui/badge";
9+import { Button } from "@/components/ui/button";
10+import { Stat, StatGrid } from "@/components/ui/stat";
11+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
12+import { DateCell, KV, Mono, NetworkBadge, Panel, ProviderBadge, numCell } from "@/components/admin/primitives";
13+import { DomainPolicyEditor, ResetDomainStatsButton } from "@/components/admin/domain-policy-editor";
14+
15+export const dynamic = "force-dynamic";
16+
17+export default async function AdminDomainDetail({ params }: { params: Promise<{ domain: string }> }) {
18+ await requireAdmin();
19+ const { domain: raw } = await params;
20+ const domain = decodeURIComponent(raw).toLowerCase();
21+ const d = await getDomainProfile(domain);
22+ if (!d) notFound();
23+ const p = d.profile;
24+ const routes = Object.entries(p.routeStats ?? {}).sort((a, b) => b[1].n - a[1].n);
25+ const availableKeys = Array.from(new Set([...routes.map(([k]) => k), ...PROVIDER_IDS.flatMap((pr) => CONCRETE_NETWORKS.map((n) => `${pr}:${n}`))]));
26+
27+ return (
28+ <>
29+ <div className="mb-4">
30+ <Button asChild variant="ghost" size="sm" className="-ml-2">
31+ <Link href="/admin/domains">
32+ <ArrowLeft className="size-3.5" /> Domains
33+ </Link>
34+ </Button>
35+ </div>
36+ <PageHeader
37+ eyebrow="Domain profile"
38+ title={<Mono className="text-[20px]">{p.domain}</Mono>}
39+ description={`Learned since ${formatDate(p.updatedAt)} · last seen ${formatDate(p.lastSeenAt)}`}
40+ actions={
41+ <>
42+ <Button asChild variant="outline" size="sm">
43+ <Link href={`/admin/requests?domain=${encodeURIComponent(p.domain)}`}>Explore requests</Link>
44+ </Button>
45+ <ResetDomainStatsButton domain={p.domain} />
46+ </>
47+ }
48+ />
49+
50+ <StatGrid cols={6} className="mb-6">
51+ <Stat label="Requests" value={formatNumber(p.requests)} />
52+ <Stat label="Success" value={formatPercent(p.requests ? (p.successes / p.requests) * 100 : null)} />
53+ <Stat label="Blocked" value={formatPercent(p.requests ? (p.blocks / p.requests) * 100 : null)} hint={`${p.blocks} blocks`} />
54+ <Stat label="Captcha" value={formatPercent(p.requests ? (p.captchas / p.requests) * 100 : null)} hint={`${p.captchas} seen`} />
55+ <Stat label="Browser required" value={formatNumber(p.browserRequired)} hint="signals seen" />
56+ <Stat label="Avg latency" value={formatMs(p.avgLatencyMs)} />
57+ </StatGrid>
58+
59+ <div className="grid gap-6 lg:grid-cols-2">
60+ <Panel title="Route statistics" description="Per provider:network key, as used by the routing score (35 % success, 15 % latency)." flush>
61+ <Table>
62+ <TableHeader>
63+ <TableRow>
64+ <TableHead>Route</TableHead>
65+ <TableHead className="text-right">n</TableHead>
66+ <TableHead className="text-right">ok</TableHead>
67+ <TableHead className="text-right">Success</TableHead>
68+ <TableHead className="text-right">Blocked</TableHead>
69+ <TableHead className="text-right">Avg lat.</TableHead>
70+ <TableHead className="text-right">Cost</TableHead>
71+ <TableHead className="text-right">Cost / success</TableHead>
72+ </TableRow>
73+ </TableHeader>
74+ <TableBody>
75+ {routes.length === 0 ? (
76+ <TableEmpty colSpan={8}>No route statistics yet.</TableEmpty>
77+ ) : (
78+ routes.map(([key, s]) => {
79+ const [prov, net] = key.split(":");
80+ return (
81+ <TableRow key={key}>
82+ <TableCell>
83+ <span className="inline-flex items-center gap-1.5">
84+ <ProviderBadge id={prov} />
85+ <NetworkBadge network={net} />
86+ </span>
87+ </TableCell>
88+ <TableCell className={numCell}>{s.n}</TableCell>
89+ <TableCell className={numCell}>{s.ok}</TableCell>
90+ <TableCell className={numCell}>{formatPercent(s.n ? (s.ok / s.n) * 100 : null)}</TableCell>
91+ <TableCell className={numCell}>{formatPercent(s.n ? (s.blocked / s.n) * 100 : null)}</TableCell>
92+ <TableCell className={numCell}>{formatMs(s.lat)}</TableCell>
93+ <TableCell className={numCell}>{formatUsd(s.cost, true)}</TableCell>
94+ <TableCell className={`${numCell} font-semibold`}>{formatUsd(s.ok ? s.cost / s.ok : null, true)}</TableCell>
95+ </TableRow>
96+ );
97+ })
98+ )}
99+ </TableBody>
100+ </Table>
101+ </Panel>
102+ <div className="grid gap-6">
103+ <Panel title="Policy" description="Admin overrides applied before scoring. Saved immediately; takes effect on the next request.">
104+ <DomainPolicyEditor domain={p.domain} policy={p.policy ?? null} availableKeys={availableKeys} />
105+ </Panel>
106+ <Panel title="Learned preference">
107+ <KV
108+ cols={2}
109+ items={[
110+ { label: "Preferred provider", value: <ProviderBadge id={p.preferredProvider} /> },
111+ { label: "Preferred network", value: <NetworkBadge network={p.preferredNetwork} /> },
112+ { label: "Policy JSON", value: p.policy ? JSON.stringify(p.policy) : "none", mono: true, span: true },
113+ ]}
114+ />
115+ </Panel>
116+ </div>
117+ </div>
118+
119+ <Panel title="Recent requests (7d)" flush className="mt-6">
120+ <Table>
121+ <TableHeader>
122+ <TableRow>
123+ <TableHead>Request</TableHead>
124+ <TableHead>Organization</TableHead>
125+ <TableHead>Status</TableHead>
126+ <TableHead>Network</TableHead>
127+ <TableHead className="text-right">Attempts</TableHead>
128+ <TableHead className="text-right">Latency</TableHead>
129+ <TableHead className="text-right">When</TableHead>
130+ </TableRow>
131+ </TableHeader>
132+ <TableBody>
133+ {d.recent.length === 0 ? (
134+ <TableEmpty colSpan={7}>No requests in the last 7 days.</TableEmpty>
135+ ) : (
136+ d.recent.map((r) => (
137+ <TableRow key={r.id}>
138+ <TableCell>
139+ <Link href={`/admin/requests/${r.id}`} className="hover:underline">
140+ <Mono>{r.id}</Mono>
141+ </Link>
142+ </TableCell>
143+ <TableCell>{r.orgName ?? "—"}</TableCell>
144+ <TableCell>
145+ <StatusBadge status={r.status} />
146+ {r.errorCode ? <Badge variant="outline" className="ml-1 font-mono">{r.errorCode}</Badge> : null}
147+ {r.httpStatus ? <Mono className="ml-1 text-fg-muted">{r.httpStatus}</Mono> : null}
148+ </TableCell>
149+ <TableCell>
150+ <NetworkBadge network={r.network} />
151+ </TableCell>
152+ <TableCell className={numCell}>{r.attempts}</TableCell>
153+ <TableCell className={numCell}>{formatMs(r.latencyMs)}</TableCell>
154+ <TableCell className="text-right">
155+ <DateCell value={r.createdAt} />
156+ </TableCell>
157+ </TableRow>
158+ ))
159+ )}
160+ </TableBody>
161+ </Table>
162+ </Panel>
163+ </>
164+ );
165+}
added apps/web/src/app/admin/domains/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={0} rows={12} />;
5+}
added apps/web/src/app/admin/domains/page.tsx +109 −0
@@ -0,0 +1,109 @@
1+import Link from "next/link";
2+import { ArrowDown, ArrowUp, Globe } from "lucide-react";
3+import { requireAdmin } from "@/lib/session";
4+import { listDomains, pageNum, str, DOMAIN_SORTS } from "@/lib/queries/admin";
5+import { formatMs, formatNumber, formatPercent } from "@/lib/format";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { Badge } from "@/components/ui/badge";
8+import { EmptyState } from "@/components/ui/empty-state";
9+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
10+import { DateCell, NetworkBadge, Pagination, Panel, ProviderBadge, SearchForm, numCell } from "@/components/admin/primitives";
11+
12+export const dynamic = "force-dynamic";
13+
14+const COLS: Array<{ key: (typeof DOMAIN_SORTS)[number]; label: string; right?: boolean }> = [
15+ { key: "domain", label: "Domain" },
16+ { key: "requests", label: "Requests", right: true },
17+ { key: "successRate", label: "Success", right: true },
18+ { key: "blockRate", label: "Blocked", right: true },
19+ { key: "captchaRate", label: "Captcha", right: true },
20+ { key: "avgLatencyMs", label: "Avg latency", right: true },
21+];
22+
23+export default async function AdminDomainsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
24+ await requireAdmin();
25+ const sp = await searchParams;
26+ const q = str(sp.q);
27+ const sort = str(sp.sort);
28+ const dir = str(sp.dir);
29+ const page = pageNum(sp.page);
30+ const data = await listDomains({ q: q || undefined, sort: sort || undefined, dir: dir || undefined, page });
31+
32+ const sortHref = (key: string) => {
33+ const nextDir = data.sort === key && data.dir === "desc" ? "asc" : "desc";
34+ const p = new URLSearchParams();
35+ if (q) p.set("q", q);
36+ p.set("sort", key);
37+ p.set("dir", nextDir);
38+ return `/admin/domains?${p.toString()}`;
39+ };
40+
41+ return (
42+ <>
43+ <PageHeader eyebrow="Intelligence" title="Domains" description={`${formatNumber(data.total)} learned domain profiles. Click a column to sort.`} actions={<SearchForm action="/admin/domains" q={q} placeholder="domain contains…" hidden={{ sort: data.sort, dir: data.dir }} />} />
44+ <Panel flush>
45+ {data.total === 0 ? (
46+ <div className="p-4">
47+ <EmptyState icon={Globe} title={q ? "No domains match" : "No domain profiles yet"} description="Profiles are created automatically after the first request to a domain." compact />
48+ </div>
49+ ) : (
50+ <>
51+ <Table>
52+ <TableHeader>
53+ <TableRow>
54+ {COLS.map((c) => (
55+ <TableHead key={c.key} className={c.right ? "text-right" : undefined} aria-sort={data.sort === c.key ? (data.dir === "asc" ? "ascending" : "descending") : "none"}>
56+ <Link href={sortHref(c.key)} className="inline-flex items-center gap-1 hover:text-fg">
57+ {c.label}
58+ {data.sort === c.key ? data.dir === "asc" ? <ArrowUp className="size-3" /> : <ArrowDown className="size-3" /> : null}
59+ </Link>
60+ </TableHead>
61+ ))}
62+ <TableHead>Preferred route</TableHead>
63+ <TableHead>Policy</TableHead>
64+ <TableHead className="text-right" aria-sort={data.sort === "lastSeenAt" ? (data.dir === "asc" ? "ascending" : "descending") : "none"}>
65+ <Link href={sortHref("lastSeenAt")} className="inline-flex items-center gap-1 hover:text-fg">
66+ Last seen
67+ {data.sort === "lastSeenAt" ? data.dir === "asc" ? <ArrowUp className="size-3" /> : <ArrowDown className="size-3" /> : null}
68+ </Link>
69+ </TableHead>
70+ </TableRow>
71+ </TableHeader>
72+ <TableBody>
73+ {data.rows.map((d) => (
74+ <TableRow key={d.domain}>
75+ <TableCell>
76+ <Link href={`/admin/domains/${encodeURIComponent(d.domain)}`} className="font-medium hover:underline">
77+ {d.domain}
78+ </Link>
79+ </TableCell>
80+ <TableCell className={numCell}>{formatNumber(d.requests)}</TableCell>
81+ <TableCell className={numCell}>{formatPercent(d.successRate)}</TableCell>
82+ <TableCell className={numCell}>{formatPercent(d.blockRate)}</TableCell>
83+ <TableCell className={numCell}>{formatPercent(d.captchaRate)}</TableCell>
84+ <TableCell className={numCell}>{formatMs(d.avgLatencyMs)}</TableCell>
85+ <TableCell>
86+ <span className="inline-flex items-center gap-1.5">
87+ <ProviderBadge id={d.preferredProvider} />
88+ <NetworkBadge network={d.preferredNetwork} />
89+ </span>
90+ </TableCell>
91+ <TableCell>
92+ {d.policy?.force_network ? <Badge variant="warning">force {d.policy.force_network}</Badge> : null}
93+ {d.policy?.order?.length ? <Badge variant="info" className="ml-1">pinned {d.policy.order.length}</Badge> : null}
94+ {!d.policy?.force_network && !d.policy?.order?.length ? <span className="text-fg-subtle">—</span> : null}
95+ </TableCell>
96+ <TableCell className="text-right">
97+ <DateCell value={d.lastSeenAt} />
98+ </TableCell>
99+ </TableRow>
100+ ))}
101+ </TableBody>
102+ </Table>
103+ <Pagination page={data.page} pages={data.pages} total={data.total} pageSize={data.pageSize} basePath="/admin/domains" params={{ q, sort: data.sort, dir: data.dir }} />
104+ </>
105+ )}
106+ </Panel>
107+ </>
108+ );
109+}
added apps/web/src/app/admin/flags/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={0} rows={6} />;
5+}
added apps/web/src/app/admin/flags/page.tsx +76 −0
@@ -0,0 +1,76 @@
1+import { Flag } from "lucide-react";
2+import { requireAdmin } from "@/lib/session";
3+import { getFeatureFlags } from "@/lib/queries/admin";
4+import { PageHeader } from "@/components/ui/page-header";
5+import { Badge } from "@/components/ui/badge";
6+import { EmptyState } from "@/components/ui/empty-state";
7+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
8+import { DateCell, Mono, Panel } from "@/components/admin/primitives";
9+import { DeleteFlagButton, FlagSwitch, NewFlagDialog } from "@/components/admin/flag-controls";
10+
11+export const dynamic = "force-dynamic";
12+
13+export default async function AdminFlagsPage() {
14+ await requireAdmin();
15+ const flags = await getFeatureFlags();
16+
17+ return (
18+ <>
19+ <PageHeader eyebrow="Configuration" title="Feature flags" description="Global kill-switches and staged rollouts. A flag applies to everyone unless plans or organization ids are set." actions={<NewFlagDialog />} />
20+ <Panel flush>
21+ {flags.length === 0 ? (
22+ <div className="p-4">
23+ <EmptyState icon={Flag} title="No feature flags" description="Create a flag, then read it from code with the feature_flags table. Flags start disabled." action={<NewFlagDialog />} compact />
24+ </div>
25+ ) : (
26+ <Table>
27+ <TableHeader>
28+ <TableRow>
29+ <TableHead>Key</TableHead>
30+ <TableHead>Description</TableHead>
31+ <TableHead>Plans</TableHead>
32+ <TableHead>Organizations</TableHead>
33+ <TableHead>Updated</TableHead>
34+ <TableHead>Enabled</TableHead>
35+ <TableHead></TableHead>
36+ </TableRow>
37+ </TableHeader>
38+ <TableBody>
39+ {flags.map((f) => (
40+ <TableRow key={f.key}>
41+ <TableCell>
42+ <Mono className="font-medium">{f.key}</Mono>
43+ </TableCell>
44+ <TableCell className="max-w-[320px] text-fg-muted">{f.description ?? "—"}</TableCell>
45+ <TableCell>
46+ {f.plans?.length ? (
47+ <div className="flex flex-wrap gap-1">
48+ {f.plans.map((p) => (
49+ <Badge key={p} variant="outline" className="capitalize">
50+ {p}
51+ </Badge>
52+ ))}
53+ </div>
54+ ) : (
55+ <span className="text-fg-subtle">all</span>
56+ )}
57+ </TableCell>
58+ <TableCell className="text-[12px] text-fg-muted">{f.organizationIds?.length ? `${f.organizationIds.length} org(s)` : <span className="text-fg-subtle">all</span>}</TableCell>
59+ <TableCell>
60+ <DateCell value={f.updatedAt} />
61+ </TableCell>
62+ <TableCell>
63+ <FlagSwitch flagKey={f.key} enabled={f.enabled} />
64+ </TableCell>
65+ <TableCell className="text-right">
66+ <DeleteFlagButton flagKey={f.key} />
67+ </TableCell>
68+ </TableRow>
69+ ))}
70+ </TableBody>
71+ </Table>
72+ )}
73+ </Panel>
74+ </>
75+ );
76+}
added apps/web/src/app/admin/layout.tsx +15 −0
@@ -0,0 +1,15 @@
1+import type { Metadata } from "next";
2+import { requireAdmin } from "@/lib/session";
3+import { AdminShell } from "@/components/admin/admin-shell";
4+
5+export const dynamic = "force-dynamic";
6+
7+export const metadata: Metadata = {
8+ title: { default: "Admin", template: "%s · Fetcha Admin" },
9+ robots: { index: false, follow: false },
10+};
11+
12+export default async function AdminLayout({ children }: { children: React.ReactNode }) {
13+ const user = await requireAdmin();
14+ return <AdminShell user={{ email: user.email, name: user.name }}>{children}</AdminShell>;
15+}
added apps/web/src/app/admin/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading />;
5+}
added apps/web/src/app/admin/organizations/[id]/page.tsx +293 −0
@@ -0,0 +1,293 @@
1+import Link from "next/link";
2+import { notFound } from "next/navigation";
3+import { ArrowLeft } from "lucide-react";
4+import { requireAdmin } from "@/lib/session";
5+import { getOrganizationDetail, PLAN_OPTIONS } from "@/lib/queries/admin";
6+import { formatBytes, formatDate, formatMs, formatNumber, formatPercent, formatUsd } from "@/lib/format";
7+import { PageHeader } from "@/components/ui/page-header";
8+import { Badge, StatusBadge } from "@/components/ui/badge";
9+import { Button } from "@/components/ui/button";
10+import { Stat, StatGrid } from "@/components/ui/stat";
11+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
12+import { CopyButton } from "@/components/ui/copy-button";
13+import { Alert } from "@/components/ui/alert";
14+import { DateCell, KV, MarginText, Mono, NetworkBadge, Panel, PlanBadge, numCell } from "@/components/admin/primitives";
15+import { OrgLimitsForm, OrgPlanSelect, OrgProviderVisibilitySwitch, OrgSuspendSwitch } from "@/components/admin/org-actions";
16+
17+export const dynamic = "force-dynamic";
18+
19+export default async function AdminOrganizationDetail({ params }: { params: Promise<{ id: string }> }) {
20+ await requireAdmin();
21+ const { id } = await params;
22+ const d = await getOrganizationDetail(id);
23+ if (!d) notFound();
24+ const { org, stats30d: s } = d;
25+ const margin = s.revenue - s.cost;
26+
27+ return (
28+ <>
29+ <div className="mb-4">
30+ <Button asChild variant="ghost" size="sm" className="-ml-2">
31+ <Link href="/admin/organizations">
32+ <ArrowLeft className="size-3.5" /> Organizations
33+ </Link>
34+ </Button>
35+ </div>
36+ <PageHeader
37+ eyebrow="Organization"
38+ title={
39+ <span className="flex flex-wrap items-center gap-2">
40+ {org.name}
41+ <PlanBadge plan={org.plan} />
42+ {org.suspended ? <StatusBadge status="suspended" /> : null}
43+ {org.providerVisibility ? <Badge variant="warning" dot>provider debug on</Badge> : null}
44+ </span>
45+ }
46+ description={
47+ <span className="inline-flex items-center gap-1">
48+ <Mono>{org.id}</Mono>
49+ <CopyButton value={org.id} size="icon-sm" className="size-6" />
50+ <span className="text-fg-subtle">· /{org.slug}</span>
51+ </span>
52+ }
53+ actions={
54+ <Button asChild variant="outline" size="sm">
55+ <Link href={`/admin/requests?org=${org.id}`}>Explore requests</Link>
56+ </Button>
57+ }
58+ />
59+
60+ {org.suspended ? <Alert variant="danger" title="Suspended" className="mb-4">API access is blocked for this organization.</Alert> : null}
61+
62+ <StatGrid cols={6} className="mb-6">
63+ <Stat label="Requests 30d" value={formatNumber(s.requests)} />
64+ <Stat label="Success" value={formatPercent(s.requests ? (s.successes / s.requests) * 100 : null)} />
65+ <Stat label="Revenue 30d" value={formatUsd(s.revenue)} />
66+ <Stat label="Upstream cost" value={formatUsd(s.cost)} />
67+ <Stat label="Margin" value={<MarginText value={margin} pct={s.revenue > 0 ? (margin / s.revenue) * 100 : null} />} />
68+ <Stat label="Bandwidth" value={formatBytes(s.bytes)} />
69+ </StatGrid>
70+
71+ <div className="grid gap-6 lg:grid-cols-2">
72+ <Panel title="Controls" description="Every change is written to the audit log with your user id.">
73+ <div className="grid gap-5">
74+ <div className="grid gap-1.5">
75+ <div className="text-[13px] font-medium">Plan</div>
76+ <OrgPlanSelect orgId={org.id} plan={org.plan} options={PLAN_OPTIONS} />
77+ </div>
78+ <OrgProviderVisibilitySwitch orgId={org.id} enabled={org.providerVisibility} />
79+ <OrgSuspendSwitch orgId={org.id} suspended={org.suspended} />
80+ </div>
81+ </Panel>
82+ <div className="grid gap-6">
83+ <Panel title="Spend limits">
84+ <OrgLimitsForm orgId={org.id} softLimitUsd={org.softLimitUsd} hardLimitUsd={org.hardLimitUsd} />
85+ </Panel>
86+ <Panel title="Details">
87+ <KV
88+ items={[
89+ { label: "Owner", value: d.owner ? <Link href={`/admin/users/${d.owner.id}`} className="hover:underline">{d.owner.email}</Link> : "—" },
90+ { label: "Stripe customer", value: org.stripeCustomerId ?? "not linked", mono: true },
91+ { label: "Created", value: formatDate(org.createdAt) },
92+ { label: "Updated", value: formatDate(org.updatedAt) },
93+ ]}
94+ />
95+ </Panel>
96+ </div>
97+ </div>
98+
99+ <Panel title="Members" flush className="mt-6">
100+ <Table>
101+ <TableHeader>
102+ <TableRow>
103+ <TableHead>User</TableHead>
104+ <TableHead>Role</TableHead>
105+ <TableHead className="text-right">Joined</TableHead>
106+ </TableRow>
107+ </TableHeader>
108+ <TableBody>
109+ {d.members.map((m) => (
110+ <TableRow key={m.userId}>
111+ <TableCell>
112+ <Link href={`/admin/users/${m.userId}`} className="font-medium hover:underline">
113+ {m.email}
114+ </Link>
115+ <span className="ml-2 text-fg-muted">{m.name}</span>
116+ </TableCell>
117+ <TableCell className="text-fg-muted">{m.role}</TableCell>
118+ <TableCell className="text-right">
119+ <DateCell value={m.joinedAt} />
120+ </TableCell>
121+ </TableRow>
122+ ))}
123+ </TableBody>
124+ </Table>
125+ </Panel>
126+
127+ <div className="mt-6 grid gap-6 xl:grid-cols-2">
128+ <Panel title="Projects" flush>
129+ <Table>
130+ <TableHeader>
131+ <TableRow>
132+ <TableHead>Project</TableHead>
133+ <TableHead>Env</TableHead>
134+ <TableHead className="text-right">Keys</TableHead>
135+ <TableHead className="text-right">Req 30d</TableHead>
136+ <TableHead className="text-right">Created</TableHead>
137+ </TableRow>
138+ </TableHeader>
139+ <TableBody>
140+ {d.projects.length === 0 ? (
141+ <TableEmpty colSpan={5}>No projects.</TableEmpty>
142+ ) : (
143+ d.projects.map((p) => (
144+ <TableRow key={p.id}>
145+ <TableCell>
146+ <Link href={`/admin/requests?project=${p.id}`} className="font-medium hover:underline">
147+ {p.name}
148+ </Link>
149+ {p.archivedAt ? <Badge variant="outline" className="ml-2">archived</Badge> : null}
150+ <div className="font-mono text-[11.5px] text-fg-subtle">{p.id}</div>
151+ </TableCell>
152+ <TableCell>
153+ <Badge variant={p.environment === "development" ? "warning" : "success"} dot>
154+ {p.environment}
155+ </Badge>
156+ </TableCell>
157+ <TableCell className={numCell}>{p.keys}</TableCell>
158+ <TableCell className={numCell}>{formatNumber(p.requests30d)}</TableCell>
159+ <TableCell className="text-right">
160+ <DateCell value={p.createdAt} />
161+ </TableCell>
162+ </TableRow>
163+ ))
164+ )}
165+ </TableBody>
166+ </Table>
167+ </Panel>
168+ <Panel title="API keys" description="Prefix and last 4 only. Plaintext keys are never stored." flush>
169+ <Table>
170+ <TableHeader>
171+ <TableRow>
172+ <TableHead>Key</TableHead>
173+ <TableHead>Project</TableHead>
174+ <TableHead>Mode</TableHead>
175+ <TableHead>Status</TableHead>
176+ <TableHead className="text-right">Last used</TableHead>
177+ </TableRow>
178+ </TableHeader>
179+ <TableBody>
180+ {d.keys.length === 0 ? (
181+ <TableEmpty colSpan={5}>No API keys.</TableEmpty>
182+ ) : (
183+ d.keys.map((k) => (
184+ <TableRow key={k.id}>
185+ <TableCell>
186+ <div className="font-medium">{k.name}</div>
187+ <Mono className="text-fg-muted">
188+ {k.keyPrefix}…{k.last4}
189+ </Mono>
190+ </TableCell>
191+ <TableCell className="text-fg-muted">{k.projectName ?? "—"}</TableCell>
192+ <TableCell>
193+ <StatusBadge status={k.mode} />
194+ </TableCell>
195+ <TableCell>{k.revokedAt ? <StatusBadge status="revoked" /> : k.expiresAt && k.expiresAt < new Date() ? <StatusBadge status="expired" /> : <StatusBadge status="active" />}</TableCell>
196+ <TableCell className="text-right">
197+ <DateCell value={k.lastUsedAt} />
198+ </TableCell>
199+ </TableRow>
200+ ))
201+ )}
202+ </TableBody>
203+ </Table>
204+ </Panel>
205+ </div>
206+
207+ <Panel title="Recent requests" description="Latest 25. Cost is upstream; price is what the customer is billed." flush className="mt-6">
208+ <Table>
209+ <TableHeader>
210+ <TableRow>
211+ <TableHead>Request</TableHead>
212+ <TableHead>Domain</TableHead>
213+ <TableHead>Status</TableHead>
214+ <TableHead>Network</TableHead>
215+ <TableHead>Country</TableHead>
216+ <TableHead className="text-right">Latency</TableHead>
217+ <TableHead className="text-right">Cost</TableHead>
218+ <TableHead className="text-right">Price</TableHead>
219+ <TableHead className="text-right">When</TableHead>
220+ </TableRow>
221+ </TableHeader>
222+ <TableBody>
223+ {d.recentRequests.length === 0 ? (
224+ <TableEmpty colSpan={9}>No requests yet.</TableEmpty>
225+ ) : (
226+ d.recentRequests.map((r) => (
227+ <TableRow key={r.id}>
228+ <TableCell>
229+ <Link href={`/admin/requests/${r.id}`} className="hover:underline">
230+ <Mono>{r.id}</Mono>
231+ </Link>
232+ </TableCell>
233+ <TableCell className="max-w-[220px] truncate">{r.domain}</TableCell>
234+ <TableCell>
235+ <StatusBadge status={r.status} />
236+ {r.errorCode ? <span className="ml-1.5 font-mono text-[11.5px] text-fg-muted">{r.errorCode}</span> : null}
237+ </TableCell>
238+ <TableCell>
239+ <NetworkBadge network={r.network} />
240+ </TableCell>
241+ <TableCell>
242+ <Mono>{r.country ?? "—"}</Mono>
243+ </TableCell>
244+ <TableCell className={numCell}>{formatMs(r.latencyMs)}</TableCell>
245+ <TableCell className={numCell}>{formatUsd(r.costUsd, true)}</TableCell>
246+ <TableCell className={numCell}>{formatUsd(r.priceUsd, true)}</TableCell>
247+ <TableCell className="text-right">
248+ <DateCell value={r.createdAt} />
249+ </TableCell>
250+ </TableRow>
251+ ))
252+ )}
253+ </TableBody>
254+ </Table>
255+ </Panel>
256+
257+ <Panel title="Audit log" description="Last 30 entries scoped to this organization." flush className="mt-6">
258+ <Table>
259+ <TableHeader>
260+ <TableRow>
261+ <TableHead>When</TableHead>
262+ <TableHead>Action</TableHead>
263+ <TableHead>Target</TableHead>
264+ <TableHead>Metadata</TableHead>
265+ </TableRow>
266+ </TableHeader>
267+ <TableBody>
268+ {d.audit.length === 0 ? (
269+ <TableEmpty colSpan={4}>No audit entries.</TableEmpty>
270+ ) : (
271+ d.audit.map((a) => (
272+ <TableRow key={a.id}>
273+ <TableCell>
274+ <DateCell value={a.createdAt} relative={false} />
275+ </TableCell>
276+ <TableCell>
277+ <Badge variant={a.action === "admin.action" ? "danger" : "default"}>{a.action}</Badge>
278+ </TableCell>
279+ <TableCell>
280+ <Mono>{a.target ?? "—"}</Mono>
281+ </TableCell>
282+ <TableCell className="max-w-[420px] truncate font-mono text-[12px] text-fg-muted" title={a.metadata ? JSON.stringify(a.metadata) : undefined}>
283+ {a.metadata ? JSON.stringify(a.metadata) : "—"}
284+ </TableCell>
285+ </TableRow>
286+ ))
287+ )}
288+ </TableBody>
289+ </Table>
290+ </Panel>
291+ </>
292+ );
293+}
added apps/web/src/app/admin/organizations/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={0} rows={10} />;
5+}
added apps/web/src/app/admin/organizations/page.tsx +94 −0
@@ -0,0 +1,94 @@
1+import Link from "next/link";
2+import { Building2 } from "lucide-react";
3+import { requireAdmin } from "@/lib/session";
4+import { listOrganizations, pageNum, str, PLAN_OPTIONS } from "@/lib/queries/admin";
5+import { formatNumber, formatUsd } from "@/lib/format";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { Badge, StatusBadge } from "@/components/ui/badge";
8+import { EmptyState } from "@/components/ui/empty-state";
9+import { NativeSelect } from "@/components/ui/input";
10+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
11+import { DateCell, Pagination, Panel, PlanBadge, SearchForm, numCell } from "@/components/admin/primitives";
12+
13+export const dynamic = "force-dynamic";
14+
15+export default async function AdminOrganizationsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
16+ await requireAdmin();
17+ const sp = await searchParams;
18+ const q = str(sp.q);
19+ const plan = str(sp.plan);
20+ const page = pageNum(sp.page);
21+ const data = await listOrganizations({ q: q || undefined, plan: plan || undefined, page });
22+
23+ return (
24+ <>
25+ <PageHeader
26+ eyebrow="Customers"
27+ title="Organizations"
28+ description={`${formatNumber(data.total)} organizations. Requests and spend are for the last 30 days.`}
29+ actions={
30+ <SearchForm action="/admin/organizations" q={q} placeholder="name, slug, owner email or id">
31+ <NativeSelect name="plan" defaultValue={plan} className="h-8 w-[150px] text-[13px]" aria-label="Plan">
32+ <option value="">All plans</option>
33+ {PLAN_OPTIONS.map((p) => (
34+ <option key={p.value} value={p.value}>
35+ {p.label}
36+ </option>
37+ ))}
38+ </NativeSelect>
39+ </SearchForm>
40+ }
41+ />
42+ <Panel flush>
43+ {data.total === 0 ? (
44+ <div className="p-4">
45+ <EmptyState icon={Building2} title="No organizations match" description="Every signup creates a personal organization automatically." compact />
46+ </div>
47+ ) : (
48+ <>
49+ <Table>
50+ <TableHeader>
51+ <TableRow>
52+ <TableHead>Name</TableHead>
53+ <TableHead>Plan</TableHead>
54+ <TableHead>Owner</TableHead>
55+ <TableHead className="text-right">Projects</TableHead>
56+ <TableHead className="text-right">Requests 30d</TableHead>
57+ <TableHead className="text-right">Spend 30d</TableHead>
58+ <TableHead>Status</TableHead>
59+ <TableHead>Provider vis.</TableHead>
60+ <TableHead className="text-right">Created</TableHead>
61+ </TableRow>
62+ </TableHeader>
63+ <TableBody>
64+ {data.rows.map((o) => (
65+ <TableRow key={o.id}>
66+ <TableCell>
67+ <Link href={`/admin/organizations/${o.id}`} className="font-medium hover:underline">
68+ {o.name}
69+ </Link>
70+ <div className="font-mono text-[11.5px] text-fg-subtle">{o.slug}</div>
71+ </TableCell>
72+ <TableCell>
73+ <PlanBadge plan={o.plan} />
74+ </TableCell>
75+ <TableCell className="max-w-[220px] truncate text-fg-muted">{o.ownerEmail ?? "—"}</TableCell>
76+ <TableCell className={numCell}>{o.projects}</TableCell>
77+ <TableCell className={numCell}>{formatNumber(o.requests30d)}</TableCell>
78+ <TableCell className={numCell}>{formatUsd(o.spend30d)}</TableCell>
79+ <TableCell>{o.suspended ? <StatusBadge status="suspended" /> : <StatusBadge status="active" />}</TableCell>
80+ <TableCell>{o.providerVisibility ? <Badge variant="warning" dot>debug on</Badge> : <span className="text-fg-subtle">off</span>}</TableCell>
81+ <TableCell className="text-right">
82+ <DateCell value={o.createdAt} />
83+ </TableCell>
84+ </TableRow>
85+ ))}
86+ </TableBody>
87+ </Table>
88+ <Pagination page={data.page} pages={data.pages} total={data.total} pageSize={data.pageSize} basePath="/admin/organizations" params={{ q, plan }} />
89+ </>
90+ )}
91+ </Panel>
92+ </>
93+ );
94+}
added apps/web/src/app/admin/page.tsx +141 −0
@@ -0,0 +1,141 @@
1+import Link from "next/link";
2+import { requireAdmin } from "@/lib/session";
3+import { getPlatformKpis, getProviderStats, getRecentFailures, getRecentSignups, parseRange, rangeLabel, rangeStart } from "@/lib/queries/admin";
4+import { formatBytes, formatCompact, formatMs, formatNumber, formatPercent, formatUsd } from "@/lib/format";
5+import { PageHeader } from "@/components/ui/page-header";
6+import { Stat, StatGrid } from "@/components/ui/stat";
7+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
8+import { Badge, StatusBadge } from "@/components/ui/badge";
9+import { DateCell, MarginText, Mono, Panel, ProviderBadge, RangeTabs, numCell } from "@/components/admin/primitives";
10+
11+export const dynamic = "force-dynamic";
12+
13+export default async function AdminOverview({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
14+ await requireAdmin();
15+ const sp = await searchParams;
16+ const range = parseRange(sp.range);
17+ const since = rangeStart(range);
18+ const [kpi, providers, signups, failures] = await Promise.all([getPlatformKpis(since), getProviderStats(since), getRecentSignups(8), getRecentFailures(10)]);
19+
20+ return (
21+ <>
22+ <PageHeader eyebrow="Platform" title="Overview" description={`${rangeLabel(range)} · all organizations. Costs shown here are upstream costs and must never leave this area.`} actions={<RangeTabs current={range} basePath="/admin" />} />
23+
24+ <div className="space-y-4">
25+ <StatGrid cols={4}>
26+ <Stat label="Requests" value={formatNumber(kpi.requests)} hint={`${formatNumber(kpi.successes)} ok · ${formatNumber(kpi.failed)} failed`} />
27+ <Stat label="Success rate" value={formatPercent(kpi.successRate)} hint={`avg ${kpi.avgAttempts ? kpi.avgAttempts.toFixed(2) : "—"} attempts · ${formatMs(kpi.avgLatency)}`} />
28+ <Stat label="Active organizations" value={formatNumber(kpi.activeOrgs)} hint={`${kpi.newOrgs} new orgs · ${kpi.newUsers} new users`} />
29+ <Stat label="Bandwidth" value={formatBytes(kpi.bytes)} hint="in + out, all networks" />
30+ </StatGrid>
31+ <StatGrid cols={4}>
32+ <Stat label="Revenue (billed)" value={formatUsd(kpi.revenue)} hint="sum of price_usd" />
33+ <Stat label="Upstream cost" value={formatUsd(kpi.cost)} hint="sum of cost_usd" />
34+ <Stat label="Gross margin" value={<MarginText value={kpi.margin} pct={kpi.marginPct} />} hint="revenue − upstream cost" />
35+ <Stat label="Cost / successful request" value={formatUsd(kpi.costPerSuccess, true)} hint="key unit-economics metric" />
36+ </StatGrid>
37+ </div>
38+
39+ <div className="mt-6 grid gap-6 xl:grid-cols-5">
40+ <Panel title="Providers" description="Attempt-level stats from request_attempts. Provider names are admin-only." right={<Link href="/admin/providers" className="text-[12.5px] text-accent hover:underline">Manage →</Link>} flush className="xl:col-span-3">
41+ <Table>
42+ <TableHeader>
43+ <TableRow>
44+ <TableHead>Provider</TableHead>
45+ <TableHead className="text-right">Attempts</TableHead>
46+ <TableHead className="text-right">Success</TableHead>
47+ <TableHead className="text-right">Blocked</TableHead>
48+ <TableHead className="text-right">Avg latency</TableHead>
49+ <TableHead className="text-right">Cost</TableHead>
50+ <TableHead className="text-right">Cost / success</TableHead>
51+ </TableRow>
52+ </TableHeader>
53+ <TableBody>
54+ {providers.length === 0 ? (
55+ <TableEmpty colSpan={7}>No attempts in this range.</TableEmpty>
56+ ) : (
57+ providers.map((p) => (
58+ <TableRow key={p.provider}>
59+ <TableCell>
60+ <ProviderBadge id={p.provider} />
61+ </TableCell>
62+ <TableCell className={numCell}>{formatNumber(p.requests)}</TableCell>
63+ <TableCell className={numCell}>{formatPercent(p.successRate)}</TableCell>
64+ <TableCell className={numCell}>{formatPercent(p.blockedRate)}</TableCell>
65+ <TableCell className={numCell}>{formatMs(p.avgLatency)}</TableCell>
66+ <TableCell className={numCell}>{formatUsd(p.cost, true)}</TableCell>
67+ <TableCell className={`${numCell} font-semibold`}>{formatUsd(p.costPerSuccess, true)}</TableCell>
68+ </TableRow>
69+ ))
70+ )}
71+ </TableBody>
72+ </Table>
73+ </Panel>
74+
75+ <Panel title="Recent signups" right={<Link href="/admin/users" className="text-[12.5px] text-accent hover:underline">All users →</Link>} flush className="xl:col-span-2">
76+ <ul className="divide-y divide-border">
77+ {signups.length === 0 ? <li className="px-4 py-8 text-center text-[13px] text-fg-muted">No users yet.</li> : null}
78+ {signups.map((u) => (
79+ <li key={u.id} className="flex items-center gap-3 px-4 py-2.5 text-[13px]">
80+ <div className="min-w-0 flex-1">
81+ <Link href={`/admin/users/${u.id}`} className="block truncate font-medium hover:underline">
82+ {u.email}
83+ </Link>
84+ <div className="truncate text-[12px] text-fg-muted">{u.name || "—"}</div>
85+ </div>
86+ {u.emailVerified ? <Badge variant="success" dot>verified</Badge> : <Badge variant="outline">unverified</Badge>}
87+ <DateCell value={u.createdAt} />
88+ </li>
89+ ))}
90+ </ul>
91+ </Panel>
92+ </div>
93+
94+ <Panel title="Recent failures" description="Latest failed requests across all customers." right={<Link href="/admin/requests?status=failed" className="text-[12.5px] text-accent hover:underline">Explore →</Link>} flush className="mt-6">
95+ <Table>
96+ <TableHeader>
97+ <TableRow>
98+ <TableHead>Request</TableHead>
99+ <TableHead>Organization</TableHead>
100+ <TableHead>Domain</TableHead>
101+ <TableHead>Error</TableHead>
102+ <TableHead className="text-right">HTTP</TableHead>
103+ <TableHead className="text-right">Attempts</TableHead>
104+ <TableHead className="text-right">When</TableHead>
105+ </TableRow>
106+ </TableHeader>
107+ <TableBody>
108+ {failures.length === 0 ? (
109+ <TableEmpty colSpan={7}>No failures recorded.</TableEmpty>
110+ ) : (
111+ failures.map((f) => (
112+ <TableRow key={f.id}>
113+ <TableCell>
114+ <Link href={`/admin/requests/${f.id}`} className="hover:underline">
115+ <Mono>{f.id}</Mono>
116+ </Link>
117+ </TableCell>
118+ <TableCell>
119+ <Link href={`/admin/organizations/${f.organizationId}`} className="hover:underline">
120+ {f.orgName ?? f.organizationId}
121+ </Link>
122+ </TableCell>
123+ <TableCell className="max-w-[240px] truncate">{f.domain}</TableCell>
124+ <TableCell>{f.errorCode ? <StatusBadge status={f.errorCode} /> : "—"}</TableCell>
125+ <TableCell className={numCell}>{f.httpStatus ?? "—"}</TableCell>
126+ <TableCell className={numCell}>{f.attempts}</TableCell>
127+ <TableCell className="text-right">
128+ <DateCell value={f.createdAt} />
129+ </TableCell>
130+ </TableRow>
131+ ))
132+ )}
133+ </TableBody>
134+ </Table>
135+ </Panel>
136+ <p className="mt-4 text-[12px] text-fg-subtle">
137+ Compact figures: {formatCompact(kpi.requests)} requests · {formatCompact(kpi.bytes)} bytes.
138+ </p>
139+ </>
140+ );
141+}
added apps/web/src/app/admin/projects/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={0} rows={10} />;
5+}
added apps/web/src/app/admin/projects/page.tsx +97 −0
@@ -0,0 +1,97 @@
1+import Link from "next/link";
2+import { FolderKanban } from "lucide-react";
3+import { requireAdmin } from "@/lib/session";
4+import { listProjects, pageNum, str } from "@/lib/queries/admin";
5+import { formatNumber, formatUsd } from "@/lib/format";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { Badge } from "@/components/ui/badge";
8+import { EmptyState } from "@/components/ui/empty-state";
9+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
10+import { DateCell, Mono, Pagination, Panel, PlanBadge, SearchForm, numCell } from "@/components/admin/primitives";
11+
12+export const dynamic = "force-dynamic";
13+
14+export default async function AdminProjectsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
15+ await requireAdmin();
16+ const sp = await searchParams;
17+ const q = str(sp.q);
18+ const archived = str(sp.archived) === "1";
19+ const page = pageNum(sp.page);
20+ const data = await listProjects({ q: q || undefined, page, includeArchived: archived });
21+
22+ return (
23+ <>
24+ <PageHeader
25+ eyebrow="Customers"
26+ title="Projects"
27+ description={`${formatNumber(data.total)} projects across all organizations. Requests and spend are for the last 30 days.`}
28+ actions={
29+ <SearchForm action="/admin/projects" q={q} placeholder="project or organization name">
30+ <label className="flex items-center gap-1.5 text-[13px] text-fg-muted">
31+ <input type="checkbox" name="archived" value="1" defaultChecked={archived} className="size-3.5 accent-[var(--accent)]" /> include archived
32+ </label>
33+ </SearchForm>
34+ }
35+ />
36+ <Panel flush>
37+ {data.total === 0 ? (
38+ <div className="p-4">
39+ <EmptyState icon={FolderKanban} title="No projects match" compact />
40+ </div>
41+ ) : (
42+ <>
43+ <Table>
44+ <TableHeader>
45+ <TableRow>
46+ <TableHead>Project</TableHead>
47+ <TableHead>Organization</TableHead>
48+ <TableHead>Env</TableHead>
49+ <TableHead className="text-right">Requests 30d</TableHead>
50+ <TableHead className="text-right">Spend 30d</TableHead>
51+ <TableHead className="text-right">Keys</TableHead>
52+ <TableHead className="text-right">Created</TableHead>
53+ <TableHead>Status</TableHead>
54+ </TableRow>
55+ </TableHeader>
56+ <TableBody>
57+ {data.rows.map((p) => (
58+ <TableRow key={p.id}>
59+ <TableCell>
60+ <Link href={`/admin/requests?project=${p.id}`} className="font-medium hover:underline">
61+ {p.name}
62+ </Link>
63+ <div>
64+ <Mono className="text-[11.5px] text-fg-subtle">{p.id}</Mono>
65+ </div>
66+ </TableCell>
67+ <TableCell>
68+ <Link href={`/admin/organizations/${p.organizationId}`} className="hover:underline">
69+ {p.orgName}
70+ </Link>
71+ <span className="ml-2">
72+ <PlanBadge plan={p.orgPlan} />
73+ </span>
74+ </TableCell>
75+ <TableCell>
76+ <Badge variant={p.environment === "development" ? "warning" : "success"} dot>
77+ {p.environment}
78+ </Badge>
79+ </TableCell>
80+ <TableCell className={numCell}>{formatNumber(p.requests30d)}</TableCell>
81+ <TableCell className={numCell}>{formatUsd(p.spend30d)}</TableCell>
82+ <TableCell className={numCell}>{p.keys}</TableCell>
83+ <TableCell className="text-right">
84+ <DateCell value={p.createdAt} />
85+ </TableCell>
86+ <TableCell>{p.archivedAt ? <Badge variant="outline">archived</Badge> : <Badge variant="success" dot>active</Badge>}</TableCell>
87+ </TableRow>
88+ ))}
89+ </TableBody>
90+ </Table>
91+ <Pagination page={data.page} pages={data.pages} total={data.total} pageSize={data.pageSize} basePath="/admin/projects" params={{ q, archived: archived ? "1" : undefined }} />
92+ </>
93+ )}
94+ </Panel>
95+ </>
96+ );
97+}
added apps/web/src/app/admin/providers/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={6} rows={6} />;
5+}
added apps/web/src/app/admin/providers/page.tsx +186 −0
@@ -0,0 +1,186 @@
1+import { requireAdmin } from "@/lib/session";
2+import { internalApi, InternalApiError } from "@/lib/api";
3+import { getProviderConfigs, getProviderHealthHistory, getProviderStats, rangeStart } from "@/lib/queries/admin";
4+import { formatBytes, formatMs, formatNumber, formatPercent, formatUsd } from "@/lib/format";
5+import { PageHeader } from "@/components/ui/page-header";
6+import { Alert } from "@/components/ui/alert";
7+import { Badge, StatusBadge } from "@/components/ui/badge";
8+import { Stat, StatGrid } from "@/components/ui/stat";
9+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
10+import { DateCell, NetworkBadge, Panel, ProviderBadge, numCell } from "@/components/admin/primitives";
11+import { CircuitList, ProbeButton, ProviderConfigForm, ResetAllCircuitsButton } from "@/components/admin/provider-panel";
12+
13+export const dynamic = "force-dynamic";
14+
15+type ProvidersPayload = Awaited<ReturnType<typeof internalApi.providers>>;
16+
17+export default async function AdminProvidersPage() {
18+ await requireAdmin();
19+ let live: ProvidersPayload | null = null;
20+ let apiError: string | null = null;
21+ try {
22+ live = await internalApi.providers();
23+ } catch (e) {
24+ apiError = e instanceof InternalApiError ? `${e.code}: ${e.message}` : (e as Error).message;
25+ }
26+ const [configs, stats, history] = await Promise.all([getProviderConfigs(), getProviderStats(rangeStart("24h")), getProviderHealthHistory(50)]);
27+ const configById = new Map(configs.map((c) => [c.id, c]));
28+ const statById = new Map(stats.map((s) => [s.provider, s]));
29+
30+ // Providers known to the API, plus any config rows the API does not report (e.g. disabled ones).
31+ const providerIds = Array.from(new Set([...(live?.providers.map((p) => p.id) ?? []), ...configs.map((c) => c.id)]));
32+
33+ return (
34+ <>
35+ <PageHeader
36+ eyebrow="Upstream"
37+ title="Providers"
38+ description="Configuration, health, circuit breakers and 24-hour unit economics per upstream provider. This page is the only place provider names appear."
39+ actions={
40+ <>
41+ <ResetAllCircuitsButton />
42+ <ProbeButton />
43+ </>
44+ }
45+ />
46+
47+ {apiError ? (
48+ <Alert variant="warning" title="API service unreachable" className="mb-4">
49+ Live provider status could not be loaded ({apiError}). Configuration and database statistics are still shown; saving a config will retry the reload.
50+ </Alert>
51+ ) : (
52+ <p className="mb-4 text-[12.5px] text-fg-muted">
53+ Available networks right now: {live!.available_networks.length ? live!.available_networks.map((n) => <NetworkBadge key={n} network={n} />) : <span>none</span>}
54+ </p>
55+ )}
56+
57+ <div className="space-y-6">
58+ {providerIds.length === 0 ? <Alert variant="info">No providers registered.</Alert> : null}
59+ {providerIds.map((id) => {
60+ const p = live?.providers.find((x) => x.id === id) ?? null;
61+ const cfg = configById.get(id) ?? null;
62+ const st = statById.get(id) ?? null;
63+ const label = cfg?.label ?? p?.label ?? id;
64+ const enabled = cfg?.enabled ?? true;
65+ return (
66+ <Panel
67+ key={id}
68+ title={
69+ <span className="flex flex-wrap items-center gap-2">
70+ <span className="text-[15px]">{label}</span>
71+ <ProviderBadge id={id} />
72+ {p ? p.configured ? <Badge variant="success" dot>configured</Badge> : <Badge variant="danger" dot>credentials missing</Badge> : <Badge variant="outline">not reported by API</Badge>}
73+ {enabled ? <Badge variant="accent">enabled</Badge> : <Badge variant="outline">disabled</Badge>}
74+ </span>
75+ }
76+ description={p ? `Networks: ${p.networks.join(", ") || "none"}` : cfg ? `Networks (config): ${cfg.networks.join(", ")}` : undefined}
77+ bodyClassName="p-0"
78+ >
79+ <div className="border-b border-border">
80+ <StatGrid cols={6} className="rounded-none border-0 shadow-none">
81+ <Stat label="Attempts 24h" value={formatNumber(st?.requests ?? 0)} />
82+ <Stat label="Success" value={formatPercent(st?.successRate ?? null)} />
83+ <Stat label="Blocked" value={formatPercent(st?.blockedRate ?? null)} />
84+ <Stat label="Avg latency" value={formatMs(st?.avgLatency ?? null)} />
85+ <Stat label="Bytes · cost" value={`${formatBytes(st?.bytes ?? 0)} · ${formatUsd(st?.cost ?? 0, true)}`} />
86+ <Stat label="Cost / success" value={<span className="text-accent">{formatUsd(st?.costPerSuccess ?? null, true)}</span>} hint="key metric" />
87+ </StatGrid>
88+ </div>
89+ <div className="grid gap-6 p-4 lg:grid-cols-2">
90+ <div>
91+ <h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Configuration</h3>
92+ <ProviderConfigForm
93+ id={id}
94+ label={label}
95+ enabled={enabled}
96+ networks={cfg?.networks ?? []}
97+ pricePerGbUsd={cfg?.pricePerGbUsd ?? {}}
98+ weight={cfg?.weight ?? 1}
99+ maxConcurrency={cfg?.maxConcurrency ?? 200}
100+ notes={cfg?.notes ?? null}
101+ livePrices={p?.prices ?? {}}
102+ liveNetworks={p?.networks ?? []}
103+ hasConfigRow={Boolean(cfg)}
104+ />
105+ </div>
106+ <div className="grid gap-5">
107+ <div>
108+ <h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Latest health per network</h3>
109+ {p?.health.length ? (
110+ <ul className="divide-y divide-border rounded-md border border-border">
111+ {p.health.map((h) => (
112+ <li key={h.network} className="flex flex-wrap items-center gap-3 px-3 py-2 text-[12.5px]">
113+ <NetworkBadge network={h.network} />
114+ <StatusBadge status={h.status} />
115+ <span className="tabular font-mono text-fg-muted">{formatMs(h.latency_ms)}</span>
116+ <span className="text-fg-subtle">
117+ <DateCell value={h.checked_at} />
118+ </span>
119+ {h.detail ? (
120+ <span className="w-full truncate font-mono text-[11.5px] text-fg-subtle" title={h.detail}>
121+ {h.detail}
122+ </span>
123+ ) : null}
124+ </li>
125+ ))}
126+ </ul>
127+ ) : (
128+ <p className="text-[12.5px] text-fg-muted">No health check in the last 24 hours. Run a probe.</p>
129+ )}
130+ </div>
131+ <div>
132+ <h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Circuit breakers</h3>
133+ <CircuitList circuits={p?.circuits ?? []} />
134+ </div>
135+ </div>
136+ </div>
137+ </Panel>
138+ );
139+ })}
140+ </div>
141+
142+ <Panel title="Health history" description="Last 50 probe results across providers." flush className="mt-6">
143+ <Table>
144+ <TableHeader>
145+ <TableRow>
146+ <TableHead>Checked</TableHead>
147+ <TableHead>Provider</TableHead>
148+ <TableHead>Network</TableHead>
149+ <TableHead>Status</TableHead>
150+ <TableHead className="text-right">Latency</TableHead>
151+ <TableHead className="text-right">Success rate</TableHead>
152+ <TableHead>Detail</TableHead>
153+ </TableRow>
154+ </TableHeader>
155+ <TableBody>
156+ {history.length === 0 ? (
157+ <TableEmpty colSpan={7}>No probes recorded yet.</TableEmpty>
158+ ) : (
159+ history.map((h) => (
160+ <TableRow key={h.id}>
161+ <TableCell>
162+ <DateCell value={h.checkedAt} relative={false} />
163+ </TableCell>
164+ <TableCell>
165+ <ProviderBadge id={h.provider} />
166+ </TableCell>
167+ <TableCell>
168+ <NetworkBadge network={h.network} />
169+ </TableCell>
170+ <TableCell>
171+ <StatusBadge status={h.status} />
172+ </TableCell>
173+ <TableCell className={numCell}>{formatMs(h.latencyMs)}</TableCell>
174+ <TableCell className={numCell}>{h.successRate !== null ? formatPercent(h.successRate * (h.successRate <= 1 ? 100 : 1)) : "—"}</TableCell>
175+ <TableCell className="max-w-[360px] truncate font-mono text-[11.5px] text-fg-muted" title={h.detail ?? undefined}>
176+ {h.detail ?? "—"}
177+ </TableCell>
178+ </TableRow>
179+ ))
180+ )}
181+ </TableBody>
182+ </Table>
183+ </Panel>
184+ </>
185+ );
186+}
added apps/web/src/app/admin/requests/[id]/page.tsx +233 −0
@@ -0,0 +1,233 @@
1+import Link from "next/link";
2+import { notFound } from "next/navigation";
3+import { ArrowLeft } from "lucide-react";
4+import { requireAdmin } from "@/lib/session";
5+import { getRequestDetail } from "@/lib/queries/admin";
6+import { formatBytes, formatDate, formatMs, formatUsd } from "@/lib/format";
7+import { PageHeader } from "@/components/ui/page-header";
8+import { Badge, StatusBadge } from "@/components/ui/badge";
9+import { Button } from "@/components/ui/button";
10+import { Stat, StatGrid } from "@/components/ui/stat";
11+import { CodeBlock } from "@/components/ui/code-block";
12+import { CopyButton } from "@/components/ui/copy-button";
13+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
14+import { KV, MarginText, Mono, NetworkBadge, Panel, PlanBadge, ProviderBadge, numCell } from "@/components/admin/primitives";
15+
16+export const dynamic = "force-dynamic";
17+
18+export default async function AdminRequestDetail({ params }: { params: Promise<{ id: string }> }) {
19+ await requireAdmin();
20+ const { id } = await params;
21+ const d = await getRequestDetail(id);
22+ if (!d) notFound();
23+ const { req } = d;
24+ const margin = req.priceUsd - req.costUsd;
25+
26+ return (
27+ <>
28+ <div className="mb-4">
29+ <Button asChild variant="ghost" size="sm" className="-ml-2">
30+ <Link href="/admin/requests">
31+ <ArrowLeft className="size-3.5" /> Requests
32+ </Link>
33+ </Button>
34+ </div>
35+ <PageHeader
36+ eyebrow="Request"
37+ title={
38+ <span className="flex flex-wrap items-center gap-2">
39+ <Mono className="text-[18px]">{req.id}</Mono>
40+ <CopyButton value={req.id} size="icon-sm" className="size-6" />
41+ <StatusBadge status={req.status} />
42+ {req.errorCode ? <Badge variant="danger">{req.errorCode}</Badge> : null}
43+ {req.cached ? <Badge variant="info">cached</Badge> : null}
44+ </span>
45+ }
46+ description={
47+ <span className="break-all">
48+ {req.method} {req.url}
49+ </span>
50+ }
51+ actions={
52+ <>
53+ {d.org ? (
54+ <Button asChild variant="outline" size="sm">
55+ <Link href={`/admin/organizations/${d.org.id}`}>Organization</Link>
56+ </Button>
57+ ) : null}
58+ <Button asChild variant="outline" size="sm">
59+ <Link href={`/admin/domains/${encodeURIComponent(req.domain)}`}>Domain profile</Link>
60+ </Button>
61+ </>
62+ }
63+ />
64+
65+ <StatGrid cols={6} className="mb-6">
66+ <Stat label="HTTP status" value={req.httpStatus ?? "—"} />
67+ <Stat label="Attempts" value={req.attempts} />
68+ <Stat label="Latency" value={formatMs(req.latencyMs)} />
69+ <Stat label="Bytes in / out" value={`${formatBytes(req.bytesIn)} / ${formatBytes(req.bytesOut)}`} />
70+ <Stat label="Upstream cost → price" value={`${formatUsd(req.costUsd, true)} → ${formatUsd(req.priceUsd, true)}`} />
71+ <Stat label="Margin" value={<MarginText value={margin} pct={req.priceUsd > 0 ? (margin / req.priceUsd) * 100 : null} />} />
72+ </StatGrid>
73+
74+ <div className="grid gap-6 lg:grid-cols-2">
75+ <Panel title="Request">
76+ <KV
77+ cols={2}
78+ items={[
79+ { label: "Organization", value: d.org ? <span className="inline-flex items-center gap-2"><Link href={`/admin/organizations/${d.org.id}`} className="hover:underline">{d.org.name}</Link><PlanBadge plan={d.org.plan} />{d.org.providerVisibility ? <Badge variant="warning">provider debug on</Badge> : null}</span> : req.organizationId },
80+ { label: "Project", value: d.project ? `${d.project.name} (${d.project.environment})` : req.projectId },
81+ { label: "API key", value: d.key ? <span>{d.key.name} <Mono className="text-fg-muted">{d.key.keyPrefix}…{d.key.last4}</Mono> <StatusBadge status={d.key.mode} /></span> : req.source === "playground" ? "— (playground)" : "—" },
82+ { label: "Source", value: req.source },
83+ { label: "URL", value: <span className="break-all">{req.url}</span>, mono: true, span: true },
84+ { label: "Final URL", value: req.finalUrl ? <span className="break-all">{req.finalUrl}</span> : "—", mono: true, span: true },
85+ { label: "Domain", value: req.domain, mono: true },
86+ { label: "Method", value: req.method, mono: true },
87+ { label: "Requested network", value: req.requestedNetwork },
88+ { label: "Resolved network", value: <NetworkBadge network={req.network} /> },
89+ { label: "Geo", value: [req.country, req.region, req.city].filter(Boolean).join(" / ") || "—", mono: true },
90+ { label: "Session", value: req.sessionId ?? "—", mono: true },
91+ { label: "Browser", value: req.browser ? "requested" : "no" },
92+ { label: "Format", value: req.format },
93+ { label: "Client IP", value: req.clientIp ?? "—", mono: true },
94+ { label: "User agent", value: req.userAgent ?? "—", span: true },
95+ { label: "Created", value: formatDate(req.createdAt) },
96+ { label: "Completed", value: req.completedAt ? formatDate(req.completedAt) : "—" },
97+ { label: "Error message", value: req.errorMessage ?? "—", span: true },
98+ ]}
99+ />
100+ </Panel>
101+ <div className="grid gap-6">
102+ <Panel title="Timing" description="Milliseconds, as recorded by the gateway.">
103+ {req.timing && Object.keys(req.timing).length ? (
104+ <KV cols={3} items={Object.entries(req.timing).map(([k, v]) => ({ label: k, value: formatMs(v), mono: true }))} />
105+ ) : (
106+ <p className="text-[13px] text-fg-muted">No timing recorded.</p>
107+ )}
108+ </Panel>
109+ {d.session ? (
110+ <Panel title="Proxy session" description="Upstream sticky session used by this request.">
111+ <KV
112+ cols={2}
113+ items={[
114+ { label: "Session", value: d.session.id, mono: true },
115+ { label: "Provider", value: <ProviderBadge id={d.session.provider} /> },
116+ { label: "Network", value: <NetworkBadge network={d.session.network} /> },
117+ { label: "Status", value: <StatusBadge status={d.session.status} /> },
118+ { label: "Requests", value: d.session.requestCount },
119+ { label: "Expires", value: formatDate(d.session.expiresAt) },
120+ ]}
121+ />
122+ </Panel>
123+ ) : null}
124+ <Panel title="Usage events" flush>
125+ <Table>
126+ <TableHeader>
127+ <TableRow>
128+ <TableHead>Metric</TableHead>
129+ <TableHead className="text-right">Quantity</TableHead>
130+ <TableHead className="text-right">Price</TableHead>
131+ <TableHead className="text-right">Upstream</TableHead>
132+ </TableRow>
133+ </TableHeader>
134+ <TableBody>
135+ {d.usage.length === 0 ? (
136+ <TableEmpty colSpan={4}>No usage events (free-tier or failed before metering).</TableEmpty>
137+ ) : (
138+ d.usage.map((u) => (
139+ <TableRow key={u.id}>
140+ <TableCell>
141+ <Mono>{u.metric}</Mono>
142+ </TableCell>
143+ <TableCell className={numCell}>
144+ {u.unit === "bytes" ? formatBytes(u.quantity) : `${u.quantity} ${u.unit}`}
145+ </TableCell>
146+ <TableCell className={numCell}>{formatUsd(u.costUsd, true)}</TableCell>
147+ <TableCell className={numCell}>{formatUsd(u.upstreamCostUsd, true)}</TableCell>
148+ </TableRow>
149+ ))
150+ )}
151+ </TableBody>
152+ </Table>
153+ </Panel>
154+ </div>
155+ </div>
156+
157+ <Panel title="Attempts" description="Every upstream attempt with provider, routing score and internal error detail. Never shown to customers." flush className="mt-6">
158+ <Table>
159+ <TableHeader>
160+ <TableRow>
161+ <TableHead>#</TableHead>
162+ <TableHead>Provider</TableHead>
163+ <TableHead>Network</TableHead>
164+ <TableHead>Geo</TableHead>
165+ <TableHead>Outcome</TableHead>
166+ <TableHead className="text-right">HTTP</TableHead>
167+ <TableHead>Error / block reason</TableHead>
168+ <TableHead className="text-right">Score</TableHead>
169+ <TableHead className="text-right">Duration</TableHead>
170+ <TableHead className="text-right">Bytes</TableHead>
171+ <TableHead className="text-right">$/GB</TableHead>
172+ <TableHead className="text-right">Cost</TableHead>
173+ </TableRow>
174+ </TableHeader>
175+ <TableBody>
176+ {d.attempts.length === 0 ? (
177+ <TableEmpty colSpan={12}>No attempts recorded (request rejected before routing).</TableEmpty>
178+ ) : (
179+ d.attempts.map((a) => (
180+ <TableRow key={a.id}>
181+ <TableCell className={numCell}>{a.attemptNo}</TableCell>
182+ <TableCell>
183+ <ProviderBadge id={a.provider} />
184+ </TableCell>
185+ <TableCell>
186+ <NetworkBadge network={a.network} />
187+ </TableCell>
188+ <TableCell>
189+ <Mono>{a.country ?? "—"}</Mono>
190+ {a.sessionKey ? <div className="font-mono text-[11px] text-fg-subtle">sticky</div> : null}
191+ </TableCell>
192+ <TableCell>
193+ <StatusBadge status={a.outcome} />
194+ </TableCell>
195+ <TableCell className={numCell}>{a.httpStatus ?? "—"}</TableCell>
196+ <TableCell className="max-w-[320px]">
197+ {a.errorCode ? <div className="font-mono text-[12px] text-danger">{a.errorCode}</div> : null}
198+ {a.blockReason ? <div className="text-[12px] text-fg-muted">block: {a.blockReason}</div> : null}
199+ {a.errorDetail ? (
200+ <div className="truncate font-mono text-[11.5px] text-fg-subtle" title={a.errorDetail}>
201+ {a.errorDetail}
202+ </div>
203+ ) : null}
204+ {!a.errorCode && !a.blockReason && !a.errorDetail ? <span className="text-fg-subtle">—</span> : null}
205+ </TableCell>
206+ <TableCell className={numCell}>{a.routingScore !== null ? a.routingScore.toFixed(3) : "—"}</TableCell>
207+ <TableCell className={numCell}>{formatMs(a.durationMs)}</TableCell>
208+ <TableCell className={numCell}>{formatBytes(a.bytesIn + a.bytesOut)}</TableCell>
209+ <TableCell className={numCell}>{a.unitPricePerGb ? `$${a.unitPricePerGb}` : "—"}</TableCell>
210+ <TableCell className={numCell}>{formatUsd(a.costUsd, true)}</TableCell>
211+ </TableRow>
212+ ))
213+ )}
214+ </TableBody>
215+ </Table>
216+ </Panel>
217+
218+ <div className="mt-6 grid gap-6 lg:grid-cols-2">
219+ <Panel title="Request headers" description="Sensitive headers are redacted at ingestion.">
220+ <CodeBlock code={JSON.stringify(req.requestHeaders ?? {}, null, 2)} lang="json" maxHeight={320} />
221+ </Panel>
222+ <Panel title="Response headers">
223+ <CodeBlock code={JSON.stringify(req.responseHeaders ?? {}, null, 2)} lang="json" maxHeight={320} />
224+ </Panel>
225+ </div>
226+ {d.attempts.some((a) => a.timing) ? (
227+ <Panel title="Attempt timing (raw)" className="mt-6">
228+ <CodeBlock code={JSON.stringify(d.attempts.map((a) => ({ attempt: a.attemptNo, provider: a.provider, timing: a.timing })), null, 2)} lang="json" maxHeight={320} />
229+ </Panel>
230+ ) : null}
231+ </>
232+ );
233+}
added apps/web/src/app/admin/requests/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={0} rows={12} />;
5+}
added apps/web/src/app/admin/requests/page.tsx +223 −0
@@ -0,0 +1,223 @@
1+import Link from "next/link";
2+import { ListTree } from "lucide-react";
3+import { requireAdmin } from "@/lib/session";
4+import { CONCRETE_NETWORKS, PROVIDER_IDS, getRequestFilterOptions, listRequests, requestFiltersFromParams, str } from "@/lib/queries/admin";
5+import { formatBytes, formatMs, formatNumber, formatUsd } from "@/lib/format";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { StatusBadge } from "@/components/ui/badge";
8+import { Button } from "@/components/ui/button";
9+import { Input, NativeSelect } from "@/components/ui/input";
10+import { Field, Label } from "@/components/ui/label";
11+import { EmptyState } from "@/components/ui/empty-state";
12+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
13+import { DateCell, Mono, NetworkBadge, Pagination, Panel, ProviderBadge, numCell } from "@/components/admin/primitives";
14+
15+export const dynamic = "force-dynamic";
16+
17+export default async function AdminRequestsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
18+ await requireAdmin();
19+ const sp = await searchParams;
20+ const f = requestFiltersFromParams(sp);
21+ const [data, opts] = await Promise.all([listRequests(f), getRequestFilterOptions()]);
22+ const params: Record<string, string | undefined> = {
23+ org: f.org,
24+ project: f.project,
25+ domain: f.domain,
26+ status: f.status,
27+ provider: f.provider,
28+ network: f.network,
29+ country: f.country,
30+ source: f.source,
31+ errorCode: f.errorCode,
32+ from: f.from,
33+ to: f.to,
34+ id: f.id,
35+ };
36+ const active = Object.values(params).filter(Boolean).length;
37+
38+ return (
39+ <>
40+ <PageHeader eyebrow="Explorer" title="Requests" description={`${formatNumber(data.total)} matching requests. Provider chain is shown per request (admin-only).`} />
41+
42+ <Panel className="mb-4" title="Filters" right={active ? <Button asChild variant="ghost" size="xs"><Link href="/admin/requests">Clear {active}</Link></Button> : null}>
43+ <form method="get" action="/admin/requests" className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
44+ <Field>
45+ <Label htmlFor="f-id">Request id</Label>
46+ <Input id="f-id" name="id" defaultValue={f.id} placeholder="req_…" className="h-8 font-mono text-[12.5px]" />
47+ </Field>
48+ <Field>
49+ <Label htmlFor="f-org">Organization</Label>
50+ <NativeSelect id="f-org" name="org" defaultValue={f.org ?? ""} className="h-8 text-[13px]">
51+ <option value="">Any</option>
52+ {opts.orgs.map((o) => (
53+ <option key={o.id} value={o.id}>
54+ {o.name}
55+ </option>
56+ ))}
57+ </NativeSelect>
58+ </Field>
59+ <Field>
60+ <Label htmlFor="f-project">Project id</Label>
61+ <Input id="f-project" name="project" defaultValue={f.project} placeholder="proj_…" className="h-8 font-mono text-[12.5px]" />
62+ </Field>
63+ <Field>
64+ <Label htmlFor="f-domain">Domain</Label>
65+ <Input id="f-domain" name="domain" defaultValue={f.domain} placeholder="contains…" className="h-8 text-[13px]" />
66+ </Field>
67+ <Field>
68+ <Label htmlFor="f-status">Status</Label>
69+ <NativeSelect id="f-status" name="status" defaultValue={f.status ?? ""} className="h-8 text-[13px]">
70+ <option value="">Any</option>
71+ <option value="success">success</option>
72+ <option value="failed">failed</option>
73+ <option value="pending">pending</option>
74+ </NativeSelect>
75+ </Field>
76+ <Field>
77+ <Label htmlFor="f-provider">Provider</Label>
78+ <NativeSelect id="f-provider" name="provider" defaultValue={f.provider ?? ""} className="h-8 font-mono text-[12.5px]">
79+ <option value="">Any</option>
80+ {PROVIDER_IDS.map((p) => (
81+ <option key={p} value={p}>
82+ {p}
83+ </option>
84+ ))}
85+ </NativeSelect>
86+ </Field>
87+ <Field>
88+ <Label htmlFor="f-network">Network</Label>
89+ <NativeSelect id="f-network" name="network" defaultValue={f.network ?? ""} className="h-8 text-[13px]">
90+ <option value="">Any</option>
91+ {CONCRETE_NETWORKS.map((n) => (
92+ <option key={n} value={n}>
93+ {n}
94+ </option>
95+ ))}
96+ <option value="direct">direct</option>
97+ </NativeSelect>
98+ </Field>
99+ <Field>
100+ <Label htmlFor="f-country">Country</Label>
101+ <Input id="f-country" name="country" defaultValue={f.country} placeholder="CA" maxLength={2} className="h-8 font-mono uppercase text-[12.5px]" />
102+ </Field>
103+ <Field>
104+ <Label htmlFor="f-source">Source</Label>
105+ <NativeSelect id="f-source" name="source" defaultValue={f.source ?? ""} className="h-8 text-[13px]">
106+ <option value="">Any</option>
107+ <option value="api">api</option>
108+ <option value="playground">playground</option>
109+ <option value="sdk">sdk</option>
110+ </NativeSelect>
111+ </Field>
112+ <Field>
113+ <Label htmlFor="f-error">Error code</Label>
114+ <NativeSelect id="f-error" name="errorCode" defaultValue={f.errorCode ?? ""} className="h-8 font-mono text-[12.5px]">
115+ <option value="">Any</option>
116+ {opts.errorCodes.map((c) => (
117+ <option key={c} value={c}>
118+ {c}
119+ </option>
120+ ))}
121+ </NativeSelect>
122+ </Field>
123+ <Field>
124+ <Label htmlFor="f-from">From</Label>
125+ <Input id="f-from" name="from" type="datetime-local" defaultValue={f.from} className="h-8 text-[12.5px]" />
126+ </Field>
127+ <Field>
128+ <Label htmlFor="f-to">To</Label>
129+ <Input id="f-to" name="to" type="datetime-local" defaultValue={f.to} className="h-8 text-[12.5px]" />
130+ </Field>
131+ <div className="flex items-end gap-2 sm:col-span-2 lg:col-span-4 xl:col-span-6">
132+ <Button type="submit" size="sm">
133+ Apply filters
134+ </Button>
135+ <span className="text-[12px] text-fg-subtle">Times are interpreted in the server timezone.</span>
136+ </div>
137+ </form>
138+ </Panel>
139+
140+ <Panel flush>
141+ {data.total === 0 ? (
142+ <div className="p-4">
143+ <EmptyState icon={ListTree} title="No requests match" description="Loosen a filter or widen the date range." compact />
144+ </div>
145+ ) : (
146+ <>
147+ <Table>
148+ <TableHeader>
149+ <TableRow>
150+ <TableHead>Request</TableHead>
151+ <TableHead>Org / project</TableHead>
152+ <TableHead>Domain</TableHead>
153+ <TableHead>Status</TableHead>
154+ <TableHead>Network</TableHead>
155+ <TableHead>Providers</TableHead>
156+ <TableHead>Geo</TableHead>
157+ <TableHead className="text-right">Att.</TableHead>
158+ <TableHead className="text-right">Latency</TableHead>
159+ <TableHead className="text-right">Bytes</TableHead>
160+ <TableHead className="text-right">Cost</TableHead>
161+ <TableHead className="text-right">Price</TableHead>
162+ <TableHead className="text-right">When</TableHead>
163+ </TableRow>
164+ </TableHeader>
165+ <TableBody>
166+ {data.rows.map((r) => (
167+ <TableRow key={r.id}>
168+ <TableCell>
169+ <Link href={`/admin/requests/${r.id}`} className="hover:underline">
170+ <Mono>{r.id}</Mono>
171+ </Link>
172+ <div className="text-[11.5px] text-fg-subtle">{r.source}</div>
173+ </TableCell>
174+ <TableCell className="max-w-[180px]">
175+ <Link href={`/admin/organizations/${r.organizationId}`} className="block truncate hover:underline">
176+ {r.orgName ?? r.organizationId}
177+ </Link>
178+ <div className="truncate text-[11.5px] text-fg-subtle">{r.projectName ?? r.projectId}</div>
179+ </TableCell>
180+ <TableCell className="max-w-[220px] truncate" title={r.domain}>
181+ <Link href={`/admin/domains/${encodeURIComponent(r.domain)}`} className="hover:underline">
182+ {r.domain}
183+ </Link>
184+ </TableCell>
185+ <TableCell>
186+ <div className="flex items-center gap-1.5">
187+ <StatusBadge status={r.status} />
188+ {r.httpStatus ? <Mono className="text-fg-muted">{r.httpStatus}</Mono> : null}
189+ </div>
190+ {r.errorCode ? <div className="font-mono text-[11px] text-danger">{r.errorCode}</div> : null}
191+ </TableCell>
192+ <TableCell>
193+ <NetworkBadge network={r.network} />
194+ {r.requestedNetwork !== r.network ? <div className="text-[11px] text-fg-subtle">asked {r.requestedNetwork}</div> : null}
195+ </TableCell>
196+ <TableCell>
197+ <div className="flex flex-wrap gap-1">
198+ {r.providers ? r.providers.split(",").map((p, i) => <ProviderBadge key={i} id={p} />) : <span className="text-fg-subtle">—</span>}
199+ </div>
200+ </TableCell>
201+ <TableCell>
202+ <Mono>{r.country ?? "—"}</Mono>
203+ </TableCell>
204+ <TableCell className={numCell}>{r.attempts}</TableCell>
205+ <TableCell className={numCell}>{formatMs(r.latencyMs)}</TableCell>
206+ <TableCell className={numCell}>{formatBytes(r.bytesIn)}</TableCell>
207+ <TableCell className={numCell}>{formatUsd(r.costUsd, true)}</TableCell>
208+ <TableCell className={numCell}>{formatUsd(r.priceUsd, true)}</TableCell>
209+ <TableCell className="text-right">
210+ <DateCell value={r.createdAt} />
211+ </TableCell>
212+ </TableRow>
213+ ))}
214+ </TableBody>
215+ </Table>
216+ <Pagination page={data.page} pages={data.pages} total={data.total} pageSize={data.pageSize} basePath="/admin/requests" params={params} />
217+ </>
218+ )}
219+ </Panel>
220+ {str(sp.page) && data.rows.length === 0 && data.total > 0 ? <p className="mt-2 text-[12px] text-fg-subtle">This page is empty — go back to page 1.</p> : null}
221+ </>
222+ );
223+}
added apps/web/src/app/admin/routing/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={0} rows={8} />;
5+}
added apps/web/src/app/admin/routing/page.tsx +136 −0
@@ -0,0 +1,136 @@
1+import Link from "next/link";
2+import { requireAdmin } from "@/lib/session";
3+import { getRoutingAnalytics, parseRange, rangeLabel } from "@/lib/queries/admin";
4+import { formatBytes, formatMs, formatNumber, formatPercent, formatUsd } from "@/lib/format";
5+import { PageHeader } from "@/components/ui/page-header";
6+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
7+import { NetworkBadge, Panel, ProviderBadge, RangeTabs, numCell } from "@/components/admin/primitives";
8+import { ProviderSeriesChart } from "@/components/admin/charts";
9+
10+export const dynamic = "force-dynamic";
11+
12+/** Mirrors SCORE_WEIGHTS in packages/routing/src/engine.ts (kept in sync by hand; web does not depend on @fetcha/routing). */
13+const WEIGHTS: Array<{ key: string; weight: number; what: string; how: string }> = [
14+ { key: "success", weight: 35, what: "Historical success on this domain × route", how: "Bayesian-smoothed toward the network prior (datacenter 70 %, isp 85 %, residential 93 %, mobile 95 %) with 5 pseudo-observations, so new routes are not punished." },
15+ { key: "cost", weight: 20, what: "Cost efficiency", how: "1 − price_per_gb / $15. Cheaper networks score higher; the ceiling is the most expensive class." },
16+ { key: "latency", weight: 15, what: "Observed latency", how: "300 ms → 1.0, 5 s → 0, linear. Uses route stats when present, else 500 ms (datacenter) / 1 200 ms (others)." },
17+ { key: "health", weight: 15, what: "Provider health", how: "Circuit-breaker health factor for the route key (recent failure window)." },
18+ { key: "geo", weight: 10, what: "Geography match", how: "1 when the provider supports the requested country (or no country requested), 0 otherwise." },
19+ { key: "session", weight: 5, what: "Session stability", how: "1 unless a sticky session is required and the provider cannot honour it (direct)." },
20+];
21+
22+export default async function AdminRoutingPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
23+ await requireAdmin();
24+ const sp = await searchParams;
25+ const range = parseRange(sp.range);
26+ const a = await getRoutingAnalytics(range);
27+ const totals = a.matrix.reduce((acc, r) => ({ requests: acc.requests + r.requests, successes: acc.successes + r.successes, cost: acc.cost + r.cost }), { requests: 0, successes: 0, cost: 0 });
28+
29+ return (
30+ <>
31+ <PageHeader eyebrow="Intelligence" title="Routing" description={`${rangeLabel(range)} · hourly routing_metrics aggregated by provider × network.`} actions={<RangeTabs current={range} basePath="/admin/routing" />} />
32+
33+ <Panel title="Provider × network" description={`${formatNumber(totals.requests)} routed attempts · ${formatPercent(totals.requests ? (totals.successes / totals.requests) * 100 : null)} success · ${formatUsd(totals.cost, true)} upstream`} flush>
34+ <Table>
35+ <TableHeader>
36+ <TableRow>
37+ <TableHead>Provider</TableHead>
38+ <TableHead>Network</TableHead>
39+ <TableHead className="text-right">Requests</TableHead>
40+ <TableHead className="text-right">Successes</TableHead>
41+ <TableHead className="text-right">Success</TableHead>
42+ <TableHead className="text-right">Blocked</TableHead>
43+ <TableHead className="text-right">Errors</TableHead>
44+ <TableHead className="text-right">Avg latency</TableHead>
45+ <TableHead className="text-right">GB</TableHead>
46+ <TableHead className="text-right">Cost</TableHead>
47+ <TableHead className="text-right">Cost / success</TableHead>
48+ </TableRow>
49+ </TableHeader>
50+ <TableBody>
51+ {a.matrix.length === 0 ? (
52+ <TableEmpty colSpan={11}>No routing metrics in this range. Metrics are written hourly by the API as attempts complete.</TableEmpty>
53+ ) : (
54+ a.matrix.map((r) => (
55+ <TableRow key={`${r.provider}:${r.network}`}>
56+ <TableCell>
57+ <ProviderBadge id={r.provider} />
58+ </TableCell>
59+ <TableCell>
60+ <NetworkBadge network={r.network} />
61+ </TableCell>
62+ <TableCell className={numCell}>{formatNumber(r.requests)}</TableCell>
63+ <TableCell className={numCell}>{formatNumber(r.successes)}</TableCell>
64+ <TableCell className={numCell}>{formatPercent(r.successRate)}</TableCell>
65+ <TableCell className={numCell}>{formatNumber(r.blocked)}</TableCell>
66+ <TableCell className={numCell}>{formatNumber(r.errors)}</TableCell>
67+ <TableCell className={numCell}>{formatMs(r.avgLatency)}</TableCell>
68+ <TableCell className={numCell}>{formatBytes(r.bytes)}</TableCell>
69+ <TableCell className={numCell}>{formatUsd(r.cost, true)}</TableCell>
70+ <TableCell className={`${numCell} font-semibold`}>{formatUsd(r.costPerSuccess, true)}</TableCell>
71+ </TableRow>
72+ ))
73+ )}
74+ </TableBody>
75+ </Table>
76+ </Panel>
77+
78+ <div className="mt-6 grid gap-6 xl:grid-cols-2">
79+ <Panel title="Requests over time per provider" bodyClassName="p-2 pt-3">
80+ <ProviderSeriesChart data={a.series} providers={a.providers} unit={a.unit as "hour" | "day"} metric="requests" />
81+ </Panel>
82+ <Panel title="Upstream cost over time per provider" bodyClassName="p-2 pt-3">
83+ <ProviderSeriesChart data={a.series} providers={a.providers} unit={a.unit as "hour" | "day"} metric="cost" />
84+ </Panel>
85+ </div>
86+
87+ <Panel title="How the routing score works" description="Each candidate route (provider × network) gets a weighted score in [0, 1]. Weights mirror packages/routing/src/engine.ts." flush className="mt-6">
88+ <Table>
89+ <TableHeader>
90+ <TableRow>
91+ <TableHead>Factor</TableHead>
92+ <TableHead className="text-right">Weight</TableHead>
93+ <TableHead>Measures</TableHead>
94+ <TableHead>Computation</TableHead>
95+ </TableRow>
96+ </TableHeader>
97+ <TableBody>
98+ {WEIGHTS.map((w) => (
99+ <TableRow key={w.key}>
100+ <TableCell className="font-mono text-[12.5px]">{w.key}</TableCell>
101+ <TableCell className={numCell}>{w.weight} %</TableCell>
102+ <TableCell>{w.what}</TableCell>
103+ <TableCell className="max-w-[520px] text-[12.5px] text-fg-muted">{w.how}</TableCell>
104+ </TableRow>
105+ ))}
106+ </TableBody>
107+ </Table>
108+ <div className="grid gap-4 border-t border-border p-4 text-[13px] leading-relaxed text-fg-muted lg:grid-cols-3">
109+ <div>
110+ <h3 className="mb-1 font-semibold text-fg">Auto escalation</h3>
111+ <p>
112+ For <code className="font-mono text-[12px]">network: &quot;auto&quot;</code> candidates are grouped by class in the order datacenter → isp → residential → mobile (limited to the plan&apos;s networks), then sorted by score inside each class. A class is skipped
113+ entirely for a domain once its route stats show ≥ 5 attempts with &gt; 60 % blocks (residential is never skipped). Providers are de-duplicated so retries alternate upstreams before repeating one.
114+ </p>
115+ </div>
116+ <div>
117+ <h3 className="mb-1 font-semibold text-fg">Domain profiles</h3>
118+ <p>
119+ <Link href="/admin/domains" className="text-accent hover:underline">
120+ domain_profiles.route_stats
121+ </Link>{" "}
122+ feed the success and latency factors per route key. The admin <em>policy</em> can pin an explicit route order (wins over score, still bounded by circuit state) or force a network for auto requests. Explicit customer network choices are never overridden.
123+ </p>
124+ </div>
125+ <div>
126+ <h3 className="mb-1 font-semibold text-fg">Circuit breakers</h3>
127+ <p>
128+ Each route key has a breaker. An open circuit removes the route from the candidate list entirely; a half-open one lets a trickle through. The breaker&apos;s recent failure ratio is also the 15 % health factor, so a degrading route loses score before it is cut off.
129+ Reset circuits from <Link href="/admin/providers" className="text-accent hover:underline">Providers</Link>.
130+ </p>
131+ </div>
132+ </div>
133+ </Panel>
134+ </>
135+ );
136+}
added apps/web/src/app/admin/system/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={0} rows={8} />;
5+}
added apps/web/src/app/admin/system/page.tsx +233 −0
@@ -0,0 +1,233 @@
1+import Link from "next/link";
2+import { requireAdmin } from "@/lib/session";
3+import { internalApi, InternalApiError } from "@/lib/api";
4+import { getEnvPresence, getFeatureFlags, getIncidents, getTableCounts } from "@/lib/queries/admin";
5+import { formatNumber, titleCase } from "@/lib/format";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { Alert } from "@/components/ui/alert";
8+import { Badge, StatusBadge } from "@/components/ui/badge";
9+import { Button } from "@/components/ui/button";
10+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
11+import { DateCell, KV, Mono, Panel, numCell } from "@/components/admin/primitives";
12+import { FlagSwitch } from "@/components/admin/flag-controls";
13+import { NewIncidentDialog, ResolveIncidentButton } from "@/components/admin/incident-controls";
14+
15+export const dynamic = "force-dynamic";
16+
17+async function safe<T>(fn: () => Promise<T>): Promise<{ ok: true; value: T } | { ok: false; error: string }> {
18+ try {
19+ return { ok: true, value: await fn() };
20+ } catch (e) {
21+ return { ok: false, error: e instanceof InternalApiError ? `${e.code}: ${e.message}` : (e as Error).message };
22+ }
23+}
24+
25+export default async function AdminSystemPage() {
26+ await requireAdmin();
27+ const [health, ready, dbStats, flags, incidents] = await Promise.all([safe(() => internalApi.health()), safe(() => internalApi.ready()), safe(getTableCounts), getFeatureFlags(), getIncidents(50)]);
28+ const env = getEnvPresence();
29+ const groups = Array.from(new Set(env.map((e) => e.group)));
30+ const openIncidents = incidents.filter((i) => !i.resolvedAt).length;
31+
32+ return (
33+ <>
34+ <PageHeader eyebrow="Operations" title="System" description="Service health, database footprint, environment presence, feature flags and public status incidents." actions={<NewIncidentDialog />} />
35+
36+ <div className="grid gap-6 lg:grid-cols-2">
37+ <Panel title="API service" description="GET /health and /ready on the Fastify service, called server-side with the internal token.">
38+ {health.ok ? (
39+ <KV
40+ cols={2}
41+ items={[
42+ { label: "Health", value: <StatusBadge status={health.value.status === "ok" ? "healthy" : health.value.status} /> },
43+ { label: "Version", value: health.value.version, mono: true },
44+ { label: "Readiness", value: ready.ok ? <StatusBadge status={ready.value.status === "ok" || ready.value.status === "ready" ? "healthy" : ready.value.status} /> : <StatusBadge status="down" /> },
45+ { label: "Available networks", value: ready.ok && ready.value.available_networks.length ? ready.value.available_networks.join(", ") : "none" },
46+ {
47+ label: "Readiness checks",
48+ span: true,
49+ value: ready.ok ? (
50+ Object.keys(ready.value.checks).length ? (
51+ <ul className="flex flex-wrap gap-1.5">
52+ {Object.entries(ready.value.checks).map(([k, v]) => (
53+ <li key={k}>
54+ <Badge variant={v ? "success" : "danger"} dot>
55+ {k}
56+ </Badge>
57+ </li>
58+ ))}
59+ </ul>
60+ ) : (
61+ <span className="text-fg-muted">no check details (degraded)</span>
62+ )
63+ ) : (
64+ <span className="text-danger">{ready.error}</span>
65+ ),
66+ },
67+ ]}
68+ />
69+ ) : (
70+ <Alert variant="danger" title="API unreachable">
71+ {health.error}
72+ </Alert>
73+ )}
74+ </Panel>
75+
76+ <Panel title="Database" description={dbStats.ok ? `${dbStats.value.pgVersion} · ${dbStats.value.dbSize}` : undefined} flush>
77+ {dbStats.ok ? (
78+ <Table>
79+ <TableHeader>
80+ <TableRow>
81+ <TableHead>Table</TableHead>
82+ <TableHead className="text-right">Rows</TableHead>
83+ </TableRow>
84+ </TableHeader>
85+ <TableBody>
86+ {Object.entries(dbStats.value.counts).map(([t, n]) => (
87+ <TableRow key={t}>
88+ <TableCell>
89+ <Mono>{t}</Mono>
90+ </TableCell>
91+ <TableCell className={numCell}>{formatNumber(n)}</TableCell>
92+ </TableRow>
93+ ))}
94+ </TableBody>
95+ </Table>
96+ ) : (
97+ <div className="p-4">
98+ <Alert variant="danger" title="Database query failed">
99+ {dbStats.error}
100+ </Alert>
101+ </div>
102+ )}
103+ </Panel>
104+ </div>
105+
106+ <Panel title="Environment presence" description="Whether each variable is set in the web process. Values are never read or displayed. The API service has its own environment — provider credentials must be set there too." className="mt-6">
107+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
108+ {groups.map((g) => (
109+ <div key={g}>
110+ <h3 className="mb-1.5 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">{g}</h3>
111+ <ul className="space-y-1">
112+ {env
113+ .filter((e) => e.group === g)
114+ .map((e) => (
115+ <li key={e.key} className="flex items-center justify-between gap-2 text-[12.5px]">
116+ <Mono>{e.key}</Mono>
117+ {e.present ? <Badge variant="success" dot>set</Badge> : <Badge variant="outline">missing</Badge>}
118+ </li>
119+ ))}
120+ </ul>
121+ </div>
122+ ))}
123+ </div>
124+ </Panel>
125+
126+ <div className="mt-6 grid gap-6 xl:grid-cols-2">
127+ <Panel title="Feature flags" right={<Button asChild variant="ghost" size="xs"><Link href="/admin/flags">Manage →</Link></Button>} flush>
128+ <Table>
129+ <TableHeader>
130+ <TableRow>
131+ <TableHead>Key</TableHead>
132+ <TableHead>Description</TableHead>
133+ <TableHead>Targeting</TableHead>
134+ <TableHead>Enabled</TableHead>
135+ </TableRow>
136+ </TableHeader>
137+ <TableBody>
138+ {flags.length === 0 ? (
139+ <TableEmpty colSpan={4}>
140+ No flags. <Link href="/admin/flags" className="text-accent hover:underline">Create one</Link>.
141+ </TableEmpty>
142+ ) : (
143+ flags.map((f) => (
144+ <TableRow key={f.key}>
145+ <TableCell>
146+ <Mono>{f.key}</Mono>
147+ </TableCell>
148+ <TableCell className="max-w-[260px] truncate text-fg-muted">{f.description ?? "—"}</TableCell>
149+ <TableCell className="text-[12px] text-fg-muted">
150+ {f.plans?.length ? `plans: ${f.plans.join(", ")}` : null}
151+ {f.plans?.length && f.organizationIds?.length ? " · " : null}
152+ {f.organizationIds?.length ? `${f.organizationIds.length} org(s)` : null}
153+ {!f.plans?.length && !f.organizationIds?.length ? "everyone" : null}
154+ </TableCell>
155+ <TableCell>
156+ <FlagSwitch flagKey={f.key} enabled={f.enabled} />
157+ </TableCell>
158+ </TableRow>
159+ ))
160+ )}
161+ </TableBody>
162+ </Table>
163+ </Panel>
164+
165+ <Panel
166+ title={
167+ <span className="flex items-center gap-2">
168+ Status incidents {openIncidents ? <Badge variant="danger" dot>{openIncidents} open</Badge> : <Badge variant="success" dot>all resolved</Badge>}
169+ </span>
170+ }
171+ description="Shown on the public /status page. Write for customers; never name upstream providers."
172+ flush
173+ >
174+ <Table>
175+ <TableHeader>
176+ <TableRow>
177+ <TableHead>Started</TableHead>
178+ <TableHead>Component</TableHead>
179+ <TableHead>Severity</TableHead>
180+ <TableHead>Title</TableHead>
181+ <TableHead>Status</TableHead>
182+ <TableHead></TableHead>
183+ </TableRow>
184+ </TableHeader>
185+ <TableBody>
186+ {incidents.length === 0 ? (
187+ <TableEmpty colSpan={6}>No incidents recorded. The status page shows all systems operational.</TableEmpty>
188+ ) : (
189+ incidents.map((i) => (
190+ <TableRow key={i.id} className={i.resolvedAt ? "opacity-70" : undefined}>
191+ <TableCell>
192+ <DateCell value={i.startedAt} relative={false} />
193+ </TableCell>
194+ <TableCell>
195+ <Badge variant="outline">{titleCase(i.component)}</Badge>
196+ </TableCell>
197+ <TableCell>
198+ <Badge variant={i.severity === "critical" || i.severity === "major" ? "danger" : i.severity === "maintenance" ? "info" : "warning"}>{i.severity}</Badge>
199+ </TableCell>
200+ <TableCell className="max-w-[280px]">
201+ <div className="truncate font-medium" title={i.title}>
202+ {i.title}
203+ </div>
204+ {i.body ? (
205+ <div className="truncate text-[12px] text-fg-muted" title={i.body}>
206+ {i.body}
207+ </div>
208+ ) : null}
209+ </TableCell>
210+ <TableCell>
211+ {i.resolvedAt ? (
212+ <span className="text-[12px] text-fg-muted">
213+ resolved <DateCell value={i.resolvedAt} />
214+ </span>
215+ ) : (
216+ <Badge variant="danger" dot>
217+ open
218+ </Badge>
219+ )}
220+ </TableCell>
221+ <TableCell className="text-right">
222+ <ResolveIncidentButton id={i.id} resolved={Boolean(i.resolvedAt)} />
223+ </TableCell>
224+ </TableRow>
225+ ))
226+ )}
227+ </TableBody>
228+ </Table>
229+ </Panel>
230+ </div>
231+ </>
232+ );
233+}
added apps/web/src/app/admin/usage/export/route.ts +39 −0
@@ -0,0 +1,39 @@
1+import { NextResponse, type NextRequest } from "next/server";
2+import { getUser, isAdmin } from "@/lib/session";
3+import { getEconomics, parseRange } from "@/lib/queries/admin";
4+import { writeAudit } from "@/lib/audit";
5+
6+export const dynamic = "force-dynamic";
7+
8+function csvCell(v: unknown): string {
9+ const s = v === null || v === undefined ? "" : String(v);
10+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
11+}
12+
13+/** CSV export of unit economics (admin only). Returns 403 for non-admins rather than redirecting. */
14+export async function GET(req: NextRequest) {
15+ const user = await getUser();
16+ if (!user || !isAdmin(user)) return NextResponse.json({ error: { code: "FORBIDDEN", message: "Admin only." } }, { status: 403 });
17+ const range = parseRange(req.nextUrl.searchParams.get("range") ?? undefined);
18+ const e = await getEconomics(range);
19+ const lines: string[] = ["section,key,label,requests,successes,revenue_usd,upstream_cost_usd,margin_usd,margin_pct,bytes"];
20+ const push = (section: string, key: string, label: string, r: { requests: number; successes: number; revenue: number; cost: number; margin: number; marginPct: number | null; bytes: number }) =>
21+ lines.push([section, key, label, r.requests, r.successes, r.revenue.toFixed(6), r.cost.toFixed(6), r.margin.toFixed(6), r.marginPct === null ? "" : r.marginPct.toFixed(2), r.bytes].map(csvCell).join(","));
22+ for (const r of e.byBucket) push(e.unit, r.key, r.key, r);
23+ for (const r of e.byPlan) push("plan", r.key, r.key, r);
24+ for (const r of e.byNetwork) push("network", r.key, r.key, r);
25+ for (const r of e.topCustomers) push("customer", r.key, r.name, r);
26+ for (const p of e.byProvider) {
27+ const margin = p.attributedRevenue - p.cost;
28+ push("provider", p.provider, p.provider, { requests: p.attempts, successes: p.successes, revenue: p.attributedRevenue, cost: p.cost, margin, marginPct: p.attributedRevenue > 0 ? (margin / p.attributedRevenue) * 100 : null, bytes: p.bytes });
29+ }
30+ await writeAudit({ userId: user.id, action: "admin.action", target: "usage.export", metadata: { type: "usage.export", range } });
31+ const stamp = new Date().toISOString().slice(0, 10);
32+ return new NextResponse(lines.join("\n") + "\n", {
33+ headers: {
34+ "content-type": "text/csv; charset=utf-8",
35+ "content-disposition": `attachment; filename="fetcha-unit-economics-${range}-${stamp}.csv"`,
36+ "cache-control": "no-store",
37+ },
38+ });
39+}
added apps/web/src/app/admin/usage/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={0} rows={8} />;
5+}
added apps/web/src/app/admin/usage/page.tsx +179 −0
@@ -0,0 +1,179 @@
1+import Link from "next/link";
2+import { Download } from "lucide-react";
3+import { requireAdmin } from "@/lib/session";
4+import { getEconomics, parseRange, rangeLabel } from "@/lib/queries/admin";
5+import { formatBytes, formatNumber, formatPercent, formatUsd } from "@/lib/format";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { Button } from "@/components/ui/button";
8+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
9+import { MarginText, NetworkBadge, Panel, PlanBadge, ProviderBadge, RangeTabs, numCell } from "@/components/admin/primitives";
10+import { EconomicsChart } from "@/components/admin/charts";
11+import type { EconRow } from "@/lib/queries/admin";
12+
13+export const dynamic = "force-dynamic";
14+
15+function EconTable({ rows, labelHead, renderLabel }: { rows: EconRow[]; labelHead: string; renderLabel: (r: EconRow) => React.ReactNode }) {
16+ return (
17+ <Table>
18+ <TableHeader>
19+ <TableRow>
20+ <TableHead>{labelHead}</TableHead>
21+ <TableHead className="text-right">Requests</TableHead>
22+ <TableHead className="text-right">Success</TableHead>
23+ <TableHead className="text-right">Revenue</TableHead>
24+ <TableHead className="text-right">Upstream</TableHead>
25+ <TableHead className="text-right">Margin</TableHead>
26+ <TableHead className="text-right">GB</TableHead>
27+ </TableRow>
28+ </TableHeader>
29+ <TableBody>
30+ {rows.length === 0 ? (
31+ <TableEmpty colSpan={7}>No data in this range.</TableEmpty>
32+ ) : (
33+ rows.map((r) => (
34+ <TableRow key={r.key}>
35+ <TableCell>{renderLabel(r)}</TableCell>
36+ <TableCell className={numCell}>{formatNumber(r.requests)}</TableCell>
37+ <TableCell className={numCell}>{formatPercent(r.requests ? (r.successes / r.requests) * 100 : null)}</TableCell>
38+ <TableCell className={numCell}>{formatUsd(r.revenue, true)}</TableCell>
39+ <TableCell className={numCell}>{formatUsd(r.cost, true)}</TableCell>
40+ <TableCell className="text-right">
41+ <MarginText value={r.margin} pct={r.marginPct} />
42+ </TableCell>
43+ <TableCell className={numCell}>{formatBytes(r.bytes)}</TableCell>
44+ </TableRow>
45+ ))
46+ )}
47+ </TableBody>
48+ </Table>
49+ );
50+}
51+
52+export default async function AdminUsagePage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
53+ await requireAdmin();
54+ const sp = await searchParams;
55+ const range = parseRange(sp.range);
56+ const e = await getEconomics(range);
57+ const totals = e.byBucket.reduce((acc, r) => ({ revenue: acc.revenue + r.revenue, cost: acc.cost + r.cost, requests: acc.requests + r.requests }), { revenue: 0, cost: 0, requests: 0 });
58+
59+ return (
60+ <>
61+ <PageHeader
62+ eyebrow="Unit economics"
63+ title="Usage"
64+ description={`${rangeLabel(range)} · revenue is what customers are billed (price_usd); upstream cost is what providers charge us (cost_usd).`}
65+ actions={
66+ <>
67+ <RangeTabs current={range} basePath="/admin/usage" />
68+ <Button asChild variant="outline" size="sm">
69+ <Link href={`/admin/usage/export?range=${range}`} prefetch={false}>
70+ <Download className="size-3.5" /> Export CSV
71+ </Link>
72+ </Button>
73+ </>
74+ }
75+ />
76+
77+ <Panel title={`Revenue vs upstream cost by ${e.unit}`} description={`Total: ${formatUsd(totals.revenue)} revenue · ${formatUsd(totals.cost)} cost · ${formatNumber(totals.requests)} requests`} bodyClassName="p-2 pt-3">
78+ <EconomicsChart data={e.byBucket} unit={e.unit as "hour" | "day"} />
79+ </Panel>
80+
81+ <Panel title={`By ${e.unit}`} flush className="mt-6">
82+ <EconTable rows={e.byBucket} labelHead={e.unit === "hour" ? "Hour" : "Day"} renderLabel={(r) => <span className="font-mono text-[12.5px]">{e.unit === "hour" ? new Date(r.key).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : new Date(r.key).toLocaleDateString(undefined, { month: "short", day: "numeric" })}</span>} />
83+ </Panel>
84+
85+ <div className="mt-6 grid gap-6 xl:grid-cols-2">
86+ <Panel title="By plan" flush>
87+ <EconTable rows={e.byPlan} labelHead="Plan" renderLabel={(r) => <PlanBadge plan={r.key} />} />
88+ </Panel>
89+ <Panel title="By network" flush>
90+ <EconTable rows={e.byNetwork} labelHead="Network" renderLabel={(r) => <NetworkBadge network={r.key} />} />
91+ </Panel>
92+ </div>
93+
94+ <Panel title="By provider" description="Attempt-level. Attributed revenue = price of requests whose successful attempt ran on this provider." flush className="mt-6">
95+ <Table>
96+ <TableHeader>
97+ <TableRow>
98+ <TableHead>Provider</TableHead>
99+ <TableHead className="text-right">Attempts</TableHead>
100+ <TableHead className="text-right">Successes</TableHead>
101+ <TableHead className="text-right">Success</TableHead>
102+ <TableHead className="text-right">Upstream cost</TableHead>
103+ <TableHead className="text-right">Attributed revenue</TableHead>
104+ <TableHead className="text-right">Margin</TableHead>
105+ <TableHead className="text-right">Cost / success</TableHead>
106+ <TableHead className="text-right">GB</TableHead>
107+ </TableRow>
108+ </TableHeader>
109+ <TableBody>
110+ {e.byProvider.length === 0 ? (
111+ <TableEmpty colSpan={9}>No attempts in this range.</TableEmpty>
112+ ) : (
113+ e.byProvider.map((p) => (
114+ <TableRow key={p.provider}>
115+ <TableCell>
116+ <ProviderBadge id={p.provider} />
117+ </TableCell>
118+ <TableCell className={numCell}>{formatNumber(p.attempts)}</TableCell>
119+ <TableCell className={numCell}>{formatNumber(p.successes)}</TableCell>
120+ <TableCell className={numCell}>{formatPercent(p.attempts ? (p.successes / p.attempts) * 100 : null)}</TableCell>
121+ <TableCell className={numCell}>{formatUsd(p.cost, true)}</TableCell>
122+ <TableCell className={numCell}>{formatUsd(p.attributedRevenue, true)}</TableCell>
123+ <TableCell className="text-right">
124+ <MarginText value={p.attributedRevenue - p.cost} pct={p.attributedRevenue > 0 ? ((p.attributedRevenue - p.cost) / p.attributedRevenue) * 100 : null} />
125+ </TableCell>
126+ <TableCell className={`${numCell} font-semibold`}>{formatUsd(p.costPerSuccess, true)}</TableCell>
127+ <TableCell className={numCell}>{formatBytes(p.bytes)}</TableCell>
128+ </TableRow>
129+ ))
130+ )}
131+ </TableBody>
132+ </Table>
133+ </Panel>
134+
135+ <Panel title="Top 20 customers" description="Ordered by revenue in the selected range." flush className="mt-6">
136+ <Table>
137+ <TableHeader>
138+ <TableRow>
139+ <TableHead>Organization</TableHead>
140+ <TableHead>Plan</TableHead>
141+ <TableHead className="text-right">Requests</TableHead>
142+ <TableHead className="text-right">Success</TableHead>
143+ <TableHead className="text-right">Revenue</TableHead>
144+ <TableHead className="text-right">Upstream</TableHead>
145+ <TableHead className="text-right">Margin</TableHead>
146+ <TableHead className="text-right">GB</TableHead>
147+ </TableRow>
148+ </TableHeader>
149+ <TableBody>
150+ {e.topCustomers.length === 0 ? (
151+ <TableEmpty colSpan={8}>No customer activity in this range.</TableEmpty>
152+ ) : (
153+ e.topCustomers.map((c) => (
154+ <TableRow key={c.key}>
155+ <TableCell>
156+ <Link href={`/admin/organizations/${c.key}`} className="font-medium hover:underline">
157+ {c.name}
158+ </Link>
159+ </TableCell>
160+ <TableCell>
161+ <PlanBadge plan={c.plan} />
162+ </TableCell>
163+ <TableCell className={numCell}>{formatNumber(c.requests)}</TableCell>
164+ <TableCell className={numCell}>{formatPercent(c.requests ? (c.successes / c.requests) * 100 : null)}</TableCell>
165+ <TableCell className={numCell}>{formatUsd(c.revenue, true)}</TableCell>
166+ <TableCell className={numCell}>{formatUsd(c.cost, true)}</TableCell>
167+ <TableCell className="text-right">
168+ <MarginText value={c.margin} pct={c.marginPct} />
169+ </TableCell>
170+ <TableCell className={numCell}>{formatBytes(c.bytes)}</TableCell>
171+ </TableRow>
172+ ))
173+ )}
174+ </TableBody>
175+ </Table>
176+ </Panel>
177+ </>
178+ );
179+}
added apps/web/src/app/admin/users/[id]/page.tsx +195 −0
@@ -0,0 +1,195 @@
1+import Link from "next/link";
2+import { notFound } from "next/navigation";
3+import { ArrowLeft } from "lucide-react";
4+import { requireAdmin } from "@/lib/session";
5+import { getUserDetail } from "@/lib/queries/admin";
6+import { formatDate, formatNumber, truncate } from "@/lib/format";
7+import { PageHeader } from "@/components/ui/page-header";
8+import { Badge, StatusBadge } from "@/components/ui/badge";
9+import { Button } from "@/components/ui/button";
10+import { Stat, StatGrid } from "@/components/ui/stat";
11+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
12+import { Alert } from "@/components/ui/alert";
13+import { CopyButton } from "@/components/ui/copy-button";
14+import { DateCell, KV, Mono, Panel, PlanBadge } from "@/components/admin/primitives";
15+import { UserActions } from "@/components/admin/user-actions";
16+
17+export const dynamic = "force-dynamic";
18+
19+export default async function AdminUserDetail({ params }: { params: Promise<{ id: string }> }) {
20+ const admin = await requireAdmin();
21+ const { id } = await params;
22+ const d = await getUserDetail(id);
23+ if (!d) notFound();
24+ const { user } = d;
25+
26+ return (
27+ <>
28+ <div className="mb-4">
29+ <Button asChild variant="ghost" size="sm" className="-ml-2">
30+ <Link href="/admin/users">
31+ <ArrowLeft className="size-3.5" /> Users
32+ </Link>
33+ </Button>
34+ </div>
35+ <PageHeader
36+ eyebrow="User"
37+ title={
38+ <span className="flex flex-wrap items-center gap-2">
39+ {user.email}
40+ {user.role === "admin" ? <Badge variant="danger">admin</Badge> : null}
41+ {user.banned ? <Badge variant="danger" dot>banned</Badge> : null}
42+ {user.emailVerified ? <Badge variant="success" dot>verified</Badge> : <Badge variant="outline">unverified</Badge>}
43+ </span>
44+ }
45+ description={
46+ <span className="inline-flex items-center gap-1">
47+ <Mono>{user.id}</Mono>
48+ <CopyButton value={user.id} size="icon-sm" className="size-6" />
49+ </span>
50+ }
51+ actions={<UserActions userId={user.id} banned={user.banned} role={user.role} emailVerified={user.emailVerified} isSelf={user.id === admin.id} />}
52+ />
53+
54+ {user.banned ? (
55+ <Alert variant="danger" title="This account is banned" className="mb-4">
56+ Reason: {user.banReason || "not recorded"}
57+ </Alert>
58+ ) : null}
59+
60+ <StatGrid cols={4} className="mb-6">
61+ <Stat label="Organizations" value={formatNumber(d.memberships.length)} />
62+ <Stat label="Projects" value={formatNumber(d.projectsCount)} />
63+ <Stat label="API keys" value={formatNumber(d.keysCount)} hint="incl. revoked" />
64+ <Stat label="Requests (30d)" value={formatNumber(d.requests30d)} hint="across their organizations" />
65+ </StatGrid>
66+
67+ <div className="grid gap-6 lg:grid-cols-2">
68+ <Panel title="Profile">
69+ <KV
70+ items={[
71+ { label: "Name", value: user.name || "—" },
72+ { label: "Email", value: user.email },
73+ { label: "Role", value: user.role },
74+ { label: "Email verified", value: user.emailVerified ? "yes" : "no" },
75+ { label: "Onboarding completed", value: user.onboardingCompletedAt ? formatDate(user.onboardingCompletedAt) : "not yet" },
76+ { label: "Created", value: formatDate(user.createdAt) },
77+ { label: "Updated", value: formatDate(user.updatedAt) },
78+ { label: "Image", value: user.image ? truncate(user.image, 40) : "—", mono: true },
79+ ]}
80+ />
81+ </Panel>
82+ <Panel title="Organizations" flush>
83+ <Table>
84+ <TableHeader>
85+ <TableRow>
86+ <TableHead>Organization</TableHead>
87+ <TableHead>Plan</TableHead>
88+ <TableHead>Role</TableHead>
89+ <TableHead>Status</TableHead>
90+ <TableHead className="text-right">Joined</TableHead>
91+ </TableRow>
92+ </TableHeader>
93+ <TableBody>
94+ {d.memberships.length === 0 ? (
95+ <TableEmpty colSpan={5}>No memberships.</TableEmpty>
96+ ) : (
97+ d.memberships.map((m) => (
98+ <TableRow key={m.organizationId}>
99+ <TableCell>
100+ <Link href={`/admin/organizations/${m.organizationId}`} className="font-medium hover:underline">
101+ {m.name}
102+ </Link>
103+ </TableCell>
104+ <TableCell>
105+ <PlanBadge plan={m.plan} />
106+ </TableCell>
107+ <TableCell className="text-fg-muted">{m.role}</TableCell>
108+ <TableCell>{m.suspended ? <StatusBadge status="suspended" /> : <StatusBadge status="active" />}</TableCell>
109+ <TableCell className="text-right">
110+ <DateCell value={m.joinedAt} />
111+ </TableCell>
112+ </TableRow>
113+ ))
114+ )}
115+ </TableBody>
116+ </Table>
117+ </Panel>
118+ </div>
119+
120+ <Panel title="Sessions" description="Most recent 10 login sessions." flush className="mt-6">
121+ <Table>
122+ <TableHeader>
123+ <TableRow>
124+ <TableHead>Created</TableHead>
125+ <TableHead>Expires</TableHead>
126+ <TableHead>IP</TableHead>
127+ <TableHead>User agent</TableHead>
128+ </TableRow>
129+ </TableHeader>
130+ <TableBody>
131+ {d.sessions.length === 0 ? (
132+ <TableEmpty colSpan={4}>Never logged in.</TableEmpty>
133+ ) : (
134+ d.sessions.map((s) => (
135+ <TableRow key={s.id}>
136+ <TableCell>
137+ <DateCell value={s.createdAt} relative={false} />
138+ </TableCell>
139+ <TableCell>
140+ <DateCell value={s.expiresAt} />
141+ </TableCell>
142+ <TableCell>
143+ <Mono>{s.ipAddress ?? "—"}</Mono>
144+ </TableCell>
145+ <TableCell className="max-w-[420px] truncate text-[12.5px] text-fg-muted" title={s.userAgent ?? undefined}>
146+ {s.userAgent ?? "—"}
147+ </TableCell>
148+ </TableRow>
149+ ))
150+ )}
151+ </TableBody>
152+ </Table>
153+ </Panel>
154+
155+ <Panel title="Audit trail" description="Last 50 audit entries where this user is the actor." flush className="mt-6">
156+ <Table>
157+ <TableHeader>
158+ <TableRow>
159+ <TableHead>When</TableHead>
160+ <TableHead>Action</TableHead>
161+ <TableHead>Target</TableHead>
162+ <TableHead>Metadata</TableHead>
163+ <TableHead>IP</TableHead>
164+ </TableRow>
165+ </TableHeader>
166+ <TableBody>
167+ {d.audit.length === 0 ? (
168+ <TableEmpty colSpan={5}>No audit entries.</TableEmpty>
169+ ) : (
170+ d.audit.map((a) => (
171+ <TableRow key={a.id}>
172+ <TableCell>
173+ <DateCell value={a.createdAt} relative={false} />
174+ </TableCell>
175+ <TableCell>
176+ <Badge variant={a.action === "admin.action" ? "danger" : "default"}>{a.action}</Badge>
177+ </TableCell>
178+ <TableCell>
179+ <Mono>{a.target ?? "—"}</Mono>
180+ </TableCell>
181+ <TableCell className="max-w-[360px] truncate font-mono text-[12px] text-fg-muted" title={a.metadata ? JSON.stringify(a.metadata) : undefined}>
182+ {a.metadata ? JSON.stringify(a.metadata) : "—"}
183+ </TableCell>
184+ <TableCell>
185+ <Mono>{a.ipAddress ?? "—"}</Mono>
186+ </TableCell>
187+ </TableRow>
188+ ))
189+ )}
190+ </TableBody>
191+ </Table>
192+ </Panel>
193+ </>
194+ );
195+}
added apps/web/src/app/admin/users/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { AdminLoading } from "@/components/admin/primitives";
2+
3+export default function Loading() {
4+ return <AdminLoading stats={0} rows={10} />;
5+}
added apps/web/src/app/admin/users/page.tsx +73 −0
@@ -0,0 +1,73 @@
1+import Link from "next/link";
2+import { Users } from "lucide-react";
3+import { requireAdmin } from "@/lib/session";
4+import { listUsers, pageNum, str } from "@/lib/queries/admin";
5+import { formatNumber } from "@/lib/format";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { Badge } from "@/components/ui/badge";
8+import { EmptyState } from "@/components/ui/empty-state";
9+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
10+import { DateCell, Pagination, Panel, SearchForm, numCell } from "@/components/admin/primitives";
11+
12+export const dynamic = "force-dynamic";
13+
14+export default async function AdminUsersPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
15+ await requireAdmin();
16+ const sp = await searchParams;
17+ const q = str(sp.q);
18+ const page = pageNum(sp.page);
19+ const data = await listUsers({ q: q || undefined, page });
20+
21+ return (
22+ <>
23+ <PageHeader eyebrow="Accounts" title="Users" description={`${formatNumber(data.total)} accounts. Search by email, name or id.`} actions={<SearchForm action="/admin/users" q={q} placeholder="email, name or user id" />} />
24+ <Panel flush>
25+ {data.total === 0 ? (
26+ <div className="p-4">
27+ <EmptyState icon={Users} title={q ? "No users match" : "No users yet"} description={q ? "Try a partial email or a different spelling." : "Users appear here as soon as they sign up."} compact />
28+ </div>
29+ ) : (
30+ <>
31+ <Table>
32+ <TableHeader>
33+ <TableRow>
34+ <TableHead>Email</TableHead>
35+ <TableHead>Name</TableHead>
36+ <TableHead>Verified</TableHead>
37+ <TableHead>Role</TableHead>
38+ <TableHead>Banned</TableHead>
39+ <TableHead className="text-right">Orgs</TableHead>
40+ <TableHead className="text-right">Created</TableHead>
41+ <TableHead className="text-right">Last login</TableHead>
42+ </TableRow>
43+ </TableHeader>
44+ <TableBody>
45+ {data.rows.map((u) => (
46+ <TableRow key={u.id}>
47+ <TableCell>
48+ <Link href={`/admin/users/${u.id}`} className="font-medium hover:underline">
49+ {u.email}
50+ </Link>
51+ </TableCell>
52+ <TableCell className="max-w-[200px] truncate text-fg-muted">{u.name || "—"}</TableCell>
53+ <TableCell>{u.emailVerified ? <Badge variant="success" dot>verified</Badge> : <Badge variant="outline">unverified</Badge>}</TableCell>
54+ <TableCell>{u.role === "admin" ? <Badge variant="danger">admin</Badge> : <span className="text-fg-muted">user</span>}</TableCell>
55+ <TableCell>{u.banned ? <Badge variant="danger" dot>banned</Badge> : <span className="text-fg-subtle">—</span>}</TableCell>
56+ <TableCell className={numCell}>{u.orgs}</TableCell>
57+ <TableCell className="text-right">
58+ <DateCell value={u.createdAt} />
59+ </TableCell>
60+ <TableCell className="text-right">
61+ <DateCell value={u.lastLogin} />
62+ </TableCell>
63+ </TableRow>
64+ ))}
65+ </TableBody>
66+ </Table>
67+ <Pagination page={data.page} pages={data.pages} total={data.total} pageSize={data.pageSize} basePath="/admin/users" params={{ q }} />
68+ </>
69+ )}
70+ </Panel>
71+ </>
72+ );
73+}
added apps/web/src/app/api/auth/[...all]/route.ts +4 −0
@@ -0,0 +1,4 @@
1+import { auth } from "@/lib/auth";
2+import { toNextJsHandler } from "better-auth/next-js";
3+
4+export const { GET, POST } = toNextJsHandler(auth);
added apps/web/src/app/dashboard/analytics/loading.tsx +40 −0
@@ -0,0 +1,40 @@
1+import { Skeleton, TableSkeleton } from "@/components/ui/skeleton";
2+
3+export default function AnalyticsLoading() {
4+ return (
5+ <div className="flex flex-col gap-5" aria-busy="true" aria-label="Loading analytics">
6+ <div className="flex items-start justify-between gap-3">
7+ <div className="flex flex-col gap-2">
8+ <Skeleton className="h-7 w-32" />
9+ <Skeleton className="h-3.5 w-[420px] max-w-full" />
10+ </div>
11+ <Skeleton className="h-9 w-64" />
12+ </div>
13+ <div className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-5">
14+ {Array.from({ length: 5 }).map((_, i) => (
15+ <div key={i} className="flex flex-col gap-2 bg-bg-elevated px-5 py-4">
16+ <Skeleton className="h-3 w-20" />
17+ <Skeleton className="h-6 w-16" />
18+ </div>
19+ ))}
20+ </div>
21+ <div className="grid gap-4 lg:grid-cols-2">
22+ {Array.from({ length: 4 }).map((_, i) => (
23+ <div key={i} className="rounded-lg border border-border bg-bg-elevated p-5">
24+ <Skeleton className="h-4 w-40" />
25+ <Skeleton className="mt-1.5 h-3 w-64" />
26+ <Skeleton className="mt-5 h-[220px] w-full" />
27+ </div>
28+ ))}
29+ </div>
30+ <div className="grid gap-4 lg:grid-cols-5">
31+ <div className="rounded-lg border border-border bg-bg-elevated lg:col-span-3">
32+ <TableSkeleton rows={6} cols={5} />
33+ </div>
34+ <div className="rounded-lg border border-border bg-bg-elevated lg:col-span-2">
35+ <TableSkeleton rows={6} cols={4} />
36+ </div>
37+ </div>
38+ </div>
39+ );
40+}
added apps/web/src/app/dashboard/analytics/page.tsx +219 −0
@@ -0,0 +1,219 @@
1+import Link from "next/link";
2+import { BarChart3 } from "lucide-react";
3+import { COUNTRIES } from "@fetcha/core";
4+import { getWorkspace } from "@/lib/session";
5+import { formatMs, formatNumber, formatPercent } from "@/lib/format";
6+import { cn } from "@/lib/utils";
7+import { getCountries, getErrorBreakdown, getRequestSeries, getRequestStats, getSuccessByNetwork, getTopDomains, type Scope } from "@/lib/queries/dashboard";
8+import { PageHeader } from "@/components/ui/page-header";
9+import { Stat, StatGrid } from "@/components/ui/stat";
10+import { Card } from "@/components/ui/card";
11+import { Button } from "@/components/ui/button";
12+import { EmptyState } from "@/components/ui/empty-state";
13+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
14+import { ChartFrame } from "@/components/dashboard/charts/chart-frame";
15+import { RequestsBarChart } from "@/components/dashboard/charts/requests-bar-chart";
16+import { LatencyLineChart } from "@/components/dashboard/charts/latency-line-chart";
17+import { HorizontalBarChart } from "@/components/dashboard/charts/horizontal-bar-chart";
18+import { CHART_COLORS, NETWORK_COLORS } from "@/components/dashboard/charts/chart-theme";
19+
20+export const dynamic = "force-dynamic";
21+
22+const RANGES = [
23+ { key: "24h", label: "24 hours", hours: 24, bucket: "hour" as const },
24+ { key: "7d", label: "7 days", hours: 24 * 7, bucket: "day" as const },
25+ { key: "30d", label: "30 days", hours: 24 * 30, bucket: "day" as const },
26+];
27+
28+export default async function AnalyticsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
29+ const [ws, sp] = await Promise.all([getWorkspace(), searchParams]);
30+ const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id };
31+ const rangeKey = Array.isArray(sp.range) ? sp.range[0] : sp.range;
32+ const range = RANGES.find((r) => r.key === rangeKey) ?? RANGES[1]!;
33+ const now = new Date();
34+ const from = range.bucket === "hour" ? new Date(now.getTime() - range.hours * 3_600_000) : new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (range.hours / 24 - 1)));
35+
36+ const [stats, series, byNetwork, domains, errors, countries] = await Promise.all([
37+ getRequestStats(scope, from),
38+ getRequestSeries(scope, from, range.bucket),
39+ getSuccessByNetwork(scope, from),
40+ getTopDomains(scope, from, 10),
41+ getErrorBreakdown(scope, from, 8),
42+ getCountries(scope, from, 10),
43+ ]);
44+
45+ const hasData = stats.total > 0;
46+ const latencyHasData = series.some((p) => p.p50 !== null);
47+
48+ return (
49+ <div className="flex flex-col gap-5">
50+ <PageHeader
51+ title="Analytics"
52+ description={`Routing performance for ${ws.project.name}. Times are UTC; latency is measured end to end from Fetcha's edge.`}
53+ actions={
54+ <nav className="inline-flex h-9 items-center gap-0.5 rounded-md bg-bg-muted p-1" aria-label="Range">
55+ {RANGES.map((r) => (
56+ <Link
57+ key={r.key}
58+ href={r.key === "7d" ? "/dashboard/analytics" : `/dashboard/analytics?range=${r.key}`}
59+ className={cn("inline-flex h-7 items-center rounded-[5px] px-3 text-[12.5px] font-medium transition-colors", r.key === range.key ? "bg-bg text-fg shadow-xs" : "text-fg-muted hover:text-fg")}
60+ aria-current={r.key === range.key ? "page" : undefined}
61+ >
62+ {r.label}
63+ </Link>
64+ ))}
65+ </nav>
66+ }
67+ />
68+
69+ <StatGrid cols={5}>
70+ <Stat label="Requests" value={formatNumber(stats.total)} hint={`last ${range.label}`} />
71+ <Stat label="Success rate" value={stats.successRate === null ? "—" : formatPercent(stats.successRate)} hint={`${formatNumber(stats.failed)} failed`} />
72+ <Stat label="P50 latency" value={formatMs(stats.p50)} />
73+ <Stat label="P95 latency" value={formatMs(stats.p95)} />
74+ <Stat label="Attempts / request" value={stats.avgAttempts === null ? "—" : stats.avgAttempts.toFixed(2)} hint="1.00 = first route worked" />
75+ </StatGrid>
76+
77+ {!hasData ? (
78+ <EmptyState
79+ icon={BarChart3}
80+ title={`No requests in the last ${range.label}`}
81+ description="Analytics fill in as soon as requests flow. Try a wider range, or send a request from the Playground."
82+ action={
83+ <>
84+ <Button asChild size="sm" variant="primary">
85+ <Link href="/dashboard/playground">Open Playground</Link>
86+ </Button>
87+ {range.key !== "30d" ? (
88+ <Button asChild size="sm" variant="outline">
89+ <Link href="/dashboard/analytics?range=30d">Last 30 days</Link>
90+ </Button>
91+ ) : null}
92+ </>
93+ }
94+ />
95+ ) : null}
96+
97+ <div className="grid gap-4 lg:grid-cols-2">
98+ <ChartFrame
99+ title="Requests over time"
100+ description={range.bucket === "hour" ? "Per hour, successful vs failed." : "Per day, successful vs failed."}
101+ empty={!hasData}
102+ table={{ columns: [range.bucket === "hour" ? "Hour (UTC)" : "Day", "Successful", "Failed"], rows: series.filter((p) => p.success + p.failed > 0).map((p) => [range.bucket === "hour" ? p.t.slice(0, 16).replace("T", " ") : p.t.slice(0, 10), p.success, p.failed]) }}
103+ >
104+ <RequestsBarChart data={series} bucket={range.bucket} height={220} />
105+ </ChartFrame>
106+ <ChartFrame
107+ title="Latency percentiles"
108+ description="P50 and P95 per bucket. Gaps mean no requests in that bucket."
109+ empty={!latencyHasData}
110+ table={{ columns: [range.bucket === "hour" ? "Hour (UTC)" : "Day", "P50", "P95"], rows: series.filter((p) => p.p50 !== null).map((p) => [range.bucket === "hour" ? p.t.slice(0, 16).replace("T", " ") : p.t.slice(0, 10), formatMs(p.p50), formatMs(p.p95)]) }}
111+ >
112+ <LatencyLineChart data={series} bucket={range.bucket} height={220} />
113+ </ChartFrame>
114+ </div>
115+
116+ <div className="grid gap-4 lg:grid-cols-2">
117+ <ChartFrame
118+ title="Success rate by network class"
119+ description="Share of completed requests that succeeded, per resolved network. Only residential is live today."
120+ empty={!byNetwork.some((n) => n.requests > 0)}
121+ table={{ columns: ["Network", "Requests", "Success rate"], rows: byNetwork.map((n) => [n.network, n.requests, n.successRate === null ? "—" : formatPercent(n.successRate)]) }}
122+ >
123+ <HorizontalBarChart
124+ data={byNetwork.map((n) => ({ label: n.network === "isp" ? "ISP" : n.network[0]!.toUpperCase() + n.network.slice(1), value: n.successRate ?? 0, color: NETWORK_COLORS[n.network] ?? CHART_COLORS.accent, hint: `${formatNumber(n.requests)} requests` }))}
125+ max={100}
126+ format="percent"
127+ labelWidth={100}
128+ />
129+ </ChartFrame>
130+ <ChartFrame
131+ title="Failures by error code"
132+ description="Failed requests grouped by the error code returned to you."
133+ empty={errors.length === 0}
134+ emptyMessage={hasData ? "No failed requests in this period." : "No data for this period yet."}
135+ table={{ columns: ["Code", "Requests", "Share"], rows: errors.map((e) => [e.code, e.requests, formatPercent(e.percent, 1)]) }}
136+ >
137+ <HorizontalBarChart data={errors.map((e) => ({ label: e.code, value: e.requests, hint: `${formatPercent(e.percent, 1)} of failures` }))} color={CHART_COLORS.danger} format="number" labelWidth={168} />
138+ </ChartFrame>
139+ </div>
140+
141+ <div className="grid gap-4 lg:grid-cols-5">
142+ <Card className="overflow-hidden lg:col-span-3">
143+ <div className="border-b border-border px-5 py-3">
144+ <h3 className="text-[14px] font-semibold tracking-tight">Top domains</h3>
145+ <p className="text-[12.5px] text-fg-muted">Most requested targets and how they behaved.</p>
146+ </div>
147+ <Table>
148+ <TableHeader>
149+ <TableRow className="hover:bg-transparent">
150+ <TableHead>Domain</TableHead>
151+ <TableHead className="text-right">Requests</TableHead>
152+ <TableHead className="text-right">Success</TableHead>
153+ <TableHead className="text-right">Avg latency</TableHead>
154+ <TableHead className="text-right">Blocked</TableHead>
155+ </TableRow>
156+ </TableHeader>
157+ <TableBody>
158+ {domains.length === 0 ? (
159+ <TableEmpty colSpan={5}>No domains in this period.</TableEmpty>
160+ ) : (
161+ domains.map((d) => (
162+ <TableRow key={d.domain}>
163+ <TableCell className="max-w-[260px] truncate">
164+ <Link href={`/dashboard/requests?range=${range.key}&domain=${encodeURIComponent(d.domain)}`} className="underline-offset-4 hover:underline" title={d.domain}>
165+ {d.domain}
166+ </Link>
167+ </TableCell>
168+ <TableCell className="text-right font-mono tabular">{formatNumber(d.requests)}</TableCell>
169+ <TableCell className={cn("text-right font-mono tabular", d.successRate !== null && d.successRate < 80 && "text-warning")}>{d.successRate === null ? "—" : formatPercent(d.successRate, 1)}</TableCell>
170+ <TableCell className="text-right font-mono tabular">{formatMs(d.avgLatencyMs)}</TableCell>
171+ <TableCell className={cn("text-right font-mono tabular", d.blocked > 0 && "text-danger")}>{d.blocked || "—"}</TableCell>
172+ </TableRow>
173+ ))
174+ )}
175+ </TableBody>
176+ </Table>
177+ </Card>
178+ <Card className="overflow-hidden lg:col-span-2">
179+ <div className="border-b border-border px-5 py-3">
180+ <h3 className="text-[14px] font-semibold tracking-tight">Countries</h3>
181+ <p className="text-[12.5px] text-fg-muted">Requested exit country.</p>
182+ </div>
183+ <Table>
184+ <TableHeader>
185+ <TableRow className="hover:bg-transparent">
186+ <TableHead>Country</TableHead>
187+ <TableHead className="text-right">Requests</TableHead>
188+ <TableHead className="text-right">Success</TableHead>
189+ <TableHead className="text-right">Latency</TableHead>
190+ </TableRow>
191+ </TableHeader>
192+ <TableBody>
193+ {countries.length === 0 ? (
194+ <TableEmpty colSpan={4}>No requests in this period.</TableEmpty>
195+ ) : (
196+ countries.map((c) => (
197+ <TableRow key={c.country ?? "any"}>
198+ <TableCell>
199+ {c.country ? (
200+ <span>
201+ <span className="font-mono text-[12.5px] text-fg-subtle">{c.country}</span> {COUNTRIES[c.country] ?? ""}
202+ </span>
203+ ) : (
204+ <span className="text-fg-muted">Any (not specified)</span>
205+ )}
206+ </TableCell>
207+ <TableCell className="text-right font-mono tabular">{formatNumber(c.requests)}</TableCell>
208+ <TableCell className="text-right font-mono tabular">{c.successRate === null ? "—" : formatPercent(c.successRate, 1)}</TableCell>
209+ <TableCell className="text-right font-mono tabular">{formatMs(c.avgLatencyMs)}</TableCell>
210+ </TableRow>
211+ ))
212+ )}
213+ </TableBody>
214+ </Table>
215+ </Card>
216+ </div>
217+ </div>
218+ );
219+}
added apps/web/src/app/dashboard/api-keys/loading.tsx +19 −0
@@ -0,0 +1,19 @@
1+import { Skeleton, TableSkeleton } from "@/components/ui/skeleton";
2+
3+export default function Loading() {
4+ return (
5+ <div className="grid gap-6" aria-busy="true" aria-label="Loading API keys">
6+ <div className="flex items-start justify-between">
7+ <div className="grid gap-2">
8+ <Skeleton className="h-6 w-32" />
9+ <Skeleton className="h-3.5 w-80" />
10+ </div>
11+ <Skeleton className="h-9 w-36" />
12+ </div>
13+ <Skeleton className="h-9 w-64" />
14+ <div className="rounded-lg border border-border bg-bg-elevated">
15+ <TableSkeleton rows={5} cols={8} />
16+ </div>
17+ </div>
18+ );
19+}
added apps/web/src/app/dashboard/api-keys/page.tsx +92 −0
@@ -0,0 +1,92 @@
1+import Link from "next/link";
2+import { ShieldCheck } from "lucide-react";
3+import { getWorkspace } from "@/lib/session";
4+import { listApiKeys } from "@/lib/queries/account";
5+import { PageHeader } from "@/components/ui/page-header";
6+import { Alert } from "@/components/ui/alert";
7+import { cn } from "@/lib/utils";
8+import { KeysTable } from "@/components/dashboard/api-keys/keys-table";
9+import { CreateKeyDialog } from "@/components/dashboard/api-keys/create-key-dialog";
10+
11+export const dynamic = "force-dynamic";
12+
13+export const metadata = { title: "API keys · Fetcha" };
14+
15+export default async function ApiKeysPage({ searchParams }: { searchParams: Promise<{ project?: string; new?: string }> }) {
16+ const sp = await searchParams;
17+ const ws = await getWorkspace();
18+
19+ // ?project=all → every project of the org; ?project=<id> → that project; default → current project.
20+ const allProjects = sp.project === "all";
21+ const requested = !allProjects && sp.project ? ws.projects.find((p) => p.id === sp.project) : null;
22+ const scopeProject = allProjects ? null : requested ?? ws.project;
23+
24+ const keys = await listApiKeys(ws.organization.id, scopeProject?.id ?? null);
25+ const projectOptions = ws.projects.map((p) => ({ id: p.id, name: p.name, environment: p.environment }));
26+ const active = keys.filter((k) => k.status === "active").length;
27+
28+ return (
29+ <div className="grid gap-6">
30+ <PageHeader
31+ title="API keys"
32+ description="Authenticate requests with a Bearer token. Keys belong to a project and inherit its limits and defaults."
33+ actions={<CreateKeyDialog projects={projectOptions} defaultProjectId={scopeProject?.id ?? ws.project.id} defaultOpen={sp.new === "1"} />}
34+ />
35+
36+ <div className="flex flex-wrap items-center justify-between gap-3">
37+ <nav className="inline-flex h-9 items-center gap-0.5 rounded-md bg-bg-muted p-1 text-[13px] font-medium text-fg-muted" aria-label="Project filter">
38+ <FilterLink href={`/dashboard/api-keys?project=${ws.project.id}`} active={!allProjects && scopeProject?.id === ws.project.id}>
39+ {ws.project.name}
40+ <span className="ml-1 text-[11px] font-normal text-fg-subtle">current</span>
41+ </FilterLink>
42+ {requested && requested.id !== ws.project.id ? (
43+ <FilterLink href={`/dashboard/api-keys?project=${requested.id}`} active>
44+ {requested.name}
45+ </FilterLink>
46+ ) : null}
47+ <FilterLink href="/dashboard/api-keys?project=all" active={allProjects}>
48+ All projects
49+ </FilterLink>
50+ </nav>
51+ <p className="text-[12.5px] text-fg-subtle tabular">
52+ {active} active · {keys.length - active} inactive
53+ </p>
54+ </div>
55+
56+ <KeysTable keys={keys} projects={projectOptions} defaultProjectId={scopeProject?.id ?? ws.project.id} showProject={allProjects} />
57+
58+ <Alert
59+ variant="info"
60+ title="Keep your keys secret"
61+ action={
62+ <Link href="/docs/authentication" className="text-[13px] font-medium text-accent underline-offset-4 hover:underline">
63+ Auth docs
64+ </Link>
65+ }
66+ >
67+ <ul className="mt-1 grid gap-1 text-[13px]">
68+ <li className="flex gap-2">
69+ <ShieldCheck className="mt-0.5 size-3.5 shrink-0 text-info" aria-hidden />
70+ Keys are hashed at rest — we cannot recover a lost secret, only rotate it.
71+ </li>
72+ <li className="flex gap-2">
73+ <ShieldCheck className="mt-0.5 size-3.5 shrink-0 text-info" aria-hidden />
74+ Rotate regularly and give every service, environment and contractor its own key with the minimum scopes.
75+ </li>
76+ <li className="flex gap-2">
77+ <ShieldCheck className="mt-0.5 size-3.5 shrink-0 text-info" aria-hidden />
78+ Never commit keys to source control or ship them in browser code. Load them from environment variables on your server.
79+ </li>
80+ </ul>
81+ </Alert>
82+ </div>
83+ );
84+}
85+
86+function FilterLink({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) {
87+ return (
88+ <Link href={href} aria-current={active ? "page" : undefined} className={cn("inline-flex h-7 items-center rounded-[5px] px-3 transition-colors", active ? "bg-bg text-fg shadow-xs" : "hover:text-fg")}>
89+ {children}
90+ </Link>
91+ );
92+}
added apps/web/src/app/dashboard/billing/page.tsx +201 −0
@@ -0,0 +1,201 @@
1+import Link from "next/link";
2+import { ArrowRight, Check, CreditCard, Receipt } from "lucide-react";
3+import { PLAN_LIMITS, PLANS, type Plan } from "@fetcha/core";
4+import { getWorkspace } from "@/lib/session";
5+import { monthlyUsage } from "@/lib/queries/account";
6+import { formatNumber, formatPercent, formatUsd, formatDateOnly } from "@/lib/format";
7+import { cn } from "@/lib/utils";
8+import { PageHeader, SectionTitle } from "@/components/ui/page-header";
9+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
10+import { Badge } from "@/components/ui/badge";
11+import { Button } from "@/components/ui/button";
12+import { EmptyState } from "@/components/ui/empty-state";
13+import { Alert } from "@/components/ui/alert";
14+import { UpgradeDialog } from "@/components/dashboard/billing/upgrade-dialog";
15+
16+export const dynamic = "force-dynamic";
17+
18+export const metadata = { title: "Billing · Fetcha" };
19+
20+function planOf(plan: string): Plan {
21+ return (PLANS as readonly string[]).includes(plan) ? (plan as Plan) : "free";
22+}
23+
24+function priceLabel(p: Plan): string {
25+ const l = PLAN_LIMITS[p];
26+ if (p === "enterprise") return "Custom pricing";
27+ if (l.price_usd_month === 0) return "$0 / month";
28+ return `$${l.price_usd_month} / month`;
29+}
30+
31+function limitLines(p: Plan): string[] {
32+ const l = PLAN_LIMITS[p];
33+ const unlimited = l.monthly_requests >= Number.MAX_SAFE_INTEGER;
34+ const lines = [
35+ unlimited ? "Unlimited requests" : `${formatNumber(l.monthly_requests)} requests / month`,
36+ `${formatNumber(l.concurrency)} concurrent requests`,
37+ `${l.max_timeout_ms / 1000} s max timeout · ${l.max_retries} retries`,
38+ `${l.retention_days}-day request logs`,
39+ ];
40+ if (l.included_gb > 0) lines.push(`${l.included_gb} GB residential bandwidth included`);
41+ if (l.overage_per_1k_requests_usd > 0) lines.push(`$${l.overage_per_1k_requests_usd.toFixed(2)} per extra 1k requests`);
42+ if (l.residential_per_gb_usd > 0) lines.push(`$${l.residential_per_gb_usd.toFixed(2)} per extra residential GB`);
43+ if (p === "enterprise") lines.push("Custom bandwidth and overage", "SLA, invoicing, dedicated support");
44+ return lines;
45+}
46+
47+export default async function BillingPage() {
48+ const ws = await getWorkspace();
49+ const plan = planOf(ws.organization.plan);
50+ const limits = PLAN_LIMITS[plan];
51+ const usage = await monthlyUsage(ws.organization.id);
52+ const unlimited = limits.monthly_requests >= Number.MAX_SAFE_INTEGER;
53+ const pct = unlimited ? null : Math.min(100, (usage.requests / limits.monthly_requests) * 100);
54+ const now = new Date();
55+ const periodEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
56+ const planIndex = PLANS.indexOf(plan);
57+
58+ return (
59+ <div className="grid gap-8">
60+ <PageHeader title="Billing" description="Your plan, usage against its limits, and invoices. Checkout and card payments open at the end of the public preview." />
61+
62+ <div className="grid gap-6 lg:grid-cols-[1fr_360px]">
63+ <Card>
64+ <CardHeader>
65+ <div className="flex items-center justify-between gap-3">
66+ <CardTitle>Current plan</CardTitle>
67+ <Badge variant="accent" dot>
68+ {limits.label}
69+ </Badge>
70+ </div>
71+ <CardDescription>
72+ {priceLabel(plan)} · usage period resets {formatDateOnly(periodEnd)}. Included with every plan: auto routing, retries with new IPs, sticky sessions and the Playground.
73+ </CardDescription>
74+ </CardHeader>
75+ <CardContent>
76+ <ul className="grid gap-2 text-[13.5px] sm:grid-cols-2">
77+ {limitLines(plan).map((line) => (
78+ <li key={line} className="flex items-start gap-2">
79+ <Check className="mt-0.5 size-4 shrink-0 text-success" aria-hidden />
80+ <span className="tabular">{line}</span>
81+ </li>
82+ ))}
83+ </ul>
84+ </CardContent>
85+ </Card>
86+
87+ <Card>
88+ <CardHeader>
89+ <CardTitle>Usage this month</CardTitle>
90+ <CardDescription>Across all projects of {ws.organization.name}.</CardDescription>
91+ </CardHeader>
92+ <CardContent className="grid gap-4">
93+ <div className="grid gap-1.5">
94+ <div className="flex items-baseline justify-between text-[13px]">
95+ <span className="text-fg-muted">Requests</span>
96+ <span className="font-mono tabular">
97+ {formatNumber(usage.requests)} <span className="text-fg-subtle">/ {unlimited ? "∞" : formatNumber(limits.monthly_requests)}</span>
98+ </span>
99+ </div>
100+ <div className="h-1.5 overflow-hidden rounded-full bg-bg-muted" role="progressbar" aria-valuenow={pct ?? undefined} aria-valuemin={0} aria-valuemax={100} aria-label="Requests used">
101+ {pct !== null ? <div className={cn("h-full", pct >= 90 ? "bg-danger" : pct >= 70 ? "bg-warning" : "bg-accent")} style={{ width: `${pct}%` }} /> : null}
102+ </div>
103+ <div className="text-[11.5px] text-fg-subtle">{pct !== null ? `${formatPercent(pct, 0)} of plan allowance` : "no cap on this plan"}</div>
104+ </div>
105+ <div className="flex items-baseline justify-between border-t border-border pt-3 text-[13px]">
106+ <span className="text-fg-muted">Metered spend</span>
107+ <span className="font-mono tabular">{formatUsd(usage.spendUsd)}</span>
108+ </div>
109+ <div className="flex items-baseline justify-between text-[13px]">
110+ <span className="text-fg-muted">Plan fee</span>
111+ <span className="font-mono tabular">{plan === "enterprise" ? "custom" : formatUsd(limits.price_usd_month)}</span>
112+ </div>
113+ <Link href="/dashboard/usage" className="inline-flex items-center gap-1 text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">
114+ Detailed usage <ArrowRight className="size-3.5" />
115+ </Link>
116+ </CardContent>
117+ </Card>
118+ </div>
119+
120+ <section>
121+ <SectionTitle right={<span className="text-[12px] font-normal normal-case tracking-normal text-fg-subtle">Prices in USD, billed monthly</span>}>Plans</SectionTitle>
122+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
123+ {PLANS.map((p, i) => {
124+ const l = PLAN_LIMITS[p];
125+ const current = p === plan;
126+ const downgrade = i < planIndex;
127+ return (
128+ <div key={p} className={cn("flex flex-col rounded-lg border bg-bg-elevated p-4 shadow-xs", current ? "border-accent ring-1 ring-accent/30" : "border-border")}>
129+ <div className="flex items-center justify-between gap-2">
130+ <h3 className="text-[14px] font-semibold tracking-tight">{l.label}</h3>
131+ {current ? <Badge variant="accent">Current</Badge> : null}
132+ </div>
133+ <div className="mt-2 font-mono text-[20px] font-semibold tabular leading-none">{p === "enterprise" ? "Custom" : `$${l.price_usd_month}`}</div>
134+ <div className="mt-1 text-[11.5px] text-fg-subtle">{p === "enterprise" ? "annual contract" : "per month"}</div>
135+ <ul className="mt-4 grid flex-1 gap-1.5 text-[12.5px] text-fg-muted">
136+ {limitLines(p)
137+ .slice(0, 5)
138+ .map((line) => (
139+ <li key={line} className="tabular">
140+ {line}
141+ </li>
142+ ))}
143+ </ul>
144+ <div className="mt-4">
145+ {current ? (
146+ <Button variant="secondary" size="sm" className="w-full" disabled>
147+ Your plan
148+ </Button>
149+ ) : downgrade ? (
150+ <Button asChild variant="ghost" size="sm" className="w-full">
151+ <a href={`mailto:sales@fetcha.co?subject=${encodeURIComponent(`Downgrade to ${l.label}`)}`}>Request downgrade</a>
152+ </Button>
153+ ) : (
154+ <UpgradeDialog planLabel={l.label} priceLabel={priceLabel(p)} currentPlanLabel={limits.label} variant={i === planIndex + 1 ? "primary" : "outline"} />
155+ )}
156+ </div>
157+ </div>
158+ );
159+ })}
160+ </div>
161+ </section>
162+
163+ <div className="grid gap-6 lg:grid-cols-2">
164+ <section>
165+ <SectionTitle>Invoices</SectionTitle>
166+ <EmptyState compact icon={Receipt} title="No invoices yet" description="Billing is not enabled during the public preview. Invoices and receipts will appear here once checkout opens." />
167+ </section>
168+ <section className="grid gap-4">
169+ <div>
170+ <SectionTitle>Payment method</SectionTitle>
171+ <Card>
172+ <CardContent className="flex items-center gap-3 pt-5">
173+ <div className="flex size-9 items-center justify-center rounded-md border border-border bg-bg">
174+ <CreditCard className="size-4 text-fg-muted" aria-hidden />
175+ </div>
176+ <div className="min-w-0 flex-1">
177+ <div className="text-[13.5px] font-medium">Not configured</div>
178+ <div className="text-[12.5px] text-fg-muted">Card checkout opens soon. You will be asked for a payment method before any paid plan is charged.</div>
179+ </div>
180+ <Button variant="outline" size="sm" disabled>
181+ Add card
182+ </Button>
183+ </CardContent>
184+ </Card>
185+ </div>
186+ <Alert
187+ variant="info"
188+ title="Spending limits"
189+ action={
190+ <Button asChild variant="outline" size="xs">
191+ <Link href="/dashboard/settings">Settings</Link>
192+ </Button>
193+ }
194+ >
195+ Cap monthly spend for the whole organization{ws.organization.hardLimitUsd ? ` (currently ${formatUsd(ws.organization.hardLimitUsd)} hard limit)` : ""} or per project. Hard limits stop requests with USAGE_LIMIT_REACHED.
196+ </Alert>
197+ </section>
198+ </div>
199+ </div>
200+ );
201+}
added apps/web/src/app/dashboard/layout.tsx +39 −0
@@ -0,0 +1,39 @@
1+import Link from "next/link";
2+import { MailWarning } from "lucide-react";
3+import { getWorkspace } from "@/lib/session";
4+import { Sidebar } from "@/components/dashboard/sidebar";
5+import { Topbar } from "@/components/dashboard/topbar";
6+import { ResendVerificationButton } from "@/components/dashboard/resend-verification";
7+
8+export const dynamic = "force-dynamic";
9+
10+export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
11+ const ws = await getWorkspace();
12+ return (
13+ <div className="flex min-h-dvh">
14+ <Sidebar isAdmin={ws.isAdmin} />
15+ <div className="flex min-w-0 flex-1 flex-col">
16+ <Topbar
17+ user={{ name: ws.user.name, email: ws.user.email, image: ws.user.image }}
18+ organization={{ id: ws.organization.id, name: ws.organization.name, plan: ws.organization.plan }}
19+ projects={ws.projects.map((p) => ({ id: p.id, name: p.name, environment: p.environment }))}
20+ currentProjectId={ws.project.id}
21+ isAdmin={ws.isAdmin}
22+ />
23+ {!ws.user.emailVerified ? (
24+ <div className="flex flex-wrap items-center gap-3 border-b border-warning/30 bg-warning-soft px-4 py-2 text-[13px] sm:px-6">
25+ <MailWarning className="size-4 text-warning" aria-hidden />
26+ <span>
27+ Verify <strong>{ws.user.email}</strong> to unlock production API access. The Playground works while you wait.
28+ </span>
29+ <ResendVerificationButton email={ws.user.email} />
30+ <Link href="/dashboard/settings" className="ml-auto text-fg-muted underline-offset-4 hover:underline">Change email</Link>
31+ </div>
32+ ) : null}
33+ <main className="flex-1 px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
34+ <div className="mx-auto w-full max-w-[1200px] animate-fade-in">{children}</div>
35+ </main>
36+ </div>
37+ </div>
38+ );
39+}
added apps/web/src/app/dashboard/loading.tsx +53 −0
@@ -0,0 +1,53 @@
1+import { Skeleton, TableSkeleton } from "@/components/ui/skeleton";
2+
3+export default function DashboardOverviewLoading() {
4+ return (
5+ <div className="flex flex-col gap-6" aria-busy="true" aria-label="Loading overview">
6+ <div className="flex flex-col gap-2">
7+ <Skeleton className="h-3 w-32" />
8+ <Skeleton className="h-7 w-64" />
9+ <Skeleton className="h-3.5 w-96 max-w-full" />
10+ </div>
11+ <div className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-3 lg:grid-cols-6">
12+ {Array.from({ length: 6 }).map((_, i) => (
13+ <div key={i} className="flex flex-col gap-2 bg-bg-elevated px-5 py-4">
14+ <Skeleton className="h-3 w-20" />
15+ <Skeleton className="h-6 w-24" />
16+ </div>
17+ ))}
18+ </div>
19+ <div className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-5">
20+ {Array.from({ length: 5 }).map((_, i) => (
21+ <div key={i} className="flex flex-col gap-2 bg-bg-elevated px-5 py-4">
22+ <Skeleton className="h-3 w-20" />
23+ <Skeleton className="h-6 w-20" />
24+ </div>
25+ ))}
26+ </div>
27+ <div className="rounded-lg border border-border bg-bg-elevated p-5">
28+ <Skeleton className="h-4 w-40" />
29+ <Skeleton className="mt-4 h-1.5 w-full" />
30+ </div>
31+ <div className="grid gap-4 lg:grid-cols-3">
32+ <div className="rounded-lg border border-border bg-bg-elevated p-5 lg:col-span-2">
33+ <Skeleton className="h-4 w-36" />
34+ <Skeleton className="mt-6 h-[240px] w-full" />
35+ </div>
36+ <div className="rounded-lg border border-border bg-bg-elevated p-5">
37+ <Skeleton className="h-4 w-36" />
38+ <div className="mt-6 flex items-center gap-5">
39+ <Skeleton className="size-[156px] rounded-full" />
40+ <div className="flex-1 space-y-3">
41+ {Array.from({ length: 4 }).map((_, i) => (
42+ <Skeleton key={i} className="h-3.5 w-full" />
43+ ))}
44+ </div>
45+ </div>
46+ </div>
47+ </div>
48+ <div className="rounded-lg border border-border bg-bg-elevated">
49+ <TableSkeleton rows={6} cols={6} />
50+ </div>
51+ </div>
52+ );
53+}
added apps/web/src/app/dashboard/onboarding/page.tsx +27 −0
@@ -0,0 +1,27 @@
1+import { getWorkspace } from "@/lib/session";
2+import { onboardingState } from "@/lib/queries/account";
3+import { PageHeader } from "@/components/ui/page-header";
4+import { OnboardingFlow } from "@/components/dashboard/onboarding/flow";
5+
6+export const dynamic = "force-dynamic";
7+
8+export const metadata = { title: "Get started · Fetcha" };
9+
10+export default async function OnboardingPage() {
11+ const ws = await getWorkspace();
12+ const state = await onboardingState(ws.organization.id);
13+ const projects = ws.projects.map((p) => ({ id: p.id, name: p.name, environment: p.environment }));
14+ return (
15+ <div className="mx-auto w-full max-w-3xl">
16+ <PageHeader eyebrow="Getting started" title="Set up Fetcha" description="Five short steps from an empty account to your first routed request. You can leave and come back anytime." />
17+ <OnboardingFlow
18+ projects={projects}
19+ currentProject={{ id: ws.project.id, name: ws.project.name, environment: ws.project.environment }}
20+ hasKey={state.hasKey}
21+ hasRequest={state.hasRequest}
22+ alreadyCompleted={Boolean(ws.user.onboardingCompletedAt)}
23+ userName={ws.user.name}
24+ />
25+ </div>
26+ );
27+}
added apps/web/src/app/dashboard/page.tsx +221 −0
@@ -0,0 +1,221 @@
1+import Link from "next/link";
2+import { ArrowRight, BookOpen, CheckCircle2, Circle, KeyRound, Play } from "lucide-react";
3+import { PLAN_LIMITS, type Plan } from "@fetcha/core";
4+import { getWorkspace } from "@/lib/session";
5+import { formatBytes, formatCompact, formatMs, formatNumber, formatPercent, formatUsd, formatDateOnly } from "@/lib/format";
6+import { getActiveSessionCount, getMonthStats, getNetworkDistribution, getOnboardingState, getRecentRequests, getRequestSeries, type Scope } from "@/lib/queries/dashboard";
7+import { PageHeader, SectionTitle } from "@/components/ui/page-header";
8+import { Stat, StatGrid } from "@/components/ui/stat";
9+import { Badge } from "@/components/ui/badge";
10+import { Alert } from "@/components/ui/alert";
11+import { Button } from "@/components/ui/button";
12+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
13+import { EmptyState } from "@/components/ui/empty-state";
14+import { ChartFrame } from "@/components/dashboard/charts/chart-frame";
15+import { RequestsBarChart } from "@/components/dashboard/charts/requests-bar-chart";
16+import { NetworkDonut } from "@/components/dashboard/charts/network-donut";
17+import { QuotaBar } from "@/components/dashboard/charts/quota-bar";
18+import { RequestsTable } from "@/components/dashboard/requests/requests-table";
19+
20+export const dynamic = "force-dynamic";
21+
22+export default async function DashboardOverviewPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
23+ const [ws, sp] = await Promise.all([getWorkspace(), searchParams]);
24+ const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id };
25+ const now = new Date();
26+ const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
27+ const thirtyDaysAgo = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 29));
28+
29+ const [month, series, network, recent, onboarding, activeSessions] = await Promise.all([
30+ getMonthStats(scope, monthStart),
31+ getRequestSeries(scope, thirtyDaysAgo, "day"),
32+ getNetworkDistribution(scope, thirtyDaysAgo),
33+ getRecentRequests(scope, 8),
34+ getOnboardingState(scope),
35+ getActiveSessionCount(scope),
36+ ]);
37+
38+ const plan = (ws.organization.plan in PLAN_LIMITS ? ws.organization.plan : "free") as Plan;
39+ const limits = PLAN_LIMITS[plan];
40+ const unlimited = limits.monthly_requests >= Number.MAX_SAFE_INTEGER;
41+ const projectLimit = ws.project.monthlyRequestLimit;
42+ const effectiveLimit = projectLimit && (unlimited || projectLimit < limits.monthly_requests) ? projectLimit : unlimited ? null : limits.monthly_requests;
43+ const firstName = ws.user.name?.trim().split(/\s+/)[0] || ws.user.email.split("@")[0];
44+ const verified = sp.verified === "1";
45+ const seriesHasData = series.some((p) => p.success + p.failed > 0);
46+ const onboardingDone = onboarding.hasApiKey && onboarding.hasRequest;
47+ const softLimit = ws.project.softLimitUsd ?? ws.organization.softLimitUsd ?? null;
48+ const hardLimit = ws.project.hardLimitUsd ?? ws.organization.hardLimitUsd ?? null;
49+ const limitSource = ws.project.softLimitUsd !== null || ws.project.hardLimitUsd !== null ? "project" : "organization";
50+
51+ return (
52+ <div className="flex flex-col gap-6">
53+ {verified ? (
54+ <Alert variant="success" title="Email verified">
55+ Production API access is unlocked for this organization.
56+ </Alert>
57+ ) : null}
58+
59+ <PageHeader
60+ eyebrow={
61+ <>
62+ {ws.project.name} · {ws.project.environment}
63+ </>
64+ }
65+ title={
66+ <span className="inline-flex flex-wrap items-center gap-2.5">
67+ Welcome back, {firstName}
68+ <Badge variant="accent">{limits.label} plan</Badge>
69+ </span>
70+ }
71+ description={`Here is what happened in ${ws.project.name} since ${formatDateOnly(monthStart)}. Figures reflect requests billed to this project.`}
72+ actions={
73+ <>
74+ <Button asChild variant="outline" size="sm">
75+ <Link href="/dashboard/requests">View requests</Link>
76+ </Button>
77+ <Button asChild variant="primary" size="sm">
78+ <Link href="/dashboard/playground">
79+ <Play /> Open Playground
80+ </Link>
81+ </Button>
82+ </>
83+ }
84+ />
85+
86+ {!onboardingDone ? (
87+ <Card>
88+ <CardHeader className="pb-2">
89+ <CardTitle>Getting started</CardTitle>
90+ <CardDescription>Three steps to your first routed request. This card disappears once they are all done.</CardDescription>
91+ </CardHeader>
92+ <CardContent>
93+ <ol className="divide-y divide-border">
94+ {[
95+ { done: onboarding.hasApiKey, icon: KeyRound, title: "Create an API key", body: "Keys are shown once at creation. Use a test key while you explore.", href: "/dashboard/api-keys", cta: "Create key" },
96+ { done: onboarding.hasRequest, icon: Play, title: "Run your first request", body: "Try the Playground or send a POST /v1/fetch with cURL.", href: "/dashboard/playground", cta: "Open Playground" },
97+ { done: false, icon: BookOpen, title: "Read the quickstart", body: "Five minutes on routing, network classes, sessions and error codes.", href: "/docs/quickstart", cta: "Read docs", external: true },
98+ ].map((step) => (
99+ <li key={step.title} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
100+ {step.done ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-success" aria-label="Done" /> : <Circle className="mt-0.5 size-4 shrink-0 text-fg-subtle" aria-label="To do" />}
101+ <div className="min-w-0 flex-1">
102+ <div className={`text-[13.5px] font-medium ${step.done ? "text-fg-muted line-through decoration-fg-subtle" : ""}`}>{step.title}</div>
103+ <p className="text-[12.5px] text-fg-muted">{step.body}</p>
104+ </div>
105+ {!step.done ? (
106+ <Button asChild variant="ghost" size="sm" className="shrink-0">
107+ <Link href={step.href} target={step.external ? "_blank" : undefined}>
108+ {step.cta} <ArrowRight />
109+ </Link>
110+ </Button>
111+ ) : null}
112+ </li>
113+ ))}
114+ </ol>
115+ </CardContent>
116+ </Card>
117+ ) : null}
118+
119+ <section aria-label="This month">
120+ <StatGrid cols={6}>
121+ <Stat label="Requests" value={formatNumber(month.total)} hint="this month" />
122+ <Stat label="Successful" value={formatNumber(month.successful)} hint={month.failed ? `${formatNumber(month.failed)} failed` : "no failures"} />
123+ <Stat label="Success rate" value={month.successRate === null ? "—" : formatPercent(month.successRate)} hint="of completed requests" />
124+ <Stat label="Bandwidth" value={formatBytes(month.bytes)} hint="in + out" />
125+ <Stat label="Estimated spend" value={formatUsd(month.spendUsd)} hint="billed usage" />
126+ <Stat label="Avg latency" value={formatMs(month.avgLatencyMs)} hint="end to end" />
127+ </StatGrid>
128+ <StatGrid cols={5} className="mt-3">
129+ <Stat label="P50 latency" value={formatMs(month.p50)} />
130+ <Stat label="P95 latency" value={formatMs(month.p95)} />
131+ <Stat label="P99 latency" value={formatMs(month.p99)} />
132+ <Stat label="Attempts / request" value={month.avgAttempts === null ? "—" : month.avgAttempts.toFixed(2)} hint="routes tried on average" />
133+ <Stat label="Active sessions" value={formatNumber(activeSessions)} hint={<Link href="/dashboard/sessions" className="underline-offset-4 hover:underline">manage</Link>} />
134+ </StatGrid>
135+ </section>
136+
137+ <Card>
138+ <CardHeader className="pb-3">
139+ <CardTitle>Quota and limits</CardTitle>
140+ <CardDescription>
141+ Monthly request allowance on the {limits.label} plan
142+ {projectLimit ? ` (project cap: ${formatNumber(projectLimit)})` : ""}. Spending limits stop new requests with <code className="font-mono text-[12px]">USAGE_LIMIT_REACHED</code>.
143+ </CardDescription>
144+ </CardHeader>
145+ <CardContent className="grid gap-6 lg:grid-cols-2">
146+ <QuotaBar label="Requests this month" used={month.total} limit={effectiveLimit} unlimited={effectiveLimit === null} usedLabel={formatNumber(month.total)} limitLabel={effectiveLimit ? formatCompact(effectiveLimit) : undefined} hint={effectiveLimit ? `${formatNumber(Math.max(0, effectiveLimit - month.total))} remaining` : "Enterprise plan"} />
147+ <div className="grid grid-cols-2 gap-4 text-[13px]">
148+ <div>
149+ <div className="text-[12px] font-medium text-fg-subtle">Soft limit</div>
150+ <div className="mt-1 font-mono tabular text-[15px]">{softLimit === null ? <span className="text-fg-subtle">not set</span> : formatUsd(softLimit)}</div>
151+ <div className="text-[11.5px] text-fg-subtle">{softLimit === null ? "alerts only" : `${limitSource} · email alert`}</div>
152+ </div>
153+ <div>
154+ <div className="text-[12px] font-medium text-fg-subtle">Hard limit</div>
155+ <div className="mt-1 font-mono tabular text-[15px]">{hardLimit === null ? <span className="text-fg-subtle">not set</span> : formatUsd(hardLimit)}</div>
156+ <div className="text-[11.5px] text-fg-subtle">{hardLimit === null ? "requests never blocked" : `${limitSource} · blocks requests`}</div>
157+ </div>
158+ <div className="col-span-2 text-[12px] text-fg-subtle">
159+ Spend so far: <span className="font-mono tabular text-fg">{formatUsd(month.spendUsd)}</span>
160+ {hardLimit ? <> of {formatUsd(hardLimit)} ({formatPercent(Math.min(100, (month.spendUsd / hardLimit) * 100), 0)})</> : null} ·{" "}
161+ <Link href="/dashboard/projects" className="underline-offset-4 hover:underline">
162+ Edit limits
163+ </Link>
164+ </div>
165+ </div>
166+ </CardContent>
167+ </Card>
168+
169+ <div className="grid gap-4 lg:grid-cols-3">
170+ <ChartFrame
171+ className="lg:col-span-2"
172+ title="Requests per day"
173+ description="Last 30 days, UTC. Successful and failed requests stacked."
174+ empty={!seriesHasData}
175+ emptyMessage="No requests in the last 30 days."
176+ table={{ columns: ["Day", "Successful", "Failed"], rows: series.filter((p) => p.success + p.failed > 0).map((p) => [p.t.slice(0, 10), p.success, p.failed]) }}
177+ >
178+ <RequestsBarChart data={series} bucket="day" height={240} />
179+ </ChartFrame>
180+ <ChartFrame title="Network distribution" description="Resolved network class, last 30 days." empty={network.resolved === 0} emptyMessage="No routed requests yet.">
181+ <NetworkDonut data={network.shares} />
182+ {network.unresolved > 0 ? <p className="mt-3 text-[11.5px] text-fg-subtle">{formatNumber(network.unresolved)} request(s) failed before a network was selected and are not counted.</p> : null}
183+ </ChartFrame>
184+ </div>
185+
186+ <section>
187+ <SectionTitle
188+ right={
189+ <Link href="/dashboard/requests" className="text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">
190+ All requests <ArrowRight className="ml-0.5 inline size-3.5" />
191+ </Link>
192+ }
193+ >
194+ Recent requests
195+ </SectionTitle>
196+ {recent.length ? (
197+ <Card className="overflow-hidden">
198+ <RequestsTable rows={recent} compact />
199+ </Card>
200+ ) : (
201+ <EmptyState
202+ compact
203+ icon={Play}
204+ title="No requests yet"
205+ description="Send your first request from the Playground or with an API key. Each request shows up here with its route, latency and cost."
206+ action={
207+ <>
208+ <Button asChild size="sm" variant="primary">
209+ <Link href="/dashboard/playground">Open Playground</Link>
210+ </Button>
211+ <Button asChild size="sm" variant="outline">
212+ <Link href="/docs/quickstart">Quickstart</Link>
213+ </Button>
214+ </>
215+ }
216+ />
217+ )}
218+ </section>
219+ </div>
220+ );
221+}
added apps/web/src/app/dashboard/playground/loading.tsx +55 −0
@@ -0,0 +1,55 @@
1+import { Skeleton } from "@/components/ui/skeleton";
2+
3+export default function PlaygroundLoading() {
4+ return (
5+ <div aria-busy="true" aria-label="Loading playground">
6+ <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
7+ <div className="grid gap-2">
8+ <Skeleton className="h-3 w-24" />
9+ <Skeleton className="h-6 w-40" />
10+ <Skeleton className="h-3.5 w-[28rem] max-w-full" />
11+ </div>
12+ <Skeleton className="h-8 w-32" />
13+ </div>
14+ <div className="grid gap-8 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] lg:gap-10">
15+ <div className="grid gap-4">
16+ <div className="grid gap-2">
17+ <Skeleton className="h-3.5 w-10" />
18+ <div className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2">
19+ <Skeleton className="h-11" />
20+ <Skeleton className="h-11" />
21+ </div>
22+ </div>
23+ {Array.from({ length: 4 }).map((_, i) => (
24+ <div key={i} className="border-t border-border py-2.5">
25+ <Skeleton className="h-3.5 w-28" />
26+ </div>
27+ ))}
28+ <div className="border-t border-border pt-3 grid gap-4">
29+ <div className="grid gap-4 sm:grid-cols-2">
30+ <Skeleton className="h-9" />
31+ <Skeleton className="h-9" />
32+ </div>
33+ <div className="grid gap-4 sm:grid-cols-2">
34+ <Skeleton className="h-9" />
35+ <Skeleton className="h-9" />
36+ </div>
37+ <Skeleton className="h-24" />
38+ </div>
39+ <div className="hidden items-center gap-2 border-t border-border pt-4 lg:flex">
40+ <Skeleton className="h-9 w-40" />
41+ <Skeleton className="h-9 w-20" />
42+ </div>
43+ </div>
44+ <div className="grid gap-6">
45+ <Skeleton className="h-40 rounded-lg" />
46+ <div className="grid gap-3">
47+ <Skeleton className="h-3 w-36" />
48+ <Skeleton className="h-8 w-full" />
49+ <Skeleton className="h-64 rounded-lg" />
50+ </div>
51+ </div>
52+ </div>
53+ </div>
54+ );
55+}
added apps/web/src/app/dashboard/playground/page.tsx +88 −0
@@ -0,0 +1,88 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { BookOpen } from "lucide-react";
4+import { COUNTRIES, PLAN_LIMITS, type Plan } from "@fetcha/core";
5+import { Button } from "@/components/ui/button";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { Playground } from "@/components/playground/playground";
8+import { FORMATS, HTTP_METHODS, NETWORKS, type BuilderState } from "@/components/playground/types";
9+import { internalApi } from "@/lib/api";
10+import { getReplayRequest, listPlaygroundKeys } from "@/lib/queries/playground";
11+import { getWorkspace } from "@/lib/session";
12+import { API_PUBLIC_URL } from "@/lib/utils";
13+
14+export const dynamic = "force-dynamic";
15+export const metadata: Metadata = { title: "Playground" };
16+
17+type SearchParams = Promise<{ url?: string | string[]; request?: string | string[] }>;
18+
19+function first(v: string | string[] | undefined): string | undefined {
20+ return Array.isArray(v) ? v[0] : v;
21+}
22+
23+export default async function PlaygroundPage({ searchParams }: { searchParams: SearchParams }) {
24+ const [sp, ws] = await Promise.all([searchParams, getWorkspace()]);
25+ const planKey = (ws.organization.plan in PLAN_LIMITS ? ws.organization.plan : "free") as Plan;
26+ const limits = PLAN_LIMITS[planKey];
27+
28+ const replayId = first(sp.request)?.trim();
29+ const prefillUrl = first(sp.url)?.trim();
30+
31+ const [keys, ready, replay] = await Promise.all([
32+ listPlaygroundKeys(ws.project.id, ws.organization.id),
33+ internalApi.ready().catch(() => ({ status: "unknown", checks: {}, available_networks: [] as string[] })),
34+ replayId ? getReplayRequest(ws.project.id, replayId).catch(() => null) : Promise.resolve(null),
35+ ]);
36+
37+ let initial: Partial<BuilderState> | null = null;
38+ if (replay) {
39+ initial = {
40+ url: replay.url,
41+ method: (HTTP_METHODS as readonly string[]).includes(replay.method) ? (replay.method as BuilderState["method"]) : "GET",
42+ network: (NETWORKS as readonly string[]).includes(replay.requestedNetwork) ? (replay.requestedNetwork as BuilderState["network"]) : "auto",
43+ country: replay.country ?? "",
44+ region: replay.region ?? "",
45+ city: replay.city ?? "",
46+ format: (FORMATS as readonly string[]).includes(replay.format) ? (replay.format as BuilderState["format"]) : "html",
47+ };
48+ } else if (prefillUrl) {
49+ initial = { url: prefillUrl.slice(0, 8192) };
50+ }
51+
52+ const projectDefaults: Partial<BuilderState> = {
53+ country: ws.project.defaultCountry ?? "",
54+ network: (NETWORKS as readonly string[]).includes(ws.project.defaultNetwork) ? (ws.project.defaultNetwork as BuilderState["network"]) : "auto",
55+ };
56+
57+ const countries = Object.entries(COUNTRIES)
58+ .map(([code, name]) => ({ code, name }))
59+ .sort((a, b) => a.name.localeCompare(b.name));
60+
61+ return (
62+ <>
63+ <PageHeader
64+ eyebrow={ws.project.name}
65+ title="Playground"
66+ description="Send a request through Fetcha and inspect the response, the network route it took and where the time went. Playground requests count toward your plan usage."
67+ actions={
68+ <Button variant="outline" size="sm" asChild>
69+ <Link href="/docs">
70+ <BookOpen className="size-3.5" /> API reference
71+ </Link>
72+ </Button>
73+ }
74+ />
75+ <Playground
76+ keys={keys}
77+ plan={{ label: limits.label, concurrency: limits.concurrency, max_timeout_ms: limits.max_timeout_ms, max_retries: limits.max_retries, networks: limits.networks }}
78+ availableNetworks={ready.available_networks ?? []}
79+ countries={countries}
80+ baseUrl={API_PUBLIC_URL}
81+ projectDefaults={projectDefaults}
82+ initial={initial}
83+ replayId={replay?.id ?? null}
84+ />
85+ {replayId && !replay ? <p className="mt-4 text-xs text-fg-subtle">Request <span className="font-mono">{replayId}</span> was not found in this project, so nothing was prefilled.</p> : null}
86+ </>
87+ );
88+}
added apps/web/src/app/dashboard/projects/[id]/loading.tsx +26 −0
@@ -0,0 +1,26 @@
1+import { Skeleton } from "@/components/ui/skeleton";
2+
3+export default function Loading() {
4+ return (
5+ <div className="grid gap-6" aria-busy="true" aria-label="Loading project">
6+ <Skeleton className="h-3.5 w-24" />
7+ <div className="grid gap-2">
8+ <Skeleton className="h-3 w-40" />
9+ <Skeleton className="h-6 w-56" />
10+ <Skeleton className="h-3.5 w-72" />
11+ </div>
12+ <div className="grid gap-px overflow-hidden rounded-lg border border-border sm:grid-cols-4">
13+ {Array.from({ length: 4 }).map((_, i) => (
14+ <div key={i} className="grid gap-2 bg-bg-elevated px-5 py-4">
15+ <Skeleton className="h-3 w-24" />
16+ <Skeleton className="h-6 w-20" />
17+ </div>
18+ ))}
19+ </div>
20+ <div className="grid gap-6 lg:grid-cols-[1fr_320px]">
21+ <Skeleton className="h-[520px]" />
22+ <Skeleton className="h-56" />
23+ </div>
24+ </div>
25+ );
26+}
added apps/web/src/app/dashboard/projects/[id]/page.tsx +224 −0
@@ -0,0 +1,224 @@
1+import Link from "next/link";
2+import { notFound } from "next/navigation";
3+import { ArrowLeft, ArrowRight, KeyRound } from "lucide-react";
4+import { PLAN_LIMITS, type Plan } from "@fetcha/core";
5+import { getWorkspace } from "@/lib/session";
6+import { getOrgProject, listApiKeys, monthlyUsage } from "@/lib/queries/account";
7+import { formatDate, formatDateOnly, formatNumber, formatPercent, formatUsd, timeAgo } from "@/lib/format";
8+import { maskedKey } from "@/lib/account-snippets";
9+import { PageHeader, SectionTitle } from "@/components/ui/page-header";
10+import { Badge, StatusBadge } from "@/components/ui/badge";
11+import { Button } from "@/components/ui/button";
12+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
13+import { Stat, StatGrid } from "@/components/ui/stat";
14+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
15+import { EmptyState } from "@/components/ui/empty-state";
16+import { Alert } from "@/components/ui/alert";
17+import { ProjectForm } from "@/components/dashboard/projects/project-form";
18+import { ArchiveProjectButton, SelectProjectButton } from "@/components/dashboard/projects/project-actions";
19+import { CreateKeyDialog } from "@/components/dashboard/api-keys/create-key-dialog";
20+
21+export const dynamic = "force-dynamic";
22+
23+export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) {
24+ const { id } = await params;
25+ const ws = await getWorkspace();
26+ const project = await getOrgProject(ws.organization.id, id);
27+ if (!project) notFound();
28+
29+ const [usage, keys] = await Promise.all([monthlyUsage(ws.organization.id, project.id), listApiKeys(ws.organization.id, project.id)]);
30+ const activeKeys = keys.filter((k) => k.status === "active");
31+ const current = ws.project.id === project.id;
32+ const archived = Boolean(project.archivedAt);
33+ const planLimits = PLAN_LIMITS[(ws.organization.plan as Plan) in PLAN_LIMITS ? (ws.organization.plan as Plan) : "free"];
34+ const requestCap = project.monthlyRequestLimit ?? planLimits.monthly_requests;
35+ const requestPct = requestCap > 0 && requestCap < Number.MAX_SAFE_INTEGER ? Math.min(100, (usage.requests / requestCap) * 100) : null;
36+ const spendPct = project.hardLimitUsd ? Math.min(100, (usage.spendUsd / project.hardLimitUsd) * 100) : null;
37+
38+ return (
39+ <div className="grid gap-6">
40+ <div>
41+ <Link href="/dashboard/projects" className="inline-flex items-center gap-1 text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">
42+ <ArrowLeft className="size-3.5" /> All projects
43+ </Link>
44+ </div>
45+ <PageHeader
46+ eyebrow={<span className="font-mono normal-case tracking-normal">{project.id}</span>}
47+ title={
48+ <span className="flex flex-wrap items-center gap-2">
49+ {project.name}
50+ <Badge variant={project.environment === "production" ? "success" : "warning"}>{project.environment}</Badge>
51+ {current ? (
52+ <Badge variant="accent" dot>
53+ Current
54+ </Badge>
55+ ) : null}
56+ {archived ? <StatusBadge status="archived" /> : null}
57+ </span>
58+ }
59+ description={project.description || `Created ${formatDate(project.createdAt)}.`}
60+ actions={
61+ <>
62+ {!current && !archived ? (
63+ <SelectProjectButton projectId={project.id} size="sm">
64+ Make current
65+ </SelectProjectButton>
66+ ) : null}
67+ <Button asChild size="sm" variant="outline">
68+ <Link href={`/dashboard/api-keys?project=${project.id}`}>
69+ <KeyRound /> API keys
70+ </Link>
71+ </Button>
72+ </>
73+ }
74+ />
75+
76+ {archived ? <Alert variant="warning" title="This project is archived">Its keys no longer authenticate and it is hidden from the project switcher. Contact support@fetcha.co to restore it.</Alert> : null}
77+
78+ <StatGrid cols={4}>
79+ <Stat label="Requests this month" value={formatNumber(usage.requests)} hint={requestPct !== null ? `${formatPercent(requestPct, 0)} of ${formatNumber(requestCap)}` : "no cap"} />
80+ <Stat label="Spend this month" value={formatUsd(usage.spendUsd)} hint={project.hardLimitUsd ? `hard limit ${formatUsd(project.hardLimitUsd)}` : "no hard limit"} />
81+ <Stat label="Active keys" value={formatNumber(activeKeys.length)} hint={`${keys.length - activeKeys.length} inactive`} />
82+ <Stat label="Created" value={formatDateOnly(project.createdAt)} mono={false} hint={`updated ${timeAgo(project.updatedAt)}`} />
83+ </StatGrid>
84+
85+ <div className="grid gap-6 lg:grid-cols-[1fr_320px]">
86+ <div className="grid gap-6">
87+ <Card>
88+ <CardHeader>
89+ <CardTitle>Project settings</CardTitle>
90+ <CardDescription>Defaults apply to requests that omit the field. Limits are enforced per calendar month.</CardDescription>
91+ </CardHeader>
92+ <CardContent>
93+ <ProjectForm
94+ mode="edit"
95+ projectId={project.id}
96+ initial={{
97+ name: project.name,
98+ description: project.description,
99+ environment: project.environment,
100+ defaultCountry: project.defaultCountry,
101+ defaultNetwork: project.defaultNetwork,
102+ logLevel: project.logLevel,
103+ softLimitUsd: project.softLimitUsd,
104+ hardLimitUsd: project.hardLimitUsd,
105+ monthlyRequestLimit: project.monthlyRequestLimit,
106+ }}
107+ />
108+ </CardContent>
109+ </Card>
110+
111+ <section>
112+ <SectionTitle
113+ right={
114+ <Link href={`/dashboard/api-keys?project=${project.id}`} className="inline-flex items-center gap-1 text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">
115+ Manage keys <ArrowRight className="size-3.5" />
116+ </Link>
117+ }
118+ >
119+ API keys · {keys.length}
120+ </SectionTitle>
121+ {keys.length === 0 ? (
122+ <EmptyState
123+ compact
124+ icon={KeyRound}
125+ title="No keys for this project"
126+ description="Generate a key to call the API on behalf of this project."
127+ action={!archived ? <CreateKeyDialog projects={[{ id: project.id, name: project.name, environment: project.environment }]} defaultProjectId={project.id} /> : undefined}
128+ />
129+ ) : (
130+ <div className="overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-xs">
131+ <Table>
132+ <TableHeader>
133+ <TableRow className="hover:bg-transparent">
134+ <TableHead>Name</TableHead>
135+ <TableHead>Key</TableHead>
136+ <TableHead>Mode</TableHead>
137+ <TableHead>Last used</TableHead>
138+ <TableHead>Status</TableHead>
139+ </TableRow>
140+ </TableHeader>
141+ <TableBody>
142+ {keys.slice(0, 8).map((k) => (
143+ <TableRow key={k.id} className={k.status !== "active" ? "text-fg-muted" : undefined}>
144+ <TableCell className="font-medium text-fg">{k.name}</TableCell>
145+ <TableCell>
146+ <code className="font-mono text-[12.5px]">{maskedKey(k.keyPrefix, k.last4)}</code>
147+ </TableCell>
148+ <TableCell>
149+ <Badge variant={k.mode === "live" ? "success" : "warning"}>{k.mode}</Badge>
150+ </TableCell>
151+ <TableCell className="tabular">{timeAgo(k.lastUsedAt)}</TableCell>
152+ <TableCell>
153+ <StatusBadge status={k.status} />
154+ </TableCell>
155+ </TableRow>
156+ ))}
157+ </TableBody>
158+ </Table>
159+ {keys.length > 8 ? (
160+ <div className="border-t border-border px-4 py-2 text-[12.5px] text-fg-muted">
161+ <Link href={`/dashboard/api-keys?project=${project.id}`} className="underline-offset-4 hover:underline">
162+ View all {keys.length} keys
163+ </Link>
164+ </div>
165+ ) : null}
166+ </div>
167+ )}
168+ </section>
169+ </div>
170+
171+ <div className="grid gap-6 self-start">
172+ <Card>
173+ <CardHeader>
174+ <CardTitle>Spending limits</CardTitle>
175+ <CardDescription>Month-to-date against this project&apos;s limits.</CardDescription>
176+ </CardHeader>
177+ <CardContent className="grid gap-4 text-[13px]">
178+ <LimitRow label="Requests" value={`${formatNumber(usage.requests)} / ${requestCap < Number.MAX_SAFE_INTEGER ? formatNumber(requestCap) : "∞"}`} pct={requestPct} note={project.monthlyRequestLimit ? "project cap" : `${planLimits.label} plan`} />
179+ <LimitRow label="Spend" value={`${formatUsd(usage.spendUsd)}${project.hardLimitUsd ? ` / ${formatUsd(project.hardLimitUsd)}` : ""}`} pct={spendPct} note={project.hardLimitUsd ? "hard limit" : "no hard limit"} />
180+ <div className="flex items-center justify-between border-t border-border pt-3 text-fg-muted">
181+ <span>Soft limit (alert)</span>
182+ <span className="font-mono tabular text-fg">{project.softLimitUsd ? formatUsd(project.softLimitUsd) : "—"}</span>
183+ </div>
184+ <p className="text-[12px] text-fg-subtle">
185+ Organization-wide limits live in{" "}
186+ <Link href="/dashboard/settings" className="underline-offset-4 hover:underline">
187+ Settings
188+ </Link>
189+ .
190+ </p>
191+ </CardContent>
192+ </Card>
193+
194+ {!archived ? (
195+ <Card className="border-danger/30">
196+ <CardHeader>
197+ <CardTitle className="text-danger">Danger zone</CardTitle>
198+ <CardDescription>Archiving hides the project and disables its keys.</CardDescription>
199+ </CardHeader>
200+ <CardContent>
201+ <ArchiveProjectButton projectId={project.id} name={project.name} disabled={ws.projects.length <= 1} disabledReason="You need at least one active project." />
202+ </CardContent>
203+ </Card>
204+ ) : null}
205+ </div>
206+ </div>
207+ </div>
208+ );
209+}
210+
211+function LimitRow({ label, value, pct, note }: { label: string; value: string; pct: number | null; note: string }) {
212+ return (
213+ <div className="grid gap-1.5">
214+ <div className="flex items-center justify-between">
215+ <span className="text-fg-muted">{label}</span>
216+ <span className="font-mono tabular">{value}</span>
217+ </div>
218+ <div className="h-1.5 overflow-hidden rounded-full bg-bg-muted" role="progressbar" aria-valuenow={pct ?? undefined} aria-valuemin={0} aria-valuemax={100} aria-label={`${label} used`}>
219+ {pct !== null ? <div className={pct >= 90 ? "h-full bg-danger" : pct >= 70 ? "h-full bg-warning" : "h-full bg-accent"} style={{ width: `${pct}%` }} /> : null}
220+ </div>
221+ <div className="text-[11.5px] text-fg-subtle">{note}</div>
222+ </div>
223+ );
224+}
added apps/web/src/app/dashboard/projects/loading.tsx +18 −0
@@ -0,0 +1,18 @@
1+import { Skeleton, TableSkeleton } from "@/components/ui/skeleton";
2+
3+export default function Loading() {
4+ return (
5+ <div className="grid gap-6" aria-busy="true" aria-label="Loading projects">
6+ <div className="flex items-start justify-between">
7+ <div className="grid gap-2">
8+ <Skeleton className="h-6 w-28" />
9+ <Skeleton className="h-3.5 w-96" />
10+ </div>
11+ <Skeleton className="h-9 w-32" />
12+ </div>
13+ <div className="rounded-lg border border-border bg-bg-elevated">
14+ <TableSkeleton rows={4} cols={6} />
15+ </div>
16+ </div>
17+ );
18+}
added apps/web/src/app/dashboard/projects/page.tsx +100 −0
@@ -0,0 +1,100 @@
1+import Link from "next/link";
2+import { ArrowRight, FolderKanban } from "lucide-react";
3+import { getWorkspace } from "@/lib/session";
4+import { projectStatsByProject } from "@/lib/queries/account";
5+import { formatDateOnly, formatNumber, formatUsd, truncate } from "@/lib/format";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
8+import { Badge } from "@/components/ui/badge";
9+import { Button } from "@/components/ui/button";
10+import { EmptyState } from "@/components/ui/empty-state";
11+import { CreateProjectDialog } from "@/components/dashboard/projects/create-project-dialog";
12+import { SelectProjectButton } from "@/components/dashboard/projects/project-actions";
13+
14+export const dynamic = "force-dynamic";
15+
16+export const metadata = { title: "Projects · Fetcha" };
17+
18+export default async function ProjectsPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
19+ const sp = await searchParams;
20+ const ws = await getWorkspace();
21+ const stats = await projectStatsByProject(ws.organization.id);
22+
23+ return (
24+ <div className="grid gap-6">
25+ <PageHeader
26+ title="Projects"
27+ description="Each project has its own API keys, request log, defaults and spending limits. Switch the current project from the top bar or here."
28+ actions={<CreateProjectDialog defaultOpen={sp.new === "1"} />}
29+ />
30+
31+ {ws.projects.length === 0 ? (
32+ <EmptyState icon={FolderKanban} title="No projects" description="Create a project to start generating API keys." action={<CreateProjectDialog />} />
33+ ) : (
34+ <div className="overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-xs">
35+ <Table>
36+ <TableHeader>
37+ <TableRow className="hover:bg-transparent">
38+ <TableHead>Project</TableHead>
39+ <TableHead>Environment</TableHead>
40+ <TableHead className="text-right">Requests · month</TableHead>
41+ <TableHead className="text-right">Spend · month</TableHead>
42+ <TableHead className="text-right">Keys</TableHead>
43+ <TableHead>Created</TableHead>
44+ <TableHead className="text-right">
45+ <span className="sr-only">Actions</span>
46+ </TableHead>
47+ </TableRow>
48+ </TableHeader>
49+ <TableBody>
50+ {ws.projects.map((p) => {
51+ const s = stats.get(p.id) ?? { requests: 0, spendUsd: 0, activeKeys: 0 };
52+ const current = p.id === ws.project.id;
53+ return (
54+ <TableRow key={p.id}>
55+ <TableCell>
56+ <div className="flex min-w-0 flex-col gap-0.5">
57+ <div className="flex items-center gap-2">
58+ <Link href={`/dashboard/projects/${p.id}`} className="font-medium text-fg underline-offset-4 hover:underline">
59+ {p.name}
60+ </Link>
61+ {current ? (
62+ <Badge variant="accent" dot>
63+ Current
64+ </Badge>
65+ ) : null}
66+ </div>
67+ {p.description ? <span className="text-[12.5px] text-fg-muted">{truncate(p.description, 80)}</span> : <span className="font-mono text-[11.5px] text-fg-subtle">{p.id}</span>}
68+ </div>
69+ </TableCell>
70+ <TableCell>
71+ <Badge variant={p.environment === "production" ? "success" : "warning"}>{p.environment}</Badge>
72+ </TableCell>
73+ <TableCell className="text-right font-mono tabular">{formatNumber(s.requests)}</TableCell>
74+ <TableCell className="text-right font-mono tabular">{formatUsd(s.spendUsd)}</TableCell>
75+ <TableCell className="text-right font-mono tabular">
76+ <Link href={`/dashboard/api-keys?project=${p.id}`} className="underline-offset-4 hover:underline">
77+ {s.activeKeys}
78+ </Link>
79+ </TableCell>
80+ <TableCell className="whitespace-nowrap tabular text-fg-muted">{formatDateOnly(p.createdAt)}</TableCell>
81+ <TableCell className="text-right">
82+ <div className="flex items-center justify-end gap-1.5">
83+ {!current ? <SelectProjectButton projectId={p.id} /> : null}
84+ <Button asChild size="xs" variant="ghost">
85+ <Link href={`/dashboard/projects/${p.id}`}>
86+ Open <ArrowRight />
87+ </Link>
88+ </Button>
89+ </div>
90+ </TableCell>
91+ </TableRow>
92+ );
93+ })}
94+ </TableBody>
95+ </Table>
96+ </div>
97+ )}
98+ </div>
99+ );
100+}
added apps/web/src/app/dashboard/requests/[id]/page.tsx +233 −0
@@ -0,0 +1,233 @@
1+import Link from "next/link";
2+import { notFound } from "next/navigation";
3+import { ArrowLeft, LifeBuoy } from "lucide-react";
4+import { ERROR_MESSAGES, type ErrorCode } from "@fetcha/core";
5+import { getWorkspace } from "@/lib/session";
6+import { formatBytes, formatDate, formatMs, formatUsd, timeAgo } from "@/lib/format";
7+import { getRequestDetail } from "@/lib/queries/dashboard";
8+import { PageHeader } from "@/components/ui/page-header";
9+import { Badge, StatusBadge } from "@/components/ui/badge";
10+import { Button } from "@/components/ui/button";
11+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
12+import { CopyButton } from "@/components/ui/copy-button";
13+import { Alert } from "@/components/ui/alert";
14+import { RequestDetailTabs } from "@/components/dashboard/requests/request-detail-tabs";
15+import { TimingWaterfall } from "@/components/dashboard/requests/timing-waterfall";
16+import { AttemptsTimeline } from "@/components/dashboard/requests/attempts-timeline";
17+import { HeadersTables } from "@/components/dashboard/requests/headers-table";
18+import { HttpCode, NetworkLabel } from "@/components/dashboard/requests/requests-table";
19+
20+export const dynamic = "force-dynamic";
21+
22+function Row({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) {
23+ return (
24+ <div className="grid grid-cols-[140px_1fr] gap-3 px-4 py-2.5 text-[13px] sm:grid-cols-[180px_1fr]">
25+ <dt className="text-fg-subtle">{label}</dt>
26+ <dd className={`min-w-0 break-all ${mono ? "font-mono tabular" : ""}`}>{children}</dd>
27+ </div>
28+ );
29+}
30+
31+export default async function RequestDetailPage({ params }: { params: Promise<{ id: string }> }) {
32+ const [ws, { id }] = await Promise.all([getWorkspace(), params]);
33+ if (!/^req_[A-Za-z0-9]{4,64}$/.test(id)) notFound();
34+ const req = await getRequestDetail(ws.organization.id, id, { providerVisibility: ws.organization.providerVisibility });
35+ if (!req) notFound();
36+
37+ const headerCount = Object.keys(req.requestHeaders ?? {}).length + Object.keys(req.responseHeaders ?? {}).length;
38+ const errorHelp = req.errorCode && req.errorCode in ERROR_MESSAGES ? ERROR_MESSAGES[req.errorCode as ErrorCode] : null;
39+ const otherProject = req.projectId !== ws.project.id;
40+
41+ const overview = (
42+ <div className="grid gap-4 lg:grid-cols-2">
43+ <Card>
44+ <CardHeader className="pb-1">
45+ <CardTitle className="text-[14px]">Request</CardTitle>
46+ </CardHeader>
47+ <dl className="divide-y divide-border">
48+ <Row label="URL" mono>
49+ <a href={req.url} target="_blank" rel="noreferrer noopener" className="text-accent underline-offset-4 hover:underline">
50+ {req.url}
51+ </a>
52+ </Row>
53+ {req.finalUrl && req.finalUrl !== req.url ? (
54+ <Row label="Final URL" mono>
55+ {req.finalUrl}
56+ </Row>
57+ ) : null}
58+ <Row label="Method" mono>
59+ {req.method}
60+ </Row>
61+ <Row label="Domain">{req.domain}</Row>
62+ <Row label="Format" mono>
63+ {req.format}
64+ </Row>
65+ <Row label="Source">
66+ <Badge variant="outline">{req.source}</Badge>
67+ {req.browser ? (
68+ <Badge variant="warning" className="ml-1.5">
69+ browser requested
70+ </Badge>
71+ ) : null}
72+ </Row>
73+ <Row label="Project">
74+ {req.projectName}
75+ {otherProject ? <span className="ml-1.5 text-[11.5px] text-fg-subtle">(not the current project)</span> : null}
76+ </Row>
77+ <Row label="API key">
78+ {req.apiKey ? (
79+ <span className="inline-flex flex-wrap items-center gap-1.5">
80+ <Link href="/dashboard/api-keys" className="underline-offset-4 hover:underline">
81+ {req.apiKey.name}
82+ </Link>
83+ <code className="font-mono text-[12px] text-fg-muted">
84+ {req.apiKey.prefix}…{req.apiKey.last4}
85+ </code>
86+ {req.apiKey.revoked ? <Badge variant="danger">revoked</Badge> : null}
87+ </span>
88+ ) : (
89+ <span className="text-fg-subtle">{req.source === "playground" ? "Playground (no key)" : "—"}</span>
90+ )}
91+ </Row>
92+ <Row label="Session">
93+ {req.sessionId ? (
94+ <Link href="/dashboard/sessions" className="font-mono text-accent underline-offset-4 hover:underline">
95+ {req.sessionId}
96+ </Link>
97+ ) : (
98+ <span className="text-fg-subtle">none</span>
99+ )}
100+ </Row>
101+ </dl>
102+ </Card>
103+ <Card>
104+ <CardHeader className="pb-1">
105+ <CardTitle className="text-[14px]">Routing and result</CardTitle>
106+ </CardHeader>
107+ <dl className="divide-y divide-border">
108+ <Row label="Network">
109+ <NetworkLabel network={req.network} requested={req.requestedNetwork} />
110+ <span className="ml-1.5 text-[11.5px] text-fg-subtle">requested {req.requestedNetwork}</span>
111+ </Row>
112+ <Row label="Location" mono>
113+ {[req.country, req.region, req.city].filter(Boolean).join(" · ") || <span className="font-sans text-fg-subtle">any</span>}
114+ </Row>
115+ <Row label="HTTP status">
116+ <HttpCode code={req.httpStatus} />
117+ </Row>
118+ <Row label="Latency" mono>
119+ {formatMs(req.latencyMs)}
120+ </Row>
121+ <Row label="Attempts" mono>
122+ {req.attempts}
123+ </Row>
124+ <Row label="Bytes in / out" mono>
125+ {formatBytes(req.bytesIn)} / {formatBytes(req.bytesOut)}
126+ </Row>
127+ <Row label="Price" mono>
128+ {formatUsd(req.priceUsd, true)}
129+ {req.cached ? <Badge variant="info" className="ml-1.5 font-sans">cached</Badge> : null}
130+ </Row>
131+ {req.errorCode ? (
132+ <Row label="Error" mono>
133+ <span className="text-danger">{req.errorCode}</span>
134+ {req.errorMessage ? <div className="mt-0.5 font-sans text-fg-muted">{req.errorMessage}</div> : null}
135+ </Row>
136+ ) : null}
137+ <Row label="Completed">{req.completedAt ? formatDate(req.completedAt, { timeStyle: "medium" }) : <span className="text-fg-subtle">pending</span>}</Row>
138+ </dl>
139+ </Card>
140+ </div>
141+ );
142+
143+ return (
144+ <div className="flex flex-col gap-5">
145+ <Link href="/dashboard/requests" className="inline-flex items-center gap-1 text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">
146+ <ArrowLeft className="size-3.5" /> Requests
147+ </Link>
148+ <PageHeader
149+ eyebrow="Request"
150+ title={
151+ <span className="inline-flex flex-wrap items-center gap-2">
152+ <span className="font-mono text-[18px] sm:text-[20px]">{req.id}</span>
153+ <CopyButton value={req.id} />
154+ <StatusBadge status={req.status} />
155+ </span>
156+ }
157+ description={
158+ <>
159+ {formatDate(req.createdAt, { timeStyle: "medium" })} ({timeAgo(req.createdAt)}) · {req.method} {req.domain}
160+ </>
161+ }
162+ actions={
163+ <Button asChild variant="outline" size="sm">
164+ <Link href={`/dashboard/requests?id=${encodeURIComponent(req.id)}`}>Find in log</Link>
165+ </Button>
166+ }
167+ />
168+
169+ {req.status === "failed" && errorHelp ? (
170+ <Alert variant="danger" title={req.errorCode ?? "Request failed"}>
171+ {req.errorMessage ?? errorHelp}
172+ {req.errorMessage && req.errorMessage !== errorHelp ? <span className="block text-fg-subtle">{errorHelp}</span> : null}
173+ </Alert>
174+ ) : null}
175+
176+ <div className="grid gap-5 lg:grid-cols-[1fr_280px]">
177+ <div className="min-w-0">
178+ <RequestDetailTabs
179+ attemptCount={req.attemptRows.length}
180+ headerCount={headerCount}
181+ overview={overview}
182+ attempts={
183+ <Card>
184+ <CardHeader>
185+ <CardTitle className="text-[14px]">Attempts</CardTitle>
186+ <CardDescription>Each route the router tried, in order. A blocked route escalates to another network or IP automatically.</CardDescription>
187+ </CardHeader>
188+ <CardContent>
189+ <AttemptsTimeline attempts={req.attemptRows} providerVisibility={ws.organization.providerVisibility} />
190+ </CardContent>
191+ </Card>
192+ }
193+ headers={<HeadersTables request={req.requestHeaders} response={req.responseHeaders} />}
194+ timing={<TimingWaterfall timing={req.timing} totalMs={req.latencyMs} />}
195+ />
196+ </div>
197+ <aside className="flex flex-col gap-4">
198+ <Card>
199+ <CardHeader className="pb-2">
200+ <CardTitle className="flex items-center gap-2 text-[14px]">
201+ <LifeBuoy className="size-4 text-fg-subtle" aria-hidden /> Need help?
202+ </CardTitle>
203+ </CardHeader>
204+ <CardContent className="text-[13px] text-fg-muted">
205+ Include this request ID when contacting{" "}
206+ <a href={`mailto:support@fetcha.co?subject=${encodeURIComponent(`Request ${req.id}`)}`} className="text-accent underline-offset-4 hover:underline">
207+ support@fetcha.co
208+ </a>
209+ . It lets us trace every attempt without you sharing the URL or headers.
210+ <div className="mt-3 flex items-center gap-1 rounded-md border border-border bg-bg-subtle px-2 py-1 font-mono text-[12px] text-fg">
211+ <span className="truncate">{req.id}</span>
212+ <CopyButton value={req.id} className="ml-auto" />
213+ </div>
214+ </CardContent>
215+ </Card>
216+ <Card>
217+ <CardHeader className="pb-2">
218+ <CardTitle className="text-[14px]">Reading this page</CardTitle>
219+ </CardHeader>
220+ <CardContent className="space-y-2 text-[12.5px] text-fg-muted">
221+ <p>
222+ <strong className="text-fg">Attempts</strong> greater than 1 means the first route was blocked or timed out and the router escalated. You are billed for the bytes that were actually transferred.
223+ </p>
224+ <p>
225+ <strong className="text-fg">Network</strong> is the class that served the final attempt. <code className="font-mono">auto</code> resolves to residential today.
226+ </p>
227+ </CardContent>
228+ </Card>
229+ </aside>
230+ </div>
231+ </div>
232+ );
233+}
added apps/web/src/app/dashboard/requests/export/route.ts +96 −0
@@ -0,0 +1,96 @@
1+import { getWorkspace } from "@/lib/session";
2+import { parseRequestFilters, REQUESTS_EXPORT_MAX, type SearchParams } from "@/lib/requests-filters";
3+import { listRequestsBatch, type RequestListRow, type Scope } from "@/lib/queries/dashboard";
4+
5+export const dynamic = "force-dynamic";
6+
7+const COLUMNS: Array<[string, (r: RequestListRow) => unknown]> = [
8+ ["request_id", (r) => r.id],
9+ ["timestamp", (r) => r.createdAt.toISOString()],
10+ ["completed_at", (r) => r.completedAt?.toISOString() ?? ""],
11+ ["status", (r) => r.status],
12+ ["http_status", (r) => r.httpStatus ?? ""],
13+ ["error_code", (r) => r.errorCode ?? ""],
14+ ["method", (r) => r.method],
15+ ["url", (r) => r.url],
16+ ["final_url", (r) => r.finalUrl ?? ""],
17+ ["domain", (r) => r.domain],
18+ ["source", (r) => r.source],
19+ ["requested_network", (r) => r.requestedNetwork],
20+ ["network", (r) => r.network ?? ""],
21+ ["country", (r) => r.country ?? ""],
22+ ["region", (r) => r.region ?? ""],
23+ ["city", (r) => r.city ?? ""],
24+ ["session_id", (r) => r.sessionId ?? ""],
25+ ["latency_ms", (r) => r.latencyMs ?? ""],
26+ ["attempts", (r) => r.attempts],
27+ ["bytes_in", (r) => r.bytesIn],
28+ ["bytes_out", (r) => r.bytesOut],
29+ ["price_usd", (r) => r.priceUsd.toFixed(6)],
30+ ["cached", (r) => (r.cached ? "true" : "false")],
31+];
32+
33+/** RFC 4180 quoting plus a guard against spreadsheet formula injection. */
34+function cell(v: unknown): string {
35+ let s = v === null || v === undefined ? "" : String(v);
36+ if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
37+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
38+}
39+
40+const BATCH = 1000;
41+
42+/**
43+ * GET /dashboard/requests/export?…filters — streams a CSV of the current filter set (newest first),
44+ * capped at REQUESTS_EXPORT_MAX rows. Upstream cost is never included; `price_usd` is the billed price.
45+ */
46+export async function GET(req: Request) {
47+ const ws = await getWorkspace();
48+ const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id };
49+ const url = new URL(req.url);
50+ const sp: SearchParams = Object.fromEntries(url.searchParams.entries());
51+ const filters = parseRequestFilters(sp);
52+ const encoder = new TextEncoder();
53+ const state = { offset: 0, done: false };
54+
55+ const stream = new ReadableStream<Uint8Array>({
56+ async start(controller) {
57+ controller.enqueue(encoder.encode(`${COLUMNS.map(([name]) => name).join(",")}\r\n`));
58+ },
59+ async pull(controller) {
60+ // Pull-based: each call fetches one batch; state lives in the closure.
61+ const offset = state.offset;
62+ if (offset >= REQUESTS_EXPORT_MAX || state.done) {
63+ controller.close();
64+ return;
65+ }
66+ const limit = Math.min(BATCH, REQUESTS_EXPORT_MAX - offset);
67+ let rows: RequestListRow[];
68+ try {
69+ rows = await listRequestsBatch(scope, filters, offset, limit);
70+ } catch (e) {
71+ controller.error(e);
72+ return;
73+ }
74+ if (!rows.length) {
75+ state.done = true;
76+ controller.close();
77+ return;
78+ }
79+ const chunk = rows.map((r) => COLUMNS.map(([, get]) => cell(get(r))).join(",")).join("\r\n") + "\r\n";
80+ controller.enqueue(encoder.encode(chunk));
81+ state.offset += rows.length;
82+ if (rows.length < limit) state.done = true;
83+ },
84+ });
85+
86+ const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "").replace(/(\d{8})(\d{6})/, "$1-$2");
87+ const slug = ws.project.slug || "project";
88+ return new Response(stream, {
89+ headers: {
90+ "content-type": "text/csv; charset=utf-8",
91+ "content-disposition": `attachment; filename="fetcha-requests-${slug}-${stamp}.csv"`,
92+ "cache-control": "no-store",
93+ "x-content-type-options": "nosniff",
94+ },
95+ });
96+}
added apps/web/src/app/dashboard/requests/loading.tsx +25 −0
@@ -0,0 +1,25 @@
1+import { Skeleton, TableSkeleton } from "@/components/ui/skeleton";
2+
3+export default function RequestsLoading() {
4+ return (
5+ <div className="flex flex-col gap-5" aria-busy="true" aria-label="Loading requests">
6+ <div className="flex flex-col gap-2">
7+ <Skeleton className="h-7 w-40" />
8+ <Skeleton className="h-3.5 w-[420px] max-w-full" />
9+ </div>
10+ <div className="rounded-lg border border-border bg-bg-elevated p-4">
11+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
12+ {Array.from({ length: 10 }).map((_, i) => (
13+ <div key={i} className="flex flex-col gap-1.5">
14+ <Skeleton className="h-3 w-16" />
15+ <Skeleton className="h-9 w-full" />
16+ </div>
17+ ))}
18+ </div>
19+ </div>
20+ <div className="rounded-lg border border-border bg-bg-elevated">
21+ <TableSkeleton rows={12} cols={8} />
22+ </div>
23+ </div>
24+ );
25+}
added apps/web/src/app/dashboard/requests/page.tsx +71 −0
@@ -0,0 +1,71 @@
1+import Link from "next/link";
2+import { ListTree } from "lucide-react";
3+import { getWorkspace } from "@/lib/session";
4+import { API_PUBLIC_URL } from "@/lib/utils";
5+import { parseRequestFilters, requestsHref, REQUESTS_PAGE_SIZE, type SearchParams } from "@/lib/requests-filters";
6+import { listRequests, projectHasRequests, type Scope } from "@/lib/queries/dashboard";
7+import { PageHeader } from "@/components/ui/page-header";
8+import { Card } from "@/components/ui/card";
9+import { Button } from "@/components/ui/button";
10+import { EmptyState } from "@/components/ui/empty-state";
11+import { CodeBlock } from "@/components/ui/code-block";
12+import { RequestsTable } from "@/components/dashboard/requests/requests-table";
13+import { ActiveFilterChips, RequestsFilterForm } from "@/components/dashboard/requests/requests-filters";
14+import { Pagination } from "@/components/dashboard/requests/pagination";
15+
16+export const dynamic = "force-dynamic";
17+
18+const CURL_SNIPPET = `curl -X POST ${API_PUBLIC_URL}/v1/fetch \\
19+ -H "Authorization: Bearer fch_live_YOUR_KEY" \\
20+ -H "Content-Type: application/json" \\
21+ -d '{ "url": "https://example.com", "country": "CA", "format": "text" }'`;
22+
23+export default async function RequestsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
24+ const [ws, sp] = await Promise.all([getWorkspace(), searchParams]);
25+ const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id };
26+ const filters = parseRequestFilters(sp);
27+ const [hasAny, result] = await Promise.all([projectHasRequests(scope), listRequests(scope, filters, REQUESTS_PAGE_SIZE)]);
28+
29+ return (
30+ <div className="flex flex-col gap-5">
31+ <PageHeader
32+ title="Requests"
33+ description={`Every request routed for ${ws.project.name}, with its resolved network, latency, attempts and billed price. Logs are retained according to your plan.`}
34+ />
35+
36+ {!hasAny ? (
37+ <EmptyState
38+ icon={ListTree}
39+ title="No requests yet"
40+ description="Requests appear here the moment you send one. Start in the Playground, or call the API directly with a key from API Keys."
41+ action={
42+ <>
43+ <Button asChild variant="primary" size="sm">
44+ <Link href="/dashboard/playground">Open Playground</Link>
45+ </Button>
46+ <Button asChild variant="outline" size="sm">
47+ <Link href="/dashboard/api-keys">Create API key</Link>
48+ </Button>
49+ </>
50+ }
51+ className="pb-8"
52+ />
53+ ) : null}
54+
55+ {!hasAny ? (
56+ <div className="mx-auto w-full max-w-2xl">
57+ <CodeBlock code={CURL_SNIPPET} lang="bash" title="Your first request (replace the key)" />
58+ </div>
59+ ) : (
60+ <>
61+ <RequestsFilterForm filters={filters} exportCount={result.total} />
62+ <ActiveFilterChips filters={filters} />
63+ <Card className="overflow-hidden">
64+ <RequestsTable rows={result.rows} emptyMessage={<span>No requests match these filters. Widen the date range or <Link href="/dashboard/requests" className="text-accent underline-offset-4 hover:underline">clear the filters</Link>.</span>} />
65+ </Card>
66+ <Pagination page={result.page} pageCount={result.pageCount} total={result.total} pageSize={REQUESTS_PAGE_SIZE} hrefFor={(p) => requestsHref(filters, { page: p })} />
67+ </>
68+ )}
69+ </div>
70+ );
71+}
added apps/web/src/app/dashboard/sessions/loading.tsx +30 −0
@@ -0,0 +1,30 @@
1+import { Skeleton, TableSkeleton } from "@/components/ui/skeleton";
2+
3+export default function SessionsLoading() {
4+ return (
5+ <div className="flex flex-col gap-5" aria-busy="true" aria-label="Loading sessions">
6+ <div className="flex items-start justify-between gap-3">
7+ <div className="flex flex-col gap-2">
8+ <Skeleton className="h-7 w-36" />
9+ <Skeleton className="h-3.5 w-[420px] max-w-full" />
10+ </div>
11+ <Skeleton className="h-8 w-32" />
12+ </div>
13+ <div className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-3">
14+ {Array.from({ length: 3 }).map((_, i) => (
15+ <div key={i} className="flex flex-col gap-2 bg-bg-elevated px-5 py-4">
16+ <Skeleton className="h-3 w-24" />
17+ <Skeleton className="h-6 w-16" />
18+ </div>
19+ ))}
20+ </div>
21+ <div className="rounded-lg border border-border bg-bg-elevated">
22+ <TableSkeleton rows={6} cols={8} />
23+ </div>
24+ <div className="grid gap-4 lg:grid-cols-2">
25+ <Skeleton className="h-64 w-full" />
26+ <Skeleton className="h-64 w-full" />
27+ </div>
28+ </div>
29+ );
30+}
added apps/web/src/app/dashboard/sessions/page.tsx +92 −0
@@ -0,0 +1,92 @@
1+import Link from "next/link";
2+import { Waypoints } from "lucide-react";
3+import { getWorkspace } from "@/lib/session";
4+import { API_PUBLIC_URL } from "@/lib/utils";
5+import { listProjectSessions, type Scope } from "@/lib/queries/dashboard";
6+import { PageHeader } from "@/components/ui/page-header";
7+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8+import { EmptyState } from "@/components/ui/empty-state";
9+import { Stat, StatGrid } from "@/components/ui/stat";
10+import { CodeBlock } from "@/components/ui/code-block";
11+import { formatNumber } from "@/lib/format";
12+import { SessionsTable } from "@/components/dashboard/sessions/sessions-table";
13+import { CreateSessionDialog } from "@/components/dashboard/sessions/create-session-dialog";
14+
15+export const dynamic = "force-dynamic";
16+
17+const API_SNIPPET = `# 1. Create a session (60–1800 s)
18+curl -X POST ${API_PUBLIC_URL}/v1/sessions \\
19+ -H "Authorization: Bearer fch_live_YOUR_KEY" \\
20+ -H "Content-Type: application/json" \\
21+ -d '{ "country": "CA", "ttl": 600, "label": "checkout" }'
22+# → { "id": "sess_…", "status": "active", … }
23+
24+# 2. Reuse it on every request that must share the same IP and cookies
25+curl -X POST ${API_PUBLIC_URL}/v1/fetch \\
26+ -H "Authorization: Bearer fch_live_YOUR_KEY" \\
27+ -H "Content-Type: application/json" \\
28+ -d '{ "url": "https://example.com/cart", "session": "sess_…" }'`;
29+
30+export default async function SessionsPage() {
31+ const ws = await getWorkspace();
32+ const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id };
33+ const rows = await listProjectSessions(scope);
34+ const active = rows.filter((r) => r.status === "active");
35+ const totalRequests = rows.reduce((a, r) => a + r.requestCount, 0);
36+
37+ return (
38+ <div className="flex flex-col gap-5">
39+ <PageHeader
40+ title="Sessions"
41+ description={`Sticky sessions for ${ws.project.name}. A session keeps the same exit IP and cookie jar across requests until it expires or is closed.`}
42+ actions={<CreateSessionDialog apiBase={API_PUBLIC_URL} defaultCountry={ws.project.defaultCountry} />}
43+ />
44+
45+ <StatGrid cols={3}>
46+ <Stat label="Active sessions" value={formatNumber(active.length)} hint="expire automatically" />
47+ <Stat label="Sessions created" value={formatNumber(rows.length)} hint="in this project" />
48+ <Stat label="Requests via sessions" value={formatNumber(totalRequests)} hint="all time" />
49+ </StatGrid>
50+
51+ {rows.length ? (
52+ <Card className="overflow-hidden">
53+ <SessionsTable rows={rows} />
54+ </Card>
55+ ) : (
56+ <EmptyState
57+ icon={Waypoints}
58+ title="No sessions yet"
59+ description="Create one here to try it, or from your code with POST /v1/sessions. Sessions are optional: stateless requests get a fresh IP every time."
60+ action={<CreateSessionDialog apiBase={API_PUBLIC_URL} defaultCountry={ws.project.defaultCountry} />}
61+ />
62+ )}
63+
64+ <div className="grid gap-4 lg:grid-cols-2">
65+ <Card>
66+ <CardHeader>
67+ <CardTitle>How sessions work</CardTitle>
68+ <CardDescription>When to pin an IP, and when not to.</CardDescription>
69+ </CardHeader>
70+ <CardContent className="space-y-3 text-[13px] leading-relaxed text-fg-muted">
71+ <p>
72+ <strong className="text-fg">Same IP, same cookies.</strong> Every request that passes <code className="font-mono">session: &quot;sess_…&quot;</code> is routed through the same exit IP in the requested country, and cookies set by the target are replayed automatically. Use it for logins, carts, multi-step forms and paginated listings that break when the visitor changes.
73+ </p>
74+ <p>
75+ <strong className="text-fg">Short-lived by design.</strong> TTL is 60 to 1800 seconds and does not extend on use. When a session expires or the IP is rotated away by the network, requests fail with <code className="font-mono">SESSION_EXPIRED</code>; create a new one and retry.
76+ </p>
77+ <p>
78+ <strong className="text-fg">Network class.</strong> Only <code className="font-mono">auto</code> and <code className="font-mono">residential</code> are available for sessions today. Datacenter, ISP and mobile are coming soon.
79+ </p>
80+ <p>
81+ <strong className="text-fg">Billing.</strong> Sessions are free; you pay for the requests and bandwidth that go through them. Closing a session early frees the sticky slot immediately.
82+ </p>
83+ <p className="text-fg-subtle">
84+ Requests made through a session are listed in <Link href="/dashboard/requests" className="underline-offset-4 hover:underline">Requests</Link> with the session id on the detail page.
85+ </p>
86+ </CardContent>
87+ </Card>
88+ <CodeBlock code={API_SNIPPET} lang="bash" title="Sessions from the API" className="self-start" />
89+ </div>
90+ </div>
91+ );
92+}
added apps/web/src/app/dashboard/settings/audit/page.tsx +110 −0
@@ -0,0 +1,110 @@
1+import Link from "next/link";
2+import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react";
3+import { getWorkspace } from "@/lib/session";
4+import { listAuditLogs } from "@/lib/queries/account";
5+import { formatDate, titleCase, truncate } from "@/lib/format";
6+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
7+import { EmptyState } from "@/components/ui/empty-state";
8+import { Badge } from "@/components/ui/badge";
9+import { Button } from "@/components/ui/button";
10+import { SimpleTooltip } from "@/components/ui/tooltip";
11+
12+export const dynamic = "force-dynamic";
13+
14+const PAGE_SIZE = 25;
15+
16+function actionVariant(action: string): "default" | "success" | "danger" | "warning" | "info" | "accent" {
17+ if (action.endsWith(".revoked") || action.endsWith(".deleted") || action.endsWith(".archived")) return "danger";
18+ if (action.endsWith(".created") || action === "login" || action === "email.verified") return "success";
19+ if (action.startsWith("password") || action.startsWith("email") || action.startsWith("session")) return "warning";
20+ if (action.startsWith("admin")) return "accent";
21+ return "default";
22+}
23+
24+function summarize(meta: Record<string, unknown> | null): string {
25+ if (!meta) return "—";
26+ const parts = Object.entries(meta)
27+ .filter(([, v]) => v !== null && v !== undefined && typeof v !== "object")
28+ .map(([k, v]) => `${k}: ${String(v)}`);
29+ return parts.length ? truncate(parts.join(" · "), 72) : "—";
30+}
31+
32+export default async function AuditLogPage({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
33+ const sp = await searchParams;
34+ const page = Math.max(1, Number.parseInt(sp.page ?? "1", 10) || 1);
35+ const ws = await getWorkspace();
36+ const { rows, total } = await listAuditLogs(ws.user.id, ws.organization.id, page, PAGE_SIZE);
37+ const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
38+
39+ return (
40+ <div className="grid gap-4">
41+ <div className="flex flex-wrap items-end justify-between gap-3">
42+ <div>
43+ <h2 className="text-[15px] font-semibold tracking-tight">Audit log</h2>
44+ <p className="text-[13px] text-fg-muted">Security-relevant events for your account and organization. Retained for 365 days.</p>
45+ </div>
46+ <p className="text-[12.5px] text-fg-subtle tabular">{total.toLocaleString("en-US")} events</p>
47+ </div>
48+
49+ {rows.length === 0 ? (
50+ <EmptyState icon={ScrollText} title={page > 1 ? "Nothing on this page" : "No events yet"} description={page > 1 ? "Go back to the first page." : "Logins, key changes and settings updates will appear here."} action={page > 1 ? <Button asChild variant="outline" size="sm"><Link href="/dashboard/settings/audit">First page</Link></Button> : undefined} />
51+ ) : (
52+ <div className="overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-xs">
53+ <Table>
54+ <TableHeader>
55+ <TableRow className="hover:bg-transparent">
56+ <TableHead>Time</TableHead>
57+ <TableHead>Action</TableHead>
58+ <TableHead>Target</TableHead>
59+ <TableHead>Details</TableHead>
60+ <TableHead>IP</TableHead>
61+ <TableHead>User agent</TableHead>
62+ </TableRow>
63+ </TableHeader>
64+ <TableBody>
65+ {rows.map((r) => (
66+ <TableRow key={r.id}>
67+ <TableCell className="whitespace-nowrap tabular text-fg-muted">{formatDate(r.createdAt)}</TableCell>
68+ <TableCell>
69+ <Badge variant={actionVariant(r.action)}>{titleCase(r.action.replace(/\./g, " "))}</Badge>
70+ </TableCell>
71+ <TableCell className="font-mono text-[12px] text-fg-muted">{r.target ? truncate(r.target, 28) : "—"}</TableCell>
72+ <TableCell className="max-w-[280px] truncate text-[12.5px] text-fg-muted" title={r.metadata ? JSON.stringify(r.metadata) : undefined}>
73+ {summarize(r.metadata)}
74+ </TableCell>
75+ <TableCell className="font-mono text-[12px] tabular">{r.ipAddress ?? "—"}</TableCell>
76+ <TableCell className="text-[12px] text-fg-subtle">
77+ {r.userAgent ? (
78+ <SimpleTooltip content={<span className="font-mono text-[11px]">{r.userAgent}</span>}>
79+ <span className="font-mono">{truncate(r.userAgent, 36)}</span>
80+ </SimpleTooltip>
81+ ) : (
82+ "—"
83+ )}
84+ </TableCell>
85+ </TableRow>
86+ ))}
87+ </TableBody>
88+ </Table>
89+ <div className="flex items-center justify-between border-t border-border px-4 py-2 text-[12.5px] text-fg-muted">
90+ <span className="tabular">
91+ Page {page} of {pages}
92+ </span>
93+ <div className="flex items-center gap-1">
94+ <Button asChild variant="ghost" size="xs" aria-disabled={page <= 1}>
95+ <Link href={`/dashboard/settings/audit?page=${page - 1}`} aria-label="Previous page" className={page <= 1 ? "pointer-events-none opacity-50" : undefined}>
96+ <ChevronLeft /> Prev
97+ </Link>
98+ </Button>
99+ <Button asChild variant="ghost" size="xs" aria-disabled={page >= pages}>
100+ <Link href={`/dashboard/settings/audit?page=${page + 1}`} aria-label="Next page" className={page >= pages ? "pointer-events-none opacity-50" : undefined}>
101+ Next <ChevronRight />
102+ </Link>
103+ </Button>
104+ </div>
105+ </div>
106+ </div>
107+ )}
108+ </div>
109+ );
110+}
added apps/web/src/app/dashboard/settings/layout.tsx +16 −0
@@ -0,0 +1,16 @@
1+import { PageHeader } from "@/components/ui/page-header";
2+import { SettingsNav } from "@/components/dashboard/settings/settings-nav";
3+
4+export const dynamic = "force-dynamic";
5+
6+export const metadata = { title: "Settings · Fetcha" };
7+
8+export default function SettingsLayout({ children }: { children: React.ReactNode }) {
9+ return (
10+ <div className="grid gap-6">
11+ <PageHeader title="Settings" description="Your profile, organization, security and compliance records." className="mb-0" />
12+ <SettingsNav />
13+ <div className="animate-fade-in">{children}</div>
14+ </div>
15+ );
16+}
added apps/web/src/app/dashboard/settings/legal/page.tsx +90 −0
@@ -0,0 +1,90 @@
1+import Link from "next/link";
2+import { ExternalLink, FileCheck2 } from "lucide-react";
3+import { getWorkspace } from "@/lib/session";
4+import { listLegalAcceptances } from "@/lib/queries/account";
5+import { formatDate } from "@/lib/format";
6+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
7+import { EmptyState } from "@/components/ui/empty-state";
8+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
9+
10+export const dynamic = "force-dynamic";
11+
12+const DOCUMENTS: Record<string, { label: string; href: string }> = {
13+ terms: { label: "Terms of Service", href: "/legal/terms" },
14+ privacy: { label: "Privacy Policy", href: "/legal/privacy" },
15+ acceptable_use: { label: "Acceptable Use Policy", href: "/legal/acceptable-use" },
16+};
17+
18+export default async function LegalPage() {
19+ const ws = await getWorkspace();
20+ const rows = await listLegalAcceptances(ws.user.id);
21+
22+ return (
23+ <div className="grid gap-6">
24+ <div className="grid gap-4">
25+ <div>
26+ <h2 className="text-[15px] font-semibold tracking-tight">Accepted documents</h2>
27+ <p className="text-[13px] text-fg-muted">Every version you agreed to, with the time and IP address recorded at acceptance.</p>
28+ </div>
29+ {rows.length === 0 ? (
30+ <EmptyState icon={FileCheck2} title="No acceptances recorded" description="Acceptances are recorded when you sign up. If your account predates this record, they will be requested the next time terms change." />
31+ ) : (
32+ <div className="overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-xs">
33+ <Table>
34+ <TableHeader>
35+ <TableRow className="hover:bg-transparent">
36+ <TableHead>Document</TableHead>
37+ <TableHead>Version</TableHead>
38+ <TableHead>Accepted</TableHead>
39+ <TableHead>IP address</TableHead>
40+ </TableRow>
41+ </TableHeader>
42+ <TableBody>
43+ {rows.map((r) => {
44+ const doc = DOCUMENTS[r.document];
45+ return (
46+ <TableRow key={r.id}>
47+ <TableCell>
48+ {doc ? (
49+ <Link href={doc.href} className="inline-flex items-center gap-1 font-medium underline-offset-4 hover:underline">
50+ {doc.label} <ExternalLink className="size-3 text-fg-subtle" aria-hidden />
51+ </Link>
52+ ) : (
53+ <span className="font-medium">{r.document}</span>
54+ )}
55+ </TableCell>
56+ <TableCell className="font-mono text-[12.5px] tabular">{r.version}</TableCell>
57+ <TableCell className="whitespace-nowrap tabular text-fg-muted">{formatDate(r.acceptedAt)}</TableCell>
58+ <TableCell className="font-mono text-[12.5px] tabular">{r.ipAddress ?? "—"}</TableCell>
59+ </TableRow>
60+ );
61+ })}
62+ </TableBody>
63+ </Table>
64+ </div>
65+ )}
66+ </div>
67+
68+ <Card>
69+ <CardHeader>
70+ <CardTitle>Current documents</CardTitle>
71+ <CardDescription>The versions in force today. We email you before material changes take effect.</CardDescription>
72+ </CardHeader>
73+ <CardContent>
74+ <ul className="grid gap-2 text-[13.5px] sm:grid-cols-3">
75+ {Object.values(DOCUMENTS).map((d) => (
76+ <li key={d.href}>
77+ <Link href={d.href} className="inline-flex items-center gap-1.5 text-fg underline-offset-4 hover:underline">
78+ {d.label} <ExternalLink className="size-3 text-fg-subtle" aria-hidden />
79+ </Link>
80+ </li>
81+ ))}
82+ </ul>
83+ <p className="mt-4 text-[12.5px] text-fg-subtle">
84+ Data processing questions: <a href="mailto:privacy@fetcha.co" className="underline-offset-4 hover:underline">privacy@fetcha.co</a>
85+ </p>
86+ </CardContent>
87+ </Card>
88+ </div>
89+ );
90+}
added apps/web/src/app/dashboard/settings/loading.tsx +11 −0
@@ -0,0 +1,11 @@
1+import { Skeleton } from "@/components/ui/skeleton";
2+
3+export default function Loading() {
4+ return (
5+ <div className="grid gap-6" aria-busy="true" aria-label="Loading settings">
6+ <Skeleton className="h-64" />
7+ <Skeleton className="h-48" />
8+ <Skeleton className="h-32" />
9+ </div>
10+ );
11+}
added apps/web/src/app/dashboard/settings/page.tsx +100 −0
@@ -0,0 +1,100 @@
1+import Link from "next/link";
2+import { BadgeCheck, MailWarning } from "lucide-react";
3+import { getWorkspace } from "@/lib/session";
4+import { formatDate, titleCase } from "@/lib/format";
5+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
6+import { Badge } from "@/components/ui/badge";
7+import { Alert } from "@/components/ui/alert";
8+import { ThemeToggle } from "@/components/ui/theme";
9+import { ResendVerificationButton } from "@/components/dashboard/resend-verification";
10+import { ProfileNameForm } from "@/components/dashboard/settings/profile-form";
11+import { ChangeEmailDialog } from "@/components/dashboard/settings/change-email-dialog";
12+import { OrganizationForm } from "@/components/dashboard/settings/organization-form";
13+import { DeleteAccountDialog } from "@/components/dashboard/settings/delete-account-dialog";
14+
15+export const dynamic = "force-dynamic";
16+
17+export default async function SettingsProfilePage({ searchParams }: { searchParams: Promise<{ "email-changed"?: string }> }) {
18+ const sp = await searchParams;
19+ const ws = await getWorkspace();
20+ const canEditOrg = ["owner", "admin", "billing"].includes(ws.role);
21+
22+ return (
23+ <div className="grid gap-6">
24+ {sp["email-changed"] === "1" ? <Alert variant="success" title="Email address updated">Your new address is now your login. If it is not verified yet, use the button below.</Alert> : null}
25+
26+ <Card>
27+ <CardHeader>
28+ <CardTitle>Profile</CardTitle>
29+ <CardDescription>How you appear across Fetcha. Member since {formatDate(ws.user.createdAt, { timeStyle: undefined })}.</CardDescription>
30+ </CardHeader>
31+ <CardContent className="grid gap-6">
32+ <ProfileNameForm name={ws.user.name} />
33+ <div className="grid gap-2 border-t border-border pt-5">
34+ <div className="text-[13px] font-medium">Email</div>
35+ <div className="flex flex-wrap items-center gap-3">
36+ <span className="font-mono text-[13.5px]">{ws.user.email}</span>
37+ {ws.user.emailVerified ? (
38+ <Badge variant="success">
39+ <BadgeCheck className="size-3" /> Verified
40+ </Badge>
41+ ) : (
42+ <Badge variant="warning">
43+ <MailWarning className="size-3" /> Unverified
44+ </Badge>
45+ )}
46+ <div className="ml-auto flex items-center gap-2">
47+ {!ws.user.emailVerified ? <ResendVerificationButton email={ws.user.email} size="sm" /> : null}
48+ <ChangeEmailDialog currentEmail={ws.user.email} />
49+ </div>
50+ </div>
51+ <p className="text-xs text-fg-subtle">{ws.user.emailVerified ? "Security notices and receipts go to this address." : "Verify your address to unlock production API access."}</p>
52+ </div>
53+ </CardContent>
54+ </Card>
55+
56+ <Card>
57+ <CardHeader>
58+ <div className="flex items-center justify-between gap-3">
59+ <CardTitle>Organization</CardTitle>
60+ <div className="flex items-center gap-2">
61+ <Badge variant="outline">{titleCase(ws.role)}</Badge>
62+ <Badge variant="accent">{titleCase(ws.organization.plan)} plan</Badge>
63+ </div>
64+ </div>
65+ <CardDescription>
66+ Organization-wide spending limits apply on top of per-project limits. Manage your plan in{" "}
67+ <Link href="/dashboard/billing" className="text-fg underline-offset-4 hover:underline">
68+ Billing
69+ </Link>
70+ . Teams and invitations are not available yet.
71+ </CardDescription>
72+ </CardHeader>
73+ <CardContent>
74+ <OrganizationForm name={ws.organization.name} softLimitUsd={ws.organization.softLimitUsd} hardLimitUsd={ws.organization.hardLimitUsd} canEdit={canEditOrg} />
75+ </CardContent>
76+ </Card>
77+
78+ <Card>
79+ <CardHeader>
80+ <CardTitle>Appearance</CardTitle>
81+ <CardDescription>Theme preference is stored in this browser.</CardDescription>
82+ </CardHeader>
83+ <CardContent className="flex items-center justify-between gap-4">
84+ <p className="text-[13px] text-fg-muted">Light, dark, or follow your system.</p>
85+ <ThemeToggle className="border border-border" />
86+ </CardContent>
87+ </Card>
88+
89+ <Card className="border-danger/30">
90+ <CardHeader>
91+ <CardTitle className="text-danger">Danger zone</CardTitle>
92+ <CardDescription>Deleting your account removes your organization, projects, keys and request history. Requires your password and an email confirmation.</CardDescription>
93+ </CardHeader>
94+ <CardContent>
95+ <DeleteAccountDialog email={ws.user.email} />
96+ </CardContent>
97+ </Card>
98+ </div>
99+ );
100+}
added apps/web/src/app/dashboard/settings/security/page.tsx +88 −0
@@ -0,0 +1,88 @@
1+import { KeyRound, LogIn, ShieldAlert } from "lucide-react";
2+import { getWorkspace } from "@/lib/session";
3+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4+import { Badge } from "@/components/ui/badge";
5+import { Switch } from "@/components/ui/switch";
6+import { ChangePasswordForm } from "@/components/dashboard/settings/change-password-form";
7+import { ActiveSessions } from "@/components/dashboard/settings/active-sessions";
8+
9+export const dynamic = "force-dynamic";
10+
11+export default async function SecurityPage() {
12+ await getWorkspace();
13+ return (
14+ <div className="grid gap-6">
15+ <Card>
16+ <CardHeader>
17+ <CardTitle>Change password</CardTitle>
18+ <CardDescription>Use at least 10 characters with mixed case and a number or symbol. Changing it is recorded in your audit log.</CardDescription>
19+ </CardHeader>
20+ <CardContent>
21+ <ChangePasswordForm />
22+ </CardContent>
23+ </Card>
24+
25+ <Card>
26+ <CardHeader>
27+ <CardTitle>Active sessions</CardTitle>
28+ <CardDescription>Everywhere you are signed in. Revoke anything you do not recognise — API keys are separate and unaffected.</CardDescription>
29+ </CardHeader>
30+ <CardContent>
31+ <ActiveSessions />
32+ </CardContent>
33+ </Card>
34+
35+ <div className="grid gap-6 md:grid-cols-2">
36+ <Card>
37+ <CardHeader>
38+ <div className="flex items-center justify-between gap-3">
39+ <CardTitle>Two-factor authentication</CardTitle>
40+ <Badge variant="outline">Coming soon</Badge>
41+ </div>
42+ <CardDescription>Authenticator app (TOTP) codes are planned first; passkeys will follow. Not available during the public preview.</CardDescription>
43+ </CardHeader>
44+ <CardContent>
45+ <label className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2.5 text-[13px] opacity-70">
46+ <span className="flex items-center gap-2">
47+ <KeyRound className="size-4 text-fg-subtle" aria-hidden /> Require a second factor at sign-in
48+ </span>
49+ <Switch disabled aria-label="Two-factor authentication (coming soon)" />
50+ </label>
51+ </CardContent>
52+ </Card>
53+
54+ <Card>
55+ <CardHeader>
56+ <CardTitle>Security notifications</CardTitle>
57+ <CardDescription>Emails we always send to your verified address. They cannot be turned off.</CardDescription>
58+ </CardHeader>
59+ <CardContent>
60+ <ul className="grid gap-2.5 text-[13px]">
61+ <li className="flex gap-2.5">
62+ <KeyRound className="mt-0.5 size-4 shrink-0 text-fg-subtle" aria-hidden />
63+ <span>
64+ <span className="font-medium">API key created or revoked</span>
65+ <span className="block text-fg-muted">Includes the key name and prefix, never the secret.</span>
66+ </span>
67+ </li>
68+ <li className="flex gap-2.5">
69+ <LogIn className="mt-0.5 size-4 shrink-0 text-fg-subtle" aria-hidden />
70+ <span>
71+ <span className="font-medium">Password, email or account changes</span>
72+ <span className="block text-fg-muted">Password resets, email change approvals and account deletion confirmations.</span>
73+ </span>
74+ </li>
75+ <li className="flex gap-2.5">
76+ <ShieldAlert className="mt-0.5 size-4 shrink-0 text-fg-subtle" aria-hidden />
77+ <span>
78+ <span className="font-medium">Spending limits</span>
79+ <span className="block text-fg-muted">When a soft limit is crossed or a hard limit stops requests.</span>
80+ </span>
81+ </li>
82+ </ul>
83+ </CardContent>
84+ </Card>
85+ </div>
86+ </div>
87+ );
88+}
added apps/web/src/app/dashboard/usage/loading.tsx +38 −0
@@ -0,0 +1,38 @@
1+import { Skeleton, TableSkeleton } from "@/components/ui/skeleton";
2+
3+export default function UsageLoading() {
4+ return (
5+ <div className="flex flex-col gap-6" aria-busy="true" aria-label="Loading usage">
6+ <div className="flex items-start justify-between gap-3">
7+ <div className="flex flex-col gap-2">
8+ <Skeleton className="h-7 w-28" />
9+ <Skeleton className="h-3.5 w-[420px] max-w-full" />
10+ </div>
11+ <Skeleton className="h-9 w-[420px] max-w-full" />
12+ </div>
13+ <div className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-2 lg:grid-cols-4">
14+ {Array.from({ length: 4 }).map((_, i) => (
15+ <div key={i} className="flex flex-col gap-2 bg-bg-elevated px-5 py-4">
16+ <Skeleton className="h-3 w-24" />
17+ <Skeleton className="h-6 w-20" />
18+ </div>
19+ ))}
20+ </div>
21+ <div className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-3">
22+ {Array.from({ length: 3 }).map((_, i) => (
23+ <div key={i} className="flex flex-col gap-2 bg-bg-elevated px-5 py-4">
24+ <Skeleton className="h-3 w-24" />
25+ <Skeleton className="h-6 w-20" />
26+ </div>
27+ ))}
28+ </div>
29+ <div className="grid gap-4 lg:grid-cols-3">
30+ <Skeleton className="h-40 lg:col-span-2" />
31+ <Skeleton className="h-40" />
32+ </div>
33+ <div className="rounded-lg border border-border bg-bg-elevated">
34+ <TableSkeleton rows={8} cols={4} />
35+ </div>
36+ </div>
37+ );
38+}
added apps/web/src/app/dashboard/usage/page.tsx +277 −0
@@ -0,0 +1,277 @@
1+import Link from "next/link";
2+import { Activity, ArrowRight } from "lucide-react";
3+import { PLAN_LIMITS, type Plan } from "@fetcha/core";
4+import { getWorkspace } from "@/lib/session";
5+import { formatBytes, formatCompact, formatDate, formatDateOnly, formatNumber, formatUsd } from "@/lib/format";
6+import { getUsageMonth, lastMonths, type Scope } from "@/lib/queries/dashboard";
7+import { cn } from "@/lib/utils";
8+import { PageHeader, SectionTitle } from "@/components/ui/page-header";
9+import { Stat, StatGrid } from "@/components/ui/stat";
10+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
11+import { Badge } from "@/components/ui/badge";
12+import { Button } from "@/components/ui/button";
13+import { EmptyState } from "@/components/ui/empty-state";
14+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
15+import { QuotaBar } from "@/components/dashboard/charts/quota-bar";
16+
17+export const dynamic = "force-dynamic";
18+
19+const GB = 1024 ** 3;
20+
21+function formatQuantity(metric: string, quantity: number, unit: string): string {
22+ if (unit === "bytes") return formatBytes(quantity);
23+ if (unit === "seconds") return `${formatNumber(quantity, { maximumFractionDigits: 1 })} s`;
24+ if (metric === "request") return `${formatNumber(quantity)} req`;
25+ return formatNumber(quantity, { maximumFractionDigits: 2 });
26+}
27+
28+function metricLabel(metric: string): string {
29+ switch (metric) {
30+ case "request":
31+ return "Request";
32+ case "bandwidth":
33+ return "Bandwidth";
34+ case "residential_bandwidth":
35+ return "Residential bandwidth";
36+ case "mobile_bandwidth":
37+ return "Mobile bandwidth";
38+ case "browser_seconds":
39+ return "Browser seconds";
40+ default:
41+ return metric.replace(/_/g, " ");
42+ }
43+}
44+
45+export default async function UsagePage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
46+ const [ws, sp] = await Promise.all([getWorkspace(), searchParams]);
47+ const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id };
48+ const monthParam = Array.isArray(sp.month) ? sp.month[0] : sp.month;
49+ const usage = await getUsageMonth(scope, monthParam);
50+ const months = lastMonths(6);
51+ const isCurrent = usage.month === months[0]!.key;
52+
53+ const plan = (ws.organization.plan in PLAN_LIMITS ? ws.organization.plan : "free") as Plan;
54+ const limits = PLAN_LIMITS[plan];
55+ const unlimited = limits.monthly_requests >= Number.MAX_SAFE_INTEGER;
56+ const projectCap = ws.project.monthlyRequestLimit;
57+ const requestLimit = projectCap && (unlimited || projectCap < limits.monthly_requests) ? projectCap : unlimited ? null : limits.monthly_requests;
58+ const includedBytes = limits.included_gb * GB;
59+ const residentialUsed = usage.residentialBytes || usage.bandwidthBytes;
60+
61+ const monthLabel = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" }).format(usage.from);
62+ const hasData = usage.requests > 0 || usage.ledger.length > 0;
63+
64+ return (
65+ <div className="flex flex-col gap-6">
66+ <PageHeader
67+ title="Usage"
68+ description={`Metered usage for ${ws.project.name}. Amounts are estimates until the monthly statement is issued; the ledger below is the source of truth.`}
69+ actions={
70+ <nav className="inline-flex h-9 items-center gap-0.5 rounded-md bg-bg-muted p-1" aria-label="Month">
71+ {months.map((m) => (
72+ <Link
73+ key={m.key}
74+ href={m.key === months[0]!.key ? "/dashboard/usage" : `/dashboard/usage?month=${m.key}`}
75+ className={cn("inline-flex h-7 items-center rounded-[5px] px-2.5 text-[12.5px] font-medium transition-colors", m.key === usage.month ? "bg-bg text-fg shadow-xs" : "text-fg-muted hover:text-fg")}
76+ aria-current={m.key === usage.month ? "page" : undefined}
77+ >
78+ {m.label}
79+ </Link>
80+ ))}
81+ </nav>
82+ }
83+ />
84+
85+ <section aria-label={monthLabel}>
86+ <SectionTitle right={<span className="text-[12px] text-fg-subtle">{isCurrent ? `Month to date · resets ${formatDateOnly(usage.to)}` : `${formatDateOnly(usage.from)} – ${formatDateOnly(new Date(usage.to.getTime() - 1))}`}</span>}>{monthLabel}</SectionTitle>
87+ <StatGrid cols={4}>
88+ <Stat label="Requests" value={formatNumber(usage.requests)} hint={requestLimit ? `of ${formatCompact(requestLimit)} included` : "unlimited"} />
89+ <Stat label="Successful" value={formatNumber(usage.successful)} hint={usage.requests ? `${((usage.successful / usage.requests) * 100).toFixed(1)}% of requests` : "—"} />
90+ <Stat label="Bandwidth" value={formatBytes(usage.bytes)} hint="transferred in + out" />
91+ <Stat label="Estimated spend" value={formatUsd(usage.spendUsd)} hint={limits.price_usd_month ? `plus ${formatUsd(limits.price_usd_month)} plan fee` : "no plan fee"} />
92+ </StatGrid>
93+ <StatGrid cols={3} className="mt-3">
94+ <Stat label="Residential bandwidth" value={formatBytes(residentialUsed)} hint={limits.included_gb ? `${limits.included_gb} GB included` : "metered"} />
95+ <Stat label="Mobile bandwidth" value={formatBytes(usage.mobileBytes)} hint={<Badge variant="outline">coming soon</Badge>} />
96+ <Stat label="Browser seconds" value={formatNumber(usage.browserSeconds)} hint={<Badge variant="outline">not launched</Badge>} />
97+ </StatGrid>
98+ </section>
99+
100+ <div className="grid gap-4 lg:grid-cols-3">
101+ <Card className="lg:col-span-2">
102+ <CardHeader>
103+ <CardTitle>Quotas</CardTitle>
104+ <CardDescription>Included allowances on the {limits.label} plan for {monthLabel}.</CardDescription>
105+ </CardHeader>
106+ <CardContent className="grid gap-6 sm:grid-cols-2">
107+ <QuotaBar label="Requests" used={usage.requests} limit={requestLimit} unlimited={requestLimit === null} usedLabel={formatNumber(usage.requests)} limitLabel={requestLimit ? formatNumber(requestLimit) : undefined} hint={projectCap && requestLimit === projectCap ? "project cap" : limits.overage_per_1k_requests_usd ? `then ${formatUsd(limits.overage_per_1k_requests_usd)} per 1k` : "hard stop at the limit"} />
108+ <QuotaBar label="Included residential bandwidth" used={residentialUsed} limit={includedBytes > 0 ? includedBytes : null} unlimited={includedBytes <= 0} usedLabel={formatBytes(residentialUsed)} limitLabel={includedBytes > 0 ? `${limits.included_gb} GB` : undefined} hint={limits.residential_per_gb_usd ? `then ${formatUsd(limits.residential_per_gb_usd)} per GB` : "metered per plan"} />
109+ </CardContent>
110+ </Card>
111+ <Card>
112+ <CardHeader>
113+ <CardTitle className="flex items-center justify-between">
114+ {limits.label} plan
115+ <Badge variant="accent">{limits.price_usd_month ? `${formatUsd(limits.price_usd_month)}/mo` : plan === "enterprise" ? "custom" : "free"}</Badge>
116+ </CardTitle>
117+ <CardDescription>Limits that apply to every project in {ws.organization.name}.</CardDescription>
118+ </CardHeader>
119+ <CardContent>
120+ <dl className="divide-y divide-border text-[13px]">
121+ {[
122+ ["Requests / month", unlimited ? "Unlimited" : formatNumber(limits.monthly_requests)],
123+ ["Concurrency", formatNumber(limits.concurrency)],
124+ ["Max timeout", `${limits.max_timeout_ms / 1000} s`],
125+ ["Max retries", String(limits.max_retries)],
126+ ["Log retention", `${limits.retention_days} days`],
127+ ["Included residential", limits.included_gb ? `${limits.included_gb} GB` : "—"],
128+ ].map(([k, v]) => (
129+ <div key={k} className="flex items-center justify-between py-1.5">
130+ <dt className="text-fg-muted">{k}</dt>
131+ <dd className="font-mono tabular">{v}</dd>
132+ </div>
133+ ))}
134+ </dl>
135+ <Button asChild variant="outline" size="sm" className="mt-4 w-full">
136+ <Link href="/dashboard/billing">
137+ Plans and billing <ArrowRight />
138+ </Link>
139+ </Button>
140+ </CardContent>
141+ </Card>
142+ </div>
143+
144+ <Card>
145+ <CardHeader className="flex-row items-start justify-between gap-3">
146+ <div>
147+ <CardTitle>Spending limits</CardTitle>
148+ <CardDescription>
149+ Soft limits email you; hard limits reject new requests with <code className="font-mono text-[12px]">USAGE_LIMIT_REACHED</code>. The stricter of project and organization applies.
150+ </CardDescription>
151+ </div>
152+ <div className="flex shrink-0 gap-2">
153+ <Button asChild variant="ghost" size="sm">
154+ <Link href="/dashboard/projects">Project limits</Link>
155+ </Button>
156+ <Button asChild variant="ghost" size="sm">
157+ <Link href="/dashboard/settings">Org limits</Link>
158+ </Button>
159+ </div>
160+ </CardHeader>
161+ <CardContent>
162+ <Table>
163+ <TableHeader>
164+ <TableRow className="hover:bg-transparent">
165+ <TableHead>Scope</TableHead>
166+ <TableHead className="text-right">Soft limit</TableHead>
167+ <TableHead className="text-right">Hard limit</TableHead>
168+ <TableHead className="text-right">Spend this month</TableHead>
169+ </TableRow>
170+ </TableHeader>
171+ <TableBody>
172+ {[
173+ { scope: `Project · ${ws.project.name}`, soft: ws.project.softLimitUsd, hard: ws.project.hardLimitUsd },
174+ { scope: `Organization · ${ws.organization.name}`, soft: ws.organization.softLimitUsd, hard: ws.organization.hardLimitUsd },
175+ ].map((r) => (
176+ <TableRow key={r.scope}>
177+ <TableCell>{r.scope}</TableCell>
178+ <TableCell className="text-right font-mono tabular">{r.soft === null ? <span className="text-fg-subtle">not set</span> : formatUsd(r.soft)}</TableCell>
179+ <TableCell className="text-right font-mono tabular">{r.hard === null ? <span className="text-fg-subtle">not set</span> : formatUsd(r.hard)}</TableCell>
180+ <TableCell className="text-right font-mono tabular">{formatUsd(usage.spendUsd)}</TableCell>
181+ </TableRow>
182+ ))}
183+ </TableBody>
184+ </Table>
185+ </CardContent>
186+ </Card>
187+
188+ {!hasData ? (
189+ <EmptyState
190+ icon={Activity}
191+ title={`No usage in ${monthLabel}`}
192+ description="Usage is metered per request the moment it completes. Send a request from the Playground or the API and it will show up here within seconds."
193+ action={
194+ <Button asChild size="sm" variant="primary">
195+ <Link href="/dashboard/playground">Open Playground</Link>
196+ </Button>
197+ }
198+ />
199+ ) : (
200+ <>
201+ <section>
202+ <SectionTitle>Daily usage</SectionTitle>
203+ <Card className="overflow-hidden">
204+ <Table>
205+ <TableHeader>
206+ <TableRow className="hover:bg-transparent">
207+ <TableHead>Day (UTC)</TableHead>
208+ <TableHead className="text-right">Requests</TableHead>
209+ <TableHead className="text-right">Bandwidth</TableHead>
210+ <TableHead className="text-right">Spend</TableHead>
211+ </TableRow>
212+ </TableHeader>
213+ <TableBody>
214+ {usage.daily.length === 0 ? (
215+ <TableEmpty colSpan={4}>No usage events recorded for this month yet.</TableEmpty>
216+ ) : (
217+ usage.daily.map((d) => (
218+ <TableRow key={d.day}>
219+ <TableCell className="font-mono tabular">{d.day}</TableCell>
220+ <TableCell className="text-right font-mono tabular">{formatNumber(d.requests)}</TableCell>
221+ <TableCell className="text-right font-mono tabular">{formatBytes(d.bandwidthBytes)}</TableCell>
222+ <TableCell className="text-right font-mono tabular">{formatUsd(d.spendUsd, true)}</TableCell>
223+ </TableRow>
224+ ))
225+ )}
226+ </TableBody>
227+ </Table>
228+ </Card>
229+ </section>
230+
231+ <section>
232+ <SectionTitle right={<span className="text-[12px] text-fg-subtle">Last 100 events · append-only, never edited</span>}>Usage ledger</SectionTitle>
233+ <Card className="overflow-hidden">
234+ <Table>
235+ <TableHeader>
236+ <TableRow className="hover:bg-transparent">
237+ <TableHead>Time</TableHead>
238+ <TableHead>Metric</TableHead>
239+ <TableHead className="text-right">Quantity</TableHead>
240+ <TableHead className="text-right">Price</TableHead>
241+ <TableHead>Request</TableHead>
242+ </TableRow>
243+ </TableHeader>
244+ <TableBody>
245+ {usage.ledger.length === 0 ? (
246+ <TableEmpty colSpan={5}>No ledger entries for this month.</TableEmpty>
247+ ) : (
248+ usage.ledger.map((e) => (
249+ <TableRow key={e.id}>
250+ <TableCell className="whitespace-nowrap text-fg-muted" title={e.createdAt.toISOString()}>
251+ {formatDate(e.createdAt, { timeStyle: "medium" })}
252+ </TableCell>
253+ <TableCell>{metricLabel(e.metric)}</TableCell>
254+ <TableCell className="text-right font-mono tabular">{formatQuantity(e.metric, e.quantity, e.unit)}</TableCell>
255+ <TableCell className="text-right font-mono tabular">{formatUsd(e.priceUsd, true)}</TableCell>
256+ <TableCell>
257+ {e.requestId ? (
258+ <Link href={`/dashboard/requests/${e.requestId}`} className="font-mono text-[12.5px] text-accent underline-offset-4 hover:underline" prefetch={false}>
259+ {e.requestId}
260+ </Link>
261+ ) : (
262+ <span className="text-fg-subtle">—</span>
263+ )}
264+ </TableCell>
265+ </TableRow>
266+ ))
267+ )}
268+ </TableBody>
269+ </Table>
270+ </Card>
271+ <p className="mt-2 text-[12px] text-fg-subtle">The ledger is immutable: corrections are posted as new entries, never by editing past ones. Prices are what you are billed; upstream costs are not part of your account.</p>
272+ </section>
273+ </>
274+ )}
275+ </div>
276+ );
277+}
added apps/web/src/app/error.tsx +13 −0
@@ -0,0 +1,13 @@
1+"use client";
2+import { Button } from "@/components/ui/button";
3+
4+export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
5+ return (
6+ <div className="flex flex-1 flex-col items-center justify-center px-6 py-24 text-center">
7+ <p className="font-mono text-[12px] uppercase tracking-widest text-fg-subtle">INTERNAL_ERROR</p>
8+ <h1 className="mt-3 text-2xl font-semibold tracking-tight">Something went wrong.</h1>
9+ <p className="mt-2 max-w-sm text-[14px] text-fg-muted">An unexpected error occurred. {error.digest ? <>Reference <code className="font-mono">{error.digest}</code>.</> : null}</p>
10+ <Button className="mt-8" onClick={reset}>Try again</Button>
11+ </div>
12+ );
13+}
added apps/web/src/app/globals.css +199 −0
@@ -0,0 +1,199 @@
1+@import "tailwindcss";
2+
3+@custom-variant dark (&:where(.dark, .dark *));
4+
5+@theme {
6+ --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
7+ --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
8+
9+ --color-bg: var(--bg);
10+ --color-bg-subtle: var(--bg-subtle);
11+ --color-bg-muted: var(--bg-muted);
12+ --color-bg-elevated: var(--bg-elevated);
13+ --color-fg: var(--fg);
14+ --color-fg-muted: var(--fg-muted);
15+ --color-fg-subtle: var(--fg-subtle);
16+ --color-border: var(--border);
17+ --color-border-strong: var(--border-strong);
18+ --color-accent: var(--accent);
19+ --color-accent-fg: var(--accent-fg);
20+ --color-accent-soft: var(--accent-soft);
21+ --color-success: var(--success);
22+ --color-success-soft: var(--success-soft);
23+ --color-warning: var(--warning);
24+ --color-warning-soft: var(--warning-soft);
25+ --color-danger: var(--danger);
26+ --color-danger-soft: var(--danger-soft);
27+ --color-info: var(--info);
28+ --color-info-soft: var(--info-soft);
29+ --color-ring: var(--accent);
30+
31+ --radius-sm: 4px;
32+ --radius-md: 6px;
33+ --radius-lg: 10px;
34+ --radius-xl: 14px;
35+
36+ --shadow-xs: 0 1px 2px rgb(15 23 42 / 0.05);
37+ --shadow-sm: 0 1px 3px rgb(15 23 42 / 0.08), 0 1px 2px rgb(15 23 42 / 0.04);
38+ --shadow-md: 0 4px 12px rgb(15 23 42 / 0.08), 0 1px 3px rgb(15 23 42 / 0.05);
39+ --shadow-lg: 0 12px 32px rgb(15 23 42 / 0.12), 0 2px 6px rgb(15 23 42 / 0.06);
40+
41+ --animate-fade-in: fade-in 0.25s ease-out both;
42+ --animate-fade-up: fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
43+ --animate-pulse-soft: pulse-soft 2s ease-in-out infinite;
44+ --animate-flow: flow 3s linear infinite;
45+
46+ @keyframes fade-in {
47+ from { opacity: 0; }
48+ to { opacity: 1; }
49+ }
50+ @keyframes fade-up {
51+ from { opacity: 0; transform: translateY(8px); }
52+ to { opacity: 1; transform: translateY(0); }
53+ }
54+ @keyframes pulse-soft {
55+ 0%, 100% { opacity: 1; }
56+ 50% { opacity: 0.45; }
57+ }
58+ @keyframes flow {
59+ from { stroke-dashoffset: 24; }
60+ to { stroke-dashoffset: 0; }
61+ }
62+}
63+
64+:root {
65+ color-scheme: light;
66+ --bg: #ffffff;
67+ --bg-subtle: #f7f8fa;
68+ --bg-muted: #eef0f3;
69+ --bg-elevated: #ffffff;
70+ --fg: #0b0f19;
71+ --fg-muted: #4b5563;
72+ --fg-subtle: #8a93a3;
73+ --border: #e4e7ec;
74+ --border-strong: #c9cfd8;
75+ --accent: #1f5eff;
76+ --accent-fg: #ffffff;
77+ --accent-soft: #e8efff;
78+ --success: #158a4c;
79+ --success-soft: #e3f5ea;
80+ --warning: #b7791f;
81+ --warning-soft: #fbf1dc;
82+ --danger: #c93b3b;
83+ --danger-soft: #fbe6e6;
84+ --info: #2461c7;
85+ --info-soft: #e6eefb;
86+}
87+
88+.dark {
89+ color-scheme: dark;
90+ --bg: #0a0d14;
91+ --bg-subtle: #0f131c;
92+ --bg-muted: #161b26;
93+ --bg-elevated: #121721;
94+ --fg: #eef1f6;
95+ --fg-muted: #a6afbf;
96+ --fg-subtle: #6b7484;
97+ --border: #1f2633;
98+ --border-strong: #2e3746;
99+ --accent: #5b8cff;
100+ --accent-fg: #061029;
101+ --accent-soft: #14213f;
102+ --success: #3ccb7f;
103+ --success-soft: #10291c;
104+ --warning: #e0a94a;
105+ --warning-soft: #2b2312;
106+ --danger: #f06b6b;
107+ --danger-soft: #331717;
108+ --info: #6fa2ff;
109+ --info-soft: #14213f;
110+}
111+
112+@layer base {
113+ * {
114+ border-color: var(--border);
115+ }
116+ html {
117+ -webkit-text-size-adjust: 100%;
118+ text-rendering: optimizeLegibility;
119+ font-feature-settings: "cv11", "ss01", "calt";
120+ }
121+ body {
122+ @apply bg-bg text-fg font-sans antialiased;
123+ font-size: 15px;
124+ line-height: 1.55;
125+ }
126+ ::selection {
127+ background: color-mix(in oklab, var(--accent) 25%, transparent);
128+ }
129+ :focus-visible {
130+ outline: 2px solid var(--accent);
131+ outline-offset: 2px;
132+ border-radius: 4px;
133+ }
134+ h1, h2, h3, h4 {
135+ letter-spacing: -0.02em;
136+ text-wrap: balance;
137+ }
138+ code, kbd, pre, samp {
139+ @apply font-mono;
140+ font-size: 0.92em;
141+ }
142+ input, textarea, select, button {
143+ font: inherit;
144+ }
145+ input::placeholder, textarea::placeholder {
146+ color: var(--fg-subtle);
147+ }
148+ @media (prefers-reduced-motion: reduce) {
149+ *, *::before, *::after {
150+ animation-duration: 0.001ms !important;
151+ animation-iteration-count: 1 !important;
152+ transition-duration: 0.001ms !important;
153+ scroll-behavior: auto !important;
154+ }
155+ }
156+}
157+
158+@layer utilities {
159+ .container-page {
160+ @apply mx-auto w-full max-w-6xl px-5 sm:px-8;
161+ }
162+ .text-balance {
163+ text-wrap: balance;
164+ }
165+ .tabular {
166+ font-variant-numeric: tabular-nums;
167+ }
168+ .grid-bg {
169+ background-image: linear-gradient(to right, color-mix(in oklab, var(--border) 60%, transparent) 1px, transparent 1px),
170+ linear-gradient(to bottom, color-mix(in oklab, var(--border) 60%, transparent) 1px, transparent 1px);
171+ background-size: 32px 32px;
172+ mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
173+ }
174+ .scrollbar-thin {
175+ scrollbar-width: thin;
176+ scrollbar-color: var(--border-strong) transparent;
177+ }
178+ .no-scrollbar::-webkit-scrollbar {
179+ display: none;
180+ }
181+ .no-scrollbar {
182+ -ms-overflow-style: none;
183+ scrollbar-width: none;
184+ }
185+}
186+
187+/* Syntax highlighting (lightweight, used by CodeBlock) */
188+.tok-k { color: #7c3aed; }
189+.tok-s { color: #0f766e; }
190+.tok-n { color: #b45309; }
191+.tok-c { color: var(--fg-subtle); font-style: italic; }
192+.tok-p { color: var(--fg-muted); }
193+.tok-t { color: #be185d; }
194+.tok-a { color: #1d4ed8; }
195+.dark .tok-k { color: #c4b5fd; }
196+.dark .tok-s { color: #6ee7b7; }
197+.dark .tok-n { color: #fcd34d; }
198+.dark .tok-t { color: #f9a8d4; }
199+.dark .tok-a { color: #93c5fd; }
added apps/web/src/app/goodbye/page.tsx +53 −0
@@ -0,0 +1,53 @@
1+import Link from "next/link";
2+import { Logo } from "@/components/ui/logo";
3+import { Button } from "@/components/ui/button";
4+
5+export const metadata = { title: "Account deletion · Fetcha" };
6+
7+export default function GoodbyePage() {
8+ return (
9+ <div className="relative flex min-h-dvh flex-col">
10+ <div className="absolute inset-0 -z-10 grid-bg opacity-60" aria-hidden />
11+ <header className="container-page flex h-16 items-center">
12+ <Logo />
13+ </header>
14+ <main className="flex flex-1 items-start justify-center px-4 pb-16 pt-6 sm:pt-12">
15+ <div className="w-full max-w-[440px] animate-fade-up rounded-xl border border-border bg-bg-elevated p-6 shadow-md sm:p-8">
16+ <h1 className="text-xl font-semibold tracking-tight">Account deletion</h1>
17+ <p className="mt-1.5 text-[13.5px] leading-relaxed text-fg-muted">Sorry to see you go. Here is what happens next.</p>
18+ <ol className="mt-6 grid gap-4 text-[13.5px] leading-relaxed">
19+ <li className="flex gap-3">
20+ <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-bg-muted font-mono text-[12px] tabular">1</span>
21+ <span>
22+ <span className="font-medium">Requested.</span> <span className="text-fg-muted">If you just asked to delete your account, we sent a confirmation link to your email. Nothing is removed until you click it. The link expires after 24 hours.</span>
23+ </span>
24+ </li>
25+ <li className="flex gap-3">
26+ <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-bg-muted font-mono text-[12px] tabular">2</span>
27+ <span>
28+ <span className="font-medium">Confirmed.</span> <span className="text-fg-muted">If you arrived here from that link, your login is now disabled, every API key is rejected and you have been signed out.</span>
29+ </span>
30+ </li>
31+ <li className="flex gap-3">
32+ <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-bg-muted font-mono text-[12px] tabular">3</span>
33+ <span>
34+ <span className="font-medium">Purged after 7 days.</span> <span className="text-fg-muted">Projects, keys, request logs and usage records are permanently erased 7 days after confirmation. To recover the account within that window, email support@fetcha.co from the deleted address.</span>
35+ </span>
36+ </li>
37+ </ol>
38+ <div className="mt-6 flex flex-wrap items-center gap-2 border-t border-border pt-5">
39+ <Button asChild variant="outline" size="sm">
40+ <Link href="/">Back to fetcha.co</Link>
41+ </Button>
42+ <Button asChild variant="ghost" size="sm">
43+ <a href="mailto:support@fetcha.co?subject=Account%20deletion">Contact support</a>
44+ </Button>
45+ </div>
46+ </div>
47+ </main>
48+ <footer className="container-page pb-6 text-center text-[12px] text-fg-subtle">
49+ <Link href="/legal/privacy" className="hover:text-fg">Privacy Policy</Link> · <Link href="/legal/terms" className="hover:text-fg">Terms</Link>
50+ </footer>
51+ </div>
52+ );
53+}
added apps/web/src/app/layout.tsx +46 −0
@@ -0,0 +1,46 @@
1+import type { Metadata, Viewport } from "next";
2+import "./globals.css";
3+import { ThemeProvider } from "@/components/ui/theme";
4+import { TooltipProvider } from "@/components/ui/tooltip";
5+import { SITE_URL } from "@/lib/utils";
6+
7+export const metadata: Metadata = {
8+ metadataBase: new URL(SITE_URL),
9+ title: { default: "Fetcha — Intelligent Web Access Infrastructure", template: "%s · Fetcha" },
10+ description: "One API. Multiple proxy networks. Reliable web access. Fetcha intelligently routes every request across premium proxy networks, sessions and browser infrastructure.",
11+ applicationName: "Fetcha",
12+ keywords: ["web access API", "proxy API", "residential proxies", "web scraping infrastructure", "smart routing", "fetch API"],
13+ openGraph: {
14+ type: "website",
15+ siteName: "Fetcha",
16+ title: "Fetcha — The web, accessible through one API.",
17+ description: "Fetcha intelligently routes every request across premium proxy networks, sessions and browser infrastructure to deliver reliable web access at scale.",
18+ url: SITE_URL,
19+ images: [{ url: "/og.png", width: 1200, height: 630, alt: "Fetcha" }],
20+ },
21+ twitter: { card: "summary_large_image", title: "Fetcha — Intelligent Web Access Infrastructure", description: "One API. Multiple proxy networks. Reliable web access." },
22+ icons: { icon: [{ url: "/icon.svg", type: "image/svg+xml" }] },
23+ robots: { index: true, follow: true },
24+};
25+
26+export const viewport: Viewport = {
27+ themeColor: [
28+ { media: "(prefers-color-scheme: light)", color: "#ffffff" },
29+ { media: "(prefers-color-scheme: dark)", color: "#0a0d14" },
30+ ],
31+ width: "device-width",
32+ initialScale: 1,
33+ viewportFit: "cover",
34+};
35+
36+export default function RootLayout({ children }: { children: React.ReactNode }) {
37+ return (
38+ <html lang="en" suppressHydrationWarning>
39+ <body className="min-h-dvh flex flex-col">
40+ <ThemeProvider>
41+ <TooltipProvider delayDuration={200}>{children}</TooltipProvider>
42+ </ThemeProvider>
43+ </body>
44+ </html>
45+ );
46+}
added apps/web/src/app/not-found.tsx +18 −0
@@ -0,0 +1,18 @@
1+import Link from "next/link";
2+import { Button } from "@/components/ui/button";
3+import { Logo } from "@/components/ui/logo";
4+
5+export default function NotFound() {
6+ return (
7+ <div className="flex flex-1 flex-col items-center justify-center px-6 py-24 text-center">
8+ <Logo />
9+ <p className="mt-10 font-mono text-[12px] uppercase tracking-widest text-fg-subtle">404 · NOT_FOUND</p>
10+ <h1 className="mt-3 text-2xl font-semibold tracking-tight">This page could not be fetched.</h1>
11+ <p className="mt-2 max-w-sm text-[14px] text-fg-muted">The URL you requested does not exist on fetcha.co. Check the address or head back home.</p>
12+ <div className="mt-8 flex gap-2">
13+ <Button asChild><Link href="/">Go home</Link></Button>
14+ <Button asChild variant="outline"><Link href="/docs">Documentation</Link></Button>
15+ </div>
16+ </div>
17+ );
18+}
added apps/web/src/app/opengraph-image.tsx +63 −0
@@ -0,0 +1,63 @@
1+import { ImageResponse } from "next/og";
2+
3+export const alt = "Fetcha — The web, accessible through one API.";
4+export const size = { width: 1200, height: 630 };
5+export const contentType = "image/png";
6+
7+/** Dynamic Open Graph image. System fonts only so it is edge-safe and has no font fetches. */
8+export default function OpenGraphImage() {
9+ return new ImageResponse(
10+ (
11+ <div
12+ style={{
13+ width: "100%",
14+ height: "100%",
15+ display: "flex",
16+ flexDirection: "column",
17+ justifyContent: "space-between",
18+ padding: 72,
19+ background: "#0a0d14",
20+ color: "#eef1f6",
21+ fontFamily: "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
22+ position: "relative",
23+ }}
24+ >
25+ {/* subtle grid */}
26+ <div
27+ style={{
28+ position: "absolute",
29+ inset: 0,
30+ backgroundImage: "linear-gradient(to right, rgba(31,38,51,0.9) 1px, transparent 1px), linear-gradient(to bottom, rgba(31,38,51,0.9) 1px, transparent 1px)",
31+ backgroundSize: "48px 48px",
32+ opacity: 0.7,
33+ }}
34+ />
35+ {/* wordmark */}
36+ <div style={{ display: "flex", alignItems: "center", gap: 18 }}>
37+ <div style={{ width: 56, height: 56, borderRadius: 14, background: "#eef1f6", display: "flex", alignItems: "center", justifyContent: "center" }}>
38+ <svg width="36" height="36" viewBox="0 0 24 24" fill="none">
39+ <path d="M6 12h4.5m0 0 3-4.5H18m-7.5 4.5 3 4.5H18" stroke="#0a0d14" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" />
40+ <circle cx="6" cy="12" r="1.6" fill="#5b8cff" />
41+ </svg>
42+ </div>
43+ <div style={{ fontSize: 44, fontWeight: 600, letterSpacing: "-0.03em" }}>fetcha</div>
44+ </div>
45+
46+ <div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
47+ <div style={{ fontSize: 30, fontWeight: 500, color: "#5b8cff", letterSpacing: "0.02em", textTransform: "uppercase" }}>Intelligent web access infrastructure</div>
48+ <div style={{ fontSize: 76, fontWeight: 600, lineHeight: 1.04, letterSpacing: "-0.035em", maxWidth: 1000 }}>The web, accessible through one API.</div>
49+ <div style={{ fontSize: 28, color: "#a6afbf", lineHeight: 1.35, maxWidth: 960 }}>One endpoint. Smart routing across proxy networks, sticky sessions and geo targeting.</div>
50+ </div>
51+
52+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: 24, color: "#6b7484" }}>
53+ <div style={{ display: "flex", alignItems: "center", gap: 14, fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" }}>
54+ <span style={{ color: "#5b8cff", fontWeight: 600 }}>POST</span>
55+ <span>https://www.fetcha.co/v1/fetch</span>
56+ </div>
57+ <div>www.fetcha.co</div>
58+ </div>
59+ </div>
60+ ),
61+ { ...size },
62+ );
63+}
added apps/web/src/app/sitemap.ts +27 −0
@@ -0,0 +1,27 @@
1+import type { MetadataRoute } from "next";
2+import { SITE_URL } from "@/lib/utils";
3+
4+const LAST_MODIFIED = new Date("2026-09-07T00:00:00Z");
5+
6+const DOCS = ["", "quickstart", "authentication", "fetch", "networks", "geolocation", "sessions", "browser", "extraction", "errors", "retries", "webhooks", "rate-limits", "sdks", "examples"];
7+const LEGAL = ["terms", "privacy", "acceptable-use", "dpa", "cookies"];
8+
9+export default function sitemap(): MetadataRoute.Sitemap {
10+ const entry = (path: string, priority: number, changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]): MetadataRoute.Sitemap[number] => ({
11+ url: `${SITE_URL}${path}`,
12+ lastModified: LAST_MODIFIED,
13+ changeFrequency,
14+ priority,
15+ });
16+
17+ return [
18+ entry("/", 1, "weekly"),
19+ entry("/pricing", 0.9, "monthly"),
20+ entry("/changelog", 0.7, "weekly"),
21+ entry("/status", 0.5, "hourly"),
22+ ...DOCS.map((slug) => entry(slug ? `/docs/${slug}` : "/docs", slug ? 0.8 : 0.9, "monthly")),
23+ ...LEGAL.map((slug) => entry(`/legal/${slug}`, 0.3, "yearly")),
24+ entry("/login", 0.4, "yearly"),
25+ entry("/signup", 0.6, "yearly"),
26+ ];
27+}
added apps/web/src/components/admin/abuse-actions.tsx +114 −0
@@ -0,0 +1,114 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { Check, Plus, Undo2 } from "lucide-react";
5+import { recordAbuseEvent, resolveAbuseEvent } from "@/actions/admin";
6+import { Button } from "@/components/ui/button";
7+import { Input, NativeSelect, Textarea } from "@/components/ui/input";
8+import { Field, Hint, Label } from "@/components/ui/label";
9+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
10+import { ActionButton } from "./action-button";
11+
12+export function ResolveAbuseButton({ id, resolved }: { id: string; resolved: boolean }) {
13+ return resolved ? (
14+ <ActionButton variant="ghost" size="xs" action={() => resolveAbuseEvent(id, false)}>
15+ <Undo2 className="size-3" /> Reopen
16+ </ActionButton>
17+ ) : (
18+ <ActionButton variant="outline" size="xs" action={() => resolveAbuseEvent(id, true)}>
19+ <Check className="size-3" /> Resolve
20+ </ActionButton>
21+ );
22+}
23+
24+export function RecordAbuseDialog({ defaultOrgId }: { defaultOrgId?: string }) {
25+ const router = useRouter();
26+ const [open, setOpen] = React.useState(false);
27+ const [pending, start] = React.useTransition();
28+ const [error, setError] = React.useState<string | null>(null);
29+ const [form, setForm] = React.useState({ kind: "manual", severity: "medium", organizationId: defaultOrgId ?? "", projectId: "", requestId: "", detail: "" });
30+ const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => setForm((f) => ({ ...f, [k]: e.target.value }));
31+ return (
32+ <>
33+ <Button variant="primary" size="sm" onClick={() => setOpen(true)}>
34+ <Plus className="size-3.5" /> Record abuse event
35+ </Button>
36+ <Dialog open={open} onOpenChange={setOpen}>
37+ <DialogContent size="md">
38+ <DialogHeader>
39+ <DialogTitle>Record an abuse event</DialogTitle>
40+ <DialogDescription>Manual entries appear alongside automatically detected events and are linked to the organization when an id is given.</DialogDescription>
41+ </DialogHeader>
42+ <form
43+ className="grid gap-3"
44+ onSubmit={(e) => {
45+ e.preventDefault();
46+ start(async () => {
47+ setError(null);
48+ const r = await recordAbuseEvent(form as Parameters<typeof recordAbuseEvent>[0]);
49+ if (!r.ok) return setError(r.error);
50+ setOpen(false);
51+ setForm({ kind: "manual", severity: "medium", organizationId: defaultOrgId ?? "", projectId: "", requestId: "", detail: "" });
52+ router.refresh();
53+ });
54+ }}
55+ >
56+ <div className="grid gap-3 sm:grid-cols-2">
57+ <Field>
58+ <Label htmlFor="ab-kind">Kind</Label>
59+ <NativeSelect id="ab-kind" value={form.kind} onChange={set("kind")}>
60+ <option value="manual">manual</option>
61+ <option value="ssrf_attempt">ssrf_attempt</option>
62+ <option value="prohibited_target">prohibited_target</option>
63+ <option value="rate_abuse">rate_abuse</option>
64+ <option value="credential_abuse">credential_abuse</option>
65+ </NativeSelect>
66+ </Field>
67+ <Field>
68+ <Label htmlFor="ab-sev">Severity</Label>
69+ <NativeSelect id="ab-sev" value={form.severity} onChange={set("severity")}>
70+ <option value="low">low</option>
71+ <option value="medium">medium</option>
72+ <option value="high">high</option>
73+ <option value="critical">critical</option>
74+ </NativeSelect>
75+ </Field>
76+ </div>
77+ <Field>
78+ <Label htmlFor="ab-org">Organization id</Label>
79+ <Input id="ab-org" value={form.organizationId} onChange={set("organizationId")} placeholder="org_…" className="font-mono" />
80+ </Field>
81+ <div className="grid gap-3 sm:grid-cols-2">
82+ <Field>
83+ <Label htmlFor="ab-proj">Project id</Label>
84+ <Input id="ab-proj" value={form.projectId} onChange={set("projectId")} placeholder="proj_… (optional)" className="font-mono" />
85+ </Field>
86+ <Field>
87+ <Label htmlFor="ab-req">Request id</Label>
88+ <Input id="ab-req" value={form.requestId} onChange={set("requestId")} placeholder="req_… (optional)" className="font-mono" />
89+ </Field>
90+ </div>
91+ <Field>
92+ <Label htmlFor="ab-detail">Detail</Label>
93+ <Textarea id="ab-detail" value={form.detail} onChange={set("detail")} placeholder="What happened, evidence, links to requests…" required />
94+ <Hint>Internal only.</Hint>
95+ </Field>
96+ {error ? (
97+ <p role="alert" className="text-[13px] text-danger">
98+ {error}
99+ </p>
100+ ) : null}
101+ <DialogFooter>
102+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
103+ Cancel
104+ </Button>
105+ <Button type="submit" variant="primary" loading={pending}>
106+ Record
107+ </Button>
108+ </DialogFooter>
109+ </form>
110+ </DialogContent>
111+ </Dialog>
112+ </>
113+ );
114+}
added apps/web/src/components/admin/action-button.tsx +100 −0
@@ -0,0 +1,100 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { Button, type ButtonProps } from "@/components/ui/button";
5+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
6+import type { AdminActionResult } from "@/actions/admin";
7+
8+/**
9+ * Runs a server action inside a transition and refreshes the router. When `confirm` is given, a
10+ * dialog gates the (usually destructive) action.
11+ */
12+export function ActionButton({
13+ action,
14+ children,
15+ confirm,
16+ onDone,
17+ ...btn
18+}: {
19+ action: () => Promise<AdminActionResult<unknown>>;
20+ children: React.ReactNode;
21+ confirm?: { title: string; description?: React.ReactNode; confirmLabel?: string; variant?: ButtonProps["variant"] };
22+ onDone?: (r: AdminActionResult<unknown>) => void;
23+} & Omit<ButtonProps, "onClick" | "children">) {
24+ const router = useRouter();
25+ const [pending, start] = React.useTransition();
26+ const [open, setOpen] = React.useState(false);
27+ const [msg, setMsg] = React.useState<{ kind: "error" | "warning"; text: string } | null>(null);
28+
29+ const run = () =>
30+ start(async () => {
31+ setMsg(null);
32+ const r = await action();
33+ if (!r.ok) setMsg({ kind: "error", text: r.error });
34+ else if (r.warning) setMsg({ kind: "warning", text: r.warning });
35+ onDone?.(r);
36+ if (r.ok) {
37+ setOpen(false);
38+ router.refresh();
39+ }
40+ });
41+
42+ return (
43+ <>
44+ <span className="inline-flex flex-col items-start gap-1">
45+ <Button type="button" {...btn} loading={pending} onClick={() => (confirm ? setOpen(true) : run())}>
46+ {children}
47+ </Button>
48+ {msg && !confirm ? (
49+ <span role="alert" className={msg.kind === "error" ? "text-[12px] text-danger" : "text-[12px] text-warning"}>
50+ {msg.text}
51+ </span>
52+ ) : null}
53+ </span>
54+ {confirm ? (
55+ <Dialog open={open} onOpenChange={setOpen}>
56+ <DialogContent size="sm">
57+ <DialogHeader>
58+ <DialogTitle>{confirm.title}</DialogTitle>
59+ {confirm.description ? <DialogDescription>{confirm.description}</DialogDescription> : null}
60+ </DialogHeader>
61+ {msg ? (
62+ <p role="alert" className={msg.kind === "error" ? "text-[13px] text-danger" : "text-[13px] text-warning"}>
63+ {msg.text}
64+ </p>
65+ ) : null}
66+ <DialogFooter>
67+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
68+ Cancel
69+ </Button>
70+ <Button type="button" variant={confirm.variant ?? "danger"} loading={pending} onClick={run}>
71+ {confirm.confirmLabel ?? "Confirm"}
72+ </Button>
73+ </DialogFooter>
74+ </DialogContent>
75+ </Dialog>
76+ ) : null}
77+ </>
78+ );
79+}
80+
81+export function ResultMessage({ result }: { result: AdminActionResult<unknown> | null }) {
82+ if (!result) return null;
83+ if (!result.ok)
84+ return (
85+ <p role="alert" className="text-[12.5px] text-danger">
86+ {result.error}
87+ </p>
88+ );
89+ if (result.warning)
90+ return (
91+ <p role="status" className="text-[12.5px] text-warning">
92+ {result.warning}
93+ </p>
94+ );
95+ return (
96+ <p role="status" className="text-[12.5px] text-success">
97+ Saved.
98+ </p>
99+ );
100+}
added apps/web/src/components/admin/admin-shell.tsx +136 −0
@@ -0,0 +1,136 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { usePathname } from "next/navigation";
5+import {
6+ Activity,
7+ ArrowLeft,
8+ Building2,
9+ ChevronDown,
10+ CreditCard,
11+ Flag,
12+ FolderKanban,
13+ Globe,
14+ LayoutDashboard,
15+ ListTree,
16+ Menu,
17+ Server,
18+ ShieldAlert,
19+ Users,
20+ Waypoints,
21+ Wrench,
22+ X,
23+} from "lucide-react";
24+import { cn } from "@/lib/utils";
25+import { LogoMark } from "@/components/ui/logo";
26+import { ThemeToggle } from "@/components/ui/theme";
27+import { Badge } from "@/components/ui/badge";
28+import { Button } from "@/components/ui/button";
29+
30+export const ADMIN_NAV = [
31+ { href: "/admin", label: "Overview", icon: LayoutDashboard, exact: true },
32+ { href: "/admin/users", label: "Users", icon: Users },
33+ { href: "/admin/organizations", label: "Organizations", icon: Building2 },
34+ { href: "/admin/projects", label: "Projects", icon: FolderKanban },
35+ { href: "/admin/requests", label: "Requests", icon: ListTree },
36+ { href: "/admin/usage", label: "Usage", icon: Activity },
37+ { href: "/admin/providers", label: "Providers", icon: Server },
38+ { href: "/admin/routing", label: "Routing", icon: Waypoints },
39+ { href: "/admin/domains", label: "Domains", icon: Globe },
40+ { href: "/admin/billing", label: "Billing", icon: CreditCard },
41+ { href: "/admin/abuse", label: "Abuse", icon: ShieldAlert },
42+ { href: "/admin/system", label: "System", icon: Wrench },
43+ { href: "/admin/flags", label: "Flags", icon: Flag },
44+] as const;
45+
46+function AdminNav({ onNavigate, className }: { onNavigate?: () => void; className?: string }) {
47+ const pathname = usePathname();
48+ return (
49+ <nav className={cn("flex flex-col gap-0.5", className)} aria-label="Admin">
50+ {ADMIN_NAV.map((item) => {
51+ const active = "exact" in item && item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(item.href + "/");
52+ const Icon = item.icon;
53+ return (
54+ <Link
55+ key={item.href}
56+ href={item.href}
57+ onClick={onNavigate}
58+ aria-current={active ? "page" : undefined}
59+ className={cn(
60+ "group flex h-8 items-center gap-2.5 rounded-md px-2.5 text-[13.5px] font-medium transition-colors",
61+ active ? "bg-bg-muted text-fg" : "text-fg-muted hover:bg-bg-subtle hover:text-fg",
62+ )}
63+ >
64+ <Icon className={cn("size-4 shrink-0", active ? "text-fg" : "text-fg-subtle group-hover:text-fg-muted")} aria-hidden />
65+ {item.label}
66+ </Link>
67+ );
68+ })}
69+ </nav>
70+ );
71+}
72+
73+export function AdminShell({ children, user }: { children: React.ReactNode; user: { email: string; name: string } }) {
74+ const pathname = usePathname();
75+ // The menu is "open for" a given pathname, so it closes itself on navigation without an effect.
76+ const [openPath, setOpenPath] = React.useState<string | null>(null);
77+ const open = openPath === pathname;
78+ const setOpen = (v: boolean | ((prev: boolean) => boolean)) => setOpenPath((prev) => ((typeof v === "function" ? v(prev === pathname) : v) ? pathname : null));
79+ const current = ADMIN_NAV.find((i) => ("exact" in i && i.exact ? pathname === i.href : pathname === i.href || pathname.startsWith(i.href + "/")));
80+
81+ return (
82+ <div className="flex min-h-dvh flex-col bg-bg">
83+ <header className="sticky top-0 z-30 border-b border-white/10 bg-[#0a0d14] text-[#eef1f6]">
84+ <div className="flex h-12 items-center gap-3 px-4 sm:px-6">
85+ <Link href="/admin" className="flex items-center gap-2 rounded-md font-semibold tracking-[-0.03em] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40">
86+ <LogoMark size={20} className="[&_rect]:fill-white [&_path]:stroke-[#0a0d14]" />
87+ <span className="text-[15px]">Fetcha Admin</span>
88+ </Link>
89+ <Badge className="border-transparent bg-[#c93b3b]/25 text-[#ffb4b4] uppercase tracking-wide" dot>
90+ Internal
91+ </Badge>
92+ <span className="hidden text-[12px] text-white/50 md:inline">Upstream provider names and costs are visible here. Never share screenshots externally.</span>
93+ <div className="ml-auto flex items-center gap-1">
94+ <Button asChild variant="ghost" size="sm" className="text-white/70 hover:bg-white/10 hover:text-white">
95+ <Link href="/dashboard">
96+ <ArrowLeft className="size-3.5" /> Dashboard
97+ </Link>
98+ </Button>
99+ <ThemeToggle className="text-white/70 hover:bg-white/10 hover:text-white" />
100+ <span className="ml-2 hidden max-w-[220px] truncate text-[12.5px] text-white/60 sm:inline" title={user.email}>
101+ {user.email}
102+ </span>
103+ </div>
104+ </div>
105+ </header>
106+
107+ <div className="flex flex-1">
108+ <aside className="hidden w-[220px] shrink-0 flex-col border-r border-border bg-bg-subtle/60 lg:flex">
109+ <div className="sticky top-12 max-h-[calc(100dvh-3rem)] overflow-y-auto px-3 py-4 scrollbar-thin">
110+ <AdminNav />
111+ </div>
112+ </aside>
113+
114+ <div className="flex min-w-0 flex-1 flex-col">
115+ <div className="border-b border-border bg-bg-subtle/60 px-4 py-2 lg:hidden">
116+ <Button variant="outline" size="sm" className="w-full justify-between" onClick={() => setOpen((v) => !v)} aria-expanded={open} aria-controls="admin-mobile-nav">
117+ <span className="flex items-center gap-2">
118+ {open ? <X className="size-4" /> : <Menu className="size-4" />}
119+ {current?.label ?? "Admin"}
120+ </span>
121+ <ChevronDown className={cn("size-4 text-fg-subtle transition-transform", open && "rotate-180")} />
122+ </Button>
123+ {open ? (
124+ <div id="admin-mobile-nav" className="mt-2 rounded-md border border-border bg-bg p-2 shadow-sm">
125+ <AdminNav onNavigate={() => setOpen(false)} />
126+ </div>
127+ ) : null}
128+ </div>
129+ <main className="flex-1 px-4 py-6 sm:px-6 lg:px-8">
130+ <div className="mx-auto w-full max-w-[1320px] animate-fade-in">{children}</div>
131+ </main>
132+ </div>
133+ </div>
134+ </div>
135+ );
136+}
added apps/web/src/components/admin/charts.tsx +105 −0
@@ -0,0 +1,105 @@
1+"use client";
2+import * as React from "react";
3+import { Bar, BarChart, CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
4+import { useTheme } from "next-themes";
5+import { formatCompact, formatUsd } from "@/lib/format";
6+
7+/**
8+ * Categorical palette, validated (dataviz skill) against both surfaces.
9+ * Fixed slot order — providers always keep the same hue regardless of which are present.
10+ */
11+const LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100"];
12+const DARK = ["#3987e5", "#d95926", "#199e70", "#c98500"];
13+const PROVIDER_SLOT: Record<string, number> = { oxylabs: 0, decodo: 1, soax: 2, direct: 3 };
14+
15+const noop = () => () => {};
16+/** false during SSR/hydration, true once on the client — avoids a theme mismatch warning. */
17+const useMounted = () => React.useSyncExternalStore(noop, () => true, () => false);
18+
19+function usePalette() {
20+ const { resolvedTheme } = useTheme();
21+ const mounted = useMounted();
22+ const dark = mounted && resolvedTheme === "dark";
23+ return dark ? DARK : LIGHT;
24+}
25+
26+export function providerColor(provider: string, palette: string[]): string {
27+ const slot = PROVIDER_SLOT[provider];
28+ return palette[slot ?? 3] ?? palette[0]!;
29+}
30+
31+const axisStyle = { fontSize: 11, fill: "var(--fg-subtle)", fontFamily: "var(--font-mono)" } as const;
32+const gridStroke = "var(--border)";
33+
34+function fmtTick(unit: "hour" | "day") {
35+ return (t: string) => {
36+ const d = new Date(t);
37+ return unit === "hour" ? `${String(d.getHours()).padStart(2, "0")}:00` : `${d.getMonth() + 1}/${d.getDate()}`;
38+ };
39+}
40+
41+function TooltipBox({ active, payload, label, unit, money }: { active?: boolean; payload?: Array<{ name?: string; value?: number; color?: string }>; label?: string; unit: "hour" | "day"; money?: boolean }) {
42+ if (!active || !payload?.length) return null;
43+ const d = label ? new Date(label) : null;
44+ return (
45+ <div className="rounded-md border border-border bg-bg-elevated px-3 py-2 text-[12px] shadow-md">
46+ <div className="mb-1 font-medium text-fg">{d ? (unit === "hour" ? d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit" }) : d.toLocaleDateString(undefined, { month: "short", day: "numeric" })) : label}</div>
47+ <div className="space-y-0.5">
48+ {payload.map((p) => (
49+ <div key={p.name} className="flex items-center justify-between gap-4">
50+ <span className="flex items-center gap-1.5 text-fg-muted">
51+ <span className="size-2 rounded-full" style={{ background: p.color }} aria-hidden />
52+ {p.name}
53+ </span>
54+ <span className="tabular font-mono text-fg">{money ? formatUsd(p.value ?? 0, true) : formatCompact(p.value ?? 0)}</span>
55+ </div>
56+ ))}
57+ </div>
58+ </div>
59+ );
60+}
61+
62+/** Revenue vs upstream cost vs margin over time. One axis (USD), three series, legend + direct table alongside. */
63+export function EconomicsChart({ data, unit }: { data: Array<{ key: string; revenue: number; cost: number; margin: number }>; unit: "hour" | "day" }) {
64+ const pal = usePalette();
65+ if (!data.length) return <div className="flex h-[260px] items-center justify-center text-[13px] text-fg-muted">No requests in this range.</div>;
66+ return (
67+ <div className="h-[260px] w-full" role="img" aria-label="Revenue, upstream cost and margin over time">
68+ <ResponsiveContainer width="100%" height="100%">
69+ <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }} barGap={2} barCategoryGap="20%">
70+ <CartesianGrid vertical={false} stroke={gridStroke} strokeDasharray="0" />
71+ <XAxis dataKey="key" tickFormatter={fmtTick(unit)} tick={axisStyle} axisLine={false} tickLine={false} minTickGap={24} />
72+ <YAxis tick={axisStyle} axisLine={false} tickLine={false} width={56} tickFormatter={(v: number) => (v >= 1 ? `$${formatCompact(v)}` : `$${v.toFixed(2)}`)} />
73+ <Tooltip content={<TooltipBox unit={unit} money />} cursor={{ fill: "var(--bg-muted)" }} />
74+ <Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 12, color: "var(--fg-muted)" }} />
75+ <Bar dataKey="revenue" name="Revenue" fill={pal[0]} radius={[3, 3, 0, 0]} maxBarSize={28} />
76+ <Bar dataKey="cost" name="Upstream cost" fill={pal[1]} radius={[3, 3, 0, 0]} maxBarSize={28} />
77+ <Bar dataKey="margin" name="Margin" fill={pal[2]} radius={[3, 3, 0, 0]} maxBarSize={28} />
78+ </BarChart>
79+ </ResponsiveContainer>
80+ </div>
81+ );
82+}
83+
84+/** Requests over time, one line per provider (fixed hue per provider). */
85+export function ProviderSeriesChart({ data, providers, unit, metric = "requests" }: { data: Array<Record<string, number | string>>; providers: string[]; unit: "hour" | "day"; metric?: "requests" | "successes" | "cost" }) {
86+ const pal = usePalette();
87+ if (!data.length || !providers.length) return <div className="flex h-[260px] items-center justify-center text-[13px] text-fg-muted">No routing metrics in this range.</div>;
88+ const money = metric === "cost";
89+ return (
90+ <div className="h-[260px] w-full" role="img" aria-label={`${metric} over time per provider`}>
91+ <ResponsiveContainer width="100%" height="100%">
92+ <LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
93+ <CartesianGrid vertical={false} stroke={gridStroke} />
94+ <XAxis dataKey="t" tickFormatter={fmtTick(unit)} tick={axisStyle} axisLine={false} tickLine={false} minTickGap={24} />
95+ <YAxis tick={axisStyle} axisLine={false} tickLine={false} width={56} tickFormatter={(v: number) => (money ? `$${v >= 1 ? formatCompact(v) : v.toFixed(2)}` : formatCompact(v))} />
96+ <Tooltip content={<TooltipBox unit={unit} money={money} />} cursor={{ stroke: "var(--border-strong)" }} />
97+ <Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 12, color: "var(--fg-muted)" }} />
98+ {providers.map((p) => (
99+ <Line key={p} type="monotone" dataKey={`${p}:${metric}`} name={p} stroke={providerColor(p, pal)} strokeWidth={2} dot={false} activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--bg-elevated)" }} connectNulls />
100+ ))}
101+ </LineChart>
102+ </ResponsiveContainer>
103+ </div>
104+ );
105+}
added apps/web/src/components/admin/domain-policy-editor.tsx +138 −0
@@ -0,0 +1,138 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { ArrowDown, ArrowUp, Eraser, Plus, Save, X } from "lucide-react";
5+import { resetDomainStats, updateDomainPolicy, type AdminActionResult } from "@/actions/admin";
6+import { Button } from "@/components/ui/button";
7+import { NativeSelect } from "@/components/ui/input";
8+import { Field, Hint, Label } from "@/components/ui/label";
9+import { ActionButton, ResultMessage } from "./action-button";
10+
11+const NETWORKS = ["datacenter", "residential", "isp", "mobile"] as const;
12+
13+export function DomainPolicyEditor({ domain, policy, availableKeys }: { domain: string; policy: { order?: string[]; force_network?: string } | null; availableKeys: string[] }) {
14+ const router = useRouter();
15+ const [pending, start] = React.useTransition();
16+ const [force, setForce] = React.useState(policy?.force_network ?? "");
17+ const [order, setOrder] = React.useState<string[]>(policy?.order ?? []);
18+ const [pick, setPick] = React.useState("");
19+ const [result, setResult] = React.useState<AdminActionResult | null>(null);
20+ const candidates = availableKeys.filter((k) => !order.includes(k));
21+
22+ const move = (i: number, d: -1 | 1) =>
23+ setOrder((cur) => {
24+ const next = [...cur];
25+ const j = i + d;
26+ if (j < 0 || j >= next.length) return cur;
27+ [next[i], next[j]] = [next[j]!, next[i]!];
28+ return next;
29+ });
30+
31+ return (
32+ <form
33+ className="grid gap-4"
34+ onSubmit={(e) => {
35+ e.preventDefault();
36+ start(async () => {
37+ const r = await updateDomainPolicy(domain, { force_network: force || null, order });
38+ setResult(r);
39+ if (r.ok) router.refresh();
40+ });
41+ }}
42+ >
43+ <Field>
44+ <Label htmlFor="force-network">Force network</Label>
45+ <NativeSelect id="force-network" value={force} onChange={(e) => setForce(e.target.value)} className="w-[220px]">
46+ <option value="">None (auto escalation)</option>
47+ {NETWORKS.map((n) => (
48+ <option key={n} value={n}>
49+ {n}
50+ </option>
51+ ))}
52+ </NativeSelect>
53+ <Hint>Applies only when the customer asks for `network: &quot;auto&quot;`. Explicit network requests are never overridden.</Hint>
54+ </Field>
55+
56+ <div className="grid gap-1.5">
57+ <Label>Pinned route order</Label>
58+ <Hint>Ordered list of `provider:network` keys. Pinned routes are tried first (in this order); unpinned candidates follow by score.</Hint>
59+ {order.length ? (
60+ <ol className="divide-y divide-border rounded-md border border-border">
61+ {order.map((k, i) => (
62+ <li key={k} className="flex items-center gap-2 px-2.5 py-1.5 text-[13px]">
63+ <span className="w-5 text-right tabular font-mono text-fg-subtle">{i + 1}.</span>
64+ <span className="font-mono">{k}</span>
65+ <span className="ml-auto flex items-center gap-0.5">
66+ <Button type="button" variant="ghost" size="icon-sm" onClick={() => move(i, -1)} disabled={i === 0} aria-label="Move up">
67+ <ArrowUp className="size-3.5" />
68+ </Button>
69+ <Button type="button" variant="ghost" size="icon-sm" onClick={() => move(i, 1)} disabled={i === order.length - 1} aria-label="Move down">
70+ <ArrowDown className="size-3.5" />
71+ </Button>
72+ <Button type="button" variant="ghost" size="icon-sm" onClick={() => setOrder((cur) => cur.filter((x) => x !== k))} aria-label={`Remove ${k}`}>
73+ <X className="size-3.5" />
74+ </Button>
75+ </span>
76+ </li>
77+ ))}
78+ </ol>
79+ ) : (
80+ <p className="rounded-md border border-dashed border-border px-3 py-2 text-[12.5px] text-fg-muted">No pinned routes — the engine orders candidates by score.</p>
81+ )}
82+ <div className="flex items-center gap-2">
83+ <NativeSelect value={pick} onChange={(e) => setPick(e.target.value)} className="w-[260px]" aria-label="Route to pin">
84+ <option value="">Add a route…</option>
85+ {candidates.map((k) => (
86+ <option key={k} value={k}>
87+ {k}
88+ </option>
89+ ))}
90+ </NativeSelect>
91+ <Button
92+ type="button"
93+ variant="outline"
94+ size="sm"
95+ disabled={!pick}
96+ onClick={() => {
97+ if (pick) setOrder((cur) => [...cur, pick]);
98+ setPick("");
99+ }}
100+ >
101+ <Plus className="size-3.5" /> Pin
102+ </Button>
103+ </div>
104+ </div>
105+
106+ <div className="flex flex-wrap items-center gap-3">
107+ <Button type="submit" size="sm" loading={pending}>
108+ <Save className="size-3.5" /> Save policy
109+ </Button>
110+ <Button
111+ type="button"
112+ variant="ghost"
113+ size="sm"
114+ onClick={() => {
115+ setForce("");
116+ setOrder([]);
117+ }}
118+ >
119+ Clear
120+ </Button>
121+ <ResultMessage result={result} />
122+ </div>
123+ </form>
124+ );
125+}
126+
127+export function ResetDomainStatsButton({ domain }: { domain: string }) {
128+ return (
129+ <ActionButton
130+ variant="outline"
131+ size="sm"
132+ action={() => resetDomainStats(domain)}
133+ confirm={{ title: `Reset learned stats for ${domain}?`, description: "Route statistics, block/captcha counters and the preferred route are cleared. The policy is kept. The router falls back to network priors until new data accumulates.", confirmLabel: "Reset stats" }}
134+ >
135+ <Eraser className="size-3.5" /> Reset stats
136+ </ActionButton>
137+ );
138+}
added apps/web/src/components/admin/flag-controls.tsx +121 −0
@@ -0,0 +1,121 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { Plus, Trash2 } from "lucide-react";
5+import { deleteFeatureFlag, setFeatureFlag, upsertFeatureFlag, type AdminActionResult } from "@/actions/admin";
6+import { Button } from "@/components/ui/button";
7+import { Input } from "@/components/ui/input";
8+import { Field, Hint, Label } from "@/components/ui/label";
9+import { Switch } from "@/components/ui/switch";
10+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
11+import { ActionButton, ResultMessage } from "./action-button";
12+
13+export function FlagSwitch({ flagKey, enabled }: { flagKey: string; enabled: boolean }) {
14+ const router = useRouter();
15+ const [pending, start] = React.useTransition();
16+ const [result, setResult] = React.useState<AdminActionResult | null>(null);
17+ return (
18+ <div className="flex items-center gap-2">
19+ <Switch
20+ checked={enabled}
21+ disabled={pending}
22+ aria-label={`Toggle ${flagKey}`}
23+ onCheckedChange={(v) =>
24+ start(async () => {
25+ const r = await setFeatureFlag(flagKey, v);
26+ setResult(r);
27+ if (r.ok) router.refresh();
28+ })
29+ }
30+ />
31+ {result && !result.ok ? <ResultMessage result={result} /> : null}
32+ </div>
33+ );
34+}
35+
36+export function DeleteFlagButton({ flagKey }: { flagKey: string }) {
37+ return (
38+ <ActionButton variant="ghost" size="icon-sm" aria-label={`Delete ${flagKey}`} action={() => deleteFeatureFlag(flagKey)} confirm={{ title: `Delete flag ${flagKey}?`, description: "Code that reads this flag will treat it as disabled.", confirmLabel: "Delete" }}>
39+ <Trash2 className="size-3.5 text-fg-subtle" />
40+ </ActionButton>
41+ );
42+}
43+
44+export function NewFlagDialog() {
45+ const router = useRouter();
46+ const [open, setOpen] = React.useState(false);
47+ const [pending, start] = React.useTransition();
48+ const [key, setKey] = React.useState("");
49+ const [description, setDescription] = React.useState("");
50+ const [plans, setPlans] = React.useState("");
51+ const [orgIds, setOrgIds] = React.useState("");
52+ const [result, setResult] = React.useState<AdminActionResult | null>(null);
53+ return (
54+ <>
55+ <Button variant="primary" size="sm" onClick={() => setOpen(true)}>
56+ <Plus className="size-3.5" /> New flag
57+ </Button>
58+ <Dialog open={open} onOpenChange={setOpen}>
59+ <DialogContent size="md">
60+ <DialogHeader>
61+ <DialogTitle>Create a feature flag</DialogTitle>
62+ <DialogDescription>Flags start disabled. Optional targeting by plan or organization id narrows who sees the feature when enabled.</DialogDescription>
63+ </DialogHeader>
64+ <form
65+ className="grid gap-3"
66+ onSubmit={(e) => {
67+ e.preventDefault();
68+ start(async () => {
69+ const r = await upsertFeatureFlag({
70+ key: key.trim(),
71+ description,
72+ enabled: false,
73+ plans: plans.split(",").map((s) => s.trim()).filter(Boolean) as never,
74+ organizationIds: orgIds.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean),
75+ });
76+ setResult(r);
77+ if (r.ok) {
78+ setOpen(false);
79+ setKey("");
80+ setDescription("");
81+ setPlans("");
82+ setOrgIds("");
83+ router.refresh();
84+ }
85+ });
86+ }}
87+ >
88+ <Field>
89+ <Label htmlFor="flag-key">Key</Label>
90+ <Input id="flag-key" value={key} onChange={(e) => setKey(e.target.value)} placeholder="e.g. browser.beta" className="font-mono" required />
91+ <Hint>Lowercase letters, digits, `.` `_` `-`.</Hint>
92+ </Field>
93+ <Field>
94+ <Label htmlFor="flag-desc">Description</Label>
95+ <Input id="flag-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What this flag gates" />
96+ </Field>
97+ <div className="grid gap-3 sm:grid-cols-2">
98+ <Field>
99+ <Label htmlFor="flag-plans">Plans (comma-separated)</Label>
100+ <Input id="flag-plans" value={plans} onChange={(e) => setPlans(e.target.value)} placeholder="growth, business" className="font-mono" />
101+ </Field>
102+ <Field>
103+ <Label htmlFor="flag-orgs">Organization ids</Label>
104+ <Input id="flag-orgs" value={orgIds} onChange={(e) => setOrgIds(e.target.value)} placeholder="org_a, org_b" className="font-mono" />
105+ </Field>
106+ </div>
107+ <ResultMessage result={result && !result.ok ? result : null} />
108+ <DialogFooter>
109+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
110+ Cancel
111+ </Button>
112+ <Button type="submit" variant="primary" loading={pending}>
113+ Create flag
114+ </Button>
115+ </DialogFooter>
116+ </form>
117+ </DialogContent>
118+ </Dialog>
119+ </>
120+ );
121+}
added apps/web/src/components/admin/incident-controls.tsx +106 −0
@@ -0,0 +1,106 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { CheckCircle2, Plus, Undo2 } from "lucide-react";
5+import { createIncident, resolveIncident } from "@/actions/admin";
6+import { Button } from "@/components/ui/button";
7+import { Input, NativeSelect, Textarea } from "@/components/ui/input";
8+import { Field, Hint, Label } from "@/components/ui/label";
9+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
10+import { ActionButton } from "./action-button";
11+
12+const COMPONENTS = ["api", "dashboard", "residential", "datacenter", "isp", "mobile", "sessions", "billing", "website"];
13+
14+export function ResolveIncidentButton({ id, resolved }: { id: string; resolved: boolean }) {
15+ return resolved ? (
16+ <ActionButton variant="ghost" size="xs" action={() => resolveIncident(id, false)}>
17+ <Undo2 className="size-3" /> Reopen
18+ </ActionButton>
19+ ) : (
20+ <ActionButton variant="outline" size="xs" action={() => resolveIncident(id, true)}>
21+ <CheckCircle2 className="size-3" /> Resolve
22+ </ActionButton>
23+ );
24+}
25+
26+export function NewIncidentDialog() {
27+ const router = useRouter();
28+ const [open, setOpen] = React.useState(false);
29+ const [pending, start] = React.useTransition();
30+ const [error, setError] = React.useState<string | null>(null);
31+ const [form, setForm] = React.useState({ component: "api", severity: "minor", title: "", body: "" });
32+ const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => setForm((f) => ({ ...f, [k]: e.target.value }));
33+ return (
34+ <>
35+ <Button variant="primary" size="sm" onClick={() => setOpen(true)}>
36+ <Plus className="size-3.5" /> Open incident
37+ </Button>
38+ <Dialog open={open} onOpenChange={setOpen}>
39+ <DialogContent size="md">
40+ <DialogHeader>
41+ <DialogTitle>Open a status incident</DialogTitle>
42+ <DialogDescription>Published immediately on the public /status page. Write for customers: describe impact and workaround, never upstream provider names.</DialogDescription>
43+ </DialogHeader>
44+ <form
45+ className="grid gap-3"
46+ onSubmit={(e) => {
47+ e.preventDefault();
48+ start(async () => {
49+ setError(null);
50+ const r = await createIncident(form as Parameters<typeof createIncident>[0]);
51+ if (!r.ok) return setError(r.error);
52+ setOpen(false);
53+ setForm({ component: "api", severity: "minor", title: "", body: "" });
54+ router.refresh();
55+ });
56+ }}
57+ >
58+ <div className="grid gap-3 sm:grid-cols-2">
59+ <Field>
60+ <Label htmlFor="inc-comp">Component</Label>
61+ <NativeSelect id="inc-comp" value={form.component} onChange={set("component")}>
62+ {COMPONENTS.map((c) => (
63+ <option key={c} value={c}>
64+ {c}
65+ </option>
66+ ))}
67+ </NativeSelect>
68+ </Field>
69+ <Field>
70+ <Label htmlFor="inc-sev">Severity</Label>
71+ <NativeSelect id="inc-sev" value={form.severity} onChange={set("severity")}>
72+ <option value="minor">minor</option>
73+ <option value="major">major</option>
74+ <option value="critical">critical</option>
75+ <option value="maintenance">maintenance</option>
76+ </NativeSelect>
77+ </Field>
78+ </div>
79+ <Field>
80+ <Label htmlFor="inc-title">Title</Label>
81+ <Input id="inc-title" value={form.title} onChange={set("title")} placeholder="Elevated error rates on residential network" required />
82+ </Field>
83+ <Field>
84+ <Label htmlFor="inc-body">Update</Label>
85+ <Textarea id="inc-body" value={form.body} onChange={set("body")} placeholder="We are investigating elevated TARGET_BLOCKED rates for some regions…" />
86+ <Hint>Public. Uses network classes only.</Hint>
87+ </Field>
88+ {error ? (
89+ <p role="alert" className="text-[13px] text-danger">
90+ {error}
91+ </p>
92+ ) : null}
93+ <DialogFooter>
94+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
95+ Cancel
96+ </Button>
97+ <Button type="submit" variant="primary" loading={pending}>
98+ Publish
99+ </Button>
100+ </DialogFooter>
101+ </form>
102+ </DialogContent>
103+ </Dialog>
104+ </>
105+ );
106+}
added apps/web/src/components/admin/org-actions.tsx +162 −0
@@ -0,0 +1,162 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { setOrganizationProviderVisibility, setOrganizationSuspended, updateOrganizationLimits, updateOrganizationPlan, type AdminActionResult } from "@/actions/admin";
5+import { Button } from "@/components/ui/button";
6+import { Input, Textarea } from "@/components/ui/input";
7+import { Field, Hint, Label } from "@/components/ui/label";
8+import { Switch } from "@/components/ui/switch";
9+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
10+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
11+import { ResultMessage } from "./action-button";
12+
13+export function OrgPlanSelect({ orgId, plan, options }: { orgId: string; plan: string; options: Array<{ value: string; label: string }> }) {
14+ const router = useRouter();
15+ const [pending, start] = React.useTransition();
16+ const [result, setResult] = React.useState<AdminActionResult | null>(null);
17+ return (
18+ <div className="grid gap-1.5">
19+ <Select
20+ value={plan}
21+ disabled={pending}
22+ onValueChange={(v) =>
23+ start(async () => {
24+ const r = await updateOrganizationPlan(orgId, v);
25+ setResult(r);
26+ if (r.ok) router.refresh();
27+ })
28+ }
29+ >
30+ <SelectTrigger aria-label="Plan" className="w-[200px]">
31+ <SelectValue />
32+ </SelectTrigger>
33+ <SelectContent>
34+ {options.map((o) => (
35+ <SelectItem key={o.value} value={o.value}>
36+ {o.label}
37+ </SelectItem>
38+ ))}
39+ </SelectContent>
40+ </Select>
41+ <Hint>Changes limits immediately. Billing is not connected yet, so no invoice is generated.</Hint>
42+ <ResultMessage result={result} />
43+ </div>
44+ );
45+}
46+
47+export function OrgProviderVisibilitySwitch({ orgId, enabled }: { orgId: string; enabled: boolean }) {
48+ const router = useRouter();
49+ const [pending, start] = React.useTransition();
50+ const [result, setResult] = React.useState<AdminActionResult | null>(null);
51+ return (
52+ <div className="flex items-start gap-3">
53+ <Switch
54+ id={`pv-${orgId}`}
55+ checked={enabled}
56+ disabled={pending}
57+ aria-label="Provider visibility"
58+ onCheckedChange={(v) =>
59+ start(async () => {
60+ const r = await setOrganizationProviderVisibility(orgId, v);
61+ setResult(r);
62+ if (r.ok) router.refresh();
63+ })
64+ }
65+ />
66+ <div className="grid gap-0.5">
67+ <Label htmlFor={`pv-${orgId}`}>Provider visibility (debug)</Label>
68+ <Hint>When on, `debug: true` responses include real upstream provider names for this customer. Off for everyone by default.</Hint>
69+ <ResultMessage result={result} />
70+ </div>
71+ </div>
72+ );
73+}
74+
75+export function OrgSuspendSwitch({ orgId, suspended }: { orgId: string; suspended: boolean }) {
76+ const router = useRouter();
77+ const [pending, start] = React.useTransition();
78+ const [open, setOpen] = React.useState(false);
79+ const [reason, setReason] = React.useState("");
80+ const [result, setResult] = React.useState<AdminActionResult | null>(null);
81+ const apply = (v: boolean) =>
82+ start(async () => {
83+ const r = await setOrganizationSuspended(orgId, v, reason);
84+ setResult(r);
85+ if (r.ok) {
86+ setOpen(false);
87+ setReason("");
88+ router.refresh();
89+ }
90+ });
91+ return (
92+ <div className="flex items-start gap-3">
93+ <Switch id={`sus-${orgId}`} checked={suspended} disabled={pending} aria-label="Suspended" onCheckedChange={(v) => (v ? setOpen(true) : apply(false))} />
94+ <div className="grid gap-0.5">
95+ <Label htmlFor={`sus-${orgId}`}>Suspended</Label>
96+ <Hint>Suspended organizations get `FORBIDDEN` on every API call and see a banner in the dashboard.</Hint>
97+ <ResultMessage result={result} />
98+ </div>
99+ <Dialog open={open} onOpenChange={setOpen}>
100+ <DialogContent size="sm">
101+ <DialogHeader>
102+ <DialogTitle>Suspend this organization?</DialogTitle>
103+ <DialogDescription>All API keys stop working immediately. Members can still sign in to read logs and contact support.</DialogDescription>
104+ </DialogHeader>
105+ <Field>
106+ <Label htmlFor="sus-reason">Reason (internal)</Label>
107+ <Textarea id="sus-reason" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Optional — stored in the audit log" />
108+ </Field>
109+ <DialogFooter>
110+ <Button variant="outline" onClick={() => setOpen(false)}>
111+ Cancel
112+ </Button>
113+ <Button variant="danger" loading={pending} onClick={() => apply(true)}>
114+ Suspend
115+ </Button>
116+ </DialogFooter>
117+ </DialogContent>
118+ </Dialog>
119+ </div>
120+ );
121+}
122+
123+export function OrgLimitsForm({ orgId, softLimitUsd, hardLimitUsd }: { orgId: string; softLimitUsd: number | null; hardLimitUsd: number | null }) {
124+ const router = useRouter();
125+ const [pending, start] = React.useTransition();
126+ const [soft, setSoft] = React.useState(softLimitUsd?.toString() ?? "");
127+ const [hard, setHard] = React.useState(hardLimitUsd?.toString() ?? "");
128+ const [result, setResult] = React.useState<AdminActionResult | null>(null);
129+ const toNum = (s: string) => (s.trim() === "" ? null : Number(s));
130+ return (
131+ <form
132+ className="grid gap-3"
133+ onSubmit={(e) => {
134+ e.preventDefault();
135+ start(async () => {
136+ const r = await updateOrganizationLimits(orgId, { softLimitUsd: toNum(soft), hardLimitUsd: toNum(hard) });
137+ setResult(r);
138+ if (r.ok) router.refresh();
139+ });
140+ }}
141+ >
142+ <div className="grid gap-3 sm:grid-cols-2">
143+ <Field>
144+ <Label htmlFor="soft">Soft limit (USD / month)</Label>
145+ <Input id="soft" type="number" min={0} step="0.01" inputMode="decimal" value={soft} onChange={(e) => setSoft(e.target.value)} placeholder="none" className="font-mono" />
146+ <Hint>Sends a warning email; requests continue.</Hint>
147+ </Field>
148+ <Field>
149+ <Label htmlFor="hard">Hard limit (USD / month)</Label>
150+ <Input id="hard" type="number" min={0} step="0.01" inputMode="decimal" value={hard} onChange={(e) => setHard(e.target.value)} placeholder="none" className="font-mono" />
151+ <Hint>Requests fail with `USAGE_LIMIT_REACHED` once exceeded.</Hint>
152+ </Field>
153+ </div>
154+ <div className="flex items-center gap-3">
155+ <Button type="submit" size="sm" loading={pending}>
156+ Save limits
157+ </Button>
158+ <ResultMessage result={result} />
159+ </div>
160+ </form>
161+ );
162+}
added apps/web/src/components/admin/primitives.tsx +214 −0
@@ -0,0 +1,214 @@
1+import * as React from "react";
2+import Link from "next/link";
3+import { ChevronLeft, ChevronRight, Search } from "lucide-react";
4+import { cn } from "@/lib/utils";
5+import { Badge } from "@/components/ui/badge";
6+import { Button } from "@/components/ui/button";
7+import { Input } from "@/components/ui/input";
8+import { Skeleton, TableSkeleton } from "@/components/ui/skeleton";
9+import { formatDate, timeAgo } from "@/lib/format";
10+import { RANGES, rangeLabel, type Range } from "@/lib/queries/admin";
11+
12+/** Dense table cell for numbers: right-aligned, tabular, mono. */
13+export function Num({ children, className, muted }: { children: React.ReactNode; className?: string; muted?: boolean }) {
14+ return <span className={cn("tabular font-mono text-[12.5px]", muted && "text-fg-subtle", className)}>{children}</span>;
15+}
16+export const numCell = "text-right tabular font-mono text-[12.5px] whitespace-nowrap";
17+export const monoCell = "font-mono text-[12.5px] whitespace-nowrap";
18+
19+export function Mono({ children, className, title }: { children: React.ReactNode; className?: string; title?: string }) {
20+ return (
21+ <span className={cn("font-mono text-[12.5px]", className)} title={title}>
22+ {children}
23+ </span>
24+ );
25+}
26+
27+/** Upstream provider name. Admin-only by construction: this component only lives in components/admin. */
28+export function ProviderBadge({ id, className }: { id: string | null | undefined; className?: string }) {
29+ if (!id) return <span className="text-fg-subtle">—</span>;
30+ return (
31+ <Badge variant="outline" className={cn("font-mono lowercase", className)}>
32+ {id}
33+ </Badge>
34+ );
35+}
36+
37+export function NetworkBadge({ network }: { network: string | null | undefined }) {
38+ if (!network) return <span className="text-fg-subtle">—</span>;
39+ return <Badge variant={network === "residential" ? "accent" : network === "mobile" ? "info" : "default"}>{network}</Badge>;
40+}
41+
42+export function PlanBadge({ plan }: { plan: string }) {
43+ const v = plan === "free" ? "outline" : plan === "developer" ? "default" : plan === "growth" ? "info" : plan === "business" ? "accent" : "solid";
44+ return (
45+ <Badge variant={v} className="capitalize">
46+ {plan}
47+ </Badge>
48+ );
49+}
50+
51+export function BoolBadge({ value, yes = "yes", no = "no", invert }: { value: boolean; yes?: string; no?: string; invert?: boolean }) {
52+ const good = invert ? !value : value;
53+ return (
54+ <Badge variant={good ? "success" : "outline"} dot>
55+ {value ? yes : no}
56+ </Badge>
57+ );
58+}
59+
60+export function DateCell({ value, relative = true }: { value: Date | string | null | undefined; relative?: boolean }) {
61+ if (!value) return <span className="text-fg-subtle">—</span>;
62+ return (
63+ <time dateTime={new Date(value).toISOString()} title={formatDate(value)} className="whitespace-nowrap text-[12.5px] text-fg-muted">
64+ {relative ? timeAgo(value) : formatDate(value)}
65+ </time>
66+ );
67+}
68+
69+export function Panel({ title, description, right, children, className, bodyClassName, flush }: { title?: React.ReactNode; description?: React.ReactNode; right?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string; flush?: boolean }) {
70+ return (
71+ <section className={cn("rounded-lg border border-border bg-bg-elevated shadow-xs", className)}>
72+ {title ? (
73+ <header className="flex flex-wrap items-start justify-between gap-2 border-b border-border px-4 py-2.5">
74+ <div className="min-w-0">
75+ <h2 className="text-[13px] font-semibold tracking-tight">{title}</h2>
76+ {description ? <p className="mt-0.5 text-[12px] text-fg-muted leading-relaxed">{description}</p> : null}
77+ </div>
78+ {right ? <div className="flex shrink-0 items-center gap-2">{right}</div> : null}
79+ </header>
80+ ) : null}
81+ <div className={cn(!flush && "p-4", bodyClassName)}>{children}</div>
82+ </section>
83+ );
84+}
85+
86+/** Two-column key/value grid for detail pages. */
87+export function KV({ items, cols = 2, className }: { items: Array<{ label: React.ReactNode; value: React.ReactNode; mono?: boolean; span?: boolean }>; cols?: 1 | 2 | 3; className?: string }) {
88+ return (
89+ <dl className={cn("grid gap-x-6 gap-y-3 text-[13px]", cols === 2 && "sm:grid-cols-2", cols === 3 && "sm:grid-cols-2 lg:grid-cols-3", className)}>
90+ {items.map((it, i) => (
91+ <div key={i} className={cn("min-w-0", it.span && "sm:col-span-full")}>
92+ <dt className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">{it.label}</dt>
93+ <dd className={cn("mt-0.5 break-words text-fg", it.mono && "font-mono text-[12.5px]")}>{it.value ?? <span className="text-fg-subtle">—</span>}</dd>
94+ </div>
95+ ))}
96+ </dl>
97+ );
98+}
99+
100+/** Range selector (24h / 7d / 30d) rendered as links; preserves other query params. */
101+export function RangeTabs({ current, basePath, params }: { current: Range; basePath: string; params?: Record<string, string | undefined> }) {
102+ return (
103+ <div className="inline-flex h-8 items-center gap-0.5 rounded-md bg-bg-muted p-1 text-fg-muted" role="tablist" aria-label="Time range">
104+ {RANGES.map((r) => {
105+ const sp = new URLSearchParams();
106+ for (const [k, v] of Object.entries(params ?? {})) if (v) sp.set(k, v);
107+ sp.set("range", r);
108+ const active = r === current;
109+ return (
110+ <Link
111+ key={r}
112+ href={`${basePath}?${sp.toString()}`}
113+ role="tab"
114+ aria-selected={active}
115+ title={rangeLabel(r)}
116+ className={cn("inline-flex h-6 items-center rounded-[5px] px-2.5 text-[12.5px] font-medium transition-colors", active ? "bg-bg text-fg shadow-xs" : "hover:text-fg")}
117+ >
118+ {r}
119+ </Link>
120+ );
121+ })}
122+ </div>
123+ );
124+}
125+
126+export function Pagination({ page, pages, total, basePath, params, pageSize }: { page: number; pages: number; total: number; basePath: string; params?: Record<string, string | undefined>; pageSize: number }) {
127+ const href = (p: number) => {
128+ const sp = new URLSearchParams();
129+ for (const [k, v] of Object.entries(params ?? {})) if (v) sp.set(k, v);
130+ if (p > 1) sp.set("page", String(p));
131+ const q = sp.toString();
132+ return q ? `${basePath}?${q}` : basePath;
133+ };
134+ const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
135+ const to = Math.min(total, page * pageSize);
136+ return (
137+ <div className="flex items-center justify-between gap-3 border-t border-border px-4 py-2 text-[12.5px] text-fg-muted">
138+ <span className="tabular">
139+ {from.toLocaleString()}–{to.toLocaleString()} of {total.toLocaleString()}
140+ </span>
141+ <div className="flex items-center gap-1">
142+ <Button asChild variant="outline" size="xs" disabled={page <= 1} aria-disabled={page <= 1} className={cn(page <= 1 && "pointer-events-none opacity-50")}>
143+ <Link href={href(Math.max(1, page - 1))} aria-label="Previous page">
144+ <ChevronLeft className="size-3.5" />
145+ </Link>
146+ </Button>
147+ <span className="px-2 tabular">
148+ {page} / {pages}
149+ </span>
150+ <Button asChild variant="outline" size="xs" className={cn(page >= pages && "pointer-events-none opacity-50")}>
151+ <Link href={href(Math.min(pages, page + 1))} aria-label="Next page" aria-disabled={page >= pages}>
152+ <ChevronRight className="size-3.5" />
153+ </Link>
154+ </Button>
155+ </div>
156+ </div>
157+ );
158+}
159+
160+/** Plain GET search form — no client JS needed. Extra hidden params are preserved. */
161+export function SearchForm({ action, q, placeholder, hidden, children }: { action: string; q?: string; placeholder?: string; hidden?: Record<string, string | undefined>; children?: React.ReactNode }) {
162+ return (
163+ <form action={action} method="get" className="flex flex-wrap items-center gap-2" role="search">
164+ {Object.entries(hidden ?? {}).map(([k, v]) => (v ? <input key={k} type="hidden" name={k} value={v} /> : null))}
165+ <div className="relative">
166+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-subtle" aria-hidden />
167+ <Input name="q" defaultValue={q} placeholder={placeholder ?? "Search…"} className="h-8 w-[260px] pl-8 text-[13px]" aria-label="Search" />
168+ </div>
169+ {children}
170+ <Button type="submit" variant="outline" size="sm">
171+ Search
172+ </Button>
173+ {q ? (
174+ <Button asChild variant="ghost" size="sm">
175+ <Link href={action}>Clear</Link>
176+ </Button>
177+ ) : null}
178+ </form>
179+ );
180+}
181+
182+export function MarginText({ value, pct }: { value: number; pct: number | null }) {
183+ const neg = value < 0;
184+ return (
185+ <span className={cn("tabular font-mono text-[12.5px]", neg ? "text-danger" : "text-success")}>
186+ {neg ? "−" : ""}${Math.abs(value).toFixed(value !== 0 && Math.abs(value) < 0.01 ? 4 : 2)}
187+ {pct !== null ? <span className="ml-1 text-fg-subtle">({pct.toFixed(1)}%)</span> : null}
188+ </span>
189+ );
190+}
191+
192+export function AdminLoading({ stats = 4, rows = 8 }: { stats?: number; rows?: number }) {
193+ return (
194+ <div className="space-y-6" aria-busy="true" aria-live="polite">
195+ <div className="space-y-2">
196+ <Skeleton className="h-6 w-48" />
197+ <Skeleton className="h-3.5 w-80" />
198+ </div>
199+ {stats ? (
200+ <div className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-2 lg:grid-cols-4">
201+ {Array.from({ length: stats }).map((_, i) => (
202+ <div key={i} className="space-y-2 bg-bg-elevated px-5 py-4">
203+ <Skeleton className="h-3 w-20" />
204+ <Skeleton className="h-6 w-28" />
205+ </div>
206+ ))}
207+ </div>
208+ ) : null}
209+ <div className="rounded-lg border border-border bg-bg-elevated">
210+ <TableSkeleton rows={rows} cols={6} />
211+ </div>
212+ </div>
213+ );
214+}
added apps/web/src/components/admin/provider-panel.tsx +174 −0
@@ -0,0 +1,174 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { RefreshCw, RotateCcw, Save } from "lucide-react";
5+import { resetCircuit, runProviderProbe, updateProviderConfig, type AdminActionResult, type ProviderConfigInput } from "@/actions/admin";
6+import { Button } from "@/components/ui/button";
7+import { Input, Textarea } from "@/components/ui/input";
8+import { Field, Hint, Label } from "@/components/ui/label";
9+import { Switch } from "@/components/ui/switch";
10+import { Badge, StatusBadge } from "@/components/ui/badge";
11+import { ActionButton, ResultMessage } from "./action-button";
12+
13+const ALL_NETWORKS = ["datacenter", "residential", "isp", "mobile"] as const;
14+
15+export interface ProviderConfigFormProps {
16+ id: string;
17+ label: string;
18+ enabled: boolean;
19+ networks: string[];
20+ pricePerGbUsd: Record<string, number>;
21+ weight: number;
22+ maxConcurrency: number;
23+ notes: string | null;
24+ /** Prices reported live by the API (fallback when no config row exists). */
25+ livePrices: Record<string, number>;
26+ liveNetworks: string[];
27+ hasConfigRow: boolean;
28+}
29+
30+export function ProviderConfigForm(p: ProviderConfigFormProps) {
31+ const router = useRouter();
32+ const [pending, start] = React.useTransition();
33+ const [result, setResult] = React.useState<AdminActionResult | null>(null);
34+ const [enabled, setEnabled] = React.useState(p.enabled);
35+ const [networks, setNetworks] = React.useState<string[]>(p.networks.length ? p.networks : p.liveNetworks);
36+ const [prices, setPrices] = React.useState<Record<string, string>>(() => {
37+ const out: Record<string, string> = {};
38+ for (const n of ALL_NETWORKS) {
39+ const v = p.pricePerGbUsd[n] ?? p.livePrices[n];
40+ if (v !== undefined) out[n] = String(v);
41+ }
42+ return out;
43+ });
44+ const [weight, setWeight] = React.useState(String(p.weight));
45+ const [maxConcurrency, setMaxConcurrency] = React.useState(String(p.maxConcurrency));
46+ const [notes, setNotes] = React.useState(p.notes ?? "");
47+
48+ const submit = (e: React.FormEvent) => {
49+ e.preventDefault();
50+ start(async () => {
51+ const pricePerGbUsd: Record<string, number> = {};
52+ for (const [k, v] of Object.entries(prices)) if (v.trim() !== "" && networks.includes(k)) pricePerGbUsd[k] = Number(v);
53+ const input: ProviderConfigInput = {
54+ label: p.label,
55+ enabled,
56+ networks: networks as ProviderConfigInput["networks"],
57+ pricePerGbUsd,
58+ weight: Number(weight),
59+ maxConcurrency: Number(maxConcurrency),
60+ notes: notes.trim() || null,
61+ };
62+ const r = await updateProviderConfig(p.id, input);
63+ setResult(r);
64+ if (r.ok) router.refresh();
65+ });
66+ };
67+
68+ return (
69+ <form onSubmit={submit} className="grid gap-4">
70+ <div className="flex items-center gap-3">
71+ <Switch id={`en-${p.id}`} checked={enabled} onCheckedChange={setEnabled} aria-label="Enabled" />
72+ <Label htmlFor={`en-${p.id}`}>Enabled in routing</Label>
73+ {!p.hasConfigRow ? <Badge variant="outline">no config row yet — defaults shown</Badge> : null}
74+ </div>
75+ <div>
76+ <div className="mb-1.5 text-[13px] font-medium">Networks and price per GB (USD)</div>
77+ <div className="grid gap-2 sm:grid-cols-2">
78+ {ALL_NETWORKS.map((n) => {
79+ const on = networks.includes(n);
80+ return (
81+ <div key={n} className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
82+ <input
83+ type="checkbox"
84+ id={`${p.id}-${n}`}
85+ className="size-3.5 accent-[var(--accent)]"
86+ checked={on}
87+ onChange={(e) => setNetworks((cur) => (e.target.checked ? [...cur, n] : cur.filter((x) => x !== n)))}
88+ />
89+ <label htmlFor={`${p.id}-${n}`} className="w-24 text-[13px]">
90+ {n}
91+ </label>
92+ <span className="text-[12px] text-fg-subtle">$</span>
93+ <Input
94+ aria-label={`${n} price per GB`}
95+ type="number"
96+ min={0}
97+ step="0.01"
98+ inputMode="decimal"
99+ disabled={!on}
100+ value={prices[n] ?? ""}
101+ onChange={(e) => setPrices((cur) => ({ ...cur, [n]: e.target.value }))}
102+ className="h-7 w-24 font-mono text-[12.5px]"
103+ placeholder="—"
104+ />
105+ <span className="text-[12px] text-fg-subtle">/GB</span>
106+ </div>
107+ );
108+ })}
109+ </div>
110+ </div>
111+ <div className="grid gap-3 sm:grid-cols-2">
112+ <Field>
113+ <Label htmlFor={`w-${p.id}`}>Weight</Label>
114+ <Input id={`w-${p.id}`} type="number" min={0} max={10} step="0.1" value={weight} onChange={(e) => setWeight(e.target.value)} className="font-mono" />
115+ <Hint>Relative preference multiplier (1 = neutral).</Hint>
116+ </Field>
117+ <Field>
118+ <Label htmlFor={`mc-${p.id}`}>Max concurrency</Label>
119+ <Input id={`mc-${p.id}`} type="number" min={1} step={1} value={maxConcurrency} onChange={(e) => setMaxConcurrency(e.target.value)} className="font-mono" />
120+ <Hint>Upstream connection ceiling for this provider.</Hint>
121+ </Field>
122+ </div>
123+ <Field>
124+ <Label htmlFor={`notes-${p.id}`}>Notes</Label>
125+ <Textarea id={`notes-${p.id}`} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Contract terms, account manager, quotas…" className="min-h-[64px]" />
126+ </Field>
127+ <div className="flex items-center gap-3">
128+ <Button type="submit" size="sm" loading={pending}>
129+ <Save className="size-3.5" /> Save &amp; reload providers
130+ </Button>
131+ <ResultMessage result={result} />
132+ </div>
133+ </form>
134+ );
135+}
136+
137+export function CircuitList({ circuits }: { circuits: Array<{ key: string; state: string; failures: number; successes: number; openedAt: number | null }> }) {
138+ if (!circuits.length) return <p className="text-[12.5px] text-fg-muted">No circuit entries yet — circuits are created on the first attempt per route.</p>;
139+ return (
140+ <ul className="divide-y divide-border">
141+ {circuits.map((c) => (
142+ <li key={c.key} className="flex flex-wrap items-center gap-3 py-2 text-[12.5px]">
143+ <span className="font-mono">{c.key}</span>
144+ <StatusBadge status={c.state === "closed" ? "healthy" : c.state === "open" ? "down" : c.state} />
145+ <span className="tabular font-mono text-fg-muted">
146+ {c.successes} ok / {c.failures} fail
147+ </span>
148+ {c.openedAt ? <span className="text-fg-subtle">opened {new Date(c.openedAt).toLocaleTimeString()}</span> : null}
149+ <span className="ml-auto">
150+ <ActionButton variant="outline" size="xs" action={() => resetCircuit(c.key)}>
151+ <RotateCcw className="size-3" /> Reset circuit
152+ </ActionButton>
153+ </span>
154+ </li>
155+ ))}
156+ </ul>
157+ );
158+}
159+
160+export function ProbeButton() {
161+ return (
162+ <ActionButton variant="primary" size="sm" action={() => runProviderProbe()}>
163+ <RefreshCw className="size-3.5" /> Run health probe now
164+ </ActionButton>
165+ );
166+}
167+
168+export function ResetAllCircuitsButton() {
169+ return (
170+ <ActionButton variant="outline" size="sm" action={() => resetCircuit()} confirm={{ title: "Reset every circuit breaker?", description: "All routes become eligible again immediately, including ones that were failing.", confirmLabel: "Reset all" }}>
171+ <RotateCcw className="size-3.5" /> Reset all circuits
172+ </ActionButton>
173+ );
174+}
added apps/web/src/components/admin/user-actions.tsx +94 −0
@@ -0,0 +1,94 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { Ban, BadgeCheck, ShieldCheck, ShieldOff, Undo2 } from "lucide-react";
5+import { banUser, unbanUser, setUserRole, forceVerifyEmail } from "@/actions/admin";
6+import { Button } from "@/components/ui/button";
7+import { Textarea } from "@/components/ui/input";
8+import { Field, Label, Hint } from "@/components/ui/label";
9+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
10+import { ActionButton } from "./action-button";
11+
12+export function UserActions({ userId, banned, role, emailVerified, isSelf }: { userId: string; banned: boolean; role: string; emailVerified: boolean; isSelf: boolean }) {
13+ const router = useRouter();
14+ const [banOpen, setBanOpen] = React.useState(false);
15+ const [reason, setReason] = React.useState("");
16+ const [error, setError] = React.useState<string | null>(null);
17+ const [pending, start] = React.useTransition();
18+
19+ const doBan = () =>
20+ start(async () => {
21+ setError(null);
22+ const r = await banUser(userId, reason);
23+ if (!r.ok) return setError(r.error);
24+ setBanOpen(false);
25+ setReason("");
26+ router.refresh();
27+ });
28+
29+ return (
30+ <div className="flex flex-wrap items-center gap-2">
31+ {banned ? (
32+ <ActionButton variant="outline" size="sm" action={() => unbanUser(userId)}>
33+ <Undo2 className="size-3.5" /> Unban
34+ </ActionButton>
35+ ) : (
36+ <Button variant="danger" size="sm" onClick={() => setBanOpen(true)} disabled={isSelf} title={isSelf ? "You cannot ban yourself" : undefined}>
37+ <Ban className="size-3.5" /> Ban user
38+ </Button>
39+ )}
40+ {role === "admin" ? (
41+ <ActionButton
42+ variant="outline"
43+ size="sm"
44+ disabled={isSelf}
45+ action={() => setUserRole(userId, "user")}
46+ confirm={{ title: "Remove admin role?", description: "This user will lose access to /admin immediately.", confirmLabel: "Demote", variant: "danger" }}
47+ >
48+ <ShieldOff className="size-3.5" /> Demote to user
49+ </ActionButton>
50+ ) : (
51+ <ActionButton
52+ variant="outline"
53+ size="sm"
54+ action={() => setUserRole(userId, "admin")}
55+ confirm={{ title: "Grant admin role?", description: "Admins can see upstream provider names, costs and every customer's data. Only for Fetcha staff.", confirmLabel: "Promote", variant: "primary" }}
56+ >
57+ <ShieldCheck className="size-3.5" /> Promote to admin
58+ </ActionButton>
59+ )}
60+ {!emailVerified ? (
61+ <ActionButton variant="outline" size="sm" action={() => forceVerifyEmail(userId)} confirm={{ title: "Mark email as verified?", description: "Unlocks production API access without the user clicking the verification link.", confirmLabel: "Verify", variant: "primary" }}>
62+ <BadgeCheck className="size-3.5" /> Force verify email
63+ </ActionButton>
64+ ) : null}
65+
66+ <Dialog open={banOpen} onOpenChange={setBanOpen}>
67+ <DialogContent size="sm">
68+ <DialogHeader>
69+ <DialogTitle>Ban this user</DialogTitle>
70+ <DialogDescription>All active sessions are revoked and the user cannot sign in until unbanned. The reason is stored on the account and in the audit log.</DialogDescription>
71+ </DialogHeader>
72+ <Field>
73+ <Label htmlFor="ban-reason">Reason</Label>
74+ <Textarea id="ban-reason" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="e.g. SSRF attempts against internal ranges (abuse_…)" />
75+ <Hint>Internal only — never shown to the user.</Hint>
76+ </Field>
77+ {error ? (
78+ <p role="alert" className="text-[13px] text-danger">
79+ {error}
80+ </p>
81+ ) : null}
82+ <DialogFooter>
83+ <Button variant="outline" onClick={() => setBanOpen(false)}>
84+ Cancel
85+ </Button>
86+ <Button variant="danger" loading={pending} onClick={doBan}>
87+ Ban user
88+ </Button>
89+ </DialogFooter>
90+ </DialogContent>
91+ </Dialog>
92+ </div>
93+ );
94+}
added apps/web/src/components/auth/auth-card.tsx +12 −0
@@ -0,0 +1,12 @@
1+import * as React from "react";
2+
3+export function AuthCard({ title, description, children, footer }: { title: string; description?: React.ReactNode; children: React.ReactNode; footer?: React.ReactNode }) {
4+ return (
5+ <div className="rounded-xl border border-border bg-bg-elevated p-6 shadow-md sm:p-8">
6+ <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
7+ {description ? <p className="mt-1.5 text-[13.5px] leading-relaxed text-fg-muted">{description}</p> : null}
8+ <div className="mt-6">{children}</div>
9+ {footer ? <div className="mt-6 border-t border-border pt-5 text-center text-[13px] text-fg-muted">{footer}</div> : null}
10+ </div>
11+ );
12+}
added apps/web/src/components/auth/password-input.tsx +43 −0
@@ -0,0 +1,43 @@
1+"use client";
2+import * as React from "react";
3+import { Eye, EyeOff } from "lucide-react";
4+import { Input } from "@/components/ui/input";
5+import { cn } from "@/lib/utils";
6+
7+export function PasswordInput({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
8+ const [show, setShow] = React.useState(false);
9+ return (
10+ <div className="relative">
11+ <Input type={show ? "text" : "password"} className={cn("pr-10", className)} {...props} />
12+ <button type="button" onClick={() => setShow((s) => !s)} className="absolute inset-y-0 right-0 flex w-10 items-center justify-center text-fg-subtle hover:text-fg" aria-label={show ? "Hide password" : "Show password"} tabIndex={-1}>
13+ {show ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
14+ </button>
15+ </div>
16+ );
17+}
18+
19+export const PASSWORD_RULES = [
20+ { id: "len", label: "At least 10 characters", test: (p: string) => p.length >= 10 },
21+ { id: "case", label: "Upper and lower case letters", test: (p: string) => /[a-z]/.test(p) && /[A-Z]/.test(p) },
22+ { id: "num", label: "A number or symbol", test: (p: string) => /[\d\W_]/.test(p) },
23+];
24+
25+export function PasswordRequirements({ password }: { password: string }) {
26+ return (
27+ <ul className="mt-2 grid gap-1 text-[12px]" aria-live="polite">
28+ {PASSWORD_RULES.map((r) => {
29+ const ok = r.test(password);
30+ return (
31+ <li key={r.id} className={cn("flex items-center gap-2 transition-colors", ok ? "text-success" : "text-fg-subtle")}>
32+ <span className={cn("inline-block size-1.5 rounded-full", ok ? "bg-success" : "bg-border-strong")} aria-hidden />
33+ {r.label}
34+ </li>
35+ );
36+ })}
37+ </ul>
38+ );
39+}
40+
41+export function passwordStrong(p: string): boolean {
42+ return PASSWORD_RULES.every((r) => r.test(p));
43+}
added apps/web/src/components/dashboard/api-keys/create-key-dialog.tsx +236 −0
@@ -0,0 +1,236 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { Plus } from "lucide-react";
5+import { API_KEY_SCOPES } from "@fetcha/core/client";
6+import { createApiKey, type CreatedKey } from "@/actions/api-keys";
7+import { Button } from "@/components/ui/button";
8+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
9+import { Input } from "@/components/ui/input";
10+import { Field, Hint, Label } from "@/components/ui/label";
11+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
12+import { Alert } from "@/components/ui/alert";
13+import { Badge } from "@/components/ui/badge";
14+import { DEFAULT_SCOPES, SCOPE_INFO } from "@/lib/account-snippets";
15+import { cn } from "@/lib/utils";
16+import { KeyReveal } from "./key-reveal";
17+
18+export interface ProjectOption {
19+ id: string;
20+ name: string;
21+ environment: string;
22+}
23+
24+const EXPIRATIONS = [
25+ { value: "0", label: "Never expires" },
26+ { value: "30", label: "30 days" },
27+ { value: "90", label: "90 days" },
28+ { value: "365", label: "1 year" },
29+];
30+
31+export function CreateKeyDialog({
32+ projects,
33+ defaultProjectId,
34+ trigger,
35+ defaultOpen,
36+ onCreated,
37+}: {
38+ projects: ProjectOption[];
39+ defaultProjectId: string;
40+ trigger?: React.ReactNode;
41+ defaultOpen?: boolean;
42+ onCreated?: (key: CreatedKey) => void;
43+}) {
44+ const router = useRouter();
45+ const [open, setOpen] = React.useState(Boolean(defaultOpen));
46+ const [created, setCreated] = React.useState<CreatedKey | null>(null);
47+
48+ function handleOpenChange(next: boolean) {
49+ // While the plaintext is on screen, closing must go through the explicit "Done" button.
50+ if (!next && created) return;
51+ setOpen(next);
52+ if (!next) setCreated(null);
53+ }
54+
55+ return (
56+ <Dialog open={open} onOpenChange={handleOpenChange}>
57+ <DialogTrigger asChild>
58+ {trigger ?? (
59+ <Button variant="primary">
60+ <Plus /> Create API key
61+ </Button>
62+ )}
63+ </DialogTrigger>
64+ <DialogContent size="lg" onEscapeKeyDown={(e) => created && e.preventDefault()} onPointerDownOutside={(e) => created && e.preventDefault()} onInteractOutside={(e) => created && e.preventDefault()}>
65+ {created ? (
66+ <>
67+ <DialogHeader>
68+ <DialogTitle>API key created</DialogTitle>
69+ <DialogDescription>Your new key for {created.name} is ready.</DialogDescription>
70+ </DialogHeader>
71+ <KeyReveal
72+ plaintext={created.plaintext}
73+ name={created.name}
74+ onDone={() => {
75+ setCreated(null);
76+ setOpen(false);
77+ router.refresh();
78+ }}
79+ />
80+ </>
81+ ) : (
82+ <>
83+ <DialogHeader>
84+ <DialogTitle>Create API key</DialogTitle>
85+ <DialogDescription>Keys authenticate requests to the Fetcha API. The secret is shown once, then stored hashed.</DialogDescription>
86+ </DialogHeader>
87+ <CreateKeyForm
88+ projects={projects}
89+ defaultProjectId={defaultProjectId}
90+ onCreated={(k) => {
91+ setCreated(k);
92+ onCreated?.(k);
93+ }}
94+ />
95+ </>
96+ )}
97+ </DialogContent>
98+ </Dialog>
99+ );
100+}
101+
102+/** The form alone (also used inline by onboarding). */
103+export function CreateKeyForm({
104+ projects,
105+ defaultProjectId,
106+ onCreated,
107+ compact,
108+ defaultName = "",
109+ submitLabel = "Create key",
110+}: {
111+ projects: ProjectOption[];
112+ defaultProjectId: string;
113+ onCreated: (key: CreatedKey) => void;
114+ compact?: boolean;
115+ defaultName?: string;
116+ submitLabel?: string;
117+}) {
118+ const [pending, startTransition] = React.useTransition();
119+ const [name, setName] = React.useState(defaultName);
120+ const [projectId, setProjectId] = React.useState(defaultProjectId);
121+ const [mode, setMode] = React.useState<"live" | "test">("live");
122+ const [scopes, setScopes] = React.useState<string[]>(DEFAULT_SCOPES);
123+ const [expires, setExpires] = React.useState("0");
124+ const [error, setError] = React.useState<string | null>(null);
125+
126+ function toggleScope(s: string) {
127+ setScopes((cur) => (cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]));
128+ }
129+
130+ function submit(e: React.FormEvent) {
131+ e.preventDefault();
132+ setError(null);
133+ startTransition(async () => {
134+ const res = await createApiKey({ name, projectId, mode, scopes, expiresInDays: Number(expires) || undefined });
135+ if (!res.ok) {
136+ setError(res.error);
137+ return;
138+ }
139+ if (res.data) onCreated(res.data);
140+ });
141+ }
142+
143+ return (
144+ <form onSubmit={submit} className="grid gap-4" noValidate>
145+ {error ? <Alert variant="danger">{error}</Alert> : null}
146+ <div className={cn("grid gap-4", !compact && "sm:grid-cols-2")}>
147+ <Field>
148+ <Label htmlFor="key-name">Name</Label>
149+ <Input id="key-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Production backend" maxLength={64} required autoFocus />
150+ <Hint>Only for you — appears in the key list and audit log.</Hint>
151+ </Field>
152+ <Field>
153+ <Label htmlFor="key-project">Project</Label>
154+ <Select value={projectId} onValueChange={setProjectId}>
155+ <SelectTrigger id="key-project">
156+ <SelectValue placeholder="Select a project" />
157+ </SelectTrigger>
158+ <SelectContent>
159+ {projects.map((p) => (
160+ <SelectItem key={p.id} value={p.id}>
161+ {p.name} <span className="text-fg-subtle">· {p.environment}</span>
162+ </SelectItem>
163+ ))}
164+ </SelectContent>
165+ </Select>
166+ <Hint>Requests, usage and limits are tracked per project.</Hint>
167+ </Field>
168+ </div>
169+
170+ <fieldset className="grid gap-2">
171+ <legend className="mb-1.5 text-[13px] font-medium">Mode</legend>
172+ <div className={cn("grid gap-2", !compact && "sm:grid-cols-2")}>
173+ {(
174+ [
175+ { v: "live", title: "Live", desc: "fch_live_… — real traffic through the network, billed against your plan." },
176+ { v: "test", title: "Test", desc: "fch_test_… — for integration tests and CI. Same API, clearly separated in logs and usage." },
177+ ] as const
178+ ).map((o) => (
179+ <label key={o.v} className={cn("flex cursor-pointer items-start gap-2.5 rounded-md border px-3 py-2.5 transition-colors", mode === o.v ? "border-accent bg-accent-soft/40" : "border-border hover:border-border-strong")}>
180+ <input type="radio" name="mode" value={o.v} checked={mode === o.v} onChange={() => setMode(o.v)} className="mt-0.5 accent-[var(--accent)]" />
181+ <span className="grid gap-0.5">
182+ <span className="text-[13px] font-medium">{o.title}</span>
183+ <span className="text-[12px] leading-relaxed text-fg-muted">{o.desc}</span>
184+ </span>
185+ </label>
186+ ))}
187+ </div>
188+ </fieldset>
189+
190+ <fieldset className="grid gap-2">
191+ <legend className="mb-1.5 text-[13px] font-medium">Scopes</legend>
192+ <div className="grid gap-1.5">
193+ {API_KEY_SCOPES.map((s) => {
194+ const info = SCOPE_INFO[s];
195+ const disabled = Boolean(info?.comingSoon);
196+ return (
197+ <label key={s} className={cn("flex items-start gap-2.5 rounded-md border border-border px-3 py-2", disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:border-border-strong")}>
198+ <input type="checkbox" checked={!disabled && scopes.includes(s)} disabled={disabled} onChange={() => toggleScope(s)} className="mt-0.5 size-4 rounded border-border accent-[var(--accent)]" />
199+ <span className="grid gap-0.5">
200+ <span className="flex items-center gap-2 font-mono text-[12.5px]">
201+ {info?.label ?? s}
202+ {disabled ? <Badge variant="outline">Coming soon</Badge> : null}
203+ </span>
204+ <span className="text-[12px] text-fg-muted">{info?.description}</span>
205+ </span>
206+ </label>
207+ );
208+ })}
209+ </div>
210+ </fieldset>
211+
212+ <Field>
213+ <Label htmlFor="key-expires">Expiration</Label>
214+ <Select value={expires} onValueChange={setExpires}>
215+ <SelectTrigger id="key-expires" className="sm:max-w-xs">
216+ <SelectValue />
217+ </SelectTrigger>
218+ <SelectContent>
219+ {EXPIRATIONS.map((o) => (
220+ <SelectItem key={o.value} value={o.value}>
221+ {o.label}
222+ </SelectItem>
223+ ))}
224+ </SelectContent>
225+ </Select>
226+ <Hint>Expired keys are rejected with INVALID_API_KEY. Short-lived keys are safer for CI and contractors.</Hint>
227+ </Field>
228+
229+ <div className="flex items-center justify-end gap-2 border-t border-border pt-4">
230+ <Button type="submit" variant="primary" loading={pending} disabled={!name.trim() || scopes.length === 0}>
231+ {submitLabel}
232+ </Button>
233+ </div>
234+ </form>
235+ );
236+}
added apps/web/src/components/dashboard/api-keys/key-reveal.tsx +47 −0
@@ -0,0 +1,47 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { Alert } from "@/components/ui/alert";
5+import { Button } from "@/components/ui/button";
6+import { CopyButton } from "@/components/ui/copy-button";
7+import { CodeBlock } from "@/components/ui/code-block";
8+import { curlSnippet } from "@/lib/account-snippets";
9+
10+/**
11+ * One-time display of a freshly created / rotated key. The plaintext is only ever
12+ * available in memory here — it is never sent back by the server again.
13+ */
14+export function KeyReveal({ plaintext, name, onDone, doneLabel = "Done", compact }: { plaintext: string; name: string; onDone?: () => void; doneLabel?: string; compact?: boolean }) {
15+ const [ack, setAck] = React.useState(false);
16+ return (
17+ <div className="grid gap-4">
18+ <Alert variant="warning" title="Copy your key now — it will not be shown again.">
19+ We store only a hash of this key. If you lose it, rotate the key to get a new one.
20+ </Alert>
21+ <div className="grid gap-1.5">
22+ <div className="text-[12px] font-medium text-fg-subtle">{name}</div>
23+ <div className="flex items-center gap-2 rounded-md border border-border bg-bg-subtle px-3 py-2">
24+ <code className="min-w-0 flex-1 select-all break-all font-mono text-[13px] tabular">{plaintext}</code>
25+ <CopyButton value={plaintext} label="Copy" variant="outline" size="sm" />
26+ </div>
27+ </div>
28+ {!compact ? <CodeBlock code={curlSnippet(plaintext)} lang="bash" title="Try it — first request" maxHeight={180} /> : null}
29+ {onDone ? (
30+ <div className="flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
31+ <label className="flex items-start gap-2 text-[13px] text-fg-muted">
32+ <input type="checkbox" checked={ack} onChange={(e) => setAck(e.target.checked)} className="mt-0.5 size-4 rounded border-border accent-[var(--accent)]" />
33+ I have stored this key somewhere safe.
34+ </label>
35+ <div className="flex items-center gap-2">
36+ <Button asChild variant="ghost" size="sm">
37+ <Link href="/dashboard/playground">Open Playground</Link>
38+ </Button>
39+ <Button size="sm" disabled={!ack} onClick={onDone}>
40+ {doneLabel}
41+ </Button>
42+ </div>
43+ </div>
44+ ) : null}
45+ </div>
46+ );
47+}
added apps/web/src/components/dashboard/api-keys/keys-table.tsx +220 −0
@@ -0,0 +1,220 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { useRouter } from "next/navigation";
5+import { MoreHorizontal, RefreshCw, Ban, KeyRound } from "lucide-react";
6+import { revokeApiKey, rotateApiKey, type CreatedKey } from "@/actions/api-keys";
7+import type { ApiKeyRow } from "@/lib/queries/account";
8+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
9+import { Badge, StatusBadge } from "@/components/ui/badge";
10+import { Button } from "@/components/ui/button";
11+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
12+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
13+import { Alert } from "@/components/ui/alert";
14+import { EmptyState } from "@/components/ui/empty-state";
15+import { SimpleTooltip } from "@/components/ui/tooltip";
16+import { formatDateOnly, timeAgo, formatDate } from "@/lib/format";
17+import { maskedKey } from "@/lib/account-snippets";
18+import { KeyReveal } from "./key-reveal";
19+import { CreateKeyDialog, type ProjectOption } from "./create-key-dialog";
20+
21+type PendingAction = { kind: "rotate" | "revoke"; key: ApiKeyRow } | null;
22+
23+export function KeysTable({ keys, projects, defaultProjectId, showProject }: { keys: ApiKeyRow[]; projects: ProjectOption[]; defaultProjectId: string; showProject: boolean }) {
24+ const router = useRouter();
25+ const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
26+ const [rotated, setRotated] = React.useState<CreatedKey | null>(null);
27+ const [error, setError] = React.useState<string | null>(null);
28+ const [busy, startTransition] = React.useTransition();
29+
30+ function confirm() {
31+ if (!pendingAction) return;
32+ const { kind, key } = pendingAction;
33+ setError(null);
34+ startTransition(async () => {
35+ if (kind === "revoke") {
36+ const res = await revokeApiKey(key.id);
37+ if (!res.ok) {
38+ setError(res.error);
39+ return;
40+ }
41+ setPendingAction(null);
42+ router.refresh();
43+ } else {
44+ const res = await rotateApiKey(key.id);
45+ if (!res.ok) {
46+ setError(res.error);
47+ return;
48+ }
49+ setPendingAction(null);
50+ if (res.data) setRotated(res.data);
51+ }
52+ });
53+ }
54+
55+ if (keys.length === 0) {
56+ return (
57+ <EmptyState
58+ icon={KeyRound}
59+ title="No API keys yet"
60+ description="API keys authenticate requests to Fetcha. Create one, copy it once, and use it as a Bearer token."
61+ action={<CreateKeyDialog projects={projects} defaultProjectId={defaultProjectId} />}
62+ />
63+ );
64+ }
65+
66+ return (
67+ <>
68+ {error && !pendingAction ? <Alert variant="danger" className="mb-3">{error}</Alert> : null}
69+ <div className="overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-xs">
70+ <Table>
71+ <TableHeader>
72+ <TableRow className="hover:bg-transparent">
73+ <TableHead>Name</TableHead>
74+ <TableHead>Key</TableHead>
75+ {showProject ? <TableHead>Project</TableHead> : null}
76+ <TableHead>Mode</TableHead>
77+ <TableHead>Scopes</TableHead>
78+ <TableHead>Created</TableHead>
79+ <TableHead>Last used</TableHead>
80+ <TableHead>Expires</TableHead>
81+ <TableHead>Status</TableHead>
82+ <TableHead className="w-10 text-right">
83+ <span className="sr-only">Actions</span>
84+ </TableHead>
85+ </TableRow>
86+ </TableHeader>
87+ <TableBody>
88+ {keys.map((k) => {
89+ const inactive = k.status !== "active";
90+ return (
91+ <TableRow key={k.id} className={inactive ? "text-fg-muted" : undefined}>
92+ <TableCell className="font-medium text-fg">{k.name}</TableCell>
93+ <TableCell>
94+ <code className="font-mono text-[12.5px] tabular">{maskedKey(k.keyPrefix, k.last4)}</code>
95+ </TableCell>
96+ {showProject ? (
97+ <TableCell>
98+ <Link href={`/dashboard/projects/${k.projectId}`} className="text-fg-muted underline-offset-4 hover:text-fg hover:underline">
99+ {k.projectName}
100+ </Link>
101+ </TableCell>
102+ ) : null}
103+ <TableCell>
104+ <Badge variant={k.mode === "live" ? "success" : "warning"}>{k.mode}</Badge>
105+ </TableCell>
106+ <TableCell>
107+ <div className="flex flex-wrap gap-1">
108+ {k.scopes.map((s) => (
109+ <Badge key={s} variant="outline" className="font-mono text-[11px]">
110+ {s}
111+ </Badge>
112+ ))}
113+ </div>
114+ </TableCell>
115+ <TableCell className="whitespace-nowrap tabular">
116+ <SimpleTooltip content={formatDate(k.createdAt)}>
117+ <span>{formatDateOnly(k.createdAt)}</span>
118+ </SimpleTooltip>
119+ </TableCell>
120+ <TableCell className="whitespace-nowrap tabular">{timeAgo(k.lastUsedAt)}</TableCell>
121+ <TableCell className="whitespace-nowrap tabular">{k.expiresAt ? formatDateOnly(k.expiresAt) : "Never"}</TableCell>
122+ <TableCell>
123+ <StatusBadge status={k.status} />
124+ </TableCell>
125+ <TableCell className="text-right">
126+ <DropdownMenu>
127+ <DropdownMenuTrigger asChild>
128+ <Button variant="ghost" size="icon-sm" aria-label={`Actions for ${k.name}`}>
129+ <MoreHorizontal />
130+ </Button>
131+ </DropdownMenuTrigger>
132+ <DropdownMenuContent align="end">
133+ <DropdownMenuItem disabled={k.status === "revoked"} onSelect={() => setPendingAction({ kind: "rotate", key: k })}>
134+ <RefreshCw /> Rotate key
135+ </DropdownMenuItem>
136+ <DropdownMenuSeparator />
137+ <DropdownMenuItem destructive disabled={k.status === "revoked"} onSelect={() => setPendingAction({ kind: "revoke", key: k })}>
138+ <Ban /> Revoke
139+ </DropdownMenuItem>
140+ </DropdownMenuContent>
141+ </DropdownMenu>
142+ </TableCell>
143+ </TableRow>
144+ );
145+ })}
146+ </TableBody>
147+ </Table>
148+ </div>
149+
150+ {/* Confirm rotate / revoke */}
151+ <Dialog
152+ open={Boolean(pendingAction)}
153+ onOpenChange={(o) => {
154+ if (!o && !busy) {
155+ setPendingAction(null);
156+ setError(null);
157+ }
158+ }}
159+ >
160+ <DialogContent size="sm">
161+ {pendingAction ? (
162+ <>
163+ <DialogHeader>
164+ <DialogTitle>{pendingAction.kind === "rotate" ? "Rotate this key?" : "Revoke this key?"}</DialogTitle>
165+ <DialogDescription>
166+ {pendingAction.kind === "rotate" ? (
167+ <>
168+ A new secret is generated for <strong className="text-fg">{pendingAction.key.name}</strong> with the same project, mode, scopes and expiration. The current key{" "}
169+ <code className="font-mono">{maskedKey(pendingAction.key.keyPrefix, pendingAction.key.last4)}</code> stops working immediately.
170+ </>
171+ ) : (
172+ <>
173+ <strong className="text-fg">{pendingAction.key.name}</strong> (<code className="font-mono">{maskedKey(pendingAction.key.keyPrefix, pendingAction.key.last4)}</code>) will be rejected on the next request. This cannot be undone.
174+ </>
175+ )}
176+ </DialogDescription>
177+ </DialogHeader>
178+ {error ? <Alert variant="danger">{error}</Alert> : null}
179+ <DialogFooter>
180+ <Button variant="outline" onClick={() => setPendingAction(null)} disabled={busy}>
181+ Cancel
182+ </Button>
183+ <Button variant={pendingAction.kind === "revoke" ? "danger" : "primary"} onClick={confirm} loading={busy}>
184+ {pendingAction.kind === "rotate" ? "Rotate key" : "Revoke key"}
185+ </Button>
186+ </DialogFooter>
187+ </>
188+ ) : null}
189+ </DialogContent>
190+ </Dialog>
191+
192+ {/* Show the rotated key once */}
193+ <Dialog
194+ open={Boolean(rotated)}
195+ onOpenChange={() => {
196+ /* closing only through the Done button */
197+ }}
198+ >
199+ <DialogContent size="lg" onEscapeKeyDown={(e) => e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()}>
200+ {rotated ? (
201+ <>
202+ <DialogHeader>
203+ <DialogTitle>Key rotated</DialogTitle>
204+ <DialogDescription>Update your integrations with the new secret. The previous key has been revoked.</DialogDescription>
205+ </DialogHeader>
206+ <KeyReveal
207+ plaintext={rotated.plaintext}
208+ name={rotated.name}
209+ onDone={() => {
210+ setRotated(null);
211+ router.refresh();
212+ }}
213+ />
214+ </>
215+ ) : null}
216+ </DialogContent>
217+ </Dialog>
218+ </>
219+ );
220+}
added apps/web/src/components/dashboard/billing/upgrade-dialog.tsx +39 −0
@@ -0,0 +1,39 @@
1+"use client";
2+import * as React from "react";
3+import { Mail } from "lucide-react";
4+import { Button, type ButtonProps } from "@/components/ui/button";
5+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
6+import { Alert } from "@/components/ui/alert";
7+
8+export function UpgradeDialog({ planLabel, priceLabel, currentPlanLabel, variant = "outline" }: { planLabel: string; priceLabel: string; currentPlanLabel: string; variant?: ButtonProps["variant"] }) {
9+ const isEnterprise = planLabel.toLowerCase() === "enterprise";
10+ const subject = encodeURIComponent(`Upgrade to ${planLabel}`);
11+ const body = encodeURIComponent(`Hi Fetcha team,\n\nI'd like to move from ${currentPlanLabel} to ${planLabel}.\n\nExpected monthly volume:\nMain use case:\n\nThanks`);
12+ return (
13+ <Dialog>
14+ <DialogTrigger asChild>
15+ <Button variant={variant} size="sm" className="w-full">
16+ {isEnterprise ? "Contact sales" : "Request upgrade"}
17+ </Button>
18+ </DialogTrigger>
19+ <DialogContent size="sm">
20+ <DialogHeader>
21+ <DialogTitle>{isEnterprise ? "Talk to us about Enterprise" : `Upgrade to ${planLabel}`}</DialogTitle>
22+ <DialogDescription>
23+ {isEnterprise ? "Custom volumes, SLAs, invoicing and dedicated support." : `${priceLabel}. Billed monthly once checkout is live.`}
24+ </DialogDescription>
25+ </DialogHeader>
26+ <Alert variant="info" title="Self-serve checkout is coming soon">
27+ During the public preview, plan changes are handled by our team. Email us and we will switch your organization to {planLabel} within one business day — no card is charged until billing opens.
28+ </Alert>
29+ <DialogFooter>
30+ <Button asChild variant="primary">
31+ <a href={`mailto:sales@fetcha.co?subject=${subject}&body=${body}`}>
32+ <Mail /> Email sales@fetcha.co
33+ </a>
34+ </Button>
35+ </DialogFooter>
36+ </DialogContent>
37+ </Dialog>
38+ );
39+}
added apps/web/src/components/dashboard/charts/chart-frame.tsx +106 −0
@@ -0,0 +1,106 @@
1+import * as React from "react";
2+import { cn } from "@/lib/utils";
3+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4+
5+export interface ChartTableView {
6+ columns: string[];
7+ rows: Array<Array<string | number>>;
8+}
9+
10+/**
11+ * Card wrapper for a chart: title, description, optional right slot, an "empty" overlay when
12+ * there is no data, and a collapsible table twin so every value is reachable without hover.
13+ * Server-compatible (no client hooks) so pages can pass client charts as children.
14+ */
15+export function ChartFrame({
16+ title,
17+ description,
18+ right,
19+ empty,
20+ emptyMessage = "No data for this period yet.",
21+ table,
22+ children,
23+ className,
24+ contentClassName,
25+}: {
26+ title: React.ReactNode;
27+ description?: React.ReactNode;
28+ right?: React.ReactNode;
29+ empty?: boolean;
30+ emptyMessage?: React.ReactNode;
31+ table?: ChartTableView;
32+ children: React.ReactNode;
33+ className?: string;
34+ contentClassName?: string;
35+}) {
36+ return (
37+ <Card className={cn("flex flex-col", className)}>
38+ <CardHeader className="flex-row items-start justify-between gap-3 pb-2">
39+ <div className="min-w-0">
40+ <CardTitle className="text-[14px]">{title}</CardTitle>
41+ {description ? <CardDescription className="mt-0.5 text-[12.5px]">{description}</CardDescription> : null}
42+ </div>
43+ {right ? <div className="shrink-0">{right}</div> : null}
44+ </CardHeader>
45+ <CardContent className={cn("flex-1 pt-1", contentClassName)}>
46+ <div className="relative">
47+ <div className={cn(empty && "pointer-events-none opacity-30 grayscale")} aria-hidden={empty || undefined}>
48+ {children}
49+ </div>
50+ {empty ? (
51+ <div className="absolute inset-0 flex items-center justify-center">
52+ <p className="rounded-md border border-border bg-bg-elevated px-3 py-1.5 text-[12.5px] text-fg-muted shadow-xs">{emptyMessage}</p>
53+ </div>
54+ ) : null}
55+ </div>
56+ {table && !empty && table.rows.length > 0 ? (
57+ <details className="group mt-3">
58+ <summary className="cursor-pointer select-none text-[12px] text-fg-subtle underline-offset-4 hover:text-fg hover:underline">
59+ <span className="group-open:hidden">View as table</span>
60+ <span className="hidden group-open:inline">Hide table</span>
61+ </summary>
62+ <div className="mt-2 max-h-56 overflow-auto rounded-md border border-border scrollbar-thin">
63+ <table className="w-full text-[12px]">
64+ <thead className="sticky top-0 bg-bg-subtle">
65+ <tr>
66+ {table.columns.map((c, i) => (
67+ <th key={c} className={cn("px-2.5 py-1.5 text-left font-medium text-fg-subtle", i > 0 && "text-right")}>
68+ {c}
69+ </th>
70+ ))}
71+ </tr>
72+ </thead>
73+ <tbody>
74+ {table.rows.map((r, i) => (
75+ <tr key={i} className="border-t border-border">
76+ {r.map((v, j) => (
77+ <td key={j} className={cn("px-2.5 py-1 tabular", j > 0 ? "text-right font-mono" : "text-fg-muted")}>
78+ {v}
79+ </td>
80+ ))}
81+ </tr>
82+ ))}
83+ </tbody>
84+ </table>
85+ </div>
86+ </details>
87+ ) : null}
88+ </CardContent>
89+ </Card>
90+ );
91+}
92+
93+/** Text legend rendered in HTML (outside the SVG) so it is readable and selectable. */
94+export function ChartLegend({ items, className }: { items: Array<{ label: string; color: string; value?: React.ReactNode; dashed?: boolean }>; className?: string }) {
95+ return (
96+ <ul className={cn("flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-fg-muted", className)}>
97+ {items.map((it) => (
98+ <li key={it.label} className="flex items-center gap-1.5">
99+ <span aria-hidden className={cn("inline-block h-2 w-3 rounded-[2px]", it.dashed && "border-t-2 border-dashed bg-transparent h-0")} style={it.dashed ? { borderColor: it.color } : { backgroundColor: it.color }} />
100+ <span>{it.label}</span>
101+ {it.value !== undefined ? <span className="font-mono tabular text-fg">{it.value}</span> : null}
102+ </li>
103+ ))}
104+ </ul>
105+ );
106+}
added apps/web/src/components/dashboard/charts/chart-theme.ts +41 −0
@@ -0,0 +1,41 @@
1+/**
2+ * Shared chart styling. All colors are CSS variables so charts follow light/dark themes.
3+ * Success/failed use status tokens (they *mean* good/bad); every chart pairs color with
4+ * a text legend and a table twin so identity is never carried by color alone.
5+ */
6+export const CHART_COLORS = {
7+ accent: "var(--accent)",
8+ success: "var(--success)",
9+ danger: "var(--danger)",
10+ warning: "var(--warning)",
11+ info: "var(--info)",
12+ muted: "var(--fg-subtle)",
13+ faint: "var(--border-strong)",
14+ grid: "var(--border)",
15+ surface: "var(--bg-elevated)",
16+ cursor: "var(--bg-muted)",
17+} as const;
18+
19+/** Network classes get a fixed color per entity: residential (live) is emphasized, the rest are neutral. */
20+export const NETWORK_COLORS: Record<string, string> = {
21+ residential: CHART_COLORS.accent,
22+ datacenter: CHART_COLORS.muted,
23+ isp: CHART_COLORS.faint,
24+ mobile: CHART_COLORS.info,
25+};
26+
27+export const AXIS_TICK = { fill: "var(--fg-subtle)", fontSize: 11, fontFamily: "var(--font-mono)" } as const;
28+
29+export const CHART_MARGIN = { top: 8, right: 8, bottom: 0, left: 0 } as const;
30+
31+export function formatTick(iso: string, bucket: "hour" | "day"): string {
32+ const d = new Date(iso);
33+ if (bucket === "hour") return new Intl.DateTimeFormat("en-US", { hour: "numeric", timeZone: "UTC" }).format(d);
34+ return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(d);
35+}
36+
37+export function formatBucketLabel(iso: string, bucket: "hour" | "day"): string {
38+ const d = new Date(iso);
39+ if (bucket === "hour") return `${new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", hour: "numeric", timeZone: "UTC" }).format(d)} UTC`;
40+ return new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }).format(d);
41+}
added apps/web/src/components/dashboard/charts/chart-tooltip.tsx +53 −0
@@ -0,0 +1,53 @@
1+"use client";
2+import * as React from "react";
3+
4+export interface TooltipEntry {
5+ name?: string | number;
6+ value?: number | string | ReadonlyArray<number | string>;
7+ color?: string;
8+ fill?: string;
9+ dataKey?: string | number;
10+ payload?: Record<string, unknown>;
11+}
12+
13+export interface ChartTooltipProps {
14+ active?: boolean;
15+ payload?: ReadonlyArray<TooltipEntry>;
16+ label?: unknown;
17+ /** Format the heading (bucket label). */
18+ labelFormatter?: (label: unknown, payload: ReadonlyArray<TooltipEntry>) => React.ReactNode;
19+ /** Format a series value. */
20+ valueFormatter?: (value: number, entry: TooltipEntry) => React.ReactNode;
21+ /** Optional display names per dataKey. */
22+ names?: Record<string, string>;
23+ /** Extra footer line (e.g. total). */
24+ footer?: (payload: ReadonlyArray<TooltipEntry>) => React.ReactNode;
25+}
26+
27+/** Theme-matched tooltip body; recharts clones it with `active`, `payload` and `label`. */
28+export function ChartTooltip({ active, payload, label, labelFormatter, valueFormatter, names, footer }: ChartTooltipProps) {
29+ if (!active || !payload || payload.length === 0) return null;
30+ const rows = payload.filter((p) => p.value !== undefined && p.value !== null);
31+ if (!rows.length) return null;
32+ return (
33+ <div className="pointer-events-none min-w-[150px] rounded-md border border-border bg-bg-elevated px-3 py-2 text-[12px] shadow-md">
34+ <div className="mb-1.5 font-medium text-fg">{labelFormatter ? labelFormatter(label, payload) : String(label ?? "")}</div>
35+ <ul className="space-y-1">
36+ {rows.map((p, i) => {
37+ const key = String(p.dataKey ?? p.name ?? i);
38+ const raw = Array.isArray(p.value) ? Number(p.value[0]) : Number(p.value);
39+ return (
40+ <li key={key} className="flex items-center justify-between gap-4">
41+ <span className="flex items-center gap-1.5 text-fg-muted">
42+ <span aria-hidden className="inline-block size-2 rounded-[2px]" style={{ backgroundColor: p.color ?? p.fill ?? "var(--fg-subtle)" }} />
43+ {names?.[key] ?? String(p.name ?? key)}
44+ </span>
45+ <span className="font-mono tabular text-fg">{valueFormatter ? valueFormatter(raw, p) : Number.isFinite(raw) ? raw.toLocaleString("en-US") : "—"}</span>
46+ </li>
47+ );
48+ })}
49+ </ul>
50+ {footer ? <div className="mt-1.5 border-t border-border pt-1.5 text-fg-muted">{footer(payload)}</div> : null}
51+ </div>
52+ );
53+}
added apps/web/src/components/dashboard/charts/horizontal-bar-chart.tsx +76 −0
@@ -0,0 +1,76 @@
1+"use client";
2+import * as React from "react";
3+import { Bar, BarChart, CartesianGrid, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
4+import { AXIS_TICK, CHART_COLORS } from "./chart-theme";
5+import { ChartTooltip } from "./chart-tooltip";
6+
7+export type HorizontalBarFormat = "number" | "percent" | "ms" | "usd";
8+
9+const FORMATTERS: Record<HorizontalBarFormat, (v: number) => string> = {
10+ number: (v) => v.toLocaleString("en-US"),
11+ percent: (v) => `${v.toFixed(1)}%`,
12+ ms: (v) => (v < 1000 ? `${Math.round(v)} ms` : `${(v / 1000).toFixed(2)} s`),
13+ usd: (v) => (v < 0.01 && v > 0 ? `$${v.toFixed(4)}` : `$${v.toFixed(2)}`),
14+};
15+
16+export interface HorizontalBarDatum {
17+ label: string;
18+ value: number;
19+ /** Optional per-entity color (defaults to accent). */
20+ color?: string;
21+ /** Optional secondary text shown in the tooltip (e.g. request count). */
22+ hint?: string;
23+}
24+
25+/**
26+ * Single-series horizontal bars (one color for all bars unless the entity has a fixed color).
27+ * Used for success rate by network and error-code breakdowns.
28+ */
29+export function HorizontalBarChart({
30+ data,
31+ color = CHART_COLORS.accent,
32+ max,
33+ format = "number",
34+ rowHeight = 34,
35+ labelWidth = 120,
36+}: {
37+ data: HorizontalBarDatum[];
38+ color?: string;
39+ max?: number;
40+ /** Serializable formatter key (server components cannot pass functions to client components). */
41+ format?: HorizontalBarFormat;
42+ rowHeight?: number;
43+ labelWidth?: number;
44+}) {
45+ const valueFormatter = FORMATTERS[format];
46+ const height = Math.max(rowHeight * Math.max(data.length, 1) + 16, 80);
47+ return (
48+ <ResponsiveContainer width="100%" height={height}>
49+ <BarChart data={data} layout="vertical" margin={{ top: 4, right: 56, bottom: 4, left: 0 }} barCategoryGap="28%">
50+ <CartesianGrid horizontal={false} stroke={CHART_COLORS.grid} />
51+ <XAxis type="number" hide domain={[0, max ?? "auto"]} />
52+ <YAxis type="category" dataKey="label" width={labelWidth} tick={{ ...AXIS_TICK, fill: "var(--fg-muted)", fontSize: 12 }} axisLine={false} tickLine={false} interval={0} />
53+ <Tooltip
54+ cursor={{ fill: CHART_COLORS.cursor, opacity: 0.6 }}
55+ content={
56+ <ChartTooltip
57+ labelFormatter={(l) => String(l)}
58+ valueFormatter={(v) => valueFormatter(v)}
59+ names={{ value: "Value" }}
60+ footer={(p) => {
61+ const hint = (p[0]?.payload as HorizontalBarDatum | undefined)?.hint;
62+ return hint ? <span>{hint}</span> : null;
63+ }}
64+ />
65+ }
66+ />
67+ <Bar dataKey="value" radius={[0, 3, 3, 0]} maxBarSize={18} isAnimationActive={false} background={{ fill: "var(--bg-muted)", radius: 3 }}>
68+ {data.map((d) => (
69+ <Cell key={d.label} fill={d.color ?? color} />
70+ ))}
71+ <LabelList dataKey="value" position="right" formatter={(v: unknown) => valueFormatter(Number(v))} style={{ fill: "var(--fg-muted)", fontSize: 11.5, fontFamily: "var(--font-mono)" }} />
72+ </Bar>
73+ </BarChart>
74+ </ResponsiveContainer>
75+ );
76+}
added apps/web/src/components/dashboard/charts/latency-line-chart.tsx +40 −0
@@ -0,0 +1,40 @@
1+"use client";
2+import * as React from "react";
3+import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
4+import { formatMs } from "@/lib/format";
5+import { AXIS_TICK, CHART_COLORS, CHART_MARGIN, formatBucketLabel, formatTick } from "./chart-theme";
6+import { ChartTooltip } from "./chart-tooltip";
7+import { ChartLegend } from "./chart-frame";
8+
9+export interface LatencyPoint {
10+ t: string;
11+ p50: number | null;
12+ p95: number | null;
13+}
14+
15+/** P50 (accent) and P95 (neutral) latency over time; one axis, gaps where no requests happened. */
16+export function LatencyLineChart({ data, bucket, height = 240 }: { data: LatencyPoint[]; bucket: "hour" | "day"; height?: number }) {
17+ const last = [...data].reverse().find((d) => d.p50 !== null);
18+ const tickEvery = Math.max(1, Math.ceil(data.length / (bucket === "hour" ? 8 : 10)));
19+ return (
20+ <div>
21+ <ChartLegend
22+ className="mb-3"
23+ items={[
24+ { label: "P50", color: CHART_COLORS.accent, value: last ? formatMs(last.p50) : undefined },
25+ { label: "P95", color: CHART_COLORS.muted, value: last ? formatMs(last.p95) : undefined },
26+ ]}
27+ />
28+ <ResponsiveContainer width="100%" height={height}>
29+ <LineChart data={data} margin={CHART_MARGIN}>
30+ <CartesianGrid vertical={false} stroke={CHART_COLORS.grid} />
31+ <XAxis dataKey="t" tickFormatter={(v: string) => formatTick(v, bucket)} tick={AXIS_TICK} axisLine={false} tickLine={false} interval={tickEvery - 1} minTickGap={16} />
32+ <YAxis tick={AXIS_TICK} axisLine={false} tickLine={false} width={48} tickFormatter={(v: number) => formatMs(v)} />
33+ <Tooltip cursor={{ stroke: CHART_COLORS.faint, strokeWidth: 1 }} content={<ChartTooltip labelFormatter={(l) => formatBucketLabel(String(l), bucket)} names={{ p50: "P50", p95: "P95" }} valueFormatter={(v) => formatMs(v)} />} />
34+ <Line type="monotone" dataKey="p95" stroke={CHART_COLORS.muted} strokeWidth={2} dot={false} activeDot={{ r: 4, strokeWidth: 2, stroke: CHART_COLORS.surface }} connectNulls={false} isAnimationActive={false} />
35+ <Line type="monotone" dataKey="p50" stroke={CHART_COLORS.accent} strokeWidth={2} dot={false} activeDot={{ r: 4, strokeWidth: 2, stroke: CHART_COLORS.surface }} connectNulls={false} isAnimationActive={false} />
36+ </LineChart>
37+ </ResponsiveContainer>
38+ </div>
39+ );
40+}
added apps/web/src/components/dashboard/charts/network-donut.tsx +69 −0
@@ -0,0 +1,69 @@
1+"use client";
2+import * as React from "react";
3+import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
4+import { formatNumber, formatPercent, titleCase } from "@/lib/format";
5+import { CHART_COLORS, NETWORK_COLORS } from "./chart-theme";
6+import { ChartTooltip } from "./chart-tooltip";
7+
8+export interface NetworkSlice {
9+ network: string;
10+ requests: number;
11+ percent: number;
12+}
13+
14+const LIVE_NETWORKS = new Set(["residential"]);
15+
16+/**
17+ * Part-to-whole of resolved network classes. All four classes are listed (0 % rows included);
18+ * networks that are not live yet say so instead of pretending to have traffic.
19+ */
20+export function NetworkDonut({ data, size = 156 }: { data: NetworkSlice[]; size?: number }) {
21+ const nonZero = data.filter((d) => d.requests > 0);
22+ const total = data.reduce((a, d) => a + d.requests, 0);
23+ return (
24+ <div className="flex flex-col items-center gap-5 sm:flex-row sm:items-center">
25+ <div className="relative shrink-0" style={{ width: size, height: size }}>
26+ <ResponsiveContainer width="100%" height="100%">
27+ <PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
28+ <Tooltip content={<ChartTooltip labelFormatter={(_l, p) => titleCase(String(p[0]?.name ?? ""))} valueFormatter={(v) => `${formatNumber(v)} req`} />} />
29+ <Pie
30+ data={nonZero.length ? nonZero : [{ network: "none", requests: 1, percent: 0 }]}
31+ dataKey="requests"
32+ nameKey="network"
33+ innerRadius={size / 2 - 22}
34+ outerRadius={size / 2 - 6}
35+ paddingAngle={nonZero.length > 1 ? 2 : 0}
36+ stroke={CHART_COLORS.surface}
37+ strokeWidth={2}
38+ isAnimationActive={false}
39+ cornerRadius={3}
40+ >
41+ {(nonZero.length ? nonZero : [{ network: "none" }]).map((d) => (
42+ <Cell key={d.network} fill={d.network === "none" ? "var(--bg-muted)" : NETWORK_COLORS[d.network] ?? CHART_COLORS.muted} />
43+ ))}
44+ </Pie>
45+ </PieChart>
46+ </ResponsiveContainer>
47+ <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
48+ <span className="text-[18px] font-semibold leading-none tracking-tight tabular">{formatNumber(total)}</span>
49+ <span className="mt-1 text-[11px] text-fg-subtle">resolved</span>
50+ </div>
51+ </div>
52+ <ul className="w-full min-w-0 flex-1 divide-y divide-border text-[13px]">
53+ {data.map((d) => (
54+ <li key={d.network} className="flex items-center justify-between gap-3 py-2">
55+ <span className="flex items-center gap-2 text-fg-muted">
56+ <span aria-hidden className="inline-block size-2 rounded-[2px]" style={{ backgroundColor: NETWORK_COLORS[d.network] ?? CHART_COLORS.muted }} />
57+ <span className="text-fg">{d.network === "isp" ? "ISP" : titleCase(d.network)}</span>
58+ {!LIVE_NETWORKS.has(d.network) ? <span className="text-[11px] text-fg-subtle">coming soon</span> : null}
59+ </span>
60+ <span className="flex items-center gap-3 font-mono tabular">
61+ <span className="text-fg-subtle">{formatNumber(d.requests)}</span>
62+ <span className="w-12 text-right">{formatPercent(d.percent, d.percent > 0 && d.percent < 1 ? 1 : 0)}</span>
63+ </span>
64+ </li>
65+ ))}
66+ </ul>
67+ </div>
68+ );
69+}
added apps/web/src/components/dashboard/charts/quota-bar.tsx +47 −0
@@ -0,0 +1,47 @@
1+import * as React from "react";
2+import { cn } from "@/lib/utils";
3+
4+/**
5+ * Linear meter for quota-style values (used vs limit). Color only turns to warning/danger when
6+ * the threshold is actually crossed; the label always carries the numbers.
7+ */
8+export function QuotaBar({
9+ label,
10+ used,
11+ limit,
12+ usedLabel,
13+ limitLabel,
14+ unlimited,
15+ hint,
16+ className,
17+}: {
18+ label: React.ReactNode;
19+ used: number;
20+ limit: number | null;
21+ usedLabel: string;
22+ limitLabel?: string;
23+ unlimited?: boolean;
24+ hint?: React.ReactNode;
25+ className?: string;
26+}) {
27+ const pct = unlimited || !limit || limit <= 0 ? 0 : Math.min(100, (used / limit) * 100);
28+ const tone = pct >= 100 ? "bg-danger" : pct >= 80 ? "bg-warning" : "bg-accent";
29+ return (
30+ <div className={cn("flex flex-col gap-1.5", className)}>
31+ <div className="flex items-baseline justify-between gap-3 text-[13px]">
32+ <span className="font-medium">{label}</span>
33+ <span className="font-mono tabular text-fg-muted">
34+ <span className="text-fg">{usedLabel}</span>
35+ {unlimited ? <span className="text-fg-subtle"> · unlimited</span> : limitLabel ? <span> / {limitLabel}</span> : null}
36+ </span>
37+ </div>
38+ <div className="h-1.5 w-full overflow-hidden rounded-full bg-bg-muted" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)} aria-label={typeof label === "string" ? label : undefined}>
39+ <div className={cn("h-full rounded-full transition-[width]", tone)} style={{ width: `${unlimited ? 0 : pct}%` }} />
40+ </div>
41+ <div className="flex items-center justify-between text-[11.5px] text-fg-subtle">
42+ <span>{hint}</span>
43+ {!unlimited && limit ? <span className="font-mono tabular">{pct.toFixed(pct > 0 && pct < 1 ? 1 : 0)}%</span> : null}
44+ </div>
45+ </div>
46+ );
47+}
added apps/web/src/components/dashboard/charts/requests-bar-chart.tsx +58 −0
@@ -0,0 +1,58 @@
1+"use client";
2+import * as React from "react";
3+import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
4+import { formatCompact, formatNumber } from "@/lib/format";
5+import { AXIS_TICK, CHART_COLORS, CHART_MARGIN, formatBucketLabel, formatTick } from "./chart-theme";
6+import { ChartTooltip } from "./chart-tooltip";
7+import { ChartLegend } from "./chart-frame";
8+
9+export interface RequestsBarPoint {
10+ t: string;
11+ success: number;
12+ failed: number;
13+}
14+
15+/** Stacked success/failed bars per bucket. Legend + 1px surface gap between segments keep the two readable without color. */
16+export function RequestsBarChart({ data, bucket, height = 240 }: { data: RequestsBarPoint[]; bucket: "hour" | "day"; height?: number }) {
17+ const totalSuccess = data.reduce((a, d) => a + d.success, 0);
18+ const totalFailed = data.reduce((a, d) => a + d.failed, 0);
19+ const tickEvery = Math.max(1, Math.ceil(data.length / (bucket === "hour" ? 8 : 10)));
20+ return (
21+ <div>
22+ <ChartLegend
23+ className="mb-3"
24+ items={[
25+ { label: "Successful", color: CHART_COLORS.success, value: formatNumber(totalSuccess) },
26+ { label: "Failed", color: CHART_COLORS.danger, value: formatNumber(totalFailed) },
27+ ]}
28+ />
29+ <ResponsiveContainer width="100%" height={height}>
30+ <BarChart data={data} margin={CHART_MARGIN} barCategoryGap="30%">
31+ <CartesianGrid vertical={false} stroke={CHART_COLORS.grid} />
32+ <XAxis dataKey="t" tickFormatter={(v: string) => formatTick(v, bucket)} tick={AXIS_TICK} axisLine={false} tickLine={false} interval={tickEvery - 1} minTickGap={16} />
33+ <YAxis tick={AXIS_TICK} axisLine={false} tickLine={false} width={40} allowDecimals={false} tickFormatter={(v: number) => formatCompact(v)} />
34+ <Tooltip
35+ cursor={{ fill: CHART_COLORS.cursor, opacity: 0.6 }}
36+ content={
37+ <ChartTooltip
38+ labelFormatter={(l) => formatBucketLabel(String(l), bucket)}
39+ names={{ success: "Successful", failed: "Failed" }}
40+ footer={(p) => {
41+ const total = p.reduce((a, e) => a + Number(e.value ?? 0), 0);
42+ return (
43+ <span className="flex justify-between">
44+ <span>Total</span>
45+ <span className="font-mono tabular text-fg">{formatNumber(total)}</span>
46+ </span>
47+ );
48+ }}
49+ />
50+ }
51+ />
52+ <Bar dataKey="success" stackId="r" fill={CHART_COLORS.success} stroke={CHART_COLORS.surface} strokeWidth={1} maxBarSize={28} isAnimationActive={false} />
53+ <Bar dataKey="failed" stackId="r" fill={CHART_COLORS.danger} stroke={CHART_COLORS.surface} strokeWidth={1} radius={[3, 3, 0, 0]} maxBarSize={28} isAnimationActive={false} />
54+ </BarChart>
55+ </ResponsiveContainer>
56+ </div>
57+ );
58+}
added apps/web/src/components/dashboard/onboarding/banner.tsx +29 −0
@@ -0,0 +1,29 @@
1+import Link from "next/link";
2+import { ArrowRight, Sparkles } from "lucide-react";
3+import { Button } from "@/components/ui/button";
4+import { cn } from "@/lib/utils";
5+
6+/**
7+ * Small server component the overview can render when `user.onboardingCompletedAt` is null.
8+ * Pure presentation — pass `completedAt` and it returns null when onboarding is done.
9+ */
10+export function OnboardingBanner({ completedAt, userName, className }: { completedAt?: Date | null; userName?: string | null; className?: string }) {
11+ if (completedAt) return null;
12+ const first = userName?.trim().split(/\s+/)[0];
13+ return (
14+ <div className={cn("flex flex-col gap-3 rounded-lg border border-accent/30 bg-accent-soft/40 px-4 py-3 sm:flex-row sm:items-center", className)}>
15+ <div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-bg shadow-xs">
16+ <Sparkles className="size-4 text-accent" aria-hidden />
17+ </div>
18+ <div className="min-w-0 flex-1 text-[13.5px]">
19+ <div className="font-medium">{first ? `Welcome, ${first}.` : "Welcome to Fetcha."} Finish setting up in about two minutes.</div>
20+ <div className="text-fg-muted">Create an API key, run your first request and grab the generated code for your stack.</div>
21+ </div>
22+ <Button asChild size="sm" variant="primary" className="shrink-0">
23+ <Link href="/dashboard/onboarding">
24+ Continue setup <ArrowRight />
25+ </Link>
26+ </Button>
27+ </div>
28+ );
29+}
added apps/web/src/components/dashboard/onboarding/flow.tsx +258 −0
@@ -0,0 +1,258 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { useRouter } from "next/navigation";
5+import { ArrowLeft, ArrowRight, Check, Code2, FolderKanban, KeyRound, Play, Route, ShieldCheck, Zap } from "lucide-react";
6+import type { CreatedKey } from "@/actions/api-keys";
7+import { completeOnboarding } from "@/actions/account";
8+import { Button } from "@/components/ui/button";
9+import { Badge } from "@/components/ui/badge";
10+import { Alert } from "@/components/ui/alert";
11+import { CodeBlock } from "@/components/ui/code-block";
12+import { cn } from "@/lib/utils";
13+import { curlSnippet } from "@/lib/account-snippets";
14+import { CreateKeyForm, type ProjectOption } from "@/components/dashboard/api-keys/create-key-dialog";
15+import { KeyReveal } from "@/components/dashboard/api-keys/key-reveal";
16+import { CreateProjectDialog } from "@/components/dashboard/projects/create-project-dialog";
17+
18+const STEPS = [
19+ { id: "welcome", label: "Welcome", icon: Zap },
20+ { id: "project", label: "Project", icon: FolderKanban },
21+ { id: "key", label: "API key", icon: KeyRound },
22+ { id: "request", label: "First request", icon: Play },
23+ { id: "code", label: "Generated code", icon: Code2 },
24+] as const;
25+
26+export function OnboardingFlow({
27+ projects,
28+ currentProject,
29+ hasKey,
30+ hasRequest,
31+ alreadyCompleted,
32+ userName,
33+}: {
34+ projects: ProjectOption[];
35+ currentProject: ProjectOption;
36+ hasKey: boolean;
37+ hasRequest: boolean;
38+ alreadyCompleted: boolean;
39+ userName: string;
40+}) {
41+ const router = useRouter();
42+ const [step, setStep] = React.useState(0);
43+ const [created, setCreated] = React.useState<CreatedKey | null>(null);
44+ const [finishing, startFinish] = React.useTransition();
45+ const [error, setError] = React.useState<string | null>(null);
46+
47+ const keyReady = hasKey || Boolean(created);
48+ const apiKeyForSnippet = created?.plaintext ?? "fch_live_YOUR_API_KEY";
49+ const last = step === STEPS.length - 1;
50+
51+ function finish() {
52+ setError(null);
53+ startFinish(async () => {
54+ const res = await completeOnboarding();
55+ if (!res.ok) {
56+ setError(res.error);
57+ return;
58+ }
59+ router.push("/dashboard");
60+ router.refresh();
61+ });
62+ }
63+
64+ return (
65+ <div className="grid gap-6">
66+ {/* Progress */}
67+ <ol className="grid grid-cols-5 gap-2" aria-label="Onboarding progress">
68+ {STEPS.map((s, i) => {
69+ const done = i < step || (i === 1) || (i === 2 && keyReady) || (i === 3 && hasRequest);
70+ const active = i === step;
71+ const Icon = s.icon;
72+ return (
73+ <li key={s.id} className="grid gap-2">
74+ <button
75+ type="button"
76+ onClick={() => setStep(i)}
77+ className={cn("flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12.5px] font-medium transition-colors hover:bg-bg-muted", active ? "text-fg" : "text-fg-muted")}
78+ aria-current={active ? "step" : undefined}
79+ >
80+ <span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full border text-[11px]", active ? "border-accent bg-accent text-accent-fg" : done && !active ? "border-success/40 bg-success-soft text-success" : "border-border bg-bg")}>
81+ {done && !active ? <Check className="size-3.5" /> : <Icon className="size-3.5" />}
82+ </span>
83+ <span className="hidden truncate sm:inline">{s.label}</span>
84+ </button>
85+ <span className={cn("h-1 rounded-full", i <= step ? "bg-accent" : "bg-bg-muted")} aria-hidden />
86+ </li>
87+ );
88+ })}
89+ </ol>
90+
91+ <div className="rounded-lg border border-border bg-bg-elevated p-6 shadow-xs sm:p-8">
92+ {alreadyCompleted && step === 0 ? <Alert variant="info" className="mb-6">You already finished onboarding — feel free to walk through it again.</Alert> : null}
93+
94+ {step === 0 ? (
95+ <div className="grid gap-5">
96+ <div>
97+ <div className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">Step 1 of 5</div>
98+ <h2 className="mt-1 text-xl font-semibold tracking-tight">Welcome to Fetcha{userName ? `, ${userName.split(" ")[0]}` : ""}</h2>
99+ <p className="mt-2 max-w-xl text-[13.5px] leading-relaxed text-fg-muted">One API that fetches any public web page for you, choosing the right network and location automatically, retrying when a target blocks, and returning clean HTML, text or JSON.</p>
100+ </div>
101+ <ul className="grid gap-3 sm:grid-cols-3">
102+ {[
103+ { icon: Route, title: "Intelligent routing", desc: "Every request is scored on success history, cost, latency, network health and geography, then escalated only when needed." },
104+ { icon: ShieldCheck, title: "Built-in resilience", desc: "Retries with a fresh IP or alternate network, circuit breakers per domain and sticky sessions when you need the same identity." },
105+ { icon: Code2, title: "Developer first", desc: "A single POST /v1/fetch, request IDs on every call, a Playground that writes your code, and SDKs for JS and Python." },
106+ ].map((b) => (
107+ <li key={b.title} className="rounded-md border border-border bg-bg p-4">
108+ <b.icon className="size-4 text-accent" aria-hidden />
109+ <div className="mt-2 text-[13.5px] font-medium">{b.title}</div>
110+ <div className="mt-1 text-[12.5px] leading-relaxed text-fg-muted">{b.desc}</div>
111+ </li>
112+ ))}
113+ </ul>
114+ </div>
115+ ) : null}
116+
117+ {step === 1 ? (
118+ <div className="grid gap-5">
119+ <div>
120+ <div className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">Step 2 of 5</div>
121+ <h2 className="mt-1 text-xl font-semibold tracking-tight">Create your first project</h2>
122+ <p className="mt-2 max-w-xl text-[13.5px] leading-relaxed text-fg-muted">Projects separate keys, logs, defaults and spending limits. We created one for you — keep it or add another for a different environment.</p>
123+ </div>
124+ <div className="flex flex-col gap-3 rounded-md border border-border bg-bg p-4 sm:flex-row sm:items-center">
125+ <div className="min-w-0 flex-1">
126+ <div className="flex items-center gap-2">
127+ <span className="text-[14px] font-medium">{currentProject.name}</span>
128+ <Badge variant={currentProject.environment === "production" ? "success" : "warning"}>{currentProject.environment}</Badge>
129+ <Badge variant="accent" dot>
130+ Current
131+ </Badge>
132+ </div>
133+ <div className="mt-0.5 font-mono text-[11.5px] text-fg-subtle">{currentProject.id}</div>
134+ </div>
135+ <div className="flex items-center gap-2">
136+ <Button asChild variant="ghost" size="sm">
137+ <Link href={`/dashboard/projects/${currentProject.id}`}>Edit defaults</Link>
138+ </Button>
139+ <CreateProjectDialog
140+ redirectToDetail={false}
141+ trigger={
142+ <Button variant="outline" size="sm">
143+ New project
144+ </Button>
145+ }
146+ />
147+ </div>
148+ </div>
149+ {projects.length > 1 ? <p className="text-[12.5px] text-fg-subtle">You have {projects.length} projects. The current one is used for the key in the next step.</p> : null}
150+ </div>
151+ ) : null}
152+
153+ {step === 2 ? (
154+ <div className="grid gap-5">
155+ <div>
156+ <div className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">Step 3 of 5</div>
157+ <h2 className="mt-1 text-xl font-semibold tracking-tight">Generate an API key</h2>
158+ <p className="mt-2 max-w-xl text-[13.5px] leading-relaxed text-fg-muted">Keys are shown once and stored hashed. This one gets the default scopes for fetching, sessions and usage.</p>
159+ </div>
160+ {created ? (
161+ <KeyReveal plaintext={created.plaintext} name={created.name} compact />
162+ ) : hasKey ? (
163+ <Alert
164+ variant="success"
165+ title="You already have an active key"
166+ action={
167+ <Button asChild variant="outline" size="xs">
168+ <Link href="/dashboard/api-keys">Manage keys</Link>
169+ </Button>
170+ }
171+ >
172+ Continue with it, or create another one below for this walkthrough.
173+ </Alert>
174+ ) : null}
175+ {!created ? (
176+ <div className="rounded-md border border-border bg-bg p-4">
177+ <CreateKeyForm projects={projects} defaultProjectId={currentProject.id} onCreated={setCreated} compact defaultName="Getting started" submitLabel="Generate key" />
178+ </div>
179+ ) : null}
180+ </div>
181+ ) : null}
182+
183+ {step === 3 ? (
184+ <div className="grid gap-5">
185+ <div>
186+ <div className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">Step 4 of 5</div>
187+ <h2 className="mt-1 text-xl font-semibold tracking-tight">Run your first request</h2>
188+ <p className="mt-2 max-w-xl text-[13.5px] leading-relaxed text-fg-muted">
189+ Paste this in a terminal. {created ? "It already contains the key you just created." : "Replace the placeholder with your key."} The response includes <code className="font-mono text-[12.5px]">request_id</code>, the page content and routing metadata.
190+ </p>
191+ </div>
192+ {hasRequest ? <Alert variant="success">Nice — this organization has already made requests. The Requests page shows every one of them.</Alert> : null}
193+ <CodeBlock code={curlSnippet(apiKeyForSnippet)} lang="bash" title="terminal" />
194+ <div className="flex flex-wrap items-center gap-2">
195+ <Button asChild variant="outline" size="sm">
196+ <Link href="/dashboard/playground">
197+ <Play /> Try it in the Playground
198+ </Link>
199+ </Button>
200+ <Button asChild variant="ghost" size="sm">
201+ <Link href="/dashboard/requests">View requests</Link>
202+ </Button>
203+ </div>
204+ </div>
205+ ) : null}
206+
207+ {step === 4 ? (
208+ <div className="grid gap-5">
209+ <div>
210+ <div className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">Step 5 of 5</div>
211+ <h2 className="mt-1 text-xl font-semibold tracking-tight">Get generated code</h2>
212+ <p className="mt-2 max-w-xl text-[13.5px] leading-relaxed text-fg-muted">The Playground writes the request in cURL, JavaScript, Python, Go, PHP, Ruby, Java and C# as you configure it. Copy it straight into your project.</p>
213+ </div>
214+ <div className="grid gap-3 sm:grid-cols-2">
215+ <Link href="/dashboard/playground?tab=code" className="group rounded-md border border-border bg-bg p-4 transition-colors hover:border-border-strong">
216+ <Code2 className="size-4 text-accent" aria-hidden />
217+ <div className="mt-2 flex items-center gap-1 text-[13.5px] font-medium">
218+ Playground · Code tab <ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
219+ </div>
220+ <div className="mt-1 text-[12.5px] text-fg-muted">Snippets stay in sync with the request you build.</div>
221+ </Link>
222+ <Link href="/docs" className="group rounded-md border border-border bg-bg p-4 transition-colors hover:border-border-strong">
223+ <ShieldCheck className="size-4 text-accent" aria-hidden />
224+ <div className="mt-2 flex items-center gap-1 text-[13.5px] font-medium">
225+ Documentation <ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
226+ </div>
227+ <div className="mt-1 text-[12.5px] text-fg-muted">API reference, error codes, sessions, SDK install from source.</div>
228+ </Link>
229+ </div>
230+ {error ? <Alert variant="danger">{error}</Alert> : null}
231+ </div>
232+ ) : null}
233+
234+ <div className="mt-8 flex items-center justify-between border-t border-border pt-5">
235+ <Button variant="ghost" size="sm" onClick={() => setStep((s) => Math.max(0, s - 1))} disabled={step === 0 || finishing}>
236+ <ArrowLeft /> Back
237+ </Button>
238+ <div className="flex items-center gap-2">
239+ {!last ? (
240+ <Button variant="link" size="sm" onClick={finish} disabled={finishing} className="text-fg-muted">
241+ Skip for now
242+ </Button>
243+ ) : null}
244+ {last ? (
245+ <Button variant="primary" onClick={finish} loading={finishing}>
246+ Finish <Check />
247+ </Button>
248+ ) : (
249+ <Button variant="primary" onClick={() => setStep((s) => Math.min(STEPS.length - 1, s + 1))}>
250+ {step === 2 && !keyReady ? "Skip this step" : "Continue"} <ArrowRight />
251+ </Button>
252+ )}
253+ </div>
254+ </div>
255+ </div>
256+ </div>
257+ );
258+}
added apps/web/src/components/dashboard/projects/create-project-dialog.tsx +45 −0
@@ -0,0 +1,45 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { Plus } from "lucide-react";
5+import { Button } from "@/components/ui/button";
6+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
7+import { ProjectForm } from "./project-form";
8+
9+export function CreateProjectDialog({ defaultOpen, trigger, redirectToDetail = true }: { defaultOpen?: boolean; trigger?: React.ReactNode; redirectToDetail?: boolean }) {
10+ const router = useRouter();
11+ const [open, setOpen] = React.useState(Boolean(defaultOpen));
12+ return (
13+ <Dialog
14+ open={open}
15+ onOpenChange={(o) => {
16+ setOpen(o);
17+ // Drop ?new=1 from the URL when the dialog closes so a refresh does not reopen it.
18+ if (!o && defaultOpen) router.replace("/dashboard/projects");
19+ }}
20+ >
21+ <DialogTrigger asChild>
22+ {trigger ?? (
23+ <Button variant="primary">
24+ <Plus /> New project
25+ </Button>
26+ )}
27+ </DialogTrigger>
28+ <DialogContent size="lg" className="max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-thin">
29+ <DialogHeader>
30+ <DialogTitle>Create project</DialogTitle>
31+ <DialogDescription>Projects isolate keys, usage, logs and limits. The new project becomes your current one.</DialogDescription>
32+ </DialogHeader>
33+ <ProjectForm
34+ mode="create"
35+ onCancel={() => setOpen(false)}
36+ onSuccess={(id) => {
37+ setOpen(false);
38+ if (redirectToDetail) router.push(`/dashboard/projects/${id}`);
39+ router.refresh();
40+ }}
41+ />
42+ </DialogContent>
43+ </Dialog>
44+ );
45+}
added apps/web/src/components/dashboard/projects/project-actions.tsx +104 −0
@@ -0,0 +1,104 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { Archive, Check } from "lucide-react";
5+import { archiveProject, selectProject } from "@/actions/projects";
6+import { Button, type ButtonProps } from "@/components/ui/button";
7+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
8+import { Alert } from "@/components/ui/alert";
9+import { Input } from "@/components/ui/input";
10+import { Field, Label } from "@/components/ui/label";
11+
12+/** Makes the project the current one (cookie) and refreshes the dashboard. */
13+export function SelectProjectButton({ projectId, size = "xs", variant = "outline", children }: { projectId: string } & Pick<ButtonProps, "size" | "variant" | "children">) {
14+ const router = useRouter();
15+ const [pending, startTransition] = React.useTransition();
16+ return (
17+ <Button
18+ size={size}
19+ variant={variant}
20+ loading={pending}
21+ onClick={() =>
22+ startTransition(async () => {
23+ await selectProject(projectId);
24+ router.refresh();
25+ })
26+ }
27+ >
28+ {children ?? (
29+ <>
30+ <Check /> Select
31+ </>
32+ )}
33+ </Button>
34+ );
35+}
36+
37+export function ArchiveProjectButton({ projectId, name, disabled, disabledReason }: { projectId: string; name: string; disabled?: boolean; disabledReason?: string }) {
38+ const router = useRouter();
39+ const [open, setOpen] = React.useState(false);
40+ const [confirmName, setConfirmName] = React.useState("");
41+ const [error, setError] = React.useState<string | null>(null);
42+ const [pending, startTransition] = React.useTransition();
43+
44+ return (
45+ <>
46+ <div className="flex flex-col items-start gap-1.5">
47+ <Button variant="danger" size="sm" disabled={disabled} onClick={() => setOpen(true)}>
48+ <Archive /> Archive project
49+ </Button>
50+ {disabled && disabledReason ? <p className="text-[12px] text-fg-subtle">{disabledReason}</p> : null}
51+ </div>
52+ <Dialog
53+ open={open}
54+ onOpenChange={(o) => {
55+ if (!pending) {
56+ setOpen(o);
57+ setError(null);
58+ setConfirmName("");
59+ }
60+ }}
61+ >
62+ <DialogContent size="sm">
63+ <DialogHeader>
64+ <DialogTitle>Archive {name}?</DialogTitle>
65+ <DialogDescription>
66+ Archived projects disappear from the dashboard and their API keys stop authenticating. Request history is kept for your plan&apos;s retention window. Contact support to restore an archived project.
67+ </DialogDescription>
68+ </DialogHeader>
69+ {error ? <Alert variant="danger">{error}</Alert> : null}
70+ <Field>
71+ <Label htmlFor="archive-confirm">
72+ Type <span className="font-mono">{name}</span> to confirm
73+ </Label>
74+ <Input id="archive-confirm" value={confirmName} onChange={(e) => setConfirmName(e.target.value)} autoComplete="off" autoFocus />
75+ </Field>
76+ <DialogFooter>
77+ <Button variant="outline" onClick={() => setOpen(false)} disabled={pending}>
78+ Cancel
79+ </Button>
80+ <Button
81+ variant="danger"
82+ disabled={confirmName.trim() !== name}
83+ loading={pending}
84+ onClick={() =>
85+ startTransition(async () => {
86+ const res = await archiveProject(projectId);
87+ if (!res.ok) {
88+ setError(res.error);
89+ return;
90+ }
91+ setOpen(false);
92+ router.push("/dashboard/projects");
93+ router.refresh();
94+ })
95+ }
96+ >
97+ Archive project
98+ </Button>
99+ </DialogFooter>
100+ </DialogContent>
101+ </Dialog>
102+ </>
103+ );
104+}
added apps/web/src/components/dashboard/projects/project-form.tsx +198 −0
@@ -0,0 +1,198 @@
1+"use client";
2+import * as React from "react";
3+import { COUNTRIES } from "@fetcha/core/client";
4+import { createProject, updateProject } from "@/actions/projects";
5+import { Button } from "@/components/ui/button";
6+import { Input, NativeSelect, Textarea } from "@/components/ui/input";
7+import { Field, FieldError, Hint, Label } from "@/components/ui/label";
8+import { Alert } from "@/components/ui/alert";
9+import { Badge } from "@/components/ui/badge";
10+import { cn } from "@/lib/utils";
11+
12+export interface ProjectFormValues {
13+ name: string;
14+ description: string | null;
15+ environment: string;
16+ defaultCountry: string | null;
17+ defaultNetwork: string;
18+ logLevel: string;
19+ softLimitUsd: number | null;
20+ hardLimitUsd: number | null;
21+ monthlyRequestLimit: number | null;
22+}
23+
24+const LOG_LEVELS = [
25+ { v: "none", title: "None", desc: "Only billing counters. No URL, headers or timing are kept — nothing to inspect in Requests." },
26+ { v: "metadata", title: "Metadata", desc: "URL, status, network, country, timing and size. Recommended for production.", recommended: true },
27+ { v: "headers", title: "Headers", desc: "Metadata plus request and response headers. Authorization and Cookie are always redacted." },
28+ { v: "full", title: "Full", desc: "Headers plus a response body preview, kept for your plan's retention window. Use for debugging only." },
29+] as const;
30+
31+const NETWORKS = [
32+ { v: "auto", label: "Auto (recommended)", live: true },
33+ { v: "residential", label: "Residential", live: true },
34+ { v: "datacenter", label: "Datacenter — coming soon", live: false },
35+ { v: "isp", label: "ISP — coming soon", live: false },
36+ { v: "mobile", label: "Mobile — coming soon", live: false },
37+];
38+
39+const COUNTRY_OPTIONS = Object.entries(COUNTRIES).sort((a, b) => a[1].localeCompare(b[1]));
40+
41+export function ProjectForm({
42+ mode,
43+ projectId,
44+ initial,
45+ onSuccess,
46+ onCancel,
47+ submitLabel,
48+ compact,
49+}: {
50+ mode: "create" | "edit";
51+ projectId?: string;
52+ initial?: Partial<ProjectFormValues>;
53+ onSuccess?: (id: string) => void;
54+ onCancel?: () => void;
55+ submitLabel?: string;
56+ compact?: boolean;
57+}) {
58+ const [pending, startTransition] = React.useTransition();
59+ const [error, setError] = React.useState<string | null>(null);
60+ const [fieldError, setFieldError] = React.useState<string | null>(null);
61+ const [saved, setSaved] = React.useState(false);
62+ const [logLevel, setLogLevel] = React.useState(initial?.logLevel ?? "metadata");
63+ const [environment, setEnvironment] = React.useState(initial?.environment ?? "production");
64+
65+ function submit(e: React.FormEvent<HTMLFormElement>) {
66+ e.preventDefault();
67+ setError(null);
68+ setFieldError(null);
69+ setSaved(false);
70+ const fd = new FormData(e.currentTarget);
71+ startTransition(async () => {
72+ const res = mode === "create" ? await createProject(fd) : await updateProject(projectId!, fd);
73+ if (!res.ok) {
74+ setError(res.error);
75+ setFieldError(res.field ?? null);
76+ return;
77+ }
78+ setSaved(true);
79+ const id = mode === "create" && "data" in res && res.data ? res.data.id : projectId!;
80+ onSuccess?.(id);
81+ });
82+ }
83+
84+ const err = (f: string) => (fieldError === f ? error : null);
85+
86+ return (
87+ <form onSubmit={submit} className="grid gap-5" noValidate>
88+ {error && !fieldError ? <Alert variant="danger">{error}</Alert> : null}
89+ {saved && mode === "edit" ? <Alert variant="success">Project settings saved.</Alert> : null}
90+
91+ <section className="grid gap-4">
92+ <div className={cn("grid gap-4", !compact && "sm:grid-cols-[1fr_200px]")}>
93+ <Field>
94+ <Label htmlFor="p-name">Name</Label>
95+ <Input id="p-name" name="name" defaultValue={initial?.name ?? ""} placeholder="e.g. Price monitoring" maxLength={64} required autoFocus={mode === "create"} aria-invalid={Boolean(err("name"))} />
96+ <FieldError>{err("name")}</FieldError>
97+ </Field>
98+ <Field>
99+ <Label>Environment</Label>
100+ <div className="flex h-9 items-center gap-0.5 rounded-md bg-bg-muted p-1 text-[13px] font-medium text-fg-muted">
101+ {(["production", "development"] as const).map((env) => (
102+ <label key={env} className={cn("flex h-7 flex-1 cursor-pointer items-center justify-center rounded-[5px] capitalize transition-colors", environment === env ? "bg-bg text-fg shadow-xs" : "hover:text-fg")}>
103+ <input type="radio" name="environment" value={env} checked={environment === env} onChange={() => setEnvironment(env)} className="sr-only" />
104+ {env}
105+ </label>
106+ ))}
107+ </div>
108+ </Field>
109+ </div>
110+ <Field>
111+ <Label htmlFor="p-description">Description</Label>
112+ <Textarea id="p-description" name="description" defaultValue={initial?.description ?? ""} placeholder="What this project fetches and for whom (optional)" maxLength={280} className="min-h-[64px]" />
113+ </Field>
114+ </section>
115+
116+ <section className="grid gap-4">
117+ <h4 className="text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Request defaults</h4>
118+ <div className={cn("grid gap-4", !compact && "sm:grid-cols-2")}>
119+ <Field>
120+ <Label htmlFor="p-country">Default country</Label>
121+ <NativeSelect id="p-country" name="defaultCountry" defaultValue={initial?.defaultCountry ?? ""}>
122+ <option value="">No default — let routing decide</option>
123+ {COUNTRY_OPTIONS.map(([code, name]) => (
124+ <option key={code} value={code}>
125+ {name} ({code})
126+ </option>
127+ ))}
128+ </NativeSelect>
129+ <Hint>Used when a request omits `country`.</Hint>
130+ </Field>
131+ <Field>
132+ <Label htmlFor="p-network">Default network</Label>
133+ <NativeSelect id="p-network" name="defaultNetwork" defaultValue={initial?.defaultNetwork ?? "auto"}>
134+ {NETWORKS.map((n) => (
135+ <option key={n.v} value={n.v} disabled={!n.live}>
136+ {n.label}
137+ </option>
138+ ))}
139+ </NativeSelect>
140+ <Hint>Only residential is live today; auto resolves to residential.</Hint>
141+ </Field>
142+ </div>
143+ <fieldset className="grid gap-2">
144+ <legend className="mb-1.5 text-[13px] font-medium">Request logging</legend>
145+ <div className={cn("grid gap-2", !compact && "sm:grid-cols-2")}>
146+ {LOG_LEVELS.map((l) => (
147+ <label key={l.v} className={cn("flex cursor-pointer items-start gap-2.5 rounded-md border px-3 py-2.5 transition-colors", logLevel === l.v ? "border-accent bg-accent-soft/40" : "border-border hover:border-border-strong")}>
148+ <input type="radio" name="logLevel" value={l.v} checked={logLevel === l.v} onChange={() => setLogLevel(l.v)} className="mt-0.5 accent-[var(--accent)]" />
149+ <span className="grid gap-0.5">
150+ <span className="flex items-center gap-2 text-[13px] font-medium">
151+ {l.title}
152+ {"recommended" in l && l.recommended ? <Badge variant="accent">Recommended</Badge> : null}
153+ </span>
154+ <span className="text-[12px] leading-relaxed text-fg-muted">{l.desc}</span>
155+ </span>
156+ </label>
157+ ))}
158+ </div>
159+ </fieldset>
160+ </section>
161+
162+ <section className="grid gap-4">
163+ <h4 className="text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Limits</h4>
164+ <div className={cn("grid gap-4", !compact && "sm:grid-cols-3")}>
165+ <Field>
166+ <Label htmlFor="p-soft">Soft limit (USD / month)</Label>
167+ <Input id="p-soft" name="softLimitUsd" type="number" min={0} step="0.01" inputMode="decimal" defaultValue={initial?.softLimitUsd ?? ""} placeholder="No limit" className="font-mono tabular" aria-invalid={Boolean(err("softLimitUsd"))} />
168+ <Hint>We email you when spend crosses it. Requests continue.</Hint>
169+ <FieldError>{err("softLimitUsd")}</FieldError>
170+ </Field>
171+ <Field>
172+ <Label htmlFor="p-hard">Hard limit (USD / month)</Label>
173+ <Input id="p-hard" name="hardLimitUsd" type="number" min={0} step="0.01" inputMode="decimal" defaultValue={initial?.hardLimitUsd ?? ""} placeholder="No limit" className="font-mono tabular" aria-invalid={Boolean(err("hardLimitUsd"))} />
174+ <Hint>Requests are rejected with USAGE_LIMIT_REACHED until next month.</Hint>
175+ <FieldError>{err("hardLimitUsd")}</FieldError>
176+ </Field>
177+ <Field>
178+ <Label htmlFor="p-reqs">Monthly request limit</Label>
179+ <Input id="p-reqs" name="monthlyRequestLimit" type="number" min={0} step="1" inputMode="numeric" defaultValue={initial?.monthlyRequestLimit ?? ""} placeholder="Plan limit" className="font-mono tabular" aria-invalid={Boolean(err("monthlyRequestLimit"))} />
180+ <Hint>Caps this project below your plan allowance.</Hint>
181+ <FieldError>{err("monthlyRequestLimit")}</FieldError>
182+ </Field>
183+ </div>
184+ </section>
185+
186+ <div className="flex items-center justify-end gap-2 border-t border-border pt-4">
187+ {onCancel ? (
188+ <Button type="button" variant="ghost" onClick={onCancel} disabled={pending}>
189+ Cancel
190+ </Button>
191+ ) : null}
192+ <Button type="submit" variant="primary" loading={pending}>
193+ {submitLabel ?? (mode === "create" ? "Create project" : "Save changes")}
194+ </Button>
195+ </div>
196+ </form>
197+ );
198+}
added apps/web/src/components/dashboard/requests/attempts-timeline.tsx +78 −0
@@ -0,0 +1,78 @@
1+import { Route } from "lucide-react";
2+import { Badge, StatusBadge } from "@/components/ui/badge";
3+import { EmptyState } from "@/components/ui/empty-state";
4+import { formatBytes, formatMs } from "@/lib/format";
5+import type { RequestAttemptView } from "@/lib/queries/dashboard";
6+import { HttpCode } from "./requests-table";
7+
8+function outcomeStatus(outcome: string): string {
9+ if (outcome === "success") return "success";
10+ if (outcome === "blocked") return "blocked";
11+ if (outcome === "timeout") return "pending";
12+ return "failed";
13+}
14+
15+/**
16+ * Vertical timeline of routing attempts. `provider` is only present when the organization has
17+ * provider visibility enabled; it is then labelled as debug output.
18+ */
19+export function AttemptsTimeline({ attempts, providerVisibility }: { attempts: RequestAttemptView[]; providerVisibility: boolean }) {
20+ if (!attempts.length) {
21+ return <EmptyState compact icon={Route} title="No attempts recorded" description="Attempts appear once the router has tried at least one route for this request." />;
22+ }
23+ return (
24+ <ol className="relative ml-3 border-l border-border">
25+ {attempts.map((a) => (
26+ <li key={a.id} className="relative pb-5 pl-6 last:pb-0">
27+ <span aria-hidden className={`absolute -left-[5px] top-1.5 size-2.5 rounded-full border-2 border-bg-elevated ${a.outcome === "success" ? "bg-success" : a.outcome === "blocked" ? "bg-danger" : "bg-warning"}`} />
28+ <div className="flex flex-wrap items-center gap-2 text-[13px]">
29+ <span className="font-mono text-[12px] text-fg-subtle">#{a.attemptNo}</span>
30+ <StatusBadge status={outcomeStatus(a.outcome)} />
31+ <span className="text-fg-muted">
32+ via <span className="text-fg">{a.network === "isp" ? "ISP" : a.network}</span>
33+ {a.country ? <span className="font-mono"> · {a.country}</span> : null}
34+ </span>
35+ {providerVisibility && a.provider ? (
36+ <Badge variant="outline" className="font-mono">
37+ <span className="text-fg-subtle">debug</span> {a.provider}
38+ </Badge>
39+ ) : null}
40+ <span className="ml-auto font-mono tabular text-fg-muted">{formatMs(a.durationMs)}</span>
41+ </div>
42+ <dl className="mt-1.5 grid grid-cols-2 gap-x-6 gap-y-1 text-[12.5px] sm:grid-cols-4">
43+ <div>
44+ <dt className="text-fg-subtle">Outcome</dt>
45+ <dd className="font-medium">{a.outcome.replace(/_/g, " ")}</dd>
46+ </div>
47+ <div>
48+ <dt className="text-fg-subtle">HTTP</dt>
49+ <dd>
50+ <HttpCode code={a.httpStatus} />
51+ </dd>
52+ </div>
53+ <div>
54+ <dt className="text-fg-subtle">Bytes</dt>
55+ <dd className="font-mono tabular">{formatBytes(a.bytesIn + a.bytesOut)}</dd>
56+ </div>
57+ <div>
58+ <dt className="text-fg-subtle">Routing score</dt>
59+ <dd className="font-mono tabular">{a.routingScore === null ? "—" : a.routingScore.toFixed(2)}</dd>
60+ </div>
61+ {a.errorCode ? (
62+ <div className="col-span-2">
63+ <dt className="text-fg-subtle">Error</dt>
64+ <dd className="font-mono text-danger">{a.errorCode}</dd>
65+ </div>
66+ ) : null}
67+ {a.blockReason ? (
68+ <div className="col-span-2">
69+ <dt className="text-fg-subtle">Block reason</dt>
70+ <dd>{a.blockReason}</dd>
71+ </div>
72+ ) : null}
73+ </dl>
74+ </li>
75+ ))}
76+ </ol>
77+ );
78+}
added apps/web/src/components/dashboard/requests/headers-table.tsx +42 −0
@@ -0,0 +1,42 @@
1+import { FileText } from "lucide-react";
2+import { EmptyState } from "@/components/ui/empty-state";
3+import { SectionTitle } from "@/components/ui/page-header";
4+
5+function HeaderList({ headers }: { headers: Record<string, string> }) {
6+ const entries = Object.entries(headers).sort(([a], [b]) => a.localeCompare(b));
7+ return (
8+ <div className="overflow-hidden rounded-lg border border-border">
9+ <dl className="divide-y divide-border text-[12.5px]">
10+ {entries.map(([k, v]) => (
11+ <div key={k} className="grid grid-cols-[minmax(120px,32%)_1fr] gap-3 px-3 py-2">
12+ <dt className="truncate font-mono text-fg-muted" title={k}>
13+ {k}
14+ </dt>
15+ <dd className="break-all font-mono">{v}</dd>
16+ </div>
17+ ))}
18+ </dl>
19+ </div>
20+ );
21+}
22+
23+/** Stored (already redacted) request/response headers. Server component. */
24+export function HeadersTables({ request, response }: { request: Record<string, string> | null; response: Record<string, string> | null }) {
25+ const hasReq = request && Object.keys(request).length > 0;
26+ const hasRes = response && Object.keys(response).length > 0;
27+ if (!hasReq && !hasRes) {
28+ return <EmptyState compact icon={FileText} title="Headers not stored" description={<>Headers are kept only when the project log level is <code className="font-mono">headers</code> or <code className="font-mono">full</code>. Sensitive headers such as Authorization and Cookie are always redacted.</>} />;
29+ }
30+ return (
31+ <div className="grid gap-6 lg:grid-cols-2">
32+ <section>
33+ <SectionTitle right={<span className="text-[11.5px] text-fg-subtle">Authorization and Cookie redacted</span>}>Request headers</SectionTitle>
34+ {hasReq ? <HeaderList headers={request} /> : <p className="text-[13px] text-fg-muted">None stored.</p>}
35+ </section>
36+ <section>
37+ <SectionTitle>Response headers</SectionTitle>
38+ {hasRes ? <HeaderList headers={response} /> : <p className="text-[13px] text-fg-muted">None stored.</p>}
39+ </section>
40+ </div>
41+ );
42+}
added apps/web/src/components/dashboard/requests/pagination.tsx +45 −0
@@ -0,0 +1,45 @@
1+import Link from "next/link";
2+import { ChevronLeft, ChevronRight } from "lucide-react";
3+import { Button } from "@/components/ui/button";
4+import { formatNumber } from "@/lib/format";
5+
6+/** Offset pagination with URL-preserving links. */
7+export function Pagination({ page, pageCount, total, pageSize, hrefFor }: { page: number; pageCount: number; total: number; pageSize: number; hrefFor: (page: number) => string }) {
8+ if (total === 0) return null;
9+ const start = (page - 1) * pageSize + 1;
10+ const end = Math.min(total, page * pageSize);
11+ return (
12+ <nav className="flex flex-wrap items-center justify-between gap-3 text-[12.5px] text-fg-muted" aria-label="Pagination">
13+ <span className="font-mono tabular">
14+ {formatNumber(start)}–{formatNumber(end)} of {formatNumber(total)}
15+ </span>
16+ <div className="flex items-center gap-1">
17+ <Button asChild={page > 1} variant="outline" size="sm" disabled={page <= 1} aria-label="Previous page">
18+ {page > 1 ? (
19+ <Link href={hrefFor(page - 1)} rel="prev">
20+ <ChevronLeft /> Previous
21+ </Link>
22+ ) : (
23+ <span>
24+ <ChevronLeft /> Previous
25+ </span>
26+ )}
27+ </Button>
28+ <span className="px-2 font-mono tabular">
29+ {page} / {pageCount}
30+ </span>
31+ <Button asChild={page < pageCount} variant="outline" size="sm" disabled={page >= pageCount} aria-label="Next page">
32+ {page < pageCount ? (
33+ <Link href={hrefFor(page + 1)} rel="next">
34+ Next <ChevronRight />
35+ </Link>
36+ ) : (
37+ <span>
38+ Next <ChevronRight />
39+ </span>
40+ )}
41+ </Button>
42+ </div>
43+ </nav>
44+ );
45+}
added apps/web/src/components/dashboard/requests/request-detail-tabs.tsx +25 −0
@@ -0,0 +1,25 @@
1+"use client";
2+import * as React from "react";
3+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
4+
5+/** Client shell for the request detail tabs; each panel is server-rendered and passed in as a node. */
6+export function RequestDetailTabs({ overview, attempts, headers, timing, attemptCount, headerCount }: { overview: React.ReactNode; attempts: React.ReactNode; headers: React.ReactNode; timing: React.ReactNode; attemptCount: number; headerCount: number }) {
7+ return (
8+ <Tabs defaultValue="overview">
9+ <TabsList variant="underline">
10+ <TabsTrigger value="overview">Overview</TabsTrigger>
11+ <TabsTrigger value="attempts">
12+ Attempts <span className="font-mono text-[11px] text-fg-subtle">{attemptCount}</span>
13+ </TabsTrigger>
14+ <TabsTrigger value="headers">
15+ Headers <span className="font-mono text-[11px] text-fg-subtle">{headerCount}</span>
16+ </TabsTrigger>
17+ <TabsTrigger value="timing">Timing</TabsTrigger>
18+ </TabsList>
19+ <TabsContent value="overview">{overview}</TabsContent>
20+ <TabsContent value="attempts">{attempts}</TabsContent>
21+ <TabsContent value="headers">{headers}</TabsContent>
22+ <TabsContent value="timing">{timing}</TabsContent>
23+ </Tabs>
24+ );
25+}
added apps/web/src/components/dashboard/requests/requests-filters.tsx +131 −0
@@ -0,0 +1,131 @@
1+import Link from "next/link";
2+import { Download, Search, X } from "lucide-react";
3+import { COUNTRIES } from "@fetcha/core";
4+import { Button } from "@/components/ui/button";
5+import { Input, NativeSelect } from "@/components/ui/input";
6+import { Label } from "@/components/ui/label";
7+import { REQUEST_NETWORKS, activeFilterChips, filtersToSearchParams, hrefWithoutChip, type RequestFilters } from "@/lib/requests-filters";
8+
9+/**
10+ * Server-rendered GET form. Submitting reloads the page with the new query string, so it works
11+ * without JavaScript and every filter set is a shareable URL.
12+ */
13+export function RequestsFilterForm({ filters, exportCount }: { filters: RequestFilters; exportCount: number }) {
14+ const exportQs = filtersToSearchParams(filters, { page: undefined }).toString();
15+ return (
16+ <form method="get" action="/dashboard/requests" className="rounded-lg border border-border bg-bg-elevated shadow-xs">
17+ <div className="grid gap-3 p-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
18+ <div className="grid gap-1.5">
19+ <Label htmlFor="f-range">Range</Label>
20+ <NativeSelect id="f-range" name="range" defaultValue={filters.range}>
21+ <option value="24h">Last 24 hours</option>
22+ <option value="7d">Last 7 days</option>
23+ <option value="30d">Last 30 days</option>
24+ <option value="custom">Custom dates</option>
25+ </NativeSelect>
26+ </div>
27+ <div className="grid gap-1.5">
28+ <Label htmlFor="f-from">From</Label>
29+ <Input id="f-from" name="from" type="date" defaultValue={filters.from ?? ""} className="font-mono text-[13px]" />
30+ </div>
31+ <div className="grid gap-1.5">
32+ <Label htmlFor="f-to">To</Label>
33+ <Input id="f-to" name="to" type="date" defaultValue={filters.to ?? ""} className="font-mono text-[13px]" />
34+ </div>
35+ <div className="grid gap-1.5">
36+ <Label htmlFor="f-status">Status</Label>
37+ <NativeSelect id="f-status" name="status" defaultValue={filters.status ?? ""}>
38+ <option value="">Any</option>
39+ <option value="success">Success</option>
40+ <option value="failed">Failed</option>
41+ <option value="pending">Pending</option>
42+ </NativeSelect>
43+ </div>
44+ <div className="grid gap-1.5">
45+ <Label htmlFor="f-source">Source</Label>
46+ <NativeSelect id="f-source" name="source" defaultValue={filters.source ?? ""}>
47+ <option value="">Any</option>
48+ <option value="api">API</option>
49+ <option value="sdk">SDK</option>
50+ <option value="playground">Playground</option>
51+ </NativeSelect>
52+ </div>
53+ <div className="grid gap-1.5">
54+ <Label htmlFor="f-domain">Domain contains</Label>
55+ <Input id="f-domain" name="domain" placeholder="example.com" defaultValue={filters.domain ?? ""} autoComplete="off" />
56+ </div>
57+ <div className="grid gap-1.5">
58+ <Label htmlFor="f-network">Network</Label>
59+ <NativeSelect id="f-network" name="network" defaultValue={filters.network ?? ""}>
60+ <option value="">Any</option>
61+ {REQUEST_NETWORKS.map((n) => (
62+ <option key={n} value={n}>
63+ {n === "isp" ? "ISP" : n[0]!.toUpperCase() + n.slice(1)}
64+ </option>
65+ ))}
66+ </NativeSelect>
67+ </div>
68+ <div className="grid gap-1.5">
69+ <Label htmlFor="f-country">Country</Label>
70+ <NativeSelect id="f-country" name="country" defaultValue={filters.country ?? ""}>
71+ <option value="">Any</option>
72+ {Object.entries(COUNTRIES).map(([code, name]) => (
73+ <option key={code} value={code}>
74+ {code} — {name}
75+ </option>
76+ ))}
77+ </NativeSelect>
78+ </div>
79+ <div className="grid gap-1.5">
80+ <Label htmlFor="f-http">HTTP code</Label>
81+ <Input id="f-http" name="http" inputMode="numeric" pattern="[0-9]{3}" placeholder="200" defaultValue={filters.httpStatus ?? ""} className="font-mono" />
82+ </div>
83+ <div className="grid gap-1.5">
84+ <Label htmlFor="f-id">Request ID</Label>
85+ <Input id="f-id" name="id" placeholder="req_…" defaultValue={filters.requestId ?? ""} className="font-mono" autoComplete="off" />
86+ </div>
87+ </div>
88+ <div className="flex flex-wrap items-center gap-2 border-t border-border bg-bg-subtle/60 px-4 py-2.5">
89+ <Button type="submit" size="sm">
90+ <Search /> Apply filters
91+ </Button>
92+ <Button asChild variant="ghost" size="sm">
93+ <Link href="/dashboard/requests">Reset</Link>
94+ </Button>
95+ <span className="ml-auto text-[12px] text-fg-subtle">
96+ {exportCount > 10_000 ? "Export limited to the newest 10,000 rows" : `${exportCount.toLocaleString("en-US")} row${exportCount === 1 ? "" : "s"} match`}
97+ </span>
98+ <Button asChild variant="outline" size="sm" disabled={exportCount === 0}>
99+ <a href={`/dashboard/requests/export${exportQs ? `?${exportQs}` : ""}`} download aria-disabled={exportCount === 0}>
100+ <Download /> Export CSV
101+ </a>
102+ </Button>
103+ </div>
104+ </form>
105+ );
106+}
107+
108+export function ActiveFilterChips({ filters }: { filters: RequestFilters }) {
109+ const chips = activeFilterChips(filters);
110+ if (!chips.length) return null;
111+ return (
112+ <div className="flex flex-wrap items-center gap-1.5 text-[12px]">
113+ <span className="mr-1 text-fg-subtle">Filters:</span>
114+ {chips.map((c) => (
115+ <Link
116+ key={c.key}
117+ href={hrefWithoutChip(filters, c.key)}
118+ className="group inline-flex items-center gap-1 rounded-full border border-border bg-bg-elevated py-0.5 pl-2.5 pr-1.5 text-fg-muted transition-colors hover:border-border-strong hover:text-fg"
119+ title={`Remove ${c.label} filter`}
120+ >
121+ <span className="text-fg-subtle">{c.label}</span>
122+ <span className="font-mono text-fg">{c.value}</span>
123+ <X className="size-3 text-fg-subtle group-hover:text-fg" aria-hidden />
124+ </Link>
125+ ))}
126+ <Link href="/dashboard/requests" className="ml-1 text-fg-subtle underline-offset-4 hover:text-fg hover:underline">
127+ Clear all
128+ </Link>
129+ </div>
130+ );
131+}
added apps/web/src/components/dashboard/requests/requests-table.tsx +92 −0
@@ -0,0 +1,92 @@
1+import Link from "next/link";
2+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
3+import { Badge, StatusBadge } from "@/components/ui/badge";
4+import { formatBytes, formatDate, formatMs, formatUsd, timeAgo } from "@/lib/format";
5+import type { RequestListRow } from "@/lib/queries/dashboard";
6+import { cn } from "@/lib/utils";
7+
8+export function NetworkLabel({ network, requested }: { network: string | null; requested?: string }) {
9+ if (!network) return <span className="text-fg-subtle">—</span>;
10+ return (
11+ <span className="inline-flex items-center gap-1">
12+ <span>{network === "isp" ? "ISP" : network}</span>
13+ {requested && requested !== "auto" && requested !== network ? (
14+ <span className="text-[11px] text-fg-subtle" title={`Requested ${requested}`}>
15+ (asked {requested})
16+ </span>
17+ ) : null}
18+ </span>
19+ );
20+}
21+
22+export function HttpCode({ code }: { code: number | null }) {
23+ if (code === null) return <span className="text-fg-subtle">—</span>;
24+ const tone = code < 300 ? "text-success" : code < 400 ? "text-info" : code < 500 ? "text-warning" : "text-danger";
25+ return <span className={cn("font-mono tabular", tone)}>{code}</span>;
26+}
27+
28+/** Request log table. `compact` hides secondary columns (used on the Overview). */
29+export function RequestsTable({ rows, compact, emptyMessage = "No requests match these filters." }: { rows: RequestListRow[]; compact?: boolean; emptyMessage?: React.ReactNode }) {
30+ const cols = compact ? 7 : 11;
31+ return (
32+ <Table>
33+ <TableHeader>
34+ <TableRow className="hover:bg-transparent">
35+ <TableHead>Request</TableHead>
36+ <TableHead>Time</TableHead>
37+ <TableHead>Domain</TableHead>
38+ <TableHead>Status</TableHead>
39+ <TableHead className="text-right">HTTP</TableHead>
40+ {!compact ? <TableHead>Network</TableHead> : null}
41+ {!compact ? <TableHead>Country</TableHead> : null}
42+ <TableHead className="text-right">Latency</TableHead>
43+ {!compact ? <TableHead className="text-right">Attempts</TableHead> : null}
44+ {!compact ? <TableHead className="text-right">Bandwidth</TableHead> : null}
45+ <TableHead className="text-right">Cost</TableHead>
46+ </TableRow>
47+ </TableHeader>
48+ <TableBody>
49+ {rows.length === 0 ? (
50+ <TableEmpty colSpan={cols}>{emptyMessage}</TableEmpty>
51+ ) : (
52+ rows.map((r) => (
53+ <TableRow key={r.id}>
54+ <TableCell>
55+ <Link href={`/dashboard/requests/${r.id}`} className="font-mono text-[12.5px] text-accent underline-offset-4 hover:underline" prefetch={false}>
56+ {r.id}
57+ </Link>
58+ {r.source !== "api" ? (
59+ <Badge variant="outline" className="ml-2 align-middle text-[10.5px]">
60+ {r.source}
61+ </Badge>
62+ ) : null}
63+ </TableCell>
64+ <TableCell className="whitespace-nowrap text-fg-muted" title={formatDate(r.createdAt, { timeStyle: "medium" })}>
65+ {timeAgo(r.createdAt)}
66+ </TableCell>
67+ <TableCell className="max-w-[220px] truncate" title={r.url}>
68+ {r.domain}
69+ </TableCell>
70+ <TableCell>
71+ <StatusBadge status={r.status} />
72+ </TableCell>
73+ <TableCell className="text-right">
74+ <HttpCode code={r.httpStatus} />
75+ </TableCell>
76+ {!compact ? (
77+ <TableCell className="text-fg-muted">
78+ <NetworkLabel network={r.network} requested={r.requestedNetwork} />
79+ </TableCell>
80+ ) : null}
81+ {!compact ? <TableCell className="font-mono tabular text-fg-muted">{r.country ?? "—"}</TableCell> : null}
82+ <TableCell className="text-right font-mono tabular">{formatMs(r.latencyMs)}</TableCell>
83+ {!compact ? <TableCell className="text-right font-mono tabular">{r.attempts || "—"}</TableCell> : null}
84+ {!compact ? <TableCell className="text-right font-mono tabular text-fg-muted">{formatBytes(r.bytesIn + r.bytesOut)}</TableCell> : null}
85+ <TableCell className="text-right font-mono tabular">{formatUsd(r.priceUsd, true)}</TableCell>
86+ </TableRow>
87+ ))
88+ )}
89+ </TableBody>
90+ </Table>
91+ );
92+}
added apps/web/src/components/dashboard/requests/timing-waterfall.tsx +54 −0
@@ -0,0 +1,54 @@
1+import { formatMs } from "@/lib/format";
2+import { EmptyState } from "@/components/ui/empty-state";
3+import { Timer } from "lucide-react";
4+
5+const PHASES: Array<{ key: string; label: string; description: string }> = [
6+ { key: "dns_ms", label: "DNS", description: "Resolving the target hostname" },
7+ { key: "proxy_connect_ms", label: "Network connect", description: "Establishing the route through the selected network class" },
8+ { key: "tls_ms", label: "TLS", description: "TLS handshake with the origin" },
9+ { key: "origin_ms", label: "Origin", description: "Waiting for the origin server to respond and stream the body" },
10+ { key: "processing_ms", label: "Processing", description: "Format conversion, redaction and metering" },
11+];
12+
13+function read(timing: Record<string, number>, key: string): number | null {
14+ const v = timing[key] ?? timing[key.replace(/_ms$/, "")];
15+ return typeof v === "number" && Number.isFinite(v) ? v : null;
16+}
17+
18+/** Horizontal waterfall of request phases from the stored `timing` JSON. Server component. */
19+export function TimingWaterfall({ timing, totalMs }: { timing: Record<string, number> | null; totalMs: number | null }) {
20+ if (!timing || Object.keys(timing).length === 0) {
21+ return <EmptyState compact icon={Timer} title="No timing recorded" description="Timing breakdown is captured for completed requests when the project log level is metadata or higher." />;
22+ }
23+ const phases = PHASES.map((p) => ({ ...p, value: read(timing, p.key) })).filter((p) => p.value !== null) as Array<(typeof PHASES)[number] & { value: number }>;
24+ const total = read(timing, "total_ms") ?? totalMs ?? phases.reduce((a, p) => a + p.value, 0);
25+ const scale = Math.max(total, phases.reduce((a, p) => a + p.value, 0), 1);
26+ // Each phase starts where the previous one ended (cumulative offsets computed up front).
27+ const rows = phases.map((p, i) => ({ ...p, start: phases.slice(0, i).reduce((a, q) => a + q.value, 0) }));
28+ return (
29+ <div className="rounded-lg border border-border bg-bg-elevated">
30+ <ol className="divide-y divide-border">
31+ {rows.map((p) => {
32+ const left = (p.start / scale) * 100;
33+ const width = Math.max((p.value / scale) * 100, 0.5);
34+ return (
35+ <li key={p.key} className="grid grid-cols-[120px_1fr_72px] items-center gap-3 px-4 py-2.5 text-[13px] sm:grid-cols-[160px_1fr_88px]">
36+ <div className="min-w-0">
37+ <div className="font-medium">{p.label}</div>
38+ <div className="hidden truncate text-[11.5px] text-fg-subtle sm:block">{p.description}</div>
39+ </div>
40+ <div className="relative h-2.5 rounded-full bg-bg-muted" aria-hidden>
41+ <div className="absolute top-0 h-full rounded-full bg-accent" style={{ left: `${left}%`, width: `${width}%` }} />
42+ </div>
43+ <div className="text-right font-mono tabular">{formatMs(p.value)}</div>
44+ </li>
45+ );
46+ })}
47+ </ol>
48+ <div className="flex items-center justify-between border-t border-border bg-bg-subtle/60 px-4 py-2.5 text-[13px]">
49+ <span className="font-medium">Total</span>
50+ <span className="font-mono tabular font-semibold">{formatMs(total)}</span>
51+ </div>
52+ </div>
53+ );
54+}
added apps/web/src/components/dashboard/resend-verification.tsx +23 −0
@@ -0,0 +1,23 @@
1+"use client";
2+import * as React from "react";
3+import { authClient } from "@/lib/auth-client";
4+import { Button } from "@/components/ui/button";
5+
6+export function ResendVerificationButton({ email, variant = "outline", size = "xs" }: { email: string; variant?: "outline" | "primary" | "secondary" | "link"; size?: "xs" | "sm" | "default" }) {
7+ const [state, setState] = React.useState<"idle" | "sending" | "sent" | "error">("idle");
8+ return (
9+ <Button
10+ variant={variant}
11+ size={size}
12+ loading={state === "sending"}
13+ disabled={state === "sent"}
14+ onClick={async () => {
15+ setState("sending");
16+ const { error } = await authClient.sendVerificationEmail({ email, callbackURL: "/dashboard?verified=1" });
17+ setState(error ? "error" : "sent");
18+ }}
19+ >
20+ {state === "sent" ? "Email sent" : state === "error" ? "Try again" : "Resend verification"}
21+ </Button>
22+ );
23+}
added apps/web/src/components/dashboard/sessions/close-session-button.tsx +32 −0
@@ -0,0 +1,32 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { closeDashboardSession } from "@/actions/sessions";
5+import { Button } from "@/components/ui/button";
6+
7+export function CloseSessionButton({ id }: { id: string }) {
8+ const router = useRouter();
9+ const [pending, startTransition] = React.useTransition();
10+ const [error, setError] = React.useState<string | null>(null);
11+ return (
12+ <span className="inline-flex items-center gap-2">
13+ {error ? <span className="text-[11.5px] text-danger">{error}</span> : null}
14+ <Button
15+ variant="outline"
16+ size="xs"
17+ loading={pending}
18+ onClick={() =>
19+ startTransition(async () => {
20+ setError(null);
21+ const res = await closeDashboardSession(id);
22+ if (!res.ok) setError(res.error);
23+ else router.refresh();
24+ })
25+ }
26+ aria-label={`Close session ${id}`}
27+ >
28+ Close
29+ </Button>
30+ </span>
31+ );
32+}
added apps/web/src/components/dashboard/sessions/create-session-dialog.tsx +214 −0
@@ -0,0 +1,214 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { Plus } from "lucide-react";
5+import { COUNTRIES } from "@fetcha/core/client";
6+import { createDashboardSession, type DashboardSession } from "@/actions/sessions";
7+import { Button } from "@/components/ui/button";
8+import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
9+import { Field, FieldError, Hint, Label } from "@/components/ui/label";
10+import { Input } from "@/components/ui/input";
11+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
12+import { Alert } from "@/components/ui/alert";
13+import { CodeBlock } from "@/components/ui/code-block";
14+import { CopyButton } from "@/components/ui/copy-button";
15+import { Badge } from "@/components/ui/badge";
16+
17+const ANY = "__any__";
18+
19+function curlFor(sessionId: string, apiBase: string, country?: string): string {
20+ const body: Record<string, unknown> = { url: "https://example.com", session: sessionId };
21+ if (country) body.country = country;
22+ return `curl -X POST ${apiBase}/v1/fetch \\
23+ -H "Authorization: Bearer fch_live_YOUR_KEY" \\
24+ -H "Content-Type: application/json" \\
25+ -d '${JSON.stringify(body)}'`;
26+}
27+
28+export function CreateSessionDialog({ apiBase, defaultCountry }: { apiBase: string; defaultCountry?: string | null }) {
29+ const router = useRouter();
30+ const [open, setOpen] = React.useState(false);
31+ const [pending, startTransition] = React.useTransition();
32+ const [error, setError] = React.useState<string | null>(null);
33+ const [created, setCreated] = React.useState<DashboardSession | null>(null);
34+
35+ const [country, setCountry] = React.useState<string>(defaultCountry && defaultCountry in COUNTRIES ? defaultCountry : ANY);
36+ const [network, setNetwork] = React.useState<string>("auto");
37+ const [region, setRegion] = React.useState("");
38+ const [city, setCity] = React.useState("");
39+ const [ttl, setTtl] = React.useState(600);
40+ const [label, setLabel] = React.useState("");
41+
42+ function reset() {
43+ setError(null);
44+ setCreated(null);
45+ setRegion("");
46+ setCity("");
47+ setTtl(600);
48+ setLabel("");
49+ setNetwork("auto");
50+ }
51+
52+ function submit(e: React.FormEvent) {
53+ e.preventDefault();
54+ setError(null);
55+ startTransition(async () => {
56+ const res = await createDashboardSession({
57+ country: country === ANY ? undefined : country,
58+ region: region || undefined,
59+ city: city || undefined,
60+ network: network as "auto" | "residential",
61+ ttl,
62+ label: label || undefined,
63+ });
64+ if (!res.ok) {
65+ setError(res.error);
66+ return;
67+ }
68+ setCreated(res.data ?? null);
69+ router.refresh();
70+ });
71+ }
72+
73+ const ttlLabel = ttl >= 60 ? `${Math.floor(ttl / 60)} min${ttl % 60 ? ` ${ttl % 60} s` : ""}` : `${ttl} s`;
74+
75+ return (
76+ <Dialog
77+ open={open}
78+ onOpenChange={(o) => {
79+ setOpen(o);
80+ if (!o) reset();
81+ }}
82+ >
83+ <DialogTrigger asChild>
84+ <Button variant="primary" size="sm">
85+ <Plus /> Create session
86+ </Button>
87+ </DialogTrigger>
88+ <DialogContent size="lg">
89+ {created ? (
90+ <>
91+ <DialogHeader>
92+ <DialogTitle>Session created</DialogTitle>
93+ <DialogDescription>
94+ Pass this id as <code className="font-mono">session</code> in each request. It keeps the same exit IP and cookies until it expires or you close it.
95+ </DialogDescription>
96+ </DialogHeader>
97+ <div className="flex items-center gap-2 rounded-md border border-border bg-bg-subtle px-3 py-2">
98+ <code className="min-w-0 flex-1 truncate font-mono text-[13px]">{created.id}</code>
99+ <Badge variant="success" dot>
100+ {created.status}
101+ </Badge>
102+ <CopyButton value={created.id} label="Copy" />
103+ </div>
104+ <dl className="grid grid-cols-3 gap-3 text-[12.5px]">
105+ <div>
106+ <dt className="text-fg-subtle">Network</dt>
107+ <dd className="font-medium">{created.network}</dd>
108+ </div>
109+ <div>
110+ <dt className="text-fg-subtle">Country</dt>
111+ <dd className="font-mono">{created.country ?? "any"}</dd>
112+ </div>
113+ <div>
114+ <dt className="text-fg-subtle">Expires</dt>
115+ <dd className="font-mono">{new Date(created.expires_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}</dd>
116+ </div>
117+ </dl>
118+ <CodeBlock code={curlFor(created.id, apiBase, created.country ?? undefined)} lang="bash" title="Use it in a request" />
119+ <DialogFooter>
120+ <Button variant="outline" onClick={() => reset()}>
121+ Create another
122+ </Button>
123+ <DialogClose asChild>
124+ <Button>Done</Button>
125+ </DialogClose>
126+ </DialogFooter>
127+ </>
128+ ) : (
129+ <form onSubmit={submit} className="contents">
130+ <DialogHeader>
131+ <DialogTitle>Create a sticky session</DialogTitle>
132+ <DialogDescription>A session pins one exit IP and carries cookies across requests, for logins, carts and paginated crawls. Sessions live 1 to 30 minutes.</DialogDescription>
133+ </DialogHeader>
134+ {error ? <Alert variant="danger">{error}</Alert> : null}
135+ <div className="grid gap-4 sm:grid-cols-2">
136+ <Field>
137+ <Label htmlFor="s-country">Country</Label>
138+ <Select value={country} onValueChange={setCountry}>
139+ <SelectTrigger id="s-country">
140+ <SelectValue />
141+ </SelectTrigger>
142+ <SelectContent>
143+ <SelectItem value={ANY}>Any country</SelectItem>
144+ {Object.entries(COUNTRIES).map(([code, name]) => (
145+ <SelectItem key={code} value={code}>
146+ <span className="font-mono text-[12px] text-fg-subtle">{code}</span> {name}
147+ </SelectItem>
148+ ))}
149+ </SelectContent>
150+ </Select>
151+ </Field>
152+ <Field>
153+ <Label htmlFor="s-network">Network</Label>
154+ <Select value={network} onValueChange={setNetwork}>
155+ <SelectTrigger id="s-network">
156+ <SelectValue />
157+ </SelectTrigger>
158+ <SelectContent>
159+ <SelectItem value="auto">Auto (recommended)</SelectItem>
160+ <SelectItem value="residential">Residential</SelectItem>
161+ <SelectItem value="datacenter" disabled>
162+ Datacenter — coming soon
163+ </SelectItem>
164+ <SelectItem value="isp" disabled>
165+ ISP — coming soon
166+ </SelectItem>
167+ <SelectItem value="mobile" disabled>
168+ Mobile — coming soon
169+ </SelectItem>
170+ </SelectContent>
171+ </Select>
172+ <Hint>Auto resolves to residential today.</Hint>
173+ </Field>
174+ <Field>
175+ <Label htmlFor="s-region">Region (optional)</Label>
176+ <Input id="s-region" value={region} onChange={(e) => setRegion(e.target.value)} placeholder="quebec" maxLength={64} autoComplete="off" />
177+ </Field>
178+ <Field>
179+ <Label htmlFor="s-city">City (optional)</Label>
180+ <Input id="s-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="montreal" maxLength={128} autoComplete="off" />
181+ </Field>
182+ <Field className="sm:col-span-2">
183+ <div className="flex items-center justify-between">
184+ <Label htmlFor="s-ttl">Time to live</Label>
185+ <span className="font-mono tabular text-[12.5px] text-fg-muted">{ttlLabel}</span>
186+ </div>
187+ <div className="flex items-center gap-3">
188+ <input id="s-ttl" type="range" min={60} max={1800} step={30} value={ttl} onChange={(e) => setTtl(Number(e.target.value))} className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-bg-muted accent-[var(--accent)]" aria-valuetext={ttlLabel} />
189+ <Input type="number" min={60} max={1800} step={1} value={ttl} onChange={(e) => setTtl(Math.min(1800, Math.max(60, Number(e.target.value) || 60)))} className="w-24 font-mono" aria-label="TTL in seconds" />
190+ </div>
191+ <Hint>Seconds, 60–1800. The clock does not reset on use.</Hint>
192+ </Field>
193+ <Field className="sm:col-span-2">
194+ <Label htmlFor="s-label">Label (optional)</Label>
195+ <Input id="s-label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="checkout-flow-ca" maxLength={128} autoComplete="off" />
196+ <FieldError />
197+ </Field>
198+ </div>
199+ <DialogFooter>
200+ <DialogClose asChild>
201+ <Button type="button" variant="ghost">
202+ Cancel
203+ </Button>
204+ </DialogClose>
205+ <Button type="submit" variant="primary" loading={pending}>
206+ Create session
207+ </Button>
208+ </DialogFooter>
209+ </form>
210+ )}
211+ </DialogContent>
212+ </Dialog>
213+ );
214+}
added apps/web/src/components/dashboard/sessions/sessions-table.tsx +83 −0
@@ -0,0 +1,83 @@
1+import Link from "next/link";
2+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";
3+import { StatusBadge } from "@/components/ui/badge";
4+import { CopyButton } from "@/components/ui/copy-button";
5+import { formatDate, formatNumber, timeAgo } from "@/lib/format";
6+import type { SessionRow } from "@/lib/queries/dashboard";
7+import { CloseSessionButton } from "./close-session-button";
8+
9+function expiresIn(d: Date): string {
10+ const diff = d.getTime() - Date.now();
11+ if (diff <= 0) return "expired";
12+ const s = Math.round(diff / 1000);
13+ if (s < 60) return `in ${s}s`;
14+ const m = Math.round(s / 60);
15+ if (m < 60) return `in ${m}m`;
16+ return `in ${Math.round(m / 60)}h`;
17+}
18+
19+export function SessionsTable({ rows }: { rows: SessionRow[] }) {
20+ return (
21+ <Table>
22+ <TableHeader>
23+ <TableRow className="hover:bg-transparent">
24+ <TableHead>Session</TableHead>
25+ <TableHead>Label</TableHead>
26+ <TableHead>Project</TableHead>
27+ <TableHead>Created</TableHead>
28+ <TableHead>Last used</TableHead>
29+ <TableHead>Country</TableHead>
30+ <TableHead>Network</TableHead>
31+ <TableHead className="text-right">Requests</TableHead>
32+ <TableHead>Status</TableHead>
33+ <TableHead>Expires</TableHead>
34+ <TableHead className="text-right"> </TableHead>
35+ </TableRow>
36+ </TableHeader>
37+ <TableBody>
38+ {rows.length === 0 ? (
39+ <TableEmpty colSpan={11}>No sessions yet.</TableEmpty>
40+ ) : (
41+ rows.map((s) => (
42+ <TableRow key={s.id}>
43+ <TableCell>
44+ <span className="inline-flex items-center gap-1">
45+ <code className="font-mono text-[12.5px]">{s.id}</code>
46+ <CopyButton value={s.id} className="size-6" />
47+ </span>
48+ </TableCell>
49+ <TableCell className="max-w-[160px] truncate text-fg-muted" title={s.label ?? undefined}>
50+ {s.label ?? <span className="text-fg-subtle">—</span>}
51+ </TableCell>
52+ <TableCell className="text-fg-muted">{s.projectName}</TableCell>
53+ <TableCell className="whitespace-nowrap text-fg-muted" title={formatDate(s.createdAt, { timeStyle: "medium" })}>
54+ {timeAgo(s.createdAt)}
55+ </TableCell>
56+ <TableCell className="whitespace-nowrap text-fg-muted" title={s.lastUsedAt ? formatDate(s.lastUsedAt, { timeStyle: "medium" }) : undefined}>
57+ {s.lastUsedAt ? timeAgo(s.lastUsedAt) : <span className="text-fg-subtle">never</span>}
58+ </TableCell>
59+ <TableCell className="font-mono tabular text-fg-muted">{[s.country, s.region, s.city].filter(Boolean).join(" · ") || "any"}</TableCell>
60+ <TableCell className="text-fg-muted">{s.network === "isp" ? "ISP" : s.network}</TableCell>
61+ <TableCell className="text-right font-mono tabular">
62+ {s.requestCount > 0 ? (
63+ <Link href={`/dashboard/requests?range=30d`} className="underline-offset-4 hover:underline">
64+ {formatNumber(s.requestCount)}
65+ </Link>
66+ ) : (
67+ "0"
68+ )}
69+ </TableCell>
70+ <TableCell>
71+ <StatusBadge status={s.status} />
72+ </TableCell>
73+ <TableCell className="whitespace-nowrap font-mono tabular text-fg-muted" title={formatDate(s.expiresAt, { timeStyle: "medium" })}>
74+ {s.status === "active" ? expiresIn(s.expiresAt) : formatDate(s.expiresAt, { dateStyle: "short", timeStyle: "short" })}
75+ </TableCell>
76+ <TableCell className="text-right">{s.status === "active" ? <CloseSessionButton id={s.id} /> : null}</TableCell>
77+ </TableRow>
78+ ))
79+ )}
80+ </TableBody>
81+ </Table>
82+ );
83+}
added apps/web/src/components/dashboard/settings/active-sessions.tsx +163 −0
@@ -0,0 +1,163 @@
1+"use client";
2+import * as React from "react";
3+import { LogOut, MonitorSmartphone } from "lucide-react";
4+import { authClient, useSession } from "@/lib/auth-client";
5+import { recordAuditFromClient } from "@/actions/account";
6+import { Button } from "@/components/ui/button";
7+import { Badge } from "@/components/ui/badge";
8+import { Alert } from "@/components/ui/alert";
9+import { Skeleton } from "@/components/ui/skeleton";
10+import { EmptyState } from "@/components/ui/empty-state";
11+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
12+import { formatDate, timeAgo, truncate } from "@/lib/format";
13+
14+interface SessionRow {
15+ id: string;
16+ token: string;
17+ ipAddress?: string | null;
18+ userAgent?: string | null;
19+ createdAt: Date | string;
20+ expiresAt: Date | string;
21+}
22+
23+/** Turn a UA string into something a human can recognise, without a UA-parser dependency. */
24+function describeAgent(ua: string | null | undefined): string {
25+ if (!ua) return "Unknown device";
26+ const browser = /Edg\//.test(ua) ? "Edge" : /OPR\//.test(ua) ? "Opera" : /Chrome\//.test(ua) ? "Chrome" : /Firefox\//.test(ua) ? "Firefox" : /Safari\//.test(ua) ? "Safari" : /curl|python|node|okhttp/i.test(ua) ? "API client" : "Browser";
27+ const os = /iPhone|iPad/.test(ua) ? "iOS" : /Android/.test(ua) ? "Android" : /Mac OS X/.test(ua) ? "macOS" : /Windows/.test(ua) ? "Windows" : /Linux/.test(ua) ? "Linux" : "";
28+ return os ? `${browser} · ${os}` : browser;
29+}
30+
31+export function ActiveSessions() {
32+ const { data: current } = useSession();
33+ const [sessions, setSessions] = React.useState<SessionRow[] | null>(null);
34+ const [error, setError] = React.useState<string | null>(null);
35+ const [busyToken, setBusyToken] = React.useState<string | null>(null);
36+ const [busyAll, setBusyAll] = React.useState(false);
37+
38+ const [reloadKey, setReloadKey] = React.useState(0);
39+ const load = () => setReloadKey((k) => k + 1);
40+
41+ React.useEffect(() => {
42+ let cancelled = false;
43+ authClient.listSessions().then(({ data, error }) => {
44+ if (cancelled) return;
45+ if (error) {
46+ setError(error.message ?? "Could not load sessions.");
47+ setSessions([]);
48+ return;
49+ }
50+ const rows = ((data ?? []) as SessionRow[]).slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
51+ setSessions(rows);
52+ });
53+ return () => {
54+ cancelled = true;
55+ };
56+ }, [reloadKey]);
57+
58+ const currentToken = current?.session?.token;
59+
60+ async function revoke(token: string) {
61+ setError(null);
62+ setBusyToken(token);
63+ const { error } = await authClient.revokeSession({ token });
64+ if (error) setError(error.message ?? "Could not revoke the session.");
65+ else await recordAuditFromClient("session.revoked", { scope: "one" }).catch(() => {});
66+ setBusyToken(null);
67+ load();
68+ }
69+
70+ async function revokeOthers() {
71+ setError(null);
72+ setBusyAll(true);
73+ const { error } = await authClient.revokeOtherSessions();
74+ if (error) setError(error.message ?? "Could not sign out other devices.");
75+ else await recordAuditFromClient("session.revoked", { scope: "others" }).catch(() => {});
76+ setBusyAll(false);
77+ load();
78+ }
79+
80+ const others = sessions?.filter((s) => s.token !== currentToken).length ?? 0;
81+
82+ return (
83+ <div className="grid gap-3">
84+ {error ? <Alert variant="danger">{error}</Alert> : null}
85+ <div className="flex items-center justify-between gap-3">
86+ <p className="text-[13px] text-fg-muted">
87+ {sessions ? (
88+ <>
89+ <span className="font-mono tabular text-fg">{sessions.length}</span> active {sessions.length === 1 ? "session" : "sessions"}
90+ {others > 0 ? <> · {others} on other devices</> : null}
91+ </>
92+ ) : (
93+ "Loading sessions…"
94+ )}
95+ </p>
96+ <Button variant="outline" size="sm" onClick={revokeOthers} loading={busyAll} disabled={!sessions || others === 0}>
97+ <LogOut /> Sign out other devices
98+ </Button>
99+ </div>
100+ {sessions === null ? (
101+ <div className="grid gap-2 rounded-lg border border-border p-4">
102+ <Skeleton className="h-4 w-2/3" />
103+ <Skeleton className="h-4 w-1/2" />
104+ <Skeleton className="h-4 w-3/5" />
105+ </div>
106+ ) : sessions.length === 0 ? (
107+ <EmptyState compact icon={MonitorSmartphone} title="No sessions found" description="Sign in again to see your devices here." />
108+ ) : (
109+ <div className="overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-xs">
110+ <Table>
111+ <TableHeader>
112+ <TableRow className="hover:bg-transparent">
113+ <TableHead>Device</TableHead>
114+ <TableHead>IP address</TableHead>
115+ <TableHead>Signed in</TableHead>
116+ <TableHead>Expires</TableHead>
117+ <TableHead className="text-right">
118+ <span className="sr-only">Actions</span>
119+ </TableHead>
120+ </TableRow>
121+ </TableHeader>
122+ <TableBody>
123+ {sessions.map((s) => {
124+ const isCurrent = s.token === currentToken;
125+ return (
126+ <TableRow key={s.id}>
127+ <TableCell>
128+ <div className="flex flex-col gap-0.5">
129+ <span className="flex items-center gap-2 font-medium">
130+ {describeAgent(s.userAgent)}
131+ {isCurrent ? (
132+ <Badge variant="accent" dot>
133+ This device
134+ </Badge>
135+ ) : null}
136+ </span>
137+ <span className="font-mono text-[11px] text-fg-subtle" title={s.userAgent ?? undefined}>
138+ {s.userAgent ? truncate(s.userAgent, 64) : "—"}
139+ </span>
140+ </div>
141+ </TableCell>
142+ <TableCell className="font-mono text-[12.5px] tabular">{s.ipAddress || "—"}</TableCell>
143+ <TableCell className="whitespace-nowrap tabular" title={formatDate(s.createdAt)}>
144+ {timeAgo(s.createdAt)}
145+ </TableCell>
146+ <TableCell className="whitespace-nowrap tabular text-fg-muted">{formatDate(s.expiresAt, { timeStyle: undefined })}</TableCell>
147+ <TableCell className="text-right">
148+ {!isCurrent ? (
149+ <Button variant="ghost" size="xs" onClick={() => revoke(s.token)} loading={busyToken === s.token}>
150+ Revoke
151+ </Button>
152+ ) : null}
153+ </TableCell>
154+ </TableRow>
155+ );
156+ })}
157+ </TableBody>
158+ </Table>
159+ </div>
160+ )}
161+ </div>
162+ );
163+}
added apps/web/src/components/dashboard/settings/change-email-dialog.tsx +93 −0
@@ -0,0 +1,93 @@
1+"use client";
2+import * as React from "react";
3+import { authClient } from "@/lib/auth-client";
4+import { Button } from "@/components/ui/button";
5+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
6+import { Input } from "@/components/ui/input";
7+import { Field, Hint, Label } from "@/components/ui/label";
8+import { Alert } from "@/components/ui/alert";
9+
10+export function ChangeEmailDialog({ currentEmail }: { currentEmail: string }) {
11+ const [open, setOpen] = React.useState(false);
12+ const [email, setEmail] = React.useState("");
13+ const [error, setError] = React.useState<string | null>(null);
14+ const [sent, setSent] = React.useState(false);
15+ const [loading, setLoading] = React.useState(false);
16+
17+ async function submit(e: React.FormEvent) {
18+ e.preventDefault();
19+ setError(null);
20+ const newEmail = email.trim().toLowerCase();
21+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
22+ setError("Enter a valid email address.");
23+ return;
24+ }
25+ if (newEmail === currentEmail.toLowerCase()) {
26+ setError("That is already your email address.");
27+ return;
28+ }
29+ setLoading(true);
30+ const { error } = await authClient.changeEmail({ newEmail, callbackURL: "/dashboard/settings?email-changed=1" });
31+ setLoading(false);
32+ if (error) {
33+ setError(error.message ?? "Could not start the email change. Try again.");
34+ return;
35+ }
36+ setSent(true);
37+ }
38+
39+ return (
40+ <Dialog
41+ open={open}
42+ onOpenChange={(o) => {
43+ setOpen(o);
44+ if (!o) {
45+ setSent(false);
46+ setError(null);
47+ setEmail("");
48+ }
49+ }}
50+ >
51+ <DialogTrigger asChild>
52+ <Button variant="outline" size="sm">
53+ Change email
54+ </Button>
55+ </DialogTrigger>
56+ <DialogContent size="sm">
57+ <DialogHeader>
58+ <DialogTitle>Change email address</DialogTitle>
59+ <DialogDescription>
60+ For your security we first send an approval link to your <strong className="text-fg">current</strong> address ({currentEmail}). Once you approve it, the new address becomes your login and must be verified in turn.
61+ </DialogDescription>
62+ </DialogHeader>
63+ {sent ? (
64+ <>
65+ <Alert variant="success" title="Approval email sent">
66+ Open the link we sent to {currentEmail} to approve switching to <strong className="text-fg">{email.trim()}</strong>. The link expires in 24 hours. Until then you keep signing in with your current address.
67+ </Alert>
68+ <DialogFooter>
69+ <Button onClick={() => setOpen(false)}>Done</Button>
70+ </DialogFooter>
71+ </>
72+ ) : (
73+ <form onSubmit={submit} className="grid gap-4" noValidate>
74+ {error ? <Alert variant="danger">{error}</Alert> : null}
75+ <Field>
76+ <Label htmlFor="new-email">New email</Label>
77+ <Input id="new-email" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@company.com" required autoFocus />
78+ <Hint>API keys, projects and usage are unaffected.</Hint>
79+ </Field>
80+ <DialogFooter>
81+ <Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={loading}>
82+ Cancel
83+ </Button>
84+ <Button type="submit" variant="primary" loading={loading} disabled={!email.trim()}>
85+ Send approval email
86+ </Button>
87+ </DialogFooter>
88+ </form>
89+ )}
90+ </DialogContent>
91+ </Dialog>
92+ );
93+}
added apps/web/src/components/dashboard/settings/change-password-form.tsx +74 −0
@@ -0,0 +1,74 @@
1+"use client";
2+import * as React from "react";
3+import { authClient } from "@/lib/auth-client";
4+import { recordAuditFromClient } from "@/actions/account";
5+import { PasswordInput, PasswordRequirements, passwordStrong } from "@/components/auth/password-input";
6+import { Button } from "@/components/ui/button";
7+import { Field, FieldError, Hint, Label } from "@/components/ui/label";
8+import { Alert } from "@/components/ui/alert";
9+
10+export function ChangePasswordForm() {
11+ const [current, setCurrent] = React.useState("");
12+ const [next, setNext] = React.useState("");
13+ const [confirm, setConfirm] = React.useState("");
14+ const [revokeOthers, setRevokeOthers] = React.useState(true);
15+ const [error, setError] = React.useState<string | null>(null);
16+ const [done, setDone] = React.useState(false);
17+ const [loading, setLoading] = React.useState(false);
18+
19+ const mismatch = confirm.length > 0 && confirm !== next;
20+ const canSubmit = current.length > 0 && passwordStrong(next) && next === confirm && next !== current;
21+
22+ async function submit(e: React.FormEvent) {
23+ e.preventDefault();
24+ setError(null);
25+ setDone(false);
26+ setLoading(true);
27+ const { error } = await authClient.changePassword({ currentPassword: current, newPassword: next, revokeOtherSessions: revokeOthers });
28+ if (error) {
29+ setLoading(false);
30+ setError(error.status === 400 || error.status === 401 ? "Your current password is incorrect." : error.message ?? "Could not change the password. Try again.");
31+ return;
32+ }
33+ await recordAuditFromClient("password.changed", { revokedOtherSessions: revokeOthers }).catch(() => {});
34+ setLoading(false);
35+ setDone(true);
36+ setCurrent("");
37+ setNext("");
38+ setConfirm("");
39+ }
40+
41+ return (
42+ <form onSubmit={submit} className="grid gap-4 sm:max-w-md" noValidate>
43+ {error ? <Alert variant="danger">{error}</Alert> : null}
44+ {done ? <Alert variant="success">Password changed.{revokeOthers ? " Other devices have been signed out." : ""}</Alert> : null}
45+ <Field>
46+ <Label htmlFor="pw-current">Current password</Label>
47+ <PasswordInput id="pw-current" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
48+ </Field>
49+ <Field>
50+ <Label htmlFor="pw-new">New password</Label>
51+ <PasswordInput id="pw-new" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} required />
52+ <PasswordRequirements password={next} />
53+ {next && current && next === current ? <FieldError>Choose a password different from the current one.</FieldError> : null}
54+ </Field>
55+ <Field>
56+ <Label htmlFor="pw-confirm">Confirm new password</Label>
57+ <PasswordInput id="pw-confirm" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required aria-invalid={mismatch} />
58+ <FieldError>{mismatch ? "Passwords do not match." : null}</FieldError>
59+ </Field>
60+ <label className="flex items-start gap-2 text-[13px] text-fg-muted">
61+ <input type="checkbox" checked={revokeOthers} onChange={(e) => setRevokeOthers(e.target.checked)} className="mt-0.5 size-4 rounded border-border accent-[var(--accent)]" />
62+ <span>
63+ Sign out all other devices
64+ <Hint>Recommended if you suspect your password leaked. This session stays active.</Hint>
65+ </span>
66+ </label>
67+ <div>
68+ <Button type="submit" variant="primary" loading={loading} disabled={!canSubmit}>
69+ Update password
70+ </Button>
71+ </div>
72+ </form>
73+ );
74+}
added apps/web/src/components/dashboard/settings/delete-account-dialog.tsx +83 −0
@@ -0,0 +1,83 @@
1+"use client";
2+import * as React from "react";
3+import { Trash2 } from "lucide-react";
4+import { authClient } from "@/lib/auth-client";
5+import { PasswordInput } from "@/components/auth/password-input";
6+import { Button } from "@/components/ui/button";
7+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
8+import { Field, Label } from "@/components/ui/label";
9+import { Alert } from "@/components/ui/alert";
10+
11+export function DeleteAccountDialog({ email }: { email: string }) {
12+ const [open, setOpen] = React.useState(false);
13+ const [password, setPassword] = React.useState("");
14+ const [error, setError] = React.useState<string | null>(null);
15+ const [requested, setRequested] = React.useState(false);
16+ const [loading, setLoading] = React.useState(false);
17+
18+ async function submit(e: React.FormEvent) {
19+ e.preventDefault();
20+ setError(null);
21+ setLoading(true);
22+ const { error } = await authClient.deleteUser({ password, callbackURL: "/goodbye" });
23+ setLoading(false);
24+ if (error) {
25+ setError(error.status === 400 || error.status === 401 ? "Incorrect password." : error.message ?? "Could not start account deletion. Try again.");
26+ return;
27+ }
28+ setRequested(true);
29+ }
30+
31+ return (
32+ <Dialog
33+ open={open}
34+ onOpenChange={(o) => {
35+ setOpen(o);
36+ if (!o) {
37+ setPassword("");
38+ setError(null);
39+ }
40+ }}
41+ >
42+ <DialogTrigger asChild>
43+ <Button variant="danger" size="sm">
44+ <Trash2 /> Delete account
45+ </Button>
46+ </DialogTrigger>
47+ <DialogContent size="sm">
48+ <DialogHeader>
49+ <DialogTitle>Delete your account</DialogTitle>
50+ <DialogDescription>
51+ This removes your login, organization, projects, API keys and request history. We send a confirmation link to <strong className="text-fg">{email}</strong>; nothing is deleted until you click it. After confirmation, data is purged 7 days later — email support@fetcha.co within that window if you change your mind.
52+ </DialogDescription>
53+ </DialogHeader>
54+ {requested ? (
55+ <>
56+ <Alert variant="success" title="Check your inbox">
57+ We emailed a confirmation link to {email}. Your account stays active until you confirm.
58+ </Alert>
59+ <DialogFooter>
60+ <Button onClick={() => setOpen(false)}>Close</Button>
61+ </DialogFooter>
62+ </>
63+ ) : (
64+ <form onSubmit={submit} className="grid gap-4" noValidate>
65+ {error ? <Alert variant="danger">{error}</Alert> : null}
66+ <Field>
67+ <Label htmlFor="delete-password">Confirm your password</Label>
68+ <PasswordInput id="delete-password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required autoFocus />
69+ </Field>
70+ <DialogFooter>
71+ <Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={loading}>
72+ Cancel
73+ </Button>
74+ <Button type="submit" variant="danger" loading={loading} disabled={!password}>
75+ Send confirmation email
76+ </Button>
77+ </DialogFooter>
78+ </form>
79+ )}
80+ </DialogContent>
81+ </Dialog>
82+ );
83+}
added apps/web/src/components/dashboard/settings/organization-form.tsx +63 −0
@@ -0,0 +1,63 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { updateOrganization } from "@/actions/account";
5+import { Button } from "@/components/ui/button";
6+import { Input } from "@/components/ui/input";
7+import { Field, Hint, Label } from "@/components/ui/label";
8+import { Alert } from "@/components/ui/alert";
9+
10+export function OrganizationForm({ name, softLimitUsd, hardLimitUsd, canEdit }: { name: string; softLimitUsd: number | null; hardLimitUsd: number | null; canEdit: boolean }) {
11+ const router = useRouter();
12+ const [error, setError] = React.useState<string | null>(null);
13+ const [saved, setSaved] = React.useState(false);
14+ const [pending, startTransition] = React.useTransition();
15+
16+ return (
17+ <form
18+ className="grid gap-4"
19+ onSubmit={(e) => {
20+ e.preventDefault();
21+ setError(null);
22+ setSaved(false);
23+ const fd = new FormData(e.currentTarget);
24+ startTransition(async () => {
25+ const res = await updateOrganization(fd);
26+ if (!res.ok) {
27+ setError(res.error);
28+ return;
29+ }
30+ setSaved(true);
31+ router.refresh();
32+ });
33+ }}
34+ >
35+ {!canEdit ? <Alert variant="info">Only owners, admins and billing members can change organization settings.</Alert> : null}
36+ {error ? <Alert variant="danger">{error}</Alert> : null}
37+ {saved ? <Alert variant="success">Organization updated.</Alert> : null}
38+ <Field>
39+ <Label htmlFor="org-name">Organization name</Label>
40+ <Input id="org-name" name="name" defaultValue={name} maxLength={80} required disabled={!canEdit} className="sm:max-w-sm" />
41+ </Field>
42+ <div className="grid gap-4 sm:grid-cols-2 sm:max-w-lg">
43+ <Field>
44+ <Label htmlFor="org-soft">Soft limit (USD / month)</Label>
45+ <Input id="org-soft" name="softLimitUsd" type="number" min={0} step="0.01" inputMode="decimal" defaultValue={softLimitUsd ?? ""} placeholder="No limit" disabled={!canEdit} className="font-mono tabular" />
46+ <Hint>You get an email when total spend crosses this amount.</Hint>
47+ </Field>
48+ <Field>
49+ <Label htmlFor="org-hard">Hard limit (USD / month)</Label>
50+ <Input id="org-hard" name="hardLimitUsd" type="number" min={0} step="0.01" inputMode="decimal" defaultValue={hardLimitUsd ?? ""} placeholder="No limit" disabled={!canEdit} className="font-mono tabular" />
51+ <Hint>All projects stop with USAGE_LIMIT_REACHED until the next month.</Hint>
52+ </Field>
53+ </div>
54+ {canEdit ? (
55+ <div>
56+ <Button type="submit" variant="primary" loading={pending}>
57+ Save organization
58+ </Button>
59+ </div>
60+ ) : null}
61+ </form>
62+ );
63+}
added apps/web/src/components/dashboard/settings/profile-form.tsx +50 −0
@@ -0,0 +1,50 @@
1+"use client";
2+import * as React from "react";
3+import { useRouter } from "next/navigation";
4+import { updateProfileName } from "@/actions/account";
5+import { Button } from "@/components/ui/button";
6+import { Input } from "@/components/ui/input";
7+import { Field, Hint, Label } from "@/components/ui/label";
8+import { Alert } from "@/components/ui/alert";
9+
10+export function ProfileNameForm({ name }: { name: string }) {
11+ const router = useRouter();
12+ const [value, setValue] = React.useState(name);
13+ const [error, setError] = React.useState<string | null>(null);
14+ const [saved, setSaved] = React.useState(false);
15+ const [pending, startTransition] = React.useTransition();
16+ const dirty = value.trim() !== name;
17+
18+ return (
19+ <form
20+ className="grid gap-3"
21+ onSubmit={(e) => {
22+ e.preventDefault();
23+ setError(null);
24+ setSaved(false);
25+ startTransition(async () => {
26+ const res = await updateProfileName(value);
27+ if (!res.ok) {
28+ setError(res.error);
29+ return;
30+ }
31+ setSaved(true);
32+ router.refresh();
33+ });
34+ }}
35+ >
36+ {error ? <Alert variant="danger">{error}</Alert> : null}
37+ {saved ? <Alert variant="success">Name updated.</Alert> : null}
38+ <Field>
39+ <Label htmlFor="profile-name">Full name</Label>
40+ <div className="flex gap-2">
41+ <Input id="profile-name" value={value} onChange={(e) => setValue(e.target.value)} maxLength={80} autoComplete="name" className="sm:max-w-sm" />
42+ <Button type="submit" variant="primary" loading={pending} disabled={!dirty || value.trim().length === 0}>
43+ Save
44+ </Button>
45+ </div>
46+ <Hint>Shown in the dashboard and in emails we send you.</Hint>
47+ </Field>
48+ </form>
49+ );
50+}
added apps/web/src/components/dashboard/settings/settings-nav.tsx +32 −0
@@ -0,0 +1,32 @@
1+"use client";
2+import Link from "next/link";
3+import { usePathname } from "next/navigation";
4+import { cn } from "@/lib/utils";
5+
6+export const SETTINGS_TABS = [
7+ { href: "/dashboard/settings", label: "Profile", exact: true },
8+ { href: "/dashboard/settings/security", label: "Security" },
9+ { href: "/dashboard/settings/audit", label: "Audit log" },
10+ { href: "/dashboard/settings/legal", label: "Legal" },
11+] as const;
12+
13+export function SettingsNav() {
14+ const pathname = usePathname();
15+ return (
16+ <nav className="no-scrollbar flex items-center gap-4 overflow-x-auto border-b border-border text-fg-muted" aria-label="Settings sections">
17+ {SETTINGS_TABS.map((t) => {
18+ const active = "exact" in t && t.exact ? pathname === t.href : pathname === t.href || pathname.startsWith(t.href + "/");
19+ return (
20+ <Link
21+ key={t.href}
22+ href={t.href}
23+ aria-current={active ? "page" : undefined}
24+ className={cn("-mb-px whitespace-nowrap border-b-2 pb-2.5 pt-1 text-[13px] font-medium transition-colors hover:text-fg", active ? "border-fg text-fg" : "border-transparent")}
25+ >
26+ {t.label}
27+ </Link>
28+ );
29+ })}
30+ </nav>
31+ );
32+}
added apps/web/src/components/dashboard/sidebar.tsx +67 −0
@@ -0,0 +1,67 @@
1+"use client";
2+import Link from "next/link";
3+import { usePathname } from "next/navigation";
4+import { Activity, BarChart3, BookOpen, CreditCard, FolderKanban, KeyRound, LayoutDashboard, ListTree, Play, Settings, Shield, Waypoints } from "lucide-react";
5+import { cn } from "@/lib/utils";
6+import { Logo } from "@/components/ui/logo";
7+
8+export const DASHBOARD_NAV = [
9+ { href: "/dashboard", label: "Overview", icon: LayoutDashboard, exact: true },
10+ { href: "/dashboard/playground", label: "Playground", icon: Play },
11+ { href: "/dashboard/api-keys", label: "API Keys", icon: KeyRound },
12+ { href: "/dashboard/projects", label: "Projects", icon: FolderKanban },
13+ { href: "/dashboard/requests", label: "Requests", icon: ListTree },
14+ { href: "/dashboard/sessions", label: "Sessions", icon: Waypoints },
15+ { href: "/dashboard/usage", label: "Usage", icon: Activity },
16+ { href: "/dashboard/analytics", label: "Analytics", icon: BarChart3 },
17+ { href: "/dashboard/billing", label: "Billing", icon: CreditCard },
18+ { href: "/docs", label: "Documentation", icon: BookOpen, external: true },
19+ { href: "/dashboard/settings", label: "Settings", icon: Settings },
20+] as const;
21+
22+export function SidebarNav({ isAdmin, onNavigate, className }: { isAdmin?: boolean; onNavigate?: () => void; className?: string }) {
23+ const pathname = usePathname();
24+ const items = [...DASHBOARD_NAV, ...(isAdmin ? [{ href: "/admin", label: "Admin", icon: Shield } as const] : [])];
25+ return (
26+ <nav className={cn("flex flex-col gap-0.5", className)} aria-label="Dashboard">
27+ {items.map((item) => {
28+ const active = "exact" in item && item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(item.href + "/");
29+ const Icon = item.icon;
30+ return (
31+ <Link
32+ key={item.href}
33+ href={item.href}
34+ onClick={onNavigate}
35+ target={"external" in item && item.external ? "_blank" : undefined}
36+ className={cn(
37+ "group flex h-8 items-center gap-2.5 rounded-md px-2.5 text-[13.5px] font-medium transition-colors",
38+ active ? "bg-bg-muted text-fg" : "text-fg-muted hover:bg-bg-subtle hover:text-fg",
39+ )}
40+ aria-current={active ? "page" : undefined}
41+ >
42+ <Icon className={cn("size-4 shrink-0", active ? "text-fg" : "text-fg-subtle group-hover:text-fg-muted")} aria-hidden />
43+ {item.label}
44+ </Link>
45+ );
46+ })}
47+ </nav>
48+ );
49+}
50+
51+export function Sidebar({ isAdmin }: { isAdmin?: boolean }) {
52+ return (
53+ <aside className="hidden lg:flex w-[232px] shrink-0 flex-col border-r border-border bg-bg-subtle/60">
54+ <div className="flex h-14 items-center px-5">
55+ <Logo href="/dashboard" size="sm" />
56+ </div>
57+ <div className="flex-1 overflow-y-auto px-3 pb-4 scrollbar-thin">
58+ <SidebarNav isAdmin={isAdmin} />
59+ </div>
60+ <div className="border-t border-border px-4 py-3 text-[11.5px] text-fg-subtle">
61+ <a href="/status" className="hover:text-fg" target="_blank" rel="noreferrer">
62+ <span className="mr-1.5 inline-block size-1.5 rounded-full bg-success align-middle" /> All systems operational
63+ </a>
64+ </div>
65+ </aside>
66+ );
67+}
added apps/web/src/components/dashboard/topbar.tsx +116 −0
@@ -0,0 +1,116 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { useRouter } from "next/navigation";
5+import { Check, ChevronsUpDown, LogOut, Menu, Plus, Settings, User } from "lucide-react";
6+import { authClient } from "@/lib/auth-client";
7+import { selectProject } from "@/actions/projects";
8+import { Button } from "@/components/ui/button";
9+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
10+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
11+import { ThemeToggle } from "@/components/ui/theme";
12+import { Logo } from "@/components/ui/logo";
13+import { SidebarNav } from "./sidebar";
14+import { cn } from "@/lib/utils";
15+
16+export interface TopbarProps {
17+ user: { name: string; email: string; image?: string | null };
18+ organization: { id: string; name: string; plan: string };
19+ projects: Array<{ id: string; name: string; environment: string }>;
20+ currentProjectId: string;
21+ isAdmin: boolean;
22+}
23+
24+export function Topbar({ user, organization, projects, currentProjectId, isAdmin }: TopbarProps) {
25+ const router = useRouter();
26+ const [open, setOpen] = React.useState(false);
27+ const [pending, startTransition] = React.useTransition();
28+ const current = projects.find((p) => p.id === currentProjectId) ?? projects[0];
29+
30+ return (
31+ <header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-bg/85 px-4 backdrop-blur sm:px-6">
32+ <Button variant="ghost" size="icon-sm" className="lg:hidden -ml-1.5" onClick={() => setOpen(true)} aria-label="Open navigation">
33+ <Menu />
34+ </Button>
35+ <div className="lg:hidden">
36+ <Logo href="/dashboard" size="sm" />
37+ </div>
38+ <div className="hidden items-center gap-1.5 text-[13px] text-fg-muted lg:flex">
39+ <span className="truncate max-w-[180px]">{organization.name}</span>
40+ <span className="text-fg-subtle">/</span>
41+ </div>
42+ <DropdownMenu>
43+ <DropdownMenuTrigger asChild>
44+ <Button variant="outline" size="sm" className={cn("gap-2 font-medium max-w-[220px]", pending && "opacity-70")} aria-label="Switch project">
45+ <span className={cn("size-1.5 rounded-full", current?.environment === "development" ? "bg-warning" : "bg-success")} />
46+ <span className="truncate">{current?.name ?? "Project"}</span>
47+ <ChevronsUpDown className="size-3.5 text-fg-subtle" />
48+ </Button>
49+ </DropdownMenuTrigger>
50+ <DropdownMenuContent align="start" className="w-64">
51+ <DropdownMenuLabel>Projects</DropdownMenuLabel>
52+ {projects.map((p) => (
53+ <DropdownMenuItem
54+ key={p.id}
55+ onClick={() => startTransition(async () => { await selectProject(p.id); router.refresh(); })}
56+ className="justify-between"
57+ >
58+ <span className="flex items-center gap-2 truncate">
59+ <span className={cn("size-1.5 rounded-full", p.environment === "development" ? "bg-warning" : "bg-success")} />
60+ <span className="truncate">{p.name}</span>
61+ </span>
62+ {p.id === currentProjectId ? <Check className="size-4" /> : null}
63+ </DropdownMenuItem>
64+ ))}
65+ <DropdownMenuSeparator />
66+ <DropdownMenuItem asChild>
67+ <Link href="/dashboard/projects?new=1"><Plus /> New project</Link>
68+ </DropdownMenuItem>
69+ </DropdownMenuContent>
70+ </DropdownMenu>
71+ <span className="hidden sm:inline-flex rounded-full border border-border px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-fg-subtle">{organization.plan}</span>
72+
73+ <div className="ml-auto flex items-center gap-1.5">
74+ <Button asChild variant="ghost" size="sm" className="hidden sm:inline-flex text-fg-muted">
75+ <Link href="/docs">Docs</Link>
76+ </Button>
77+ <ThemeToggle />
78+ <DropdownMenu>
79+ <DropdownMenuTrigger asChild>
80+ <button className="flex size-8 items-center justify-center rounded-full bg-fg text-bg text-[12px] font-semibold uppercase ring-offset-bg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" aria-label="Account menu">
81+ {(user.name || user.email).slice(0, 1)}
82+ </button>
83+ </DropdownMenuTrigger>
84+ <DropdownMenuContent align="end" className="w-60">
85+ <div className="px-2 py-2">
86+ <div className="truncate text-[13px] font-medium">{user.name || "Account"}</div>
87+ <div className="truncate text-xs text-fg-muted">{user.email}</div>
88+ </div>
89+ <DropdownMenuSeparator />
90+ <DropdownMenuItem asChild><Link href="/dashboard/settings"><User /> Profile</Link></DropdownMenuItem>
91+ <DropdownMenuItem asChild><Link href="/dashboard/settings/security"><Settings /> Security</Link></DropdownMenuItem>
92+ {isAdmin ? <DropdownMenuItem asChild><Link href="/admin"><Settings /> Admin</Link></DropdownMenuItem> : null}
93+ <DropdownMenuSeparator />
94+ <DropdownMenuItem
95+ onClick={async () => {
96+ await authClient.signOut();
97+ router.push("/login");
98+ router.refresh();
99+ }}
100+ >
101+ <LogOut /> Sign out
102+ </DropdownMenuItem>
103+ </DropdownMenuContent>
104+ </DropdownMenu>
105+ </div>
106+
107+ <Dialog open={open} onOpenChange={setOpen}>
108+ <DialogContent size="sm" className="left-0 top-0 h-dvh max-w-[280px] translate-x-0 translate-y-0 rounded-none border-r p-4 data-[state=open]:animate-fade-in">
109+ <DialogTitle className="sr-only">Navigation</DialogTitle>
110+ <div className="mb-4 px-1"><Logo href="/dashboard" size="sm" /></div>
111+ <SidebarNav isAdmin={isAdmin} onNavigate={() => setOpen(false)} />
112+ </DialogContent>
113+ </Dialog>
114+ </header>
115+ );
116+}
added apps/web/src/components/docs/callout.tsx +31 −0
@@ -0,0 +1,31 @@
1+import * as React from "react";
2+import { Alert } from "@/components/ui/alert";
3+import { cn } from "@/lib/utils";
4+
5+/** Documentation callout built on the shared `Alert`. */
6+export function Callout({
7+ variant = "info",
8+ title,
9+ children,
10+ className,
11+}: {
12+ variant?: "info" | "success" | "warning" | "danger";
13+ title?: React.ReactNode;
14+ children?: React.ReactNode;
15+ className?: string;
16+}) {
17+ return (
18+ <Alert variant={variant} title={title} className={cn("my-5 [&_code]:rounded-[4px] [&_code]:border [&_code]:border-border [&_code]:bg-bg/60 [&_code]:px-1 [&_code]:py-px [&_code]:font-mono [&_code]:text-[0.88em] [&_a]:font-medium [&_a]:text-accent [&_a]:underline [&_a]:underline-offset-2", className)}>
19+ {children}
20+ </Alert>
21+ );
22+}
23+
24+/** Standard notice for features that are not yet available. */
25+export function ComingSoon({ children, title = "Not yet available" }: { children?: React.ReactNode; title?: React.ReactNode }) {
26+ return (
27+ <Callout variant="warning" title={title}>
28+ {children ?? "This capability is planned but not live yet. The documentation below describes the intended design so you can plan ahead; nothing on this page is callable today."}
29+ </Callout>
30+ );
31+}
added apps/web/src/components/docs/code-tabs.tsx +64 −0
@@ -0,0 +1,64 @@
1+"use client";
2+import * as React from "react";
3+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
4+import { CodeBlock, type CodeLang } from "@/components/ui/code-block";
5+import { cn } from "@/lib/utils";
6+
7+export interface CodeTab {
8+ /** Tab label, e.g. "cURL". Also used as the tab value. */
9+ label: string;
10+ lang: CodeLang;
11+ code: string;
12+ title?: string;
13+}
14+
15+const STORAGE_KEY = "fetcha-docs-lang";
16+const EVENT = "fetcha-docs-lang-change";
17+
18+/**
19+ * Language switcher for code samples. The selected language is remembered in localStorage
20+ * and synchronised across every CodeTabs instance on the page.
21+ */
22+export function CodeTabs({ tabs, className, maxHeight, lineNumbers }: { tabs: CodeTab[]; className?: string; maxHeight?: number | string; lineNumbers?: boolean }) {
23+ const labels = React.useMemo(() => tabs.map((t) => t.label), [tabs]);
24+ const [value, setValue] = React.useState(labels[0] ?? "");
25+
26+ React.useEffect(() => {
27+ const apply = (stored: string | null) => {
28+ if (stored && labels.includes(stored)) setValue(stored);
29+ };
30+ try {
31+ apply(window.localStorage.getItem(STORAGE_KEY));
32+ } catch {}
33+ const onChange = (e: Event) => apply((e as CustomEvent<string>).detail);
34+ window.addEventListener(EVENT, onChange);
35+ return () => window.removeEventListener(EVENT, onChange);
36+ }, [labels]);
37+
38+ const onValueChange = (v: string) => {
39+ setValue(v);
40+ try {
41+ window.localStorage.setItem(STORAGE_KEY, v);
42+ } catch {}
43+ window.dispatchEvent(new CustomEvent(EVENT, { detail: v }));
44+ };
45+
46+ if (!tabs.length) return null;
47+
48+ return (
49+ <Tabs value={value} onValueChange={onValueChange} className={cn("my-5", className)}>
50+ <TabsList variant="underline" aria-label="Code language" className="gap-3">
51+ {tabs.map((t) => (
52+ <TabsTrigger key={t.label} value={t.label} className="text-[12.5px]">
53+ {t.label}
54+ </TabsTrigger>
55+ ))}
56+ </TabsList>
57+ {tabs.map((t) => (
58+ <TabsContent key={t.label} value={t.label} className="mt-3">
59+ <CodeBlock code={t.code} lang={t.lang} title={t.title} maxHeight={maxHeight} lineNumbers={lineNumbers} />
60+ </TabsContent>
61+ ))}
62+ </Tabs>
63+ );
64+}
added apps/web/src/components/docs/doc-page.tsx +84 −0
@@ -0,0 +1,84 @@
1+import * as React from "react";
2+import Link from "next/link";
3+import { ArrowLeft, ArrowRight } from "lucide-react";
4+import { Badge } from "@/components/ui/badge";
5+import { cn } from "@/lib/utils";
6+import { getPrevNext } from "./nav-config";
7+
8+export type DocStatus = "Stable" | "Beta" | "Coming soon" | "Reference";
9+
10+const STATUS_VARIANT: Record<DocStatus, "success" | "info" | "warning" | "default"> = {
11+ Stable: "success",
12+ Beta: "info",
13+ "Coming soon": "warning",
14+ Reference: "default",
15+};
16+
17+export function DocPage({
18+ path,
19+ eyebrow,
20+ title,
21+ description,
22+ status,
23+ children,
24+ className,
25+}: {
26+ /** Route of this page, e.g. `/docs/fetch`. Used for prev/next links. */
27+ path: string;
28+ eyebrow?: string;
29+ title: string;
30+ description?: React.ReactNode;
31+ status?: DocStatus;
32+ children: React.ReactNode;
33+ className?: string;
34+}) {
35+ const { prev, next } = getPrevNext(path);
36+ return (
37+ <article data-docs-article className={cn("min-w-0 animate-fade-in", className)}>
38+ <header className="border-b border-border pb-6">
39+ <div className="flex flex-wrap items-center gap-2 text-[12px] font-medium uppercase tracking-wide text-fg-subtle">
40+ <span>{eyebrow ?? "Documentation"}</span>
41+ {status ? (
42+ <Badge variant={STATUS_VARIANT[status]} dot>
43+ {status}
44+ </Badge>
45+ ) : null}
46+ </div>
47+ <h1 className="mt-2 text-[30px] font-semibold leading-tight tracking-tight text-fg sm:text-[34px]">{title}</h1>
48+ {description ? <p className="mt-3 max-w-2xl text-[16px] leading-relaxed text-fg-muted">{description}</p> : null}
49+ </header>
50+
51+ <div className="pt-6">{children}</div>
52+
53+ <footer className="mt-14 border-t border-border pt-6">
54+ <div className="grid gap-3 sm:grid-cols-2">
55+ {prev ? (
56+ <Link href={prev.href} className="group flex flex-col rounded-lg border border-border p-4 transition-colors hover:border-border-strong hover:bg-bg-subtle">
57+ <span className="flex items-center gap-1 text-[12px] text-fg-subtle">
58+ <ArrowLeft className="size-3.5" aria-hidden /> Previous
59+ </span>
60+ <span className="mt-1 text-[14.5px] font-medium text-fg">{prev.title}</span>
61+ </Link>
62+ ) : (
63+ <span />
64+ )}
65+ {next ? (
66+ <Link href={next.href} className="group flex flex-col items-end rounded-lg border border-border p-4 text-right transition-colors hover:border-border-strong hover:bg-bg-subtle">
67+ <span className="flex items-center gap-1 text-[12px] text-fg-subtle">
68+ Next <ArrowRight className="size-3.5" aria-hidden />
69+ </span>
70+ <span className="mt-1 text-[14.5px] font-medium text-fg">{next.title}</span>
71+ </Link>
72+ ) : null}
73+ </div>
74+ <p className="mt-6 text-[12.5px] text-fg-subtle">
75+ Questions or a mistake in this page? Email{" "}
76+ <a href="mailto:support@fetcha.co" className="text-fg-muted underline underline-offset-2 hover:text-fg">
77+ support@fetcha.co
78+ </a>{" "}
79+ and include the request ID when it concerns a specific call.
80+ </p>
81+ </footer>
82+ </article>
83+ );
84+}
added apps/web/src/components/docs/endpoint.tsx +49 −0
@@ -0,0 +1,49 @@
1+import * as React from "react";
2+import { cn } from "@/lib/utils";
3+import { Badge } from "@/components/ui/badge";
4+
5+type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
6+
7+const METHOD_CLASS: Record<Method, string> = {
8+ GET: "bg-success-soft text-success",
9+ POST: "bg-accent-soft text-accent",
10+ PUT: "bg-warning-soft text-warning",
11+ PATCH: "bg-warning-soft text-warning",
12+ DELETE: "bg-danger-soft text-danger",
13+};
14+
15+export function Endpoint({
16+ method,
17+ path,
18+ scope,
19+ status,
20+ className,
21+}: {
22+ method: Method;
23+ path: string;
24+ /** Required API key scope, if any. */
25+ scope?: string | null;
26+ status?: "Live" | "Coming soon";
27+ className?: string;
28+}) {
29+ return (
30+ <div className={cn("my-5 flex flex-wrap items-center gap-3 rounded-lg border border-border bg-bg-subtle px-3.5 py-2.5", className)}>
31+ <span className={cn("rounded-[5px] px-2 py-0.5 font-mono text-[12px] font-semibold tracking-wide", METHOD_CLASS[method])}>{method}</span>
32+ <code className="font-mono text-[13.5px] text-fg">{path}</code>
33+ <span className="ml-auto flex items-center gap-2">
34+ {scope ? (
35+ <span className="text-[12px] text-fg-subtle">
36+ scope <code className="rounded-[4px] border border-border bg-bg px-1.5 py-px font-mono text-[11.5px] text-fg-muted">{scope}</code>
37+ </span>
38+ ) : scope === null ? (
39+ <span className="text-[12px] text-fg-subtle">any valid key</span>
40+ ) : null}
41+ {status ? (
42+ <Badge variant={status === "Live" ? "success" : "warning"} dot>
43+ {status}
44+ </Badge>
45+ ) : null}
46+ </span>
47+ </div>
48+ );
49+}
added apps/web/src/components/docs/index.ts +9 −0
@@ -0,0 +1,9 @@
1+export * from "./nav-config";
2+export * from "./doc-page";
3+export * from "./prose";
4+export * from "./param-table";
5+export * from "./endpoint";
6+export * from "./callout";
7+export * from "./response-example";
8+export { CodeTabs, type CodeTab } from "./code-tabs";
9+export * from "./snippets";
added apps/web/src/components/docs/nav-config.ts +62 −0
@@ -0,0 +1,62 @@
1+export interface DocsNavLink {
2+ title: string;
3+ href: string;
4+ /** External links open in the same tab but are excluded from prev/next navigation. */
5+ external?: boolean;
6+ badge?: "Coming soon" | "Beta";
7+}
8+
9+export interface DocsNavSection {
10+ title: string;
11+ links: DocsNavLink[];
12+}
13+
14+export const DOCS_NAV: DocsNavSection[] = [
15+ {
16+ title: "Getting started",
17+ links: [
18+ { title: "Introduction", href: "/docs" },
19+ { title: "Quickstart", href: "/docs/quickstart" },
20+ { title: "Authentication", href: "/docs/authentication" },
21+ ],
22+ },
23+ {
24+ title: "Core API",
25+ links: [
26+ { title: "Fetch API", href: "/docs/fetch" },
27+ { title: "Network Selection", href: "/docs/networks" },
28+ { title: "Geolocation", href: "/docs/geolocation" },
29+ { title: "Sessions", href: "/docs/sessions" },
30+ { title: "Browser", href: "/docs/browser", badge: "Coming soon" },
31+ { title: "Extraction", href: "/docs/extraction", badge: "Coming soon" },
32+ ],
33+ },
34+ {
35+ title: "Reliability",
36+ links: [
37+ { title: "Errors", href: "/docs/errors" },
38+ { title: "Retries", href: "/docs/retries" },
39+ { title: "Webhooks", href: "/docs/webhooks", badge: "Coming soon" },
40+ { title: "Rate Limits", href: "/docs/rate-limits" },
41+ ],
42+ },
43+ {
44+ title: "Tools",
45+ links: [
46+ { title: "SDKs", href: "/docs/sdks" },
47+ { title: "Examples", href: "/docs/examples" },
48+ { title: "Changelog", href: "/changelog", external: true },
49+ ],
50+ },
51+];
52+
53+/** Flat, ordered list of internal doc pages (used for prev/next footers). */
54+export const DOCS_PAGES: DocsNavLink[] = DOCS_NAV.flatMap((s) => s.links).filter((l) => !l.external);
55+
56+export function getPrevNext(href: string): { prev: DocsNavLink | null; next: DocsNavLink | null } {
57+ const i = DOCS_PAGES.findIndex((p) => p.href === href);
58+ if (i === -1) return { prev: null, next: null };
59+ return { prev: DOCS_PAGES[i - 1] ?? null, next: DOCS_PAGES[i + 1] ?? null };
60+}
61+
62+export const DOCS_BASE_URL = "https://www.fetcha.co";
added apps/web/src/components/docs/param-table.tsx +59 −0
@@ -0,0 +1,59 @@
1+import * as React from "react";
2+import { cn } from "@/lib/utils";
3+
4+export interface ParamRow {
5+ name: string;
6+ type: string;
7+ /** Rendered in monospace. Omit for "—". */
8+ default?: string;
9+ required?: boolean;
10+ /** Free-form description; may contain inline components. */
11+ description: React.ReactNode;
12+ /** Small note under the type, e.g. constraints. */
13+ constraints?: string;
14+ /** Marks a field the validator accepts but the platform does not act on yet. */
15+ reserved?: boolean;
16+}
17+
18+export function ParamTable({ rows, className, caption, showDefault = true }: { rows: ParamRow[]; className?: string; caption?: string; showDefault?: boolean }) {
19+ return (
20+ <div className={cn("my-5 overflow-x-auto rounded-lg border border-border scrollbar-thin", className)}>
21+ <table className="w-full border-collapse text-left text-[13.5px] leading-relaxed">
22+ {caption ? <caption className="border-b border-border bg-bg-subtle px-3.5 py-2 text-left text-[12px] font-medium text-fg-muted">{caption}</caption> : null}
23+ <thead className="bg-bg-subtle text-[12px] font-medium uppercase tracking-wide text-fg-subtle">
24+ <tr>
25+ <th className="px-3.5 py-2 font-medium">Field</th>
26+ <th className="px-3.5 py-2 font-medium">Type</th>
27+ {showDefault ? <th className="px-3.5 py-2 font-medium">Default</th> : null}
28+ <th className="px-3.5 py-2 font-medium">Description</th>
29+ </tr>
30+ </thead>
31+ <tbody className="divide-y divide-border">
32+ {rows.map((r) => (
33+ <tr key={r.name} className="align-top">
34+ <td className="whitespace-nowrap px-3.5 py-2.5">
35+ <code className="font-mono text-[12.5px] font-medium text-fg">{r.name}</code>
36+ {r.required ? (
37+ <span className="ml-1.5 align-middle text-[10.5px] font-semibold uppercase tracking-wide text-danger" title="Required">
38+ required
39+ </span>
40+ ) : null}
41+ {r.reserved ? (
42+ <span className="ml-1.5 align-middle rounded-full bg-warning-soft px-1.5 py-px text-[10.5px] font-medium text-warning" title="Accepted by the validator but not yet active">
43+ reserved
44+ </span>
45+ ) : null}
46+ </td>
47+ <td className="px-3.5 py-2.5">
48+ <code className="font-mono text-[12.5px] text-fg-muted">{r.type}</code>
49+ {r.constraints ? <div className="mt-0.5 text-[12px] text-fg-subtle">{r.constraints}</div> : null}
50+ </td>
51+ {showDefault ? <td className="whitespace-nowrap px-3.5 py-2.5 font-mono text-[12.5px] text-fg-muted">{r.default ?? <span className="text-fg-subtle">&mdash;</span>}</td> : null}
52+ <td className="px-3.5 py-2.5 text-fg-muted [&_code]:rounded-[4px] [&_code]:border [&_code]:border-border [&_code]:bg-bg-muted [&_code]:px-1 [&_code]:py-px [&_code]:font-mono [&_code]:text-[0.88em] [&_code]:text-fg">{r.description}</td>
53+ </tr>
54+ ))}
55+ </tbody>
56+ </table>
57+ </div>
58+ );
59+}
added apps/web/src/components/docs/prose.tsx +191 −0
@@ -0,0 +1,191 @@
1+import * as React from "react";
2+import Link from "next/link";
3+import { cn } from "@/lib/utils";
4+import { InlineCode } from "@/components/ui/code-block";
5+
6+/**
7+ * Prose primitives for the documentation. Implemented with plain Tailwind classes
8+ * (no typography plugin) so the docs share the exact tokens used by the product.
9+ */
10+
11+function textOf(node: React.ReactNode): string {
12+ if (node == null || typeof node === "boolean") return "";
13+ if (typeof node === "string" || typeof node === "number") return String(node);
14+ if (Array.isArray(node)) return node.map(textOf).join("");
15+ if (React.isValidElement<{ children?: React.ReactNode }>(node)) return textOf(node.props.children);
16+ return "";
17+}
18+
19+export function headingId(children: React.ReactNode, explicit?: string): string {
20+ if (explicit) return explicit;
21+ return (
22+ textOf(children)
23+ .normalize("NFD")
24+ .replace(/[̀-ͯ]/g, "")
25+ .toLowerCase()
26+ .trim()
27+ .replace(/[^a-z0-9]+/g, "-")
28+ .replace(/^-+|-+$/g, "")
29+ .slice(0, 80) || "section"
30+ );
31+}
32+
33+function AnchorLink({ id }: { id: string }) {
34+ return (
35+ <a
36+ href={`#${id}`}
37+ aria-label="Link to this section"
38+ className="ml-2 inline-block select-none text-fg-subtle/0 transition-colors group-hover:text-fg-subtle focus-visible:text-fg-subtle"
39+ >
40+ #
41+ </a>
42+ );
43+}
44+
45+export function H2({ id, children, className }: { id?: string; children: React.ReactNode; className?: string }) {
46+ const hid = headingId(children, id);
47+ return (
48+ <h2 id={hid} data-toc data-toc-level="2" className={cn("group mt-12 mb-4 scroll-mt-24 text-[20px] font-semibold tracking-tight text-fg first:mt-0", className)}>
49+ {children}
50+ <AnchorLink id={hid} />
51+ </h2>
52+ );
53+}
54+
55+export function H3({ id, children, className }: { id?: string; children: React.ReactNode; className?: string }) {
56+ const hid = headingId(children, id);
57+ return (
58+ <h3 id={hid} data-toc data-toc-level="3" className={cn("group mt-8 mb-3 scroll-mt-24 text-[15.5px] font-semibold tracking-tight text-fg", className)}>
59+ {children}
60+ <AnchorLink id={hid} />
61+ </h3>
62+ );
63+}
64+
65+export function H4({ children, className }: { children: React.ReactNode; className?: string }) {
66+ return <h4 className={cn("mt-6 mb-2 text-[13.5px] font-semibold text-fg", className)}>{children}</h4>;
67+}
68+
69+export function P({ children, className }: { children: React.ReactNode; className?: string }) {
70+ return <p className={cn("my-4 text-[14.5px] leading-[1.7] text-fg-muted", className)}>{children}</p>;
71+}
72+
73+export function Lead({ children, className }: { children: React.ReactNode; className?: string }) {
74+ return <p className={cn("my-4 text-[16px] leading-[1.65] text-fg-muted", className)}>{children}</p>;
75+}
76+
77+export function Strong({ children }: { children: React.ReactNode }) {
78+ return <strong className="font-medium text-fg">{children}</strong>;
79+}
80+
81+export function Ul({ children, className }: { children: React.ReactNode; className?: string }) {
82+ return <ul className={cn("my-4 list-disc space-y-1.5 pl-5 text-[14.5px] leading-[1.7] text-fg-muted marker:text-fg-subtle", className)}>{children}</ul>;
83+}
84+
85+export function Ol({ children, className }: { children: React.ReactNode; className?: string }) {
86+ return <ol className={cn("my-4 list-decimal space-y-1.5 pl-5 text-[14.5px] leading-[1.7] text-fg-muted marker:text-fg-subtle marker:tabular", className)}>{children}</ol>;
87+}
88+
89+export function Li({ children, className }: { children: React.ReactNode; className?: string }) {
90+ return <li className={cn("pl-0.5", className)}>{children}</li>;
91+}
92+
93+export function Code({ children }: { children: React.ReactNode }) {
94+ return <InlineCode>{children}</InlineCode>;
95+}
96+
97+export function A({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) {
98+ const external = /^https?:\/\//.test(href) || href.startsWith("mailto:");
99+ const cls = cn("font-medium text-accent underline decoration-accent/30 underline-offset-[3px] transition-colors hover:decoration-accent", className);
100+ if (external) {
101+ return (
102+ <a href={href} className={cls} target={href.startsWith("mailto:") ? undefined : "_blank"} rel="noreferrer">
103+ {children}
104+ </a>
105+ );
106+ }
107+ return (
108+ <Link href={href} className={cls}>
109+ {children}
110+ </Link>
111+ );
112+}
113+
114+export function Kbd({ children }: { children: React.ReactNode }) {
115+ return <kbd className="rounded-[4px] border border-border bg-bg-muted px-1.5 py-0.5 font-mono text-[0.8em] text-fg">{children}</kbd>;
116+}
117+
118+export function Hr() {
119+ return <hr className="my-10 border-border" />;
120+}
121+
122+/* ---------------------------------------------------------------------------------------------- */
123+/* Tables */
124+/* ---------------------------------------------------------------------------------------------- */
125+
126+export function Table({ children, className, dense }: { children: React.ReactNode; className?: string; dense?: boolean }) {
127+ return (
128+ <div className={cn("my-5 overflow-x-auto rounded-lg border border-border scrollbar-thin", className)}>
129+ <table className={cn("w-full border-collapse text-left text-[13.5px] leading-relaxed", dense && "text-[13px]")}>{children}</table>
130+ </div>
131+ );
132+}
133+
134+export function THead({ children }: { children: React.ReactNode }) {
135+ return <thead className="bg-bg-subtle text-[12px] font-medium uppercase tracking-wide text-fg-subtle">{children}</thead>;
136+}
137+
138+export function TBody({ children }: { children: React.ReactNode }) {
139+ return <tbody className="divide-y divide-border">{children}</tbody>;
140+}
141+
142+export function Tr({ children, className }: { children: React.ReactNode; className?: string }) {
143+ return <tr className={cn("align-top", className)}>{children}</tr>;
144+}
145+
146+export function Th({ children, className }: { children?: React.ReactNode; className?: string }) {
147+ return <th className={cn("px-3.5 py-2 font-medium", className)}>{children}</th>;
148+}
149+
150+export function Td({ children, className, mono }: { children?: React.ReactNode; className?: string; mono?: boolean }) {
151+ return <td className={cn("px-3.5 py-2.5 text-fg-muted", mono && "font-mono text-[12.5px] text-fg", className)}>{children}</td>;
152+}
153+
154+/* ---------------------------------------------------------------------------------------------- */
155+/* Small layout helpers */
156+/* ---------------------------------------------------------------------------------------------- */
157+
158+export function Steps({ children }: { children: React.ReactNode }) {
159+ return <ol className="my-6 space-y-0 border-l border-border pl-0 [counter-reset:step]">{children}</ol>;
160+}
161+
162+export function Step({ title, children }: { title: React.ReactNode; children?: React.ReactNode }) {
163+ return (
164+ <li className="relative pb-8 pl-8 last:pb-0 [counter-increment:step]">
165+ <span
166+ aria-hidden
167+ className="absolute -left-[13px] top-0 flex size-[26px] items-center justify-center rounded-full border border-border bg-bg font-mono text-[11.5px] font-medium text-fg-muted tabular before:content-[counter(step)]"
168+ />
169+ <div className="text-[15px] font-semibold text-fg">{title}</div>
170+ {children ? <div className="mt-1 [&>p]:my-2 [&>ul]:my-2">{children}</div> : null}
171+ </li>
172+ );
173+}
174+
175+export function CardGrid({ children, className }: { children: React.ReactNode; className?: string }) {
176+ return <div className={cn("my-6 grid gap-3 sm:grid-cols-2", className)}>{children}</div>;
177+}
178+
179+export function LinkCard({ href, title, description }: { href: string; title: string; description: string }) {
180+ return (
181+ <Link href={href} className="group rounded-lg border border-border bg-bg p-4 transition-colors hover:border-border-strong hover:bg-bg-subtle">
182+ <div className="flex items-center justify-between gap-2 text-[14px] font-medium text-fg">
183+ {title}
184+ <span aria-hidden className="text-fg-subtle transition-transform group-hover:translate-x-0.5">
185+ &rarr;
186+ </span>
187+ </div>
188+ <p className="mt-1 text-[13px] leading-relaxed text-fg-muted">{description}</p>
189+ </Link>
190+ );
191+}
added apps/web/src/components/docs/response-example.tsx +45 −0
@@ -0,0 +1,45 @@
1+import * as React from "react";
2+import { CodeBlock, type CodeLang } from "@/components/ui/code-block";
3+import { cn } from "@/lib/utils";
4+
5+/**
6+ * Renders an API response. Accepts either a JSON-serialisable value or a pre-formatted string.
7+ * The status line is coloured by class (2xx success, 4xx warning, 5xx danger).
8+ */
9+export function ResponseExample({
10+ status = 200,
11+ statusText,
12+ body,
13+ lang = "json",
14+ title,
15+ className,
16+ maxHeight = 480,
17+}: {
18+ status?: number;
19+ statusText?: string;
20+ body: unknown;
21+ lang?: CodeLang;
22+ title?: string;
23+ className?: string;
24+ maxHeight?: number | string;
25+}) {
26+ const code = typeof body === "string" ? body : JSON.stringify(body, null, 2);
27+ const label = title ?? `${status}${statusText ? ` ${statusText}` : status === 200 ? " OK" : ""}`;
28+ const tone = status >= 500 ? "danger" : status >= 400 ? "warning" : "success";
29+ return (
30+ <div className={cn("my-5", className)}>
31+ <CodeBlock
32+ code={code}
33+ lang={lang}
34+ title={label}
35+ maxHeight={maxHeight}
36+ className={cn(
37+ "[&>div:first-child>span]:font-medium",
38+ tone === "success" && "[&>div:first-child>span]:text-success",
39+ tone === "warning" && "[&>div:first-child>span]:text-warning",
40+ tone === "danger" && "[&>div:first-child>span]:text-danger",
41+ )}
42+ />
43+ </div>
44+ );
45+}
added apps/web/src/components/docs/sidebar.tsx +109 −0
@@ -0,0 +1,109 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { usePathname } from "next/navigation";
5+import { ChevronRight, ExternalLink, Menu, X } from "lucide-react";
6+import { Button } from "@/components/ui/button";
7+import { cn } from "@/lib/utils";
8+import { DOCS_NAV, type DocsNavLink } from "./nav-config";
9+
10+function NavLinks({ onNavigate, className }: { onNavigate?: () => void; className?: string }) {
11+ const pathname = usePathname();
12+ return (
13+ <nav aria-label="Documentation" className={cn("text-[13.5px]", className)}>
14+ {DOCS_NAV.map((section) => (
15+ <div key={section.title} className="mb-6 last:mb-0">
16+ <div className="mb-1.5 px-2 text-[11.5px] font-semibold uppercase tracking-wide text-fg-subtle">{section.title}</div>
17+ <ul className="space-y-px">
18+ {section.links.map((l) => (
19+ <li key={l.href}>
20+ <NavItem link={l} active={pathname === l.href} onNavigate={onNavigate} />
21+ </li>
22+ ))}
23+ </ul>
24+ </div>
25+ ))}
26+ </nav>
27+ );
28+}
29+
30+function NavItem({ link, active, onNavigate }: { link: DocsNavLink; active: boolean; onNavigate?: () => void }) {
31+ return (
32+ <Link
33+ href={link.href}
34+ onClick={onNavigate}
35+ aria-current={active ? "page" : undefined}
36+ className={cn(
37+ "flex items-center justify-between gap-2 rounded-md px-2 py-1.5 transition-colors",
38+ active ? "bg-accent-soft font-medium text-accent" : "text-fg-muted hover:bg-bg-muted hover:text-fg",
39+ )}
40+ >
41+ <span className="truncate">{link.title}</span>
42+ {link.badge ? <span className="shrink-0 rounded-full bg-warning-soft px-1.5 py-px text-[10.5px] font-medium text-warning">{link.badge}</span> : null}
43+ {link.external ? <ExternalLink className="size-3.5 shrink-0 text-fg-subtle" aria-hidden /> : null}
44+ </Link>
45+ );
46+}
47+
48+/** Desktop sidebar: sticky under the marketing header. */
49+export function DocsSidebar({ className }: { className?: string }) {
50+ return (
51+ <aside className={cn("sticky top-14 hidden max-h-[calc(100dvh-3.5rem)] overflow-y-auto py-8 pr-4 scrollbar-thin lg:block", className)}>
52+ <NavLinks />
53+ </aside>
54+ );
55+}
56+
57+/** Mobile: compact bar with the current page and a drawer with the full navigation. */
58+export function DocsMobileNav() {
59+ const [open, setOpen] = React.useState(false);
60+ const pathname = usePathname();
61+ const current = React.useMemo(() => DOCS_NAV.flatMap((s) => s.links.map((l) => ({ ...l, section: s.title }))).find((l) => l.href === pathname), [pathname]);
62+
63+ React.useEffect(() => {
64+ if (!open) return;
65+ const onKey = (e: KeyboardEvent) => {
66+ if (e.key === "Escape") setOpen(false);
67+ };
68+ document.addEventListener("keydown", onKey);
69+ const prev = document.body.style.overflow;
70+ document.body.style.overflow = "hidden";
71+ return () => {
72+ document.removeEventListener("keydown", onKey);
73+ document.body.style.overflow = prev;
74+ };
75+ }, [open]);
76+
77+ return (
78+ <div className="lg:hidden">
79+ <div className="-mx-5 flex items-center gap-2 border-b border-border bg-bg-subtle/60 px-5 py-2.5 text-[13px] sm:-mx-8 sm:px-8">
80+ <Button variant="outline" size="sm" onClick={() => setOpen(true)} aria-expanded={open} aria-controls="docs-drawer" className="gap-1.5">
81+ <Menu className="size-4" aria-hidden />
82+ Menu
83+ </Button>
84+ <span className="flex min-w-0 items-center gap-1 text-fg-muted">
85+ <span className="truncate">{current?.section ?? "Docs"}</span>
86+ <ChevronRight className="size-3.5 shrink-0 text-fg-subtle" aria-hidden />
87+ <span className="truncate font-medium text-fg">{current?.title ?? "Documentation"}</span>
88+ </span>
89+ </div>
90+
91+ {open ? (
92+ <div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label="Documentation navigation" id="docs-drawer">
93+ <button type="button" className="absolute inset-0 bg-fg/30 backdrop-blur-[2px] animate-fade-in" aria-label="Close navigation" onClick={() => setOpen(false)} />
94+ <div className="absolute inset-y-0 left-0 flex w-[min(20rem,85vw)] flex-col border-r border-border bg-bg shadow-lg animate-fade-in">
95+ <div className="flex h-14 items-center justify-between border-b border-border px-4">
96+ <span className="text-[13.5px] font-semibold text-fg">Documentation</span>
97+ <Button variant="ghost" size="icon-sm" onClick={() => setOpen(false)} aria-label="Close navigation">
98+ <X />
99+ </Button>
100+ </div>
101+ <div className="flex-1 overflow-y-auto px-3 py-4 scrollbar-thin">
102+ <NavLinks onNavigate={() => setOpen(false)} />
103+ </div>
104+ </div>
105+ </div>
106+ ) : null}
107+ </div>
108+ );
109+}
added apps/web/src/components/docs/snippets.ts +15 −0
@@ -0,0 +1,308 @@
1+import type { CodeTab } from "./code-tabs";
2+import { DOCS_BASE_URL } from "./nav-config";
3+
4+/**
5+ * Generates equivalent raw-HTTP samples for a Fetcha API call in the eight languages
6+ * shown across the docs. Samples use only standard libraries (plus `requests` in Python)
7+ * so they work without the SDKs, which are not yet published.
8+ */
9+
10+type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
11+
12+export interface ApiCall {
13+ method: "GET" | "POST" | "DELETE";
14+ path: string;
15+ body?: Record<string, Json>;

Diff truncated — file too large.

Menu

Theme