SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
10.3 KB · 255 lines typescript
Raw Blame History
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