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>
199 changed files +20,001 −0
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.