diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d5c7b9c --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Gnexus Synapse — переменные окружения. +# Скопировать: cp .env.example .env +# Секреты хранить в .env (не в репо) и дополнительно в gnexus-creds. + +# --- Postgres (контейнер из docker-compose) --- +POSTGRES_USER=synapse +POSTGRES_PASSWORD=change-me +POSTGRES_DB=synapse + +# --- Приложение --- +# Для контейнеров хост БД/Redis — имена сервисов compose; для локального запуска без docker — localhost. +DATABASE_URL=postgresql+psycopg://synapse:change-me@postgres:5432/synapse +REDIS_URL=redis://redis:6379/0 + +# --- gnexus-auth (SSO) --- +# Базовый URL сервера gnexus-auth (сейчас 192.168.1.167, протокол уточнить). +GAUTH_BASE_URL=https://192.168.1.167 +# id/secret OAuth-клиента — зарегистрировать приложение Synapse в gnexus-auth и подставить сюда. +GAUTH_CLIENT_ID=set-me +GAUTH_CLIENT_SECRET=set-me +# Куда gnexus-auth возвращает пользователя после логина (SPA). +GAUTH_REDIRECT_URI=https://synapse.localhost/oauth/callback + +# --- Misc --- +# Явные URL входа, если SPA запускается в dev-режиме на отдельном порту. +SYNAPSE_PORT=8000 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14f72ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ + +# Node +node_modules/ +frontend/dist/ + +# Env — секреты только тут и в gnexus-creds, никогда в репо +.env +.env.* +!.env.example + +# Docker +pgdata/ +redisdata/ + +# IDE / misc +.idea/ +.vscode/ +.DS_Store +*.log \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4840846 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Что это + +**Gnexus Synapse** — централизованный хаб уведомлений экосистемы Gnexus. Любой сервис шлёт событие «что случилось» в Synapse, Synapse решает, кому, куда и каким каналом доставить: Telegram, Email, Push, внутренний лог, plus system-to-system доставка в Navi и другие сервисы. Цель — убрать из каждого сервиса (bugtrail, gntodo, smart-home, Navi-инстансы, gnexus-auth) собственные реализации уведомлений. + +## Архитектура (слои) + +- **Ingestion Layer** — HTTP API приёма событий (тип, payload, приоритет, получатели/топик). Аутентификация источников по API-ключам. +- **Queue** — Celery + Redis: приём мгновенно возвращает 202, обработка асинхронная. +- **Routing Engine** — по типу уведомления и настройкам маршрутизации решает: какие каналы, кому, какие шаблоны. +- **Provider Adapters** — Telegram (бот), Email (SMTP), Push (web-push), Internal Log (БД — деградация и аудит). +- **System-to-System Delivery** — доставка в Navi (swarm-инстансы) и другие сервисы через их API/webhook. +- **Persistence** — PostgreSQL: пользователи, типы уведомлений, настройки маршрутизации, логи доставки со статусами и ретраями. SQLite допустим только для локальных быстрых тестов, основной путь — Postgres. +- **Web UI / Админ-панель** — SPA на **Vue 3** (PWA на Gnexus UI Kit), раздаётся как статика из того же контейнера FastAPI (отдельного фронтенд-контейнера нет). Это не только просмотр, но и основной способ **конфигурирования Synapse**: пользователи, типы уведомлений, маршрутизация, API-ключи, шаблоны — всё управляется из админки. Обзор — события, статусы, логи доставки. + +**Стек**: Python + FastAPI, SQLAlchemy + Alembic, Celery + Redis, PostgreSQL, Docker + docker-compose. Frontend админки — SPA на Vue 3 + Gnexus UI Kit. + +**Инфраструктурное решение**: база берём с собой в докер — Postgres поднимается контейнером из docker-compose, отдельного продового сервера БД нет (на 100%). Тоже касается Redis. Скорость ответа приёма (202) — ключевой контракт Ingestion: тяжёлая работа только в воркерах. + +## Решения, которые пока не приняты (не выбирать молча) + +Открытые вопросы — фиксировать выбранный вариант *здесь и в описании задачи gntodo*, после решения убрать из списка: + +1. Хост и домен деплоя (synapse.gnexus.space?) — VM на libvirt или существующий VPS. +2. Telegram-бот — новый или существующий токен. +3. Единый JSON-контракт событий — черновик в задаче #32. + +## Интеграция с gnexus-auth (SSO) + +gnexus-auth — Laravel (PHP), токены **opaque, не JWT**: валидация только «call home» через `GET /oauth/userinfo` с `Authorization: Bearer ` (локальной проверки/JWKS нет; зато отзыв токена и блокировка пользователя видны сразу). + +- **Python SDK**: `gnexus-gauth` (репо gnexus-auth-client-py, зависимость только httpx): полный OAuth2 Authorization Code + PKCE, exchange/refresh/revoke, `fetch_user()` → `AuthenticatedUser`. Для SPA у gnexus-auth есть свой JS-клиент (`packages/auth-client/`, доки 18–20). +- **Роли**: системные — `superadmin > admin > user` (глобальные), плюс клиентские (per-сервис slug'и, пока не используем). **Доступ к админке = `system_role in ("admin", "superadmin")`** — аналог `SystemRole::canAccessAdminArea()` на сервере auth. +- **userinfo-ответ**: `sub`, `email`, `system_role`, `profile`, `client.roles/permissions` (scopes: `openid email profile roles permissions`). Access-токен живёт ~30 мин, есть refresh-ротация с детекцией переиспользования. +- **Паттерн FastAPI**: dependency `get_current_user` (токен из заголовка, `fetch_user`, короткое кэширование userinfo) → `require_admin` → 403, если роль ниже admin. Инвалидировать кэш при 401; можно слушать webhooks `role.*`/`user.*` с HMAC-верификацией. +- Токен передаётся `Authorization: Bearer` — cookie не годятся (сессия gnexus-auth не выходит за пределы auth-сервера); SPA-клиент хранит access/refresh сам (docs 18–20 описывают клиентскую сторону). + +## GNexus UI Kit + +Фронтенд-зависимость: `npm install git+https://git.gnexus.space/git/root/gnexus-ui-kit.git` — не в npm-registry, но `dist/` и Vue-адаптер (`src/vue/`, экспорт `gnexus-ui-kit/vue`, peer-dep Vue ^3.4) закоммичены в репо кита, установка из git работает. Принцип: **минимум своего, максимум из кита**. + +Правила (полный гайд — `node_modules/gnexus-ui-kit/docs/ai-guide.md`; в репо кита — `docs/ai-guide.md` и `docs/catalog.json`): + +- Перед написанием любого UI-маркапа проверять каталог `node_modules/gnexus-ui-kit/docs/catalog.json` (need → component → props → useWhen). Если компонент для нужды есть — использовать его (GnButton, GnModal, GnStatusCard, GnMetricCard, GnNavigationShell для каркаса приложения, ...). В vanilla HTML — классы кита, не самодельные аналоги. +- Иконки — только Phosphor: `icon="ph-house"` в Vue, `` в HTML. +- Варианты — только `primary, secondary, accent, success, warning, danger, error, info`; не выдумывать свои. +- Цвета — токены кита (SCSS `$color-*`/`$surface-*` или CSS custom properties `var(--gn-color-secondary)`, `var(--gn-space-4)`, ...), никогда сырые hex. Отступы — шкала `$space-1…$space-12`, никакого произвольного пиксельного padding. +- Фокус — `focus_ring` на `:focus-visible` у интерактивных элементов; hover — через `hover_touch`. +- Если в каталоге нет нужного компонента — сначала убедиться, что нет, потом делать свой строго по рецепту из `docs/ai-guide.md` → «Building a custom component in the GNexus style» (`hard_panel`, uppercase-заголовки, IBM Plex Mono — не переопределять font-family). + +## Принятые решения + +- **FastAPI** для API (не Django) — обоснование описать в задаче #32 при проектировании API. +- **Админ-панель** — SPA на Vue 3 с Gnexus UI Kit; раздаётся как статика контейнером FastAPI. Всё конфигурирование проекта — через админку, UI первичен для настроек. Минимум своего, максимум из кита. +- **Доступ к админке** — роли gnexus-auth (SSO): только `admin` и выше. Пользователя с ролью ниже — отсекать явной ошибкой (403 «недостаточно прав»), как на уровне API, так и в UI (гейт после логина). Своих паролей Synapse не хранит. + +## Инфраструктура + +- **gnexus-auth** (SSO, 192.168.1.167) — единая аутентификация экосистемы. +- **Navi swarm** — инстансы: rei, ayame, tsukiko, pilar, melody, zoe, Kael. Доставка system-to-system через их API/webhook. +- Публичный доступ — через gnexus.space (nginx), внутренняя сеть 192.168.1.0/24. + +## Рабочие правила + +- **Задачи в gntodo** (проект id 9): #33 скелет → #34 схема БД → #32 проектирование API → #31 Ingestion → #30 Routing Engine → #29 Provider Adapters → #28 System-to-System → #27 тестирование и деплой. Статусы обновлять в gntodo по ходу работы. +- **Спорные архитектурные решения** — фиксировать в описаниях задач, не молча. +- **Документация**: документировать всё (README, схема БД, примеры curl). После деплоя — задокументировать сервис в gnexus-book через MCP-инструменты. +- **Секреты** — в `.env` + репозиторий gnexus-creds, никогда не в этом репозитории. + +## Принципы владельца + +Self-hosted, open-source, бюджет-first, без платных SaaS. Простые прямые решения без оверинжиниринга. Веб-интерфейс при необходимости — PWA на Gnexus UI Kit. + +## Definition of Done (MVP) + +API принимает событие → очередь → маршрутизация → доставка минимум в 2 канала (Telegram + внутренний лог) → лог доставки в БД; Navi получает уведомление system-to-system; docker-compose поднимает всё одной командой; README с quickstart. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..49171f5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Gnexus Synapse — образ API/воркера. +# Многоэтапная сборка: frontend -> статики, python -> рантайм. +# SPA раздаётся тем же контейнером FastAPI (отдельного фронтенд-контейнера нет). + +# --- Этап 1: сборка SPA (Vue 3 + gnexus-ui-kit) --- +FROM node:22-alpine AS frontend-build +RUN apk add --no-cache git +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json* frontend/.npmrc ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +# --- Этап 2: рантайм --- +FROM python:3.12-slim +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# git нужен для pip-зависимостей из git.gnexus.space (gnexus-gauth) +RUN apt-get update \ + && apt-get install -y --no-install-recommends git curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /srv/synapse +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY alembic.ini ./ +COPY alembic ./alembic +COPY app ./app +COPY docker/entrypoint.sh /entrypoint.sh +COPY --from=frontend-build /frontend/dist ./spa_static +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD curl -fsS http://localhost:8000/api/healthz || exit 1 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["api"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..602d5d1 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Gnexus Synapse + +Централизованный хаб уведомлений экосистемы Gnexus: сервисы присылают событие «что случилось», Synapse решает — кому, куда и каким каналом доставить (Telegram, Email, Push, внутренний лог, system-to-system в Navi). + +## Стек + +- **API**: Python, FastAPI, SQLAlchemy 2 + Alembic +- **Очередь**: Celery + Redis +- **БД**: PostgreSQL (в докере, вместе с приложением) +- **Админка**: SPA на Vue 3 + [Gnexus UI Kit](https://git.gnexus.space/git/root/gnexus-ui-kit), раздаётся статикой из контейнера FastAPI +- **Аутентификация**: gnexus-auth (SSO), доступ к админке — роли `admin` и выше + +## Что уже в каркасе + +- FastAPI-приложение: `/api/healthz` (liveness), `/api/readyz` (зависимости), `/api/docs` (Swagger) +- Гейт админки по ролям gnexus-auth: `app/auth/deps.py` (`get_current_user` → `require_admin`, 401/403) +- Celery-воркер с каркасной задачей `synapse.ping` +- Alembic (миграции пока пустые — схема БД в задаче #34) +- Vue 3 SPA: вход (dev-заглушка), дашборд, экран «доступ запрещён» для роли ниже admin +- docker-compose: api + worker + postgres + redis, миграции применяются при старте + +## Quickstart + +```bash +cp .env.example .env # заполнить пароль БД, gnexus-auth-креды появятся после регистрации клиента +docker compose up -d --build +curl http://localhost:8000/api/healthz +``` + +После сборки админка доступна на `http://localhost:8000/` (SPA раздаёт контейнер api). + +### Локальная разработка без docker + +```bash +# терминал 1 — API (нужны работающие Postgres/Redis, DATABASE_URL хост = localhost) +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload + +# терминал 2 — воркер +celery -A app.worker.celery_app worker --loglevel=info + +# терминал 3 — SPA dev-сервер (проксирует /api на :8000) +cd frontend && npm install && npm run dev # http://localhost:5173 +``` + +### Проверка воркера + +```bash +docker compose exec api celery -A app.worker.celery_app call synapse.ping # возвращает task id +``` + +## Вход в админку + +1. Зарегистровать OAuth-клиент `synapse` на сервере gnexus-auth и вписать креды в `.env` (`GAUTH_*`). +2. Получить access-токен через `POST /oauth/token` gnexus-auth (или вставить токен в форму входа — dev-режим каркаса). +3. Пользователь с `system_role` ниже `admin` получает экран «Доступ запрещён» (и 403 от API). + +## Структура + +```text +app/ + main.py FastAPI: роуты API + статики SPA + config.py настройки из .env + database.py SQLAlchemy engine/session + auth/deps.py SSO-валидация + гейт ролей admin/superadmin + api/routes.py healthz, readyz, admin/me + worker/ Celery: celery_app, tasks +alembic/ миграции (env.py читает DATABASE_URL из .env) +frontend/ Vue 3 SPA админки (сборка кладётся в spa_static/ образа) +docker/entrypoint.sh режимы api (миграции+uvicorn) / worker (celery) +docker-compose.yml api, worker, postgres, redis +``` + +## Roadmap (задачи gntodo, проект 9) + +#33 скелет → #34 схема БД → #32 контракты API → #31 Ingestion → #30 Routing Engine → #29 Provider Adapters → #28 System-to-System (Navi) → #27 тесты и деплой. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..78d0d72 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..6712770 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,38 @@ +"""Alembic env: URL берём из настроек Synapse, метаданные — из app.models.""" + +from alembic import context +from sqlalchemy import pool + +from app.config import get_settings +from app.database import engine + +# Импорт моделей здесь обязателен: Base.metadata должен знать все таблицы. +# from app import models # включить в задаче #34 (схема БД) — пока моделей нет + +config = context.config +target_metadata = __import__("app").database.Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=get_settings().database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..e058b76 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,22 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/alembic/versions/.gitkeep diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/app/__init__.py diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/app/api/__init__.py diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..124f4a9 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,52 @@ +"""API-роуты скелета: healthz/readyz и пример admin-endpoint с гейтом ролей.""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from redis import Redis +from sqlalchemy import text + +from app.auth.deps import AuthenticatedUser, require_admin +from app.config import get_settings +from app.database import SessionLocal + +router = APIRouter(prefix="/api") + + +@router.get("/healthz") +def healthz() -> dict: + """Liveness: процесс жив, конфигурация не проверяется.""" + return {"status": "ok", "service": "synapse"} + + +@router.get("/readyz") +def readyz() -> dict: + """Readiness: доступность зависимостей (Postgres, Redis).""" + components: dict[str, str] = {} + + try: + with SessionLocal() as db: + db.execute(text("SELECT 1")) + components["postgres"] = "ok" + except Exception as exc: + components["postgres"] = f"fail: {exc.__class__.__name__}" + + try: + Redis.from_url(get_settings().redis_url, socket_connect_timeout=2).ping() + components["redis"] = "ok" + except Exception as exc: + components["redis"] = f"fail: {exc.__class__.__name__}" + + ready = all(v == "ok" for v in components.values()) + return {"ready": ready, "components": components} + + +class AdminMe(BaseModel): + sub: str + email: str | None + system_role: str + + +@router.get("/v1/admin/me") +def admin_me(user: AuthenticatedUser = Depends(require_admin)) -> AdminMe: + """Проверка гейта админки: 401 без токена, 403 без роли admin.""" + return AdminMe(sub=str(user.user_id), email=user.email, system_role=user.system_role) \ No newline at end of file diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/app/auth/__init__.py diff --git a/app/auth/deps.py b/app/auth/deps.py new file mode 100644 index 0000000..8725c6b --- /dev/null +++ b/app/auth/deps.py @@ -0,0 +1,68 @@ +"""Аутентификация через gnexus-auth и гейт админки по ролям. + +Токены gnexus-auth — opaque (не JWT), валидация только «call home»: +GET /oauth/userinfo с Bearer-токеном. Отзыв токена и блокировка +пользователя учитываются сервером auth мгновенно. + +Доступ к админке — system_role in ("admin", "superadmin"). +""" + +from fastapi import Depends, HTTPException, Request, status +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.dto import AuthenticatedUser +from gnexus_gauth.runtime import HttpRuntimeUserProvider + +from app.config import Settings, get_settings + +ADMIN_ROLES = ("admin", "superadmin") + +_user_provider: HttpRuntimeUserProvider | None = None + + +def _get_user_provider(settings: Settings) -> HttpRuntimeUserProvider: + """Один провайдер на процесс (внутри — свой httpx.Client).""" + global _user_provider + if _user_provider is None: + config = GAuthConfig( + base_url=settings.gauth_base_url, + client_id=settings.gauth_client_id, + client_secret=settings.gauth_client_secret, + redirect_uri=settings.gauth_redirect_uri, + ) + _user_provider = HttpRuntimeUserProvider(config) + return _user_provider + + +def get_current_user( + request: Request, + settings: Settings = Depends(get_settings), +) -> AuthenticatedUser: + """Валидирует Bearer-токен через userinfo gnexus-auth.""" + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Требуется Bearer-токен gnexus-auth", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + # TODO: короткий TTL-кэш userinfo по токену (до 60 c) — после появления нагрузки. + return _get_user_provider(settings).fetch_user(auth.removeprefix("Bearer ")) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Токен невалиден или истёк", + headers={"WWW-Authenticate": "Bearer"}, + ) from None + + +def require_admin( + user: AuthenticatedUser = Depends(get_current_user), +) -> AuthenticatedUser: + """Гейт админки: system_role admin или выше, остальным — 403.""" + if (user.system_role or "") not in ADMIN_ROLES: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав: админ-панель доступна роли admin и выше", + ) + return user \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..5f0aa07 --- /dev/null +++ b/app/config.py @@ -0,0 +1,38 @@ +"""Настройки Synapse. Всё из окружения (.env), никаких секретов в коде.""" + +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + +BASE_DIR = Path(__file__).resolve().parent.parent + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + app_name: str = "Gnexus Synapse" + + database_url: str = "postgresql+psycopg://synapse:synapse@localhost:5432/synapse" + redis_url: str = "redis://localhost:6379/0" + + # gnexus-auth (SSO). Пока клиент не зарегистрирован на сервере auth, + # здесь заглушки (не пустые — валидатор GAuthConfig требует непустые). + gauth_base_url: str = "https://192.168.1.167" + gauth_client_id: str = "set-me" + gauth_client_secret: str = "set-me" + gauth_redirect_uri: str = "https://synapse.localhost/oauth/callback" + + # SPA лежит в образе рядом с приложением; в dev используется vite-сервер. + spa_dist_dir: Path = BASE_DIR / "spa_static" + + # CORS для dev-режима (vite :5173); в проде SPA раздаётся с того же origin. + cors_origins: list[str] = ["http://localhost:5173"] + + def gauth_configured(self) -> bool: + return self.gauth_client_id != "set-me" and self.gauth_client_secret != "set-me" + + +@lru_cache +def get_settings() -> Settings: + return Settings() \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..2667653 --- /dev/null +++ b/app/database.py @@ -0,0 +1,25 @@ +"""SQLAlchemy: движок, сессии, базовый класс.""" + +from collections.abc import Iterator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import get_settings + +# pool_pre_ping — чтобы соединения не умирали молча после рестарта Postgres-контейнера. +engine = create_engine(get_settings().database_url, pool_pre_ping=True, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + +class Base(DeclarativeBase): + """Базовый класс всех ORM-моделей.""" + + +def get_db() -> Iterator[Session]: + """FastAPI-зависимость: сессия БД на время запроса.""" + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..38902a5 --- /dev/null +++ b/app/main.py @@ -0,0 +1,44 @@ +"""Точка входа FastAPI: API + раздача собранной SPA-статики с того же контейнера.""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app.api.routes import router as api_router +from app.config import get_settings +from app.worker.celery_app import celery_app # noqa: F401 — регистрирует задачи в очереди import'ов + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI(title=settings.app_name, docs_url="/api/docs", openapi_url="/api/openapi.json") + + # В проде SPA с того же origin, CORS нужен только dev-серверу vite. + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(api_router) + + # Собранная SPA: в Docker-образе кладётся в spa_static/. Если собранной + # статики нет (локальный запуск api без фронта) — просто пропускаем. + spa = settings.spa_dist_dir + if (spa / "index.html").exists(): + app.mount("/", StaticFiles(directory=spa, html=True), name="spa") + + @app.get("/") + def root() -> dict: + return { + "service": settings.app_name, + "docs": "/api/docs", + "health": "/api/healthz", + } + + return app + + +app = create_app() \ No newline at end of file diff --git a/app/worker/__init__.py b/app/worker/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/app/worker/__init__.py diff --git a/app/worker/celery_app.py b/app/worker/celery_app.py new file mode 100644 index 0000000..4de2145 --- /dev/null +++ b/app/worker/celery_app.py @@ -0,0 +1,22 @@ +"""Celery: приложение воркера и каркасные задачи.""" + +from celery import Celery + +from app.config import get_settings + +settings = get_settings() + +celery_app = Celery( + "synapse", + broker=settings.redis_url, + backend=settings.redis_url, + include=["app.worker.tasks"], +) + +celery_app.conf.update( + task_default_queue="synapse", + task_track_started=True, + # TODO(#29): политика ретраев и таймаутов — уточнить при реализации доставок. + task_acks_late=True, + worker_prefetch_multiplier=1, +) \ No newline at end of file diff --git a/app/worker/tasks.py b/app/worker/tasks.py new file mode 100644 index 0000000..c03b46d --- /dev/null +++ b/app/worker/tasks.py @@ -0,0 +1,11 @@ +"""Задачи воркера. Пока каркасная ping — на её месте будут доставки.""" + +from celery import shared_task + +from app.worker.celery_app import celery_app + + +@celery_app.task(name="synapse.ping") +def ping() -> str: + """Проверка связки api -> очередь -> воркер.""" + return "pong" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a725bc4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +# Gnexus Synapse — всё поднимается одной командой: docker compose up -d --build +# Postgres и Redis едут в докере вместе с приложением, отдельного продового сервера БД нет. + +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-synapse} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?задайте POSTGRES_PASSWORD в .env} + POSTGRES_DB: ${POSTGRES_DB:-synapse} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-synapse} -d ${POSTGRES_DB:-synapse}"] + interval: 5s + timeout: 3s + retries: 12 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 12 + + api: + build: . + restart: unless-stopped + env_file: .env + environment: + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-synapse}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-synapse}} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + ports: + - "${SYNAPSE_PORT:-8000}:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + worker: + build: . + restart: unless-stopped + env_file: .env + environment: + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-synapse}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-synapse}} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + command: ["worker"] + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + +volumes: + pgdata: + redisdata: \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..89605f7 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# entrypoint: режим api — миграции + uvicorn; режим worker — celery-воркер. +set -e + +case "$1" in + api) + echo "[synapse] применяю миграции..." + alembic upgrade head + exec uvicorn app.main:app --host 0.0.0.0 --port 8000 + ;; + worker) + echo "[synapse] запускаю celery-воркер..." + exec celery -A app.worker.celery_app worker --loglevel=info + ;; + *) + exec "$@" + ;; +esac \ No newline at end of file diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..5399828 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1,2 @@ +# Киты Gnexus идут из git-репозиториев (в npm-registry их нет). +allow-git=git.gnexus.space \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..df267d2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Synapse Admin + + +
+ + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..534e724 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1301 @@ +{ + "name": "synapse-admin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "synapse-admin", + "version": "0.1.0", + "dependencies": { + "gnexus-ui-kit": "git+https://git.gnexus.space/git/root/gnexus-ui-kit.git", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.0.tgz", + "integrity": "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.0.tgz", + "integrity": "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.0.tgz", + "integrity": "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.0.tgz", + "integrity": "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.0.tgz", + "integrity": "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.0.tgz", + "integrity": "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.0.tgz", + "integrity": "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.0.tgz", + "integrity": "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.0.tgz", + "integrity": "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.0.tgz", + "integrity": "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.0.tgz", + "integrity": "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.0.tgz", + "integrity": "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.0.tgz", + "integrity": "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.0.tgz", + "integrity": "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.0.tgz", + "integrity": "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.0.tgz", + "integrity": "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.0.tgz", + "integrity": "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.0.tgz", + "integrity": "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.0.tgz", + "integrity": "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.0.tgz", + "integrity": "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.0.tgz", + "integrity": "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.0.tgz", + "integrity": "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.0.tgz", + "integrity": "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.0.tgz", + "integrity": "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.0.tgz", + "integrity": "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gnexus-ui-kit": { + "version": "1.0.0", + "resolved": "git+https://git.gnexus.space/git/root/gnexus-ui-kit.git#e28f382b7bbf405f9f33e9f5d5b53ad64c1aabf3", + "license": "UNLICENSED", + "peerDependencies": { + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.0.tgz", + "integrity": "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.0", + "@rollup/rollup-android-arm64": "4.64.0", + "@rollup/rollup-darwin-arm64": "4.64.0", + "@rollup/rollup-darwin-x64": "4.64.0", + "@rollup/rollup-freebsd-arm64": "4.64.0", + "@rollup/rollup-freebsd-x64": "4.64.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.0", + "@rollup/rollup-linux-arm-musleabihf": "4.64.0", + "@rollup/rollup-linux-arm64-gnu": "4.64.0", + "@rollup/rollup-linux-arm64-musl": "4.64.0", + "@rollup/rollup-linux-loong64-gnu": "4.64.0", + "@rollup/rollup-linux-loong64-musl": "4.64.0", + "@rollup/rollup-linux-ppc64-gnu": "4.64.0", + "@rollup/rollup-linux-ppc64-musl": "4.64.0", + "@rollup/rollup-linux-riscv64-gnu": "4.64.0", + "@rollup/rollup-linux-riscv64-musl": "4.64.0", + "@rollup/rollup-linux-s390x-gnu": "4.64.0", + "@rollup/rollup-linux-x64-gnu": "4.64.0", + "@rollup/rollup-linux-x64-musl": "4.64.0", + "@rollup/rollup-openbsd-x64": "4.64.0", + "@rollup/rollup-openharmony-arm64": "4.64.0", + "@rollup/rollup-win32-arm64-msvc": "4.64.0", + "@rollup/rollup-win32-ia32-msvc": "4.64.0", + "@rollup/rollup-win32-x64-gnu": "4.64.0", + "@rollup/rollup-win32-x64-msvc": "4.64.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..69541ac --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "synapse-admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "gnexus-ui-kit": "git+https://git.gnexus.space/git/root/gnexus-ui-kit.git", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.2.0" + }, + "allowScripts": { + "esbuild@0.21.5": true + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..7aa025c --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..7fc4817 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,58 @@ +/** Тонкий клиент к API Synapse. Токен — opaque Bearer от gnexus-auth. */ + +const TOKEN_KEY = "synapse.access_token"; + +export function getToken() { + try { + return localStorage.getItem(TOKEN_KEY) || ""; + } catch { + return ""; + } +} + +export function setToken(token) { + try { + localStorage.setItem(TOKEN_KEY, token); + } catch { + /* приватный режим — жить без сохранения сессии */ + } +} + +export function clearToken() { + try { + localStorage.removeItem(TOKEN_KEY); + } catch { + /* игнорируем */ + } +} + +async function apiFetch(path, options = {}) { + const headers = { ...(options.headers || {}) }; + const token = getToken(); + if (token) headers.Authorization = `Bearer ${token}`; + if (options.body) headers["Content-Type"] = "application/json"; + + const response = await fetch(path, { ...options, headers }); + if (!response.ok) { + let detail = response.statusText; + try { + const payload = await response.json(); + if (payload.detail) detail = payload.detail; + } catch { + /* не JSON — берём statusText */ + } + const error = new Error(detail); + error.status = response.status; + throw error; + } + return response.json(); +} + +export function healthz() { + return apiFetch("/api/healthz"); +} + +/** Профиль текущего пользователя. 401 — токена нет/невалиден, 403 — роль ниже admin. */ +export function adminMe() { + return apiFetch("/api/v1/admin/me"); +} \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..faf7b1a --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,8 @@ +import { createApp } from "vue"; +import { GnexusUiVue } from "gnexus-ui-kit/vue"; +import "gnexus-ui-kit/dist/css/kit.css"; +import "gnexus-ui-kit/dist/assets/fonts/phosphor-icons/src/css/icons.css"; +import App from "./App.vue"; +import { router } from "./router.js"; + +createApp(App).use(GnexusUiVue).use(router).mount("#app"); \ No newline at end of file diff --git a/frontend/src/router.js b/frontend/src/router.js new file mode 100644 index 0000000..e50258d --- /dev/null +++ b/frontend/src/router.js @@ -0,0 +1,30 @@ +import { createRouter, createWebHistory } from "vue-router"; +import { getToken } from "./api.js"; +import LoginView from "./views/LoginView.vue"; +import DashboardView from "./views/DashboardView.vue"; +import DeniedView from "./views/DeniedView.vue"; + +// Гейт ролей: /dashboard и далее — только system_role admin и выше. +// Проверка роли происходит на сервере (require_admin), фронт лишь заранее +// разводит потоки 401 (нет токена -> login) и 403 (роль ниже admin -> denied). +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/", redirect: "/dashboard" }, + { path: "/login", component: LoginView }, + { path: "/denied", component: DeniedView }, + { + path: "/dashboard", + component: DashboardView, + meta: { requiresAuth: true }, + }, + ], +}); + +router.beforeEach((to) => { + if (to.meta.requiresAuth && !getToken()) { + return { path: "/login", query: { next: to.fullPath } }; + } +}); + +export { router }; \ No newline at end of file diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue new file mode 100644 index 0000000..ad26c13 --- /dev/null +++ b/frontend/src/views/DashboardView.vue @@ -0,0 +1,54 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/DeniedView.vue b/frontend/src/views/DeniedView.vue new file mode 100644 index 0000000..e8274cf --- /dev/null +++ b/frontend/src/views/DeniedView.vue @@ -0,0 +1,36 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..ef4217a --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,95 @@ + + + + + \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..696977b --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + // dev: API ходит на локальный FastAPI без CORS-шума + proxy: { + "/api": { target: "http://localhost:8000", changeOrigin: true }, + }, + }, + build: { + outDir: "dist", + }, +}); \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d6daea7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +gunicorn>=22.0 +sqlalchemy>=2.0 +alembic>=1.13 +celery[redis]>=5.4 +redis>=5.0 +pydantic>=2.7 +pydantic-settings>=2.3 +psycopg[binary]>=3.1 +httpx>=0.27 +gnexus-gauth @ git+https://git.gnexus.space/git/root/gnexus-auth-client-py.git \ No newline at end of file