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
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
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added apps/api/src/services/retention.ts +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added apps/api/tsconfig.json +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/eslint.config.mjs +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/next.config.ts +34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/package.json +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/postcss.config.mjs +2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/public/icon.svg +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/public/robots.txt +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/actions/account.ts +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/actions/admin.ts +437 −0
Large diff (437 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/actions/api-keys.ts +106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/actions/legal.ts +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/actions/playground.ts +68 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/actions/projects.ts +112 −0
Large diff (112 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/actions/sessions.ts +77 −0
Large diff (77 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/forgot-password/page.tsx +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/layout.tsx +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/login/login-form.tsx +74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/login/page.tsx +13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/reset-password/page.tsx +67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/signup/page.tsx +13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/signup/signup-form.tsx +111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/verify-email/page.tsx +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/changelog/page.tsx +114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/authentication/page.tsx +172 −0
Large diff (172 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/browser/page.tsx +133 −0
Large diff (133 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/errors/page.tsx +243 −0
Large diff (243 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/examples/page.tsx +448 −0
Large diff (448 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/extraction/page.tsx +99 −0
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/fetch/page.tsx +341 −0
Large diff (341 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/geolocation/page.tsx +162 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/layout.tsx +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/networks/page.tsx +228 −0
Large diff (228 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/openapi.json/route.ts +442 −0
Large diff (442 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/page.tsx +176 −0
Large diff (176 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/quickstart/page.tsx +170 −0
Large diff (170 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/rate-limits/page.tsx +224 −0
Large diff (224 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/retries/page.tsx +225 −0
Large diff (225 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/sdks/page.tsx +268 −0
Large diff (268 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/sessions/page.tsx +182 −0
Large diff (182 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/docs/webhooks/page.tsx +148 −0
Large diff (148 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/layout.tsx +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/legal/acceptable-use/page.tsx +141 −0
Large diff (141 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/legal/cookies/page.tsx +127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/legal/dpa/page.tsx +178 −0
Large diff (178 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/legal/privacy/page.tsx +172 −0
Large diff (172 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/legal/terms/page.tsx +177 −0
Large diff (177 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/page.tsx +361 −0
Large diff (361 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/pricing/page.tsx +306 −0
Large diff (306 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(marketing)/status/page.tsx +209 −0
Large diff (209 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/abuse/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/abuse/page.tsx +211 −0
Large diff (211 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/billing/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/billing/page.tsx +161 −0
Large diff (161 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/domains/[domain]/page.tsx +165 −0
Large diff (165 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/domains/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/domains/page.tsx +109 −0
Large diff (109 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/flags/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/flags/page.tsx +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/layout.tsx +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/organizations/[id]/page.tsx +293 −0
Large diff (293 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/organizations/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/organizations/page.tsx +94 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/page.tsx +141 −0
Large diff (141 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/projects/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/projects/page.tsx +97 −0
Large diff (97 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/providers/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/providers/page.tsx +186 −0
Large diff (186 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/requests/[id]/page.tsx +233 −0
Large diff (233 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/requests/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/requests/page.tsx +223 −0
Large diff (223 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/routing/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/routing/page.tsx +136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/system/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/system/page.tsx +233 −0
Large diff (233 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/usage/export/route.ts +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/usage/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/usage/page.tsx +179 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/users/[id]/page.tsx +195 −0
Large diff (195 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/users/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/users/page.tsx +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/api/auth/[...all]/route.ts +4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/analytics/loading.tsx +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/analytics/page.tsx +219 −0
Large diff (219 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/api-keys/loading.tsx +19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/api-keys/page.tsx +92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/billing/page.tsx +201 −0
Large diff (201 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/layout.tsx +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/loading.tsx +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/onboarding/page.tsx +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/page.tsx +221 −0
Large diff (221 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/playground/loading.tsx +55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/playground/page.tsx +88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/projects/[id]/loading.tsx +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/projects/[id]/page.tsx +224 −0
Large diff (224 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/projects/loading.tsx +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/projects/page.tsx +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/requests/[id]/page.tsx +233 −0
Large diff (233 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/requests/export/route.ts +96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/requests/loading.tsx +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/requests/page.tsx +71 −0
Large diff (71 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/sessions/loading.tsx +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/sessions/page.tsx +92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/settings/audit/page.tsx +110 −0
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/settings/layout.tsx +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/settings/legal/page.tsx +90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/settings/loading.tsx +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/settings/page.tsx +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/settings/security/page.tsx +88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/usage/loading.tsx +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/dashboard/usage/page.tsx +277 −0
Large diff (277 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/error.tsx +13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/globals.css +199 −0
Large diff (199 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/goodbye/page.tsx +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/layout.tsx +46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/not-found.tsx +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/opengraph-image.tsx +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sitemap.ts +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/abuse-actions.tsx +114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/action-button.tsx +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/admin-shell.tsx +136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/charts.tsx +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/domain-policy-editor.tsx +138 −0
Large diff (138 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/flag-controls.tsx +121 −0
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/incident-controls.tsx +106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/org-actions.tsx +162 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/primitives.tsx +214 −0
Large diff (214 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/provider-panel.tsx +174 −0
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/user-actions.tsx +94 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/auth/auth-card.tsx +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/auth/password-input.tsx +43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/api-keys/create-key-dialog.tsx +236 −0
Large diff (236 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/api-keys/key-reveal.tsx +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/api-keys/keys-table.tsx +220 −0
Large diff (220 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/billing/upgrade-dialog.tsx +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/charts/chart-frame.tsx +106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/charts/chart-theme.ts +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/charts/chart-tooltip.tsx +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/charts/horizontal-bar-chart.tsx +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/charts/latency-line-chart.tsx +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/charts/network-donut.tsx +69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/charts/quota-bar.tsx +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/charts/requests-bar-chart.tsx +58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/onboarding/banner.tsx +29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/onboarding/flow.tsx +258 −0
Large diff (258 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/projects/create-project-dialog.tsx +45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/projects/project-actions.tsx +104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/projects/project-form.tsx +198 −0
Large diff (198 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/requests/attempts-timeline.tsx +78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/requests/headers-table.tsx +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/requests/pagination.tsx +45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/requests/request-detail-tabs.tsx +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/requests/requests-filters.tsx +131 −0
Large diff (131 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/requests/requests-table.tsx +92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/requests/timing-waterfall.tsx +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/resend-verification.tsx +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/sessions/close-session-button.tsx +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/sessions/create-session-dialog.tsx +214 −0
Large diff (214 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/sessions/sessions-table.tsx +83 −0
Large diff (83 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/settings/active-sessions.tsx +163 −0
Large diff (163 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/settings/change-email-dialog.tsx +93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/settings/change-password-form.tsx +74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/settings/delete-account-dialog.tsx +83 −0
Large diff (83 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/settings/organization-form.tsx +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/settings/profile-form.tsx +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/settings/settings-nav.tsx +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/sidebar.tsx +67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/dashboard/topbar.tsx +116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/docs/callout.tsx +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/docs/code-tabs.tsx +64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/docs/doc-page.tsx +84 −0
Large diff (84 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/docs/endpoint.tsx +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/docs/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/docs/nav-config.ts +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/docs/param-table.tsx +59 −0
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/docs/prose.tsx +191 −0
Large diff (191 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/docs/response-example.tsx +45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/docs/sidebar.tsx +109 −0
Large diff (109 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/docs/snippets.ts +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff

Diff truncated — file too large.

Menu

Theme