ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
@@ -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/ <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` остались своими.
|
||||
Reference in New Issue
Block a user