diff --git a/README.md b/README.md index d9be5ff..136ecc8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - Celery-воркер с каркасной задачей `synapse.ping` - Alembic-миграции: схема v1 (#34) — `docs/04-database.md` - Vue 3 SPA: вход (dev-заглушка), дашборд, экран «доступ запрещён» для роли ниже admin +- i18n SPA: ru база + en + uk по конвенции хендбука (`frontend/src/i18n/`), язык — из профиля gnexus-auth, override в Настройках - docker-compose: api + worker + postgres + redis, миграции применяются при старте ## Quickstart diff --git a/alembic/versions/20261004_0009_user_prefs.py b/alembic/versions/20261004_0009_user_prefs.py new file mode 100644 index 0000000..b1b69b0 --- /dev/null +++ b/alembic/versions/20261004_0009_user_prefs.py @@ -0,0 +1,37 @@ +"""user_prefs: локаль-override и флаг блокировки пользователя. + +Таблица минимальной записи пользователя (sub gnexus-auth — PK): +- locale — override языка интерфейса (i18n.md, конвенция хендбука); +- blocked — флаг из вебхуков gnexus-auth (user.blocked/user.unblocked/ + user.deleted/user.archived/user.restored), гейтит MCP-токены владельца. + +Revision ID: a4f8d2c17b90 +Revises: c9a2d6e71f43 +Create Date: 2026-10-04 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "a4f8d2c17b90" +down_revision = "c9a2d6e71f43" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "user_prefs", + sa.Column("user_id", sa.String(64), primary_key=True), + sa.Column("locale", sa.String(8), nullable=True), + sa.Column("blocked", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("blocked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "updated_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_table("user_prefs") \ No newline at end of file diff --git a/app/api/admin_routes.py b/app/api/admin_routes.py index bd3d1d3..2eae8fe 100644 --- a/app/api/admin_routes.py +++ b/app/api/admin_routes.py @@ -8,7 +8,7 @@ import uuid from datetime import UTC, datetime -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Request, Depends, HTTPException, Query, status from pydantic import ValidationError from sqlalchemy import delete, func, select from sqlalchemy.orm import Session @@ -50,6 +50,7 @@ TypeIn, TypeOut, ) +from app.i18n_server import locale_from_request, localize_settings from app.settings_registry import EDITABLE, is_secret, validate as validate_setting from app.settings_store import default_of, get_setting_row, set_setting @@ -725,12 +726,16 @@ @router.get("/settings", response_model=list[SettingOut]) def list_settings( + request: Request, user: AuthenticatedUser = Depends(require_admin), db: Session = Depends(get_db) ): - """Реестр редактируемых настроек: оверрайд из БД, дефолт из .env, маска секретов.""" + """Реестр редактируемых настроек: оверрайд из БД, дефолт из .env, маска секретов. + + label/help — по Accept-Language (i18n.md); ru-текст — исходник реестра. + """ out: list[SettingOut] = [] env_settings = get_settings() - for defn in EDITABLE.values(): + for defn in localize_settings(EDITABLE.values(), locale_from_request(request)): raw = get_setting_row(db, defn.key) default = getattr(env_settings, defn.key, None) out.append( diff --git a/app/api/auth_routes.py b/app/api/auth_routes.py index 2c5059c..95d8ddb 100644 --- a/app/api/auth_routes.py +++ b/app/api/auth_routes.py @@ -14,7 +14,13 @@ from pydantic import BaseModel from app.auth.client import GAUTH_SCOPES, get_gauth_client -from app.auth.stores import revoke_logins_for_user, store_login +from app.auth.stores import ( + BLOCK_EVENTS, + UNBLOCK_EVENTS, + revoke_logins_for_user, + set_user_blocked, + store_login, +) from app.config import Settings, get_settings logger = logging.getLogger("synapse.auth") @@ -139,6 +145,12 @@ OAuth-токены, поэтому выход из auth должен долететь до нас через auth.logout / auth.global_logout / session.revoked — по user_id мы отзываем все выданные этому пользователю access+refresh. + + user.blocked/user.deleted/user.archived ставят флаг блокировки в + user_prefs — это гейтит и MCP-токены пользователя (mcp.md, docs/07); + user.unblocked/user.restored снимают. SSO-токены эта ветка не трогает + (logout ≠ блокировка; заблокированный пользователь и так не пройдёт + call-home userinfo). """ secret = get_settings().gauth_webhook_secret if not secret: @@ -162,6 +174,20 @@ ) else: logger.warning("logout-событие %s без user.id — нечем отзвать токены", event.event_type) + elif event.event_type in BLOCK_EVENTS or event.event_type in UNBLOCK_EVENTS: + # data.user = {id, status} (user.blocked, WebhookPayloadFactory); target.user_id — у остальных. + data = event.metadata or {} + user = data.get("user") or {} + target_user = user.get("id") or event.target_identifiers.get("user_id") + if target_user is None: + logger.warning("событие %s без user.id — флаг блокировки не применён", event.event_type) + else: + blocked = True + if event.event_type in UNBLOCK_EVENTS or ( + event.event_type == "user.blocked" and user.get("status") != "blocked" + ): + blocked = False + set_user_blocked(target_user, blocked, logger=logger) else: logger.info("gauth webhook: %s id=%s target=%s", event.event_type, event.event_id, event.target_identifiers) diff --git a/app/api/me_routes.py b/app/api/me_routes.py index a9c63d2..86844ef 100644 --- a/app/api/me_routes.py +++ b/app/api/me_routes.py @@ -21,7 +21,7 @@ from app.config import get_settings from app.database import get_db from app.api.routes import AdminMe -from app.models import Delivery, Event, McpToken, PushSubscription, Source +from app.models import Delivery, Event, McpToken, PushSubscription, Source, UserPrefs from app.settings_store import get_setting_row router = APIRouter(prefix="/api/v1", tags=["me"]) @@ -63,9 +63,33 @@ ua: str | None = Field(None, max_length=255) +# Языки интерфейса (i18n.md хендбука): override хранится в user_prefs.locale, +# дефолт «авто» = locale аккаунта (user.profile["locale"]), fallback en. +SUPPORTED_LOCALES = ("en", "uk", "ru") +DEFAULT_LOCALE = "en" + + +def _user_prefs_row(db: Session, user_id: str) -> UserPrefs | None: + """Отличие от blocked-пути (stores.set_user_blocked): только чтение.""" + return db.get(UserPrefs, str(user_id)) + + +def _effective_locale(user, db: Session) -> tuple[str | None, str]: + """(override, effective): override → locale аккаунта → en. + Тег аккаунта нормализуется до 2-буквенного префикса (i18n.md).""" + row = _user_prefs_row(db, str(user.user_id)) + override = row.locale if row is not None else None + account = str(user.profile.get("locale") or "").strip().lower()[:2] + if account not in SUPPORTED_LOCALES: + account = "" + effective = override or account or DEFAULT_LOCALE + return override, effective + + @router.get("/me", response_model=AdminMe) -def me(user=Depends(get_current_user)) -> AdminMe: +def me(user=Depends(get_current_user), db: Session = Depends(get_db)) -> AdminMe: """Профиль по токену gnexus-auth (любая роль). SPA-гейт решает по system_role.""" + override, effective = _effective_locale(user, db) return AdminMe( sub=str(user.user_id), email=user.email, @@ -74,9 +98,38 @@ display_name=user.profile.get("display_name"), avatar_url=user.profile.get("avatar_url"), account_url=f"{get_settings().gauth_base_url.rstrip('/')}/account/profile", + locale=override, + locale_effective=effective, ) +class MeLocaleIn(BaseModel): + locale: str = Field(min_length=0, max_length=16) + + +@router.patch("/me", response_model=AdminMe) +def patch_me(payload: MeLocaleIn, user=Depends(get_current_user), db: Session = Depends(get_db)) -> AdminMe: + """Личная настройка языка (i18n.md): ""/"auto" — «по аккаунту», иначе en/uk/ru. + + Отличие от паттерна gnexus-creds: bearer здесь имеет личность (call-home + userinfo на каждом запросе) — отдельная cookie-сессия не нужна. + """ + raw = payload.locale.strip().lower() + if raw in ("", "auto"): + new_locale = None + else: + new_locale = raw[:2] + if new_locale not in SUPPORTED_LOCALES: + raise HTTPException(422, "Локаль не поддерживается: en, uk, ru") + row = db.get(UserPrefs, str(user.user_id)) + if row is None: + row = UserPrefs(user_id=str(user.user_id)) + db.add(row) + row.locale = new_locale + db.commit() + return me(user=user, db=db) + + @router.get("/me/events", response_model=list[MeEventOut]) def my_events( user=Depends(get_current_user), diff --git a/app/api/routes.py b/app/api/routes.py index bf156a8..bb20569 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -18,6 +18,19 @@ return {"status": "ok", "service": "synapse"} +# /healthz на верхнем уровне — конвенция хендбука (10-platform/health.md): +# путь должен быть /health или /healthz, а не спрятан под префиксом API. +top_router = APIRouter() + + +@top_router.get("/healthz", include_in_schema=False) +def healthz_top() -> dict: + return healthz() +def healthz() -> dict: + """Liveness: процесс жив, конфигурация не проверяется.""" + return {"status": "ok", "service": "synapse"} + + @router.get("/readyz") def readyz() -> dict: """Readiness: доступность зависимостей (Postgres, Redis).""" @@ -50,6 +63,10 @@ avatar_url: str | None = None # Страница профиля на сервере gnexus-auth (ссылка из навигации). account_url: str = "" + # Язык интерфейса (i18n.md): override пользователя или None («авто»); + # locale_effective — override → profile.locale аккаунта → en. + locale: str | None = None + locale_effective: str = "en" @router.get("/v1/admin/me") diff --git a/app/auth/stores.py b/app/auth/stores.py index 4c3d48a..d5d09cc 100644 --- a/app/auth/stores.py +++ b/app/auth/stores.py @@ -91,4 +91,35 @@ except Exception: # noqa: BLE001 — токен может быть уже отозван на стороне auth pass redis.hdel(key, login_id) - return len(stored) \ No newline at end of file + return len(stored) + + +# --- Флаг блокировки пользователя: гейт MCP-токенов (docs/07, mcp.md) --- + +BLOCK_EVENTS = ("user.blocked", "user.deleted", "user.archived") +UNBLOCK_EVENTS = ("user.unblocked", "user.restored") + + +def set_user_blocked(user_id: str, blocked: bool, logger=None) -> None: + """Ставим/снимаем флаг в user_prefs (upsert: строки может и не быть). + + Не трогает SSO-токены — заблокированный пользователь и так не пройдёт + call-home userinfo; флаг гейтит только персональные MCP-токены. + """ + from datetime import UTC, datetime + + from sqlalchemy.orm import Session as SASession + + from app.database import SessionLocal + from app.models import UserPrefs + + with SessionLocal() as db: # type: SASession + row = db.get(UserPrefs, str(user_id)) + if row is None: + row = UserPrefs(user_id=str(user_id)) + db.add(row) + row.blocked = blocked + row.blocked_at = datetime.now(UTC) if blocked else None + db.commit() + if logger is not None: + logger.info("user_prefs.blocked=%s для user=%s", blocked, user_id) \ No newline at end of file diff --git a/app/i18n_server.py b/app/i18n_server.py new file mode 100644 index 0000000..91e00dd --- /dev/null +++ b/app/i18n_server.py @@ -0,0 +1,285 @@ +"""Локализация серверных текстов API по Accept-Language (i18n.md хендбука). + +Бэкенд формулирует ошибки по-русски (как в коде), а на границе ответа текст +переводится для en/uk: точные строки — по словарю EXACT, шаблонные (f-строки) +— регэксп-правилами PATTERNS. Нет перевода или язык ru — текст уходит как есть. +HTTPException-обработчик вешает main.py; labels/help реестра настроек +локализует localize_settings() в admin_routes. + +Единственное место серверных переводов у Synapse: документы и README +остаются по-русски (решение владельца). +""" + +import re +from typing import Callable + +from fastapi import Request + +SUPPORTED = ("ru", "en", "uk") +DEFAULT_LOCALE = "ru" + + +def locale_from_request(request: Request) -> str: + """Первый поддерживаемый язык Accept-Language; без заголовка — ru.""" + header = request.headers.get("accept-language") or "" + for part in header.split(","): + tag = part.split(";")[0].strip().lower() + prefix = tag.split("-")[0] + if prefix in SUPPORTED: + return prefix + return DEFAULT_LOCALE + + +# Точные строки (как они встречаются в HTTPException и валидации настроек). +EXACT: dict[str, dict[str, str]] = { + "API-ключ неизвестен или отозван": { + "en": "API key is unknown or revoked", + "uk": "API-ключ невідомий або відкликаний", + }, + "GAUTH_WEBHOOK_SECRET не настроен": { + "en": "GAUTH_WEBHOOK_SECRET is not configured", + "uk": "GAUTH_WEBHOOK_SECRET не налаштований", + }, + "Refresh-токен невалиден": { + "en": "Refresh token is invalid", + "uk": "Refresh-токен недійсний", + }, + "В архиве нет источника с таким id": { + "en": "No source with this id in the archive", + "uk": "У архіві немає джерела з таким id", + }, + "В архиве нет правила с таким id": { + "en": "No rule with this id in the archive", + "uk": "У архіві немає правила з таким id", + }, + "В архиве нет типа с таким id": { + "en": "No type with this id in the archive", + "uk": "У архіві немає типу з таким id", + }, + "В архиве нет цели с таким id": { + "en": "No target with this id in the archive", + "uk": "У архіві немає цілі з таким id", + }, + "Источник в архиве — сначала restore": { + "en": "Source is archived — restore it first", + "uk": "Джерело в архіві — спершу restore", + }, + "Источник ключа в архиве — события не принимаются": { + "en": "The key's source is archived — events are not accepted", + "uk": "Джерело ключа в архіві — події не приймаються", + }, + "Источник ключа удалён": { + "en": "The key's source is deleted", + "uk": "Джерело ключа видалено", + }, + "Источник не найден": { + "en": "Source not found", + "uk": "Джерело не знайдено", + }, + "Источник типа в архиве — restore невозможен": { + "en": "The type's source is archived — restore is impossible", + "uk": "Джерело типу в архіві — restore неможливий", + }, + "Ключ не найден": { + "en": "Key not found", + "uk": "Ключ не знайдено", + }, + "Недостаточно прав: админ-панель доступна роли admin и выше": { + "en": "Not enough rights: the admin area requires the admin role or higher", + "uk": "Недостатньо прав: адмін-панель доступна ролі admin і вище", + }, + "Некорректная подпись вебхука": { + "en": "Invalid webhook signature", + "uk": "Некоректний підпис вебхука", + }, + "Подписка не найдена": { + "en": "Subscription not found", + "uk": "Підписку не знайдено", + }, + "Правило не найдено": { + "en": "Rule not found", + "uk": "Правило не знайдено", + }, + "Событие не найдено": { + "en": "Event not found", + "uk": "Подію не знайдено", + }, + "Тип не найден": { + "en": "Type not found", + "uk": "Тип не знайдено", + }, + "Токен невалиден или истёк": { + "en": "Token is invalid or expired", + "uk": "Токен недійсний або закінчився", + }, + "Токен не найден": { + "en": "Token not found", + "uk": "Токен не знайдено", + }, + "Требуется Bearer-токен": { + "en": "Bearer token required", + "uk": "Потрібен Bearer-токен", + }, + "Требуется Bearer-токен gnexus-auth": { + "en": "A gnexus-auth Bearer token is required", + "uk": "Потрібен Bearer-токен gnexus-auth", + }, + "Цель не найдена": { + "en": "Target not found", + "uk": "Ціль не знайдено", + }, + "Локаль не поддерживается: en, uk, ru": { + "en": "Unsupported locale: en, uk, ru", + "uk": "Локаль не підтримується: en, uk, ru", + }, +} + +# Шаблонные формы: regex на русском тексте -> генератор перевода по захватам. +# Генераторы замыкаются на целевой язык (make_* ниже). +PATTERN_BUILDERS: list[tuple[str, Callable[[], dict[str, str]]]] = [ + ( + r"^Достигнут лимит активных MCP-токенов \((\d+)\) — отзовите лишний$", + lambda: { + "en": "MCP token limit reached ({n}) — revoke some first", + "uk": "Досягнуто ліміт активних MCP-токенів ({n}) — відкличте зайві", + }, + ), + ( + r"^Поле source='(.+?)' не совпадает с источником ключа '(.+?)'$", + lambda: { + "en": "source='{a}' does not match the key's source '{b}'", + "uk": "source='{a}' не збігається з джерелом ключа '{b}'", + }, + ), + ( + r"^Тип \((.+?)\) не зарегистрирован — обратись к админу Synapse$", + lambda: { + "en": "Type ({a}) is not registered — ask the Synapse admin", + "uk": "Тип ({a}) не зареєстрований — звертайся до адміна Synapse", + }, + ), + ( + r"^(.+?): значение должно быть числом$", + lambda: { + "en": "{a}: value must be a number", + "uk": "{a}: значення має бути числом", + }, + ), + ( + r"^(.+?): значение вне допустимого диапазона (\d+)\.\.(\d+)$", + lambda: { + "en": "{a}: value out of the allowed range {b}..{c}", + "uk": "{a}: значення поза допустимим діапазоном {b}..{c}", + }, + ), +] + + +def _apply(matched: re.Match, template: dict[str, str], lang: str, keys: list[str]) -> str: + out = template[lang] + for i, name in enumerate(keys, 1): + out = out.replace("{" + name + "}", matched.group(i)) + return out + + +# У шаблонов именованные группы-подстановки: индекс в PATTERN_BUILDERS → ключи +# (regex-группируются по порядку и подставляются в текст шаблона). +_PATTERN_KEYS: dict[int, list[str]] = { + 0: ["n"], + 1: ["a", "b"], + 2: ["a"], + 3: ["a"], + 4: ["a", "b", "c"], +} + + +def localize_detail(request: Request | None, detail: object) -> object: + """Перевод detail ответа; не-строки уходят как есть.""" + if not isinstance(detail, str): + return detail + lang = locale_from_request(request) if request is not None else DEFAULT_LOCALE + if lang == "ru": + return detail + exact = EXACT.get(detail) + if exact: + return exact[lang] + for idx, (pattern, build) in enumerate(PATTERN_BUILDERS): + matched = re.match(pattern, detail) + if matched: + text = build()[lang] + for i, key in enumerate(_PATTERN_KEYS.get(idx, ["a", "b", "c"]), 1): + text = text.replace("{" + key + "}", matched.group(i)) + return text + return detail + + +# --- Реестр настроек: перевод label/help по ключу --- + +SETTINGS: dict[str, dict[str, str]] = { + "routing_match_mode.label": {"en": "Routing match mode", "uk": "Режим маршрутизації"}, + "routing_match_mode.help": { + "en": "all — every matching rule; first — only the top one by weight", + "uk": "all — усі підходящі правила; first — лише перше за weight", + }, + "dedup_window_seconds.label": {"en": "Dedup window, seconds", "uk": "Вікно дедуплікації, сек"}, + "dedup_window_seconds.help": { + "en": "A duplicate dedup_key within the window returns the first event's id", + "uk": "Дубль з тим самим dedup_key у вікні поверне id першої події", + }, + "retention_days.label": {"en": "Retention, days", "uk": "Ретеншн, днів"}, + "retention_days.help": { + "en": "Events older than N days are deleted; 0 disables the window", + "uk": "Події старші N днів видаляються; 0 вимикає вікно", + }, + "vapid_public_key.label": {"en": "VAPID public key", "uk": "VAPID public key"}, + "vapid_public_key.help": {"en": "URLsafe base64 public key (p256dh)", "uk": "URLsafe base64 публічний ключ (p256dh)"}, + "vapid_private_key.label": {"en": "VAPID private key", "uk": "VAPID private key"}, + "vapid_private_key.help": { + "en": "The secret is stored write-only in the DB; the API only reports whether it is set", + "uk": "Секрет зберігається в БД write-only, API повертає лише факт «задано»", + }, + "push_subject.label": {"en": "Subject (sender)", "uk": "Subject (відправник)"}, + "push_subject.help": { + "en": "Sender contact for web-push, e.g. mailto:ops@gnexus.space", + "uk": "Контакти відправника для веб-пушів, наприклад mailto:ops@gnexus.space", + }, + "telegram_bot_token.label": {"en": "Telegram bot token", "uk": "Токен тг-бота"}, + "telegram_bot_token.help": { + "en": "Channel is not implemented yet (#29) — the token is stored for later", + "uk": "Канал ще не реалізований (#29) — токен зберігається наперед", + }, + "smtp_from.label": {"en": "From address", "uk": "From-адреса"}, + "smtp_from.help": { + "en": 'For example "Synapse "', + "uk": 'Наприклад "Synapse "', + }, +} +# smtp_host/port/user/password — технические имена, не переводятся. + + +def localize_settings(defs, lang: str) -> list: + """Те же SettingDef с заменёнными label/help (для en/uk); простая обёртка. + + Реестр иммутабелен (frozen dataclass в EDITABLE) — создаём лёгкие шимы, + исходные объекты не трогаем. + """ + if lang == "ru": + return defs + localized = [] + for d in defs: + label = SETTINGS.get(f"{d.key}.label", {}).get(lang, d.label) + help_text = SETTINGS.get(f"{d.key}.help", {}).get(lang, d.help) if d.help else d.help + localized.append(_SettingShim(d, label, help_text)) + return localized + + +class _SettingShim: + """С теми же полями, что SettingDef, но своим label/help.""" + + def __init__(self, base, label, help_text): + self.key = base.key + self.section = base.section + self.label = label + self.kind = base.kind + self.options = base.options + self.help = help_text \ No newline at end of file diff --git a/app/main.py b/app/main.py index b82b287..37d2633 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,7 @@ from contextlib import AsyncExitStack, asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -12,6 +12,7 @@ from app.api import me_routes from app.api.auth_routes import router as auth_router from app.api.routes import router as api_router +from app.api.routes import top_router from app.config import get_settings from app.worker.celery_app import celery_app # noqa: F401 — регистрирует задачи в очереди import'ов @@ -46,6 +47,21 @@ **lifespan_kwargs, ) + # Локализация серверных ошибок по Accept-Language (i18n.md хендбука). + from starlette.exceptions import HTTPException as StarletteHTTPException + + from app.i18n_server import localize_detail + + @app.exception_handler(StarletteHTTPException) + async def localized_http_exception(request: Request, exc: StarletteHTTPException): + from fastapi.responses import JSONResponse + + return JSONResponse( + status_code=exc.status_code, + content={"detail": localize_detail(request, exc.detail)}, + headers=getattr(exc, "headers", None), + ) + # В проде SPA с того же origin, CORS нужен только dev-серверу vite. app.add_middleware( CORSMiddleware, @@ -56,6 +72,7 @@ ) app.include_router(api_router) + app.include_router(top_router) app.include_router(auth_router) app.include_router(events_routes.router) app.include_router(admin_routes.router) @@ -65,14 +82,29 @@ # Именно Route, не Mount: Mount('/mcp') матчит только '/mcp/...' (regex # с trailing slash), точный POST /mcp провалился бы в SPA (GET index / # POST 405); Route отдаёт в саб-апп путь без root_path-трюков. + # /mcp — исторический маршрут; /mcp-protocol/ — конвенция хендбука + # (10-platform/mcp.md, слэш значим для реверс-прокси). from starlette.routing import Route - app.router.routes.append( - Route( - "/mcp", mcp_mount, methods=["GET", "POST", "DELETE", "OPTIONS"], - include_in_schema=False, + class McpPathRewriter: + """ASGI-обёртка: FastMCP-саб-апп роутит только "/mcp", + внешний маршрут (/mcp-protocol/, конвенция хендбука) существует + только до этой точки — скармливаем саб-аппу канонический путь.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + await self.app({**scope, "path": "/mcp"}, receive, send) + + for mcp_path in ("/mcp", "/mcp-protocol/"): + app.router.routes.append( + Route( + mcp_path, McpPathRewriter(mcp_mount), + methods=["GET", "POST", "DELETE", "OPTIONS"], + include_in_schema=False, + ) ) - ) # Собранная SPA: в Docker-образе кладётся в spa_static/. Если собранной # статики нет (локальный запуск api без фронта) — просто пропускаем. diff --git a/app/mcp/context.py b/app/mcp/context.py index 297b78a..ba33848 100644 --- a/app/mcp/context.py +++ b/app/mcp/context.py @@ -44,15 +44,18 @@ def resolve_personal_token(db: Session, raw_token: str) -> AuthenticatedUser | None: - """Bearer `mcp_...` -> владелец ключа; None — промах/отзыв. + """Bearer `mcp_...` -> владелец ключа; None — промах/отзыв/блокировка владельца. - Вызывается гейтом на каждом запросе /mcp. last_used_at — единственный - столбец, который MCP-путь когда-либо пишет (коммит здесь: у гейта нет - общей транзакции роутера). + Вызывается гейтом на каждом запросе /mcp. Заблокированный в gnexus-auth + владелец блокирует и его токены (mcp.md) — флаг ставят вебхуки + (user.blocked и др.) в user_prefs. last_used_at — единственный столбец, + который MCP-путь когда-либо пишет (коммит здесь: у гейта нет общей + транзакции роутера). """ from datetime import UTC, datetime from app.auth.apikeys import token_hash_of + from app.models import UserPrefs row = db.execute( select(McpToken).where( @@ -62,6 +65,9 @@ ).scalar_one_or_none() if row is None: return None + prefs = db.get(UserPrefs, row.user_id) + if prefs is not None and prefs.blocked: + return None row.last_used_at = datetime.now(UTC) db.commit() return AuthenticatedUser( diff --git a/app/models/__init__.py b/app/models/__init__.py index 3227e6f..bca6a49 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,6 +1,6 @@ """SQLAlchemy-модели Synapse. Все модули импортируются для Base.metadata.""" -from app.models.dicts import ApiKey, ChannelTarget, McpToken, NotificationType, Source +from app.models.dicts import ApiKey, ChannelTarget, McpToken, NotificationType, Source, UserPrefs from app.models.events import Delivery, Event from app.models.rules import RoutingRule, RoutingRuleAction from app.models.settings import AppSetting, PushSubscription @@ -17,4 +17,5 @@ "Delivery", "AppSetting", "PushSubscription", + "UserPrefs", ] diff --git a/app/models/dicts.py b/app/models/dicts.py index ee9fade..6c1d55f 100644 --- a/app/models/dicts.py +++ b/app/models/dicts.py @@ -8,6 +8,7 @@ from datetime import datetime from sqlalchemy import ( + Boolean, CheckConstraint, DateTime, ForeignKey, @@ -114,6 +115,28 @@ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class UserPrefs(Base): + """Минимальная запись пользователя Synapse (sub gnexus-auth — первичный ключ). + + Локальной записи профиля нет (личность каждый запрос проверяет call-home), + здесь только то, что не приходит из gnexus-auth или что нужно локально: + locale — override языка интерфейса (i18n.md: ""/None = по аккаунту); + blocked — флаг блокировки из вебхуков gnexus-auth (user.blocked и др.), + гейтит и MCP-токены пользователя (mcp.md: заблокированный пользователь — + заблокированные токены), не трогает SSO-токены. + """ + + __tablename__ = "user_prefs" + + user_id: Mapped[str] = mapped_column(String(64), primary_key=True) + locale: Mapped[str | None] = mapped_column(String(8)) + blocked: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + blocked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + class NotificationType(Base): """Тип уведомления = тройка (source, subject, action) из контракта.""" diff --git a/docs/04-database.md b/docs/04-database.md index 9a9f742..3c51c87 100644 --- a/docs/04-database.md +++ b/docs/04-database.md @@ -1,6 +1,6 @@ # 04 — Схема БД (#34), v1 -Реализация — `app/models/` + миграции `alembic/versions/` (7 таблиц; `0001` — исходная схема, `0002/0003` — `description` и полный Routing Engine). Постгрес в докере (postgres:17-alpine, том `pgdata`), Redis 7 (том `redisdata`). +Реализация — `app/models/` + миграции `alembic/versions/` (9 миграций; `0001` — исходная схема, `0002/0003` — `description` и полный Routing Engine, `0004/0005/0006` — user-канал, `expired` и push-настройки, `0007` — soft-delete справочников, `0008` — `mcp_tokens`, `0009` — `user_prefs`). Постгрес в докере (postgres:17-alpine, том `pgdata`), Redis 7 (том `redisdata`). Два контура данных: **конфигурация** (человек заводит через админку, меняется редко) и **поток событий** (пишется автоматически, растёт всегда). @@ -36,7 +36,7 @@ ## Что сознательно НЕ в таблицах -- **Пользователи** — их делает gnexus-auth; Synapse пользователей не хранит. +- **Пользователи** — их делает gnexus-auth; Synapse пользователей не хранит. Исключения (микрозапись, не зеркалирование): `user_prefs` (`0009` — ПК `sub`, per-user override локали для i18n + флаг `blocked` из вебхуков gnexus-auth для гейта MCP-токенов), `push_subscriptions` (подписки web-push) и `mcp_tokens` (`0008`). - **Секреты каналов** — .env/`gnexus-creds`, не БД (`TELEGRAM_BOT_TOKEN`, SMTP и т.д.). - **`tags` события** — убраны из v1 контракта (см. docs/05); вернутся, когда появится правило, требующее их. - **История попыток отдельно от delivery** — одна запись с `attempts`/`next_retry_at`; поэлементный лог попыток не нужен MVP. diff --git a/docs/07-mcp.md b/docs/07-mcp.md index c828bb2..5cc7689 100644 --- a/docs/07-mcp.md +++ b/docs/07-mcp.md @@ -1,7 +1,9 @@ # 07 — MCP-сервер: управление Synapse ИИ-агентом Synapse встраивает **FastMCP** (пакет `mcp`) — тот же контейнер API, тот же -процесс uvicorn. Эндпоинт `/mcp`, транспорт **streamable-http** (stateless). +процесс uvicorn. Эндпоинты `/mcp-protocol/` (конвенция хендбука, `10-platform/mcp.md` +— слэш в конце значим для реверс-прокси) и `/mcp` (исторический alias), транспорт +**streamable-http** (stateless). Зачем: ИИ-агент (Claude Code и совместимые) управляет Synapse целиком — выдаёт API-ключи клиентам (сервисам-источникам), регистрирует типы, ведёт @@ -26,7 +28,9 @@ Админ видит ключи всех на `/mcp-keys` (или `GET /api/v1/admin/mcp_tokens`) и может отозвать любой (`POST /api/v1/admin/mcp_tokens/{id}/revoke`). SSO-логаут MCP-токены не трогает — это самостоятельные креды, как - ключи источников. + ключи источников. **Блокировка пользователя** (вебхуки gnexus-auth + `user.blocked`/`deleted`/`archived` → флаг в `user_prefs`) гасит все + его личные ключи: гард отвечает тем же 401, что и при неверном токене. 2. **Статический `MCP_TOKEN` из `.env`** — суперадмин-бэкдор: сохраняет совместимость с существующими сценариями агентов. Опционален (пусто — работают только персональные ключи); держать в `.env` + gnexus-creds. @@ -130,4 +134,5 @@ - Секреты настроек остаются write-only (`settings_get` их не отдаёт). - `send_test_event` и просмотр потока требуют живого воркера (Celery) — `system_status` покажет. -- Транспорт — внутренняя сеть/локальный порт API: наружу `/mcp` не публикуем. \ No newline at end of file +- Транспорт — внутренняя сеть/локальный порт API: наружу `/mcp-protocol/` + (и alias `/mcp`) не публикуем. \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 33e6895..2b98e0b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -16,6 +16,7 @@ import { useRoute, useRouter } from "vue-router"; import { GnIdentity, GnIconButton, GnNavigationShell, GnToastProvider } from "gnexus-ui-kit/vue"; import { clearToken, logout, me } from "./api.js"; +import { setLocale, t } from "./i18n/index.js"; const route = useRoute(); const router = useRouter(); @@ -23,41 +24,43 @@ // Навигация по роли. Администратор — вся админка; обычный пользователь — // только личный раздел (роль из GET /api/v1/me, гейт дублирует require_admin). // Иконки — только Phosphor (правила кита); pulse/shapes в шрифте кита отсутствуют. -const adminItems = [ - { to: "/dashboard", label: "Обзор", icon: "ph-activity" }, - { to: "/sources", label: "Источники", icon: "ph-broadcast" }, - { to: "/types", label: "Типы", icon: "ph-squares-four" }, - { to: "/targets", label: "Цели", icon: "ph-target" }, - { to: "/rules", label: "Правила", icon: "ph-funnel" }, - { to: "/deliveries", label: "Доставки", icon: "ph-paper-plane-tilt" }, - { to: "/mcp-keys", label: "MCP-ключи", icon: "ph-key" }, - { to: "/settings", label: "Настройки", icon: "ph-gear-six" }, -]; -const adminCurrents = { - "/dashboard": "Обзор", - "/sources": "Источники", - "/types": "Типы", - "/targets": "Цели", - "/rules": "Правила", - "/deliveries": "Доставки", - "/mcp-keys": "MCP-ключи", - "/settings": "Настройки", -}; +// Всё подписанно t() через computed (i18n.md «грабли»: module-level массив +// с уже вычисленным t() застывает). +const adminItems = computed(() => [ + { to: "/dashboard", label: t("nav.dashboard"), icon: "ph-activity" }, + { to: "/sources", label: t("nav.sources"), icon: "ph-broadcast" }, + { to: "/types", label: t("nav.types"), icon: "ph-squares-four" }, + { to: "/targets", label: t("nav.targets"), icon: "ph-target" }, + { to: "/rules", label: t("nav.rules"), icon: "ph-funnel" }, + { to: "/deliveries", label: t("nav.deliveries"), icon: "ph-paper-plane-tilt" }, + { to: "/mcp-keys", label: t("nav.mcpKeys"), icon: "ph-key" }, + { to: "/settings", label: t("nav.settings"), icon: "ph-gear-six" }, +]); +const adminCurrents = computed(() => ({ + "/dashboard": t("nav.dashboard"), + "/sources": t("nav.sources"), + "/types": t("nav.types"), + "/targets": t("nav.targets"), + "/rules": t("nav.rules"), + "/deliveries": t("nav.deliveries"), + "/mcp-keys": t("nav.mcpKeys"), + "/settings": t("nav.settings"), +})); const items = computed(() => profile.value?.system_role === "user" ? [ - { to: "/my-events", label: "Мои события", icon: "ph-user" }, - { to: "/mcp-keys", label: "MCP-ключи", icon: "ph-key" }, - { to: "/settings", label: "Настройки", icon: "ph-gear-six" }, + { to: "/my-events", label: t("nav.myEvents"), icon: "ph-user" }, + { to: "/mcp-keys", label: t("nav.mcpKeys"), icon: "ph-key" }, + { to: "/settings", label: t("nav.settings"), icon: "ph-gear-six" }, ] - : adminItems, + : adminItems.value, ); // Префиксно: /sources/7 тоже подписывается «Источники». const current = computed(() => { const paths = profile.value?.system_role === "user" - ? { "/my-events": "Мои события", "/mcp-keys": "MCP-ключи" } - : adminCurrents; + ? { "/my-events": t("nav.myEvents"), "/mcp-keys": t("nav.mcpKeys") } + : adminCurrents.value; return ( Object.entries(paths) .filter(([p]) => route.path === p || route.path.startsWith(p + "/")) @@ -116,6 +119,9 @@ async function loadProfile() { try { profile.value = await me(); + // Язык держит сервер (i18n.md): override → аккаунт → en; heartbeat + // подтверждает тот же effective — смена в Настройках тут же прилетает. + setLocale(profile.value?.locale_effective); } catch (ex) { profile.value = null; authFailure(ex); @@ -165,7 +171,7 @@ brand="Synapse" logo-src="/assets/imgs/synapse-mark.svg" title="Synapse" - subtitle="Хаб уведомлений Gnexus" + :subtitle="t('app.subtitle')" :current="current" >