# 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` остались своими.