Files
ma7engine/README.md
T
maenekoandClaude Opus 5 18bd05bcbb ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:39:37 +03:00

493 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ma7engine
Общее ядро админ-панелей: оболочка, реестр вкладок и дизайн-система. Родилось из
трёх форков одного каркаса (`admin-hub`, `admin-ma7`, `awg-ui`), где ~3600 строк
жили в трёх копиях: 77 из 78 CSS-правил и 194 из 197 строк `App.tsx` совпадали
побайтово.
```
core/ ядро; половины разъезжаются по панели врозь
browser/ браузер → <панель>/frontend/src/core
shell/ <Panel config={…} /> — логин, тема, 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:*`. Пересечение — ноль файлов.
## Что уже общее
Всё перечисленное панель получает готовым и у себя не пишет. Список
исчерпывающий: чего в нём нет — того нет и в ядре, значит это продуктовый код
конкретной панели.
### Оболочка
`<Panel config={…} />` — один и тот же код у панели с ролями и без, разница
только в конфиге:
- **Вход.** Форма логина с брендингом, `POST /login`, разбор ответа: 401 —
«Неверный логин или пароль», 429 — текст лимита, пришедший с сервера,
остальное — «Сервер недоступен».
- **Сессия.** Токен кладётся в `localStorage` и восстанавливается при загрузке;
протухший стирается молча, панель показывает логин.
- **Единый разлогин по 401.** Любой `apiFetch` из любой вкладки, получивший 401,
разлогинивает панель целиком — вкладки этот случай не обрабатывают.
- **Выход.** `POST /logout` (сервер отзывает токен), очистка стораджа и
состояния.
- **Тема.** Кнопка-переключатель, `data-theme` на `<html>`, запоминание выбора.
- **Меню.** Строится из вкладок, доступных правам текущей учётки, сгруппированных
по категориям; пустой список даёт заглушку «вкладок пока нет» или «ни одна
вкладка не доступна твоим правам».
- **Мобильный режим.** Топ-бар с гамбургером, 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 () => <Panel config={config} />;
```
## Реестр вкладок
`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` остались своими.