NewsAnalyze / src /server /validation.ts
GeoStrat-CI
Deploy 5924376 to Hugging Face Space [skip ci]
2c0e275
Raw History Blame Contribute Delete
10.6 kB
/**
* Request Input Validation
* Pure parsing helpers shared by the HTTP API and the Telegram bot.
* Each parser returns a normalized value or throws InputValidationError.
*/
export class InputValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "InputValidationError";
}
}
export const HORIZON_MIN_YEARS = 1;
export const HORIZON_MAX_YEARS = 30;
export const VAR_MAX_PERIODS = 40;
export const QUERY_MAX_LENGTH = 1000;
export const LIVE_WIRE_CATEGORIES = [
"all",
"sovereign_decisions",
"economic",
"political",
"local",
"international",
"saudi_gulf",
"global_macro",
"energy_markets",
"trade_policy",
] as const;
export type LiveWireCategory = (typeof LIVE_WIRE_CATEGORIES)[number];
const ISO3_REGEX = /^[A-Z]{3}$/;
const INDICATOR_REGEX = /^[A-Za-z0-9._]{1,40}$/;
const TELEGRAM_TOKEN_REGEX = /^\d{5,15}:[A-Za-z0-9_-]{30,64}$/;
function firstValue(raw: unknown): unknown {
return Array.isArray(raw) ? raw[0] : raw;
}
const COUNTRY_MAP: Record<string, string> = {
// Saudi Arabia
SAU: "SAU", SA: "SAU", KSA: "SAU", SAUDI: "SAU", "SAUDI ARABIA": "SAU",
"السعودية": "SAU", "المملكة العربية السعودية": "SAU", "المملكة": "SAU", "الرياض": "SAU",
// Egypt
EGY: "EGY", EG: "EGY", EGYPT: "EGY", "مصر": "EGY", "جمهورية مصر العربية": "EGY", "القاهرة": "EGY",
// United States
USA: "USA", US: "USA", AMERICA: "USA", "UNITED STATES": "USA", "UNITED STATES OF AMERICA": "USA",
"أمريكا": "USA", "امريكا": "USA", "الولايات المتحدة": "USA", "الولايات المتحدة الأمريكية": "USA", "واشنطن": "USA",
// United Arab Emirates
ARE: "ARE", UAE: "ARE", EMIRATES: "ARE", "الإمارات": "ARE", "الامارات": "ARE", "الإمارات العربية المتحدة": "ARE", "أبوظبي": "ARE", "دبي": "ARE",
// Qatar
QAT: "QAT", QA: "QAT", QATAR: "QAT", "قطر": "QAT", "دولة قطر": "QAT", "الدوحة": "QAT",
// Kuwait
KWT: "KWT", KW: "KWT", KUWAIT: "KWT", "الكويت": "KWT", "دولة الكويت": "KWT",
// Oman
OMN: "OMN", OM: "OMN", OMAN: "OMN", "عمان": "OMN", "سلطنة عمان": "OMN", "مسقط": "OMN",
// Bahrain
BHR: "BHR", BH: "BHR", BAHRAIN: "BHR", "البحرين": "BHR", "مملكة البحرين": "BHR",
// Jordan
JOR: "JOR", JO: "JOR", JORDAN: "JOR", "الأردن": "JOR", "الاردن": "JOR", "المملكة الأردنية الهاشمية": "JOR",
// Turkey
TUR: "TUR", TR: "TUR", TURKEY: "TUR", TURKIYE: "TUR", "TÜRKIYE": "TUR", "تركيا": "TUR", "أنقرة": "TUR",
// Germany
DEU: "DEU", DE: "DEU", GERMANY: "DEU", DEUTSCHLAND: "DEU", "ألمانيا": "DEU", "المانيا": "DEU", "برلين": "DEU",
// Japan
JPN: "JPN", JP: "JPN", JAPAN: "JPN", "اليابان": "JPN", "طوكيو": "JPN",
// United Kingdom
GBR: "GBR", GB: "GBR", UK: "GBR", "GREAT BRITAIN": "GBR", "UNITED KINGDOM": "GBR", "المملكة المتحدة": "GBR", "بريطانيا": "GBR", "لندن": "GBR",
// China
CHN: "CHN", CN: "CHN", CHINA: "CHN", "الصين": "CHN", "بكين": "CHN",
// Russia
RUS: "RUS", RU: "RUS", RUSSIA: "RUS", "روسيا": "RUS", "موسكو": "RUS",
// Iran
IRN: "IRN", IR: "IRN", IRAN: "IRN", "إيران": "IRN", "ايران": "IRN", "طهران": "IRN",
// Iraq
IRQ: "IRQ", IQ: "IRQ", IRAQ: "IRQ", "العراق": "IRQ", "بغداد": "IRQ",
// Syria
SYR: "SYR", SY: "SYR", SYRIA: "SYR", "سوريا": "SYR", "دمشق": "SYR",
// Lebanon
LBN: "LBN", LB: "LBN", LEBANON: "LBN", "لبنان": "LBN", "بيروت": "LBN",
// Palestine
PSE: "PSE", PS: "PSE", PALESTINE: "PSE", "فلسطين": "PSE", "القدس": "PSE", "غزة": "PSE",
// Israel
ISR: "ISR", IL: "ISR", ISRAEL: "ISR", "إسرائيل": "ISR", "اسرائيل": "ISR", "تل أبيب": "ISR", "نتنياهو": "ISR", "NETANYAHU": "ISR",
// Ukraine
UKR: "UKR", UA: "UKR", UKRAINE: "UKR", "أوكرانيا": "UKR", "اوكرانيا": "UKR", "كييف": "UKR",
// Norway & Finland
NOR: "NOR", NO: "NOR", NORWAY: "NOR", "النرويج": "NOR", "أوسلو": "NOR",
FIN: "FIN", FI: "FIN", FINLAND: "FIN", "فنلندا": "FIN", "هلسنكي": "FIN",
// Sudan, Morocco, Algeria, Tunisia
SDN: "SDN", SD: "SDN", SUDAN: "SDN", "السودان": "SDN", "الخرطوم": "SDN",
MAR: "MAR", MA: "MAR", MOROCCO: "MAR", "المغرب": "MAR", "الرباط": "MAR",
DZA: "DZA", DZ: "DZA", ALGERIA: "DZA", "الجزائر": "DZA",
TUN: "TUN", TN: "TUN", TUNISIA: "TUN", "تونس": "TUN",
// Yemen
YEM: "YEM", YE: "YEM", YEMEN: "YEM", "اليمن": "YEM", "صنعاء": "YEM",
// France
FRA: "FRA", FR: "FRA", FRANCE: "FRA", "فرنسا": "FRA", "باريس": "FRA",
// India
IND: "IND", IN: "IND", INDIA: "IND", "الهند": "IND",
// Brazil
BRA: "BRA", BR: "BRA", BRAZIL: "BRA", "البرازيل": "BRA",
};
export function detectCountryFromHeadline(text: string, defaultIso: string = "SAU"): string {
if (!text) return defaultIso;
const lower = text.toLowerCase();
for (const [key, isoCode] of Object.entries(COUNTRY_MAP)) {
if (key.length > 2 && lower.includes(key.toLowerCase())) {
return isoCode;
}
}
return defaultIso;
}
export function parseIso3(raw: unknown, fallback?: string): string {
const value = firstValue(raw);
if (value === undefined || value === null || value === "") {
if (fallback !== undefined) return fallback;
throw new InputValidationError("country is required");
}
if (typeof value !== "string") {
throw new InputValidationError("country must be a string");
}
const str = value.trim();
if (!str) {
if (fallback !== undefined) return fallback;
throw new InputValidationError("country cannot be empty");
}
const upper = str.toUpperCase();
if (ISO3_REGEX.test(upper)) {
return upper;
}
// Check full country name if key is longer than 2 characters (e.g. SAUDI ARABIA, GERMANY, مصر)
if (COUNTRY_MAP[upper] && upper.length > 2) {
return COUNTRY_MAP[upper];
}
if (COUNTRY_MAP[str] && str.length > 2) {
return COUNTRY_MAP[str];
}
throw new InputValidationError(`Invalid country ISO3 code: ${str}`);
}
export function parseHorizonYears(raw: unknown, fallback: number = 15): number {
const value = firstValue(raw);
if (value === undefined || value === null || value === "") return fallback;
const num = typeof value === "number" ? value : Number(value);
if (!Number.isInteger(num) || num < HORIZON_MIN_YEARS || num > HORIZON_MAX_YEARS) {
throw new InputValidationError(
`horizon_years must be an integer between ${HORIZON_MIN_YEARS} and ${HORIZON_MAX_YEARS}`
);
}
return num;
}
export function parseQueryText(raw: unknown, fallback: string, field: string = "query"): string {
const value = firstValue(raw);
if (value === undefined || value === null) return fallback;
if (typeof value !== "string") throw new InputValidationError(`${field} must be a string`);
const text = value.trim();
if (!text) return fallback;
if (text.length > QUERY_MAX_LENGTH) {
throw new InputValidationError(`${field} must be at most ${QUERY_MAX_LENGTH} characters`);
}
return text;
}
export function parseAlpha(raw: unknown, fallback: number = 0.6): number {
const value = firstValue(raw);
if (value === undefined || value === null || value === "") return fallback;
const num = Number(value);
if (!Number.isFinite(num) || num < 0 || num > 1) {
throw new InputValidationError("alpha must be a number between 0 and 1");
}
return num;
}
export function parseIndicatorCode(raw: unknown, fallback: string): string {
const value = firstValue(raw);
if (value === undefined || value === null || value === "") return fallback;
if (typeof value !== "string" || !INDICATOR_REGEX.test(value.trim())) {
throw new InputValidationError("indicator must be an alphanumeric series code (e.g. NY.GDP.MKTP.KD.ZG)");
}
return value.trim();
}
export function parseMacroSource(raw: unknown): "WorldBank" | "IMF" | "FRED" {
const value = firstValue(raw);
if (value === undefined || value === null || value === "") return "WorldBank";
if (value === "WorldBank" || value === "IMF" || value === "FRED") return value;
throw new InputValidationError("source must be one of WorldBank, IMF, FRED");
}
export function parseLiveWireCategory(raw: unknown): LiveWireCategory {
const value = firstValue(raw);
if (value === undefined || value === null || value === "") return "all";
if (typeof value === "string" && (LIVE_WIRE_CATEGORIES as readonly string[]).includes(value)) {
return value as LiveWireCategory;
}
throw new InputValidationError(`category must be one of: ${LIVE_WIRE_CATEGORIES.join(", ")}`);
}
export function isValidTelegramBotToken(token: unknown): token is string {
return typeof token === "string" && TELEGRAM_TOKEN_REGEX.test(token.trim());
}
/**
* Clamps a numeric value into [min, max], falling back when it is not a finite number.
* Used inside simulation templates as a last line of defense against oversized workloads.
*/
export function clampInteger(raw: unknown, min: number, max: number, fallback: number): number {
const num = Number(raw);
if (!Number.isFinite(num)) return fallback;
return Math.min(max, Math.max(min, Math.round(num)));
}
export function parseBoundedNumber(raw: unknown, min: number, max: number, field: string): number | undefined {
if (raw === undefined || raw === null || raw === "") return undefined;
const num = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(num) || num < min || num > max) {
throw new InputValidationError(`${field} must be a number between ${min} and ${max}`);
}
return num;
}
export function parseEnum<T extends string>(raw: unknown, allowed: readonly T[], fallback: T, field: string): T {
if (raw === undefined || raw === null || raw === "") return fallback;
if (typeof raw === "string" && (allowed as readonly string[]).includes(raw)) return raw as T;
throw new InputValidationError(`${field} must be one of: ${allowed.join(", ")}`);
}
/** Like parseIso3, but also accepts "GLOBAL", which the news wire uses for non-country feeds. */
export function parseIso3OrGlobal(raw: unknown, fallback: string): string {
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value === "string" && value.trim().toUpperCase() === "GLOBAL") return "GLOBAL";
return parseIso3(raw, fallback);
}