import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; const messageSchema = z.object({ role: z.enum(["user", "assistant"]), text: z.string().trim().min(1).max(20000) }); const messagesSchema = z.array(messageSchema).min(1).max(50); export const askEcho = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) .validator((input: unknown) => z.object({ messages: messagesSchema, mode: z.enum(["chat", "plan", "research", "code"]).default("chat") }).parse(input)) .handler(async ({ data, context }) => { // Signed-in only, so the saved Gemini key can't be used by strangers; history lookups read only the caller's rows. const { answerWithGemini } = await import("./gemini.server"); return answerWithGemini(data.messages, context.supabase, data.mode); }); export const saveEchoCall = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) .validator((input: unknown) => z.object({ messages: messagesSchema, startedAt: z.string().datetime(), sessionId: z.string().uuid().optional() }).parse(input)) .handler(async ({ data, context }) => { // Preserve the existing public append-only conversation storage. No arbitrary code or external actions are exposed. const { saveConversation } = await import("./live-relay.server"); await saveConversation(data.messages.map((m, i) => ({ ...m, start_ms: i, end_ms: i, listeningSound: false })), new Date(data.startedAt), data.sessionId, context.userId); return { saved: true }; }); export const lookupEchoHistory = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) .handler(async ({ context }) => { // The signed-in caller's own history only (row security applies). const { historyFor } = await import("./gemini.server"); return historyFor(context.supabase); }); export const startEchoLive = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) .validator((input: unknown) => z.object({ name: z.string().trim().min(1).max(40) }).parse(input)) .handler(async ({ data }) => { const { createLiveToken } = await import("./gemini-live.server"); return createLiveToken(data.name); }); export const buildEchoSite = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) .validator((input: unknown) => z.object({ sessionId: z.string().uuid(), request: z.string().trim().min(1).max(4000), previous: z.string().max(200000).optional() }).parse(input)) .handler(async ({ data, context }) => { const { buildSite } = await import("./gemini-build.server"); return buildSite(data.sessionId, data.request, data.previous, context.userId); }); const apiBaseSchema = z.string().url().refine((u) => { const url = new URL(u); return url.protocol === "https:" || url.hostname === "localhost"; }, "Invalid site address"); export const deployEchoBuild = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) .validator((input: unknown) => z.object({ buildId: z.string().uuid(), apiBase: apiBaseSchema }).parse(input)) .handler(async ({ data, context }) => { // Row security: only the caller's own builds are readable here. const { data: build, error } = await context.supabase.from("voice_builds").select("id, session_id, html").eq("id", data.buildId).maybeSingle(); if (error || !build) return { error: "That build wasn't found in your account." } as const; const { withEchoRuntime } = await import("./echo-runtime"); const { deployToPages } = await import("./cloudflare-deploy.server"); const result = await deployToPages(`echo-${build.session_id.replace(/-/g, "").slice(0, 12)}`, withEchoRuntime(build.html, build.session_id, new URL(data.apiBase).origin)); if (!("url" in result) || !result.url) return { error: "error" in result && result.error ? result.error : "Publishing failed." } as const; const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); await supabaseAdmin.from("voice_builds").update({ deployed_url: result.url }).eq("id", build.id); const host = new URL(data.apiBase).hostname; const note = host === "localhost" || host.startsWith("id-preview--") ? "Shared data on the live site only works after you publish Echo itself, because the preview address is private." : null; return { url: result.url, note } as const; }); // Saved projects = the caller's voice builds grouped by session. Row security keeps this to the caller's own rows. export const listEchoProjects = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) .handler(async ({ context }) => { const { data, error } = await context.supabase.from("voice_builds").select("session_id, prompt, created_at").eq("user_id", context.userId).order("created_at", { ascending: false }).limit(200); if (error) return { error: "Your saved projects could not load." } as const; const { groupProjects } = await import("./project-match"); return { projects: groupProjects(data ?? []) } as const; }); export const openEchoProject = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) .validator((input: unknown) => z.object({ query: z.string().trim().min(1).max(200) }).parse(input)) .handler(async ({ data, context }) => { const { data: rows, error } = await context.supabase.from("voice_builds").select("id, session_id, prompt, html, created_at").eq("user_id", context.userId).order("created_at", { ascending: false }).limit(200); if (error) return { error: "Your saved projects could not load." } as const; const { groupProjects, matchProject } = await import("./project-match"); const match = matchProject(groupProjects(rows ?? []), data.query); if (!match) return { error: "No saved project matches that. Ask to list projects first." } as const; const latest = (rows ?? []).find((r) => r.session_id === match.project_id)!; return { project: match, build: { id: latest.id, html: latest.html } } as const; });