diff --git a/api/skills/fetch-agentskills.ts b/api/skills/fetch-agentskills.ts index 225f94db7..4559ada0e 100644 --- a/api/skills/fetch-agentskills.ts +++ b/api/skills/fetch-agentskills.ts @@ -3,6 +3,8 @@ export const config = { runtime: 'edge' }; // @ts-expect-error -- JS module, no declaration file import { getCorsHeaders } from '../_cors.js'; +const ALLOWED_AGENTSKILLS_HOSTS = new Set(['agentskills.io', 'www.agentskills.io', 'api.agentskills.io']); + export default async function handler(req: Request): Promise { const corsHeaders = getCorsHeaders(req) as Record; @@ -14,12 +16,6 @@ export default async function handler(req: Request): Promise { return Response.json({ error: 'Method not allowed' }, { status: 405, headers: corsHeaders }); } - // Simple IP-based rate limiting (10/hour) using cf-connecting-ip header first - const ip = req.headers.get('cf-connecting-ip') ?? req.headers.get('x-forwarded-for') ?? 'unknown'; - // Note: stateless edge -- implement rate limiting via KV or accept best-effort for now. - // For phase 1, log the IP and rely on Vercel rate limiting rules for abuse prevention. - void ip; - let body: { url?: string; id?: string }; try { body = await req.json() as { url?: string; id?: string }; @@ -39,9 +35,7 @@ export default async function handler(req: Request): Promise { return Response.json({ error: 'Invalid URL' }, { status: 400, headers: corsHeaders }); } - // Use exact match or subdomain check — endsWith alone is bypassable by 'evilagentskills.io' - const h = skillUrl.hostname; - if (h !== 'agentskills.io' && !h.endsWith('.agentskills.io')) { + if (!ALLOWED_AGENTSKILLS_HOSTS.has(skillUrl.hostname)) { return Response.json({ error: 'Only agentskills.io URLs are supported.' }, { status: 400, headers: corsHeaders }); } @@ -49,8 +43,12 @@ export default async function handler(req: Request): Promise { try { const res = await fetch(skillUrl.toString(), { headers: { 'Accept': 'application/json', 'User-Agent': 'WorldMonitor/1.0' }, - signal: AbortSignal.timeout(10_000), + redirect: 'manual', + signal: AbortSignal.timeout(8_000), }); + if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) { + return Response.json({ error: 'Redirects are not allowed.' }, { status: 400, headers: corsHeaders }); + } if (!res.ok) { return Response.json({ error: 'Could not reach agentskills.io. Check your connection.' }, { status: 502, headers: corsHeaders }); } @@ -61,7 +59,7 @@ export default async function handler(req: Request): Promise { const instructions = typeof skillData.instructions === 'string' ? skillData.instructions : null; if (!instructions) { - return Response.json({ error: "This skill has no instructions — it may use tools only (not supported in phase 1)." }, { status: 422, headers: corsHeaders }); + return Response.json({ error: "This skill has no instructions — it may use tools only (not supported)." }, { status: 422, headers: corsHeaders }); } const MAX_LEN = 2000; diff --git a/server/_shared/llm.ts b/server/_shared/llm.ts index 294d23fa4..d80d0e0ae 100644 --- a/server/_shared/llm.ts +++ b/server/_shared/llm.ts @@ -1,5 +1,6 @@ import { CHROME_UA } from './constants'; import { isProviderAvailable } from './llm-health'; +import { sanitizeForPrompt } from './llm-sanitize.js'; export interface ProviderCredentials { apiUrl: string; @@ -110,6 +111,7 @@ export function stripThinkingTags(text: string): string { .replace(/<\|begin_of_thought\|>[\s\S]*?<\|end_of_thought\|>/gi, '') .trim(); + // Strip unterminated opening tags (no closing tag present) s = s .replace(/[\s\S]*/gi, '') .replace(/<\|thinking\|>[\s\S]*/gi, '') @@ -121,6 +123,7 @@ export function stripThinkingTags(text: string): string { return s; } + const PROVIDER_CHAIN = ['ollama', 'groq', 'openrouter', 'generic'] as const; const PROVIDER_SET = new Set(PROVIDER_CHAIN); @@ -184,10 +187,13 @@ export async function callLlm(opts: LlmCallOptions): Promise { + const keyCheck = validateApiKey(request, {}) as { valid: boolean; required: boolean }; + // Only treat as premium when an explicit API key was validated (required: true). + // Trusted-origin short-circuits (required: false) do NOT imply PRO entitlement. + if (keyCheck.valid && keyCheck.required) return true; + + const authHeader = request.headers.get('Authorization'); + if (authHeader?.startsWith('Bearer ')) { + const session = await validateBearerToken(authHeader.slice(7)); + return session.valid && session.role === 'pro'; + } + return false; +} diff --git a/server/worldmonitor/intelligence/v1/deduct-situation.ts b/server/worldmonitor/intelligence/v1/deduct-situation.ts index 98d1e504a..934c058ed 100644 --- a/server/worldmonitor/intelligence/v1/deduct-situation.ts +++ b/server/worldmonitor/intelligence/v1/deduct-situation.ts @@ -8,12 +8,13 @@ import { cachedFetchJson } from '../../../_shared/redis'; import { sha256Hex } from './_shared'; import { callLlm } from '../../../_shared/llm'; import { buildDeductionPrompt, postProcessDeductionOutput } from './deduction-prompt'; +import { isCallerPremium } from '../../../_shared/premium-check'; const DEDUCT_TIMEOUT_MS = 120_000; const DEDUCT_CACHE_TTL = 3600; export async function deductSituation( - _ctx: ServerContext, + ctx: ServerContext, req: DeductSituationRequest, ): Promise { const MAX_QUERY_LEN = 500; @@ -22,11 +23,17 @@ export async function deductSituation( const query = typeof req.query === 'string' ? req.query.slice(0, MAX_QUERY_LEN).trim() : ''; const geoContext = typeof req.geoContext === 'string' ? req.geoContext.slice(0, MAX_GEO_LEN).trim() : ''; - const framework = typeof req.framework === 'string' ? req.framework.slice(0, MAX_FRAMEWORK_LEN) : ''; + const isPremium = await isCallerPremium(ctx.request); + const framework = isPremium && typeof req.framework === 'string' ? req.framework.slice(0, MAX_FRAMEWORK_LEN) : ''; if (!query) return { analysis: '', model: '', provider: 'skipped' }; - const cacheKey = `deduct:situation:v2:${(await sha256Hex(query.toLowerCase() + '|' + geoContext.toLowerCase())).slice(0, 16)}`; + const [queryHash, frameworkHashFull] = await Promise.all([ + sha256Hex(query.toLowerCase() + '|' + geoContext.toLowerCase()), + framework ? sha256Hex(framework) : Promise.resolve(''), + ]); + const frameworkHash = framework ? frameworkHashFull.slice(0, 8) : ''; + const cacheKey = `deduct:situation:v2:${queryHash.slice(0, 16)}${frameworkHash ? ':fw' + frameworkHash : ''}`; const { mode, systemPrompt, userPrompt } = buildDeductionPrompt({ query, geoContext }); diff --git a/server/worldmonitor/intelligence/v1/get-country-intel-brief.ts b/server/worldmonitor/intelligence/v1/get-country-intel-brief.ts index 7e529c3f5..addf272ec 100644 --- a/server/worldmonitor/intelligence/v1/get-country-intel-brief.ts +++ b/server/worldmonitor/intelligence/v1/get-country-intel-brief.ts @@ -7,6 +7,8 @@ import type { import { cachedFetchJson } from '../../../_shared/redis'; import { UPSTREAM_TIMEOUT_MS, TIER1_COUNTRIES, sha256Hex } from './_shared'; import { callLlm } from '../../../_shared/llm'; +import { isCallerPremium } from '../../../_shared/premium-check'; +import { sanitizeForPrompt } from '../../../_shared/llm-sanitize.js'; const INTEL_CACHE_TTL = 7200; @@ -28,15 +30,20 @@ export async function getCountryIntelBrief( let lang = 'en'; try { const url = new URL(ctx.request.url); - contextSnapshot = (url.searchParams.get('context') || '').trim().slice(0, 4000); + contextSnapshot = sanitizeForPrompt((url.searchParams.get('context') || '').trim().slice(0, 4000)); lang = url.searchParams.get('lang') || 'en'; } catch { contextSnapshot = ''; } - const frameworkRaw = typeof req.framework === 'string' ? req.framework.slice(0, 2000) : ''; - const contextHash = contextSnapshot ? (await sha256Hex(contextSnapshot)).slice(0, 16) : 'base'; - const frameworkHash = frameworkRaw ? (await sha256Hex(frameworkRaw)).slice(0, 8) : ''; + const isPremium = await isCallerPremium(ctx.request); + const frameworkRaw = isPremium && typeof req.framework === 'string' ? req.framework.slice(0, 2000) : ''; + const [contextHashFull, frameworkHashFull] = await Promise.all([ + contextSnapshot ? sha256Hex(contextSnapshot) : Promise.resolve('base'), + frameworkRaw ? sha256Hex(frameworkRaw) : Promise.resolve(''), + ]); + const contextHash = contextSnapshot ? contextHashFull.slice(0, 16) : 'base'; + const frameworkHash = frameworkRaw ? frameworkHashFull.slice(0, 8) : ''; const cacheKey = `ci-sebuf:v3:${req.countryCode}:${lang}:${contextHash}${frameworkHash ? `:${frameworkHash}` : ''}`; const countryName = TIER1_COUNTRIES[req.countryCode] || req.countryCode; const dateStr = new Date().toISOString().split('T')[0]; diff --git a/server/worldmonitor/news/v1/summarize-article.ts b/server/worldmonitor/news/v1/summarize-article.ts index 34616004c..616a4ac9d 100644 --- a/server/worldmonitor/news/v1/summarize-article.ts +++ b/server/worldmonitor/news/v1/summarize-article.ts @@ -15,6 +15,8 @@ import { import { CHROME_UA } from '../../../_shared/constants'; import { isProviderAvailable } from '../../../_shared/llm-health'; import { sanitizeHeadlinesLight, sanitizeHeadlines, sanitizeForPrompt } from '../../../_shared/llm-sanitize.js'; +import { isCallerPremium } from '../../../_shared/premium-check'; +import { stripThinkingTags } from '../../../_shared/llm'; // ====================================================================== // Reasoning preamble detection @@ -34,10 +36,12 @@ export function hasReasoningPreamble(text: string): boolean { // ====================================================================== export async function summarizeArticle( - _ctx: ServerContext, + ctx: ServerContext, req: SummarizeArticleRequest, ): Promise { - const { provider, mode = 'brief', geoContext = '', variant = 'full', lang = 'en', systemAppend = '' } = req; + const isPremium = await isCallerPremium(ctx.request); + const { provider, mode = 'brief', geoContext = '', variant = 'full', lang = 'en' } = req; + const systemAppend = isPremium && typeof req.systemAppend === 'string' ? req.systemAppend : ''; const MAX_HEADLINES = 10; const MAX_HEADLINE_LEN = 500; @@ -97,7 +101,7 @@ export async function summarizeArticle( } try { - const cacheKey = getCacheKey(headlines, mode, sanitizedGeoContext, variant, lang); + const cacheKey = getCacheKey(headlines, mode, sanitizedGeoContext, variant, lang, systemAppend || undefined); // Single atomic call — source tracking happens inside cachedFetchJsonWithMeta, // eliminating the TOCTOU race between a separate getCachedJson and cachedFetchJson. @@ -120,8 +124,9 @@ export async function summarizeArticle( lang, }); - const effectiveSystemPrompt = systemAppend - ? `${systemPrompt}\n\n---\n\n${systemAppend}` + const sanitizedAppend = systemAppend ? sanitizeForPrompt(systemAppend) : ''; + const effectiveSystemPrompt = sanitizedAppend + ? `${systemPrompt}\n\n---\n\n${sanitizedAppend}` : systemPrompt; const response = await fetch(apiUrl, { @@ -150,24 +155,8 @@ export async function summarizeArticle( const data = await response.json() as any; const tokens = (data.usage?.total_tokens as number) || 0; const message = data.choices?.[0]?.message; - let rawContent = typeof message?.content === 'string' ? message.content.trim() : ''; - - rawContent = rawContent - .replace(/[\s\S]*?<\/think>/gi, '') - .replace(/<\|thinking\|>[\s\S]*?<\|\/thinking\|>/gi, '') - .replace(/[\s\S]*?<\/reasoning>/gi, '') - .replace(/[\s\S]*?<\/reflection>/gi, '') - .replace(/<\|begin_of_thought\|>[\s\S]*?<\|end_of_thought\|>/gi, '') - .trim(); - - // Strip unterminated thinking blocks (no closing tag) - rawContent = rawContent - .replace(/[\s\S]*/gi, '') - .replace(/<\|thinking\|>[\s\S]*/gi, '') - .replace(/[\s\S]*/gi, '') - .replace(/[\s\S]*/gi, '') - .replace(/<\|begin_of_thought\|>[\s\S]*/gi, '') - .trim(); + const rawText = typeof message?.content === 'string' ? message.content.trim() : ''; + let rawContent = stripThinkingTags(rawText); if (['brief', 'analysis'].includes(mode) && rawContent.length < 20) { console.warn(`[SummarizeArticle:${provider}] Output too short after stripping (${rawContent.length} chars), rejecting`); diff --git a/src/app/country-intel.ts b/src/app/country-intel.ts index 77349c9cc..7f0e86adb 100644 --- a/src/app/country-intel.ts +++ b/src/app/country-intel.ts @@ -62,6 +62,7 @@ export class CountryIntelManager implements AppModule { private ctx: AppContext; private briefRequestToken = 0; private frameworkUnsubscribe: (() => void) | null = null; + private _fwDebounce: ReturnType | null = null; constructor(ctx: AppContext) { this.ctx = ctx; @@ -74,11 +75,14 @@ export class CountryIntelManager implements AppModule { if (!page?.isVisible()) return; const code = page.getCode(); const name = page.getName() ?? code; - if (code && name) void this.openCountryBriefByCode(code, name); + if (!code || !name) return; + if (this._fwDebounce) clearTimeout(this._fwDebounce); + this._fwDebounce = setTimeout(() => void this.openCountryBriefByCode(code, name), 400); }); } destroy(): void { + if (this._fwDebounce) { clearTimeout(this._fwDebounce); this._fwDebounce = null; } this.ctx.countryTimeline?.destroy(); this.ctx.countryTimeline = null; this.ctx.countryBriefPage = null; diff --git a/src/components/DailyMarketBriefPanel.ts b/src/components/DailyMarketBriefPanel.ts index 57bed4b66..5c9dd980c 100644 --- a/src/components/DailyMarketBriefPanel.ts +++ b/src/components/DailyMarketBriefPanel.ts @@ -45,7 +45,7 @@ export class DailyMarketBriefPanel extends Panel { constructor() { super({ id: 'daily-market-brief', title: 'Daily Market Brief', infoTooltip: t('components.dailyMarketBrief.infoTooltip'), premium: 'locked' }); - this.fwSelector = new FrameworkSelector({ panelId: 'daily-market-brief', isPremium: hasPremiumAccess(), panel: this }); + this.fwSelector = new FrameworkSelector({ panelId: 'daily-market-brief', isPremium: hasPremiumAccess(), panel: this, note: 'Applies to client-generated analysis only' }); this.header.appendChild(this.fwSelector.el); } diff --git a/src/components/DeductionPanel.ts b/src/components/DeductionPanel.ts index 61cc074fd..bfe27a058 100644 --- a/src/components/DeductionPanel.ts +++ b/src/components/DeductionPanel.ts @@ -121,9 +121,6 @@ export class DeductionPanel extends Panel { } const fw = getActiveFrameworkForPanel('deduction'); - if (fw) { - geoContext = `${geoContext}\n\n---\nAnalytical Framework:\n${fw.systemPromptAppend}`; - } this.isSubmitting = true; this.submitBtn.disabled = true; @@ -135,7 +132,7 @@ export class DeductionPanel extends Panel { const resp = await client.deductSituation({ query, geoContext, - framework: '', + framework: fw?.systemPromptAppend ?? '', }); if (!this.element?.isConnected) return; diff --git a/src/components/FrameworkSelector.ts b/src/components/FrameworkSelector.ts index 073299e47..f8ce94b62 100644 --- a/src/components/FrameworkSelector.ts +++ b/src/components/FrameworkSelector.ts @@ -11,6 +11,7 @@ interface FrameworkSelectorOptions { panelId: AnalysisPanelId; isPremium: boolean; panel: Panel | null; + note?: string; } export class FrameworkSelector { @@ -33,14 +34,14 @@ export class FrameworkSelector { setActiveFrameworkForPanel(opts.panelId, select.value || null); }); - if (opts.panelId === 'insights') { + if (opts.note) { const wrap = document.createElement('span'); wrap.className = 'framework-selector-wrap'; - const note = document.createElement('span'); - note.className = 'framework-selector-note'; - note.title = 'Applies to client-generated analysis only'; - note.textContent = '*'; - wrap.append(select, note); + const noteEl = document.createElement('span'); + noteEl.className = 'framework-selector-note'; + noteEl.title = opts.note; + noteEl.textContent = '*'; + wrap.append(select, noteEl); this.el = wrap; } else { this.el = select; diff --git a/src/components/InsightsPanel.ts b/src/components/InsightsPanel.ts index 2d3492c9f..68f309218 100644 --- a/src/components/InsightsPanel.ts +++ b/src/components/InsightsPanel.ts @@ -53,11 +53,10 @@ export class InsightsPanel extends Panel { } this.frameworkUnsubscribe = subscribeFrameworkChange('insights', () => { - this.updateGeneration++; void this.updateInsights(this.lastClusters); }); - this.fwSelector = new FrameworkSelector({ panelId: 'insights', isPremium: hasPremiumAccess(), panel: this }); + this.fwSelector = new FrameworkSelector({ panelId: 'insights', isPremium: hasPremiumAccess(), panel: this, note: 'Applies to client-generated analysis only' }); this.header.appendChild(this.fwSelector.el); } diff --git a/src/locales/en.json b/src/locales/en.json index ee0b31c9d..113610fb2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1447,6 +1447,15 @@ "sectionIntelligence": "Intelligence", "headlineMemoryLabel": "Headline Memory", "headlineMemoryDesc": "Remember seen headlines to highlight new stories", + "analysisFrameworksLabel": "Analysis Frameworks", + "analysisFrameworksActivePerPanel": "Active per panel", + "analysisFrameworksSkillLibrary": "Skill library", + "analysisFrameworksImportBtn": "Import framework", + "analysisFrameworksDefaultNeutral": "Default (Neutral)", + "analysisFrameworksImportTitle": "Import Framework", + "analysisFrameworksFromAgentskills": "From agentskills.io", + "analysisFrameworksPasteJson": "Paste JSON", + "analysisFrameworksSaveToLibrary": "Save to Library", "streamAlwaysOnLabel": "Keep live streams running", "streamAlwaysOnDesc": "Prevents Live Cams and Live News from auto-pausing when you are idle. Recommended for second-monitor / wallboard usage. Disable (Eco) to save CPU/bandwidth." }, diff --git a/src/locales/fr.json b/src/locales/fr.json index a6d4f6a67..f8e95a0a9 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1186,6 +1186,15 @@ "sectionIntelligence": "Renseignement", "headlineMemoryLabel": "Mémoire des titres", "headlineMemoryDesc": "Mémoriser les titres vus pour mettre en évidence les nouveaux", + "analysisFrameworksLabel": "Cadres d'analyse", + "analysisFrameworksActivePerPanel": "Actif par panneau", + "analysisFrameworksSkillLibrary": "Bibliothèque de compétences", + "analysisFrameworksImportBtn": "Importer un cadre", + "analysisFrameworksDefaultNeutral": "Par défaut (Neutre)", + "analysisFrameworksImportTitle": "Importer un cadre", + "analysisFrameworksFromAgentskills": "Depuis agentskills.io", + "analysisFrameworksPasteJson": "Coller JSON", + "analysisFrameworksSaveToLibrary": "Enregistrer dans la bibliothèque", "streamAlwaysOnLabel": "Garder les flux en direct actifs", "streamAlwaysOnDesc": "Empêche la mise en pause automatique de Live Cams et Live News lorsque vous êtes inactif. Recommandé pour un second écran / usage wallboard. Désactivez (Éco) pour économiser CPU/bande passante.", "globeRenderQualityLabel": "Qualité de rendu du globe", diff --git a/src/services/analysis-framework-store.ts b/src/services/analysis-framework-store.ts index c3392e96f..64896b812 100644 --- a/src/services/analysis-framework-store.ts +++ b/src/services/analysis-framework-store.ts @@ -105,6 +105,8 @@ Close with: a one-sentence devil's advocate verdict — what is the most importa }, ]; +const _activeCache = new Map(); + export function loadFrameworkLibrary(): AnalysisFramework[] { const imported = loadFromStorage(LIBRARY_KEY, []); return [...BUILT_IN_FRAMEWORKS, ...imported]; @@ -124,31 +126,38 @@ export function saveImportedFramework(fw: Omit f.id === id)) return; const imported = loadFromStorage(LIBRARY_KEY, []); saveToStorage(LIBRARY_KEY, imported.filter(f => f.id !== id)); + _activeCache.clear(); } export function renameImportedFramework(id: string, name: string): void { if (BUILT_IN_FRAMEWORKS.some(f => f.id === id)) return; const imported = loadFromStorage(LIBRARY_KEY, []); saveToStorage(LIBRARY_KEY, imported.map(f => f.id === id ? { ...f, name } : f)); + _activeCache.clear(); } export function getActiveFrameworkForPanel(panelId: AnalysisPanelId): AnalysisFramework | null { if (!hasPremiumAccess()) return null; + if (_activeCache.has(panelId)) return _activeCache.get(panelId)!; const selections = loadFromStorage>(PANEL_KEY, {}); const frameworkId = selections[panelId] ?? null; - if (!frameworkId) return null; - return loadFrameworkLibrary().find(f => f.id === frameworkId) ?? null; + if (!frameworkId) { _activeCache.set(panelId, null); return null; } + const result = loadFrameworkLibrary().find(f => f.id === frameworkId) ?? null; + _activeCache.set(panelId, result); + return result; } export function setActiveFrameworkForPanel(panelId: AnalysisPanelId, frameworkId: string | null): void { const selections = loadFromStorage>(PANEL_KEY, {}); saveToStorage(PANEL_KEY, { ...selections, [panelId]: frameworkId }); + _activeCache.delete(panelId); window.dispatchEvent(new CustomEvent(FRAMEWORK_CHANGED_EVENT, { detail: { panelId, frameworkId }, })); diff --git a/src/services/preferences-content.ts b/src/services/preferences-content.ts index 922c4d298..77d0fc4a3 100644 --- a/src/services/preferences-content.ts +++ b/src/services/preferences-content.ts @@ -208,7 +208,7 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { // ── Analysis Frameworks group ── html += `
`; - html += `Analysis Frameworks`; + html += `${t('components.insights.analysisFrameworksLabel')}`; html += `
`; // Per-panel active framework display @@ -218,38 +218,38 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { { id: 'daily-market-brief', label: 'Market Brief' }, { id: 'deduction', label: 'Deduction' }, ]; - html += ``; + html += ``; html += `
`; for (const { id, label } of panelIds) { const active = getActiveFrameworkForPanel(id); html += `
${escapeHtml(label)} - ${active ? escapeHtml(active.name) : 'Default (Neutral)'} + ${active ? escapeHtml(active.name) : t('components.insights.analysisFrameworksDefaultNeutral')}
`; } html += `
`; // Skill library list - html += ``; + html += ``; html += `
`; html += renderFrameworkLibraryHtml(); html += `
`; // Import button html += `
- +
`; // Import modal (hidden by default) html += `