diff --git a/api/oref-alerts.js b/api/oref-alerts.js
new file mode 100644
index 000000000..0276154cb
--- /dev/null
+++ b/api/oref-alerts.js
@@ -0,0 +1,93 @@
+import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
+
+export const config = { runtime: 'edge' };
+
+function getRelayBaseUrl() {
+ const relayUrl = process.env.WS_RELAY_URL;
+ if (!relayUrl) return null;
+ return relayUrl.replace('wss://', 'https://').replace('ws://', 'http://').replace(/\/$/, '');
+}
+
+function getRelayHeaders(baseHeaders = {}) {
+ const headers = { ...baseHeaders };
+ const relaySecret = process.env.RELAY_SHARED_SECRET || '';
+ if (relaySecret) {
+ const relayHeader = (process.env.RELAY_AUTH_HEADER || 'x-relay-key').toLowerCase();
+ headers[relayHeader] = relaySecret;
+ headers.Authorization = `Bearer ${relaySecret}`;
+ }
+ return headers;
+}
+
+async function fetchWithTimeout(url, options, timeoutMs = 15000) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ return await fetch(url, { ...options, signal: controller.signal });
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+export default async function handler(req) {
+ const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
+
+ if (isDisallowedOrigin(req)) {
+ return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
+ status: 403,
+ headers: { 'Content-Type': 'application/json', ...corsHeaders },
+ });
+ }
+
+ if (req.method === 'OPTIONS') {
+ return new Response(null, { status: 204, headers: corsHeaders });
+ }
+ if (req.method !== 'GET') {
+ return new Response(JSON.stringify({ error: 'Method not allowed' }), {
+ status: 405,
+ headers: { 'Content-Type': 'application/json', ...corsHeaders },
+ });
+ }
+
+ const requestUrl = new URL(req.url);
+ const endpoint = requestUrl.searchParams.get('endpoint');
+ const isHistory = endpoint === 'history';
+
+ const relayBaseUrl = getRelayBaseUrl();
+
+ if (relayBaseUrl) {
+ try {
+ const relayPath = isHistory ? '/oref/history' : '/oref/alerts';
+ const response = await fetchWithTimeout(`${relayBaseUrl}${relayPath}`, {
+ headers: getRelayHeaders({ Accept: 'application/json' }),
+ }, 12000);
+
+ if (response.ok) {
+ const cacheControl = isHistory
+ ? 'public, max-age=30, s-maxage=30, stale-while-revalidate=10'
+ : 'public, max-age=5, s-maxage=5, stale-while-revalidate=3';
+ return new Response(await response.text(), {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': cacheControl,
+ ...corsHeaders,
+ },
+ });
+ }
+ } catch {
+ // Relay failed
+ }
+ }
+
+ return new Response(JSON.stringify({
+ configured: false,
+ alerts: [],
+ historyCount24h: 0,
+ timestamp: new Date().toISOString(),
+ error: 'No data source available',
+ }), {
+ status: 503,
+ headers: { 'Content-Type': 'application/json', ...corsHeaders },
+ });
+}
diff --git a/scripts/ais-relay.cjs b/scripts/ais-relay.cjs
index 2abfcc316..c1016bbf1 100644
--- a/scripts/ais-relay.cjs
+++ b/scripts/ais-relay.cjs
@@ -73,6 +73,14 @@ const RELAY_RSS_RATE_LIMIT_MAX = Number.isFinite(Number(process.env.RELAY_RSS_RA
const RELAY_LOG_THROTTLE_MS = Math.max(1000, Number(process.env.RELAY_LOG_THROTTLE_MS || 10000));
const ALLOW_VERCEL_PREVIEW_ORIGINS = process.env.ALLOW_VERCEL_PREVIEW_ORIGINS === 'true';
+// OREF (Israel Home Front Command) siren alerts — fetched via HTTP proxy (Israel exit)
+const OREF_PROXY_AUTH = process.env.OREF_PROXY_AUTH || ''; // format: user:pass@host:port
+const OREF_ALERTS_URL = 'https://www.oref.org.il/WarningMessages/alert/alerts.json';
+const OREF_POLL_INTERVAL_MS = Math.max(30_000, Number(process.env.OREF_POLL_INTERVAL_MS || 300_000));
+const OREF_ENABLED = !!OREF_PROXY_AUTH;
+const RELAY_OREF_RATE_LIMIT_MAX = Number.isFinite(Number(process.env.RELAY_OREF_RATE_LIMIT_MAX))
+ ? Number(process.env.RELAY_OREF_RATE_LIMIT_MAX) : 600;
+
if (IS_PRODUCTION_RELAY && !RELAY_SHARED_SECRET && !ALLOW_UNAUTHENTICATED_RELAY) {
console.error('[Relay] Error: RELAY_SHARED_SECRET is required in production');
console.error('[Relay] Set RELAY_SHARED_SECRET on Railway and Vercel to secure relay endpoints');
@@ -162,6 +170,15 @@ const telegramState = {
startedAt: Date.now(),
};
+const orefState = {
+ lastAlerts: [],
+ lastAlertsJson: '[]',
+ lastPollAt: 0,
+ lastError: null,
+ historyCount24h: 0,
+ history: [],
+};
+
function loadTelegramChannels() {
// Product-managed curated list lives in repo root under data/ (shared by web + desktop).
// Relay is executed from scripts/, so resolve ../data.
@@ -332,6 +349,79 @@ function startTelegramPollLoop() {
console.log('[Relay] Telegram poll loop started');
}
+// ─────────────────────────────────────────────────────────────
+// OREF Siren Alerts (Israel Home Front Command)
+// Polls oref.org.il via HTTP CONNECT tunnel through residential proxy (Israel exit)
+// ─────────────────────────────────────────────────────────────
+
+function stripBom(text) {
+ return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
+}
+
+function orefCurlFetch(proxyAuth, url) {
+ // Use curl via child_process — Node.js TLS fingerprint (JA3) gets blocked by Akamai,
+ // but curl's fingerprint passes. curl is available on Railway (Linux) and macOS.
+ const { execSync } = require('child_process');
+ const proxyUrl = `http://${proxyAuth}`;
+ const result = execSync(
+ `curl -s -x "${proxyUrl}" --max-time 15 -H "Accept: application/json" -H "Referer: https://www.oref.org.il/" "${url}"`,
+ { encoding: 'utf8', timeout: 20000 }
+ );
+ return result;
+}
+
+async function orefFetchAlerts() {
+ if (!OREF_ENABLED) return;
+ try {
+ const raw = orefCurlFetch(OREF_PROXY_AUTH, OREF_ALERTS_URL);
+ const cleaned = stripBom(raw).trim();
+
+ let alerts = [];
+ if (cleaned && cleaned !== '[]' && cleaned !== 'null') {
+ try {
+ const parsed = JSON.parse(cleaned);
+ alerts = Array.isArray(parsed) ? parsed : [parsed];
+ } catch { alerts = []; }
+ }
+
+ const newJson = JSON.stringify(alerts);
+ const changed = newJson !== orefState.lastAlertsJson;
+
+ orefState.lastAlerts = alerts;
+ orefState.lastAlertsJson = newJson;
+ orefState.lastPollAt = Date.now();
+ orefState.lastError = null;
+
+ if (changed && alerts.length > 0) {
+ orefState.history.push({
+ alerts,
+ timestamp: new Date().toISOString(),
+ });
+ }
+
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
+ orefState.history = orefState.history.filter(
+ h => new Date(h.timestamp).getTime() > cutoff
+ );
+ orefState.historyCount24h = orefState.history.length;
+ } catch (err) {
+ orefState.lastError = err.message || String(err);
+ console.warn('[Relay] OREF poll error:', orefState.lastError);
+ }
+}
+
+function startOrefPollLoop() {
+ if (!OREF_ENABLED) {
+ console.log('[Relay] OREF disabled (no OREF_PROXY_AUTH)');
+ return;
+ }
+ orefFetchAlerts().catch(e => console.warn('[Relay] OREF initial poll error:', e?.message || e));
+ setInterval(() => {
+ orefFetchAlerts().catch(e => console.warn('[Relay] OREF poll error:', e?.message || e));
+ }, OREF_POLL_INTERVAL_MS).unref?.();
+ console.log(`[Relay] OREF poll loop started (interval ${OREF_POLL_INTERVAL_MS}ms)`);
+}
+
function gzipSyncBuffer(body) {
try {
return zlib.gzipSync(typeof body === 'string' ? Buffer.from(body) : body);
@@ -386,12 +476,14 @@ function getRouteGroup(pathname) {
if (pathname.startsWith('/worldbank')) return 'worldbank';
if (pathname.startsWith('/polymarket')) return 'polymarket';
if (pathname.startsWith('/ucdp-events')) return 'ucdp-events';
+ if (pathname.startsWith('/oref')) return 'oref';
return 'other';
}
function getRateLimitForPath(pathname) {
if (pathname.startsWith('/opensky')) return RELAY_OPENSKY_RATE_LIMIT_MAX;
if (pathname.startsWith('/rss')) return RELAY_RSS_RATE_LIMIT_MAX;
+ if (pathname.startsWith('/oref')) return RELAY_OREF_RATE_LIMIT_MAX;
return RELAY_RATE_LIMIT_MAX;
}
@@ -2140,6 +2232,13 @@ const server = http.createServer(async (req, res) => {
lastPollAt: telegramState.lastPollAt ? new Date(telegramState.lastPollAt).toISOString() : null,
hasError: !!telegramState.lastError,
},
+ oref: {
+ enabled: OREF_ENABLED,
+ alertCount: orefState.lastAlerts?.length || 0,
+ historyCount24h: orefState.historyCount24h,
+ lastPollAt: orefState.lastPollAt ? new Date(orefState.lastPollAt).toISOString() : null,
+ hasError: !!orefState.lastError,
+ },
memory: {
rss: `${(mem.rss / 1024 / 1024).toFixed(0)}MB`,
heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB`,
@@ -2506,6 +2605,27 @@ const server = http.createServer(async (req, res) => {
res.end(JSON.stringify({ error: err.message }));
}
}
+ } else if (pathname === '/oref/alerts') {
+ sendCompressed(req, res, 200, {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'public, max-age=5, s-maxage=5, stale-while-revalidate=3',
+ }, JSON.stringify({
+ configured: OREF_ENABLED,
+ alerts: orefState.lastAlerts || [],
+ historyCount24h: orefState.historyCount24h,
+ timestamp: orefState.lastPollAt ? new Date(orefState.lastPollAt).toISOString() : new Date().toISOString(),
+ ...(orefState.lastError ? { error: orefState.lastError } : {}),
+ }));
+ } else if (pathname === '/oref/history') {
+ sendCompressed(req, res, 200, {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=10',
+ }, JSON.stringify({
+ configured: OREF_ENABLED,
+ history: orefState.history || [],
+ historyCount24h: orefState.historyCount24h,
+ timestamp: orefState.lastPollAt ? new Date(orefState.lastPollAt).toISOString() : new Date().toISOString(),
+ }));
} else if (pathname.startsWith('/ucdp-events')) {
handleUcdpEventsRequest(req, res);
} else if (pathname.startsWith('/opensky')) {
@@ -2625,6 +2745,7 @@ const wss = new WebSocketServer({ server });
server.listen(PORT, () => {
console.log(`[Relay] WebSocket relay on port ${PORT}`);
startTelegramPollLoop();
+ startOrefPollLoop();
});
wss.on('connection', (ws, req) => {
diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts
index 0668da883..67b8acc7c 100644
--- a/src/app/data-loader.ts
+++ b/src/app/data-loader.ts
@@ -72,6 +72,7 @@ import { fetchConflictEvents, fetchUcdpClassifications, fetchHapiSummary, fetchU
import { fetchUnhcrPopulation } from '@/services/displacement';
import { fetchClimateAnomalies } from '@/services/climate';
import { fetchSecurityAdvisories } from '@/services/security-advisories';
+import { fetchOrefAlerts, startOrefPolling, stopOrefPolling, onOrefAlertsUpdate } from '@/services/oref-alerts';
import { enrichEventsWithExposure } from '@/services/population-exposure';
import { debounce, getCircuitBreakerCooldownInfo } from '@/utils';
import { isFeatureAvailable } from '@/services/runtime-config';
@@ -101,6 +102,7 @@ import {
TradePolicyPanel,
SupplyChainPanel,
SecurityAdvisoriesPanel,
+ OrefSirensPanel,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { classifyNewsItem } from '@/services/positive-classifier';
@@ -143,7 +145,9 @@ export class DataLoaderManager implements AppModule {
init(): void {}
- destroy(): void {}
+ destroy(): void {
+ stopOrefPolling();
+ }
private shouldShowIntelligenceNotifications(): boolean {
return !this.ctx.isMobile && !!this.ctx.findingsBadge?.isPopupEnabled();
@@ -1072,6 +1076,20 @@ export class DataLoaderManager implements AppModule {
// Security advisories
tasks.push(this.loadSecurityAdvisories());
+ // OREF sirens
+ tasks.push((async () => {
+ try {
+ const data = await fetchOrefAlerts();
+ (this.ctx.panels['oref-sirens'] as OrefSirensPanel)?.setData(data);
+ onOrefAlertsUpdate((update) => {
+ (this.ctx.panels['oref-sirens'] as OrefSirensPanel)?.setData(update);
+ });
+ startOrefPolling();
+ } catch (error) {
+ console.error('[Intelligence] OREF alerts fetch failed:', error);
+ }
+ })());
+
await Promise.allSettled(tasks);
try {
diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts
index b717d0f9f..b6cfe5e00 100644
--- a/src/app/panel-layout.ts
+++ b/src/app/panel-layout.ts
@@ -34,6 +34,7 @@ import {
TradePolicyPanel,
SupplyChainPanel,
SecurityAdvisoriesPanel,
+ OrefSirensPanel,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel';
@@ -548,6 +549,9 @@ export class PanelLayoutManager implements AppModule {
void this.callbacks.loadSecurityAdvisories?.();
});
this.ctx.panels['security-advisories'] = securityAdvisoriesPanel;
+
+ const orefSirensPanel = new OrefSirensPanel();
+ this.ctx.panels['oref-sirens'] = orefSirensPanel;
}
if (SITE_VARIANT === 'finance') {
diff --git a/src/components/OrefSirensPanel.ts b/src/components/OrefSirensPanel.ts
new file mode 100644
index 000000000..e35598ad7
--- /dev/null
+++ b/src/components/OrefSirensPanel.ts
@@ -0,0 +1,95 @@
+import { Panel } from './Panel';
+import { escapeHtml } from '@/utils/sanitize';
+import { t } from '@/services/i18n';
+import type { OrefAlertsResponse, OrefAlert } from '@/services/oref-alerts';
+
+export class OrefSirensPanel extends Panel {
+ private alerts: OrefAlert[] = [];
+ private historyCount24h = 0;
+
+ constructor() {
+ super({
+ id: 'oref-sirens',
+ title: t('panels.orefSirens'),
+ showCount: true,
+ trackActivity: true,
+ infoTooltip: t('components.orefSirens.infoTooltip'),
+ });
+ this.showLoading(t('components.orefSirens.checking'));
+ }
+
+ public setData(data: OrefAlertsResponse): void {
+ if (!data.configured) {
+ this.setContent(`
${t('components.orefSirens.notConfigured')}
`);
+ this.setCount(0);
+ return;
+ }
+
+ const prevCount = this.alerts.length;
+ this.alerts = data.alerts || [];
+ this.historyCount24h = data.historyCount24h || 0;
+ this.setCount(this.alerts.length);
+
+ if (prevCount === 0 && this.alerts.length > 0) {
+ this.setNewBadge(this.alerts.length);
+ }
+
+ this.render();
+ }
+
+ private formatAlertTime(dateStr: string): string {
+ try {
+ const diff = Date.now() - new Date(dateStr).getTime();
+ if (diff < 60_000) return t('components.orefSirens.justNow');
+ const mins = Math.floor(diff / 60_000);
+ if (mins < 60) return `${mins}m`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h`;
+ return `${Math.floor(hours / 24)}d`;
+ } catch {
+ return '';
+ }
+ }
+
+ private render(): void {
+ if (this.alerts.length === 0) {
+ this.setContent(`
+
+
+ ✅
+ ${t('components.orefSirens.noAlerts')}
+
+
+
+ `);
+ return;
+ }
+
+ const alertRows = this.alerts.slice(0, 20).map(alert => {
+ const areas = (alert.data || []).map(a => escapeHtml(a)).join(', ');
+ const time = this.formatAlertTime(alert.alertDate);
+ return ``;
+ }).join('');
+
+ this.setContent(`
+
+
+
+ ${t('components.orefSirens.activeSirens', { count: String(this.alerts.length) })}
+
+
${alertRows}
+
+
+ `);
+ }
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index 442ca4d27..5a4f12d8a 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -42,4 +42,5 @@ export * from './UnifiedSettings';
export * from './TradePolicyPanel';
export * from './SupplyChainPanel';
export * from './SecurityAdvisoriesPanel';
+export * from './OrefSirensPanel';
export * from './BreakingNewsBanner';
diff --git a/src/config/panels.ts b/src/config/panels.ts
index 7641e5c73..e3cb1fcb3 100644
--- a/src/config/panels.ts
+++ b/src/config/panels.ts
@@ -51,6 +51,7 @@ const FULL_PANELS: Record = {
climate: { name: 'Climate Anomalies', enabled: true, priority: 2 },
'population-exposure': { name: 'Population Exposure', enabled: true, priority: 2 },
'security-advisories': { name: 'Security Advisories', enabled: true, priority: 2 },
+ 'oref-sirens': { name: 'OREF Sirens', enabled: true, priority: 2 },
};
const FULL_MAP_LAYERS: MapLayers = {
@@ -604,7 +605,7 @@ export const PANEL_CATEGORY_MAP: Recordتنبيهات أمنية
تحذيرات السفر والتنبيهات الأمنية من الوكالات الحكومية."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/de.json b/src/locales/de.json
index 346855a02..106c0f866 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -204,7 +204,8 @@
"gccInvestments": "GCC-Investitionen",
"geoHubs": "Geopolitical Hotspots",
"liveWebcams": "Live-Webcams",
- "securityAdvisories": "Sicherheitshinweise"
+ "securityAdvisories": "Sicherheitshinweise",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "vor {{count}} Tagen"
},
"infoTooltip": "Sicherheitshinweise
Reisewarnungen und Sicherheitshinweise von Regierungsbehörden."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/el.json b/src/locales/el.json
index 8410018b4..ec77c8437 100644
--- a/src/locales/el.json
+++ b/src/locales/el.json
@@ -204,7 +204,8 @@
"gccInvestments": "Επενδύσεις GCC",
"geoHubs": "Γεωπολιτικά Κέντρα",
"liveWebcams": "Ζωντανές Κάμερες",
- "securityAdvisories": "Προειδοποιήσεις Ασφαλείας"
+ "securityAdvisories": "Προειδοποιήσεις Ασφαλείας",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1339,6 +1340,17 @@
"daysAgo": "{{count}} ημέρες πριν"
},
"infoTooltip": "Προειδοποιήσεις Ασφαλείας
Ταξιδιωτικές οδηγίες και προειδοποιήσεις ασφαλείας."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/en.json b/src/locales/en.json
index 23f5f74a6..3ca9d43d3 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -186,6 +186,7 @@
"climate": "Climate Anomalies",
"populationExposure": "Population Exposure",
"securityAdvisories": "Security Advisories",
+ "orefSirens": "OREF Sirens",
"startups": "Startups & VC",
"vcblogs": "VC Insights & Essays",
"regionalStartups": "Global Startup News",
@@ -1110,6 +1111,17 @@
},
"infoTooltip": "Security Advisories
Travel advisories and security alerts from government foreign affairs agencies:
Sources:
\uD83C\uDDFA\uD83C\uDDF8 US State Dept Travel Advisories
\uD83C\uDDE6\uD83C\uDDFA AU DFAT Smartraveller
\uD83C\uDDEC\uD83C\uDDE7 UK FCDO Travel Advice
\uD83C\uDDF3\uD83C\uDDFF NZ MFAT SafeTravel
Levels:
\uD83D\uDFE5 Do Not Travel
\uD83D\uDFE7 Reconsider Travel
\uD83D\uDFE8 Exercise Caution
\uD83D\uDFE9 Normal Precautions"
},
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
+ },
"satelliteFires": {
"noData": "No fire data available",
"region": "Region",
diff --git a/src/locales/es.json b/src/locales/es.json
index 4ac1717ee..a9735b458 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -204,7 +204,8 @@
"gccInvestments": "Inversiones del CCG",
"geoHubs": "Geopolitical Hotspots",
"liveWebcams": "Cámaras en Vivo",
- "securityAdvisories": "Alertas de Seguridad"
+ "securityAdvisories": "Alertas de Seguridad",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "hace {{count}} d"
},
"infoTooltip": "Alertas de Seguridad
Avisos de viaje y alertas de seguridad de agencias gubernamentales."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 42261fb26..d7643cba9 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -204,7 +204,8 @@
"gccInvestments": "Investissements du CCG",
"geoHubs": "Centres géopolitiques",
"liveWebcams": "Webcams en Direct",
- "securityAdvisories": "Avis de Sécurité"
+ "securityAdvisories": "Avis de Sécurité",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "il y a {{count}} j"
},
"infoTooltip": "Avis de Sécurité
Avis aux voyageurs et alertes de sécurité des agences gouvernementales."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/it.json b/src/locales/it.json
index b9823e033..baf390cce 100644
--- a/src/locales/it.json
+++ b/src/locales/it.json
@@ -204,7 +204,8 @@
"gccInvestments": "Investimenti GCC",
"geoHubs": "Hotspot geopolitici",
"liveWebcams": "Webcam in Diretta",
- "securityAdvisories": "Avvisi di Sicurezza"
+ "securityAdvisories": "Avvisi di Sicurezza",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} giorni fa"
},
"infoTooltip": "Avvisi di Sicurezza
Avvisi di viaggio e allerte di sicurezza dalle agenzie governative."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/ja.json b/src/locales/ja.json
index ea0cbc431..753af3ec6 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -204,7 +204,8 @@
"gccInvestments": "GCC投資",
"geoHubs": "地政学ハブ",
"liveWebcams": "ライブカメラ",
- "securityAdvisories": "セキュリティアドバイザリー"
+ "securityAdvisories": "セキュリティアドバイザリー",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}}日前"
},
"infoTooltip": "セキュリティアドバイザリー
各国政府の渡航情報と安全警告。"
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 7792d57d0..73462a81b 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -186,6 +186,7 @@
"climate": "기후 이상",
"populationExposure": "인구 노출",
"securityAdvisories": "보안 권고",
+ "orefSirens": "OREF Sirens",
"startups": "스타트업 & VC",
"vcblogs": "VC 인사이트 & 에세이",
"regionalStartups": "글로벌 스타트업 뉴스",
@@ -1106,6 +1107,17 @@
},
"infoTooltip": "보안 주의보
각국 외교부의 여행 주의보 및 보안 경보:
출처:
🇺🇸 미 국무부 여행 주의보
🇦🇺 호주 DFAT Smartraveller
🇬🇧 영국 FCDO 여행 안내
🇳🇿 뉴질랜드 MFAT SafeTravel
수준:
🟥 여행 금지
🟧 여행 재고
🟨 주의 필요
🟩 일반 주의"
},
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
+ },
"satelliteFires": {
"noData": "화재 데이터 없음",
"region": "지역",
diff --git a/src/locales/nl.json b/src/locales/nl.json
index c84004b68..8bf27431f 100644
--- a/src/locales/nl.json
+++ b/src/locales/nl.json
@@ -63,7 +63,8 @@
"polymarket": "Voorspellingen",
"climate": "Klimaatafwijkingen",
"liveWebcams": "Live Webcams",
- "securityAdvisories": "Veiligheidsadviezen"
+ "securityAdvisories": "Veiligheidsadviezen",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1171,6 +1172,17 @@
"daysAgo": "{{count}} dagen geleden"
},
"infoTooltip": "Veiligheidsadviezen
Reisadviezen en veiligheidswaarschuwingen van overheidsinstanties."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/pl.json b/src/locales/pl.json
index e054c1ff4..d9d917501 100644
--- a/src/locales/pl.json
+++ b/src/locales/pl.json
@@ -204,7 +204,8 @@
"gccInvestments": "Inwestycje GCC",
"geoHubs": "Geopolitical Hotspots",
"liveWebcams": "Kamery na Żywo",
- "securityAdvisories": "Ostrzeżenia Bezpieczeństwa"
+ "securityAdvisories": "Ostrzeżenia Bezpieczeństwa",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} dni temu"
},
"infoTooltip": "Ostrzeżenia Bezpieczeństwa
Ostrzeżenia podróżne i alerty bezpieczeństwa z agencji rządowych."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 35246e8aa..efc9f2617 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -63,7 +63,8 @@
"polymarket": "Previsões",
"climate": "Anomalias Climáticas",
"liveWebcams": "Câmeras ao Vivo",
- "securityAdvisories": "Alertas de Segurança"
+ "securityAdvisories": "Alertas de Segurança",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1171,6 +1172,17 @@
"daysAgo": "há {{count}} d"
},
"infoTooltip": "Alertas de Segurança
Avisos de viagem e alertas de segurança de agências governamentais."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/ru.json b/src/locales/ru.json
index e7307510a..714da52a0 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -204,7 +204,8 @@
"gccInvestments": "Инвестиции стран Залива",
"geoHubs": "Геополитические хабы",
"liveWebcams": "Веб-камеры",
- "securityAdvisories": "Предупреждения безопасности"
+ "securityAdvisories": "Предупреждения безопасности",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} дн. назад"
},
"infoTooltip": "Предупреждения безопасности
Рекомендации по поездкам и предупреждения от государственных ведомств."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/sv.json b/src/locales/sv.json
index 549f4591e..6e6b8f53c 100644
--- a/src/locales/sv.json
+++ b/src/locales/sv.json
@@ -63,7 +63,8 @@
"polymarket": "Förutsägelser",
"climate": "Klimatanomalier",
"liveWebcams": "Webbkameror",
- "securityAdvisories": "Säkerhetsvarningar"
+ "securityAdvisories": "Säkerhetsvarningar",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1171,6 +1172,17 @@
"daysAgo": "{{count}} dagar sedan"
},
"infoTooltip": "Säkerhetsvarningar
Resevarningar och säkerhetsmeddelanden från myndigheters utrikesdepartement."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/th.json b/src/locales/th.json
index f2fc0b9b6..a9918981e 100644
--- a/src/locales/th.json
+++ b/src/locales/th.json
@@ -204,7 +204,8 @@
"gccInvestments": "การลงทุน GCC",
"geoHubs": "ศูนย์กลางภูมิรัฐศาสตร์",
"liveWebcams": "เว็บแคมสด",
- "securityAdvisories": "คำเตือนด้านความปลอดภัย"
+ "securityAdvisories": "คำเตือนด้านความปลอดภัย",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} วันที่แล้ว"
},
"infoTooltip": "คำเตือนด้านความปลอดภัย
คำเตือนการเดินทางและความปลอดภัยจากหน่วยงานรัฐบาล."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/tr.json b/src/locales/tr.json
index f7cf13803..50d271ce8 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -204,7 +204,8 @@
"gccInvestments": "GCC Yatirimlari",
"geoHubs": "Jeopolitik Merkezler",
"liveWebcams": "Canli Web Kameralari",
- "securityAdvisories": "Güvenlik Uyarıları"
+ "securityAdvisories": "Güvenlik Uyarıları",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} gün önce"
},
"infoTooltip": "Güvenlik Uyarıları
Hükümet kurumlarından seyahat uyarıları ve güvenlik bildirimleri."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/vi.json b/src/locales/vi.json
index 49ffb23a7..205cef761 100644
--- a/src/locales/vi.json
+++ b/src/locales/vi.json
@@ -204,7 +204,8 @@
"gccInvestments": "Đầu tư GCC",
"geoHubs": "Trung tâm Địa chính trị",
"liveWebcams": "Webcam Trực tiếp",
- "securityAdvisories": "Cảnh báo An ninh"
+ "securityAdvisories": "Cảnh báo An ninh",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} ngày trước"
},
"infoTooltip": "Cảnh báo An ninh
Cảnh báo du lịch và an ninh từ các cơ quan chính phủ."
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 29a688f16..fb14c38c3 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -204,7 +204,8 @@
"gccInvestments": "GCC投资",
"geoHubs": "地缘政治枢纽",
"liveWebcams": "实时摄像头",
- "securityAdvisories": "安全警告"
+ "securityAdvisories": "安全警告",
+ "orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}}天前"
},
"infoTooltip": "安全警告
来自各国政府的旅行警告和安全提示。"
+ },
+ "orefSirens": {
+ "checking": "Checking OREF alerts...",
+ "noAlerts": "No active sirens — all clear",
+ "notConfigured": "OREF proxy not configured",
+ "activeSirens": "{{count}} active siren(s)",
+ "area": "Area",
+ "time": "Time",
+ "justNow": "just now",
+ "historyCount": "{{count}} alerts in last 24h",
+ "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).
Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {
diff --git a/src/services/oref-alerts.ts b/src/services/oref-alerts.ts
new file mode 100644
index 000000000..fdc899208
--- /dev/null
+++ b/src/services/oref-alerts.ts
@@ -0,0 +1,208 @@
+import { getApiBaseUrl } from '@/services/runtime';
+import { translateText } from '@/services/summarization';
+
+export interface OrefAlert {
+ id: string;
+ cat: string;
+ title: string;
+ data: string[];
+ desc: string;
+ alertDate: string;
+}
+
+export interface OrefAlertsResponse {
+ configured: boolean;
+ alerts: OrefAlert[];
+ historyCount24h: number;
+ timestamp: string;
+ error?: string;
+}
+
+export interface OrefHistoryEntry {
+ alerts: OrefAlert[];
+ timestamp: string;
+}
+
+export interface OrefHistoryResponse {
+ configured: boolean;
+ history: OrefHistoryEntry[];
+ historyCount24h: number;
+ timestamp: string;
+ error?: string;
+}
+
+let cachedResponse: OrefAlertsResponse | null = null;
+let lastFetchAt = 0;
+const CACHE_TTL = 8_000;
+let pollingInterval: ReturnType | null = null;
+let updateCallbacks: Array<(data: OrefAlertsResponse) => void> = [];
+
+const MAX_TRANSLATION_CACHE = 200;
+const translationCache = new Map();
+let translationPromise: Promise | null = null;
+
+const HEBREW_RE = /[\u0590-\u05FF]/;
+
+function hasHebrew(text: string): boolean {
+ return HEBREW_RE.test(text);
+}
+
+function alertNeedsTranslation(alert: OrefAlert): boolean {
+ return hasHebrew(alert.title) || alert.data.some(hasHebrew) || hasHebrew(alert.desc);
+}
+
+function escapeRegExp(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function buildTranslationPrompt(alerts: OrefAlert[]): string {
+ const lines: string[] = [];
+ for (const a of alerts) {
+ lines.push(`ALERT[${a.id}]: ${a.title || '(none)'}`);
+ lines.push(`AREAS[${a.id}]: ${a.data.join(', ') || '(none)'}`);
+ lines.push(`DESC[${a.id}]: ${a.desc || '(none)'}`);
+ }
+ return 'Translate each line from Hebrew to English. Keep the ALERT/AREAS/DESC labels and IDs exactly as-is. Only translate the text after the colon.\n' + lines.join('\n');
+}
+
+function parseTranslationResponse(raw: string, alerts: OrefAlert[]): void {
+ const lines = raw.split('\n');
+ for (const alert of alerts) {
+ const eid = escapeRegExp(alert.id);
+ const reAlert = new RegExp(`ALERT\\[${eid}\\]:\\s*(.+)`);
+ const reAreas = new RegExp(`AREAS\\[${eid}\\]:\\s*(.+)`);
+ const reDesc = new RegExp(`DESC\\[${eid}\\]:\\s*(.+)`);
+ let title = alert.title;
+ let areas = alert.data;
+ let desc = alert.desc;
+ for (const line of lines) {
+ const alertMatch = line.match(reAlert);
+ if (alertMatch?.[1]) title = alertMatch[1].trim();
+ const areasMatch = line.match(reAreas);
+ if (areasMatch?.[1]) areas = areasMatch[1].split(',').map(s => s.trim());
+ const descMatch = line.match(reDesc);
+ if (descMatch?.[1]) desc = descMatch[1].trim();
+ }
+ translationCache.set(alert.id, { title, data: areas, desc });
+ }
+ if (translationCache.size > MAX_TRANSLATION_CACHE) {
+ const excess = translationCache.size - MAX_TRANSLATION_CACHE;
+ const iter = translationCache.keys();
+ for (let i = 0; i < excess; i++) {
+ const k = iter.next().value;
+ if (k !== undefined) translationCache.delete(k);
+ }
+ }
+}
+
+function applyTranslations(alerts: OrefAlert[]): OrefAlert[] {
+ return alerts.map(a => {
+ const cached = translationCache.get(a.id);
+ if (cached) return { ...a, ...cached };
+ return a;
+ });
+}
+
+async function translateAlerts(alerts: OrefAlert[]): Promise {
+ const untranslated = alerts.filter(a => !translationCache.has(a.id) && alertNeedsTranslation(a));
+ if (!untranslated.length) {
+ if (translationPromise) await translationPromise;
+ return false;
+ }
+
+ if (translationPromise) {
+ await translationPromise;
+ return translateAlerts(alerts);
+ }
+
+ let translated = false;
+ translationPromise = (async () => {
+ try {
+ const prompt = buildTranslationPrompt(untranslated);
+ const result = await translateText(prompt, 'en');
+ if (result) {
+ parseTranslationResponse(result, untranslated);
+ translated = true;
+ }
+ } catch (e) {
+ console.warn('OREF alert translation failed', e);
+ } finally {
+ translationPromise = null;
+ }
+ return translated;
+ })();
+
+ await translationPromise;
+ return translated;
+}
+
+function getOrefApiUrl(endpoint?: string): string {
+ const base = getApiBaseUrl();
+ const suffix = endpoint ? `?endpoint=${endpoint}` : '';
+ return `${base}/api/oref-alerts${suffix}`;
+}
+
+export async function fetchOrefAlerts(): Promise {
+ const now = Date.now();
+ if (cachedResponse && now - lastFetchAt < CACHE_TTL) {
+ return { ...cachedResponse, alerts: applyTranslations(cachedResponse.alerts) };
+ }
+
+ try {
+ const res = await fetch(getOrefApiUrl(), {
+ headers: { Accept: 'application/json' },
+ });
+ if (!res.ok) {
+ return { configured: false, alerts: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: `HTTP ${res.status}` };
+ }
+ const data: OrefAlertsResponse = await res.json();
+ cachedResponse = data;
+ lastFetchAt = now;
+
+ if (data.alerts.length) {
+ translateAlerts(data.alerts).then((didTranslate) => {
+ if (didTranslate) {
+ for (const cb of updateCallbacks) cb({ ...data, alerts: applyTranslations(data.alerts) });
+ }
+ }).catch(() => {});
+ }
+
+ return { ...data, alerts: applyTranslations(data.alerts) };
+ } catch (err) {
+ return { configured: false, alerts: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: String(err) };
+ }
+}
+
+export async function fetchOrefHistory(): Promise {
+ try {
+ const res = await fetch(getOrefApiUrl('history'), {
+ headers: { Accept: 'application/json' },
+ });
+ if (!res.ok) {
+ return { configured: false, history: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: `HTTP ${res.status}` };
+ }
+ return await res.json();
+ } catch (err) {
+ return { configured: false, history: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: String(err) };
+ }
+}
+
+export function onOrefAlertsUpdate(cb: (data: OrefAlertsResponse) => void): void {
+ updateCallbacks.push(cb);
+}
+
+export function startOrefPolling(): void {
+ if (pollingInterval) return;
+ pollingInterval = setInterval(async () => {
+ const data = await fetchOrefAlerts();
+ for (const cb of updateCallbacks) cb(data);
+ }, 10_000);
+}
+
+export function stopOrefPolling(): void {
+ if (pollingInterval) {
+ clearInterval(pollingInterval);
+ pollingInterval = null;
+ }
+ updateCallbacks = [];
+}
diff --git a/src/styles/panels.css b/src/styles/panels.css
index 0d18e8ba8..3a19399fe 100644
--- a/src/styles/panels.css
+++ b/src/styles/panels.css
@@ -259,3 +259,25 @@
.sa-footer-source { font-size: 9px; color: var(--text-muted); }
.sa-refresh-btn { background: transparent; border: 1px solid var(--border); color: var(--text-dim); padding: 3px 10px; font-size: 10px; cursor: pointer; border-radius: 3px; }
.sa-refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
+
+/* ----------------------------------------------------------
+ OREF Sirens Panel
+ ---------------------------------------------------------- */
+.oref-panel-content { font-size: 12px; }
+.oref-status { display: flex; align-items: center; gap: 8px; padding: 10px; border-radius: 4px; margin-bottom: 8px; font-weight: 600; }
+.oref-ok { background: color-mix(in srgb, var(--semantic-normal) 10%, transparent); color: var(--semantic-normal); }
+.oref-danger { background: color-mix(in srgb, var(--semantic-critical) 12%, transparent); color: var(--semantic-critical); }
+.oref-status-icon { font-size: 16px; }
+.oref-pulse { width: 10px; height: 10px; border-radius: 50%; background: var(--semantic-critical); animation: oref-pulse-anim 1.2s ease-in-out infinite; flex-shrink: 0; }
+@keyframes oref-pulse-anim {
+ 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--semantic-critical) 60%, transparent); }
+ 50% { opacity: 0.6; box-shadow: 0 0 0 6px transparent; }
+}
+.oref-list { display: flex; flex-direction: column; gap: 4px; }
+.oref-alert-row { padding: 8px; border-radius: 4px; border-left: 3px solid var(--semantic-critical); background: var(--overlay-subtle); }
+.oref-alert-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 3px; }
+.oref-alert-title { font-size: 11px; font-weight: 600; color: var(--text-primary); }
+.oref-alert-time { font-size: 9px; color: var(--text-muted); }
+.oref-alert-areas { font-size: 10px; color: var(--text-secondary); line-height: 1.35; }
+.oref-footer { display: flex; align-items: center; justify-content: center; margin-top: 8px; padding-top: 6px; border-top: 1px solid var(--border-subtle); }
+.oref-history { font-size: 9px; color: var(--text-muted); }
diff --git a/tests/oref-proxy.test.mjs b/tests/oref-proxy.test.mjs
new file mode 100644
index 000000000..c9491ed37
--- /dev/null
+++ b/tests/oref-proxy.test.mjs
@@ -0,0 +1,107 @@
+/**
+ * OREF Proxy Connectivity Test
+ *
+ * Tests the curl-based proxy approach used by ais-relay.cjs
+ * to reach oref.org.il through a residential proxy with Israel exit node.
+ *
+ * Requires OREF_PROXY_AUTH env var (format: user:pass@host:port)
+ *
+ * Usage:
+ * OREF_PROXY_AUTH='user:pass;il;;;@proxy.froxy.com:9000' node tests/oref-proxy.test.mjs
+ */
+
+import { execSync } from 'node:child_process';
+import { strict as assert } from 'node:assert';
+
+const OREF_PROXY_AUTH = process.env.OREF_PROXY_AUTH || '';
+const OREF_ALERTS_URL = 'https://www.oref.org.il/WarningMessages/alert/alerts.json';
+
+function stripBom(text) {
+ return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
+}
+
+function orefCurlFetch(proxyAuth, url) {
+ const proxyUrl = `http://${proxyAuth}`;
+ return execSync(
+ `curl -s -x "${proxyUrl}" --max-time 15 -H "Accept: application/json" -H "Referer: https://www.oref.org.il/" "${url}"`,
+ { encoding: 'utf8', timeout: 20000 }
+ );
+}
+
+async function runTests() {
+ if (!OREF_PROXY_AUTH) {
+ console.log('SKIP: OREF_PROXY_AUTH not set — set it to run proxy connectivity tests');
+ console.log(' Example: OREF_PROXY_AUTH="user:pass;il;;;@proxy.froxy.com:9000" node tests/oref-proxy.test.mjs');
+ process.exit(0);
+ }
+
+ console.log('--- OREF Proxy Connectivity Tests ---\n');
+ let passed = 0;
+ let failed = 0;
+
+ // Test 1: curl is available
+ try {
+ execSync('curl --version', { encoding: 'utf8', timeout: 5000 });
+ console.log(' PASS: curl is available');
+ passed++;
+ } catch {
+ console.log(' FAIL: curl not found — required for OREF proxy');
+ failed++;
+ process.exit(1);
+ }
+
+ // Test 2: Fetch OREF alerts through proxy via curl
+ try {
+ const raw = orefCurlFetch(OREF_PROXY_AUTH, OREF_ALERTS_URL);
+ assert.ok(typeof raw === 'string', 'response should be a string');
+ const cleaned = stripBom(raw).trim();
+
+ if (cleaned === '' || cleaned === '[]' || cleaned === 'null') {
+ console.log(' PASS: OREF alerts fetch → no active alerts (empty response)');
+ } else {
+ const parsed = JSON.parse(cleaned);
+ // OREF returns a single object when 1 alert, or an array for multiple
+ const alerts = Array.isArray(parsed) ? parsed : [parsed];
+ assert.ok(alerts.length > 0, 'should have at least one alert');
+ assert.ok(alerts[0].id || alerts[0].cat, 'alert should have id or cat field');
+ console.log(` PASS: OREF alerts fetch → ${alerts.length} active alert(s)`);
+ }
+ passed++;
+ } catch (err) {
+ console.log(` FAIL: OREF alerts fetch — ${err.message}`);
+ failed++;
+ }
+
+ // Test 3: Fetch with HTTP status code check
+ try {
+ const proxyUrl = `http://${OREF_PROXY_AUTH}`;
+ const output = execSync(
+ `curl -s -o /dev/null -w "%{http_code}" -x "${proxyUrl}" --max-time 15 -H "Accept: application/json" -H "Referer: https://www.oref.org.il/" "${OREF_ALERTS_URL}"`,
+ { encoding: 'utf8', timeout: 20000 }
+ ).trim();
+ assert.equal(output, '200', `Expected HTTP 200, got ${output}`);
+ console.log(' PASS: OREF HTTP status is 200');
+ passed++;
+ } catch (err) {
+ console.log(` FAIL: OREF HTTP status check — ${err.message}`);
+ failed++;
+ }
+
+ // Test 4: Invalid proxy should fail gracefully
+ try {
+ assert.throws(
+ () => orefCurlFetch('baduser:badpass@127.0.0.1:1', OREF_ALERTS_URL),
+ /./
+ );
+ console.log(' PASS: Invalid proxy fails gracefully');
+ passed++;
+ } catch (err) {
+ console.log(` FAIL: Invalid proxy error handling — ${err.message}`);
+ failed++;
+ }
+
+ console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
+ process.exit(failed > 0 ? 1 : 0);
+}
+
+runTests();