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