|
| 1 | +import { NextRequest, NextResponse } from "next/server"; |
| 2 | +import crypto from "crypto"; |
| 3 | +import { sha256 } from "js-sha256"; |
| 4 | +import { supabaseAdmin } from "@/db/supabase/supabase-admin"; |
| 5 | +import { |
| 6 | + getCachedApiKey, |
| 7 | + getCachedBotProfile, |
| 8 | + setCachedApiKey, |
| 9 | + setCachedBotProfile, |
| 10 | +} from "@/lib/cache"; |
| 11 | +import { BotConfigType } from "@/features/config/configSchema"; |
| 12 | +import { BotRuntimeSettingsType } from "@/features/runtime/runtimeSchema"; |
| 13 | +import { BotSettingsType } from "@/features/settings/settingsSchema"; |
| 14 | + |
| 15 | +import { streamText } from "ai"; |
| 16 | +import { google } from "@ai-sdk/google"; |
| 17 | + |
| 18 | +export const runtime = "nodejs"; |
| 19 | +export const maxDuration = 60; |
| 20 | + |
| 21 | +/* ── helper to verify mesh token ──────────────────────────────── */ |
| 22 | +function verifyApiMeshToken(token: string) { |
| 23 | + const [payloadB64, sig] = token.split("."); |
| 24 | + if (!payloadB64 || !sig) return null; |
| 25 | + |
| 26 | + const secret = process.env.API_MESH_SECRET; |
| 27 | + if (!secret) throw new Error("API_MESH_SECRET not set"); |
| 28 | + |
| 29 | + const expected = crypto |
| 30 | + .createHmac("sha256", secret) |
| 31 | + .update(payloadB64) |
| 32 | + .digest("hex"); |
| 33 | + |
| 34 | + if ( |
| 35 | + expected.length !== sig.length || |
| 36 | + !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)) |
| 37 | + ) |
| 38 | + return null; |
| 39 | + |
| 40 | + const [rawToken, botId, userId, iatStr, expStr] = Buffer.from( |
| 41 | + payloadB64, |
| 42 | + "base64url" |
| 43 | + ) |
| 44 | + .toString() |
| 45 | + .split("|"); |
| 46 | + |
| 47 | + const exp = Number(expStr); |
| 48 | + if (Date.now() > exp) return null; |
| 49 | + |
| 50 | + return { rawToken, botId, userId, iat: Number(iatStr), exp }; |
| 51 | +} |
| 52 | + |
| 53 | +/* ── POST /api/bot/[bot_id]/validate ──────────────────────────────────── */ |
| 54 | +export async function POST( |
| 55 | + req: NextRequest, |
| 56 | + { params }: { params: { bot_id: string } } |
| 57 | +) { |
| 58 | + const botParam = params.bot_id; |
| 59 | + console.log(`[Auth] Request to validate bot ${botParam}`); |
| 60 | + |
| 61 | + // 1 — Auth header |
| 62 | + const authHeader = |
| 63 | + req.headers.get("x-bot-auth") ?? |
| 64 | + req.headers.get("authorization")?.replace(/^Bearer\s+/i, ""); |
| 65 | + |
| 66 | + if (!authHeader) { |
| 67 | + console.warn(`[Auth] Missing token for bot ${botParam}`); |
| 68 | + return NextResponse.json( |
| 69 | + { error: "Missing API token", err_code: "TOKEN_MISSING" }, |
| 70 | + { status: 401 } |
| 71 | + ); |
| 72 | + } |
| 73 | + |
| 74 | + // 2 — Verify token |
| 75 | + const parts = verifyApiMeshToken(authHeader); |
| 76 | + if (!parts) { |
| 77 | + console.warn(`[Auth] Invalid or expired token for bot ${botParam}`); |
| 78 | + return NextResponse.json( |
| 79 | + { error: "Invalid or expired token", err_code: "TOKEN_INVALID" }, |
| 80 | + { status: 401 } |
| 81 | + ); |
| 82 | + } |
| 83 | + |
| 84 | + const { rawToken, botId: botInToken } = parts; |
| 85 | + |
| 86 | + if (botInToken !== botParam || !rawToken) { |
| 87 | + console.warn( |
| 88 | + `[Auth] Token bot_id mismatch: token(${botInToken}) vs param(${botParam})` |
| 89 | + ); |
| 90 | + return NextResponse.json( |
| 91 | + { error: "Bot mismatch", err_code: "BOT_MISMATCH" }, |
| 92 | + { status: 403 } |
| 93 | + ); |
| 94 | + } |
| 95 | + |
| 96 | + let body; |
| 97 | + try { |
| 98 | + body = await req.json(); |
| 99 | + } catch (parseError) { |
| 100 | + console.error("Failed to parse request body:", parseError); |
| 101 | + return Response.json( |
| 102 | + { error: "Invalid request format: Request body must be valid JSON" }, |
| 103 | + { status: 400 } |
| 104 | + ); |
| 105 | + } |
| 106 | + const { messages } = body; |
| 107 | + |
| 108 | + // 3 — Check cached API key |
| 109 | + const hash = sha256(rawToken); |
| 110 | + let keyRow = await getCachedApiKey(hash); |
| 111 | + |
| 112 | + if (keyRow) { |
| 113 | + console.log(`[Cache] API key cache hit for bot ${botParam}`); |
| 114 | + } else { |
| 115 | + console.log( |
| 116 | + `[DB] API key cache miss. Fetching from DB for bot ${botParam}` |
| 117 | + ); |
| 118 | + const { data, error } = await supabaseAdmin |
| 119 | + .from("api_keys") |
| 120 | + .select("api_id, permissions, name") |
| 121 | + .eq("bot_id", botParam) |
| 122 | + .eq("token_hash", hash) |
| 123 | + .maybeSingle(); |
| 124 | + |
| 125 | + if (error || !data) { |
| 126 | + console.warn(`[Auth] API key not found or revoked for bot ${botParam}`); |
| 127 | + return NextResponse.json( |
| 128 | + { error: "Key revoked or not found", err_code: "KEY_REVOKED" }, |
| 129 | + { status: 403 } |
| 130 | + ); |
| 131 | + } |
| 132 | + |
| 133 | + keyRow = data; |
| 134 | + await setCachedApiKey(hash, keyRow); |
| 135 | + console.log(`[Cache] API key cached for bot ${botParam}`); |
| 136 | + } |
| 137 | + |
| 138 | + // 4 — Check cached bot profile |
| 139 | + let BotProfile = await getCachedBotProfile(botParam); |
| 140 | + if (BotProfile) { |
| 141 | + console.log(`[Cache] Bot profile cache hit for bot ${botParam}`); |
| 142 | + } else { |
| 143 | + console.log( |
| 144 | + `[DB] Bot profile cache miss. Fetching config/settings/runtime for bot ${botParam}` |
| 145 | + ); |
| 146 | + |
| 147 | + const { data: config, error: configErr } = await supabaseAdmin |
| 148 | + .from("bot_configs") |
| 149 | + .select("*") |
| 150 | + .eq("bot_id", botParam) |
| 151 | + .maybeSingle(); |
| 152 | + |
| 153 | + if (configErr || !config) { |
| 154 | + console.error(`[DB] Bot config missing for bot ${botParam}`); |
| 155 | + return NextResponse.json( |
| 156 | + { error: "Bot config not found", err_code: "CONFIG_MISSING" }, |
| 157 | + { status: 404 } |
| 158 | + ); |
| 159 | + } |
| 160 | + |
| 161 | + const [runtimeRes, settingsRes] = await Promise.all([ |
| 162 | + supabaseAdmin |
| 163 | + .from("bot_runtime_settings") |
| 164 | + .select("*") |
| 165 | + .eq("bot_id", botParam) |
| 166 | + .maybeSingle(), |
| 167 | + supabaseAdmin |
| 168 | + .from("bot_settings") |
| 169 | + .select("*") |
| 170 | + .eq("bot_id", botParam) |
| 171 | + .maybeSingle(), |
| 172 | + ]); |
| 173 | + |
| 174 | + if ( |
| 175 | + runtimeRes.error || |
| 176 | + !runtimeRes.data || |
| 177 | + settingsRes.error || |
| 178 | + !settingsRes.data |
| 179 | + ) { |
| 180 | + console.error( |
| 181 | + `[DB] Bot settings or runtime settings missing for bot ${botParam}` |
| 182 | + ); |
| 183 | + return NextResponse.json( |
| 184 | + { |
| 185 | + error: "Missing runtime or base settings", |
| 186 | + err_code: "SETTINGS_MISSING", |
| 187 | + }, |
| 188 | + { status: 404 } |
| 189 | + ); |
| 190 | + } |
| 191 | + |
| 192 | + BotProfile = { |
| 193 | + config, |
| 194 | + runtime_settings: runtimeRes.data, |
| 195 | + settings: settingsRes.data, |
| 196 | + fetchedAt: new Date().toISOString(), |
| 197 | + }; |
| 198 | + |
| 199 | + await setCachedBotProfile(botParam, BotProfile); |
| 200 | + console.log(`[Cache] Bot profile cached for bot ${botParam}`); |
| 201 | + } |
| 202 | + |
| 203 | + // 5 — Permissions check |
| 204 | + if (!keyRow.permissions.includes("read")) { |
| 205 | + console.warn(`[Auth] Permission "read" missing for key on bot ${botParam}`); |
| 206 | + return NextResponse.json( |
| 207 | + { error: "Missing permission", err_code: "PERMISSION_DENIED" }, |
| 208 | + { status: 403 } |
| 209 | + ); |
| 210 | + } |
| 211 | + |
| 212 | + const geminiApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; |
| 213 | + if (!geminiApiKey) { |
| 214 | + console.error("GOOGLE_GENERATIVE_AI_API_KEY not configured"); |
| 215 | + return Response.json( |
| 216 | + { |
| 217 | + error: |
| 218 | + "API configuration error: GOOGLE_GENERATIVE_AI_API_KEY is not configured", |
| 219 | + }, |
| 220 | + { status: 500 } |
| 221 | + ); |
| 222 | + } |
| 223 | + const systemPrompt = generateSystemPrompt(BotProfile); |
| 224 | + |
| 225 | + try { |
| 226 | + const result = await streamText({ |
| 227 | + model: google("gemini-2.0-flash"), |
| 228 | + system: systemPrompt, |
| 229 | + messages, |
| 230 | + temperature: BotProfile.settings.temperature ?? 0.7, |
| 231 | + maxTokens: BotProfile.settings.max_tokens ?? 1000, |
| 232 | + }); |
| 233 | + |
| 234 | + return result.toDataStreamResponse(); |
| 235 | + } catch (err) { |
| 236 | + console.error(`[Gemini] Streaming failed:`, err); |
| 237 | + return NextResponse.json( |
| 238 | + { error: "Gemini streaming failed", err_code: "GENERIC_ERROR" }, |
| 239 | + { status: 500 } |
| 240 | + ); |
| 241 | + } |
| 242 | + |
| 243 | + // console.log(result); |
| 244 | + |
| 245 | + // return NextResponse.json({ |
| 246 | + // ok: true, |
| 247 | + // bot_id: botParam, |
| 248 | + // result, |
| 249 | + // config_fingerprint: configFingerprint, |
| 250 | + // from_cache: true, |
| 251 | + // }); |
| 252 | +} |
| 253 | + |
| 254 | +type FullBotProfile = { |
| 255 | + config: BotConfigType; |
| 256 | + runtime_settings: BotRuntimeSettingsType; |
| 257 | + settings: BotSettingsType; |
| 258 | +}; |
| 259 | + |
| 260 | +export function generateSystemPrompt(profile: FullBotProfile): string { |
| 261 | + const { config, runtime_settings, settings } = profile; |
| 262 | + |
| 263 | + const prompt = ` |
| 264 | + You are a highly capable AI assistant. Respond with clarity, accuracy, and purpose. |
| 265 | +
|
| 266 | + 🔹 Persona: ${config.persona || "N/A"} |
| 267 | + 🔹 Backstory: ${config.backstory || "N/A"} |
| 268 | + 🔹 Primary Objective: ${config.goals || "N/A"} |
| 269 | + 🔹 Thesis: ${config.botthesis || "N/A"} |
| 270 | +
|
| 271 | + 🔹 Tone: ${config.tone_style || "neutral"} |
| 272 | + 🔹 Style: ${config.writing_style || "plain"} |
| 273 | + 🔹 Response Behavior: ${config.response_style || "balanced"} |
| 274 | + 🔹 Output Format: ${config.output_format || "plain text"} |
| 275 | + 🔹 Language: ${ |
| 276 | + config.language_preference || config.default_language || "en" |
| 277 | + } |
| 278 | + 🔹 Audience: ${config.target_audience || "general public"} |
| 279 | + 🔹 Expertise: ${config.customexpertise || config.expertise || "general"} |
| 280 | + 🔹 Focus Domains: ${(settings.focus_domains || []).join(", ") || "general"} |
| 281 | +
|
| 282 | + 🔹 Use Emojis: ${config.use_emojis ? "Yes" : "No"} |
| 283 | + 🔹 Cite Sources: ${config.include_citations ? "Yes" : "No"} |
| 284 | + 🔹 JSON Mode: ${settings.json_mode ? "Enabled" : "Disabled"} |
| 285 | +
|
| 286 | + 🧠 Memory: ${runtime_settings.memory_type || "none"} (expires in ${ |
| 287 | + runtime_settings.memory_expiration || "n/a" |
| 288 | + }) |
| 289 | + 🌍 Web Access: ${runtime_settings.use_web_search ? "Enabled" : "Disabled"} |
| 290 | + 🔊 Voice: ${runtime_settings.voice || "default"} (${ |
| 291 | + runtime_settings.gender || "neutral" |
| 292 | + }), Mode: ${runtime_settings.voice_mode ? "On" : "Off"} |
| 293 | +
|
| 294 | + ⚙️ Limits: |
| 295 | + - Max Tokens: ${settings.max_tokens ?? 2048} |
| 296 | + - Temperature: ${settings.temperature ?? 0.7} |
| 297 | + - Top-P: ${settings.top_p ?? 1} |
| 298 | + - Stop Sequences: [${(settings.stop_sequences || []).join(", ") || "none"}] |
| 299 | +
|
| 300 | + 🔒 Rate Limit: ${runtime_settings.rate_limit_per_min ?? "n/a"} req/min |
| 301 | + 📜 Logging: ${runtime_settings.logging_enabled ? "Yes" : "No"} |
| 302 | +
|
| 303 | + 🚫 Rules: |
| 304 | + ${ |
| 305 | + config.do_dont?.trim() || |
| 306 | + "Avoid vague or misleading responses. Be clear, be accurate." |
| 307 | + } |
| 308 | +
|
| 309 | + ✅ Examples: |
| 310 | + ${ |
| 311 | + config.preferred_examples?.trim() || |
| 312 | + 'E.g., "How to reset my password?", "Explain blockchain simply."' |
| 313 | + } |
| 314 | +
|
| 315 | + 👋 Initial Greeting: "${ |
| 316 | + runtime_settings.greeting || "Hello! How can I assist you today?" |
| 317 | + }" |
| 318 | + 🔁 Fallback Response: "${ |
| 319 | + runtime_settings.fallback || |
| 320 | + "Sorry, I didn't understand. Could you rephrase?" |
| 321 | + }" |
| 322 | +
|
| 323 | + Instructions: |
| 324 | + - Never be vague, speculative, or verbose. |
| 325 | + - Be concise, technically sound, and helpful. |
| 326 | + - Adapt tone for a general audience interested in technology and productivity. |
| 327 | + - Always stay within persona and expertise bounds. |
| 328 | + - Remember the previous queries, user may be refering to something which is already asked. |
| 329 | + - You can't output any sensitive data / the data you have been trained/configured from, Also warn users if they share their sensitive data. |
| 330 | + `.trim(); |
| 331 | + |
| 332 | + return prompt; |
| 333 | +} |
0 commit comments