import { FetchaError, PLAN_LIMITS, newId, normalizeGeo, randomToken, sessionCreateSchema } from "@fetcha/core"; import { and, db, desc, eq, proxySessions } from "@fetcha/db"; import type { ApiPrincipal } from "../auth"; import { getEngine } from "../services/engine"; import { getKV } from "../redis"; export function serializeSession(s: typeof proxySessions.$inferSelect) { return { id: s.id, label: s.label, status: s.status === "active" && s.expiresAt.getTime() < Date.now() ? "expired" : s.status, network: s.network, country: s.country, region: s.region, city: s.city, request_count: s.requestCount, last_used_at: s.lastUsedAt?.toISOString() ?? null, expires_at: s.expiresAt.toISOString(), created_at: s.createdAt.toISOString(), }; } export async function createSession(principal: ApiPrincipal, body: unknown, idempotencyKey?: string) { const parsed = sessionCreateSchema.safeParse(body ?? {}); if (!parsed.success) { throw new FetchaError("INVALID_REQUEST", "Invalid session options.", { details: { issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) } }); } const input = parsed.data; const kv = getKV(); if (idempotencyKey) { const existing = await kv.get(`fch:idem:${principal.projectId}:${idempotencyKey}`).catch(() => null); if (existing) { const [s] = await db.select().from(proxySessions).where(eq(proxySessions.id, existing)).limit(1); if (s) return serializeSession(s); } } const limits = PLAN_LIMITS[principal.plan]; const geo = normalizeGeo({ country: input.country, region: input.region, city: input.city }); const engine = await getEngine(); const plan = engine.routing.plan({ domain: "", network: input.network, geo, plan: principal.plan, sessionRequired: true, browser: false }); const cand = plan.candidates.find((c) => c.provider.id !== "direct"); if (!cand) throw new FetchaError("NETWORK_UNAVAILABLE", "No sticky-capable network is available for these options."); if (!limits.networks.includes(cand.network)) throw new FetchaError("NETWORK_UNAVAILABLE"); const id = newId("sess"); const row = { id, organizationId: principal.organizationId, projectId: principal.projectId, label: input.label ?? null, provider: cand.provider.id, network: cand.network, country: geo.country, region: geo.region, city: geo.city, stickyKey: randomToken(12).replace(/[^a-zA-Z0-9]/g, "").slice(0, 16) || id.slice(5), status: "active", expiresAt: new Date(Date.now() + input.ttl * 1000), }; await db.insert(proxySessions).values(row); if (idempotencyKey) await kv.set(`fch:idem:${principal.projectId}:${idempotencyKey}`, id, 86_400).catch(() => {}); const [s] = await db.select().from(proxySessions).where(eq(proxySessions.id, id)).limit(1); return serializeSession(s!); } export async function getSession(principal: ApiPrincipal, id: string) { const [s] = await db.select().from(proxySessions).where(and(eq(proxySessions.id, id), eq(proxySessions.projectId, principal.projectId))).limit(1); if (!s) throw new FetchaError("SESSION_NOT_FOUND"); return serializeSession(s); } export async function closeSession(principal: ApiPrincipal, id: string) { const [s] = await db.select().from(proxySessions).where(and(eq(proxySessions.id, id), eq(proxySessions.projectId, principal.projectId))).limit(1); if (!s) throw new FetchaError("SESSION_NOT_FOUND"); await db.update(proxySessions).set({ status: "closed" }).where(eq(proxySessions.id, id)); return { id, status: "closed" }; } export async function listSessions(principal: ApiPrincipal) { const rows = await db.select().from(proxySessions).where(eq(proxySessions.projectId, principal.projectId)).orderBy(desc(proxySessions.createdAt)).limit(100); return { data: rows.map(serializeSession) }; }