Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 127 additions & 16 deletions api/nlp.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,106 @@
// Vercel Serverless Function — Gemini NLU for SentinelForge Terminal
import { timingSafeEqual } from 'node:crypto';

// Best-effort in-memory per-IP rate limit (20 req/min)
const ipHits = new Map();

function checkRateLimit(ip) {
const now = Date.now();
const windowMs = 60 * 1000;
const maxReqs = 20;
const hits = (ipHits.get(ip) || []).filter(t => now - t < windowMs);

if (hits.length >= maxReqs) {
return false;
}
hits.push(now);
ipHits.set(ip, hits);

if (ipHits.size > 2000) {
for (const [k, v] of ipHits.entries()) {
if (v.length === 0 || now - v[v.length - 1] > windowMs) {
ipHits.delete(k);
}
}
}
return true;
}

function validApiKey(provided, expected) {
if (typeof provided !== 'string' || typeof expected !== 'string') {
return false;
}
const providedBytes = Buffer.from(provided, 'utf8');
const expectedBytes = Buffer.from(expected, 'utf8');
return providedBytes.length === expectedBytes.length
&& timingSafeEqual(providedBytes, expectedBytes);
}

const ALLOWED_COMMANDS = new Set([
'ls', 'cat', 'cd', 'pwd', 'tree', 'find', 'head', 'grep', 'wc',
'python', 'pytest', 'git', 'make', 'nano', 'ssh', 'slingshot-pipeline',
'jetson_health', 'df', 'uptime', 'whoami', 'uname', 'date', 'echo',
'clear', 'help', '_reply'
]);

export default async function handler(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
const origin = req.headers.origin;
const allowedOrigins = (process.env.ALLOWED_ORIGINS || 'https://sentinelforge.vercel.app')
.split(',')
.map(s => s.trim())
.filter(Boolean);

if (origin) {
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
} else {
return res.status(403).json({ error: 'Origin not allowed' });
}
}

res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.status(200).end();
if (req.method !== 'POST') return res.status(405).json({ error: 'POST only' });
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key');

const { input } = req.body || {};
if (!input || typeof input !== 'string' || input.length > 500)
if (req.method === 'OPTIONS') {
return res.status(204).end();
}

if (req.method !== 'POST') {
return res.status(405).json({ error: 'POST only' });
}

// Authentication is mandatory and fail-closed when the server is not configured.
const expectedApiKey = process.env.NLP_API_KEY;
if (!expectedApiKey) {
return res.status(503).json({ error: 'Service authentication is not configured' });
}
if (!validApiKey(req.headers['x-api-key'], expectedApiKey)) {
return res.status(401).json({ error: 'Unauthorized' });
}

// Best-effort per-IP rate limiting. Serverless instances do not share memory,
// so this bounds bursts per warm instance rather than providing a global quota.
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown';
if (!checkRateLimit(ip)) {
return res.status(429).json({ error: 'Too many requests. Please wait a minute.' });
}

// Strict input validation
let input = req.body?.input;
if (!input || typeof input !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
// Strip control characters (retain printable characters and whitespace)
input = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').trim();
if (input.length < 1 || input.length > 500) {
return res.status(400).json({ error: 'Invalid input length' });
}

const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) return res.status(500).json({ error: 'API key not configured' });
if (!apiKey) {
return res.status(500).json({ error: 'API key not configured' });
}

const systemPrompt = `You are a command interpreter for the SentinelForge space surveillance terminal.
The user types natural language. You must respond with ONLY a JSON object — no markdown, no explanation.
Expand Down Expand Up @@ -64,9 +153,10 @@ Examples:
"how's the gpu?" → {"cmd":"jetson_health","args":[],"narr":"Running GPU diagnostics..."}
"hello" → {"cmd":"_reply","text":"Hello! I'm the SentinelForge terminal. Ask me anything about the codebase, or tell me what to run."}`;

// Strict fixed model allow-list (cannot be used as an open proxy)
const models = ['gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.0-flash-lite'];
const reqBody = JSON.stringify({
contents: [{ parts: [{ text: `${systemPrompt}\n\nUser input: "${input}"` }] }],
contents: [{ parts: [{ text: `${systemPrompt}\n\nUser input: ${JSON.stringify(input)}` }] }],
generationConfig: {
temperature: 0.1,
maxOutputTokens: 256,
Expand All @@ -76,33 +166,54 @@ Examples:

for (const model of models) {
try {
// Pass API key via header, NEVER in URL query params
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: reqBody }
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey
},
body: reqBody
}
);

if (response.status === 503 || response.status === 429) {
console.log(`${model}: ${response.status}, trying next model...`);
continue; // Try next model
}

if (!response.ok) {
const errText = await response.text();
console.error(`${model} error:`, errText);
// Do not leak upstream error body to client
continue;
}

const data = await response.json();
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) continue;

const parsed = JSON.parse(text);
let parsed;
try {
parsed = JSON.parse(text);
} catch {
continue;
}

if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return res.status(200).json({ cmd: '_reply', text: 'Command not recognized.' });
}

// Validate command is from allowed set
if (typeof parsed.cmd !== 'string' || !ALLOWED_COMMANDS.has(parsed.cmd)) {
return res.status(200).json({ cmd: '_reply', text: 'Command not recognized.' });
}

return res.status(200).json(parsed);
} catch (err) {
console.error(`${model} failed:`, err.message);
} catch {
continue;
}
}

// Generic failure response without leaking internal errors
return res.status(502).json({ error: 'All models unavailable. Try again in a moment.' });
}
Loading