From ac09f725f6b11ec50abbf83a4f6b705f644ccee6 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 11 May 2026 23:04:44 +0400 Subject: [PATCH 01/22] hotfix: remove unauthenticated test-login endpoint --- src/pages/api/test-login.ts | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 src/pages/api/test-login.ts diff --git a/src/pages/api/test-login.ts b/src/pages/api/test-login.ts deleted file mode 100644 index dcb8856b..00000000 --- a/src/pages/api/test-login.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next'; -import { encode } from 'next-auth/jwt'; - -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { - const mockUser = { - username: 'Test User', - email: 'test@example.com', - sub: 'test-user-id', - }; - - const token = await encode({ - secret: process.env.NEXTAUTH_SECRET!, - token: mockUser, - }); - - res.status(200).json({ token }); -} From 6cdea2feef7437c6ff6a4951dc9dc9a589afc623 Mon Sep 17 00:00:00 2001 From: manager Date: Fri, 29 May 2026 11:31:30 +0000 Subject: [PATCH 02/22] chore(csp): allow GA4/Ads/Mixpanel regional collection hosts in connect-src MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production CSP shipped with the 2026-05-26 deploy allow-listed only the primary GA/Mixpanel hostnames, but GA4 actually POSTs to region-prefixed shards (region1.analytics.google.com, ...), Google Ads conversion uses stats.g.doubleclick.net, and Mixpanel's JS SDK uses api-js.mixpanel.com. Every analytics beacon was silently CSP-blocked at the browser — net effect: GA and Mixpanel dark in prod since the deploy. Same fix already hot-patched in the running container; this commit persists it across rebuilds. Co-Authored-By: Claude Opus 4.7 --- next.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/next.config.js b/next.config.js index dffe462b..bbb02edd 100644 --- a/next.config.js +++ b/next.config.js @@ -43,7 +43,10 @@ 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', ] .filter(Boolean) .join(' '); From 1271a620c7143d4aa6a1bdb89c466d216645b9af Mon Sep 17 00:00:00 2001 From: manager Date: Sun, 31 May 2026 07:08:41 +0000 Subject: [PATCH 03/22] fix(csp): allow www.google.com for Google Ads conversion endpoint Adds https://www.google.com to connect-src and img-src so the Google Ads conversion beacon (www.google.com/ccm/collect) is not blocked by the site's own CSP. Verified live on prod via QA capture. Co-Authored-By: Claude Opus 4.7 --- next.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index bbb02edd..6a451e48 100644 --- a/next.config.js +++ b/next.config.js @@ -47,6 +47,8 @@ module.exports = withBundleAnalyzer({ '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(' '); @@ -72,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 dbc949793713fb659a40954bb22cb4359263d38f Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Tue, 2 Jun 2026 12:56:59 +0400 Subject: [PATCH 04/22] hotfix: toggle gap --- src/components/ThemeToggle/ThemeToggle.module.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ThemeToggle/ThemeToggle.module.scss b/src/components/ThemeToggle/ThemeToggle.module.scss index b43db9cd..4448bb92 100644 --- a/src/components/ThemeToggle/ThemeToggle.module.scss +++ b/src/components/ThemeToggle/ThemeToggle.module.scss @@ -11,6 +11,7 @@ cursor: pointer; flex-shrink: 0; transition: opacity 0.15s ease; + margin-right: 16px; &:hover { opacity: 0.75; From 6399fa7b0b302601a93e3f9697c4492cfdb31a68 Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 2 Jun 2026 17:46:09 +0000 Subject: [PATCH 05/22] 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 13ebcace..79a1d94c 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); From dfc45c1714066fe0a35e7a7d106c1d71e5ee227d Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Tue, 9 Jun 2026 14:30:25 +0400 Subject: [PATCH 06/22] hotfix: uxcore tabs --- .../UXCoreModal/UXCoreModal.module.scss | 9 +++- .../components/UXCoreModal/UXCoreModal.tsx | 43 ++++++++++++------- src/uxcore/hooks/useUXCoreGlobals.ts | 19 +++++++- .../layouts/UXCoreLayout/UXCoreLayout.tsx | 27 ++++++------ src/uxcore/lib/offsec.ts | 5 +++ 5 files changed, 73 insertions(+), 30 deletions(-) create mode 100644 src/uxcore/lib/offsec.ts diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss index a78a1d7a..ceb8fa94 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss @@ -124,6 +124,13 @@ background-color: #fff; isolation: isolate; + // Two-item variant (OffSec hidden): the sliding indicator spans + // half the track instead of a third so there's no empty phantom + // slot where the Cybersecurity tab used to sit. + &.twoCol::before { + width: calc(50% + 1px); + } + &::before { content: ''; position: absolute; @@ -159,7 +166,7 @@ .switcherItem { position: relative; z-index: 1; - width: 33.3333%; + flex: 1 1 0; text-align: center; height: 100%; padding: 8px 0; diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx index afaa7903..0f8a20d3 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx @@ -12,6 +12,7 @@ import { getOffsecBiasContent } from '@uxcore/data/biasOffsec'; import modalIntl from '@uxcore/data/modal'; import useUXCoreGlobals from '@uxcore/hooks/useUXCoreGlobals'; import { copyToClipboard, generateSocialLinks } from '@uxcore/lib/helpers'; +import { isOffsecEnabled } from '@uxcore/lib/offsec'; import type { QuestionType, TagType } from '@uxcore/local-types/data'; import type { TRouter } from '@uxcore/local-types/global'; import cn from 'classnames'; @@ -214,7 +215,11 @@ const UXCoreModal: FC = ({ {usage}
-
+
= ({ {hrText}
-
- {isOffsecView ? : } - {offsecText} -
+ {isOffsecEnabled && ( +
+ {isOffsecView ? : } + {offsecText} +
+ )}
- {isOffsecView ? ( + {isOffsecView && isOffsecEnabled ? ( (() => { const offsecContent = getOffsecBiasContent(biasNumber); return offsecContent ? ( @@ -272,7 +285,7 @@ const UXCoreModal: FC = ({ )}
- {!isOffsecView && data.title && ( + {(!isOffsecView || !isOffsecEnabled) && data.title && ( )} {questions.length > 0 && ( diff --git a/src/uxcore/hooks/useUXCoreGlobals.ts b/src/uxcore/hooks/useUXCoreGlobals.ts index 528bf75c..6d910d57 100644 --- a/src/uxcore/hooks/useUXCoreGlobals.ts +++ b/src/uxcore/hooks/useUXCoreGlobals.ts @@ -1,3 +1,4 @@ +import { isOffsecEnabled } from '@uxcore/lib/offsec'; import { CustomHookType, DispatchFuntion } from '@uxcore/local-types/global'; import { useEffect, useState } from 'react'; @@ -51,6 +52,15 @@ const toggleIsProductView = () => { } }; const toggleIsOffsecView = () => { + // Hard gate: OffSec cannot be entered in production, regardless of how the + // toggle is reached (hash, localStorage, or a stray UI handler). + if (!isOffsecEnabled) { + if (state.isOffsecView) { + localStorage.setItem('isOffsecView', 'false'); + reducer({ isOffsecView: false }); + } + return; + } localStorage.setItem('isOffsecView', String(!state.isOffsecView)); reducer({ isOffsecView: !state.isOffsecView }); }; @@ -61,7 +71,11 @@ const toggleIsOffsecView = () => { // Cybersecurity and return to the canonical pair. const setUseCase = (target: 'product' | 'hr' | 'offsec') => { let resolved: 'product' | 'hr' | 'offsec' = target; - if (target === 'offsec' && state.isOffsecView) { + // In production OffSec is unavailable — coerce any request for it back to + // the last PM/HR view so it can never become the active use case. + if (target === 'offsec' && !isOffsecEnabled) { + resolved = state.lastBaseUseCase || 'hr'; + } else if (target === 'offsec' && state.isOffsecView) { resolved = state.lastBaseUseCase || 'hr'; } const next: Partial = { @@ -86,7 +100,8 @@ const initUseUXCoreGlobals = () => { const changeState = (localStorage.getItem('isCoreView') || true) === 'false'; const changeStateView = (localStorage.getItem('isProductView') || true) === 'false'; - const changeStateOffsec = localStorage.getItem('isOffsecView') === 'true'; + const changeStateOffsec = + isOffsecEnabled && localStorage.getItem('isOffsecView') === 'true'; const changeStateArrows = (localStorage.getItem('showArrows') || true) === 'false'; const storedBase = localStorage.getItem('lastBaseUseCase'); diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx index 0dff2dc2..2d4b5e82 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx @@ -13,6 +13,7 @@ import biasesLocalization from '@uxcore/data/biases'; import biasesMobile from '@uxcore/data/biasesMobile'; import useUXCoreGlobals from '@uxcore/hooks/useUXCoreGlobals'; import useUCoreMobile from '@uxcore/hooks/uxcoreMobile'; +import { isOffsecEnabled } from '@uxcore/lib/offsec'; import type { TRouter } from '@uxcore/local-types/global'; import cn from 'classnames'; import dynamic from 'next/dynamic'; @@ -95,7 +96,7 @@ const UXCoreLayout: FC = ({ if (hash === '#hr' && isProductView) { toggleIsProductView(); } - if (hash === '#offsec' && !isOffsecView) { + if (hash === '#offsec' && !isOffsecView && isOffsecEnabled) { toggleIsOffsecView(); } }, [mounted]); @@ -214,17 +215,19 @@ const UXCoreLayout: FC = ({ )} HR
-
handleUseCaseClick('offsec')} - className={cn(styles.useCaseRow, { - [styles.active]: isOffsecView, - })} - > - {isOffsecView ? : } - Cybersecurity - OffSec -
+ {isOffsecEnabled && ( +
handleUseCaseClick('offsec')} + className={cn(styles.useCaseRow, { + [styles.active]: isOffsecView, + })} + > + {isOffsecView ? : } + Cybersecurity + OffSec +
+ )} {isCoreView && } {isCoreView && ( diff --git a/src/uxcore/lib/offsec.ts b/src/uxcore/lib/offsec.ts new file mode 100644 index 00000000..56fae80c --- /dev/null +++ b/src/uxcore/lib/offsec.ts @@ -0,0 +1,5 @@ +// Single source of truth for whether the Offensive Cybersecurity (OffSec) +// use-case is exposed. It is hidden in production: the `#offsec` deep link, +// the localStorage flag, and the switcher rows must all be inert when +// NEXT_PUBLIC_ENV === 'prod' so users cannot open it by any path. +export const isOffsecEnabled = process.env.NEXT_PUBLIC_ENV !== 'prod'; From 9782b11d5ff9dade84282db58e473c8399ca0343 Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 9 Jun 2026 10:26:10 +0000 Subject: [PATCH 07/22] fix(uxcore): restore New Update Modal wiring dropped in May-14 fold-in The UXCoreOSS fold-in (4691c08) copied the NewUpdateModal component and its getNewUpdate fetch but never carried over the container that mounts it, so the Strapi-driven news modal has been dead on prod since May 14. Add a container that fetches the new-update single-type, gates on the "Frontend modal visibility" flag, honours "Appears after x seconds", and mounts it on UX Core routes. Also fix the empty index.ts export and the inaccurate props type. Co-Authored-By: Claude Opus 4.7 --- src/pages/_app.tsx | 2 + .../NewUpdateModal/NewUpdateModal.types.ts | 28 ++++++----- .../NewUpdateModalContainer.tsx | 50 +++++++++++++++++++ src/uxcore/components/NewUpdateModal/index.ts | 2 + 4 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 src/uxcore/components/NewUpdateModal/NewUpdateModalContainer.tsx diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 4a418761..070de934 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,5 +1,6 @@ import { getOurProjects } from '@uxcore/api/our-projects'; import { GlobalContext as UXCoreGlobalContext } from '@uxcore/components/Context/GlobalContext'; +import { NewUpdateModalContainer } from '@uxcore/components/NewUpdateModal'; import UXCoreLayoutShell from '@uxcore/layouts/Layout'; import { useRouter } from 'next/router'; import Script from 'next/script'; @@ -375,6 +376,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { + ) : ( diff --git a/src/uxcore/components/NewUpdateModal/NewUpdateModal.types.ts b/src/uxcore/components/NewUpdateModal/NewUpdateModal.types.ts index 2accc20e..43b73f88 100644 --- a/src/uxcore/components/NewUpdateModal/NewUpdateModal.types.ts +++ b/src/uxcore/components/NewUpdateModal/NewUpdateModal.types.ts @@ -1,16 +1,20 @@ -export interface NewUpdateModalProps { - data: { - title: string; - image: { - data: { - attributes: { - url: string; - }; +export interface NewUpdateData { + title: string; + description: string; + image?: { + data?: { + attributes?: { + url: string; }; }; - socialMediaLink: string; - description: string; - buttonText: string; }; - onClose: any; + 'Social media link'?: string; + 'Close button text'?: string; + 'Frontend modal visibility'?: boolean; + 'Appears after x seconds'?: number; +} + +export interface NewUpdateModalProps { + data: NewUpdateData; + onClose: () => void; } diff --git a/src/uxcore/components/NewUpdateModal/NewUpdateModalContainer.tsx b/src/uxcore/components/NewUpdateModal/NewUpdateModalContainer.tsx new file mode 100644 index 00000000..fa2d68a7 --- /dev/null +++ b/src/uxcore/components/NewUpdateModal/NewUpdateModalContainer.tsx @@ -0,0 +1,50 @@ +import { getNewUpdate } from '@uxcore/api/new-updates'; +import { useRouter } from 'next/router'; +import { FC, useEffect, useState } from 'react'; + +import NewUpdateModal from './NewUpdateModal'; +import type { NewUpdateData } from './NewUpdateModal.types'; + +const NewUpdateModalContainer: FC = () => { + const router = useRouter(); + const [data, setData] = useState(null); + const [open, setOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType; + + (async () => { + try { + const res: NewUpdateData | null = await getNewUpdate( + router.locale || 'en', + ); + + if (cancelled || !res || !res['Frontend modal visibility']) return; + + setData(res); + + const delaySeconds = Number(res['Appears after x seconds']) || 0; + timer = setTimeout( + () => { + if (!cancelled) setOpen(true); + }, + Math.max(0, delaySeconds) * 1000, + ); + } catch (err) { + console.warn('[new-update] fetch failed:', err); + } + })(); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [router.locale]); + + if (!open || !data) return null; + + return setOpen(false)} />; +}; + +export default NewUpdateModalContainer; diff --git a/src/uxcore/components/NewUpdateModal/index.ts b/src/uxcore/components/NewUpdateModal/index.ts index e69de29b..3e6d176f 100644 --- a/src/uxcore/components/NewUpdateModal/index.ts +++ b/src/uxcore/components/NewUpdateModal/index.ts @@ -0,0 +1,2 @@ +export { default } from './NewUpdateModal'; +export { default as NewUpdateModalContainer } from './NewUpdateModalContainer'; From 228cda22ac0e3b7a8da453a0c9c5f823f9c7daf6 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Tue, 9 Jun 2026 19:43:08 +0400 Subject: [PATCH 08/22] style: fix UI of New Update modal --- .../NewUpdateModal/NewUpdateModal.module.scss | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/uxcore/components/NewUpdateModal/NewUpdateModal.module.scss b/src/uxcore/components/NewUpdateModal/NewUpdateModal.module.scss index 89ba8f46..86995b8a 100644 --- a/src/uxcore/components/NewUpdateModal/NewUpdateModal.module.scss +++ b/src/uxcore/components/NewUpdateModal/NewUpdateModal.module.scss @@ -11,6 +11,25 @@ .content { text-align: left; padding: 8px 30px 0 30px; + + a { + color: #4d4d4d; + text-decoration: underline; + + &:hover { + color: #262626; + } + } +} + +:global(body.darkTheme) .content { + a { + color: #dadada; + + &:hover { + color: #ffffff; + } + } } .instaIcon { From 9fbcb1cc73ea89d3328ac0422aef9131aaba9ab0 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 10 Jun 2026 17:37:10 +0000 Subject: [PATCH 09/22] feat(ai-atlas): add Reception node outside rings with public internet globe Co-Authored-By: Claude Opus 4.7 --- public/ai-atlas/data-ru.json | 25 +++++++++++ public/ai-atlas/data.json | 25 +++++++++++ src/pages/ai-atlas.tsx | 84 ++++++++++++++++++++++++++++++++++++ src/styles/ai-atlas.css | 16 +++++++ 4 files changed, 150 insertions(+) diff --git a/public/ai-atlas/data-ru.json b/public/ai-atlas/data-ru.json index ec7c9392..2d15f42a 100644 --- a/public/ai-atlas/data-ru.json +++ b/public/ai-atlas/data-ru.json @@ -34,6 +34,18 @@ } }, + "reception": { + "r": 1.13, + "globeR": 1.33, + "member": { + "id": "reception", + "label": "Ресепшн", + "diamond": "blue", + "theta": -45, + "status": "ok" + } + }, + "devEnv": { "r": 0.4, "members": [ @@ -291,6 +303,19 @@ ] }, + "reception": { + "title": "РЕСЕПШН", + "cjk": "受付", + "desc": "Стойка приёма. Живёт вне всех колец, смотрит в публичный интернет и подчиняется Wolf напрямую — публичные сущности стучатся сюда первыми.", + "rows": [ + { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, + { "k": "роль", "v": "публичная стойка приёма" }, + { "k": "смотрит в", "v": "публичный интернет" }, + { "k": "подчиняется", "v": "Wolf", "cls": "gold", "ref": "wolf" }, + { "k": "кольцо", "v": "вне всех колец" } + ] + }, + "tools": { "title": "ИНСТРУМЕНТЫ И ТВИКИ", "cjk": "工具", diff --git a/public/ai-atlas/data.json b/public/ai-atlas/data.json index 0ad20146..f94d21d1 100644 --- a/public/ai-atlas/data.json +++ b/public/ai-atlas/data.json @@ -34,6 +34,18 @@ } }, + "reception": { + "r": 1.13, + "globeR": 1.33, + "member": { + "id": "reception", + "label": "Reception", + "diamond": "blue", + "theta": -45, + "status": "ok" + } + }, + "devEnv": { "r": 0.4, "members": [ @@ -291,6 +303,19 @@ ] }, + "reception": { + "title": "RECEPTION", + "cjk": "受付", + "desc": "The front desk. Lives outside every ring, faces the public internet, and answers to Wolf directly — public entities knock here first.", + "rows": [ + { "k": "kind", "v": "ai agent", "cls": "blue" }, + { "k": "role", "v": "public front desk" }, + { "k": "faces", "v": "public internet" }, + { "k": "reports", "v": "wolf", "cls": "gold" }, + { "k": "ring", "v": "outside all rings" } + ] + }, + "tools": { "title": "TOOLS AND TWEAKS", "cjk": "工具", diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index 33fb8a8a..8e12f059 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -77,6 +77,7 @@ const STRINGS = { apexFounderFallback: 'founder', redactedPlaceholder: 'REDACTED', engLeadLabel: 'Eng. Lead', + publicInternetLabel: 'PUBLIC INTERNET', claudeMdLabel: 'claude.md', linesValue: (n: number) => `${n.toLocaleString()} lines`, canvasStats: { @@ -287,6 +288,7 @@ const STRINGS = { apexFounderFallback: 'основатель', redactedPlaceholder: 'СКРЫТО', engLeadLabel: 'Тех. Лид', + publicInternetLabel: 'ПУБЛИЧНЫЙ ИНТЕРНЕТ', claudeMdLabel: 'claude.md', linesValue: (n: number) => { const m10 = n % 10; @@ -763,6 +765,32 @@ function Spoke({ from, to, kind = 'auth', dim, glow }: any) { ); } +/* ---------- public internet globe ---------- */ + +function GlobeMark({ x, y, label, dim }: any) { + const r = 15; + const lat = r * 0.5; + const latW = r * 0.866; + return ( + + ); +} + function TerritoryArc({ project, R }: any) { if (!project.territoryArc) return null; const half = project.territoryArc / 2; @@ -1003,6 +1031,8 @@ function tallyDiamonds(data: any) { }; if (data.apex) tally(data.apex.diamond); if (data.order && data.order.member) tally(data.order.member.diamond); + if (data.reception && data.reception.member) + tally(data.reception.member.diamond); ((data.devEnv && data.devEnv.members) || []).forEach((n: any) => tally(n.diamond), ); @@ -1613,6 +1643,14 @@ function AiAtlasApp() { const p = POL(data.order.r, n.theta); m[n.id] = { ...p, ring: 'order', node: n }; } + if (data.reception) { + const n = data.reception.member; + const p = POL(data.reception.r, n.theta); + m[n.id] = { ...p, ring: 'outside', node: n }; + // Globe sits further out on the same radial, so globe → reception → + // wolf reads as a single straight line from the public internet inward. + m['globe'] = { ...POL(data.reception.globeR, n.theta), ring: 'outside' }; + } data.devEnv.members.forEach((n: any) => { const p = POL(data.devEnv.r, n.theta); m[n.id] = { ...p, ring: 'dev', node: n }; @@ -1800,6 +1838,10 @@ function AiAtlasApp() { set.add('order'); set.add('terminal'); set.add('lead-terminal'); + if (data.reception) set.add('reception'); + } + if (highlightId === 'reception') { + set.add('wolf'); } if (highlightId === 'order') { set.add('wolf'); @@ -1936,6 +1978,25 @@ function AiAtlasApp() { glow={spokeGlow('wolf', 'order')} /> + {data.reception && ( + <> + + + + )} + {data.devEnv.members.map((n: any) => ( + {data.reception && ( + <> + + + + )} + {data.devEnv.members.map((n: any) => ( Date: Wed, 10 Jun 2026 17:39:26 +0000 Subject: [PATCH 10/22] feat(ai-atlas): rename Reception to Receptionist Co-Authored-By: Claude Opus 4.7 --- public/ai-atlas/data-ru.json | 4 ++-- public/ai-atlas/data.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/ai-atlas/data-ru.json b/public/ai-atlas/data-ru.json index 2d15f42a..a65a7d2b 100644 --- a/public/ai-atlas/data-ru.json +++ b/public/ai-atlas/data-ru.json @@ -39,7 +39,7 @@ "globeR": 1.33, "member": { "id": "reception", - "label": "Ресепшн", + "label": "Ресепшионист", "diamond": "blue", "theta": -45, "status": "ok" @@ -304,7 +304,7 @@ }, "reception": { - "title": "РЕСЕПШН", + "title": "РЕСЕПШИОНИСТ", "cjk": "受付", "desc": "Стойка приёма. Живёт вне всех колец, смотрит в публичный интернет и подчиняется Wolf напрямую — публичные сущности стучатся сюда первыми.", "rows": [ diff --git a/public/ai-atlas/data.json b/public/ai-atlas/data.json index f94d21d1..cb83ca92 100644 --- a/public/ai-atlas/data.json +++ b/public/ai-atlas/data.json @@ -39,7 +39,7 @@ "globeR": 1.33, "member": { "id": "reception", - "label": "Reception", + "label": "Receptionist", "diamond": "blue", "theta": -45, "status": "ok" @@ -304,7 +304,7 @@ }, "reception": { - "title": "RECEPTION", + "title": "RECEPTIONIST", "cjk": "受付", "desc": "The front desk. Lives outside every ring, faces the public internet, and answers to Wolf directly — public entities knock here first.", "rows": [ From af3dec143648f6542151336a65e22aa394035655 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 10 Jun 2026 17:43:15 +0000 Subject: [PATCH 11/22] feat(ai-atlas): telegram relay on receptionist wire, larger globe and label Co-Authored-By: Claude Opus 4.7 --- public/ai-atlas/data-ru.json | 2 +- public/ai-atlas/data.json | 2 +- src/pages/ai-atlas.tsx | 28 ++++++++++++++++++++++++++-- src/styles/ai-atlas.css | 2 +- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/public/ai-atlas/data-ru.json b/public/ai-atlas/data-ru.json index a65a7d2b..f4d9a6d0 100644 --- a/public/ai-atlas/data-ru.json +++ b/public/ai-atlas/data-ru.json @@ -35,7 +35,7 @@ }, "reception": { - "r": 1.13, + "r": 1.08, "globeR": 1.33, "member": { "id": "reception", diff --git a/public/ai-atlas/data.json b/public/ai-atlas/data.json index cb83ca92..011d5767 100644 --- a/public/ai-atlas/data.json +++ b/public/ai-atlas/data.json @@ -35,7 +35,7 @@ }, "reception": { - "r": 1.13, + "r": 1.08, "globeR": 1.33, "member": { "id": "reception", diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index 8e12f059..1117eb83 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -768,7 +768,7 @@ function Spoke({ from, to, kind = 'auth', dim, glow }: any) { /* ---------- public internet globe ---------- */ function GlobeMark({ x, y, label, dim }: any) { - const r = 15; + const r = 21; const lat = r * 0.5; const latW = r * 0.866; return ( @@ -779,7 +779,7 @@ function GlobeMark({ x, y, label, dim }: any) {