commit 18bd05bcbb1f66b107880d666053667d8e37ee9b Author: Ivan Vasilev Date: Wed Aug 26 20:39:37 2026 +0300 ma7engine: ядро, шаблон панели, скаффолдер и доктор Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..177eebd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Что не должно попадать в контекст сборки. +node_modules +**/node_modules +.git +.DS_Store +*.log + +# Локальная сборка фронта: в образе он собирается заново. +frontend/dist + +# Вендоренная копия ядра здесь НЕ исключается и исключаться не должна: в образ +# она обязана приехать вместе с исходниками, иначе сборка встанет на импорте +# @core. Ровно эту ошибку проверяет `npm run doctor`. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b56201 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +.DS_Store + +# Сборка шаблона +frontend/dist + +# Вендоренная копия ядра в шаблоне (npm run update) +frontend/src/core +backend/core + +# Доктор в шаблоне — копия scripts/doctor.mjs, её кладёт тот же update.sh. +# В настоящей панели он, наоборот, коммитится: там ma7engine рядом может не быть. +frontend/scripts/doctor.mjs diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..67dc814 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1 + +# Панель — один процесс и один образ: express отдаёт и API, и собранный фронт +# (core/server/spa.ts), отдельный nginx ей не нужен. +# +# Контекст сборки — каталог самой панели, и ничего выше него не требуется: +# вендоренная копия ядра закоммичена вместе с исходниками, поэтому образ +# собирается из чистого checkout'а, без ma7engine рядом. Обновляется копия +# отдельно и осознанно — `npm run update` во фронте, диффом в ревью. +# +# docker build -t admin-hub . +# docker run -p 8080:8080 -e ADMIN_PASS=… -e ADMIN_JWT_SECRET=… admin-hub + +# ─── фронт: собирается и остаётся только как dist ─────────────────────────── +FROM node:24-alpine AS frontend +WORKDIR /app/frontend + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +# prebuild зовёт update.sh. Ядра рядом в образе нет, и скрипт штатно берёт уже +# синхронизированную копию из src/core — это его нормальная ветка, не ошибка. +RUN npm run build + +# ─── рантайм: бэкенд плюс собранный фронт ─────────────────────────────────── +FROM node:24-alpine AS runtime +WORKDIR /app/backend + +COPY backend/package.json backend/package-lock.json ./ +# tsx лежит в dependencies намеренно: сервер и в деве, и в образе стартует одной +# и той же командой. Компиляция в JS дала бы образ меньше, но развела бы дев и +# прод, а в этой панели пути и команды всюду одинаковы — на том же основании +# ядро ездит копией, а не алиасом. +RUN npm ci --omit=dev + +COPY backend/ ./ +# staticDir по умолчанию — ../frontend/dist от каталога сервера. +COPY --from=frontend /app/frontend/dist /app/frontend/dist + +ENV NODE_ENV=production +ENV PORT=8080 +EXPOSE 8080 + +# NODE_ENV=production включает разбор X-Forwarded-For в рейт-лимите: за прокси +# без него все клиенты схлопываются в один ключ и блокируют вход всем сразу. +# +# ADMIN_PASS (или ADMIN_PASS_HASH) и ADMIN_JWT_SECRET значений по умолчанию не +# имеют — без них процесс не поднимется. Это не недосмотр: тихо работающая +# панель с паролем по умолчанию хуже не стартовавшей. +USER node + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- "http://127.0.0.1:${PORT}/" >/dev/null 2>&1 || exit 1 + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ee01d8 --- /dev/null +++ b/README.md @@ -0,0 +1,492 @@ +# ma7engine + +Общее ядро админ-панелей: оболочка, реестр вкладок и дизайн-система. Родилось из +трёх форков одного каркаса (`admin-hub`, `admin-ma7`, `awg-ui`), где ~3600 строк +жили в трёх копиях: 77 из 78 CSS-правил и 194 из 197 строк `App.tsx` совпадали +побайтово. + +``` +core/ ядро; половины разъезжаются по панели врозь + browser/ браузер → <панель>/frontend/src/core + shell/ — логин, тема, drawer/сайдбар, снэкбар + shell/styles.css — дизайн-система (публичный контракт классов) + tabs/ buildTabs() — валидация манифестов, порядок, права, категории + http/ apiFetch, errText + utils/ bytes, timeAgo, fmtDateTime, copyText, downloadFile + auth/ hasPermission, OWNER_PERMISSION, типы сессии и PageProps + icons/ 10 иконок, совпадавших во всех панелях + + server/ node → <панель>/backend/core + index.ts createPanelServer() — сборка всего в правильном порядке + ratelimit.ts лимит попыток логина по IP (с разбором X-Forwarded-For) + password.ts сравнение за постоянное время, scrypt-хэш, fail-closed + session.ts выпуск/проверка JWT, отзыв токена + auth.ts requireAuth, requirePermission + spa.ts раздача собранного фронта с fallback + errors.ts проброс статуса и текста ошибки вышестоящего API + +frontend/ шаблон панели: конфиг + четыре вкладки +backend/server.ts шаблон бэкенда: конфиг + три демо-маршрута +scripts/update.sh копирует обе половины ядра в панель (npm run update) +scripts/new-panel.mjs заводит новую панель из шаблона (npm run new-panel) +scripts/doctor.mjs проверяет, что панель по стандарту (npm run doctor) +``` + +Половины лежат врозь не по вкусу, а потому что это две разные сборки: у них +разные tsconfig (`lib: DOM` + JSX + резолв бандлера против `lib: ES2020` + +CommonJS), разные адресаты копии и разные зависимости — в `core/browser` +импортируются только `react` и `axios`, в `core/server` — только `express`, +`jsonwebtoken` и `node:*`. Пересечение — ноль файлов. + +## Что уже общее + +Всё перечисленное панель получает готовым и у себя не пишет. Список +исчерпывающий: чего в нём нет — того нет и в ядре, значит это продуктовый код +конкретной панели. + +### Оболочка + +`` — один и тот же код у панели с ролями и без, разница +только в конфиге: + +- **Вход.** Форма логина с брендингом, `POST /login`, разбор ответа: 401 — + «Неверный логин или пароль», 429 — текст лимита, пришедший с сервера, + остальное — «Сервер недоступен». +- **Сессия.** Токен кладётся в `localStorage` и восстанавливается при загрузке; + протухший стирается молча, панель показывает логин. +- **Единый разлогин по 401.** Любой `apiFetch` из любой вкладки, получивший 401, + разлогинивает панель целиком — вкладки этот случай не обрабатывают. +- **Выход.** `POST /logout` (сервер отзывает токен), очистка стораджа и + состояния. +- **Тема.** Кнопка-переключатель, `data-theme` на ``, запоминание выбора. +- **Меню.** Строится из вкладок, доступных правам текущей учётки, сгруппированных + по категориям; пустой список даёт заглушку «вкладок пока нет» или «ни одна + вкладка не доступна твоим правам». +- **Мобильный режим.** Топ-бар с гамбургером, drawer с затемнением, закрытие по + Escape, по клику в затемнение и свайпом влево, блокировка прокрутки фона. +- **Снэкбар.** `showMsg(text)` — сообщение на 3 секунды, общий для вкладок и + слотов. +- **Слоты.** `aboveTab` и `headerExtra` рисуются, если заданы в конфиге. Слот + получает активную вкладку в `ctx.tab` и сам решает, где не рисоваться. +- **Пропсы вкладки.** `token`, `showMsg`, `username`, `userId`, `role`, + `permissions`, `availablePermissions`, `onNavigate`. + +Дефолты, когда конфиг молчит: ключи `admin_token` и `admin_theme`, +`POST /login`, `POST /logout`, `GET /api/session`. + +### Браузерные модули + +| модуль | что даёт | +|---|---| +| `@core/shell` | `Panel`; типы `PanelConfig`, `SlotContext` | +| `@core/tabs` | `buildTabs()`; типы `TabMeta`, `TabDef`, `TabGroup`, `BuiltTabs` | +| `@core/auth` | `hasPermission()`, `OWNER_PERMISSION`, `EMPTY_SESSION`; типы `Session`, `BasePageProps`, `SessionPageProps` | +| `@core/http` | `apiFetch()`, `errText()`, `setUnauthorizedHandler()` | +| `@core/utils` | `fmtDateTime()`, `timeAgo()`, `bytes()`, `downloadFile()`, `copyText()` | +| `@core/icons` | хром `IcoMenu`, `IcoLogout`, `IcoSun`, `IcoMoon`; карта `coreIcons` (`key`, `globe`); отдельные `IcoPlus`, `IcoTrash`, `IcoRefresh`, `IcoCopy`, `IcoGlobe`, `IcoKey` | + +Плюс дизайн-система целиком: `core/browser/shell/styles.css` — 28 токенов палитры на `:root` +с переопределением на `:root[data-theme="dark"]` и контракт классов (см. +«Контракт CSS-классов»). Импортит её сама оболочка; локальный `App.css` +подключается после. + +### Серверные модули + +| модуль | что даёт | +|---|---| +| `createPanelServer()` | сборка всего в фиксированном порядке: json → логин/сессия → маршруты панели → статика с fallback | +| `createLoginRateLimit()`, `clientIp()` | 5 попыток с IP за 15 минут, с разбором `X-Forwarded-For` в проде | +| `createPasswordCheck()`, `hashPassword()`, `timingSafeEquals()` | сравнение за постоянное время, `scrypt`-хэш, fail-closed при старте | +| `createSessionStore()` | выпуск и проверка JWT (TTL 12 ч), отзыв токена | +| `createAuth()`, `bearerToken()` | `requireAuth`, `requirePermission(perm)` | +| `serveSpa()` | раздача `frontend/dist` с fallback; неизвестный `/api/*` — JSON-404, а не `index.html` | +| `forwardError()` | проброс статуса и текста ошибки вышестоящего API | + +Чем эти модули строже того, что было в панелях по отдельности, — в разделе +«Что ядро делает строже панелей». + +### Чего в списке нет + +Три вещи у панелей разные и общими не станут: **конфиг** (`PanelConfig` — имя, +версия, ключи стораджа, эндпоинты, `loadSession`, слоты), **продуктовый код** +(вкладки, свои типы и утилиты, карта `ICONS`, маршруты бэкенда) и **дельта +стилей** в `App.css`. Где проходит граница — в разделе «Что в ядро не идёт». + +## Базовая форма — панель без ролей + +Ядро по умолчанию — плоский список вкладок, доступных всем: `category`, +`permission` и `devOnly` опциональны. Панель с правами получается включением +слоя, а не наоборот. Это не косметика: панель, которой права не нужны, не тащит +их машинерию и не обязана реализовывать эндпоинт сессии. + +## Шаблон новой панели + +Ядро здесь же и шаблон: `frontend/` и `backend/` в корне — рабочая минимальная +панель на этом самом ядре, а не отдельный пример, который живёт своей жизнью. +Ломающая правка ядра ломает и её, и это видно сразу. + +### Запуск демо + +Нужен Node 20+ (проверялось на 22). Одна команда — **из каталога `ma7engine`**, +не из каталога, где он лежит: + +```bash +cd ma7engine # у меня это ~/Documents/admins/ma7engine +npm run demo +``` + +Скрипт при первом запуске сам поставит зависимости обеих половин, поднимет +бэкенд на `:8080` и Vite на `:5173`, а по Ctrl+C погасит оба. Открывать нужно +**`http://localhost:5173`**: Vite отдаёт фронт и проксирует `/login`, `/logout` +и `/api` на бэкенд, поэтому порт нигде не зашит. Логин — `admin` / `demo` +(переопределяется: `ADMIN_USER=root ADMIN_PASS=secret npm run demo`). + +Если половины нужны врозь — два терминала, полные пути, потому что `npm start` +терминал не возвращает: + +```bash +# терминал 1 +cd ma7engine/backend && npm i && ADMIN_PASS=demo ADMIN_JWT_SECRET=dev-secret npm start + +# терминал 2 +cd ma7engine/frontend && npm i && npm run dev +``` + +Прод-режим — одним процессом, без Vite: + +```bash +cd frontend && npm run build # → frontend/dist +cd backend && npm start # :8080 отдаёт и API, и собранный фронт +``` + +Ядро в шаблон приезжает копией, как в любую панель: `npm run update` кладёт +`core/browser` в `frontend/src/core`, а `core/server` — в `backend/core`. Руками +его звать не нужно, он вызывается сам перед `npm run dev` и `npm run build`, +поэтому правка в ядре подхватывается следующим запуском. + +**Что может пойти не так** + +| симптом | причина | +|---|---| +| `пароль панели не задан` при старте | не передан `ADMIN_PASS` — так и задумано, fail-closed | +| `не задан секрет подписи сессий` | не передан `ADMIN_JWT_SECRET` | +| `в frontend/dist нет index.html` | фронт не собран. Для дева это норма — открывай `:5173` | +| порт занят | `PORT=8081 npm start` и `VITE_PROXY_TARGET=http://localhost:8081 npm run dev` | +| `Could not read package.json` | команда запущена не из `ma7engine`, а из каталога уровнем выше | +| логин отвечает 429 | сработал рейт-лимит: 5 неудачных попыток с IP за 15 минут. Перезапусти бэкенд — счётчик в памяти | + + +Четыре вкладки: **«Инструкция»** — как устроен код панели: что где лежит, как +добавить вкладку, что откуда импортировать, скелет вкладки и маршрута; +**«Кто я»** — без `permission`, видна всем залогиненным, ходит в +`/api/demo/whoami`; **«Секреты»** — закрыта правом `secrets.view`; **«Витрина»** — +живой справочник дизайн-системы: все классы публичного контракта с подписями. +Первая и последняя ничего не грузят и открываются без бэкенда. Переименовал +класс в ядре — увидел это на витрине. + +Заодно показано: свой класс в `App.css` и как (не) трогать палитру, расширение +карты иконок (`{ ...coreIcons, star: IcoStar }`), проброс ошибки вышестоящего API. +Чтобы увидеть гейт по правам в работе, задай в `backend/server.ts` +`claims: { permissions: [] }` — вкладка «Секреты» исчезнет из меню, а её маршрут +начнёт отвечать 403. + +### Как из него сделать свою панель + +Одной командой: + +```sh +npm run new-panel -- admin-hub # → ../admin-hub, соседом ma7engine +npm run new-panel -- admin-hub ~/work/hub # явный путь +``` + +Скаффолдер копирует **`frontend/` и `backend/`**, но не `core/`: ядро в панель +приезжает копией через `npm run update`, а не форком. Панель кладётся соседом +ma7engine — именно там её найдёт перебор кандидатов в `update.sh`; переехав, она +найдёт ядро по `$MA7ENGINE`. + +Что он делает за тебя — ровно то, что раньше правилось руками и потому забывалось: + +1. **Имена** в `package.json` обеих половин. +2. **Брендинг и ключи `localStorage`** в `App.tsx` — `admin-hub` даёт + `admin_hub_token` и `admin_hub_theme`. Забытые ключи не абстрактны: две + панели на одном домене затирают друг другу токен и тему. +3. **Демо-часть**: вкладки `hello` и `secrets` и маршруты `/api/demo/*` уезжают, + `mountRoutes` остаётся пустым с образцом в комментарии. `docs` и `kit` + остаются — обе самодостаточны, и панель запускается непустой. +4. **`.gitignore`** и `scripts/dev.sh` (обе половины одной командой). + +Остаётся только **учётка и секрет** — `ADMIN_USER`, `ADMIN_PASS` (или +`ADMIN_PASS_HASH`), `ADMIN_JWT_SECRET` в окружении бэкенда; без них ядро не даст +процессу подняться. + +### Проверка соответствия — `npm run doctor` + +Скаффолдер заводит панель по стандарту, доктор отвечает, соответствует ли она +ему до сих пор: + +```sh +cd <панель>/frontend && npm run doctor +node scripts/doctor.mjs ../admin-hub # из ma7engine, по чужому пути +``` + +**Ошибки** (выход 1) — то, из-за чего панель соберётся неправильно или не +соберётся вовсе: пропал `update.sh` или `predev`/`prebuild`; разъехались алиасы +`@core` в `tsconfig.json` и `vite.config.ts`; `core` не закрыт `.gitignore` или, +наоборот, исключён `.dockerignore` (тогда образ соберётся без ядра); у вкладки +нет `metadata.json`, дублируется `id`, иконка не в карте `ICONS`; в бэкенде нет +`express` или `jsonwebtoken`. + +Главная проверка — **сверка вендоренной копии с ядром по хэшу дерева**: она +ловит правку `src/core/` руками, то есть ровно ту первопричину, из-за которой +три панели когда-то и разъехались. Ядро ищется тем же перебором, что в +`update.sh`; не найдено — проверка честно печатается как пропущенная. + +**Предупреждения** (выход 0, чтобы косметика не ломала CI) — следы шаблона: +`demo_token`, `Demo Panel`, имена `ma7engine-example-*`, разъехавшиеся между +половинами `typescript` и `@types/node`. На самом шаблоне они подавлены. + +Доктор едет в панель вместе с ядром — его кладёт `update.sh` рядом с собой, так +что проверки в панели всегда те же, что в ядре. + +## Подключение + +Это про панель, которая уже есть; новая начинается с шаблона выше. + +Ядро вендорится в `src/core` потребителя и подключается алиасом. Копия, а не +алиас на соседнюю папку, потому что контекст сборки образа панели — её +собственный каталог (`COPY frontend/ ./`), и путь выше него внутри контейнера не +разрешится. При копии `./src/core` одинаков в деве, в `tsc --noEmit` и в Docker, +и Dockerfile трогать не нужно. + +```bash +cp <путь>/ma7engine/scripts/update.sh admin/frontend/scripts/ +cd admin/frontend && npm run update # или sh scripts/update.sh +``` + +```jsonc +// package.json — обновление руками и перед каждым дев-запуском и сборкой +"scripts": { + "update": "sh scripts/update.sh", + "predev": "npm run update", + "prebuild": "npm run update" +} +``` + +```jsonc +// tsconfig.json +"baseUrl": ".", +"paths": { + "@core/*": ["./src/core/*"], // ядро + "@/*": ["./src/*"] // свой код панели +} +``` +```ts +// vite.config.ts +resolve: { alias: { + '@core': path.resolve(__dirname, 'src/core'), + '@': path.resolve(__dirname, 'src'), +} } +``` + +Второй алиас — не косметика: вкладка объявлена самодостаточной папкой, но с +`../../lib/shared` внутри она не переносится между панелями и ломается при +изменении вложенности. Конфликта между `@` и `@core` нет ни в Vite, ни в +TypeScript: rollup-алиас матчит строку как `id === key || id.startsWith(key + '/')`, +а `paths` выбирает самый длинный подходящий префикс. + +**Внутри самого ядра алиасов быть не должно** — оно импортит себя относительными +путями (`./session`, `../auth`). Иначе вендоренная копия потянет за собой +требование к настройке потребителя, и «скопировал папку — работает» перестанет +быть правдой. + +`src/core/` (и `backend/core/`) — в `.gitignore`, но **не** в `.dockerignore`: в +образ они должны приехать вместе с остальными исходниками. Путь к ядру берётся +из `$MA7ENGINE`, иначе перебором кандидатов от ближнего к дальнему — ближний +выигрывает, чтобы панель не утащила молча старую копию из каталога уровнем выше. +Выбранный путь печатается. + +Серверная половина кладётся в `<панель>/backend/core`. Панель из одной папки, где +сервер лежит рядом с фронтом (`server.ts` в корне, как в `awg-ui`), получает её в +`<панель>/core`. + +В контейнере ядра рядом нет — и это норма: `update.sh` видит уже +синхронизированную копию, печатает это и выходит с нулём, поэтому `prebuild` +внутри образа не падает. + +Позже это меняется на настоящую зависимость без правки импортов: спецификаторы +`@core/...` остаются, меняется только строка резолва. + +## Точка сборки панели + +```tsx +import Panel, { type PanelConfig } from '@core/shell'; +import './App.css'; // локальные стили — после стилей ядра +import * as tabs from './tabs'; + +const config: PanelConfig = { + tabs, + branding: { name: 'MA7 Admin', version: '0.1.0' }, + storageKeys: { token: 'admin_token', theme: 'admin_theme' }, + // loadSession — по умолчанию GET /api/session; панель без ролей отдаёт + // отсюда permissions: ['*'] + // slots — свой хром: aboveTab (над вкладкой), headerExtra (в углу шапки) +}; + +export default () => ; +``` + +## Реестр вкладок + +`import.meta.glob` обязан остаться в приложении: это compile-time фича Vite, её +паттерн резолвится относительно файла, где написан, — уехав в ядро, он сканировал +бы ядро, а не `src/tabs/` приложения. Поэтому в приложении остаётся 8 строк: + +```ts +import { buildTabs } from '@core/tabs'; +import { ICONS } from '../components/icons'; + +export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs( + import.meta.glob<{ default: unknown }>('./*/metadata.json', { eager: true }), + import.meta.glob<{ default: any }>('./*/index.tsx', { eager: true }), + ICONS, +); +``` + +`metadata.json` компилятором не проверяется, поэтому валидируется в рантайме — +кривой манифест падает с внятной ошибкой при сборке `TABS`, а не тихо в UI: + +| поле | обяз. | смысл | +|---|---|---| +| `id` | да | ключ активной вкладки, должен быть уникален | +| `label` | да | подпись в меню | +| `icon` | да | имя в карте `ICONS` | +| `category` | нет | группа в меню; без неё — плоский список | +| `permission` | нет | право доступа; без него видна всем залогиненным | +| `order` | нет | порядок по возрастанию, по умолчанию 0 | +| `devOnly` | нет | вырезается из prod-сборки (`import.meta.env.PROD`) | + +## Иконки + +`coreIcons` — только иконки **вкладок** (`key`, `globe`). Хром оболочки +(`IcoMenu`, `IcoLogout`, `IcoSun`, `IcoMoon`) — именованные экспорты, через карту +не ищется. Приложение расширяет и перекрывает: `{ ...coreIcons, ...myIcons }`. + +## Контракт CSS-классов + +Имена классов из `core/browser/shell/styles.css` — публичный API: на них опирается код вкладок +во всех панелях, поэтому переименование любого из них ломающее. Полный список — в +шапке самого файла. Коротко: `.btn` с вариантами, `.field`, `.table-card`, +`.chip`, `.toolbar`, `.modal-*`, `.actions`, `.empty`, `.td-mono`, `.td-num`. + +Цвета — только через токены `var(--…)`; палитра задаётся на `:root` и +переопределяется на `:root[data-theme="dark"]`. + +## Что в ядро не идёт + +Брендинг (это поле конфига), карта `ICONS` целиком, продуктовые типы и утилиты, +вкладки, бэкенды. Компонент, нужный **одной** панели, остаётся в ней: обобщать по +одному потребителю рано. Понадобился второй — тогда и переезжает. + +## Сервер + +```ts +import { createPanelServer } from './core'; + +const app = createPanelServer({ + auth: { + user: process.env.ADMIN_USER ?? 'admin', + password: process.env.ADMIN_PASS, // либо passwordHash + secret: process.env.ADMIN_JWT_SECRET ?? '', + revocable: true, // логаут реально отзывает токен + }, + staticDir: '../frontend/dist', + mountRoutes(app, { auth, forwardError }) { + app.get('/api/x', auth.requireAuth, auth.requirePermission('x.view'), handler); + }, +}); +app.listen(8080); +``` + +Порядок монтирования фиксирован внутри и не может быть нарушен снаружи: +`json` → логин/сессия → маршруты панели → статика с fallback. Fallback ловит +любой GET, поэтому маршрут, зарегистрированный после него, не сработал бы. + +Панель с готовым бэкендом может брать модули по отдельности +(`createSessionStore`, `createAuth`, `createLoginRateLimit`, `serveSpa`) — так +подключён MA7, у которого 38 своих маршрутов уже были написаны. + +### Авторизация из коробки, и панель-прокси + +`createPanelServer` всегда собирается с авторизацией: учётка задаётся в +`auth: { user, password | passwordHash, secret }`, панель сама выпускает и +проверяет токен, `/login`, `/logout` и `/api/session` монтируются готовыми. +Своей авторизации панель не пишет, а `sessions` и `auth` в контексте +гарантированы — проверять их на существование не нужно. + +Панель, у которой собственных учёток нет (логин форвардится наверх, где живут +настоящие учётки), `createPanelServer` не использует — она собирает express +сама из кусков ядра: + +```ts +const app = express(); +const rateLimit = createLoginRateLimit(); + +// Тело JSON не парсим: прокси должен переслать сырой поток запроса как есть. +app.post('/login', rateLimit.middleware, createProxyMiddleware({ … })); +// …остальные прокси-маршруты… + +serveSpa(app, STATIC_DIR); // последним: fallback ловит любой GET +``` + +Рейт-лимит и раздачу фронта наверх не вынести: вышестоящий API не видит IP +клиента панели отдельно от IP самого прокси. + +### Что ядро делает строже панелей + +- **Пароль сравнивается за постоянное время** (SHA-256 + `timingSafeEqual`), а + не через `!==`. Поддержан `scrypt`-хэш — тогда открытого пароля в окружении + нет вовсе, `hashPassword()` его генерирует. +- **Пароль проверяется даже при несовпавшем логине** — иначе по времени ответа + отличается «нет такого пользователя» от «неверный пароль». +- **Fail-closed при старте.** Без пароля или без секрета подписи процесс не + поднимается. Панель, тихо работающая с паролем `admin`, — худший исход, чем + не стартовавшая панель. +- **Рейт-лимит всегда разбирает `X-Forwarded-For`** (в проде; в деве заголовку + доверять нельзя). Панель, бравшая `req.socket.remoteAddress` напрямую, за + nginx схлопывала всех клиентов в один ключ, и пять неудачных попыток кем + угодно блокировали вход всем. +- **Неизвестный `/api/*` отдаёт JSON-404**, а не `index.html` со статусом 200, + на котором клиент падал бы при разборе ответа. + +### Ограничения, о которых надо знать + +Счётчик попыток и список отозванных токенов — в памяти процесса. Они не +переживают рестарт и не общие для нескольких инстансов за балансировщиком. +Для защиты от перебора этого достаточно; для настоящего отзыва на нескольких +инстансах передай своё `revocationStore`. + +`jsonwebtoken` и `express` — peer-зависимости, ставятся в бэкенде панели. + +## Кто на нём сидит + +Три панели, ради которых ядро и появилось. Полезно как три разных ответа на +вопрос «сколько своего остаётся». + +**admin-ma7** — панель с правами и своим бэкендом. Фронт: `App.tsx` — 18 строк +конфига (было 232), реестр вкладок — 20 (было 105), `App.css` — 52 строки +плиток дашборда (было 612, остальное нашлось в ядре). Бэкенд собран +`createPanelServer`, все 38 маршрутов ушли в `mountRoutes`; своя обёртка над +`forwardError` — только ради текста «API MA7 недоступен». + +**admin-hub** — панель с правами, логин делегирует наверх. Фронт: `App.tsx` — +23 строки конфига, реестр — 23, `App.css` **удалён**: все 88 его правил +побайтово совпали с дизайн-системой ядра. Бэкенд берёт `createLoginRateLimit` и +`serveSpa` отдельными модулями, а не через `createPanelServer`: своей +авторизации у него нет вовсе, и тянуть ради неё `jsonwebtoken` незачем. + +**awg-ui** — панель без ролей, живёт одной папкой (сервер рядом с фронтом). +Ролей нет: `loadSession` ходит в `/health` — это одновременно проверка токена и +данные для сервер-бара — и отдаёт `permissions: ['*']`. Свой хром — в слотах: +сервер-бар в `aboveTab`, кнопка перезапуска AWG в `headerExtra`. Своих стилей — +29 правил (было 700 строк). Сервер собран `createPanelServer`; ключи внешнего +API в SQLite и прокси к `awg-ctrl` остались своими. diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..2ba9275 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1629 @@ +{ + "name": "ma7engine-example-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ma7engine-example-backend", + "version": "0.1.0", + "dependencies": { + "express": "^4.19.0", + "jsonwebtoken": "^9.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.0", + "@types/jsonwebtoken": "^9.0.0", + "@types/node": "^22.0.0", + "tsx": "^4.7.0", + "typescript": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..0500935 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,21 @@ +{ + "name": "ma7engine-example-backend", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "tsx server.ts", + "dev": "tsx watch server.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "express": "^4.19.0", + "jsonwebtoken": "^9.0.0", + "tsx": "^4.7.0" + }, + "devDependencies": { + "@types/express": "^4.17.0", + "@types/jsonwebtoken": "^9.0.0", + "@types/node": "^26.3.0", + "typescript": "^5.4.0" + } +} diff --git a/backend/server.ts b/backend/server.ts new file mode 100644 index 0000000..b9aa28e --- /dev/null +++ b/backend/server.ts @@ -0,0 +1,63 @@ +// Шаблон бэкенда панели — с этого начинается новый. Копируется вместе с +// ../frontend; ядро в свою панель приезжает через `npm run update`. +// +// Всё, что здесь есть: конфиг и свои маршруты. Логин, сессия, права, +// рейт-лимит, раздача SPA и порядок их монтирования — в createPanelServer. +// +// Запуск: +// ADMIN_PASS=... ADMIN_JWT_SECRET=... npm start +// +// Панель-прокси, у которой своих учёток нет (авторизация живёт в основном +// продукте), createPanelServer не использует и собирает express сама из кусков +// ядра — см. раздел про панель-прокси в README. +import path from 'node:path'; +import type { Request, Response } from 'express'; +// Ядро — вендоренная копия в ./core: её кладёт `npm run update`, запущенный во +// фронте (он же predev/prebuild). Поэтому express и jsonwebtoken резолвятся из +// backend/node_modules, как в любой панели. +import { createPanelServer } from './core'; + +const PORT = Number(process.env.PORT) || 8080; + +const app = createPanelServer({ + auth: { + user: process.env.ADMIN_USER ?? 'admin', + password: process.env.ADMIN_PASS, + passwordHash: process.env.ADMIN_PASS_HASH, + secret: process.env.ADMIN_JWT_SECRET ?? '', + ttl: '12h', + revocable: true, + // claims не задан — вошедший получает permissions: ['*'], то есть + // видит все вкладки. Появятся учётки с разным доступом — размечать + // здесь. + }, + + // Раскладка как в панелях: <панель>/backend рядом с <панель>/frontend. + staticDir: process.env.STATIC_DIR ?? path.join(__dirname, '..', 'frontend', 'dist'), + + mountRoutes(app, { auth, forwardError }) { + const { requireAuth, requirePermission, getSession } = auth; + + // Открыт любому залогиненному — у вкладки в metadata.json нет permission. + app.get('/api/demo/whoami', requireAuth, (req: Request, res: Response) => { + res.json(getSession(req)); + }); + + // Закрыт правом. Гейт на фронте (permission во вкладке) — это только + // UX; настоящая граница здесь. + app.get('/api/demo/secrets', requireAuth, requirePermission('secrets.view'), + (_req: Request, res: Response) => { + res.json({ items: ['первый', 'второй'] }); + }); + + // Поход в вышестоящий API: его статус и текст пробрасываются клиенту, + // а не превращаются в 500. + app.get('/api/demo/upstream', requireAuth, async (_req: Request, res: Response) => { + try { + throw { response: { status: 404, data: { message: 'Не найдено наверху' } } }; + } catch (e) { forwardError(res, e); } + }); + }, +}); + +app.listen(PORT, () => console.log(`demo-panel: listening on :${PORT}`)); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..04a1424 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["server.ts"] +} diff --git a/core/browser/auth/index.ts b/core/browser/auth/index.ts new file mode 100644 index 0000000..0364008 --- /dev/null +++ b/core/browser/auth/index.ts @@ -0,0 +1,40 @@ +// Слой сессии и прав. Опционален: панель без учёток и ролей (плоский список +// вкладок, доступных всем) обходится BasePageProps и в этот модуль не заходит. + +// "*" в permissions — владелец, доступ ко всему в обход остальных проверок. +export const OWNER_PERMISSION = '*'; + +export function hasPermission(permissions: string[], perm: string): boolean { + return permissions.includes(OWNER_PERMISSION) || permissions.includes(perm); +} + +// Что оболочка узнаёт о вошедшем. Отдаётся эндпоинтом сессии, адрес и форма +// ответа которого задаются в конфиге панели (loadSession). +export interface Session { + user: string | null; + userId: string | null; + role: string; + permissions: string[]; +} + +export const EMPTY_SESSION: Session = { user: null, userId: null, role: '', permissions: [] }; + +// Пропсы вкладки в панели без прав: вкладке нужен только токен и снэкбар. +export interface BasePageProps { + token: string; + showMsg: (text: string) => void; +} + +// Пропсы вкладки в панели с сессией и правами. +export interface SessionPageProps extends BasePageProps { + username: string; + userId: string; + role: string; + permissions: string[]; + // Все permission-строки, встречающиеся у вкладок — нужно только тем вкладкам, + // которые сами выдают права; остальные это поле игнорируют. + availablePermissions: string[]; + // Переключить активную вкладку по её id из metadata.json. Роутера в панели + // нет — активная вкладка это состояние оболочки. + onNavigate: (tabId: string) => void; +} diff --git a/core/browser/http/index.ts b/core/browser/http/index.ts new file mode 100644 index 0000000..bd9bf0c --- /dev/null +++ b/core/browser/http/index.ts @@ -0,0 +1,33 @@ +// Единственная точка выхода вкладок в сеть. Вкладка не создаёт свой axios и не +// собирает заголовок авторизации руками — иначе обработка протухшего токена +// разъезжается по вкладкам. +import axios from 'axios'; + +// Что делать, когда сервер ответил 401. Ставит оболочка при старте: токен +// протух или отозван — надо разлогинить, а не показывать ошибку в снэкбаре. +// Само ядро про localStorage не знает — ключ хранения принадлежит приложению. +let onUnauthorized: (() => void) | null = null; + +export function setUnauthorizedHandler(fn: (() => void) | null) { + onUnauthorized = fn; +} + +export async function apiFetch(method: string, path: string, token: string, body?: object): Promise { + try { + const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body }); + return r.data; + } catch (e) { + if (axios.isAxiosError(e) && e.response?.status === 401) { + onUnauthorized?.(); + } + throw e; + } +} + +// Бэкенды панелей отдают ошибку по-разному: одни как {error}, другие как +// {message}. Вкладки читают через этот хелпер, а не разбирают форму ответа +// в каждом catch. +export function errText(e: unknown, fallback = 'Сервер недоступен'): string { + const data = (e as { response?: { data?: { error?: string; message?: string } } })?.response?.data; + return data?.error ?? data?.message ?? fallback; +} diff --git a/core/browser/icons/index.tsx b/core/browser/icons/index.tsx new file mode 100644 index 0000000..a19b08e --- /dev/null +++ b/core/browser/icons/index.tsx @@ -0,0 +1,79 @@ +// Иконки ядра — только те, что уже совпадали побайтово в панелях-потребителях. +// Продуктовые иконки живут в приложении; карта ICONS собирается там же: +// export const ICONS = { ...coreIcons, ...myIcons }; +// Все иконки — инлайновый SVG на currentColor, без внешних зависимостей. + +export const IcoMenu = () => ( + + + + + +); +export const IcoLogout = () => ( + + + + + +); +export const IcoSun = () => ( + + + + +); +export const IcoMoon = () => ( + + + +); +export const IcoPlus = () => ( + + + +); +export const IcoTrash = () => ( + + + + + +); +export const IcoRefresh = () => ( + + + + +); +export const IcoCopy = () => ( + + + + +); +export const IcoGlobe = () => ( + + + + + +); +export const IcoKey = () => ( + + + + +); + +// Карта иконок ВКЛАДОК — по ней реестр резолвит поле "icon" из metadata.json. +// Сюда попадают только иконки, осмысленные как иконка раздела; хром оболочки +// (menu/logout/sun/moon) оболочка импортирует именованными экспортами и через +// карту никогда не ищет. +// +// Приложение расширяет карту своими и при совпадении ключа перекрывает: +// export const ICONS = { ...coreIcons, ...myIcons }; +export const coreIcons: Record JSX.Element> = { + key: IcoKey, + globe: IcoGlobe, +}; diff --git a/core/browser/shell/index.tsx b/core/browser/shell/index.tsx new file mode 100644 index 0000000..ec514c5 --- /dev/null +++ b/core/browser/shell/index.tsx @@ -0,0 +1,301 @@ +// Оболочка панели: логин, тема, drawer/сайдбар, снэкбар и переключение вкладок. +// Про конкретные вкладки не знает ничего — их приносит реестр из конфига. +// +// Всё продуктовое вынесено в PanelConfig: имя и версия, ключи localStorage, +// адреса эндпоинтов, способ загрузки сессии и слоты для своего хрома. Панель +// без ролей (сессия отдаёт permissions: ['*']) и панель с правами — один и тот +// же код, отличаются только конфигом. +import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'; +import axios from 'axios'; +import './styles.css'; +import { IcoMenu, IcoLogout, IcoSun, IcoMoon } from '../icons'; +import { setUnauthorizedHandler } from '../http'; +import { EMPTY_SESSION, type Session } from '../auth'; +import type { BuiltTabs, TabDef } from '../tabs'; + +// Контекст, который оболочка отдаёт слотам — чтобы свой хром мог ходить в сеть +// и показывать сообщения теми же средствами, что и вкладки. +export interface SlotContext { + token: string; + showMsg: (text: string) => void; + session: Session; + tab: TabDef | null; +} + +export interface PanelConfig { + // Реестр вкладок: результат buildTabs(...) из приложения. Передаётся сюда, + // а не импортируется, потому что import.meta.glob обязан остаться в + // приложении — см. комментарий в tabs/index.ts. + tabs: BuiltTabs; + branding: { name: string; version?: string }; + // Ключи localStorage. Разводятся по панелям, чтобы две панели на одном + // домене не затирали друг другу токен и тему. + storageKeys?: { token?: string; theme?: string }; + endpoints?: { login?: string; logout?: string }; + // Как узнать, жив ли токен, и получить роль с правами. По умолчанию — + // GET /api/session. Панель без ролей отдаёт отсюда permissions: ['*']. + loadSession?: (token: string) => Promise; + // Свой хром вокруг вкладки. Слот получает активную вкладку в ctx.tab и + // может вернуть null там, где рисоваться не должен. + slots?: { + aboveTab?: (ctx: SlotContext) => ReactNode; + headerExtra?: (ctx: SlotContext) => ReactNode; + }; +} + +export default function Panel({ config }: { config: PanelConfig }) { + const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = config.tabs; + const TOKEN_KEY = config.storageKeys?.token ?? 'admin_token'; + const THEME_KEY = config.storageKeys?.theme ?? 'admin_theme'; + const LOGIN_URL = config.endpoints?.login ?? '/login'; + const LOGOUT_URL = config.endpoints?.logout ?? '/logout'; + const APP_NAME = config.branding.name; + const APP_VERSION = config.branding.version; + + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? ''); + const [loginUser, setLoginUser] = useState(''); + const [loginPass, setLoginPass] = useState(''); + const [loginError, setLoginError] = useState(''); + const [msg, setMsg] = useState(''); + const [drawerOpen, setDrawerOpen] = useState(false); + const [activeTab, setActiveTab] = useState(''); + const [session, setSession] = useState(EMPTY_SESSION); + const [theme, setTheme] = useState<'light' | 'dark'>(() => + localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'); + const touchStartX = useRef(0); + const touchCurrentX = useRef(0); + + const showMsg = useCallback((text: string) => { + setMsg(text); + setTimeout(() => setMsg(''), 3000); + }, []); + + // Проверяет, жив ли токен, и заодно тянет роль/права — по ним фильтруется + // меню. Данные конкретных вкладок грузят сами вкладки. + const loadSession = config.loadSession; + const startSession = useCallback(async (tok: string) => { + const data = loadSession + ? await loadSession(tok) + : (await axios.get('/api/session', { headers: { Authorization: `Bearer ${tok}` } })).data; + setSession(data); + return data; + }, [loadSession]); + + const login = useCallback(async () => { + try { + const { data: auth } = await axios.post(LOGIN_URL, { user: loginUser, pass: loginPass }); + const tok = auth.token as string; + await startSession(tok); + localStorage.setItem(TOKEN_KEY, tok); + setLoginError(''); + setToken(tok); + } catch (e) { + // Все три причины отказа пишем под форму, а не в снэкбар: тот живёт + // 3 секунды и уезжает, а человек в этот момент смотрит на поля и + // перенабирает пароль. Особенно важно для 429 — там верный пароль + // тоже не пустит, и без текста это выглядит как «пароль не тот». + if (axios.isAxiosError(e) && e.response?.status === 401) { + setLoginError('Неверный логин или пароль'); + } else if (axios.isAxiosError(e) && e.response?.status === 429) { + setLoginError(e.response.data?.error ?? 'Слишком много попыток'); + } else { + setLoginError('Сервер недоступен'); + } + } + }, [loginUser, loginPass, startSession, showMsg, LOGIN_URL, TOKEN_KEY]); + + const logout = useCallback(async () => { + try { + await axios.post(LOGOUT_URL, null, { headers: { Authorization: `Bearer ${token}` } }); + } catch { /* токен уже мог истечь */ } + localStorage.removeItem(TOKEN_KEY); + setToken(''); + setSession(EMPTY_SESSION); + setActiveTab(''); + setDrawerOpen(false); + }, [token, LOGOUT_URL, TOKEN_KEY]); + + // Любой 401 из apiFetch вкладки означает, что токен протух или отозван — + // разлогиниваем сразу, а не показываем ошибку в снэкбаре и ждём, пока + // пользователь сам догадается перезайти. + useEffect(() => { + setUnauthorizedHandler(() => { + localStorage.removeItem(TOKEN_KEY); + setToken(''); + setSession(EMPTY_SESSION); + setActiveTab(''); + }); + return () => setUnauthorizedHandler(null); + }, [TOKEN_KEY]); + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + touchStartX.current = e.touches[0].clientX; + touchCurrentX.current = e.touches[0].clientX; + }, []); + const handleTouchMove = useCallback((e: React.TouchEvent) => { + touchCurrentX.current = e.touches[0].clientX; + }, []); + const handleTouchEnd = useCallback(() => { + if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false); + }, []); + + // Восстановить сессию из сохранённого токена + useEffect(() => { + const saved = localStorage.getItem(TOKEN_KEY); + if (saved) { + startSession(saved).catch(() => { + localStorage.removeItem(TOKEN_KEY); + setToken(''); + }); + } + }, [startSession, TOKEN_KEY]); + + // Применять и сохранять тему + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(THEME_KEY, theme); + }, [theme, THEME_KEY]); + + // Блокировать прокрутку фона когда drawer открыт + useEffect(() => { + document.body.style.overflow = drawerOpen ? 'hidden' : ''; + return () => { document.body.style.overflow = ''; }; + }, [drawerOpen]); + + // Закрывать drawer по Escape + useEffect(() => { + if (!drawerOpen) return; + const h = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false); }; + document.addEventListener('keydown', h); + return () => document.removeEventListener('keydown', h); + }, [drawerOpen]); + + // Меню строится из вкладок, доступных правам текущей учётки, сгруппированных + // по категориям. Вкладок (или прав на них) может не быть вовсе — тогда + // вместо контента показываем заглушку. + const groups = visibleTabsByCategory(session.permissions); + const visible = groups.flatMap(g => g.tabs); + const tab = visible.find(t => t.id === activeTab) ?? visible[0] ?? null; + + // Слот рисуется, если он задан в конфиге. Погасить его на конкретной вкладке + // решает сам слот: активная вкладка лежит в ctx.tab. + const slotCtx: SlotContext = { token, showMsg, session, tab }; + const slot = (name: 'aboveTab' | 'headerExtra') => config.slots?.[name]?.(slotCtx) ?? null; + + return ( +
+ + + {token && slot('headerExtra')} + + {!token ? ( +
+
+ {APP_NAME} +

Войдите, чтобы продолжить

+ setLoginUser(e.target.value)} + autoFocus + /> + setLoginPass(e.target.value)} + onKeyDown={e => e.key === 'Enter' && login()} + /> + + {loginError &&

{loginError}

} +
+
+ ) : ( + <> + {/* Top bar — видим только на мобильном */} +
+ + {tab?.label ?? APP_NAME} +
+ + {/* Затемнение под drawer */} +
setDrawerOpen(false)} + /> + +
+ + +
+

