Imported from NurApps/NurChat (
AGENTS.md). Install upstream withnpx skills add NurApps/NurChat. Copyright stays with the author.
AGENTS.md
What This Is
NurChat — мессенджер на модели «глухой relay + E2E». Tauri v2 desktop app (React + Rust shell, FastAPI relay + SQLite). AGPL-3.0.
Пользователи НЕ запускают свой сервер: один публичный relay (FastAPI) обслуживает всех, идентичность — локальная пара ключей на устройстве. Приватные ключи устройство не покидают. P2P-транспорта в ядре НЕТ (удалён 2026-09 как мёртвый код — см. docs/E2E_AND_TRANSPORT.md, раздел 7).
Quick Start
# One-click (Windows), без аргументов — меню; режимы: dev | vite | relay | tunnel | build | checks
run.bat
# Manual:
# Terminal 1 — relay (ОБЯЗАТЕЛЬНО отдельно: `npx tauri dev` сервер НЕ поднимает)
run.bat relay
# Terminal 2 — Tauri (Vite + Rust собирает сам)
npx tauri dev
Без релея на :8000 (или удалённого через VITE_API_HOST) фронтенд показывает «Сервер недоступен».
Commands
| Action | Command |
|---|---|
| Start everything | run.bat |
| Relay only | run.bat relay |
| Relay + Cloudflare tunnel | run.bat tunnel |
| Relay via Docker | docker-compose up -d |
| Tauri dev | npx tauri dev |
| Frontend build | cd frontend && npm run build |
| Frontend dev only | cd frontend && npm run dev (port 5173) |
| Python tests | pytest test/ -v |
| Python lint | ruff check . |
| Python typecheck | mypy . |
| Frontend lint | cd frontend && npm run lint |
| Frontend tests | cd frontend && npx vitest run |
| Tauri build (installer) | npx tauri build |
Known Issues & Workarounds
- ENCRYPTION_KEY / JWT_SECRET_KEY / TOTP_MASTER_KEY not set. Автогенерятся при пустом
.env, но временные ключи = потеря данных / разлогин всех при рестарте. Для продакшена — стабильные значения в.env. - UnicodeEncodeError in Windows console. Fixed:
sys.stdout/stderr.reconfigure(errors='replace')вshared/config.py. - CORS origins. По умолчанию
localhost:5173, localhost:8000, tauri://localhost, https://tauri.localhost. Прод-домен — черезCORS_ORIGINS(comma-separated). Wildcard*нет даже в DEBUG. CSP собирается из того же whitelist — см.server/main.py: add_security_headers. - Server dies when terminal closes.
run.batдержит сервер черезstart /Bи ждёт/health. Остановка только своего релея: по порту:8000(внутриrun.bat). - Звонки за NAT не соединяются без TURN. По умолчанию только Google STUN. Прод: coturn (
infra/coturn.conf) +TURN_USERNAME/TURN_CREDENTIALв.env. Сервер пишет warning в лог, если TURN не настроен. PUBLIC_RELAYSпуст.frontend/src/config.ts— некуда резолвиться, клиенты default'ят на127.0.0.1:8000. Вписать свой relay при деплое.
Architecture
Tauri (Rust shell) ── wraps ──> React frontend ── HTTP/WS ──> FastAPI relay ──> SQLite
│ │
└── Tauri IPC (tray, файлы) ───┘
- Frontend: React 19 + Vite 8 + TypeScript + CSS modules
- Relay: FastAPI + SQLAlchemy + SQLite (
nurchat.db), глухой режимRELAY_DEAF=true - Desktop: Tauri v2 (Rust, WebView2 на Windows)
- Migrations: Alembic (fallback на
create_all) - Файлы: локальный
media/(случайные имена, EXIF счищается, TTL 30 дней). Байты — E2E-фреймNCF1приis_encrypted=true(fileE2E.ts, пропуск MIME-чеков вfiles.py:94), caption вложения шифруется E2E (handleSendAttachmentвuseChatActions.ts). View-once вложения качает только владелец (files.py:download_file) - Звонки: сигналинг через relay WS, медиа — WebRTC напрямую между устройствами (настоящий P2P, сервер медиа не касается)
Полная честная картина: docs/E2E_AND_TRANSPORT.md.
Non-Obvious Quirks (доказанные кодом)
- Token в query для медиа и WS.
<img>/<audio>/<video>и WebSocket не умеют Authorization-заголовки: файлы —?token=, сокеты —/ws/chat/{user_id}?token=. JWT сверяется сsub == user_id. Mitigations: толькоwss/httpsв проде, короткий TTL. - WS-эндпоинты (4 штуки):
/ws/chat/{user_id}— сообщения ({"event": ...}),/ws/calls/{user_id}и/ws/signaling/{user_id}— синонимы сигналинга ({"type": ...}),/ws/notifications/{user_id}— уведомления. Лимит 10 соединений/IP, 1 МБ/сообщение, ping при простое 120с, разрыв после 300с тишины. - Форматы событий разные — это нормально: chat-WS шлёт
{"event": ...}, signaling-WS —{"type": ...}. Не «унифицировать» без обновления обоих клиентов (useChatSocket.ts,CallPage.tsx). - Python imports — абсолютные от корня репо.
from shared.config import settings,from server.core.models import User. shared/config.py— без P2P-флагов.USE_P2P,P2P_*,USE_IPFS,USE_FEDERATED_BACKUPудалены 2026-09 (код их не читал). Живой флаг федерации —USE_FEDERATION. Внимание:SERVER_HOST/CLIENT_HOST/CLIENT_PORTвconfig.pyи.env.exampleживы — утверждение об их удалении было ошибкой.- CORS — whitelist, CSP — из него же. Даже в DEBUG нет
*. - Frontend env — только
VITE_префикс (shell/корневой.env, неfrontend/.env). Ключи:VITE_API_HOST,VITE_API_PROTOCOL.BASE_URL/WS_BASEзаморожены на старте модуля — смена релея требует перезагрузки. - Supabase/Firebase удалены полностью. Только локальное хранение.
- Tray icon. Close сворачивает в трей (
minimize_to_tray); выход — «Выйти» в меню трея. - E2E-ключ хранилища —
device_secret, не токен.deriveStorageKey()вe2e.ts: токен меняется при каждом логине, шифровать им сессии нельзя. Честное ограничение:device_secretлежит в IndexedDB открытым текстом (в браузере нет OS-keystore) — см. шапкуsecureStorage.ts. - X3DH — 3 DH, без OPK. В протоколе нет OPK id, клиент осознанно игнорирует
bundle.one_time_prekey(иначе первое сообщение не расшифровать). Сервер OPK при выдаче помечает использованным (безвредная трата, догрузка при <20). - Маршрутизация E2E: групповой чат ВСЕГДА шифруется групповым ключом (
groupE2E.ts), личка — 1-1 Double Ratchet (e2e.ts). Не менять порядок без понимания (был баг наоборот). - Сессии — в IndexedDB (
nurchat-secure), НЕ в localStorage. AES-256-GCM, PBKDF2 100k, автоочистка кэша через 10 мин неактивности. - Onboarding wizard. 4 шага, гасится
localStorage.onboarding_seen. - ErrorBoundary. Ловит ошибки рендера React, показывает страницу с кнопкой reload.
- P2P НЕ возвращать. TCP-нода (
src-tauri/src/p2p.rs), LAN discovery,nurchat://,USE_P2P, прототипp2pchat/— удалены как нерабочие. Остатки: таблицыp2p_*в миграции 001 (история),src-tauri/src/ipfs.rs(мёртвый импорт). Рабочий P2P остался только в WebRTC-медиа звонков. - Свои сообщения не расшифровывать. Double Ratchet: sending ≠ receiving, свои из истории нечитаемы криптографически.
decryptMessages/handleWsMessageсвои скипают, текст — изplaintextCache.ts. Чужие в кэш не писать. - Звонок:
call-joinавто-принимает.call_acceptпо chat WS гоняется с навигацией на CallPage и может потеряться —_handle_call_joinпринимает RINGING-звонок от callee сам (test/test_call_join_accept.py). Промах join/accept логируется (call-join for unknown call,call-accept rejected).CallPage:connectedRefсбрасывается в cleanup (StrictMode-remount),cleanup()гаситoncloseдоclose()(иначе ghost-reconnect).
Env Variables
Обязательные в .env:
ENCRYPTION_KEY=<stable hex key>
JWT_SECRET_KEY=<stable hex key>
TOTP_MASTER_KEY=<stable secret>
Прод связи/звонков:
CORS_ORIGINS=https://relay.example.com
TURN_USERNAME=nurchat
TURN_CREDENTIAL=<из infra/coturn.conf>
# или JSON целиком:
# WEBRTC_ICE_SERVERS=[{"urls":"turn:...","username":"...","credential":"..."}]
VITE_API_HOST=relay.example.com
VITE_API_PROTOCOL=https
Полный референс: .env.example и shared/config.py.
Auto-Update (Tauri Updater)
tauri-plugin-updater+tauri-plugin-process(Rust) и@tauri-apps/plugin-updater+@tauri-apps/plugin-process(frontend).UpdateBanner.tsxопрашивает GitHub releases черезlatest.json(публикуетtauri-action@v0в.github/workflows/release.yml), задержка 10с после старта.- Публичный ключ в
src-tauri/tauri.conf.json→plugins.updater.pubkey; приватный —update_key_private.key(gitignored). Секреты CI:TAURI_SIGNING_PRIVATE_KEY(+ пустойTAURI_SIGNING_PRIVATE_KEY_PASSWORD).tauri buildбез ключа падает — ожидаемо;tauri devключ не нужен.
Testing
- Python:
pytest test/ -v(директорияtest/, singular). Ключевые:test_double_ratchet.py,test_crypto.py,test_security.py. - Frontend:
cd frontend && npx vitest run(frontend/src/test/api.test.ts). - После правок транспорта/E2E: обязательно
ruff check .+mypy .+cd frontend && npm run lint.
Encryption Architecture (кратко; полно — в docs/)
X3DH (3 DH, подписи Ed25519 обязательны) + Double Ratchet. Лички: e2e.ts + doubleRatchet.ts (+ Python-зеркало shared/double_ratchet.py для тестов). Группы: groupE2E.ts (симметричный ключ, завёрнут per-user через ECDH; сервер хранит только завёрнутые копии в Chat.group_key). PreKey API: server/routes/keys.py. Relay принимает только encrypted_content (content="[encrypted]"), иначе 400/отброс.
Key Files
Relay entry: server/main.py — app, CORS/CSP, rate limits, WS-эндпоинты, lifespan
Config: shared/config.py — Pydantic Settings, читает корневой .env
Models: server/core/models.py — все SQLAlchemy-модели (включая SignedPreKey, OneTimePreKey, CallLog, PushSubscription)
Auth: server/routes/auth.py — register/login + captcha, 2FA TOTP, ротация E2E-ключей
Chat: server/routes/chat.py — CRUD, RELAY_DEAF-принуждение, group-key API
Keys: server/routes/keys.py — SPK/OPK/bundle/cleanup
Files: server/routes/files.py — upload/download (?token=), открытое хранение
Calls REST: server/routes/calls.py — история, ICE-серверы
WS chat: server/ws/chat_manager.py — соединения, доставка, presence
WS calls: server/ws/signaling.py — WebRTC-сигналинг, pending-буфер
WS push: server/ws/notifications.py + server/routes/push.py — VAPID Web Push
Docs: docs/E2E_AND_TRANSPORT.md — честная документация (читать первой)
Frontend entry: frontend/src/App.tsx
API client: frontend/src/services/api.ts
Relay config: frontend/src/config.ts — резолвинг релея, BASE_URL/WS_BASE
E2E: frontend/src/services/e2e.ts, doubleRatchet.ts, groupE2E.ts, secureStorage.ts, cryptoAdapter.ts
E2E-кэш своих: frontend/src/services/plaintextCache.ts — свои сообщения из истории расшифровать НЕЛЬЗЯ (DR), текст берётся из локального кэша, пишется при отправке
WS client: frontend/src/hooks/useChatSocket.ts (чат), frontend/src/pages/CallPage.tsx (звонки)
Отправка/история: frontend/src/hooks/useChatActions.ts, useChatMessages.ts
Main page: frontend/src/pages/ChatPage.tsx
Tauri: src-tauri/tauri.conf.json, src-tauri/src/lib.rs (без mod p2p)
Conventions
- Russian language in UI and commit messages
- Все API-ответы — JSON (Pydantic)
- Chat WS:
{"event": "event_name", "data": {...}}; signaling WS:{"type": "...", ...} - Префиксы ID:
file_,msg_,user_ content="[encrypted]"в БД при E2E; конверт — вencrypted_content- Реакции — серверные (
MessageReaction), не эфемерные - Не вводить новые системы шифрования/транспорта рядом с ядром — чинить ядро