/** * Temporal Baseline Anomaly Detection API * Stores and queries activity baselines using Welford's online algorithm * Backed by Upstash Redis for cross-user persistence * * GET ?type=military_flights®ion=global&count=47 — check anomaly * POST { updates: [{ type, region, count }] } — batch update baselines */ import { getCachedJson, setCachedJson, mget } from './_upstash-cache.js'; import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; export const config = { runtime: 'edge', }; const BASELINE_TTL = 7776000; // 90 days in seconds const MIN_SAMPLES = 10; const Z_THRESHOLD_LOW = 1.5; const Z_THRESHOLD_MEDIUM = 2.0; const Z_THRESHOLD_HIGH = 3.0; const VALID_TYPES = ['military_flights', 'vessels', 'protests', 'news', 'ais_gaps', 'satellite_fires']; function makeKey(type, region, weekday, month) { return `baseline:${type}:${region}:${weekday}:${month}`; } function getSeverity(zScore) { if (zScore >= Z_THRESHOLD_HIGH) return 'critical'; if (zScore >= Z_THRESHOLD_MEDIUM) return 'high'; if (zScore >= Z_THRESHOLD_LOW) return 'medium'; return 'normal'; } export default async function handler(request) { const corsHeaders = getCorsHeaders(request, 'GET, POST, OPTIONS'); if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers: corsHeaders }); } if (isDisallowedOrigin(request)) { return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } try { if (request.method === 'GET') { return await handleGet(request); } else if (request.method === 'POST') { return await handlePost(request); } return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } catch (err) { console.error('[TemporalBaseline] Error:', err); return new Response(JSON.stringify({ error: 'Internal error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } async function handleGet(request) { const { searchParams } = new URL(request.url); const type = searchParams.get('type'); const region = searchParams.get('region') || 'global'; const count = parseFloat(searchParams.get('count')); if (!type || !VALID_TYPES.includes(type) || isNaN(count)) { return json({ error: 'Missing or invalid params: type, count required' }, 400); } const now = new Date(); const weekday = now.getUTCDay(); const month = now.getUTCMonth() + 1; const key = makeKey(type, region, weekday, month); const baseline = await getCachedJson(key); if (!baseline || baseline.sampleCount < MIN_SAMPLES) { return json({ anomaly: null, learning: true, sampleCount: baseline?.sampleCount || 0, samplesNeeded: MIN_SAMPLES, }); } const variance = Math.max(0, baseline.m2 / (baseline.sampleCount - 1)); const stdDev = Math.sqrt(variance); const zScore = stdDev > 0 ? Math.abs((count - baseline.mean) / stdDev) : 0; const severity = getSeverity(zScore); const multiplier = baseline.mean > 0 ? Math.round((count / baseline.mean) * 100) / 100 : count > 0 ? 999 : 1; return json({ anomaly: zScore >= Z_THRESHOLD_LOW ? { zScore: Math.round(zScore * 100) / 100, severity, multiplier, } : null, baseline: { mean: Math.round(baseline.mean * 100) / 100, stdDev: Math.round(stdDev * 100) / 100, sampleCount: baseline.sampleCount, }, learning: false, }); } async function handlePost(request) { const contentLength = parseInt(request.headers.get('content-length') || '0', 10); if (contentLength > 51200) { return json({ error: 'Payload too large' }, 413); } const body = await request.json(); const updates = body?.updates; if (!Array.isArray(updates) || updates.length === 0) { return json({ error: 'Body must have updates array' }, 400); } const batch = updates.slice(0, 20); const now = new Date(); const weekday = now.getUTCDay(); const month = now.getUTCMonth() + 1; const keys = batch.map(u => makeKey(u.type, u.region || 'global', weekday, month)); const existing = await mget(...keys); const writes = []; for (let i = 0; i < batch.length; i++) { const { type, region = 'global', count } = batch[i]; if (!VALID_TYPES.includes(type) || typeof count !== 'number' || isNaN(count)) continue; const prev = existing[i] || { mean: 0, m2: 0, sampleCount: 0 }; const n = prev.sampleCount + 1; const delta = count - prev.mean; const newMean = prev.mean + delta / n; const delta2 = count - newMean; const newM2 = prev.m2 + delta * delta2; writes.push(setCachedJson(keys[i], { mean: newMean, m2: newM2, sampleCount: n, lastUpdated: now.toISOString(), }, BASELINE_TTL)); } if (writes.length > 0) { await Promise.all(writes); } return json({ updated: writes.length }); } function json(data, status = 200) { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }, }); }