diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx
index 9a062c4..bf9abcc 100644
--- a/src/components/SpotMap.tsx
+++ b/src/components/SpotMap.tsx
@@ -22,6 +22,20 @@ import { sanitizeUrl } from '@/lib/sanitizeUrl';
* an `href`/`src`. See .agents/skills/nostr-security.
*/
+/**
+ * Escape a value for safe interpolation into an innerHTML template literal.
+ * Weather/air-quality fields come from an external API, so they're untrusted —
+ * a compromised or malicious upstream could otherwise inject HTML/JS.
+ */
+function escapeHtml(value: unknown): string {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
/** A popup detail row: an emoji/icon prefix + untrusted text set via textContent. */
function popupRow(prefix: string, text: string): HTMLElement {
const row = document.createElement('div');
@@ -516,17 +530,17 @@ function createPopupContent(
currentWeather.className = 'flex items-center justify-between mb-2';
currentWeather.innerHTML = `
-
- ${weatherData.skateability.label}
+
+ ${escapeHtml(weatherData.skateability.label)}
-
${weatherData.skateability.score}/100
+
${escapeHtml(weatherData.skateability.score)}/100
`;
weatherSection.appendChild(currentWeather);
@@ -535,9 +549,9 @@ function createPopupContent(
const details = document.createElement('div');
details.className = 'text-xs space-y-1 text-gray-600';
details.innerHTML = `
-
💨 Wind: ${weatherData.weather.windSpeed} km/h ${weatherData.weather.windDirectionLabel}
-
💧 Humidity: ${weatherData.weather.humidity}%
- ${weatherData.airQuality ? `
🌫️ Air Quality: ${weatherData.airQuality.aqiLabel} (${weatherData.airQuality.aqi})
` : ''}
+
💨 Wind: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}
+
💧 Humidity: ${escapeHtml(weatherData.weather.humidity)}%
+ ${weatherData.airQuality ? `
🌫️ Air Quality: ${escapeHtml(weatherData.airQuality.aqiLabel)} (${escapeHtml(weatherData.airQuality.aqi)})
` : ''}
`;
weatherSection.appendChild(details);
@@ -560,10 +574,10 @@ function createPopupContent(
const date = new Date(day.date);
const dayName = date.toLocaleDateString('en-US', { weekday: 'short' });
dayDiv.innerHTML = `
-
${dayName}
-
${day.weatherIcon}
-
${day.maxTemp}°/${day.minTemp}°
- ${day.precipitationProbability > 0 ? `
${day.precipitationProbability}% 🌧️
` : ''}
+
${escapeHtml(dayName)}
+
${escapeHtml(day.weatherIcon)}
+
${escapeHtml(day.maxTemp)}°/${escapeHtml(day.minTemp)}°
+ ${day.precipitationProbability > 0 ? `
${escapeHtml(day.precipitationProbability)}% 🌧️
` : ''}
`;
forecastGrid.appendChild(dayDiv);
});
diff --git a/src/lib/sasquatchBrain.ts b/src/lib/sasquatchBrain.ts
index 485457a..3ce45f0 100644
--- a/src/lib/sasquatchBrain.ts
+++ b/src/lib/sasquatchBrain.ts
@@ -4,8 +4,13 @@
* surface can reuse the same model list + fallback behaviour.
*/
-// ─── OpenRouter free models — no credits, no NIP-98, works for everyone ───────
-const OPENROUTER_API_KEY = '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';
+// ─── OpenRouter free models ──────────────────────────────────────────────────
+// SECURITY: never hardcode an API key here. A key committed to source is shipped
+// in the production JS bundle and can be extracted + abused by anyone. Instead,
+// OpenRouter is OPT-IN via an optional build-time env var. When it's not set
+// (the default), we skip OpenRouter entirely and use the keyless Pollinations
+// backup below — so Sasquatch still works for everyone with zero secrets shipped.
+const OPENROUTER_API_KEY: string | undefined = import.meta.env.VITE_OPENROUTER_API_KEY;
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
// Try multiple free models in order — if one is rate-limited, fall back.
@@ -35,6 +40,8 @@ const POLLINATIONS_MODELS = ['openai', 'mistral'];
/** Try OpenRouter's free models, respecting 429 backoff. Returns text or ''. */
async function tryOpenRouter(messages: ChatTurn[], maxTokens: number): Promise
{
+ // No key configured → skip OpenRouter entirely (keyless backup handles it).
+ if (!OPENROUTER_API_KEY) return '';
for (const model of FREE_MODELS) {
for (let attempt = 0; attempt < 2; attempt++) {
try {
@@ -105,9 +112,10 @@ async function tryPollinations(messages: ChatTurn[], maxTokens: number): Promise
/**
* Call the AI with multi-provider fallback + rate-limit backoff.
*
- * Order: OpenRouter free models (with 429 retry) → keyless Pollinations backup.
- * Throws only if BOTH providers fail, so the caller can fall back to local
- * knowledge (static FAQ / glossary).
+ * Order: OpenRouter free models (only if VITE_OPENROUTER_API_KEY is set, with
+ * 429 retry) → keyless Pollinations backup. Throws only if all available
+ * providers fail, so the caller can fall back to local knowledge
+ * (static FAQ / glossary).
*/
export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise {
const primary = await tryOpenRouter(messages, maxTokens);