{tab?.label ?? APP_NAME}

+ {slot('aboveTab')} + {tab + ? + :

+ {TABS.length === 0 + ? <>Вкладок пока нет — добавь папку в src/tabs/ + : 'Ни одна вкладка не доступна твоим правам'} +

} +
+
+ + )} + + {msg &&
{msg}
} +
+ ); +} diff --git a/core/browser/shell/styles.css b/core/browser/shell/styles.css new file mode 100644 index 0000000..b959335 --- /dev/null +++ b/core/browser/shell/styles.css @@ -0,0 +1,583 @@ +/* +Дизайн-система панели: тёплая кремовая база + прохладный фиолетовый акцент +в ролях Material 3. Здесь только общий язык — стили конкретной вкладки живут +в её папке (tabs//.css) и импортятся её же компонентом. + +ПУБЛИЧНЫЙ КОНТРАКТ. На эти имена классов опирается код вкладок во всех +панелях-потребителях, поэтому переименование любого из них — ломающее +изменение, а не рефакторинг: + + кнопки .btn .btn--primary .btn--tonal .btn--outline .btn--danger + .btn--full .btn--sq .btn-icon .btn-icon--danger .btn-icon--neutral + формы .field + таблица .table-card .td-mono .td-num .td-actions + прочее .chip (+--online/--offline/--error) .chip-dot + .toolbar .toolbar-btns .actions .empty + модалка .modal-backdrop .modal-card .modal-title .modal-actions .modal-hint + +Всё остальное (.app, .sidebar, .drawer-*, .top-bar, .nav-*, .login-*, .snack, +.main, .page-title, .placeholder) принадлежит оболочке — вкладки этих имён +не используют. + +Живая витрина всех этих классов — вкладка «Витрина» в frontend/ (шаблон панели). + +Палитра задаётся токенами на :root и переопределяется на [data-theme="dark"]; +цвета в правилах — только через var(--token), литералов в компонентах нет. +*/ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body, #root { height: 100%; } + +:root { + --primary: #6a45b8; + --on-primary: #ffffff; + --primary-container: #ebddff; + --on-primary-container: #5a2da8; + + --surface: #faf6f0; + --surface-bright: #fdfaf6; + --surface-container: #f0e8dd; + --surface-hi: #ece2d3; + --on-surface: #3a2e22; + + --outline: #d8cdbe; + --outline-variant: #ece2d3; + + --success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922; + --neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85; + --error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a; + + --danger-border: #e6c4bc; + --danger-text: #b34a3a; + + /* приглушённый «бровый» лейбл — заголовки таблицы, неактивная навигация */ + --label: #5a4a3a; + + /* state layers / тени — зависят от темы */ + --state-primary: rgba(106, 69, 184, .10); + --state-row: rgba(106, 69, 184, .035); + --state-neutral: rgba(58, 46, 34, .08); + --scrim: rgba(58, 46, 34, .45); + --shadow: rgba(58, 46, 34, .15); + + --font: system-ui, -apple-system, 'Inter', sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + + --r-pill: 100px; + --r-card: 24px; + --r-table: 16px; + --r-logo: 9px; +} + +:root[data-theme="dark"] { + --primary: #cbb3ff; + --on-primary: #381b6c; + --primary-container: #52389e; + --on-primary-container: #ebddff; + + --surface: #1a1611; + --surface-bright: #2b251d; + --surface-container: #251f18; + --surface-hi: #302820; + --on-surface: #ede2d4; + + --outline: #4d4334; + --outline-variant: #342c20; + + --success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f; + --neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085; + --error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449; + + --danger-border: #6e4540; + --danger-text: #ffb4ab; + + --label: #b6a994; + + --state-primary: rgba(203, 179, 255, .14); + --state-row: rgba(203, 179, 255, .05); + --state-neutral: rgba(237, 226, 212, .08); + --scrim: rgba(0, 0, 0, .55); + --shadow: rgba(0, 0, 0, .45); +} + +body { + font-family: var(--font); + font-size: 14px; + font-weight: 400; + color: var(--on-surface); + background: var(--surface); + -webkit-font-smoothing: antialiased; + transition: background 0.2s ease, color 0.2s ease; +} + +.app { min-height: 100vh; display: flex; flex-direction: column; position: relative; } + +.logo-name { font-size: 17px; font-weight: 500; color: var(--on-surface); } + +.login-screen { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: var(--surface); +} +.login-card { + width: 352px; + background: var(--surface-bright); + border-radius: var(--r-card); + border: 1px solid var(--outline-variant); + padding: 36px 32px 32px; + display: flex; + flex-direction: column; + gap: 12px; +} +.login-sub { + font-size: 14px; + color: var(--neutral-text); + margin-bottom: 4px; +} +.login-error { + font-size: 13px; + color: var(--danger-text); + text-align: center; +} + +.field { + height: 44px; + padding: 0 16px; + border: 1.5px solid var(--outline); + border-radius: var(--r-pill); + background: var(--surface-bright); + font-family: var(--font); + font-size: 14px; + color: var(--on-surface); + outline: none; + transition: border-color 0.15s; + width: 100%; +} +.field:focus { border-color: var(--primary); } +.field::placeholder { color: var(--neutral-dot); } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 44px; + padding: 0 20px; + border-radius: var(--r-pill); + border: none; + font-family: var(--font); + font-size: 14px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.12s, background 0.12s; + flex-shrink: 0; +} +.btn:hover { opacity: 0.88; } +.btn:active { opacity: 0.74; } +.btn:disabled { opacity: 0.45; cursor: not-allowed; } + +.btn--primary { background: var(--primary); color: var(--on-primary); } +.btn--tonal { background: var(--primary-container); color: var(--on-primary-container); border: none; } +.btn--outline { + background: transparent; + border: 1.5px solid var(--outline); + color: var(--primary); +} +.btn--danger { + background: transparent; + border: 1.5px solid var(--danger-border); + color: var(--danger-text); +} +.btn--full { width: 100%; } +.btn--sq { width: 44px; padding: 0; flex-shrink: 0; } + +/* icon-only circular button */ +.btn-icon { + width: 30px; height: 30px; + border-radius: 50%; + border: none; + background: transparent; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--primary); + transition: background 0.12s; + flex-shrink: 0; +} +.btn-icon:hover { background: var(--state-primary); } +.btn-icon--danger { color: var(--danger-text); } +.btn-icon--danger:hover { background: rgba(179,74,58,.10); } +.btn-icon--neutral { color: var(--neutral-text); } +.btn-icon--neutral:hover { background: var(--state-neutral); } + +.theme-toggle { + position: fixed; + top: 16px; + right: 16px; + z-index: 250; + width: 40px; + height: 40px; + border-radius: 50%; + border: 1.5px solid var(--outline); + background: var(--surface-bright); + color: var(--on-surface); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.theme-toggle:hover { border-color: var(--primary); color: var(--primary); } + +.top-bar { + display: none; + align-items: center; + gap: 4px; + height: 56px; + padding: 0 8px 0 4px; + background: var(--surface); + flex-shrink: 0; + z-index: 10; +} +.hamburger-btn { + width: 48px; height: 48px; + border: none; + background: transparent; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--on-surface); + flex-shrink: 0; + -webkit-tap-highlight-color: transparent; +} +.hamburger-btn:active { background: var(--state-neutral); } +.top-bar-title { + font-size: 20px; + font-weight: 500; + color: var(--on-surface); +} + +.drawer-scrim { + display: none; + position: fixed; + inset: 0; + background: var(--scrim); + z-index: 300; + opacity: 0; + pointer-events: none; + transition: opacity 0.27s cubic-bezier(0.2, 0, 0, 1); +} +.drawer-scrim--visible { + opacity: 1; + pointer-events: auto; +} + +.layout { + flex: 1; + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 240px; + flex-shrink: 0; + background: var(--surface-container); + display: flex; + flex-direction: column; + padding: 24px 12px; + gap: 0; +} +.sidebar-logo { + padding: 0 8px 20px; + display: flex; + flex-direction: column; + gap: 2px; +} +.sidebar-version { + font-size: 11px; + color: var(--neutral-dot); + margin-top: 2px; +} +.drawer-divider { + border: none; + border-top: 0.5px solid var(--outline); + margin: 0 -12px 8px; +} + +.nav-group + .nav-group { margin-top: 8px; } +.nav-category { + padding: 12px 12px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--neutral-dot); +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--r-pill); + font-size: 14px; + font-weight: 400; + color: var(--label); + cursor: default; + user-select: none; +} +.nav-item--active { + background: var(--primary-container); + color: var(--on-primary-container); + font-weight: 500; +} + +.sidebar-spacer { flex: 1; } + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--r-pill); + font-size: 12px; + white-space: nowrap; +} +.chip-dot { + width: 6px; height: 6px; + border-radius: 50%; + flex-shrink: 0; +} +.chip--online { background: var(--success-bg); color: var(--success-text); } +.chip--online .chip-dot { background: var(--success-dot); } +.chip--offline { background: var(--neutral-bg); color: var(--neutral-text); } +.chip--offline .chip-dot { background: var(--neutral-dot); } +.chip--error { background: var(--error-bg); color: var(--error-text); } +.chip--error .chip-dot { background: var(--error-dot); } + +.main { + flex: 1; + padding: 32px 36px; + display: flex; + flex-direction: column; + gap: 20px; + overflow: auto; + min-width: 0; +} +.page-title { + font-size: 20px; + font-weight: 500; + color: var(--on-surface); +} + +/* Заглушка на месте контента — когда вкладок ещё нет */ +.placeholder { + color: var(--neutral-text); + font-size: 14px; +} +.placeholder code { + font-family: var(--mono); + font-size: 13px; + background: var(--surface-container); + padding: 2px 6px; + border-radius: 6px; +} + +/* Тултип без задержки: нативный title браузер показывает через ~500 мс */ +.tip-wrap { + position: relative; + display: inline-flex; +} +.tip-wrap::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 7px); + left: 50%; + transform: translateX(-50%); + background: var(--on-surface); + color: var(--surface-bright); + font-size: 12px; + white-space: nowrap; + padding: 5px 12px; + border-radius: var(--r-pill); + pointer-events: none; + opacity: 0; + transition: opacity 0.12s; +} +.tip-wrap:hover::after { opacity: 1; } + +.toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.toolbar .field { + width: 224px; + height: 44px; + flex-shrink: 0; +} +/* На десктопе кнопки участвуют в flex toolbar'а напрямую */ +.toolbar-btns { display: contents; } + +.table-card { + background: var(--surface-bright); + border-radius: var(--r-table); + border: 1px solid var(--outline-variant); + overflow: hidden; +} +table { + width: 100%; + border-collapse: collapse; +} +thead th { + background: var(--surface-container); + color: var(--label); + font-weight: 500; + font-size: 13px; + text-align: left; + padding: 11px 14px; +} +thead th:first-child { padding-left: 20px; } +thead th:last-child { padding-right: 20px; } + +tbody tr { border-top: 0.5px solid var(--outline-variant); } +tbody tr:hover { background: var(--state-row); } + +tbody td { + padding: 11px 14px; + font-size: 13px; + color: var(--on-surface); + vertical-align: middle; +} +tbody td:first-child { padding-left: 20px; } +tbody td:last-child { padding-right: 20px; } + +.td-num { font-size: 12px; color: var(--neutral-dot); width: 32px; } +.td-mono { font-family: var(--mono); font-size: 12px; } +.td-actions { width: 96px; } + +.actions { display: flex; align-items: center; gap: 2px; } + +.empty { + text-align: center; + color: var(--neutral-text); + padding: 48px 0 !important; + font-size: 14px; +} + +/* Модалка — общий каркас (backdrop + карточка). Содержимое даёт вкладка. */ +@keyframes backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes card-in { + from { opacity: 0; transform: scale(.92) translateY(12px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.modal-backdrop { + position: fixed; inset: 0; + background: var(--scrim); + display: flex; align-items: center; justify-content: center; + z-index: 300; + animation: backdrop-in 0.18s ease; +} +.modal-card { + background: var(--surface-bright); + border-radius: var(--r-card); + border: 1px solid var(--outline-variant); + padding: 28px 28px 24px; + display: flex; flex-direction: column; align-items: center; gap: 14px; + width: 320px; + animation: card-in 0.22s cubic-bezier(.34,1.26,.64,1); +} +.modal-title { font-size: 16px; font-weight: 500; color: var(--on-surface); } +.modal-hint { font-size: 12px; color: var(--neutral-text); text-align: center; } +.modal-actions { display: flex; gap: 8px; width: 100%; } +.modal-actions .btn { flex: 1; } + +.snack { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + background: var(--on-surface); + color: var(--surface-bright); + font-size: 13px; + padding: 10px 22px; + border-radius: var(--r-pill); + z-index: 500; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 4px 16px var(--shadow); +} + +@media (max-width: 640px) { + /* Top bar */ + .top-bar { display: flex; } + .page-title { display: none; } + + /* Drawer scrim */ + .drawer-scrim { display: block; } + + /* Запретить горизонтальный скролл страницы */ + body, .app { overflow-x: hidden; } + + /* Sidebar → модальный drawer */ + .layout { + min-height: 0; /* flex: 1 на .app справится сам */ + overflow: visible; + } + /* main — не скролл-контейнер, пусть скроллит body */ + .main { overflow: visible; } + + .sidebar { + position: fixed; + top: 0; left: 0; + height: 100%; + width: 280px; + z-index: 400; + border-radius: 0 var(--r-card) var(--r-card) 0; + transform: translateX(-100%); + transition: transform 0.27s cubic-bezier(0.2, 0, 0, 1); + box-shadow: 4px 0 24px var(--shadow); + padding-top: 36px; + } + .sidebar--open { transform: translateX(0); } + + /* Main: одна колонка, поля 16px */ + .main { + padding: 16px; + gap: 12px; + } + + /* Toolbar → вертикально */ + .toolbar { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + .toolbar .field { width: 100%; } + .toolbar-btns { + display: flex; + gap: 8px; + } + .toolbar-btns .btn { flex: 1; } + + /* Таблицы на мобильном заменяются карточками — их даёт сама вкладка */ + .table-card { display: none; } + + /* Модалка — по ширине экрана */ + .modal-card { + width: calc(100vw - 32px); + max-width: 360px; + padding: 24px 20px 20px; + } +} diff --git a/core/browser/tabs/index.ts b/core/browser/tabs/index.ts new file mode 100644 index 0000000..1ba919f --- /dev/null +++ b/core/browser/tabs/index.ts @@ -0,0 +1,150 @@ +// Реестр вкладок. Каждая вкладка — самодостаточная папка tabs//: +// index.tsx — компонент + логика +// .css — стили вкладки (импортит сам компонент) +// metadata.json — манифест: { id, label, icon, category?, permission?, order?, devOnly? } +// +// Сам поиск папок (import.meta.glob) остаётся в приложении и передаётся сюда +// аргументами — это compile-time фича Vite, её паттерн резолвится относительно +// файла, где она написана. Уехав в ядро, она сканировала бы ядро, а не +// src/tabs/ приложения. +// +// metadata.json НЕ проверяется компилятором (это JSON), поэтому содержимое +// валидируется здесь в рантайме — кривой манифест падает с внятной ошибкой при +// сборке TABS, а не «тихо» в UI. +import type { ComponentType } from 'react'; +import { OWNER_PERMISSION } from '../auth'; + +// Сериализуемая часть из metadata.json. Обязательны только id, label и icon: +// панель без ролей и без группировки меню — базовый случай, а не урезанный. +export interface TabMeta { + id: string; + label: string; + icon: string; // имя иконки → карта ICONS + // Группа в меню. Без неё вкладка попадает в единственную безымянную группу, + // и сайдбар рисует плоский список. + category?: string; + // Право, нужное для доступа. Без него вкладка видна любому залогиненному. + permission?: string; + order?: number; // порядок в меню (по возрастанию; без него — 0) + // Вкладка, которой не должно быть в проде: её роут на бэкенде там всё равно + // отдаёт 404 независимо от прав, и вкладка вела бы в тупик. + devOnly?: boolean; +} + +// Собранная вкладка: метадата + резолвнутые Icon и Page. +export interface TabDef extends TabMeta { + Icon: () => JSX.Element; + Page: ComponentType; +} + +export interface TabGroup { + category: string; + tabs: TabDef[]; +} + +export interface BuiltTabs { + TABS: TabDef[]; + ALL_TAB_PERMISSIONS: string[]; + visibleTabsByCategory: (permissions: string[]) => TabGroup[]; +} + +type MetaModules = Record; +type PageModules = Record }>; +type IconMap = Record JSX.Element>; + +// './users/metadata.json' → 'users' +const folderOf = (path: string) => path.split('/')[1]; + +const REQUIRED_STRINGS = ['id', 'label', 'icon'] as const; +const OPTIONAL_STRINGS = ['category', 'permission'] as const; + +// Валидация сырого metadata.json → типизированный TabMeta (или внятная ошибка). +function toMeta(name: string, raw: unknown): TabMeta { + const m = (raw ?? {}) as Record; + const fail = (what: string): never => { + throw new Error(`tabs/${name}/metadata.json: ${what}`); + }; + const nonEmpty = (v: unknown) => typeof v === 'string' && v.trim() !== ''; + + for (const key of REQUIRED_STRINGS) { + if (!nonEmpty(m[key])) fail(`поле "${key}" должно быть непустой строкой`); + } + for (const key of OPTIONAL_STRINGS) { + if (m[key] !== undefined && !nonEmpty(m[key])) fail(`"${key}" должно быть непустой строкой`); + } + if (m.order !== undefined && typeof m.order !== 'number') fail('"order" должно быть числом'); + if (m.devOnly !== undefined && typeof m.devOnly !== 'boolean') fail('"devOnly" должно быть булевым'); + + return { + id: m.id as string, + label: m.label as string, + icon: m.icon as string, + category: m.category as string | undefined, + permission: m.permission as string | undefined, + order: m.order as number | undefined, + devOnly: m.devOnly as boolean | undefined, + }; +} + +// Собирает реестр вкладок из результатов import.meta.glob приложения: +// +// export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs( +// import.meta.glob('./*/metadata.json', { eager: true }), +// import.meta.glob('./*/index.tsx', { eager: true }), +// ICONS, +// ); +// +// Вкладок может не быть вовсе (чистый каркас) — тогда TABS пустой, и оболочка +// показывает заглушку вместо контента. +export function buildTabs(metas: MetaModules, pages: PageModules, icons: IconMap): BuiltTabs { + const TABS: TabDef[] = Object.entries(metas) + .map(([path, mod]) => { + const name = folderOf(path); + const meta = toMeta(name, mod.default); + const page = pages[`./${name}/index.tsx`]?.default; + if (!page) { + throw new Error(`tabs/${name}: нет index.tsx рядом с metadata.json`); + } + if (!icons[meta.icon]) { + throw new Error(`tabs/${name}: иконка "${meta.icon}" не зарегистрирована в ICONS`); + } + return { ...meta, Icon: icons[meta.icon], Page: page }; + }) + // import.meta.env.PROD — режим сборки этого фронта (vite build), а не + // окружения бэкенда, к которому он ходит. + .filter(t => !t.devOnly || !import.meta.env.PROD) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + // id вкладок используются как ключ активной вкладки — дубли ломают переключение. + const ids = TABS.map(t => t.id); + const dup = ids.find((id, i) => ids.indexOf(id) !== i); + if (dup) { + throw new Error(`tabs: дублирующийся id "${dup}" — id вкладок должны быть уникальны`); + } + + // Полный список прав, встречающихся у вкладок — по нему строится форма + // выдачи прав в панелях, где такая вкладка есть: появилась вкладка с новым + // permission — появился и чекбокс, без ручной синхронизации списков. + const ALL_TAB_PERMISSIONS = Array.from( + new Set(TABS.map(t => t.permission).filter((p): p is string => !!p)), + ); + + // Вкладки, доступные набору прав, сгруппированные по категориям в порядке + // первого появления (сами вкладки уже отсортированы по order). Вкладки без + // category собираются в одну безымянную группу — сайдбар рисует её без + // заголовка, то есть плоским списком. + function visibleTabsByCategory(permissions: string[]): TabGroup[] { + const owner = permissions.includes(OWNER_PERMISSION); + const visible = TABS.filter(t => !t.permission || owner || permissions.includes(t.permission)); + const groups: TabGroup[] = []; + for (const tab of visible) { + const category = tab.category ?? ''; + let group = groups.find(g => g.category === category); + if (!group) { group = { category, tabs: [] }; groups.push(group); } + group.tabs.push(tab); + } + return groups; + } + + return { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory }; +} diff --git a/core/browser/utils/index.ts b/core/browser/utils/index.ts new file mode 100644 index 0000000..47b3aad --- /dev/null +++ b/core/browser/utils/index.ts @@ -0,0 +1,52 @@ +// Форматирование и работа с буфером/файлами. Чистые функции без зависимостей — +// то, что во всех панелях уже совпадало побайтово. + +// ISO-дата → «2026-08-01 12:34». Без секунд и без часового пояса — +// формат для таблиц, где важна разница между записями, а не точность до сек. +export function fmtDateTime(iso: string | null | undefined): string { + if (!iso) return '—'; + return iso.replace('T', ' ').slice(0, 16); +} + +// unix-секунды → «5 мин» / «3 ч» (без суффикса «назад» — его добавляет вызывающий) +export function timeAgo(ts: number | undefined): string { + if (!ts) return '—'; + const sec = Math.floor(Date.now() / 1000) - ts; + if (sec < 60) return sec + ' сек'; + if (sec < 3600) return Math.floor(sec / 60) + ' мин'; + return Math.floor(sec / 3600) + ' ч'; +} + +export function bytes(n: number | undefined): string { + if (!n) return '—'; + if (n < 1024) return n + ' B'; + if (n < 1_048_576) return (n / 1024).toFixed(1) + ' KB'; + if (n < 1_073_741_824) return (n / 1_048_576).toFixed(1) + ' MB'; + return (n / 1_073_741_824).toFixed(2) + ' GB'; +} + +export function downloadFile(filename: string, text: string) { + const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +// navigator.clipboard недоступен вне https/localhost — отсюда фолбэк на execCommand. +export async function copyText(text: string) { + if (navigator.clipboard) { + await navigator.clipboard.writeText(text); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;opacity:0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } +} diff --git a/core/server/auth.ts b/core/server/auth.ts new file mode 100644 index 0000000..1f7e047 --- /dev/null +++ b/core/server/auth.ts @@ -0,0 +1,86 @@ +// Мидлвары авторизации: «пускать ли вообще» и «пускать ли к этому разделу». +// +// Проверка прав на фронте (permission в metadata.json вкладки) — это только +// UX: не показывать то, что всё равно отклонит сервер. Настоящая граница здесь, +// и роут без requirePermission открыт всем залогиненным независимо от того, как +// размечена вкладка. +// +// Откуда берутся права — развилка без единственно верного ответа, поэтому она +// вынесена в конфиг (loadPermissions): +// из клеймов токена — дёшево, но снятое право действует к истечению TTL; +// из БД на запрос — дороже, зато отзыв мгновенный. +import type { NextFunction, Request, Response } from 'express'; +import type { SessionClaims, SessionStore } from './session'; + +export const OWNER_PERMISSION = '*'; + +// Ключ на объекте запроса. Symbol, а не req.session, чтобы не столкнуться с +// express-session и другими мидлварами, занимающими это имя. +const SESSION = Symbol.for('ma7engine.session'); + +export type Middleware = (req: Request, res: Response, next: NextFunction) => void; + +export interface AuthOptions { + sessions: SessionStore; + /** + * Права на конкретный запрос. По умолчанию — из клеймов токена. + * Верни свежие права из БД, если нужен мгновенный отзыв. + */ + loadPermissions?: (claims: SessionClaims, req: Request) => string[] | Promise; + /** Тексты отказов — панели отдают ошибки как {message}. */ + messages?: { unauthorized?: string; forbidden?: string }; +} + +export interface Auth { + /** Пускает только с живым токеном; кладёт клеймы в запрос. */ + requireAuth: Middleware; + /** Пускает только с указанным правом (или '*'). Ставится после requireAuth. */ + requirePermission: (permission: string) => Middleware; + /** Клеймы текущего запроса. Осмысленны только после requireAuth. */ + getSession: (req: Request) => SessionClaims; + /** Bearer-токен запроса — нужен, например, чтобы отозвать его на логауте. */ + bearerToken: (req: Request) => string; +} + +export function bearerToken(req: Request): string { + const header = req.headers.authorization ?? ''; + return header.startsWith('Bearer ') ? header.slice(7) : ''; +} + +export function createAuth(options: AuthOptions): Auth { + const { sessions, loadPermissions } = options; + const unauthorized = options.messages?.unauthorized ?? 'Не авторизован'; + const forbidden = options.messages?.forbidden ?? 'Недостаточно прав'; + + const getSession = (req: Request): SessionClaims => + (req as Request & { [SESSION]?: SessionClaims })[SESSION] as SessionClaims; + + const requireAuth: Middleware = (req, res, next) => { + const claims = sessions.verify(bearerToken(req)); + if (!claims) { + res.status(401).json({ message: unauthorized }); + return; + } + (req as Request & { [SESSION]?: SessionClaims })[SESSION] = claims; + next(); + }; + + const requirePermission = (permission: string): Middleware => (req, res, next) => { + const claims = getSession(req); + if (!claims) { + res.status(401).json({ message: unauthorized }); + return; + } + Promise.resolve(loadPermissions ? loadPermissions(claims, req) : claims.permissions) + .then((permissions) => { + if (permissions.includes(OWNER_PERMISSION) || permissions.includes(permission)) { + next(); + return; + } + res.status(403).json({ message: forbidden }); + }) + .catch(next); + }; + + return { requireAuth, requirePermission, getSession, bearerToken }; +} diff --git a/core/server/errors.ts b/core/server/errors.ts new file mode 100644 index 0000000..5f38915 --- /dev/null +++ b/core/server/errors.ts @@ -0,0 +1,37 @@ +// Проброс ошибок из вышестоящего API наружу. +// +// Панель, которая ходит за данными в основной API продукта, должна отдать +// клиенту его статус и текст, а не превратить всё в 500: «пользователь не +// найден» и «API лежит» — разные ситуации для оператора. +// +// Форма ошибки axios здесь проверяется по структуре, а не через +// axios.isAxiosError: ядру не нужна зависимость от axios ради одной проверки, +// а панель может ходить наверх и через fetch. +import type { Response } from 'express'; + +interface UpstreamError { + response?: { + status?: number; + data?: { message?: string; error?: string }; + }; +} + +export interface ForwardErrorOptions { + /** Текст, когда вышестоящий API недоступен вовсе. */ + unavailable?: string; + /** Текст, когда API ответил ошибкой без внятного сообщения. */ + fallback?: string; +} + +export function forwardError(res: Response, error: unknown, options: ForwardErrorOptions = {}) { + const unavailable = options.unavailable ?? 'Вышестоящий API недоступен'; + const fallback = options.fallback ?? 'Ошибка API'; + + const response = (error as UpstreamError)?.response; + if (response && typeof response.status === 'number') { + const data = response.data; + res.status(response.status).json({ message: data?.message ?? data?.error ?? fallback }); + return; + } + res.status(502).json({ message: unavailable }); +} diff --git a/core/server/index.ts b/core/server/index.ts new file mode 100644 index 0000000..d2b6a48 --- /dev/null +++ b/core/server/index.ts @@ -0,0 +1,128 @@ +// Серверная половина ядра панели: логин, сессия, права, рейт-лимит и раздача +// собранного фронта. Всё, что во всех панелях было одинаковым, — здесь; +// продуктовые маршруты остаются в самой панели. +// +// Авторизация есть всегда и приезжает готовой: задай +// auth: { user, password | passwordHash, secret } — и панель сама выпускает и +// проверяет токен, монтирует /login, /logout и /api/session. Своей авторизации +// панель не пишет. +// +// Панель, у которой собственных учёток нет вовсе (логин форвардится в основной +// продукт, где живут настоящие учётки), createPanelServer не использует — +// она собирает express сама из кусков ядра: createLoginRateLimit на маршрут +// логина, свои прокси-маршруты, serveSpa последним. См. раздел про +// панель-прокси в README. +// +// Порядок монтирования фиксирован и потому не может быть нарушен снаружи: +// json → логин/сессия → маршруты панели → статика с fallback. Fallback ловит +// любой GET, поэтому маршрут после него уже не сработал бы. +import express, { type Express, type Request, type Response } from 'express'; +import { createLoginRateLimit, type LoginRateLimit, type RateLimitOptions } from './ratelimit'; +import { createPasswordCheck, type PasswordOptions } from './password'; +import { createSessionStore, type SessionClaims, type SessionOptions, type SessionStore } from './session'; +import { createAuth, type Auth } from './auth'; +import { forwardError } from './errors'; +import { serveSpa } from './spa'; + +const LOGIN_PATH = '/login'; +const LOGOUT_PATH = '/logout'; +const SESSION_PATH = '/api/session'; + +export interface LocalAuthOptions extends PasswordOptions, Omit { + /** Логин единственной учётки панели. */ + user: string; + /** Секрет подписи сессий — свой, отдельный от секретов основного продукта. */ + secret: string; + /** + * Клеймы, которые получит вошедший. По умолчанию — владелец: permissions + * ['*'], то есть доступ ко всем вкладкам. Появится несколько учёток — + * здесь и появится разметка. + */ + claims?: Partial>; +} + +export interface PanelServerOptions { + /** Учётка панели. Обязательна: панель без авторизации ядром не собирается. */ + auth: LocalAuthOptions; + /** Каталог со сборкой фронта. Без него статика не монтируется. */ + staticDir?: string; + rateLimit?: RateLimitOptions; + /** Маршруты самой панели. Монтируются после логина и до статики. */ + mountRoutes?: (app: Express, ctx: PanelServerContext) => void; +} + +export interface PanelServerContext { + app: Express; + rateLimit: LoginRateLimit; + sessions: SessionStore; + auth: Auth; + forwardError: typeof forwardError; +} + +export function createPanelServer(options: PanelServerOptions): Express { + const app = express(); + const cfg = options.auth; + + app.use(express.json()); + + const rateLimit = createLoginRateLimit(options.rateLimit); + + // Обе проверки бросают при негодной конфигурации — панель не поднимется + // с паролем по умолчанию или с известным секретом. + const password = createPasswordCheck(cfg); + const sessions = createSessionStore({ + secret: cfg.secret, + ttl: cfg.ttl, + revocable: cfg.revocable, + revocationStore: cfg.revocationStore, + }); + const auth = createAuth({ sessions }); + + app.post(LOGIN_PATH, rateLimit.middleware, (req: Request, res: Response) => { + const { user, pass } = (req.body ?? {}) as { user?: string; pass?: string }; + // Пароль проверяется всегда, даже когда логин уже не совпал: иначе + // по времени ответа отличается «нет такого пользователя» от + // «пароль неверный». + const passOk = password.verify(pass ?? ''); + const userOk = typeof user === 'string' && user === cfg.user; + if (!userOk || !passOk) { + res.status(401).json({ message: 'Неверный логин или пароль' }); + return; + } + rateLimit.clear(req); + res.json({ + token: sessions.issue({ + user: cfg.user, + userId: cfg.claims?.userId ?? '1', + role: cfg.claims?.role ?? 'admin', + permissions: cfg.claims?.permissions ?? ['*'], + }), + }); + }); + + app.post(LOGOUT_PATH, auth.requireAuth, (req: Request, res: Response) => { + sessions.revoke(auth.bearerToken(req)); + res.status(204).end(); + }); + + app.get(SESSION_PATH, auth.requireAuth, (req: Request, res: Response) => { + res.json(auth.getSession(req)); + }); + + options.mountRoutes?.(app, { app, rateLimit, sessions, auth, forwardError }); + + if (options.staticDir) serveSpa(app, options.staticDir); + + return app; +} + +export { createLoginRateLimit, clientIp } from './ratelimit'; +export { createPasswordCheck, hashPassword, timingSafeEquals } from './password'; +export { createSessionStore } from './session'; +export { createAuth, bearerToken, OWNER_PERMISSION } from './auth'; +export { forwardError } from './errors'; +export { serveSpa } from './spa'; +export type { RateLimitOptions, LoginRateLimit } from './ratelimit'; +export type { PasswordOptions, PasswordCheck } from './password'; +export type { SessionClaims, SessionOptions, SessionStore, RevocationStore } from './session'; +export type { Auth, AuthOptions, Middleware } from './auth'; diff --git a/core/server/password.ts b/core/server/password.ts new file mode 100644 index 0000000..1c4bfc6 --- /dev/null +++ b/core/server/password.ts @@ -0,0 +1,82 @@ +// Проверка пароля единственной учётки панели. +// +// Две вещи, которые в панелях были сделаны по-разному, здесь сведены к одному +// поведению: +// +// 1. Сравнение. Везде было плоское `pass !== ADMIN_PASS` — время сравнения +// строк зависит от длины общего префикса. Здесь сравниваются SHA-256 +// дайджесты через timingSafeEqual: одинаковая длина (иначе timingSafeEqual +// бросает) и постоянное время. +// +// 2. Ненастроенный пароль. Одна панель падала в открытую (дефолт "admin" и +// предупреждение в консоль), другая — в закрытую (вход запрещён). Ядро +// падает в закрытую и максимально рано: не на первом логине, а при старте +// процесса. Тихо работающая панель с паролем по умолчанию — худший из +// возможных исходов. +import crypto from 'node:crypto'; + +/** Сравнение за постоянное время. Строки произвольной длины. */ +export function timingSafeEquals(a: string, b: string): boolean { + const ha = crypto.createHash('sha256').update(a).digest(); + const hb = crypto.createHash('sha256').update(b).digest(); + return crypto.timingSafeEqual(ha, hb); +} + +export interface PasswordOptions { + /** Пароль открытым текстом (обычно из ADMIN_PASS). */ + password?: string; + /** + * Либо хэш вида `scrypt::` (обычно из ADMIN_PASS_HASH) — + * тогда открытый пароль не хранится в окружении вовсе. + * Сгенерировать: `node -e "…"` — см. hashPassword ниже. + */ + passwordHash?: string; +} + +export interface PasswordCheck { + verify: (input: string) => boolean; + /** Как настроен пароль — для строки в логе при старте. */ + kind: 'plain' | 'scrypt'; +} + +const SCRYPT_PREFIX = 'scrypt:'; + +/** Готовый ADMIN_PASS_HASH для указанного пароля. */ +export function hashPassword(password: string): string { + const salt = crypto.randomBytes(16); + const key = crypto.scryptSync(password, salt, 32); + return `${SCRYPT_PREFIX}${salt.toString('hex')}:${key.toString('hex')}`; +} + +export function createPasswordCheck(options: PasswordOptions): PasswordCheck { + const { password, passwordHash } = options; + + if (passwordHash) { + if (!passwordHash.startsWith(SCRYPT_PREFIX)) { + throw new Error('panel-server: passwordHash должен начинаться с "scrypt:" — см. hashPassword()'); + } + const [, saltHex, keyHex] = passwordHash.split(':'); + if (!saltHex || !keyHex) { + throw new Error('panel-server: passwordHash повреждён, ожидается scrypt::'); + } + const salt = Buffer.from(saltHex, 'hex'); + const expected = Buffer.from(keyHex, 'hex'); + return { + kind: 'scrypt', + verify(input) { + const actual = crypto.scryptSync(input, salt, expected.length); + return crypto.timingSafeEqual(actual, expected); + }, + }; + } + + if (!password) { + throw new Error( + 'panel-server: пароль панели не задан.\n' + + ' Укажи ADMIN_PASS (или ADMIN_PASS_HASH — тогда открытого пароля в окружении не будет).\n' + + ' Значения по умолчанию здесь намеренно нет: панель с паролем "admin" опаснее, чем не поднявшаяся панель.', + ); + } + + return { kind: 'plain', verify: (input) => timingSafeEquals(input, password) }; +} diff --git a/core/server/ratelimit.ts b/core/server/ratelimit.ts new file mode 100644 index 0000000..c44e5e9 --- /dev/null +++ b/core/server/ratelimit.ts @@ -0,0 +1,83 @@ +// Ограничение перебора пароля на /login. Во всех трёх панелях этот код был +// скопирован дословно (те же 5 попыток за 15 минут), но в одной из них — +// с багом: IP брался как req.socket.remoteAddress без разбора X-Forwarded-For. +// За обратным прокси это адрес самого прокси, все клиенты схлопываются в один +// ключ, и пять неудачных попыток кем угодно блокируют вход всем. +// +// Счётчик — Map в памяти: он не переживает рестарт и не общий для нескольких +// инстансов за балансировщиком. Для защиты от перебора этого достаточно +// (атакующему всё равно приходится укладываться в лимит на каждом инстансе), +// но знать об этом надо — распределённый лимит требует общего хранилища. +import type { NextFunction, Request, Response } from 'express'; + +export interface RateLimitOptions { + /** Сколько неудачных попыток разрешено в окне. По умолчанию 5. */ + limit?: number; + /** Длина окна в миллисекундах. По умолчанию 15 минут. */ + windowMs?: number; + /** + * Доверять ли заголовку X-Forwarded-For. По умолчанию — только в проде: + * в деве перед сервером прокси нет, и подставить чужой IP заголовком мог + * бы кто угодно, кто достучался напрямую. + */ + trustProxy?: boolean; + /** Текст ответа. Получает количество секунд до сброса. */ + message?: (retryAfter: number) => string; +} + +interface RateEntry { count: number; resetAt: number } + +type IpSource = Pick & { socket: { remoteAddress?: string } }; + +export function clientIp(req: IpSource, trustProxy: boolean): string { + if (trustProxy) { + const forwarded = req.headers['x-forwarded-for']; + const first = Array.isArray(forwarded) ? forwarded[0] : forwarded; + if (first) return first.split(',')[0].trim(); + } + return req.socket.remoteAddress ?? 'unknown'; +} + +export interface LoginRateLimit { + /** Мидлвар на маршрут логина. */ + middleware: (req: Request, res: Response, next: NextFunction) => void; + /** + * Снять счётчик после успешного входа — иначе три опечатки подряд перед + * удачным логином всё ещё считались бы попытками перебора. + */ + clear: (req: IpSource) => void; + /** IP запроса по тем же правилам, что использует лимитер. */ + ipOf: (req: IpSource) => string; +} + +export function createLoginRateLimit(options: RateLimitOptions = {}): LoginRateLimit { + const limit = options.limit ?? 5; + const windowMs = options.windowMs ?? 15 * 60 * 1000; + const trustProxy = options.trustProxy ?? process.env.NODE_ENV === 'production'; + const message = options.message + ?? ((retryAfter: number) => `Слишком много попыток. Повтори через ${retryAfter} сек.`); + + const attempts = new Map(); + const ipOf = (req: IpSource) => clientIp(req, trustProxy); + + return { + ipOf, + clear: (req) => { attempts.delete(ipOf(req)); }, + middleware(req, res, next) { + const ip = ipOf(req); + const now = Date.now(); + const entry = attempts.get(ip); + if (!entry || entry.resetAt < now) { + attempts.set(ip, { count: 1, resetAt: now + windowMs }); + } else if (entry.count >= limit) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + res.setHeader('Retry-After', String(retryAfter)); + res.status(429).json({ error: message(retryAfter) }); + return; + } else { + entry.count++; + } + next(); + }, + }; +} diff --git a/core/server/session.ts b/core/server/session.ts new file mode 100644 index 0000000..ed2fd68 --- /dev/null +++ b/core/server/session.ts @@ -0,0 +1,93 @@ +// Сессия оператора панели: выпуск и проверка токена. +// +// Реализаций в панелях было две — подписанный JWT через jsonwebtoken и +// самописный HMAC на node:crypto. Ядро оставляет первую: алгоритм тот же, но +// краевые случаи (разбор exp/nbf, подмена alg) уже разобраны в библиотеке. +// jsonwebtoken объявлен peer-зависимостью — его надо поставить в бэкенд панели. +// +// Отзыв токена. JWT самодостаточен: сервер не хранит выданные токены и потому +// не может их отменить — логаут по умолчанию чисто клиентский. Опция +// `revocable` включает список отозванных подписей в памяти, и тогда логаут +// действительно убивает токен. Цена: список не переживает рестарт и не общий +// для нескольких инстансов за балансировщиком — после рестарта отозванный +// токен снова станет валидным до истечения TTL. Нужен настоящий отзыв на +// нескольких инстансах — нужно общее хранилище (Redis и т.п.); тогда передай +// свой store вместо встроенного. +import jwt from 'jsonwebtoken'; + +export interface SessionClaims { + user: string; + userId: string; + role: string; + permissions: string[]; +} + +/** Хранилище отозванных токенов. По умолчанию — Set в памяти. */ +export interface RevocationStore { + revoke: (signature: string) => void; + isRevoked: (signature: string) => boolean; +} + +export interface SessionOptions { + /** Секрет подписи. Свой, отдельный от секретов основного продукта. */ + secret: string; + /** Время жизни токена. По умолчанию 12 часов. */ + ttl?: string | number; + /** Включить отзыв токенов на логауте. По умолчанию выключено. */ + revocable?: boolean; + /** Своё хранилище отозванных — например, общее на несколько инстансов. */ + revocationStore?: RevocationStore; +} + +export interface SessionStore { + issue: (claims: SessionClaims) => string; + /** Клеймы живого токена, либо null — протух, подделан или отозван. */ + verify: (token: string) => SessionClaims | null; + /** Отозвать токен (если отзыв включён). */ + revoke: (token: string) => void; + revocable: boolean; +} + +function memoryRevocationStore(): RevocationStore { + const revoked = new Set(); + return { + revoke: (sig) => { revoked.add(sig); }, + isRevoked: (sig) => revoked.has(sig), + }; +} + +// Подпись однозначно идентифицирует токен и короче его самого. +const signatureOf = (token: string) => token.split('.')[2] ?? token; + +export function createSessionStore(options: SessionOptions): SessionStore { + const { secret } = options; + if (!secret) { + throw new Error( + 'panel-server: не задан секрет подписи сессий (ADMIN_JWT_SECRET).\n' + + ' Значения по умолчанию нет намеренно: с известным секретом токен подделывается кем угодно.', + ); + } + const ttl = options.ttl ?? '12h'; + const revocable = options.revocable ?? false; + const store = options.revocationStore ?? memoryRevocationStore(); + + return { + revocable, + issue: (claims) => jwt.sign(claims, secret, { expiresIn: ttl } as jwt.SignOptions), + verify(token) { + if (!token) return null; + if (revocable && store.isRevoked(signatureOf(token))) return null; + try { + // algorithms задан явно: без него подошёл бы токен, подписанный + // другим алгоритмом из заголовка самого токена. + const claims = jwt.verify(token, secret, { algorithms: ['HS256'] }); + return claims as SessionClaims & jwt.JwtPayload; + } catch { + return null; + } + }, + revoke(token) { + if (revocable && token) store.revoke(signatureOf(token)); + }, + }; +} diff --git a/core/server/spa.ts b/core/server/spa.ts new file mode 100644 index 0000000..4f8d6c2 --- /dev/null +++ b/core/server/spa.ts @@ -0,0 +1,32 @@ +// Раздача собранного фронта. Панель — один процесс и один образ: сервер сам +// отдаёт frontend/dist, отдельный nginx ей не нужен. +// +// Монтируется последним, после всех API-маршрутов: fallback отвечает на любой +// GET, поэтому маршрут, зарегистрированный после него, уже не сработает. +import express, { type Express, type Request, type Response } from 'express'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Пути под этими префиксами получают 404, а не index.html: иначе опечатка в +// адресе API возвращает HTML со статусом 200, и клиент падает на разборе JSON +// вместо внятной ошибки. +const API_PREFIXES = ['/api']; + +export function serveSpa(app: Express, dir: string) { + const root = path.resolve(dir); + + if (!fs.existsSync(path.join(root, 'index.html'))) { + console.warn(`panel-server: в ${root} нет index.html — фронт не собран (npm run build) ` + + 'или STATIC_DIR указывает не туда'); + } + + app.use(express.static(root)); + + app.get('*', (req: Request, res: Response) => { + if (API_PREFIXES.some(p => req.path.startsWith(p))) { + res.status(404).json({ message: 'Не найдено' }); + return; + } + res.sendFile(path.join(root, 'index.html')); + }); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a459a69 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Demo Panel + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..37f4c03 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2117 @@ +{ + "name": "ma7engine-example-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ma7engine-example-frontend", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.0", + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "devDependencies": { + "@types/node": "^26.3.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.4.0", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.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==", + "dev": true, + "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/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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/@types/node": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz", + "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.414", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.414.tgz", + "integrity": "sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "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/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "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.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==", + "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.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5c113c1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "ma7engine-example-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "predev": "npm run update", + "dev": "vite", + "update": "sh scripts/update.sh", + "prebuild": "npm run update", + "build": "vite build", + "typecheck": "tsc --noEmit", + "doctor": "node scripts/doctor.mjs" + }, + "dependencies": { + "axios": "^1.7.0", + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "devDependencies": { + "@types/node": "^26.3.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.4.0", + "vite": "^5.4.0" + } +} diff --git a/frontend/scripts/update.sh b/frontend/scripts/update.sh new file mode 100755 index 0000000..32a728a --- /dev/null +++ b/frontend/scripts/update.sh @@ -0,0 +1,116 @@ +#!/bin/sh +# Обновляет вендоренную копию ядра (ma7engine) в панели: +# ma7engine/core/browser → <панель>/frontend/src/core (браузерная половина) +# ma7engine/core/server → <панель>/backend/core (серверная половина) +# +# Панель из одной папки, где сервер лежит рядом с фронтом (server.ts в корне, +# как в awg-ui), получает серверную часть в <панель>/core. +# +# Зачем копия, а не алиас прямо на соседнюю папку: контекст сборки образа +# панели — её собственный каталог (COPY frontend/ ./ в Dockerfile), и путь, +# уходящий выше него, внутри контейнера не разрешится. При копии пути +# ./src/core и ./core одинаковы в деве, в tsc --noEmit и в Docker, и Dockerfile +# трогать не нужно вообще. +# +# Файл кладётся в репозиторий потребителя (frontend/scripts/), потому что +# вызывать его нужно и тогда, когда ma7engine рядом нет. +# +# Использование: npm run update (или sh scripts/update.sh) +# Где искать ядро: $MA7ENGINE, иначе перебор кандидатов ниже — первый +# существующий выигрывает, выбранный путь печатается. +set -eu + +abspath() { (CDPATH= cd -- "$1" 2>/dev/null && pwd); } + +here=$(abspath "$(dirname -- "$0")") +app=$(dirname "$here") # .../<панель>/frontend или корень панели из одной папки +panel=$(dirname "$app") # .../<панель> или каталог, где лежат панели + +# Ядро может лежать где угодно относительно панели: рядом с продуктовым +# репозиторием, рядом с остальными панелями или внутри самого репозитория. +# Перебираем, вместо того чтобы зашивать одну раскладку — переезд ядра иначе +# молча уводит синхронизацию на старую копию. +# +# Порядок — от ближнего к дальнему: панель, у которой ядро лежит рядом, не +# должна утащить копию из каталога уровнем выше (такая копия обычно старая — +# именно от неё и уезжает синхронизация молча). +CANDIDATES=" +$app/../ma7engine +$app/../../ma7engine +$app/../../../ma7engine +$app/../../../admins/ma7engine +" + +find_engine() { + if [ -n "${MA7ENGINE:-}" ]; then + if [ ! -d "$MA7ENGINE/core/browser" ]; then + echo "update: в MA7ENGINE=$MA7ENGINE нет каталога core/browser" >&2 + exit 1 + fi + abspath "$MA7ENGINE" + return + fi + for candidate in $CANDIDATES; do + if [ -d "$candidate/core/browser" ]; then + abspath "$candidate" + return + fi + done +} + +# Куда класть серверную половину. Панели без своего сервера не получают её вовсе. +server_target() { + if [ -d "$panel/backend" ]; then echo "$panel/backend/core" + elif [ -f "$app/server.ts" ]; then echo "$app/core" + fi +} + +# Скрипт обязан запускаться из фронта панели: $app определяется как каталог +# уровнем выше scripts/, и запущенный не оттуда он молча создаёт постороннюю +# src/core рядом с чем угодно. index.html — маркер vite-фронта. +if [ ! -f "$app/index.html" ]; then + echo "update: $app не похож на фронт панели (нет index.html)." >&2 + echo " Скрипт запускается из <панель>/frontend: npm run update" >&2 + exit 1 +fi + +front_to=$app/src/core +server_to=$(server_target) + +engine=$(find_engine) + +if [ -z "$engine" ]; then + # В контейнере ядра нет — оно уже приехало внутри src/core (и backend/core) + # вместе с остальными исходниками. Это норма, а не ошибка сборки. + if [ -d "$front_to" ] || { [ -n "$server_to" ] && [ -d "$server_to" ]; }; then + echo "update: ma7engine рядом не найден, использую уже синхронизированные копии" + exit 0 + fi + echo "update: ma7engine не найден ни в одном из мест:" >&2 + for candidate in $CANDIDATES; do echo " $candidate" >&2; done + echo " Укажи путь через MA7ENGINE=/путь/к/ma7engine" >&2 + exit 1 +fi + +echo "update: ядро — $engine" + +copy() { # copy <откуда> <куда> <подпись> + [ -d "$1" ] || return 0 + rm -rf "$2" + mkdir -p "$(dirname "$2")" + cp -R "$1" "$2" + echo " $3: $1 → $2" +} + +copy "$engine/core/browser" "$front_to" "фронт" +if [ -n "$server_to" ]; then + copy "$engine/core/server" "$server_to" "сервер" +fi + +# Доктор едет вместе с ядром: проверки в панели должны быть те же, что в ядре, +# иначе панель отчитывается о соответствии по устаревшему списку. Кладётся +# рядом с этим скриптом — оттуда его зовёт `npm run doctor`. +if [ -f "$engine/scripts/doctor.mjs" ]; then + cp "$engine/scripts/doctor.mjs" "$here/doctor.mjs" + echo " доктор: $engine/scripts/doctor.mjs → $here/doctor.mjs" +fi diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..7e9525e --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,21 @@ +/* Стили панели поверх дизайн-системы ядра (@core/shell/styles.css). Файл + подключается после неё, поэтому здесь и добавляются свои классы, и — если + очень нужно — переопределяются токены палитры. + + Шаблон палитру НЕ трогает: он показывает дизайн-систему как есть. Свой акцент + задаётся так, и обязательно в обеих темах — тёмная переопределяет те же + токены у себя, иначе цвет виден только в светлой: + + :root { --primary: #2f6f4f; } + :root[data-theme="dark"] { --primary: #7fc4a0; } + + Но менять стоит всю связку токенов акцента (--primary, --primary-container, + --on-primary-container, --state-primary), иначе кнопка станет своего цвета, + а тональная кнопка и активный пункт меню останутся прежними. */ + +/* Своё правило: имя с префиксом панели, чтобы не столкнуться с контрактом ядра. */ +.demo-note { + margin-top: 12px; + color: var(--neutral-text); + font-size: 13px; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..487b37a --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,36 @@ +// Шаблон фронта панели — с этого начинается новый. Копируется вместе с +// ../backend; ядро в свою панель приезжает через `npm run update`. +// +// Всё, что здесь есть: конфиг. Логин, тема, drawer/сайдбар, снэкбар, сессия, +// меню по правам и переключение вкладок — в . +// +// Парная половина — backend/server.ts: вкладки ниже ходят в его +// маршруты /api/demo/*, так что две половины запускаются вместе и работают. +import Panel, { type PanelConfig } from '@core/shell'; +import './App.css'; // локальные стили — после стилей ядра +import * as tabs from '@/tabs'; + +const config: PanelConfig = { + tabs, + branding: { name: 'Demo Panel', version: '0.1.0' }, + + // Ключи localStorage разводятся по панелям: две панели на одном домене + // иначе затирают друг другу токен и тему. + storageKeys: { token: 'demo_token', theme: 'demo_theme' }, + + // loadSession не задан — ядро берёт GET /api/session. Панель без ролей + // задаёт его сама и отдаёт permissions: ['*'], тогда эндпоинт сессии + // реализовывать не нужно: + // + // loadSession: async () => ({ user: null, userId: null, role: '', permissions: ['*'] }), + + // slots — свой хром вокруг вкладки: aboveTab (над вкладкой) и headerExtra + // (в углу шапки). Слот получает активную вкладку в ctx.tab, поэтому сам + // решает, где ему не рисоваться: + // + // slots: { aboveTab: ctx => ctx.tab?.id === 'docs' ? null : }, +}; + +export default function App() { + return ; +} diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx new file mode 100644 index 0000000..5192576 --- /dev/null +++ b/frontend/src/components/icons.tsx @@ -0,0 +1,33 @@ +// Иконки панели. Общие приезжают из ядра и реэкспортятся отсюда, чтобы вкладки +// брали все иконки из одного места; здесь остаётся только своё. +import { coreIcons } from '@core/icons'; + +export * from '@core/icons'; + +export const IcoStar = () => ( + + + +); + +export const IcoGrid = () => ( + + + + +); + +export const IcoBook = () => ( + + + +); + +// Карта иконок вкладок: имя из metadata.json → компонент. Своё перекрывает +// одноимённое из ядра. +export const ICONS: Record JSX.Element> = { + ...coreIcons, // key, globe + star: IcoStar, + grid: IcoGrid, + book: IcoBook, +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..7517b40 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/frontend/src/tabs/docs/docs.css b/frontend/src/tabs/docs/docs.css new file mode 100644 index 0000000..9529f88 --- /dev/null +++ b/frontend/src/tabs/docs/docs.css @@ -0,0 +1,56 @@ +/* Стили только этой вкладки. Всё, что можно было взять из дизайн-системы ядра + (.table-card, .chip, .empty), взято оттуда; здесь — типографика документации + и блоки кода. */ + +.doc-section { margin-bottom: 32px; max-width: 900px; } + +.doc-section > h3 { + font-size: 15px; + font-weight: 500; + color: var(--on-surface); + margin-bottom: 8px; +} +.doc-section > p, +.doc-section > ul { + font-size: 13px; + line-height: 1.6; + color: var(--neutral-text); + margin-bottom: 10px; +} +.doc-section > ul { padding-left: 18px; } +.doc-section > ul > li { margin-bottom: 4px; } + +/* Блок кода: моноширинный, со скроллом по горизонтали — длинная строка не + растягивает страницу. */ +.doc-code { + font-family: var(--mono); + font-size: 12px; + line-height: 1.55; + color: var(--on-surface); + background: var(--surface-bright); + border: 1px solid var(--outline); + border-radius: var(--r-card); + padding: 12px 14px; + margin-bottom: 12px; + overflow-x: auto; + white-space: pre; +} + +.doc-inline { + font-family: var(--mono); + font-size: 12px; + color: var(--primary); +} + +/* Предупреждение — то, что ломается молча, если сделать иначе. */ +.doc-warn { + display: flex; + gap: 8px; + font-size: 13px; + line-height: 1.55; + color: var(--on-surface); + background: var(--error-bg); + border-radius: var(--r-card); + padding: 10px 14px; + margin-bottom: 12px; +} diff --git a/frontend/src/tabs/docs/index.tsx b/frontend/src/tabs/docs/index.tsx new file mode 100644 index 0000000..ec93f0f --- /dev/null +++ b/frontend/src/tabs/docs/index.tsx @@ -0,0 +1,461 @@ +// Инструкция к коду панели — прямо в панели. Не пересказ README ядра целиком, +// а то, что нужно под рукой, когда пишешь вкладку: как запустить, куда класть +// файлы, что откуда импортировать, где границы ядра и на чём тут спотыкаются. +// +// Ничего не грузит: открывается без бэкенда. +import type { SessionPageProps } from '@core/auth'; +import './docs.css'; + +const RUN = `cd ma7engine # именно из него, не из каталога уровнем выше +npm run demo + +# ставит зависимости при первом запуске, поднимает бэкенд :8080 и Vite :5173, +# по Ctrl+C гасит обе половины. Открывать :5173, логин admin / demo.`; + +const RUN_SPLIT = `# терминал 1 +cd ma7engine/backend && npm i && ADMIN_PASS=demo ADMIN_JWT_SECRET=dev-secret npm start + +# терминал 2 +cd ma7engine/frontend && npm i && npm run dev`; + +const RUN_PROD = `cd frontend && npm run build # → frontend/dist +cd backend && npm start # :8080 отдаёт и API, и собранный фронт`; + +const TREE = `frontend/ + src/App.tsx конфиг панели: брендинг, ключи стораджа, слоты + src/App.css своё поверх дизайн-системы ядра + src/components/icons.tsx свои иконки + реэкспорт ядерных, карта ICONS + src/tabs/index.ts import.meta.glob → buildTabs (трогать не нужно) + src/tabs// вкладка: index.tsx + metadata.json + .css + src/core/ ЯДРО — копия, правки затираются (npm run update) +backend/ + server.ts конфиг сервера + свои маршруты + core/ ЯДРО — копия, правки затираются`; + +const NEW_TAB = `src/tabs/reports/ + metadata.json { "id": "reports", "label": "Отчёты", "icon": "grid", "order": 40 } + index.tsx export default function ReportsPage({ token, showMsg }: SessionPageProps) { … } + reports.css стили только этой вкладки (импортит сам index.tsx)`; + +const TAB_CODE = `import { useCallback, useEffect, useState } from 'react'; +import type { SessionPageProps } from '@core/auth'; +import { apiFetch, errText } from '@core/http'; +import { IcoRefresh } from '@/components/icons'; +import './reports.css'; + +export default function ReportsPage({ token, showMsg }: SessionPageProps) { + const [rows, setRows] = useState([]); + + const load = useCallback(async () => { + try { + setRows(await apiFetch('GET', '/api/reports', token)); + } catch (e) { + showMsg(errText(e)); // 401 ловить не надо — ядро разлогинит само + } + }, [token, showMsg]); + + useEffect(() => { load(); }, [load]); + + return rows.length === 0 ?

