Files
worldmonitor/vite.config.ts
Elie Habib e1c3b28180 feat(notifications): Phase 6 — web-push channel for PWA notifications (#3173)
* feat(notifications): Phase 6 — web-push channel for PWA notifications

Adds a web_push notification channel so PWA users receive native
notifications when this tab is closed. Deep-links click to the
brief magazine URL for brief_ready events, to the event link for
everything else.

Schema / API:
- channelTypeValidator gains 'web_push' literal
- notificationChannels union adds { endpoint, p256dh, auth,
  userAgent? } variant (standard PushSubscription identity triple +
  cosmetic UA for the settings UI)
- new setWebPushChannelForUser internal mutation upserts the row
- /relay/deactivate allow-list extended to accept 'web_push'
- api/notification-channels: 'set-web-push' action validates the
  triple, rejects non-https, truncates UA to 200 chars

Client (src/services/push-notifications.ts + src/config/push.ts):
- isWebPushSupported guards Tauri webview + iOS Safari
- subscribeToPush: permission + pushManager.subscribe + POST triple
- unsubscribeFromPush: pushManager.unsubscribe + DELETE row
- VAPID_PUBLIC_KEY constant (with VITE_VAPID_PUBLIC_KEY env override)
- base64 <-> Uint8Array helpers (VAPID key encoding)

Service worker (public/push-handler.js):
- Imported into VitePWA's generated sw.js via workbox.importScripts
- push event: renders notification; requireInteraction=true for
  brief_ready so a lock-screen swipe does not dismiss the CTA
- notificationclick: focuses+navigates existing same-origin client
  when present, otherwise opens a new window
- Malformed JSON falls back to raw text body, missing data falls
  back to a minimal WorldMonitor default

Relay (scripts/notification-relay.cjs):
- sendWebPush() with lazy-loaded web-push dep. 404/410 triggers
  deactivateChannel('web_push'). Missing VAPID env vars logs once
  and skips — other channels keep delivering.
- processEvent dispatch loop + drainHeldForUser both gain web_push
  branches

Settings UI (src/services/notifications-settings.ts):
- New 'Browser Push' tile with bell icon
- Enable button lazy-imports push-notifications, calls subscribe,
  renders 'Not supported' on Tauri/in-app webviews
- Remove button routes web_push specifically through
  unsubscribeFromPush so the browser side is cleaned up too

Env vars required on Railway services:
  VAPID_PUBLIC_KEY   public key
  VAPID_PRIVATE_KEY  private key
  VAPID_SUBJECT      mailto:support@worldmonitor.app (optional)

Public key is also committed as the default in src/config/push.ts
so the client bundle works without a build-time override.

Tests: 11 new cases in tests/brief-web-push.test.mjs
- base64 <-> Uint8Array round-trip + null guards
- VAPID default fallback when env absent
- SW push event rendering, requireInteraction gating, malformed JSON
  + no-data fallbacks
- SW notificationclick: openWindow vs focus+navigate, default url

154/154 tests pass. Both tsconfigs typecheck clean.

* fix(brief): address PR #3173 review findings + drop hardcoded VAPID

P1 (security): VAPID private key leaked in PR description.
Rotated the keypair. Old pair permanently invalidated. Structural fix:

  Removed DEFAULT_VAPID_PUBLIC_KEY entirely. Hardcoding the public
  key in src/config/push.ts gave rotations two sources of truth
  (code vs env) — exactly the friction that caused me to paste the
  private key in a PR description in the first place. VAPID_PUBLIC_KEY
  now comes SOLELY from VITE_VAPID_PUBLIC_KEY at build time.
  isWebPushConfigured() gates the subscribe flow so builds without
  the env var surface as 'Not supported' rather than crashing
  pushManager.subscribe.

  Operator setup (one-time):
    Vercel build:      VITE_VAPID_PUBLIC_KEY=<public>
    Railway services:  VAPID_PUBLIC_KEY=<public>
                       VAPID_PRIVATE_KEY=<private>
                       VAPID_SUBJECT=mailto:support@worldmonitor.app

  Rotation: update env on both sides, redeploy. No code change, no
  PR body — no chance of leaking a key in a commit.

P2: single-device fan-out — setWebPushChannelForUser replaces the
previous subscription silently. Per-device fan-out is a schema change
deferred to follow-up. Fix for now: surface the replacement in
settings UI copy ('Enabling here replaces any previously registered
browser.') so users who expect multi-device see the warning.

P2: 24h push TTL floods offline devices on reconnect. Event-type-aware:
  brief_ready:       12h  (daily editorial — still interesting)
  quiet_hours_batch:  6h  (by definition queued-on-wake)
  everything else:   30m  (transient alerts: noise after 30min)

REGRESSION test: VAPID_PUBLIC_KEY must be '' when env var is unset.
If a committed default is reintroduced, the test fails loudly.

11/11 web-push tests pass. Both tsconfigs typecheck clean.

* fix(notifications): deliver channel_welcome push for web_push connects (#3173 P2)

The settings UI queues a channel_welcome event on first web_push
subscribe (api/notification-channels.ts:240 via publishWelcome), but
processWelcome() in the relay only branched on slack/discord/email —
no web_push arm. The welcome event was consumed off the queue and
then silently dropped, leaving first-time subscribers with no
'connection confirmed' signal.

Fix: add a web_push branch to processWelcome. Calls sendWebPush with
eventType='channel_welcome' which maps to the 30-minute TTL tier in
the push-delivery switch — a welcome that arrives >30 min after
subscribe is noise, not confirmation.

Short body (under 80 chars) so Chrome/Firefox/Safari notification
shelves don't clip past ellipsis.

11/11 web-push tests pass.

* fix(notifications): address two P1 review findings on #3173

P1-A: SSRF via user-supplied web_push endpoint.
The set-web-push edge handler accepted any https:// URL and wrote
it to Convex. The relay's sendWebPush() later POSTs to whatever
endpoint sits in that row, giving any Pro user a server-side-request
primitive bounded only by the relay's network egress.

Fix: isAllowedPushEndpointHost() allow-list in api/notification-
channels.ts. Only the four known browser push-service hosts pass:

  fcm.googleapis.com                (Chrome / Edge / Brave)
  updates.push.services.mozilla.com (Firefox)
  web.push.apple.com                (Safari, macOS 13+)
  *.notify.windows.com              (Windows Notification Service)

Fail-closed: unknown hosts rejected with 400 before the row ever
reaches Convex. If a future browser ships a new push service we'll
need to widen this list (guarded by the SSRF regression tests).

P1-B: cross-account endpoint reuse on shared devices.
The browser's PushSubscription is bound to the origin, NOT to the
Clerk session. User A subscribes on device X, signs out, user B
signs in on X and subscribes — the browser hands out the SAME
endpoint/p256dh/auth triple. The previous setWebPushChannelForUser
upsert keyed only by (userId, channelType), so BOTH rows now carry
the same endpoint. Every push the relay fans out for user A also
lands on device X which is now showing user B's session.

Fix: setWebPushChannelForUser scans all web_push rows and deletes
any that match the new endpoint BEFORE upserting. Effectively
transfers ownership of the subscription to the current caller.
The previous user will need to re-subscribe on that device if they
sign in again.

No endpoint-based index on notificationChannels — the scan happens
at <10k rows and is well-bounded to the one write-path per user
per connect. If volume grows, add an  + migration.

Regression tests (tests/brief-web-push.test.mjs, 3 new cases):
  - allow-list defines all four browser hosts + fail-closed return
  - allow-list is invoked BEFORE convexRelay() in the handler
  - setWebPushChannelForUser compares + deletes rows by endpoint

14/14 web-push tests pass. Both tsconfigs typecheck clean.
2026-04-18 20:27:08 +04:00

1232 lines
51 KiB
TypeScript

import { defineConfig, loadEnv, type Plugin } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import { resolve, dirname, extname } from 'path';
import { mkdir, readFile, writeFile } from 'fs/promises';
import { brotliCompress } from 'zlib';
import { promisify } from 'util';
import pkg from './package.json';
import { VARIANT_META, type VariantMeta } from './src/config/variant-meta';
// Env-dependent constants moved inside defineConfig function
const brotliCompressAsync = promisify(brotliCompress);
const BROTLI_EXTENSIONS = new Set(['.js', '.mjs', '.css', '.html', '.svg', '.json', '.txt', '.xml', '.wasm']);
function brotliPrecompressPlugin(): Plugin {
return {
name: 'brotli-precompress',
apply: 'build',
async writeBundle(outputOptions, bundle) {
const outDir = outputOptions.dir;
if (!outDir) return;
await Promise.all(Object.keys(bundle).map(async (fileName) => {
const extension = extname(fileName).toLowerCase();
if (!BROTLI_EXTENSIONS.has(extension)) return;
const sourcePath = resolve(outDir, fileName);
const compressedPath = `${sourcePath}.br`;
const sourceBuffer = await readFile(sourcePath);
if (sourceBuffer.length < 1024) return;
const compressedBuffer = await brotliCompressAsync(sourceBuffer);
await mkdir(dirname(compressedPath), { recursive: true });
await writeFile(compressedPath, compressedBuffer);
}));
},
};
}
function htmlVariantPlugin(activeMeta: VariantMeta, activeVariant: string, isDesktopBuild: boolean): Plugin {
return {
name: 'html-variant',
transformIndexHtml(html) {
let result = html
.replace(/<title>.*?<\/title>/, `<title>${activeMeta.title}</title>`)
.replace(/<meta name="title" content=".*?" \/>/, `<meta name="title" content="${activeMeta.title}" />`)
.replace(/<meta name="description" content=".*?" \/>/, `<meta name="description" content="${activeMeta.description}" />`)
.replace(/<meta name="keywords" content=".*?" \/>/, `<meta name="keywords" content="${activeMeta.keywords}" />`)
.replace(/<link rel="canonical" href=".*?" \/>/, `<link rel="canonical" href="${activeMeta.url}" />`)
.replace(/<meta name="application-name" content=".*?" \/>/, `<meta name="application-name" content="${activeMeta.siteName}" />`)
.replace(/<meta property="og:url" content=".*?" \/>/, `<meta property="og:url" content="${activeMeta.url}" />`)
.replace(/<meta property="og:title" content=".*?" \/>/, `<meta property="og:title" content="${activeMeta.title}" />`)
.replace(/<meta property="og:description" content=".*?" \/>/, `<meta property="og:description" content="${activeMeta.description}" />`)
.replace(/<meta property="og:site_name" content=".*?" \/>/, `<meta property="og:site_name" content="${activeMeta.siteName}" />`)
.replace(/<meta name="subject" content=".*?" \/>/, `<meta name="subject" content="${activeMeta.subject}" />`)
.replace(/<meta name="classification" content=".*?" \/>/, `<meta name="classification" content="${activeMeta.classification}" />`)
.replace(/<meta name="twitter:url" content=".*?" \/>/, `<meta name="twitter:url" content="${activeMeta.url}" />`)
.replace(/<meta name="twitter:title" content=".*?" \/>/, `<meta name="twitter:title" content="${activeMeta.title}" />`)
.replace(/<meta name="twitter:description" content=".*?" \/>/, `<meta name="twitter:description" content="${activeMeta.description}" />`)
.replace(/"name": "World Monitor"/, `"name": "${activeMeta.siteName}"`)
.replace(/"alternateName": "WorldMonitor"/, `"alternateName": "${activeMeta.siteName.replace(' ', '')}"`)
.replace(/"url": "https:\/\/worldmonitor\.app\/"/, `"url": "${activeMeta.url}"`)
.replace(/"description": "Real-time global intelligence dashboard with live news, markets, military tracking, infrastructure monitoring, and geopolitical data."/, `"description": "${activeMeta.description}"`)
.replace(/"featureList": \[[\s\S]*?\]/, `"featureList": ${JSON.stringify(activeMeta.features, null, 8).replace(/\n/g, '\n ')}`);
// Theme-color meta — warm cream for happy variant
if (activeVariant === 'happy') {
result = result.replace(
/<meta name="theme-color" content=".*?" \/>/,
'<meta name="theme-color" content="#FAFAF5" />'
);
}
// Desktop builds: inject build-time variant into the inline script so data-variant is set
// before CSS loads. Web builds always use 'full' — runtime hostname detection handles variants.
if (activeVariant !== 'full') {
result = result.replace(
/if\(v\)document\.documentElement\.dataset\.variant=v;/,
`v='${activeVariant}';document.documentElement.dataset.variant=v;`
);
}
// Desktop CSP: inject localhost wildcard for dynamic sidecar port.
// Web builds intentionally exclude localhost to avoid exposing attack surface.
if (isDesktopBuild) {
result = result
.replace(
/connect-src 'self' https: http:\/\/localhost:5173/,
"connect-src 'self' https: http://localhost:5173 http://127.0.0.1:*"
)
.replace(
/frame-src 'self'/,
"frame-src 'self' http://127.0.0.1:*"
);
}
// Desktop builds: replace favicon paths with variant-specific subdirectory.
// Web builds use 'full' favicons in HTML; runtime JS swaps them per hostname.
if (activeVariant !== 'full') {
result = result
.replace(/\/favico\/favicon/g, `/favico/${activeVariant}/favicon`)
.replace(/\/favico\/apple-touch-icon/g, `/favico/${activeVariant}/apple-touch-icon`)
.replace(/\/favico\/android-chrome/g, `/favico/${activeVariant}/android-chrome`)
.replace(/\/favico\/og-image/g, `/favico/${activeVariant}/og-image`);
}
return result;
},
};
}
function polymarketPlugin(): Plugin {
const GAMMA_BASE = 'https://gamma-api.polymarket.com';
const ALLOWED_ORDER = ['volume', 'liquidity', 'startDate', 'endDate', 'spread'];
return {
name: 'polymarket-dev',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith('/api/polymarket')) return next();
const url = new URL(req.url, 'http://localhost');
const endpoint = url.searchParams.get('endpoint') || 'markets';
const closed = ['true', 'false'].includes(url.searchParams.get('closed') ?? '') ? url.searchParams.get('closed') : 'false';
const order = ALLOWED_ORDER.includes(url.searchParams.get('order') ?? '') ? url.searchParams.get('order') : 'volume';
const ascending = ['true', 'false'].includes(url.searchParams.get('ascending') ?? '') ? url.searchParams.get('ascending') : 'false';
const rawLimit = parseInt(url.searchParams.get('limit') ?? '', 10);
const limit = isNaN(rawLimit) ? 50 : Math.max(1, Math.min(100, rawLimit));
const params = new URLSearchParams({ closed: closed!, order: order!, ascending: ascending!, limit: String(limit) });
if (endpoint === 'events') {
const tag = (url.searchParams.get('tag') ?? '').replace(/[^a-z0-9-]/gi, '').slice(0, 100);
if (tag) params.set('tag_slug', tag);
}
const gammaUrl = `${GAMMA_BASE}/${endpoint === 'events' ? 'events' : 'markets'}?${params}`;
res.setHeader('Content-Type', 'application/json');
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
const resp = await fetch(gammaUrl, { headers: { Accept: 'application/json' }, signal: controller.signal });
clearTimeout(timer);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.text();
res.setHeader('Cache-Control', 'public, max-age=120');
res.setHeader('X-Polymarket-Source', 'gamma');
res.end(data);
} catch {
// Expected: Cloudflare JA3 blocks server-side TLS — return empty array
res.setHeader('Cache-Control', 'public, max-age=300');
res.end('[]');
}
});
},
};
}
/**
* Vite dev server plugin for sebuf API routes.
*
* Intercepts requests matching /api/{domain}/v1/* and routes them through
* the same handler pipeline as the Vercel catch-all gateway. Other /api/*
* paths fall through to existing proxy rules.
*/
function sebufApiPlugin(): Plugin {
// Cache router across requests (H-13 fix). Invalidated by Vite's module graph on HMR.
let cachedRouter: Awaited<ReturnType<typeof buildRouter>> | null = null;
let cachedCorsMod: any = null;
async function buildRouter() {
const [
routerMod, corsMod, errorMod,
seismologyServerMod, seismologyHandlerMod,
wildfireServerMod, wildfireHandlerMod,
climateServerMod, climateHandlerMod,
predictionServerMod, predictionHandlerMod,
displacementServerMod, displacementHandlerMod,
aviationServerMod, aviationHandlerMod,
researchServerMod, researchHandlerMod,
unrestServerMod, unrestHandlerMod,
conflictServerMod, conflictHandlerMod,
maritimeServerMod, maritimeHandlerMod,
cyberServerMod, cyberHandlerMod,
economicServerMod, economicHandlerMod,
infrastructureServerMod, infrastructureHandlerMod,
marketServerMod, marketHandlerMod,
newsServerMod, newsHandlerMod,
intelligenceServerMod, intelligenceHandlerMod,
militaryServerMod, militaryHandlerMod,
positiveEventsServerMod, positiveEventsHandlerMod,
givingServerMod, givingHandlerMod,
tradeServerMod, tradeHandlerMod,
supplyChainServerMod, supplyChainHandlerMod,
naturalServerMod, naturalHandlerMod,
resilienceServerMod, resilienceHandlerMod,
] = await Promise.all([
import('./server/router'),
import('./server/cors'),
import('./server/error-mapper'),
import('./src/generated/server/worldmonitor/seismology/v1/service_server'),
import('./server/worldmonitor/seismology/v1/handler'),
import('./src/generated/server/worldmonitor/wildfire/v1/service_server'),
import('./server/worldmonitor/wildfire/v1/handler'),
import('./src/generated/server/worldmonitor/climate/v1/service_server'),
import('./server/worldmonitor/climate/v1/handler'),
import('./src/generated/server/worldmonitor/prediction/v1/service_server'),
import('./server/worldmonitor/prediction/v1/handler'),
import('./src/generated/server/worldmonitor/displacement/v1/service_server'),
import('./server/worldmonitor/displacement/v1/handler'),
import('./src/generated/server/worldmonitor/aviation/v1/service_server'),
import('./server/worldmonitor/aviation/v1/handler'),
import('./src/generated/server/worldmonitor/research/v1/service_server'),
import('./server/worldmonitor/research/v1/handler'),
import('./src/generated/server/worldmonitor/unrest/v1/service_server'),
import('./server/worldmonitor/unrest/v1/handler'),
import('./src/generated/server/worldmonitor/conflict/v1/service_server'),
import('./server/worldmonitor/conflict/v1/handler'),
import('./src/generated/server/worldmonitor/maritime/v1/service_server'),
import('./server/worldmonitor/maritime/v1/handler'),
import('./src/generated/server/worldmonitor/cyber/v1/service_server'),
import('./server/worldmonitor/cyber/v1/handler'),
import('./src/generated/server/worldmonitor/economic/v1/service_server'),
import('./server/worldmonitor/economic/v1/handler'),
import('./src/generated/server/worldmonitor/infrastructure/v1/service_server'),
import('./server/worldmonitor/infrastructure/v1/handler'),
import('./src/generated/server/worldmonitor/market/v1/service_server'),
import('./server/worldmonitor/market/v1/handler'),
import('./src/generated/server/worldmonitor/news/v1/service_server'),
import('./server/worldmonitor/news/v1/handler'),
import('./src/generated/server/worldmonitor/intelligence/v1/service_server'),
import('./server/worldmonitor/intelligence/v1/handler'),
import('./src/generated/server/worldmonitor/military/v1/service_server'),
import('./server/worldmonitor/military/v1/handler'),
import('./src/generated/server/worldmonitor/positive_events/v1/service_server'),
import('./server/worldmonitor/positive-events/v1/handler'),
import('./src/generated/server/worldmonitor/giving/v1/service_server'),
import('./server/worldmonitor/giving/v1/handler'),
import('./src/generated/server/worldmonitor/trade/v1/service_server'),
import('./server/worldmonitor/trade/v1/handler'),
import('./src/generated/server/worldmonitor/supply_chain/v1/service_server'),
import('./server/worldmonitor/supply-chain/v1/handler'),
import('./src/generated/server/worldmonitor/natural/v1/service_server'),
import('./server/worldmonitor/natural/v1/handler'),
import('./src/generated/server/worldmonitor/resilience/v1/service_server'),
import('./server/worldmonitor/resilience/v1/handler'),
]);
const serverOptions = { onError: errorMod.mapErrorToResponse };
const allRoutes = [
...seismologyServerMod.createSeismologyServiceRoutes(seismologyHandlerMod.seismologyHandler, serverOptions),
...wildfireServerMod.createWildfireServiceRoutes(wildfireHandlerMod.wildfireHandler, serverOptions),
...climateServerMod.createClimateServiceRoutes(climateHandlerMod.climateHandler, serverOptions),
...predictionServerMod.createPredictionServiceRoutes(predictionHandlerMod.predictionHandler, serverOptions),
...displacementServerMod.createDisplacementServiceRoutes(displacementHandlerMod.displacementHandler, serverOptions),
...aviationServerMod.createAviationServiceRoutes(aviationHandlerMod.aviationHandler, serverOptions),
...researchServerMod.createResearchServiceRoutes(researchHandlerMod.researchHandler, serverOptions),
...unrestServerMod.createUnrestServiceRoutes(unrestHandlerMod.unrestHandler, serverOptions),
...conflictServerMod.createConflictServiceRoutes(conflictHandlerMod.conflictHandler, serverOptions),
...maritimeServerMod.createMaritimeServiceRoutes(maritimeHandlerMod.maritimeHandler, serverOptions),
...cyberServerMod.createCyberServiceRoutes(cyberHandlerMod.cyberHandler, serverOptions),
...economicServerMod.createEconomicServiceRoutes(economicHandlerMod.economicHandler, serverOptions),
...infrastructureServerMod.createInfrastructureServiceRoutes(infrastructureHandlerMod.infrastructureHandler, serverOptions),
...marketServerMod.createMarketServiceRoutes(marketHandlerMod.marketHandler, serverOptions),
...newsServerMod.createNewsServiceRoutes(newsHandlerMod.newsHandler, serverOptions),
...intelligenceServerMod.createIntelligenceServiceRoutes(intelligenceHandlerMod.intelligenceHandler, serverOptions),
...militaryServerMod.createMilitaryServiceRoutes(militaryHandlerMod.militaryHandler, serverOptions),
...positiveEventsServerMod.createPositiveEventsServiceRoutes(positiveEventsHandlerMod.positiveEventsHandler, serverOptions),
...givingServerMod.createGivingServiceRoutes(givingHandlerMod.givingHandler, serverOptions),
...tradeServerMod.createTradeServiceRoutes(tradeHandlerMod.tradeHandler, serverOptions),
...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions),
...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions),
...resilienceServerMod.createResilienceServiceRoutes(resilienceHandlerMod.resilienceHandler, serverOptions),
];
cachedCorsMod = corsMod;
return routerMod.createRouter(allRoutes);
}
return {
name: 'sebuf-api',
configureServer(server) {
// Invalidate cached router on HMR updates to server/ files
server.watcher.on('change', (file) => {
if (file.includes('/server/') || file.includes('/src/generated/server/')) {
cachedRouter = null;
}
});
server.middlewares.use(async (req, res, next) => {
// Only intercept sebuf routes: /api/{domain}/v1/* (domain may contain hyphens)
if (!req.url || !/^\/api\/[a-z-]+\/v1\//.test(req.url)) {
return next();
}
try {
// Build router once, reuse across requests (H-13 fix)
if (!cachedRouter) {
cachedRouter = await buildRouter();
}
const router = cachedRouter;
const corsMod = cachedCorsMod;
// Convert Connect IncomingMessage to Web Standard Request
const port = server.config.server.port || 3000;
const url = new URL(req.url, `http://localhost:${port}`);
// Read body for POST requests
let body: string | undefined;
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
body = Buffer.concat(chunks).toString();
}
// Extract headers from IncomingMessage
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === 'string') {
headers[key] = value;
} else if (Array.isArray(value)) {
headers[key] = value.join(', ');
}
}
const webRequest = new Request(url.toString(), {
method: req.method,
headers,
body: body || undefined,
});
const corsHeaders = corsMod.getCorsHeaders(webRequest);
// OPTIONS preflight
if (req.method === 'OPTIONS') {
res.statusCode = 204;
for (const [key, value] of Object.entries(corsHeaders)) {
res.setHeader(key, value);
}
res.end();
return;
}
// Origin check
if (corsMod.isDisallowedOrigin(webRequest)) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json');
for (const [key, value] of Object.entries(corsHeaders)) {
res.setHeader(key, value);
}
res.end(JSON.stringify({ error: 'Origin not allowed' }));
return;
}
// Route matching
const matchedHandler = router.match(webRequest);
if (!matchedHandler) {
const allowed = router.allowedMethods(new URL(webRequest.url).pathname);
if (allowed.length > 0) {
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Allow', allowed.join(', '));
} else {
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json');
}
for (const [key, value] of Object.entries(corsHeaders)) {
res.setHeader(key, value);
}
res.end(JSON.stringify({ error: res.statusCode === 405 ? 'Method not allowed' : 'Not found' }));
return;
}
// Execute handler
const response = await matchedHandler(webRequest);
// Write response
res.statusCode = response.status;
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
for (const [key, value] of Object.entries(corsHeaders)) {
res.setHeader(key, value);
}
res.end(await response.text());
} catch (err) {
console.error('[sebuf-api] Error:', err);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Internal server error' }));
}
});
},
};
}
// RSS proxy allowlist — duplicated from api/rss-proxy.js for dev mode.
// Keep in sync when adding new domains.
const RSS_PROXY_ALLOWED_DOMAINS = new Set([
'feeds.bbci.co.uk', 'www.theguardian.com', 'feeds.npr.org', 'news.google.com',
'www.aljazeera.com', 'rss.cnn.com', 'hnrss.org', 'feeds.arstechnica.com',
'www.theverge.com', 'www.cnbc.com', 'feeds.marketwatch.com', 'www.defenseone.com',
'breakingdefense.com', 'www.bellingcat.com', 'techcrunch.com', 'huggingface.co',
'www.technologyreview.com', 'rss.arxiv.org', 'export.arxiv.org',
'www.federalreserve.gov', 'www.sec.gov', 'www.whitehouse.gov', 'www.state.gov',
'www.defense.gov', 'home.treasury.gov', 'www.justice.gov', 'tools.cdc.gov',
'www.fema.gov', 'www.dhs.gov', 'www.thedrive.com', 'krebsonsecurity.com',
'finance.yahoo.com', 'thediplomat.com', 'venturebeat.com', 'foreignpolicy.com',
'www.ft.com', 'openai.com', 'www.reutersagency.com', 'feeds.reuters.com',
'asia.nikkei.com', 'www.cfr.org', 'www.csis.org', 'www.politico.com',
'www.brookings.edu', 'layoffs.fyi', 'www.defensenews.com', 'www.militarytimes.com',
'taskandpurpose.com', 'news.usni.org', 'www.oryxspioenkop.com', 'www.gov.uk',
'www.foreignaffairs.com', 'www.atlanticcouncil.org',
// Tech variant
'www.zdnet.com', 'www.techmeme.com', 'www.darkreading.com', 'www.schneier.com',
'rss.politico.com', 'www.anandtech.com', 'www.tomshardware.com', 'www.semianalysis.com',
'feed.infoq.com', 'thenewstack.io', 'devops.com', 'dev.to', 'lobste.rs', 'changelog.com',
'seekingalpha.com', 'news.crunchbase.com', 'www.saastr.com', 'feeds.feedburner.com',
'www.producthunt.com', 'www.axios.com', 'api.axios.com', 'github.blog', 'githubnext.com',
'mshibanami.github.io', 'www.engadget.com', 'news.mit.edu', 'dev.events',
'www.ycombinator.com', 'a16z.com', 'review.firstround.com', 'www.sequoiacap.com',
'www.nfx.com', 'www.aaronsw.com', 'bothsidesofthetable.com', 'www.lennysnewsletter.com',
'stratechery.com', 'www.eu-startups.com', 'tech.eu', 'sifted.eu', 'www.techinasia.com',
'kr-asia.com', 'techcabal.com', 'disrupt-africa.com', 'lavca.org', 'contxto.com',
'inc42.com', 'yourstory.com', 'pitchbook.com', 'www.cbinsights.com', 'www.techstars.com',
// Regional & international
'english.alarabiya.net', 'www.arabnews.com', 'www.timesofisrael.com', 'www.haaretz.com',
'www.scmp.com', 'kyivindependent.com', 'www.themoscowtimes.com', 'feeds.24.com',
'feeds.capi24.com', 'www.france24.com', 'www.euronews.com', 'www.lemonde.fr',
'rss.dw.com', 'www.africanews.com', 'www.lasillavacia.com', 'www.channelnewsasia.com',
'www.thehindu.com', 'news.un.org', 'www.iaea.org', 'www.who.int', 'www.cisa.gov',
'www.crisisgroup.org',
// Think tanks
'rusi.org', 'warontherocks.com', 'www.aei.org', 'responsiblestatecraft.org',
'www.fpri.org', 'jamestown.org', 'www.chathamhouse.org', 'ecfr.eu', 'www.gmfus.org',
'www.wilsoncenter.org', 'www.lowyinstitute.org', 'www.mei.edu', 'www.stimson.org',
'www.cnas.org', 'carnegieendowment.org', 'www.rand.org', 'fas.org',
'www.armscontrol.org', 'www.nti.org', 'thebulletin.org', 'www.iss.europa.eu',
// Economic & Food Security
'www.fao.org', 'worldbank.org', 'www.imf.org',
// Regional locale feeds
'www.hurriyet.com.tr', 'tvn24.pl', 'www.polsatnews.pl', 'www.rp.pl', 'meduza.io',
'novayagazeta.eu', 'www.bangkokpost.com', 'vnexpress.net', 'www.abc.net.au',
'news.ycombinator.com',
// Finance variant
'www.coindesk.com', 'cointelegraph.com',
// Happy variant — positive news sources
'www.goodnewsnetwork.org', 'www.positive.news', 'reasonstobecheerful.world',
'www.optimistdaily.com', 'www.sunnyskyz.com', 'www.huffpost.com',
'www.sciencedaily.com', 'feeds.nature.com', 'www.livescience.com', 'www.newscientist.com',
]);
function rssProxyPlugin(): Plugin {
return {
name: 'rss-proxy',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith('/api/rss-proxy')) {
return next();
}
const url = new URL(req.url, 'http://localhost');
const feedUrl = url.searchParams.get('url');
if (!feedUrl) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Missing url parameter' }));
return;
}
try {
const parsed = new URL(feedUrl);
if (!RSS_PROXY_ALLOWED_DOMAINS.has(parsed.hostname)) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: `Domain not allowed: ${parsed.hostname}` }));
return;
}
const controller = new AbortController();
const timeout = feedUrl.includes('news.google.com') ? 20000 : 12000;
const timer = setTimeout(() => controller.abort(), timeout);
const response = await fetch(feedUrl, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
},
redirect: 'follow',
});
clearTimeout(timer);
const data = await response.text();
res.statusCode = response.status;
res.setHeader('Content-Type', 'application/xml');
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Access-Control-Allow-Origin', '*');
res.end(data);
} catch (error: any) {
console.error('[rss-proxy]', feedUrl, error.message);
res.statusCode = error.name === 'AbortError' ? 504 : 502;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: error.name === 'AbortError' ? 'Feed timeout' : 'Failed to fetch feed' }));
}
});
},
};
}
function youtubeLivePlugin(): Plugin {
return {
name: 'youtube-live',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith('/api/youtube/live')) {
return next();
}
const url = new URL(req.url, 'http://localhost');
const channel = url.searchParams.get('channel');
if (!channel) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Missing channel parameter' }));
return;
}
try {
const channelHandle = channel.startsWith('@') ? channel : `@${channel}`;
const liveUrl = `https://www.youtube.com/${channelHandle}/live`;
const ytRes = await fetch(liveUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
redirect: 'follow',
});
if (!ytRes.ok) {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'public, max-age=300');
res.end(JSON.stringify({ videoId: null, channel }));
return;
}
const html = await ytRes.text();
// Scope both fields to the same videoDetails block so we don't
// combine a videoId from one object with isLive from another.
let videoId: string | null = null;
const detailsIdx = html.indexOf('"videoDetails"');
if (detailsIdx !== -1) {
const block = html.substring(detailsIdx, detailsIdx + 5000);
const vidMatch = block.match(/"videoId":"([a-zA-Z0-9_-]{11})"/);
const liveMatch = block.match(/"isLive"\s*:\s*true/);
if (vidMatch && liveMatch) {
videoId = vidMatch[1];
}
}
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'public, max-age=300');
res.end(JSON.stringify({ videoId, isLive: videoId !== null, channel }));
} catch (error) {
console.error(`[YouTube Live] Error:`, error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Failed to fetch', videoId: null }));
}
});
},
};
}
function gpsjamDevPlugin(): Plugin {
return {
name: 'gpsjam-dev',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (req.url !== '/api/gpsjam' && !req.url?.startsWith('/api/gpsjam?')) {
return next();
}
try {
const data = await readFile(resolve(__dirname, 'scripts/data/gpsjam-latest.json'), 'utf8');
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-cache');
res.end(data);
} catch {
res.statusCode = 503;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-cache');
res.end(JSON.stringify({ error: 'No GPS jam data. Run: node scripts/fetch-gpsjam.mjs' }));
}
});
},
};
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
// Inject environment variables from .env files into process.env.
// This ensures that API keys and other secrets in .env.local are
// available to the dev server plugins and server-side handlers.
Object.assign(process.env, env);
const isE2E = process.env.VITE_E2E === '1';
const isDesktopBuild = process.env.VITE_DESKTOP_RUNTIME === '1';
const activeVariant = process.env.VITE_VARIANT || 'full';
const activeMeta = VARIANT_META[activeVariant] || VARIANT_META.full;
return {
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
plugins: [
htmlVariantPlugin(activeMeta, activeVariant, isDesktopBuild),
polymarketPlugin(),
rssProxyPlugin(),
youtubeLivePlugin(),
gpsjamDevPlugin(),
sebufApiPlugin(),
brotliPrecompressPlugin(),
VitePWA({
registerType: 'autoUpdate',
injectRegister: false,
includeAssets: [
'favico/favicon.ico',
'favico/apple-touch-icon.png',
'favico/favicon-32x32.png',
],
manifest: {
name: `${activeMeta.siteName} - ${activeMeta.subject}`,
short_name: activeMeta.shortName,
description: activeMeta.description,
start_url: '/',
scope: '/',
display: 'standalone',
orientation: 'any',
theme_color: '#0a0f0a',
background_color: '#0a0f0a',
categories: activeMeta.categories,
icons: [
{ src: '/favico/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/favico/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: '/favico/android-chrome-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,ico,png,svg,woff2}'],
globIgnores: ['**/ml*.js', '**/onnx*.wasm', '**/locale-*.js'],
// globe.gl + three.js grows main bundle past the 2 MiB default limit
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
navigateFallback: null,
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
// Web Push handler (Phase 6). importScripts runs in the SW
// context; /push-handler.js is a static file copied from
// public/ and attaches 'push' + 'notificationclick' listeners.
importScripts: ['/push-handler.js'],
runtimeCaching: [
{
urlPattern: ({ request }: { request: Request }) => request.mode === 'navigate',
handler: 'NetworkFirst',
options: {
cacheName: 'html-navigation',
networkTimeoutSeconds: 5,
cacheableResponse: { statuses: [200] },
},
},
{
urlPattern: ({ url, sameOrigin }: { url: URL; sameOrigin: boolean }) =>
sameOrigin && /^\/api\//.test(url.pathname),
handler: 'NetworkOnly',
method: 'GET',
},
{
urlPattern: ({ url, sameOrigin }: { url: URL; sameOrigin: boolean }) =>
sameOrigin && /^\/api\//.test(url.pathname),
handler: 'NetworkOnly',
method: 'POST',
},
{
urlPattern: ({ url, sameOrigin }: { url: URL; sameOrigin: boolean }) =>
sameOrigin && /^\/rss\//.test(url.pathname),
handler: 'NetworkOnly',
method: 'GET',
},
{
urlPattern: ({ url }: { url: URL }) =>
url.pathname.endsWith('.pmtiles') ||
url.hostname.endsWith('.r2.dev') ||
url.hostname === 'build.protomaps.com',
handler: 'NetworkFirst',
options: {
cacheName: 'pmtiles-ranges',
expiration: { maxEntries: 500, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /^https:\/\/protomaps\.github\.io\//,
handler: 'CacheFirst',
options: {
cacheName: 'protomaps-assets',
expiration: { maxEntries: 100, maxAgeSeconds: 365 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\//,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'google-fonts-css',
expiration: { maxEntries: 10, maxAgeSeconds: 365 * 24 * 60 * 60 },
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\//,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-woff',
expiration: { maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /\/assets\/locale-.*\.js$/i,
handler: 'CacheFirst',
options: {
cacheName: 'locale-files',
expiration: { maxEntries: 20, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'images',
expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60 },
},
},
],
},
devOptions: {
enabled: false,
},
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
child_process: resolve(__dirname, 'src/shims/child-process.ts'),
'node:child_process': resolve(__dirname, 'src/shims/child-process.ts'),
'@loaders.gl/worker-utils/dist/lib/process-utils/child-process-proxy.js': resolve(
__dirname,
'src/shims/child-process-proxy.ts'
),
},
},
worker: {
format: 'es',
},
build: {
// Geospatial bundles (maplibre/deck) are expected to be large even when split.
// Raise warning threshold to reduce noisy false alarms in CI.
chunkSizeWarningLimit: 1200,
rollupOptions: {
onwarn(warning, warn) {
// onnxruntime-web ships a minified browser bundle that intentionally uses eval.
// Keep build logs focused by filtering this known third-party warning only.
if (
warning.code === 'EVAL'
&& typeof warning.id === 'string'
&& warning.id.includes('/onnxruntime-web/dist/ort-web.min.js')
) {
return;
}
warn(warning);
},
input: {
main: resolve(__dirname, 'index.html'),
settings: resolve(__dirname, 'settings.html'),
liveChannels: resolve(__dirname, 'live-channels.html'),
},
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('/@xenova/transformers/')) {
return 'transformers';
}
if (id.includes('/onnxruntime-web/')) {
return 'onnxruntime';
}
if (id.includes('/maplibre-gl/') || id.includes('/pmtiles/') || id.includes('/@protomaps/basemaps/')) {
return 'maplibre';
}
if (
id.includes('/@deck.gl/')
|| id.includes('/@luma.gl/')
|| id.includes('/@loaders.gl/')
|| id.includes('/@math.gl/')
|| id.includes('/h3-js/')
) {
return 'deck-stack';
}
if (id.includes('/d3/')) {
return 'd3';
}
if (id.includes('/topojson-client/')) {
return 'topojson';
}
if (id.includes('/i18next')) {
return 'i18n';
}
if (id.includes('/@sentry/')) {
return 'sentry';
}
}
if (id.includes('/src/components/') && id.endsWith('Panel.ts')) {
return 'panels';
}
// Give lazy-loaded locale chunks a recognizable prefix so the
// service worker can exclude them from precache (en.json is
// statically imported into the main bundle).
const localeMatch = id.match(/\/locales\/(\w+)\.json$/);
if (localeMatch && localeMatch[1] !== 'en') {
return `locale-${localeMatch[1]}`;
}
return undefined;
},
},
},
},
server: {
port: 3000,
open: !isE2E,
hmr: isE2E ? false : undefined,
watch: {
ignored: [
'**/test-results/**',
'**/playwright-report/**',
'**/.playwright-mcp/**',
],
},
proxy: {
// Widget agent — forward to Railway relay for SSE streaming
'/widget-agent': {
target: 'https://proxy.worldmonitor.app',
changeOrigin: true,
},
// Yahoo Finance API
'/api/yahoo': {
target: 'https://query1.finance.yahoo.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/yahoo/, ''),
},
// Polymarket handled by polymarketPlugin() — no prod proxy needed
// USGS Earthquake API
'/api/earthquake': {
target: 'https://earthquake.usgs.gov',
changeOrigin: true,
timeout: 30000,
rewrite: (path) => path.replace(/^\/api\/earthquake/, ''),
configure: (proxy) => {
proxy.on('error', (err) => {
console.log('Earthquake proxy error:', err.message);
});
},
},
// PizzINT - Pentagon Pizza Index
'/api/pizzint': {
target: 'https://www.pizzint.watch',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/pizzint/, '/api'),
configure: (proxy) => {
proxy.on('error', (err) => {
console.log('PizzINT proxy error:', err.message);
});
},
},
// FRED Economic Data - handled by Vercel serverless function in prod
// In dev, we proxy to the API directly with the key from .env
'/api/fred-data': {
target: 'https://api.stlouisfed.org',
changeOrigin: true,
rewrite: (path) => {
const url = new URL(path, 'http://localhost');
const seriesId = url.searchParams.get('series_id');
const start = url.searchParams.get('observation_start');
const end = url.searchParams.get('observation_end');
const apiKey = process.env.FRED_API_KEY || '';
return `/fred/series/observations?series_id=${seriesId}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=10${start ? `&observation_start=${start}` : ''}${end ? `&observation_end=${end}` : ''}`;
},
},
// RSS Feeds - BBC
'/rss/bbc': {
target: 'https://feeds.bbci.co.uk',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/bbc/, ''),
},
// RSS Feeds - Guardian
'/rss/guardian': {
target: 'https://www.theguardian.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/guardian/, ''),
},
// RSS Feeds - NPR
'/rss/npr': {
target: 'https://feeds.npr.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/npr/, ''),
},
// RSS Feeds - Al Jazeera
'/rss/aljazeera': {
target: 'https://www.aljazeera.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/aljazeera/, ''),
},
// RSS Feeds - CNN
'/rss/cnn': {
target: 'http://rss.cnn.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/cnn/, ''),
},
// RSS Feeds - Hacker News
'/rss/hn': {
target: 'https://hnrss.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/hn/, ''),
},
// RSS Feeds - Ars Technica
'/rss/arstechnica': {
target: 'https://feeds.arstechnica.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/arstechnica/, ''),
},
// RSS Feeds - The Verge
'/rss/verge': {
target: 'https://www.theverge.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/verge/, ''),
},
// RSS Feeds - CNBC
'/rss/cnbc': {
target: 'https://www.cnbc.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/cnbc/, ''),
},
// RSS Feeds - MarketWatch
'/rss/marketwatch': {
target: 'https://feeds.marketwatch.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/marketwatch/, ''),
},
// RSS Feeds - Defense/Intel sources
'/rss/defenseone': {
target: 'https://www.defenseone.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/defenseone/, ''),
},
'/rss/warontherocks': {
target: 'https://warontherocks.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/warontherocks/, ''),
},
'/rss/breakingdefense': {
target: 'https://breakingdefense.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/breakingdefense/, ''),
},
'/rss/bellingcat': {
target: 'https://www.bellingcat.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/bellingcat/, ''),
},
// RSS Feeds - TechCrunch (layoffs)
'/rss/techcrunch': {
target: 'https://techcrunch.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/techcrunch/, ''),
},
// Google News RSS
'/rss/googlenews': {
target: 'https://news.google.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/googlenews/, ''),
},
// AI Company Blogs
'/rss/openai': {
target: 'https://openai.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/openai/, ''),
},
'/rss/anthropic': {
target: 'https://www.anthropic.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/anthropic/, ''),
},
'/rss/googleai': {
target: 'https://blog.google',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/googleai/, ''),
},
'/rss/deepmind': {
target: 'https://deepmind.google',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/deepmind/, ''),
},
'/rss/huggingface': {
target: 'https://huggingface.co',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/huggingface/, ''),
},
'/rss/techreview': {
target: 'https://www.technologyreview.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/techreview/, ''),
},
'/rss/arxiv': {
target: 'https://rss.arxiv.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/arxiv/, ''),
},
// Government
'/rss/whitehouse': {
target: 'https://www.whitehouse.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/whitehouse/, ''),
},
'/rss/statedept': {
target: 'https://www.state.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/statedept/, ''),
},
'/rss/state': {
target: 'https://www.state.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/state/, ''),
},
'/rss/defense': {
target: 'https://www.defense.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/defense/, ''),
},
'/rss/justice': {
target: 'https://www.justice.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/justice/, ''),
},
'/rss/cdc': {
target: 'https://tools.cdc.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/cdc/, ''),
},
'/rss/fema': {
target: 'https://www.fema.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/fema/, ''),
},
'/rss/dhs': {
target: 'https://www.dhs.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/dhs/, ''),
},
'/rss/fedreserve': {
target: 'https://www.federalreserve.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/fedreserve/, ''),
},
'/rss/sec': {
target: 'https://www.sec.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/sec/, ''),
},
'/rss/treasury': {
target: 'https://home.treasury.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/treasury/, ''),
},
'/rss/cisa': {
target: 'https://www.cisa.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/cisa/, ''),
},
// Think Tanks
'/rss/brookings': {
target: 'https://www.brookings.edu',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/brookings/, ''),
},
'/rss/cfr': {
target: 'https://www.cfr.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/cfr/, ''),
},
'/rss/csis': {
target: 'https://www.csis.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/csis/, ''),
},
// Defense
'/rss/warzone': {
target: 'https://www.thedrive.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/warzone/, ''),
},
'/rss/defensegov': {
target: 'https://www.defense.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/defensegov/, ''),
},
// Security
'/rss/krebs': {
target: 'https://krebsonsecurity.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/krebs/, ''),
},
// Finance
'/rss/yahoonews': {
target: 'https://finance.yahoo.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/yahoonews/, ''),
},
// Diplomat
'/rss/diplomat': {
target: 'https://thediplomat.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/diplomat/, ''),
},
// VentureBeat
'/rss/venturebeat': {
target: 'https://venturebeat.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/venturebeat/, ''),
},
// Foreign Policy
'/rss/foreignpolicy': {
target: 'https://foreignpolicy.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/foreignpolicy/, ''),
},
// Financial Times
'/rss/ft': {
target: 'https://www.ft.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/ft/, ''),
},
// Reuters
'/rss/reuters': {
target: 'https://www.reutersagency.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/reuters/, ''),
},
// Cloudflare Radar - Internet outages
'/api/cloudflare-radar': {
target: 'https://api.cloudflare.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/cloudflare-radar/, ''),
},
// NGA Maritime Safety Information - Navigation Warnings
'/api/nga-msi': {
target: 'https://msi.nga.mil',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/nga-msi/, ''),
},
// GDELT GEO 2.0 API - Global event data
'/api/gdelt': {
target: 'https://api.gdeltproject.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/gdelt/, ''),
},
// AISStream WebSocket proxy for live vessel tracking
'/ws/aisstream': {
target: 'wss://stream.aisstream.io',
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(/^\/ws\/aisstream/, ''),
},
// FAA NASSTATUS - Airport delays and closures
'/api/faa': {
target: 'https://nasstatus.faa.gov',
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/api\/faa/, ''),
configure: (proxy) => {
proxy.on('error', (err) => {
console.log('FAA NASSTATUS proxy error:', err.message);
});
},
},
// OpenSky Network - Aircraft tracking (military flight detection)
'/api/opensky': {
target: 'https://opensky-network.org/api',
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/api\/opensky/, ''),
configure: (proxy) => {
proxy.on('error', (err) => {
console.log('OpenSky proxy error:', err.message);
});
},
},
// ADS-B Exchange - Military aircraft tracking (backup/supplement)
'/api/adsb-exchange': {
target: 'https://adsbexchange.com/api',
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/api\/adsb-exchange/, ''),
configure: (proxy) => {
proxy.on('error', (err) => {
console.log('ADS-B Exchange proxy error:', err.message);
});
},
},
},
},
};
});