import { FetchaError, hashApiKey, normalizePlan, parseApiKeyMode, safeEqual, type ApiKeyScope, type Plan } from "@fetcha/core"; import { apiKeys, db, eq, organizations, projects, sql, users } from "@fetcha/db"; import { config } from "./config"; import { getKV } from "./redis"; export interface ApiPrincipal { keyId: string | null; keyName: string | null; mode: "live" | "test"; scopes: ApiKeyScope[]; organizationId: string; organizationName: string; plan: Plan; providerVisibility: boolean; suspended: boolean; orgSoftLimitUsd: number | null; orgHardLimitUsd: number | null; projectId: string; projectName: string; projectLogLevel: "none" | "metadata" | "headers" | "full"; projectSoftLimitUsd: number | null; projectHardLimitUsd: number | null; projectMonthlyRequestLimit: number | null; ownerEmail: string; ownerVerified: boolean; /** Playground / dashboard calls (service token) — bypass email verification for limited use. */ internal: boolean; } const CACHE_TTL_SEC = 30; async function loadPrincipalByKeyHash(hash: string): Promise { const [row] = await db .select({ keyId: apiKeys.id, keyName: apiKeys.name, mode: apiKeys.mode, scopes: apiKeys.scopes, expiresAt: apiKeys.expiresAt, revokedAt: apiKeys.revokedAt, organizationId: organizations.id, organizationName: organizations.name, plan: organizations.plan, providerVisibility: organizations.providerVisibility, suspended: organizations.suspended, orgSoft: organizations.softLimitUsd, orgHard: organizations.hardLimitUsd, projectId: projects.id, projectName: projects.name, logLevel: projects.logLevel, projSoft: projects.softLimitUsd, projHard: projects.hardLimitUsd, projMonthly: projects.monthlyRequestLimit, archivedAt: projects.archivedAt, ownerEmail: users.email, ownerVerified: users.emailVerified, ownerBanned: users.banned, }) .from(apiKeys) .innerJoin(projects, eq(apiKeys.projectId, projects.id)) .innerJoin(organizations, eq(apiKeys.organizationId, organizations.id)) .innerJoin(users, eq(organizations.ownerUserId, users.id)) .where(eq(apiKeys.keyHash, hash)) .limit(1); if (!row) return null; if (row.revokedAt) throw new FetchaError("INVALID_API_KEY", "This API key was revoked."); if (row.expiresAt && row.expiresAt.getTime() < Date.now()) throw new FetchaError("INVALID_API_KEY", "This API key has expired."); if (row.archivedAt) throw new FetchaError("FORBIDDEN", "This project is archived."); if (row.ownerBanned) throw new FetchaError("FORBIDDEN", "This account is suspended."); return { keyId: row.keyId, keyName: row.keyName, mode: row.mode as "live" | "test", scopes: (row.scopes ?? []) as ApiKeyScope[], organizationId: row.organizationId, organizationName: row.organizationName, plan: normalizePlan(row.plan), providerVisibility: row.providerVisibility, suspended: row.suspended, orgSoftLimitUsd: row.orgSoft, orgHardLimitUsd: row.orgHard, projectId: row.projectId, projectName: row.projectName, projectLogLevel: row.logLevel as ApiPrincipal["projectLogLevel"], projectSoftLimitUsd: row.projSoft, projectHardLimitUsd: row.projHard, projectMonthlyRequestLimit: row.projMonthly, ownerEmail: row.ownerEmail, ownerVerified: row.ownerVerified, internal: false, }; } export async function authenticateApiKey(authorization: string | undefined, xApiKey?: string): Promise { let token = xApiKey?.trim(); if (!token && authorization) { const m = authorization.match(/^Bearer\s+(.+)$/i); token = m?.[1]?.trim(); } if (!token) throw new FetchaError("INVALID_API_KEY", "Missing API key. Send `Authorization: Bearer fch_live_…`."); if (!parseApiKeyMode(token) || token.length < 20 || token.length > 128) throw new FetchaError("INVALID_API_KEY", "Malformed API key."); const hash = hashApiKey(token); const kv = getKV(); const cacheKey = `fch:key:${hash}`; const cached = await kv.get(cacheKey).catch(() => null); let principal: ApiPrincipal | null = null; if (cached) { if (cached === "invalid") throw new FetchaError("INVALID_API_KEY"); principal = JSON.parse(cached) as ApiPrincipal; } else { principal = await loadPrincipalByKeyHash(hash); await kv.set(cacheKey, principal ? JSON.stringify(principal) : "invalid", CACHE_TTL_SEC).catch(() => {}); } if (!principal) throw new FetchaError("INVALID_API_KEY"); if (principal.suspended) throw new FetchaError("FORBIDDEN", "This organization is suspended. Contact support@fetcha.co."); if (!principal.ownerVerified) throw new FetchaError("EMAIL_NOT_VERIFIED"); // Fire-and-forget last-used update, throttled by the cache TTL. const luKey = `fch:key:lu:${principal.keyId}`; if (!(await kv.get(luKey).catch(() => "1"))) { await kv.set(luKey, "1", 60).catch(() => {}); db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, principal.keyId!)).catch(() => {}); } return principal; } export function requireScope(p: ApiPrincipal, scope: ApiKeyScope): void { if (p.internal) return; if (!p.scopes.includes(scope)) throw new FetchaError("FORBIDDEN", `This API key lacks the "${scope}" scope.`); } /** Internal service authentication (dashboard → API). */ export function assertInternalToken(header: string | undefined): void { const token = header?.replace(/^Bearer\s+/i, "").trim(); if (!config.internalToken || !token || !safeEqual(token, config.internalToken)) { throw new FetchaError("FORBIDDEN", "Invalid internal service token."); } } /** Build a principal for a project on behalf of the dashboard (playground). */ export async function principalForProject(projectId: string, userId: string): Promise { const [row] = await db .select({ organizationId: organizations.id, organizationName: organizations.name, plan: organizations.plan, providerVisibility: organizations.providerVisibility, suspended: organizations.suspended, orgSoft: organizations.softLimitUsd, orgHard: organizations.hardLimitUsd, projectId: projects.id, projectName: projects.name, logLevel: projects.logLevel, projSoft: projects.softLimitUsd, projHard: projects.hardLimitUsd, projMonthly: projects.monthlyRequestLimit, ownerEmail: users.email, ownerVerified: users.emailVerified, isMember: sql`exists (select 1 from organization_members m where m.organization_id = ${organizations.id} and m.user_id = ${userId})`, }) .from(projects) .innerJoin(organizations, eq(projects.organizationId, organizations.id)) .innerJoin(users, eq(organizations.ownerUserId, users.id)) .where(eq(projects.id, projectId)) .limit(1); if (!row) throw new FetchaError("NOT_FOUND", "Project not found."); if (!row.isMember) throw new FetchaError("FORBIDDEN"); if (row.suspended) throw new FetchaError("FORBIDDEN", "This organization is suspended."); return { keyId: null, keyName: null, mode: "live", scopes: ["fetch:execute", "sessions:write", "usage:read"], organizationId: row.organizationId, organizationName: row.organizationName, plan: normalizePlan(row.plan), providerVisibility: row.providerVisibility, suspended: row.suspended, orgSoftLimitUsd: row.orgSoft, orgHardLimitUsd: row.orgHard, projectId: row.projectId, projectName: row.projectName, projectLogLevel: row.logLevel as ApiPrincipal["projectLogLevel"], projectSoftLimitUsd: row.projSoft, projectHardLimitUsd: row.projHard, projectMonthlyRequestLimit: row.projMonthly, ownerEmail: row.ownerEmail, ownerVerified: row.ownerVerified, internal: true, }; } /** Principal for a background crawl job: the project's owner context, with the originating key when still valid. */ export async function principalForCrawl(projectId: string, apiKeyId: string | null): Promise { const [row] = await db .select({ organizationId: organizations.id, organizationName: organizations.name, plan: organizations.plan, providerVisibility: organizations.providerVisibility, suspended: organizations.suspended, orgSoft: organizations.softLimitUsd, orgHard: organizations.hardLimitUsd, projectId: projects.id, projectName: projects.name, logLevel: projects.logLevel, projSoft: projects.softLimitUsd, projHard: projects.hardLimitUsd, projMonthly: projects.monthlyRequestLimit, archivedAt: projects.archivedAt, ownerEmail: users.email, ownerVerified: users.emailVerified, ownerBanned: users.banned, }) .from(projects) .innerJoin(organizations, eq(projects.organizationId, organizations.id)) .innerJoin(users, eq(organizations.ownerUserId, users.id)) .where(eq(projects.id, projectId)) .limit(1); if (!row) throw new FetchaError("NOT_FOUND", "Project not found."); if (row.archivedAt) throw new FetchaError("FORBIDDEN", "This project is archived."); if (row.suspended || row.ownerBanned) throw new FetchaError("FORBIDDEN", "This organization is suspended."); let keyName: string | null = null; if (apiKeyId) { const [k] = await db.select({ name: apiKeys.name, revokedAt: apiKeys.revokedAt }).from(apiKeys).where(eq(apiKeys.id, apiKeyId)).limit(1); if (k?.revokedAt) throw new FetchaError("INVALID_API_KEY", "The API key that started this crawl was revoked."); keyName = k?.name ?? null; } return { keyId: apiKeyId, keyName, mode: "live", scopes: ["fetch:execute", "crawl:execute", "sessions:write", "usage:read"], organizationId: row.organizationId, organizationName: row.organizationName, plan: normalizePlan(row.plan), providerVisibility: row.providerVisibility, suspended: row.suspended, orgSoftLimitUsd: row.orgSoft, orgHardLimitUsd: row.orgHard, projectId: row.projectId, projectName: row.projectName, projectLogLevel: row.logLevel as ApiPrincipal["projectLogLevel"], projectSoftLimitUsd: row.projSoft, projectHardLimitUsd: row.projHard, projectMonthlyRequestLimit: row.projMonthly, ownerEmail: row.ownerEmail, ownerVerified: row.ownerVerified, internal: true, }; } export async function invalidateKeyCache(keyHash: string): Promise { await getKV().del(`fch:key:${keyHash}`).catch(() => {}); }