Пусто

: ( … ); +}`; + +const SESSION_FLOW = `1. Логин POST /login { user, pass } → { token } +2. Токен localStorage[storageKeys.token] +3. Кто вошёл loadSession(token) → { user, userId, role, permissions } + по умолчанию GET /api/session +4. Меню вкладки фильтруются по permissions +5. Запросы вкладок apiFetch(...) с Authorization: Bearer +6. Любой 401 ядро чистит сторадж и показывает логин — во всей панели сразу +7. Логаут POST /logout, сервер отзывает токен`; + +const SLOTS_CODE = `slots: { + // над контентом вкладки + aboveTab: ctx => , + // в углу шапки, рядом с переключателем темы + headerExtra: ctx => , +} + +// ctx: { token, showMsg, session, tab } +// Слот гасит себя сам: aboveTab: ctx => ctx.tab?.id === 'docs' ? null : `; + +const ROUTE_CODE = `mountRoutes(app, { auth, forwardError }) { + // Открыт любому залогиненному — у вкладки нет permission. + app.get('/api/reports', auth.requireAuth, async (req, res) => { + try { res.json(await db.reports()); } + catch (e) { forwardError(res, e); } + }); + + // Закрыт правом. Гейт на фронте — только UX, граница здесь. + app.post('/api/reports', auth.requireAuth, + auth.requirePermission('reports.manage'), handler); +}`; + +const HASH_CODE = `# сгенерировать scrypt-хэш — из backend/ +npx tsx -e "import { hashPassword } from './core/password'; console.log(hashPassword('s3cret'))" +# → scrypt:a62e2888…:82651449… + +# запуск без открытого пароля в окружении +ADMIN_USER=root ADMIN_PASS_HASH='scrypt:…' ADMIN_JWT_SECRET=… npm start`; + +const CONFIG_CODE = `const config: PanelConfig = { + tabs, // результат buildTabs() + branding: { name: 'Demo Panel', version: '0.1.0' }, + storageKeys: { token: 'demo_token', theme: 'demo_theme' }, + endpoints: { login: '/login', logout: '/logout' }, + loadSession: async token => ({ … }), // по умолчанию GET /api/session + slots: { aboveTab, headerExtra }, +};`; + +const NO_ROLES_CODE = `// Панель без ролей: эндпоинт сессии реализовывать не нужно — +// отдаём владельца прямо из конфига, и все вкладки видны. +loadSession: async () => ({ + user: null, userId: null, role: '', permissions: ['*'], +}),`; + +const META = [ + ['id', 'да', 'ключ активной вкладки, должен быть уникален'], + ['label', 'да', 'подпись в меню и заголовок раздела'], + ['icon', 'да', 'имя из карты ICONS в src/components/icons.tsx'], + ['category', 'нет', 'группа в меню; без неё — плоский список'], + ['permission', 'нет', 'право доступа; без него вкладка видна всем залогиненным'], + ['order', 'нет', 'порядок по возрастанию, по умолчанию 0'], + ['devOnly', 'нет', 'вырезается из прод-сборки (import.meta.env.PROD)'], +]; + +const IMPORTS = [ + ['@core/auth', 'SessionPageProps, BasePageProps, hasPermission, OWNER_PERMISSION'], + ['@core/http', 'apiFetch, errText'], + ['@core/utils', 'fmtDateTime, timeAgo, bytes, downloadFile, copyText'], + ['@core/icons', 'coreIcons и Ico* — но вкладки берут их через @/components/icons'], + ['@core/shell', 'Panel, PanelConfig, SlotContext — только для App.tsx'], + ['@core/tabs', 'buildTabs — только для src/tabs/index.ts'], + ['@/…', 'свой код панели: @/components/icons, @/lib/…'], +]; + +const PROPS = [ + ['token', 'строка для apiFetch'], + ['showMsg(text)', 'снэкбар на 3 секунды'], + ['username, userId, role', 'кто вошёл'], + ['permissions', 'права текущей учётки; hasPermission() из @core/auth'], + ['availablePermissions', 'все permission всех вкладок — нужно вкладке выдачи прав'], + ['onNavigate(tabId)', 'переключить активную вкладку (роутера в панели нет)'], +]; + +const ENV = [ + ['ADMIN_USER', 'нет', 'логин; по умолчанию admin'], + ['ADMIN_PASS', 'да', 'пароль открытым текстом — либо ADMIN_PASS_HASH'], + ['ADMIN_PASS_HASH', 'нет', 'scrypt-хэш: тогда открытого пароля в окружении нет'], + ['ADMIN_JWT_SECRET','да', 'секрет подписи сессий; без него процесс не стартует'], + ['PORT', 'нет', 'порт бэкенда, по умолчанию 8080'], + ['STATIC_DIR', 'нет', 'где лежит собранный фронт, по умолчанию ../frontend/dist'], +]; + +const TOKENS = [ + ['акцент', '--primary --on-primary --primary-container --on-primary-container'], + ['фон', '--surface --surface-bright --surface-container --surface-hi --on-surface'], + ['границы', '--outline --outline-variant'], + ['статусы', '--success-bg/-text/-dot --neutral-bg/-text/-dot --error-bg/-text/-dot'], + ['опасное', '--danger-text --danger-border'], + ['прочее', '--label --state-primary --state-row --state-neutral --scrim --shadow'], + ['типографика', '--font --mono'], + ['радиусы', '--r-pill --r-card --r-table --r-logo'], +]; + +const MISTAKES: [string, string][] = [ + ['Правка в src/core или backend/core', + 'затрётся при следующем npm run update. Нужно поведение иначе — новое поле в PanelConfig ядра либо своё в панели.'], + ['Иконка не зарегистрирована в ICONS', + 'сборка TABS падает с внятной ошибкой при старте — это не баг, а валидация манифеста.'], + ['Импорт через ../../lib/…', + 'папку вкладки нельзя перенести. Только @core/* и @/*.'], + ['Свой цвет задан одним :root', + 'в тёмной теме его не видно: она переопределяет те же токены. Задавай в обеих.'], + ['permission у вкладки без проверки на маршруте', + 'доступ не закрыт: гейт на фронте только прячет пункт меню.'], + ['Панель не стартует без ADMIN_PASS', + 'так задумано (fail-closed): панель с паролем по умолчанию хуже не поднявшейся.'], +]; + +export default function DocsPage(_props: SessionPageProps) { + return ( + <> +
+

Запуск демо

+

+ Шаблон — рабочая панель на ядре. Две половины запускаются отдельно: бэкенд отдаёт + API, Vite — фронт и проксирует на бэкенд /login,{' '} + /logout и /api. +

+
{RUN}
+

+ Если половины нужны врозь — два терминала, полными путями:{' '} + npm start терминал не возвращает. +

+
{RUN_SPLIT}
+

Прод-режим — одним процессом, без Vite:

+
{RUN_PROD}
+

+ Занят порт — PORT=8081 npm start и{' '} + VITE_PROXY_TARGET=http://localhost:8081 npm run dev. + Ядро приезжает копией: npm run update кладёт его в{' '} + src/core и{' '} + backend/core. Руками звать не нужно — он + вызывается сам перед dev и{' '} + build. +

+
+ +
+

Что где лежит

+

+ Панель — это конфиг плюс вкладки. Оболочка (логин, тема, меню, сессия), + дизайн-система и сборка реестра вкладок живут в ядре и приезжают копией. +

+
{TREE}
+
+ ⚠ + + В src/core и{' '} + backend/core писать нельзя: это копия, + её затирает npm run update. Нужно поведение + иначе — либо новое поле в PanelConfig ядра, + либо своё в панели (слот, свой класс, свой маршрут). + +
+
+ +
+

Как добавить вкладку

+

+ Создай папку с тремя файлами — реестр найдёт её сам через{' '} + import.meta.glob, править{' '} + src/tabs/index.ts не нужно. +

+
{NEW_TAB}
+
+ + + + {META.map(([field, req, meaning]) => ( + + + + + + ))} + +
поле metadata.jsonобяз.смысл
{field} + + {req} + + {meaning}
+
+

+ Манифест — JSON, компилятор его не проверяет, поэтому он валидируется в рантайме: + опечатка или незарегистрированная иконка падают с внятной ошибкой при сборке{' '} + TABS, а не тихо в UI. +

+
+ +
+

Скелет вкладки

+
{TAB_CODE}
+
+ + + + {PROPS.map(([name, meaning]) => ( + + ))} + +
пропчто это
{name}{meaning}
+
+

+ Панель без ролей объявляет BasePageProps — там + только token и{' '} + showMsg. +

+
+ +
+

Откуда что импортировать

+

+ Только через алиасы: @core/* — ядро,{' '} + @/* — свой код. Относительные{' '} + ../../ ломаются при переносе папки вкладки. +

+
+ + + + {IMPORTS.map(([mod, what]) => ( + + ))} + +
модульчто берут
{mod}{what}
+
+
+ +
+

Жизненный цикл сессии

+

Всё это делает оболочка — вкладке остаётся только ходить с токеном в свои маршруты.

+
{SESSION_FLOW}
+
+ +
+

Права — два слоя

+

+ Фронт: permission в{' '} + metadata.json прячет вкладку из меню, а{' '} + hasPermission(permissions, 'x') — отдельные кнопки + внутри неё. Это UX. +

+

+ Бэкенд: auth.requirePermission('x') на + маршруте. Это граница. Первое без второго — не защита: запрос можно послать руками. +

+

+ '*' в{' '} + permissions — владелец, проходит любую проверку. + Панель без ролей просто отдаёт его всем: +

+
{NO_ROLES_CODE}
+
+ +
+

Свой хром — слоты

+

+ Если нужен общий для вкладок элемент (панель выбора сервера, кнопка действия в шапке), + он не копируется по вкладкам, а отдаётся оболочке слотом. +

+
{SLOTS_CODE}
+
+ +
+

Конфиг панели — App.tsx

+

Всё, чем панели отличаются друг от друга, живёт здесь и больше нигде.

+
{CONFIG_CODE}
+
+ +
+

Маршруты — backend/server.ts

+

+ Логин, сессия, права, рейт-лимит (5 попыток с IP за 15 минут) и раздача фронта — в{' '} + createPanelServer; свои маршруты монтируются + между логином и статикой. Порядок фиксирован внутри: статика отвечает на любой GET, + поэтому маршрут после неё не сработал бы. +

+
{ROUTE_CODE}
+
+ + + + {ENV.map(([name, req, meaning]) => ( + + + + + + ))} + +
переменнаяобяз.смысл
{name} + + {req} + + {meaning}
+
+
+ ⚠ + + Без ADMIN_PASS и{' '} + ADMIN_JWT_SECRET процесс не поднимется — это + не баг: панель, тихо работающая с паролем по умолчанию, хуже не стартовавшей. + +
+
+ +
+

Учётка: логин и пароль

+

+ Учётка в панели одна и живёт в окружении бэкенда, а не в базе:{' '} + ADMIN_USER (по умолчанию{' '} + admin) и{' '} + ADMIN_PASS. В демо пароль —{' '} + demo, его подставляет скрипт запуска. +

+

+ Пароль сравнивается за постоянное время и проверяется даже при несовпавшем + логине — иначе по времени ответа «нет такого пользователя» отличается от + «неверный пароль». Чтобы открытого пароля не было в окружении вовсе, вместо{' '} + ADMIN_PASS задаётся{' '} + ADMIN_PASS_HASH: +

+
{HASH_CODE}
+

+ После входа панель кладёт JWT (TTL 12 часов) в{' '} + localStorage под ключом из{' '} + storageKeys.token; логаут отзывает токен на + сервере. Учёток нужно несколько — либо размечай их сам и выдавай разные{' '} + claims, либо не заводи учёток в панели + вовсе: тогда она собирает сервер сама из кусков ядра, а логин форвардит в + основной продукт, где настоящие учётки уже есть. +

+
+ +
+

Стили и токены

+

+ Имена классов ядра — публичный контракт, все они с подписями на вкладке «Витрина». + Цвета — только через var(--…): палитра задана на{' '} + :root и переопределена на{' '} + :root[data-theme="dark"], поэтому свой цвет + задаётся сразу в обеих темах. Несуществующий токен не ошибка — правило просто молча + не применится, отсюда список ниже. +

+
+ + + + {TOKENS.map(([group, list]) => ( + + ))} + +
группатокены
{group}{list}
+
+

+ Стили вкладки лежат в её папке и импортятся её же компонентом; общий{' '} + App.css подключается после стилей ядра. +

+
+ +
+

Обновление ядра

+

+ npm run update во фронте копирует обе половины + ядра в панель: core/browser →{' '} + frontend/src/core и{' '} + core/server →{' '} + backend/core. Он же вызывается сам перед{' '} + npm run dev и{' '} + npm run build. В контейнере ядра рядом нет — + скрипт видит уже синхронизированную копию и молча выходит с нулём. Путь к ядру можно + задать явно: MA7ENGINE=/путь/к/ma7engine. +

+
+ +
+

На чём тут спотыкаются

+
+ + + + {MISTAKES.map(([what, result]) => ( + + ))} + +
что сделалчто будет
{what}{result}
+
+
+ + ); +} diff --git a/frontend/src/tabs/docs/metadata.json b/frontend/src/tabs/docs/metadata.json new file mode 100644 index 0000000..aa070bf --- /dev/null +++ b/frontend/src/tabs/docs/metadata.json @@ -0,0 +1,6 @@ +{ + "id": "docs", + "label": "Инструкция", + "icon": "book", + "order": 5 +} diff --git a/frontend/src/tabs/hello/index.tsx b/frontend/src/tabs/hello/index.tsx new file mode 100644 index 0000000..7dfb7d9 --- /dev/null +++ b/frontend/src/tabs/hello/index.tsx @@ -0,0 +1,62 @@ +// Вкладка без permission — видна любому залогиненному. Ходит в /api/demo/whoami +// из backend/server.ts и показывает клеймы текущей сессии. +// +// Пропсы приходят от оболочки: SessionPageProps — это token, showMsg и данные +// сессии. Панель без ролей объявляет BasePageProps: только token и showMsg. +import { useCallback, useEffect, useState } from 'react'; +import type { SessionPageProps } from '@core/auth'; +import { apiFetch, errText } from '@core/http'; +import { IcoRefresh } from '@/components/icons'; + +export default function HelloPage({ token, showMsg, username, role }: SessionPageProps) { + const [session, setSession] = useState | null>(null); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + setSession(await apiFetch('GET', '/api/demo/whoami', token)); + } catch (e) { + // Ловить 401 не нужно: ядро на него разлогинивает панель само. + showMsg(errText(e)); + } finally { + setLoading(false); + } + }, [token, showMsg]); + + useEffect(() => { load(); }, [load]); + + return ( + <> +
+ + {username || '—'} · {role || 'без роли'} + +
+ +
+
+ +
+ + + + {Object.entries(session ?? {}).map(([key, value]) => ( + + + + + ))} + +
ПолеЗначение
{key}{JSON.stringify(value)}
+ {!session && !loading &&

Сессия не загружена

} +
+ +

+ Классы выше — из дизайн-системы ядра; .demo-note — своё, из App.css. +

+ + ); +} diff --git a/frontend/src/tabs/hello/metadata.json b/frontend/src/tabs/hello/metadata.json new file mode 100644 index 0000000..3d9aff6 --- /dev/null +++ b/frontend/src/tabs/hello/metadata.json @@ -0,0 +1,6 @@ +{ + "id": "hello", + "label": "Кто я", + "icon": "star", + "order": 10 +} diff --git a/frontend/src/tabs/index.ts b/frontend/src/tabs/index.ts new file mode 100644 index 0000000..ed47de7 --- /dev/null +++ b/frontend/src/tabs/index.ts @@ -0,0 +1,15 @@ +// Реестр вкладок. Сборка (валидация metadata.json, порядок, категории, права, +// devOnly) живёт в ядре — в панели остаётся только поиск папок: import.meta.glob +// это compile-time фича Vite, её паттерн резолвится относительно файла, где +// написан, и уехав в ядро она сканировала бы ядро, а не src/tabs/ панели. +// +// Добавить вкладку = создать папку с index.tsx и metadata.json. Этот файл +// трогать не нужно. +import { buildTabs } from '@core/tabs'; +import { ICONS } from '@/components/icons'; + +export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs( + import.meta.glob<{ default: unknown }>('./*/metadata.json', { eager: true }), + import.meta.glob<{ default: any }>('./*/index.tsx', { eager: true }), + ICONS, +); diff --git a/frontend/src/tabs/kit/index.tsx b/frontend/src/tabs/kit/index.tsx new file mode 100644 index 0000000..9d6c445 --- /dev/null +++ b/frontend/src/tabs/kit/index.tsx @@ -0,0 +1,151 @@ +// Витрина дизайн-системы ядра: все классы публичного контракта из +// @core/shell/styles.css в одном месте, с подписями. Ничего не грузит — +// смотреть можно и без бэкенда. +// +// Заодно это тест на ломающие правки: переименовал класс в ядре — увидел здесь. +import { useState } from 'react'; +import type { SessionPageProps } from '@core/auth'; +import { IcoCopy, IcoPlus, IcoRefresh, IcoTrash } from '@/components/icons'; +import './kit.css'; + +// Подпись с именем класса под каждым образцом. +function Item({ name, children }: { name: string; children: React.ReactNode }) { + return ( +
+ {children} + {name} +
+ ); +} + +export default function KitPage({ showMsg }: SessionPageProps) { + const [modal, setModal] = useState(false); + + return ( + <> +
+

Кнопки

+
+ + + + + + +
+
+ +
+

Кнопки-иконки — для действий в строке таблицы

+
+ + + +
+
+ +
+

Поля

+
+ + + +
+
+ +
+
+ +
+

Тулбар — поле растягивается, кнопки прижаты вправо

+
+ +
+ + +
+
+ .toolbar · .toolbar-btns +
+ +
+

Чипы — статус одним взглядом

+
+ онлайн + офлайн + ошибка +
+
+ +
+

Таблица

+
+ + + + + + + + + + + + + + + + + + + + +
#ИмяКлючСтатус
1Первая записьa1b2c3d4онлайн +
+ + +
+
2Вторая записьe5f6a7b8офлайн +
+ + +
+
+
+ .table-card · .td-num · .td-mono · .td-actions · .actions +
+ +
+

Пустое состояние и снэкбар

+
+

Ничего не найдено

+
+
+ + + + + + +
+
+ + {modal && ( +
setModal(false)}> +
e.stopPropagation()}> + Удалить запись? +

Действие необратимо.

+
+ + +
+
+
+ )} + + ); +} diff --git a/frontend/src/tabs/kit/kit.css b/frontend/src/tabs/kit/kit.css new file mode 100644 index 0000000..46ee94b --- /dev/null +++ b/frontend/src/tabs/kit/kit.css @@ -0,0 +1,30 @@ +/* Стили только этой вкладки — живут в её папке и импортятся её же компонентом. + Всё, что показано ниже, нарисовано классами ядра; здесь только раскладка + витрины и подписи с именами классов. */ + +.kit-section { + margin-bottom: 28px; +} +.kit-section > h3 { + font-size: 13px; + font-weight: 500; + color: var(--neutral-text); + margin-bottom: 10px; +} +.kit-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +/* Подпись с именем класса — чтобы витрина была ещё и справочником. */ +.kit-name { + font-family: var(--mono); + font-size: 11px; + color: var(--neutral-dot); +} +.kit-item { + display: flex; + flex-direction: column; + gap: 4px; +} diff --git a/frontend/src/tabs/kit/metadata.json b/frontend/src/tabs/kit/metadata.json new file mode 100644 index 0000000..f90b398 --- /dev/null +++ b/frontend/src/tabs/kit/metadata.json @@ -0,0 +1,6 @@ +{ + "id": "kit", + "label": "Витрина", + "icon": "grid", + "order": 30 +} diff --git a/frontend/src/tabs/secrets/index.tsx b/frontend/src/tabs/secrets/index.tsx new file mode 100644 index 0000000..65ae8b9 --- /dev/null +++ b/frontend/src/tabs/secrets/index.tsx @@ -0,0 +1,36 @@ +// Вкладка с permission: без права secrets.view её нет в меню. Гейт на фронте — +// это только UX; настоящая граница на маршруте бэкенда (requirePermission в +// backend/server.ts). +import { useCallback, useEffect, useState } from 'react'; +import type { SessionPageProps } from '@core/auth'; +import { apiFetch, errText } from '@core/http'; + +export default function SecretsPage({ token, showMsg }: SessionPageProps) { + const [items, setItems] = useState([]); + + const load = useCallback(async () => { + try { + const data = await apiFetch('GET', '/api/demo/secrets', token); + setItems(data.items ?? []); + } catch (e) { + showMsg(errText(e)); + } + }, [token, showMsg]); + + useEffect(() => { load(); }, [load]); + + if (items.length === 0) return

Пусто

; + + return ( +
+ + + + {items.map((item, i) => ( + + ))} + +
#Секрет
{i + 1}{item}
+
+ ); +} diff --git a/frontend/src/tabs/secrets/metadata.json b/frontend/src/tabs/secrets/metadata.json new file mode 100644 index 0000000..4ab3fca --- /dev/null +++ b/frontend/src/tabs/secrets/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "secrets", + "label": "Секреты", + "icon": "key", + "permission": "secrets.view", + "order": 20 +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..ad2ce13 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["vite/client", "node"], // node — для vite.config.ts (__dirname, node:path) + "baseUrl": ".", + "paths": { + "@core/*": ["./src/core/*"], + "@/*": ["./src/*"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..2a6d97c --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; + +// В деве Vite (:5173) проксирует запросы на бэкенд (:8080) — фронт всегда ходит +// по относительным путям, порт нигде не зашит. +const target = process.env.VITE_PROXY_TARGET ?? 'http://localhost:8080'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + // Ядро — вендоренная копия: `npm run update` (он же predev/prebuild) + // кладёт сюда ma7engine/core/browser. Копия, а не путь в соседнюю + // папку, потому что в образе панели пути выше её каталога нет. + '@core': path.resolve(__dirname, 'src/core'), + '@': path.resolve(__dirname, 'src'), + }, + }, + server: { + proxy: { + '/api': target, + '/login': target, + '/logout': target, + }, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..94a5ecf --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "ma7engine", + "version": "0.1.0", + "private": true, + "description": "Общее ядро админ-панелей: оболочка, реестр вкладок, дизайн-система", + "scripts": { + "demo": "sh scripts/demo.sh", + "new-panel": "node scripts/new-panel.mjs", + "typecheck": "tsc --noEmit", + "typecheck:server": "tsc --noEmit -p tsconfig.server.json" + }, + "peerDependencies": { + "axios": "^1.7.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "express": "^4.19.0", + "jsonwebtoken": "^9.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "axios": "^1.7.0", + "react": "^18.3.0", + "typescript": "^5.4.0", + "vite": "^5.4.0" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + }, + "jsonwebtoken": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "axios": { + "optional": true + } + } +} diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100755 index 0000000..26c6caf --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане. +# Ctrl+C гасит обе половины. +# +# npm run demo (из корня ma7engine) +# sh scripts/demo.sh (откуда угодно — пути считаются от самого скрипта) +# +# Учётка по умолчанию — admin / demo. Переопределяется окружением: +# ADMIN_USER=root ADMIN_PASS=secret npm run demo +set -eu + +here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +root=$(dirname "$here") + +# Пароль и секрет обязательны для ядра (fail-closed), поэтому у демо есть +# заведомо непригодные для прода значения по умолчанию. +ADMIN_PASS=${ADMIN_PASS:-demo} +ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET:-dev-secret} +export ADMIN_PASS ADMIN_JWT_SECRET + +for half in backend frontend; do + if [ ! -d "$root/$half/node_modules" ]; then + echo "demo: ставлю зависимости в $half…" + (cd "$root/$half" && npm i) + fi +done + +(cd "$root/backend" && npm start) & +backend=$! + +# Гасим бэкенд вместе с собой — иначе он останется висеть на :8080. Идём по +# всему дереву: между нами и слушающим порт node стоят подоболочка, npm и sh, +# и сигнал в верхний процесс до нижнего сам не доходит — тот просто осиротеет. +kill_tree() { + for child in $(pgrep -P "$1" 2>/dev/null); do kill_tree "$child"; done + kill "$1" 2>/dev/null || true +} +cleanup() { kill_tree "$backend"; } +trap cleanup EXIT INT TERM + +echo "demo: бэкенд на :8080, фронт поднимается на :5173 — открывать его" +echo "demo: логин admin / ${ADMIN_PASS}" + +cd "$root/frontend" && npm run dev diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100755 index 0000000..2ce4930 --- /dev/null +++ b/scripts/doctor.mjs @@ -0,0 +1,347 @@ +#!/usr/bin/env node +// Проверяет, что панель собрана и осталась по стандарту ma7engine. Отвечает на +// вопрос, на который раньше ответа не было: «эта панель ещё соответствует ядру, +// или уже уехала?» — именно расхождение трёх форков и породило само ядро. +// +// npm run doctor (из <панель>/frontend) +// node scripts/doctor.mjs ../admin-hub (из ma7engine, по чужому пути) +// +// Ошибки (выход 1) — то, из-за чего панель соберётся неправильно или не +// соберётся вовсе. Предупреждения (выход 0) — следы шаблона, оставшиеся после +// копирования: они не ломают сборку, поэтому не должны ломать и CI. +// +// Скрипт распространяется в панели вместе с ядром — его кладёт update.sh рядом +// с собой, так что проверки в панели всегда те же, что в ядре. +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +// ─── где панель ───────────────────────────────────────────────────────────── +// Аргументом — путь к панели; без аргумента корнем считается ближайший предок, +// в котором лежит frontend/package.json. Скрипт живёт либо в +// <панель>/frontend/scripts (куда его кладёт update.sh), либо в +// ma7engine/scripts — оба случая этот подъём разбирает одинаково, и он не +// зависит от того, какие ещё каталоги оказались рядом. +function resolvePanel() { + const arg = process.argv[2]; + if (arg) return path.resolve(arg); + for (let dir = HERE; ; dir = path.dirname(dir)) { + if (fs.existsSync(path.join(dir, 'frontend/package.json'))) return dir; + if (dir === path.dirname(dir)) break; + } + return path.dirname(HERE); +} + +const PANEL = resolvePanel(); +const FRONT = path.join(PANEL, 'frontend'); + +// Панель из одной папки (сервер рядом с фронтом, как было в awg-ui) держит +// серверную половину ядра в <панель>/core, а не в <панель>/backend/core. +const BACK = fs.existsSync(path.join(PANEL, 'backend')) ? path.join(PANEL, 'backend') : PANEL; +const SERVER_CORE = path.join(BACK, 'core'); + +// Сам шаблон ma7engine — панель по всем структурным признакам, но demo_token и +// «Demo Panel» в нём норма, а не недоделка. Узнаётся по каталогу core/ рядом. +const IS_TEMPLATE = fs.existsSync(path.join(PANEL, 'core', 'browser')); + +// ─── накопитель результатов ───────────────────────────────────────────────── +const errors = [], warns = [], skips = [], oks = []; + +const ok = (m) => oks.push(m); +const err = (m, w) => errors.push(w ? `${m}\n ${w}` : m); +const warn = (m, w) => warns.push(w ? `${m}\n ${w}` : m); +const skip = (m) => skips.push(m); + +const read = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } }; + +// Проверка «файл есть и в нём встречается X»; возвращает текст либо null. +function need(file, label) { + const text = read(file); + if (text === null) err(`${label}: нет файла ${path.relative(PANEL, file)}`); + return text; +} + +// ─── 1. раскладка и сборка ────────────────────────────────────────────────── +if (!fs.existsSync(FRONT)) { + console.error(`doctor: в ${PANEL} нет каталога frontend — это не панель ma7engine`); + process.exit(1); +} + +const updateSh = path.join(FRONT, 'scripts/update.sh'); +if (fs.existsSync(updateSh)) ok('update.sh на месте'); +else err('нет frontend/scripts/update.sh', 'без него ядро в панели не обновляется'); + +const frontPkgText = need(path.join(FRONT, 'package.json'), 'фронт'); +const frontPkg = frontPkgText ? JSON.parse(frontPkgText) : null; +if (frontPkg) { + const s = frontPkg.scripts ?? {}; + if (/update\.sh/.test(s.update ?? '')) ok('скрипт update ведёт на scripts/update.sh'); + else err('frontend/package.json: скрипт "update" не зовёт scripts/update.sh'); + + for (const hook of ['predev', 'prebuild']) { + if (/update/.test(s[hook] ?? '')) ok(`${hook} обновляет ядро`); + else err(`frontend/package.json: нет "${hook}": "npm run update"`, + 'без него dev и build идут на устаревшей копии ядра'); + } +} + +// tsconfig.json шаблона содержит //-комментарий — JSON.parse на нём падает, +// поэтому конфиги проверяются регулярками, а не разбором. +const tsconf = need(path.join(FRONT, 'tsconfig.json'), 'фронт'); +if (tsconf) { + const pairs = [[/"@core\/\*"\s*:\s*\[\s*"\.\/src\/core\/\*"\s*\]/, '@core/* → ./src/core/*'], + [/"@\/\*"\s*:\s*\[\s*"\.\/src\/\*"\s*\]/, '@/* → ./src/*']]; + for (const [re, label] of pairs) { + if (re.test(tsconf)) ok(`tsconfig: ${label}`); + else err(`tsconfig.json: нет пути ${label}`, 'вкладки перестанут резолвить импорты ядра'); + } +} + +const viteConf = need(path.join(FRONT, 'vite.config.ts'), 'фронт'); +if (viteConf) { + for (const [re, label] of [[/'@core'\s*:/, '@core'], [/'@'\s*:/, '@']]) { + if (re.test(viteConf)) ok(`vite: алиас ${label}`); + else err(`vite.config.ts: нет алиаса ${label}`, 'сборка не найдёт то, что находит tsc'); + } +} + +// ─── 2. вендоренная копия ядра ────────────────────────────────────────────── +const BROWSER_CORE = path.join(FRONT, 'src/core'); + +if (!fs.existsSync(BROWSER_CORE)) { + err('нет frontend/src/core', 'ядро ни разу не приезжало — запусти npm run update'); +} + +// Хэш дерева: имена файлов и содержимое, в устойчивом порядке. +function treeHash(dir) { + if (!fs.existsSync(dir)) return null; + const h = crypto.createHash('sha256'); + const walk = (d, prefix) => { + for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) { + if (e.name === '.DS_Store') continue; // Finder кладёт его в любую папку + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p, `${prefix}${e.name}/`); + else { h.update(`${prefix}${e.name}\0`); h.update(fs.readFileSync(p)); } + } + }; + walk(dir, ''); + return h.digest('hex'); +} + +// Ядро ищется тем же перебором, что в update.sh — порядок от ближнего к дальнему. +function findEngine() { + if (process.env.MA7ENGINE) return process.env.MA7ENGINE; + if (IS_TEMPLATE) return PANEL; + for (const c of [path.join(FRONT, '../ma7engine'), + path.join(FRONT, '../../ma7engine'), + path.join(FRONT, '../../../ma7engine'), + path.join(FRONT, '../../../admins/ma7engine')]) { + if (fs.existsSync(path.join(c, 'core/browser'))) return path.resolve(c); + } + return null; +} + +const engine = findEngine(); +if (!engine) { + skip('сверка копии ядра — ma7engine рядом не найден (укажи MA7ENGINE=/путь)'); +} else { + for (const [copy, src, label] of [[BROWSER_CORE, path.join(engine, 'core/browser'), 'браузерная'], + [SERVER_CORE, path.join(engine, 'core/server'), 'серверная']]) { + if (!fs.existsSync(copy)) { skip(`сверка: ${label} половина ядра в панель не приезжала`); continue; } + if (treeHash(copy) === treeHash(src)) ok(`копия ядра совпадает с ядром (${label})`); + else err(`копия ядра разошлась с ma7engine (${label}: ${path.relative(PANEL, copy)})`, + 'ядро правили руками либо копия устарела — npm run update перезальёт её'); + } +} + +// Ядро обязано ходить относительными путями: с @core внутри себя вендоренная +// копия потянула бы за собой требование к настройке потребителя. +for (const [dir, label] of [[BROWSER_CORE, 'browser'], [SERVER_CORE, 'server']]) { + if (!fs.existsSync(dir)) continue; + const bad = []; + const walk = (d) => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (/\.tsx?$/.test(e.name) && /from\s+'@(core)?\//.test(read(p) ?? '')) bad.push(p); + } + }; + walk(dir); + if (bad.length) err(`внутри копии ядра есть алиасные импорты (${label}): ` + + bad.map(p => path.relative(PANEL, p)).join(', '), + 'ядро должно импортировать себя относительными путями'); + else ok(`копия ядра ходит относительными путями (${label})`); +} + +// ─── 3. игнор-файлы ───────────────────────────────────────────────────────── +// Копия ядра обязана попадать и в репозиторий панели, и в образ: контекст +// сборки — каталог панели, ma7engine рядом при сборке нет. +// +// В самом ma7engine наоборот: там копия — артефакт `npm run update`, источник +// лежит в соседнем core/, и коммитить её значило бы держать ядро в одном +// репозитории трижды. +const gitignore = read(path.join(PANEL, '.gitignore')) ?? read(path.join(FRONT, '.gitignore')); +if (gitignore === null) { + skip('.gitignore — файла нет, проверять нечего'); +} else { + const ignoresCore = /(^|\/)(frontend\/)?src\/core\s*$/m.test(gitignore) + || /(^|\/)backend\/core\s*$/m.test(gitignore) + || /^core\/?\s*$/m.test(gitignore); + if (IS_TEMPLATE) { + if (ignoresCore) ok('.gitignore закрывает копию ядра (шаблон — источник рядом в core/)'); + else warn('шаблон: .gitignore не закрывает копию ядра', + 'в ma7engine она артефакт npm run update, а источник — соседний core/'); + } else if (ignoresCore) { + err('.gitignore исключает копию ядра из репозитория панели', + 'образ собирается из чистого checkout\'а, где ma7engine рядом нет — ' + + 'без закоммиченной копии сборка встанет на импорте @core'); + } else { + ok('копия ядра коммитится вместе с панелью'); + } +} + +// ─── 3b. образ ────────────────────────────────────────────────────────────── +const dockerfile = read(path.join(PANEL, 'Dockerfile')); +if (dockerfile === null) { + warn('нет Dockerfile', 'панель — один процесс и один образ; шаблон везёт готовый'); +} else { + if (/COPY --from=frontend .*dist/.test(dockerfile)) ok('Dockerfile кладёт собранный фронт в образ'); + else warn('Dockerfile не копирует frontend/dist из стадии сборки', + 'сервер отдаёт фронт сам — без dist панель поднимется пустой'); +} + +const dockerignore = read(path.join(PANEL, '.dockerignore')); +if (dockerignore === null) { + skip('.dockerignore — файла нет, проверять нечего'); +} else if (/(^|\/|\*)core\/?\s*$/m.test(dockerignore)) { + err('.dockerignore исключает core', 'в образ копия ядра должна приехать с исходниками — ' + + 'иначе сборка встанет на импорте @core'); +} else { + ok('.dockerignore не исключает копию ядра'); +} + +// ─── 4. вкладки ───────────────────────────────────────────────────────────── +// Те же инварианты, что buildTabs проверяет в рантайме, но до сборки. +const tabsDir = path.join(FRONT, 'src/tabs'); +if (!fs.existsSync(tabsDir)) { + warn('нет src/tabs', 'панель без вкладок покажет заглушку'); +} else { + // Имена иконок берутся только из тел карт ICONS и coreIcons — разбирать + // файл целиком значило бы ловить в известные и случайные `что-то: Ico…`, + // а ложная ошибка здесь хуже пропущенной: buildTabs всё равно поймает + // незарегистрированную иконку в рантайме. + const mapBody = (text, name) => { + const start = text.indexOf(`export const ${name}`); + if (start === -1) return null; + const open = text.indexOf('{', start), close = text.indexOf('\n};', open); + return open === -1 || close === -1 ? null : text.slice(open, close); + }; + const bodies = [mapBody(read(path.join(FRONT, 'src/components/icons.tsx')) ?? '', 'ICONS'), + mapBody(read(path.join(BROWSER_CORE, 'icons/index.tsx')) ?? '', 'coreIcons')]; + const known = bodies.some(b => b === null) + ? null // карту не нашли — проверку пропускаем + : new Set([...bodies.join('\n').matchAll(/(\w+)\s*:\s*Ico\w+/g)].map(m => m[1])); + if (known === null) skip('сверка иконок вкладок — карта ICONS не разобрана'); + + const ids = new Map(); + let clean = true; + + for (const e of fs.readdirSync(tabsDir, { withFileTypes: true })) { + if (!e.isDirectory()) continue; + const dir = path.join(tabsDir, e.name); + const metaPath = path.join(dir, 'metadata.json'); + if (!fs.existsSync(path.join(dir, 'index.tsx'))) { err(`tabs/${e.name}: нет index.tsx`); clean = false; continue; } + if (!fs.existsSync(metaPath)) { err(`tabs/${e.name}: нет metadata.json`); clean = false; continue; } + let meta; + try { meta = JSON.parse(read(metaPath)); } + catch { err(`tabs/${e.name}/metadata.json: не разбирается как JSON`); clean = false; continue; } + for (const key of ['id', 'label', 'icon']) { + if (typeof meta[key] !== 'string' || !meta[key].trim()) { + err(`tabs/${e.name}/metadata.json: поле "${key}" должно быть непустой строкой`); clean = false; + } + } + if (meta.id) { + if (ids.has(meta.id)) { err(`дублирующийся id вкладки "${meta.id}": ${ids.get(meta.id)} и ${e.name}`); clean = false; } + else ids.set(meta.id, e.name); + } + if (meta.icon && known && !known.has(meta.icon)) { + err(`tabs/${e.name}: иконка "${meta.icon}" не найдена в карте ICONS`); clean = false; + } + } + if (clean && ids.size) ok(`вкладки в порядке (${ids.size} шт.)`); +} + +// ─── 5. бэкенд ────────────────────────────────────────────────────────────── +const serverTs = read(path.join(BACK, 'server.ts')); +if (serverTs === null) { + skip('server.ts не найден — панель, видимо, без своего бэкенда'); +} else { + if (/from\s+'\.\/core'/.test(serverTs)) ok("server.ts импортирует './core'"); + else warn("server.ts не импортирует './core'", 'панель собирает сервер сама — это допустимо для панели-прокси'); + + const backPkgText = read(path.join(BACK, 'package.json')); + if (backPkgText) { + const deps = JSON.parse(backPkgText).dependencies ?? {}; + for (const dep of ['express', 'jsonwebtoken']) { + if (deps[dep]) ok(`бэкенд: ${dep} в зависимостях`); + else err(`backend/package.json: нет зависимости ${dep}`, + 'серверная половина ядра объявляет её peer-зависимостью'); + } + } +} + +// ─── 6. следы шаблона (предупреждения) ────────────────────────────────────── +if (!IS_TEMPLATE) { + const app = read(path.join(FRONT, 'src/App.tsx')) ?? ''; + if (/demo_token|demo_theme/.test(app)) { + warn('storageKeys остались демонстрационными (demo_token/demo_theme)', + 'две панели на одном домене затрут друг другу токен и тему'); + } + if (/name:\s*'Demo Panel'/.test(app)) warn("branding.name остался 'Demo Panel'"); + + // Заголовок вкладки браузера живёт отдельно от branding.name и виден до + // того, как смонтируется React, — забывается чаще всего. + const html = read(path.join(FRONT, 'index.html')) ?? ''; + if (/Demo Panel<\/title>/.test(html)) warn("index.html: <title> остался 'Demo Panel'"); + + for (const [pkgPath, label] of [[path.join(FRONT, 'package.json'), 'frontend'], + [path.join(BACK, 'package.json'), 'backend']]) { + const t = read(pkgPath); + if (t && /"ma7engine-example-/.test(t)) warn(`${label}/package.json: имя осталось от шаблона`); + } +} + +// Половины ставят TypeScript и @types/node врозь — расхождение мажоров значит, +// что tsc во фронте и в бэкенде проверяют код по разным правилам. +{ + const major = (r) => (r ?? '').replace(/^[^\d]*/, '').split('.')[0]; + const f = frontPkg?.devDependencies ?? {}; + const bText = read(path.join(BACK, 'package.json')); + const b = bText ? (JSON.parse(bText).devDependencies ?? {}) : {}; + for (const dep of ['typescript', '@types/node']) { + if (f[dep] && b[dep] && major(f[dep]) !== major(b[dep])) { + warn(`${dep} разъехался между половинами: ${f[dep]} и ${b[dep]}`); + } + } +} + +// ─── вывод ────────────────────────────────────────────────────────────────── +const rel = path.relative(process.cwd(), PANEL) || '.'; +console.log(`doctor: ${rel}${IS_TEMPLATE ? ' (шаблон ma7engine)' : ''}\n`); + +for (const m of oks) console.log(` ok ${m}`); +for (const m of skips) console.log(` — ${m}`); +for (const m of warns) console.log(` warn ${m}`); +for (const m of errors) console.log(` ОШИБКА ${m}`); + +console.log(); +if (errors.length) { + console.log(`doctor: ошибок ${errors.length}, предупреждений ${warns.length}`); + process.exit(1); +} +console.log(warns.length + ? `doctor: ошибок нет, предупреждений ${warns.length}` + : 'doctor: панель соответствует стандарту'); diff --git a/scripts/new-panel.mjs b/scripts/new-panel.mjs new file mode 100755 index 0000000..47ac599 --- /dev/null +++ b/scripts/new-panel.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node +// Заводит новую панель на этом ядре — копирует обе половины шаблона и +// подставляет то, что README раньше предписывал править руками: имена в +// package.json, брендинг и ключи localStorage. Забытые ключи — не мелочь: две +// панели на одном домене затирают друг другу токен и тему. +// +// npm run new-panel -- admin-hub # → ../admin-hub +// npm run new-panel -- admin-hub ~/work/hub # явный путь +// +// По умолчанию панель кладётся соседом ma7engine: именно там её найдёт перебор +// кандидатов в scripts/update.sh, и `npm run update` заработает без $MA7ENGINE. +// +// Не sh, в отличие от update.sh: здесь многострочная правка исходников, а не +// копирование каталогов, и sed по номерам строк ломается от любой правки +// шаблона. Node гарантированно есть — обе половины и так npm-проекты. +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.dirname(HERE); // …/ma7engine + +const die = (msg) => { console.error(`new-panel: ${msg}`); process.exit(1); }; + +// ─── аргументы ────────────────────────────────────────────────────────────── +const [slug, dirArg] = process.argv.slice(2); + +if (!slug || slug === '--help' || slug === '-h') { + console.log('Использование: npm run new-panel -- <slug> [каталог]\n' + + ' slug — имя панели строчными буквами через дефис, например admin-hub'); + process.exit(slug ? 0 : 1); +} +if (!/^[a-z][a-z0-9-]*$/.test(slug)) { + die(`slug "${slug}" негоден — строчные латинские буквы, цифры и дефис, начинается с буквы`); +} + +const target = path.resolve(dirArg ?? path.join(path.dirname(ROOT), slug)); +if (fs.existsSync(target) && fs.readdirSync(target).length > 0) { + die(`каталог ${target} существует и не пуст`); +} + +// admin-hub → admin_hub (ключи стораджа) и «Admin Hub» (брендинг) +const keyPrefix = slug.replace(/-/g, '_'); +const display = slug.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' '); + +// ─── копирование половин ──────────────────────────────────────────────────── +// Вендоренная копия ядра не копируется: она приедет своим `npm run update`. +// Так новая панель гарантированно получает актуальное ядро, а не слепок чужого. +const SKIP = new Set(['node_modules', 'dist', 'core', 'package-lock.json']); + +function copyTree(from, to) { + fs.mkdirSync(to, { recursive: true }); + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + if (SKIP.has(entry.name)) continue; + const src = path.join(from, entry.name); + const dst = path.join(to, entry.name); + if (entry.isDirectory()) copyTree(src, dst); + else fs.copyFileSync(src, dst); + } +} + +copyTree(path.join(ROOT, 'frontend'), path.join(target, 'frontend')); +copyTree(path.join(ROOT, 'backend'), path.join(target, 'backend')); + +// ─── подстановки ──────────────────────────────────────────────────────────── +// Каждая замена обязана сработать: молча пропущенная — это ровно тот случай, +// когда панель уезжает с чужим ключом стораджа и ловится потом руками. +function edit(relPath, pairs) { + const file = path.join(target, relPath); + let text = fs.readFileSync(file, 'utf8'); + for (const [from, to] of pairs) { + if (!text.includes(from)) die(`в ${relPath} не найдено для замены:\n---\n${from}\n---`); + text = text.replace(from, to); + } + fs.writeFileSync(file, text); +} + +edit('frontend/package.json', [['"ma7engine-example-frontend"', `"${slug}-frontend"`]]); +// Заголовок вкладки браузера — не то же, что branding.name в App.tsx: его видно +// до того, как React смонтируется, и в закладках. +edit('frontend/index.html', [['<title>Demo Panel', `${display}`]]); +edit('backend/package.json', [['"ma7engine-example-backend"', `"${slug}-backend"`]]); + +edit('frontend/src/App.tsx', [ + [`// Шаблон фронта панели — с этого начинается новый. Копируется вместе с +// ../backend; ядро в свою панель приезжает через \`npm run update\`. +// +// Всё, что здесь есть: конфиг. Логин, тема, drawer/сайдбар, снэкбар, сессия, +// меню по правам и переключение вкладок — в . +// +// Парная половина — backend/server.ts: вкладки ниже ходят в его +// маршруты /api/demo/*, так что две половины запускаются вместе и работают.`, + `// Фронт панели ${display}. Всё, что здесь есть: конфиг. Логин, тема, +// drawer/сайдбар, снэкбар, сессия, меню по правам и переключение вкладок — +// в из ядра. +// +// Ядро приезжает копией в src/core через \`npm run update\` (его зовут predev +// и prebuild). Парная половина — backend/server.ts.`], + + [`branding: { name: 'Demo Panel', version: '0.1.0' },`, + `branding: { name: '${display}', version: '0.1.0' },`], + + [`storageKeys: { token: 'demo_token', theme: 'demo_theme' },`, + `storageKeys: { token: '${keyPrefix}_token', theme: '${keyPrefix}_theme' },`], + + [` // slots: { aboveTab: ctx => ctx.tab?.id === 'docs' ? null : },`, + ` // slots: { aboveTab: ctx => ctx.tab?.id === 'kit' ? null : },`], +]); + +// ─── бэкенд: шапка, лог и вырезание демо-маршрутов ────────────────────────── +const serverFile = path.join(target, 'backend/server.ts'); +let server = fs.readFileSync(serverFile, 'utf8'); + +const headOld = `// Шаблон бэкенда панели — с этого начинается новый. Копируется вместе с +// ../frontend; ядро в свою панель приезжает через \`npm run update\`. +// +// Всё, что здесь есть: конфиг и свои маршруты. Логин, сессия, права, +// рейт-лимит, раздача SPA и порядок их монтирования — в createPanelServer.`; +const headNew = `// Бэкенд панели ${display}. Всё, что здесь есть: конфиг и свои маршруты. +// Логин, сессия, права, рейт-лимит, раздача SPA и порядок их монтирования — +// в createPanelServer. Ядро приезжает копией в ./core через \`npm run update\`, +// запущенный во фронте.`; +if (!server.includes(headOld)) die('backend/server.ts: не найдена шапка шаблона'); +server = server.replace(headOld, headNew); + +// mountRoutes с демо-маршрутами уезжает целиком: три роута /api/demo/* нужны +// только вкладкам hello и secrets, которых в новой панели не будет. Образец +// остаётся комментарием — вместе со строкой импорта, которая ему понадобится. +const startMark = '\n mountRoutes(app, { auth, forwardError }) {\n'; +const endMark = '\n },\n});\n'; +const start = server.indexOf(startMark); +const end = server.indexOf(endMark, start); +if (start === -1 || end === -1) die('backend/server.ts: не найден блок mountRoutes'); + +server = server.slice(0, start) + ` + // Маршруты панели. Монтируются ядром после логина и до статики — порядок + // зафиксирован внутри createPanelServer и снаружи не нарушается. + // + // import type { Request, Response } from 'express'; + // + // mountRoutes(app, { auth, forwardError }) { + // // Открыт любому залогиненному — у вкладки нет permission. + // app.get('/api/reports', auth.requireAuth, async (req: Request, res: Response) => { + // try { res.json(await db.reports()); } + // catch (e) { forwardError(res, e); } + // }); + // + // // Закрыт правом. Гейт на фронте (permission во вкладке) — это + // // только UX; настоящая граница здесь. + // app.post('/api/reports', auth.requireAuth, + // auth.requirePermission('reports.manage'), handler); + // }, +}); +` + server.slice(end + endMark.length); + +// Request/Response остались бы висеть неиспользованными — образец выше +// показывает, как вернуть импорт вместе с маршрутом. +const importLine = "import type { Request, Response } from 'express';\n"; +if (server.includes(importLine)) server = server.replace(importLine, ''); + +if (!server.includes('demo-panel: listening on')) die('backend/server.ts: не найдена строка лога'); +server = server.replace('demo-panel: listening on', `${slug}: listening on`); + +fs.writeFileSync(serverFile, server); + +// ─── демо-вкладки ─────────────────────────────────────────────────────────── +// hello и secrets ходят в /api/demo/*, которых больше нет — уезжают. docs +// (справочник по ядру) и kit (живой каталог классов дизайн-системы) +// самодостаточны и остаются: новая панель запускается непустой. +for (const tab of ['hello', 'secrets']) { + fs.rmSync(path.join(target, 'frontend/src/tabs', tab), { recursive: true, force: true }); +} + +// ─── файлы уровня панели ──────────────────────────────────────────────────── +// Копия ядра в панели — закоммиченный исходник, а не артефакт сборки: образ +// собирается из чистого checkout'а, где ma7engine рядом нет. Обновляется она +// осознанно, через `npm run update`, и приходит в ревью диффом; расхождение с +// ядром ловит `npm run doctor`. +fs.writeFileSync(path.join(target, '.gitignore'), +`node_modules/ + +# Сборка фронта — в образе он собирается заново. +frontend/dist + +# frontend/src/core и backend/core здесь НЕ игнорируются намеренно: копия ядра +# едет в репозиторий панели, иначе образ не собрать без ma7engine рядом. +`); + +// Dockerfile и .dockerignore едут из шаблона как есть: контекст сборки — каталог +// панели, поэтому ни одного пути в них править не нужно. +for (const f of ['Dockerfile', '.dockerignore']) { + fs.copyFileSync(path.join(ROOT, f), path.join(target, f)); +} + +fs.mkdirSync(path.join(target, 'scripts'), { recursive: true }); +const dev = fs.readFileSync(path.join(ROOT, 'scripts/demo.sh'), 'utf8') + .replace('# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане.', + `# Поднимает панель ${display} целиком: бэкенд в фоне, Vite на переднем плане.`) + .replace('# npm run demo (из корня ma7engine)\n', '') + .replace('# sh scripts/demo.sh (откуда угодно — пути считаются от самого скрипта)', + '# sh scripts/dev.sh (откуда угодно — пути считаются от самого скрипта)') + .replace('# Учётка по умолчанию — admin / demo. Переопределяется окружением:\n' + + '# ADMIN_USER=root ADMIN_PASS=secret npm run demo', + '# Учётка по умолчанию — admin / demo, только для дева. Переопределяется\n' + + '# окружением: ADMIN_USER=root ADMIN_PASS=secret sh scripts/dev.sh') + .replaceAll('demo: ', `${slug}: `); +fs.writeFileSync(path.join(target, 'scripts/dev.sh'), dev, { mode: 0o755 }); + +// ─── ядро ─────────────────────────────────────────────────────────────────── +// Копия ядра кладётся сразу: в панели она закоммиченный исходник, и панель +// должна собираться с первого коммита, а не после того, как кто-то вспомнит +// про `npm run update`. Путь к ядру известен — это мы сами. +try { + execFileSync('sh', [path.join(target, 'frontend/scripts/update.sh')], + { env: { ...process.env, MA7ENGINE: ROOT }, stdio: 'pipe' }); + console.log('new-panel: ядро скопировано в frontend/src/core и backend/core'); +} catch (e) { + console.error('new-panel: не удалось разложить ядро — запусти `npm run update` во фронте\n' + + String(e.stderr ?? e.message)); +} + +// ─── что дальше ───────────────────────────────────────────────────────────── +console.log(` +new-panel: панель ${display} создана в ${target} + + Дальше: + cd ${target}/frontend && npm i + cd ${target}/backend && npm i + cd ${target} && sh scripts/dev.sh # обе половины, фронт на :5173 + + Пароль и секрет обязательны — без них ядро не даст процессу подняться: + ADMIN_PASS=... ADMIN_JWT_SECRET=... + + Проверить, что панель собрана по стандарту: + cd ${target}/frontend && npm run doctor`); diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..32a728a --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,116 @@ +#!/bin/sh +# Обновляет вендоренную копию ядра (ma7engine) в панели: +# ma7engine/core/browser → <панель>/frontend/src/core (браузерная половина) +# ma7engine/core/server → <панель>/backend/core (серверная половина) +# +# Панель из одной папки, где сервер лежит рядом с фронтом (server.ts в корне, +# как в awg-ui), получает серверную часть в <панель>/core. +# +# Зачем копия, а не алиас прямо на соседнюю папку: контекст сборки образа +# панели — её собственный каталог (COPY frontend/ ./ в Dockerfile), и путь, +# уходящий выше него, внутри контейнера не разрешится. При копии пути +# ./src/core и ./core одинаковы в деве, в tsc --noEmit и в Docker, и Dockerfile +# трогать не нужно вообще. +# +# Файл кладётся в репозиторий потребителя (frontend/scripts/), потому что +# вызывать его нужно и тогда, когда ma7engine рядом нет. +# +# Использование: npm run update (или sh scripts/update.sh) +# Где искать ядро: $MA7ENGINE, иначе перебор кандидатов ниже — первый +# существующий выигрывает, выбранный путь печатается. +set -eu + +abspath() { (CDPATH= cd -- "$1" 2>/dev/null && pwd); } + +here=$(abspath "$(dirname -- "$0")") +app=$(dirname "$here") # .../<панель>/frontend или корень панели из одной папки +panel=$(dirname "$app") # .../<панель> или каталог, где лежат панели + +# Ядро может лежать где угодно относительно панели: рядом с продуктовым +# репозиторием, рядом с остальными панелями или внутри самого репозитория. +# Перебираем, вместо того чтобы зашивать одну раскладку — переезд ядра иначе +# молча уводит синхронизацию на старую копию. +# +# Порядок — от ближнего к дальнему: панель, у которой ядро лежит рядом, не +# должна утащить копию из каталога уровнем выше (такая копия обычно старая — +# именно от неё и уезжает синхронизация молча). +CANDIDATES=" +$app/../ma7engine +$app/../../ma7engine +$app/../../../ma7engine +$app/../../../admins/ma7engine +" + +find_engine() { + if [ -n "${MA7ENGINE:-}" ]; then + if [ ! -d "$MA7ENGINE/core/browser" ]; then + echo "update: в MA7ENGINE=$MA7ENGINE нет каталога core/browser" >&2 + exit 1 + fi + abspath "$MA7ENGINE" + return + fi + for candidate in $CANDIDATES; do + if [ -d "$candidate/core/browser" ]; then + abspath "$candidate" + return + fi + done +} + +# Куда класть серверную половину. Панели без своего сервера не получают её вовсе. +server_target() { + if [ -d "$panel/backend" ]; then echo "$panel/backend/core" + elif [ -f "$app/server.ts" ]; then echo "$app/core" + fi +} + +# Скрипт обязан запускаться из фронта панели: $app определяется как каталог +# уровнем выше scripts/, и запущенный не оттуда он молча создаёт постороннюю +# src/core рядом с чем угодно. index.html — маркер vite-фронта. +if [ ! -f "$app/index.html" ]; then + echo "update: $app не похож на фронт панели (нет index.html)." >&2 + echo " Скрипт запускается из <панель>/frontend: npm run update" >&2 + exit 1 +fi + +front_to=$app/src/core +server_to=$(server_target) + +engine=$(find_engine) + +if [ -z "$engine" ]; then + # В контейнере ядра нет — оно уже приехало внутри src/core (и backend/core) + # вместе с остальными исходниками. Это норма, а не ошибка сборки. + if [ -d "$front_to" ] || { [ -n "$server_to" ] && [ -d "$server_to" ]; }; then + echo "update: ma7engine рядом не найден, использую уже синхронизированные копии" + exit 0 + fi + echo "update: ma7engine не найден ни в одном из мест:" >&2 + for candidate in $CANDIDATES; do echo " $candidate" >&2; done + echo " Укажи путь через MA7ENGINE=/путь/к/ma7engine" >&2 + exit 1 +fi + +echo "update: ядро — $engine" + +copy() { # copy <откуда> <куда> <подпись> + [ -d "$1" ] || return 0 + rm -rf "$2" + mkdir -p "$(dirname "$2")" + cp -R "$1" "$2" + echo " $3: $1 → $2" +} + +copy "$engine/core/browser" "$front_to" "фронт" +if [ -n "$server_to" ]; then + copy "$engine/core/server" "$server_to" "сервер" +fi + +# Доктор едет вместе с ядром: проверки в панели должны быть те же, что в ядре, +# иначе панель отчитывается о соответствии по устаревшему списку. Кладётся +# рядом с этим скриптом — оттуда его зовёт `npm run doctor`. +if [ -f "$engine/scripts/doctor.mjs" ]; then + cp "$engine/scripts/doctor.mjs" "$here/doctor.mjs" + echo " доктор: $engine/scripts/doctor.mjs → $here/doctor.mjs" +fi diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b4e1600 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["vite/client"] + }, + "include": ["core/browser"] +} diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..ef2794d --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["core/server"] +}