From 37c8737ab05e49cdbf8f500e0028cff71bf31ca6 Mon Sep 17 00:00:00 2001 From: manager Date: Sun, 31 May 2026 07:55:46 +0000 Subject: [PATCH 1/5] fix(csp): bring staging CSP to prod parity for analytics Adds api-js.mixpanel.com, *.analytics.google.com, stats.g.doubleclick.net and www.google.com to connect-src (+ www.google.com to img-src) so GA4 regional collection, the Mixpanel JS shard, and the Google Ads conversion beacon are not CSP-blocked on staging. Mirrors fixes already live on main; prevents a future dev->main merge from reverting prod's analytics fixes. Co-Authored-By: Claude Opus 4.7 --- next.config.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index dffe462..6a451e4 100644 --- a/next.config.js +++ b/next.config.js @@ -43,7 +43,12 @@ module.exports = withBundleAnalyzer({ 'https://*.keepsimple.io', 'https://metrics.administration.ae', 'https://api.mixpanel.com', + 'https://api-js.mixpanel.com', 'https://www.google-analytics.com', + 'https://*.analytics.google.com', + 'https://stats.g.doubleclick.net', + // Google Ads conversion: modern endpoint www.google.com/ccm/collect. + 'https://www.google.com', ] .filter(Boolean) .join(' '); @@ -69,7 +74,7 @@ module.exports = withBundleAnalyzer({ "default-src 'self'", `script-src ${scriptSrc}`, "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: https://lh3.googleusercontent.com https://cdn.discordapp.com https://strapi.keepsimple.io https://staging-strapi.keepsimple.io https://www.google-analytics.com https://flagcdn.com", + "img-src 'self' data: blob: https://lh3.googleusercontent.com https://cdn.discordapp.com https://strapi.keepsimple.io https://staging-strapi.keepsimple.io https://www.google-analytics.com https://flagcdn.com https://www.google.com", "font-src 'self' data:", `connect-src ${connectSrc}`, "frame-ancestors 'none'", From e9442b59ca44f5888f0f52d736609a23da14579e Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 2 Jun 2026 16:28:15 +0000 Subject: [PATCH 2/5] fix(widget): gate paid organic greeting behind open + engaged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-navigation "you're on X" landing line fires a paid Claude call on every page hop, even when the pill is closed and the visitor never interacts — so cost scaled with raw visitor count. Now the organic greeting only spends when the panel is open AND the visitor has already engaged (asked a question / clicked a card / picked a suggestion) this session. Curated landings stay local/free; card-click landings already imply both signals and are unchanged. Co-Authored-By: Claude Opus 4.7 --- widget/src/AskUxCore.tsx | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index d996d58..81e248a 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -795,6 +795,7 @@ const PAGE_LANDINGS: Record> = { }; const CURATED_LANDING_FIRED_KEY = 'ks_aux_curated_landing_v1'; +const ENGAGED_KEY = 'ks_aux_engaged_v1'; const curatedLandingPathKey = (rawUrl: string): string | null => { try { const u = new URL(rawUrl, window.location.origin); @@ -833,6 +834,25 @@ const markCuratedLandingFired = (key: string) => { } }; +/* Engagement flag — set once the visitor actually uses the widget + (asks a question / clicks a card / picks a suggestion). Gates the + paid organic greeting so we only spend on visitors who've shown + interest, never on pure passers-by. Per-tab; clears on tab close. */ +const hasEngaged = (): boolean => { + try { + return sessionStorage.getItem(ENGAGED_KEY) === '1'; + } catch { + return false; + } +}; +const markEngaged = () => { + try { + sessionStorage.setItem(ENGAGED_KEY, '1'); + } catch { + /* sessionStorage disabled — engagement not persisted across nav */ + } +}; + /* ────────────────────────────────────────────────────────────────── Identity query triggers — works on any page. ────────────────────────────────────────────────────────────────── @@ -1770,6 +1790,10 @@ export function AskUxCore({ lang }: { lang: Lang }) { // effects (host-page highlights, etc.) until the visitor explicitly // opens the pill — even if the previous session ended with it open. const [open, setOpen] = useState(false); + /* Fresh mirror of `open` for the once-mounted nav effect, whose + closure would otherwise capture the initial (always-false) value. */ + const openRef = useRef(open); + openRef.current = open; const [text, setText] = useState(''); const [turns, setTurns] = useState(initial?.turns ?? []); const [loading, setLoading] = useState(false); @@ -2202,6 +2226,14 @@ export function AskUxCore({ lang }: { lang: Lang }) { return; } + /* Cost gate: the organic greeting is a paid AI call. Spend it + only when the visitor is actually in the widget — panel open + AND already engaged (asked something / picked a card) this + session. Passers-by with the pill closed cost nothing, so the + bill no longer scales with raw visitor count. Curated landings + above are local (free) and stay ungated. */ + if (!openRef.current || !hasEngaged()) return; + const ctrl = new AbortController(); organicAbortRef.current = ctrl; fetch('/api/concierge-landing', { @@ -2905,6 +2937,7 @@ export function AskUxCore({ lang }: { lang: Lang }) { }; const runQuery = async (query: string, replaceTurnId?: string) => { + markEngaged(); setLoading(true); const id = replaceTurnId ?? `${Date.now()}`; const newTurn: Turn = { @@ -3076,6 +3109,7 @@ export function AskUxCore({ lang }: { lang: Lang }) { const onCardClick = (citation: Citation) => { if (!citation.url) return; + markEngaged(); trackEvent('card_click', { url: citation.url, type: citation.type }); const tier: 'high' | 'mid' | 'low' = citation.nominated ? 'high' From a681aab8c4ac1d1312e08c894bba5c13bcb69a77 Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 2 Jun 2026 16:45:19 +0000 Subject: [PATCH 3/5] fix(widget): typed-input-only greeting with 30-min expiry + per-page cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the organic greeting cost gate per product decision: - Engagement is now set ONLY on a manually typed message. Card and suggestion clicks no longer count — clicking existing buttons is navigation, not a conversation, and must not incur paid greetings. - Engagement expires after 30 min of no typed input (timestamp + TTL), so a long-idle tab starts neutral again. - Never pay twice for the same page in a session: the organic greeting is cached per canonical path; revisits and back/forth are free. Curated (local) landings and the chat answer path are unchanged. Co-Authored-By: Claude Opus 4.7 --- widget/src/AskUxCore.tsx | 60 ++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index 81e248a..23b1cda 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -796,6 +796,7 @@ const PAGE_LANDINGS: Record> = { const CURATED_LANDING_FIRED_KEY = 'ks_aux_curated_landing_v1'; const ENGAGED_KEY = 'ks_aux_engaged_v1'; +const GREETED_PAGES_KEY = 'ks_aux_greeted_pages_v1'; const curatedLandingPathKey = (rawUrl: string): string | null => { try { const u = new URL(rawUrl, window.location.origin); @@ -834,22 +835,49 @@ const markCuratedLandingFired = (key: string) => { } }; -/* Engagement flag — set once the visitor actually uses the widget - (asks a question / clicks a card / picks a suggestion). Gates the - paid organic greeting so we only spend on visitors who've shown - interest, never on pure passers-by. Per-tab; clears on tab close. */ +/* Engagement — set ONLY when the visitor types a message themselves + (manual input). Clicking cards/suggestions/buttons does NOT count: + those are navigation, not a conversation. Gates the paid organic + greeting so we spend only on people who've actually talked to the + Copilot. Stored as a timestamp and treated as expired after 30 min + of no further typed input, so a long-idle tab starts neutral again. + Per-tab; clears on tab close. */ +const ENGAGED_TTL_MS = 30 * 60 * 1000; +const markEngaged = () => { + try { + sessionStorage.setItem(ENGAGED_KEY, String(Date.now())); + } catch { + /* sessionStorage disabled — engagement not persisted across nav */ + } +}; const hasEngaged = (): boolean => { try { - return sessionStorage.getItem(ENGAGED_KEY) === '1'; + const ts = Number(sessionStorage.getItem(ENGAGED_KEY) || '0'); + return ts > 0 && Date.now() - ts <= ENGAGED_TTL_MS; } catch { return false; } }; -const markEngaged = () => { + +/* Per-page greeting cache — once the organic greeting has fired for a + page in this tab session, never pay for it again on that page (revisits + and back/forth are free). Keyed by canonical path. */ +const hasGreetedPage = (key: string): boolean => { try { - sessionStorage.setItem(ENGAGED_KEY, '1'); + const raw = sessionStorage.getItem(GREETED_PAGES_KEY) || '{}'; + return !!JSON.parse(raw)[key]; } catch { - /* sessionStorage disabled — engagement not persisted across nav */ + return false; + } +}; +const markGreetedPage = (key: string) => { + try { + const raw = sessionStorage.getItem(GREETED_PAGES_KEY) || '{}'; + const obj = JSON.parse(raw); + obj[key] = Date.now(); + sessionStorage.setItem(GREETED_PAGES_KEY, JSON.stringify(obj)); + } catch { + /* sessionStorage disabled — greeting may re-fire on revisit */ } }; @@ -2228,11 +2256,14 @@ export function AskUxCore({ lang }: { lang: Lang }) { /* Cost gate: the organic greeting is a paid AI call. Spend it only when the visitor is actually in the widget — panel open - AND already engaged (asked something / picked a card) this - session. Passers-by with the pill closed cost nothing, so the - bill no longer scales with raw visitor count. Curated landings - above are local (free) and stay ungated. */ + AND they've typed a message themselves within the last 30 min. + Passers-by, button-only clickers and bots never type, so they + cost nothing. Then never pay twice for the same page this + session. Curated landings above are local (free) and ungated. */ if (!openRef.current || !hasEngaged()) return; + const greetKey = canonicalPathKey(rawUrl); + if (hasGreetedPage(greetKey)) return; + markGreetedPage(greetKey); const ctrl = new AbortController(); organicAbortRef.current = ctrl; @@ -2937,7 +2968,6 @@ export function AskUxCore({ lang }: { lang: Lang }) { }; const runQuery = async (query: string, replaceTurnId?: string) => { - markEngaged(); setLoading(true); const id = replaceTurnId ?? `${Date.now()}`; const newTurn: Turn = { @@ -3103,13 +3133,15 @@ export function AskUxCore({ lang }: { lang: Lang }) { if (e) e.preventDefault(); const query = text.trim(); if (!query || loading) return; + /* Manual typed input is the ONLY thing that enables the paid + organic greeting — and it refreshes the 30-min engagement clock. */ + markEngaged(); setText(''); await runQuery(query); }; const onCardClick = (citation: Citation) => { if (!citation.url) return; - markEngaged(); trackEvent('card_click', { url: citation.url, type: citation.type }); const tier: 'high' | 'mid' | 'low' = citation.nominated ? 'high' From 6eca85f5da609f51d4e84d7d8cf2b0c0c7912716 Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 2 Jun 2026 17:12:10 +0000 Subject: [PATCH 4/5] fix(widget): persist panel open-state per tab; gate greeting on open + bot filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remember the widget's open/closed panel in sessionStorage so it follows the visitor across page loads (incl. hard reloads into UX Core), instead of always booting closed. Switch the paid organic-greeting gate from "typed within 30 min" to simply "panel open" — opening the pill is a deliberate human gesture — and add a known-bot user-agent backstop on /api/concierge-landing so we never pay a crawler that reaches the route. Co-Authored-By: Claude Opus 4.7 --- src/pages/api/concierge-landing.ts | 21 +++++++++++ widget/src/AskUxCore.tsx | 60 +++++++++++++++--------------- 2 files changed, 51 insertions(+), 30 deletions(-) diff --git a/src/pages/api/concierge-landing.ts b/src/pages/api/concierge-landing.ts index 2a2db36..13ebcac 100644 --- a/src/pages/api/concierge-landing.ts +++ b/src/pages/api/concierge-landing.ts @@ -17,6 +17,19 @@ import { type LandingPayload = { text: string; suggestions: string[] }; +/* Human backstop: the organic greeting is a paid call, gated client-side + on the panel being open (a real click). This rejects known crawlers and + header-less scripted requests so we never pay a machine that reaches the + route anyway. Greeting is non-critical — a blocked caller just gets no + line, never an error. */ +const BOT_UA_RE = + /bot|crawl|spider|slurp|mediapartners|ahrefs|semrush|mj12|dotbot|bingpreview|facebookexternalhit|embedly|slackbot|telegrambot|whatsapp|headless|phantomjs|python-requests|curl\/|wget|go-http-client|scrapy|yandex(?:bot)?|baidu|duckduckbot/i; + +function isBotUserAgent(ua: string | undefined): boolean { + if (!ua || !ua.trim()) return true; + return BOT_UA_RE.test(ua); +} + async function callClaude( system: string, user: string, @@ -268,6 +281,14 @@ export default async function handler( return res.status(200).json({ text: '' }); } + const ua = + typeof req.headers['user-agent'] === 'string' + ? req.headers['user-agent'] + : undefined; + if (isBotUserAgent(ua)) { + return res.status(200).json({ text: '' }); + } + const { url, title, prevQuery, prevAnswer, lang, mode } = (req.body ?? {}) as { url?: string; diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index 23b1cda..52a894f 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -795,7 +795,7 @@ const PAGE_LANDINGS: Record> = { }; const CURATED_LANDING_FIRED_KEY = 'ks_aux_curated_landing_v1'; -const ENGAGED_KEY = 'ks_aux_engaged_v1'; +const OPEN_KEY = 'ks_aux_open_v1'; const GREETED_PAGES_KEY = 'ks_aux_greeted_pages_v1'; const curatedLandingPathKey = (rawUrl: string): string | null => { try { @@ -835,27 +835,23 @@ const markCuratedLandingFired = (key: string) => { } }; -/* Engagement — set ONLY when the visitor types a message themselves - (manual input). Clicking cards/suggestions/buttons does NOT count: - those are navigation, not a conversation. Gates the paid organic - greeting so we spend only on people who've actually talked to the - Copilot. Stored as a timestamp and treated as expired after 30 min - of no further typed input, so a long-idle tab starts neutral again. - Per-tab; clears on tab close. */ -const ENGAGED_TTL_MS = 30 * 60 * 1000; -const markEngaged = () => { +/* Widget open/closed — remembered per tab so the panel follows the + visitor across page loads (incl. hard reloads into UX Core / other + route groups). Opening the pill is a deliberate human gesture, so an + open panel is what gates the paid organic greeting. Clears on tab + close; a brand-new visit always starts closed. */ +const readOpenFlag = (): boolean => { try { - sessionStorage.setItem(ENGAGED_KEY, String(Date.now())); + return sessionStorage.getItem(OPEN_KEY) === '1'; } catch { - /* sessionStorage disabled — engagement not persisted across nav */ + return false; } }; -const hasEngaged = (): boolean => { +const writeOpenFlag = (isOpen: boolean) => { try { - const ts = Number(sessionStorage.getItem(ENGAGED_KEY) || '0'); - return ts > 0 && Date.now() - ts <= ENGAGED_TTL_MS; + sessionStorage.setItem(OPEN_KEY, isOpen ? '1' : '0'); } catch { - return false; + /* sessionStorage disabled — open state not remembered across nav */ } }; @@ -1814,14 +1810,19 @@ const applyHostHighlight = ( export function AskUxCore({ lang }: { lang: Lang }) { const initial = typeof window !== 'undefined' ? loadState() : null; - // Always boot closed. The widget should never reveal itself or its - // effects (host-page highlights, etc.) until the visitor explicitly - // opens the pill — even if the previous session ended with it open. - const [open, setOpen] = useState(false); + // Restore the open/closed panel per tab so it follows the visitor + // across page loads (incl. hard reloads into UX Core). A brand-new + // visit (fresh tab) has no flag and boots closed. + const [open, setOpen] = useState(() => + typeof window !== 'undefined' ? readOpenFlag() : false, + ); /* Fresh mirror of `open` for the once-mounted nav effect, whose - closure would otherwise capture the initial (always-false) value. */ + closure would otherwise capture the initial render's value. */ const openRef = useRef(open); openRef.current = open; + useEffect(() => { + writeOpenFlag(open); + }, [open]); const [text, setText] = useState(''); const [turns, setTurns] = useState(initial?.turns ?? []); const [loading, setLoading] = useState(false); @@ -2255,12 +2256,14 @@ export function AskUxCore({ lang }: { lang: Lang }) { } /* Cost gate: the organic greeting is a paid AI call. Spend it - only when the visitor is actually in the widget — panel open - AND they've typed a message themselves within the last 30 min. - Passers-by, button-only clickers and bots never type, so they - cost nothing. Then never pay twice for the same page this - session. Curated landings above are local (free) and ungated. */ - if (!openRef.current || !hasEngaged()) return; + only when the panel is open — opening the pill is a deliberate + human gesture, and the open panel now follows the visitor across + pages, so an open panel marks a real user. Passers-by and + crawlers never open it; the server greeting route also drops + known-bot user-agents as a backstop. Then never pay twice for + the same page this session. Curated landings above are local + (free) and ungated. */ + if (!openRef.current) return; const greetKey = canonicalPathKey(rawUrl); if (hasGreetedPage(greetKey)) return; markGreetedPage(greetKey); @@ -3133,9 +3136,6 @@ export function AskUxCore({ lang }: { lang: Lang }) { if (e) e.preventDefault(); const query = text.trim(); if (!query || loading) return; - /* Manual typed input is the ONLY thing that enables the paid - organic greeting — and it refreshes the 30-min engagement clock. */ - markEngaged(); setText(''); await runQuery(query); }; From 6399fa7b0b302601a93e3f9697c4492cfdb31a68 Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 2 Jun 2026 17:46:09 +0000 Subject: [PATCH 5/5] fix(widget): rate-limit + input caps on paid landing endpoint Address tech-lead review on /api/concierge-landing: - per-IP sliding-window rate limit (30/hr) as the real budget guard; the bot-UA filter is bypassable and only a cost hint now (comment fixed) - clamp title/prevQuery/prevAnswer before prompt injection to cap tokens Co-Authored-By: Claude Opus 4.7 --- src/pages/api/concierge-landing.ts | 66 ++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/src/pages/api/concierge-landing.ts b/src/pages/api/concierge-landing.ts index 13ebcac..79a1d94 100644 --- a/src/pages/api/concierge-landing.ts +++ b/src/pages/api/concierge-landing.ts @@ -17,11 +17,11 @@ import { type LandingPayload = { text: string; suggestions: string[] }; -/* Human backstop: the organic greeting is a paid call, gated client-side - on the panel being open (a real click). This rejects known crawlers and - header-less scripted requests so we never pay a machine that reaches the - route anyway. Greeting is non-critical — a blocked caller just gets no - line, never an error. */ +/* Cost hint ONLY — not an abuse control. This drops known crawlers and + header-less scripts so we don't pay for obvious machine traffic, but any + caller spoofing a browser UA bypasses it trivially. Real budget protection + is the per-IP rate limiter below. Greeting is non-critical — a dropped + caller just gets no line, never an error. */ const BOT_UA_RE = /bot|crawl|spider|slurp|mediapartners|ahrefs|semrush|mj12|dotbot|bingpreview|facebookexternalhit|embedly|slackbot|telegrambot|whatsapp|headless|phantomjs|python-requests|curl\/|wget|go-http-client|scrapy|yandex(?:bot)?|baidu|duckduckbot/i; @@ -30,6 +30,48 @@ function isBotUserAgent(ua: string | undefined): boolean { return BOT_UA_RE.test(ua); } +/* Per-IP sliding-window rate limit — the actual guard against budget + exhaustion on this paid endpoint. Prod is a long-running container, so the + map persists across requests; a restart just resets the windows. The UA + filter above is bypassable, this is not. */ +const RATE_LIMIT = 30; +const RATE_WINDOW_MS = 60 * 60 * 1000; +const RATE_SWEEP_AT = 5000; +const ipHits = new Map(); + +function clientIp(req: NextApiRequest): string { + const xff = req.headers['x-forwarded-for']; + const raw = Array.isArray(xff) ? xff[0] : xff; + if (raw && raw.trim()) return raw.split(',')[0].trim(); + return req.socket?.remoteAddress ?? 'unknown'; +} + +function isRateLimited(ip: string): boolean { + const now = Date.now(); + const cutoff = now - RATE_WINDOW_MS; + if (ipHits.size > RATE_SWEEP_AT) { + ipHits.forEach((v, k) => { + const fresh = v.filter(t => t > cutoff); + if (fresh.length === 0) ipHits.delete(k); + else ipHits.set(k, fresh); + }); + } + const hits = (ipHits.get(ip) ?? []).filter(t => t > cutoff); + if (hits.length >= RATE_LIMIT) { + ipHits.set(ip, hits); + return true; + } + hits.push(now); + ipHits.set(ip, hits); + return false; +} + +const MAX_TITLE_LEN = 300; +const MAX_PREV_LEN = 2000; +function clampLen(s: string | undefined, max: number): string { + return typeof s === 'string' ? s.slice(0, max) : ''; +} + async function callClaude( system: string, user: string, @@ -289,6 +331,10 @@ export default async function handler( return res.status(200).json({ text: '' }); } + if (isRateLimited(clientIp(req))) { + return res.status(429).json({ text: '' }); + } + const { url, title, prevQuery, prevAnswer, lang, mode } = (req.body ?? {}) as { url?: string; @@ -320,11 +366,15 @@ export default async function handler( ? 'Канонический блок страницы (источник истины)' : 'Canonical page block (source of truth)'; + const safeTitle = clampLen(title, MAX_TITLE_LEN); + const safePrevQuery = clampLen(prevQuery, MAX_PREV_LEN); + const safePrevAnswer = clampLen(prevAnswer, MAX_PREV_LEN); + const userMsg = [ `${identityHeader}:\n${identityBlock}`, - `Page title (raw, untrusted): ${title || '—'}`, - `User came from query: ${prevQuery || '—'}`, - `Prior bot answer: ${prevAnswer || '—'}`, + `Page title (raw, untrusted): ${safeTitle || '—'}`, + `User came from query: ${safePrevQuery || '—'}`, + `Prior bot answer: ${safePrevAnswer || '—'}`, ].join('\n'); let result = await callClaude(system, userMsg);