TypeScript 97.5%
SQL 1.4%
Python 0.8%
1import { FetchaError, hashApiKey, normalizePlan, parseApiKeyMode, safeEqual, type ApiKeyScope, type Plan } from "@fetcha/core";2import { apiKeys, db, eq, organizations, projects, sql, users } from "@fetcha/db";3import { config } from "./config";4import { getKV } from "./redis";56export 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}2930const CACHE_TTL_SEC = 30;3132async function loadPrincipalByKeyHash(hash: string): Promise<ApiPrincipal | null> {33 const [row] = await db34 .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: normalizePlan(row.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}9394export 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}125126export 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}130131/** Internal service authentication (dashboard → API). */132export 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}138139/** Build a principal for a project on behalf of the dashboard (playground). */140export async function principalForProject(projectId: string, userId: string): Promise<ApiPrincipal> {141 const [row] = await db142 .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: normalizePlan(row.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}191192/** Principal for a background crawl job: the project's owner context, with the originating key when still valid. */193export async function principalForCrawl(projectId: string, apiKeyId: string | null): Promise<ApiPrincipal> {194 const [row] = await db195 .select({196 organizationId: organizations.id,197 organizationName: organizations.name,198 plan: organizations.plan,199 providerVisibility: organizations.providerVisibility,200 suspended: organizations.suspended,201 orgSoft: organizations.softLimitUsd,202 orgHard: organizations.hardLimitUsd,203 projectId: projects.id,204 projectName: projects.name,205 logLevel: projects.logLevel,206 projSoft: projects.softLimitUsd,207 projHard: projects.hardLimitUsd,208 projMonthly: projects.monthlyRequestLimit,209 archivedAt: projects.archivedAt,210 ownerEmail: users.email,211 ownerVerified: users.emailVerified,212 ownerBanned: users.banned,213 })214 .from(projects)215 .innerJoin(organizations, eq(projects.organizationId, organizations.id))216 .innerJoin(users, eq(organizations.ownerUserId, users.id))217 .where(eq(projects.id, projectId))218 .limit(1);219 if (!row) throw new FetchaError("NOT_FOUND", "Project not found.");220 if (row.archivedAt) throw new FetchaError("FORBIDDEN", "This project is archived.");221 if (row.suspended || row.ownerBanned) throw new FetchaError("FORBIDDEN", "This organization is suspended.");222 let keyName: string | null = null;223 if (apiKeyId) {224 const [k] = await db.select({ name: apiKeys.name, revokedAt: apiKeys.revokedAt }).from(apiKeys).where(eq(apiKeys.id, apiKeyId)).limit(1);225 if (k?.revokedAt) throw new FetchaError("INVALID_API_KEY", "The API key that started this crawl was revoked.");226 keyName = k?.name ?? null;227 }228 return {229 keyId: apiKeyId,230 keyName,231 mode: "live",232 scopes: ["fetch:execute", "crawl:execute", "sessions:write", "usage:read"],233 organizationId: row.organizationId,234 organizationName: row.organizationName,235 plan: normalizePlan(row.plan),236 providerVisibility: row.providerVisibility,237 suspended: row.suspended,238 orgSoftLimitUsd: row.orgSoft,239 orgHardLimitUsd: row.orgHard,240 projectId: row.projectId,241 projectName: row.projectName,242 projectLogLevel: row.logLevel as ApiPrincipal["projectLogLevel"],243 projectSoftLimitUsd: row.projSoft,244 projectHardLimitUsd: row.projHard,245 projectMonthlyRequestLimit: row.projMonthly,246 ownerEmail: row.ownerEmail,247 ownerVerified: row.ownerVerified,248 internal: true,249 };250}251252export async function invalidateKeyCache(keyHash: string): Promise<void> {253 await getKV().del(`fch:key:${keyHash}`).catch(() => {});254}255