Dockerfile и .dockerignore: панель — один процесс и один образ, express отдаёт и API, и собранный фронт. Контекст сборки — каталог панели, ядро приезжает в образ закоммиченным, поэтому образ собирается из чистого checkout'а без ma7engine рядом. Скаффолдер больше не кладёт панели update.sh и хуки predev/prebuild: ядро в продукт приходит через git subtree, а копирование затёрло бы поддерево. Печатает готовые команды subtree add. Доктор различает два уклада ядра — subtree в продукте и копию в шаблоне — и проверяет каждый по своим правилам. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
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 шаблон бэкенда: конфиг + три демо-маршрута
Dockerfile один процесс и один образ: express отдаёт API и фронт
scripts/new-panel.mjs заводит новую панель из шаблона (npm run new-panel)
scripts/release.sh split-ветки половин ядра для subtree (npm run release)
scripts/doctor.mjs проверяет, что панель по стандарту (npm run doctor)
scripts/update.sh раскладывает ядро внутри шаблона (npm run update)
Половины лежат врозь не по вкусу, а потому что это две разные сборки: у них
разные 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,
не из каталога, где он лежит:
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
терминал не возвращает:
# терминал 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:
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.
Как из него сделать свою панель
Одной командой:
npm run new-panel -- admin-hub # → ../admin-hub, соседом ma7engine
npm run new-panel -- admin-hub ~/work/hub # явный путь
Панель — надстройка: она лежит подкаталогом продуктового репозитория
(ghosthub/admin, awg-control/admin), а не самостоятельным проектом.
Скаффолдер копирует frontend/ и backend/, но не core/: ядро приходит
отдельно, через git subtree — см. следующий раздел.
Что он делает за тебя — ровно то, что раньше правилось руками и потому забывалось:
- Имена в
package.jsonобеих половин. - Брендинг и ключи
localStorageвApp.tsx—admin-hubдаётadmin_hub_tokenиadmin_hub_theme. Забытые ключи не абстрактны: две панели на одном домене затирают друг другу токен и тему. - Демо-часть: вкладки
helloиsecretsи маршруты/api/demo/*уезжают,mountRoutesостаётся пустым с образцом в комментарии.docsиkitостаются — обе самодостаточны, и панель запускается непустой. .gitignore,Dockerfile,.dockerignoreиscripts/dev.sh.
Остаётся только учётка и секрет — ADMIN_USER, ADMIN_PASS (или
ADMIN_PASS_HASH), ADMIN_JWT_SECRET в окружении бэкенда; без них ядро не даст
процессу подняться.
Ядро в продукт — git subtree
У ядра две половины и два адресата, а git subtree работает с одним prefix за
раз, поэтому каждая половина выделяется в свою производную ветку:
npm run release # core/browser → dist/browser, core/server → dist/server
В репозитории продукта, где панель лежит подкаталогом admin/:
git subtree add --prefix=admin/frontend/src/core <ядро> dist/browser --squash
git subtree add --prefix=admin/backend/core <ядро> dist/server --squash
git subtree pull --prefix=admin/frontend/src/core <ядро> dist/browser --squash
git subtree pull --prefix=admin/backend/core <ядро> dist/server --squash
<ядро> — путь к ma7engine либо URL его репозитория.
Почему поддерево, а не копирование. update.sh сносил каталог целиком
(rm -rf + cp -R): локальная правка ядра в продукте исчезала молча, истории
не оставалось, и «панель разошлась с ядром» выяснялось постфактум. git subtree
делает настоящий merge — встречные правки одной строки дают конфликт в момент
обновления, а правку, сделанную в продукте, можно вернуть в ядро
(git subtree push). Ядро при этом лежит в репозитории продукта закоммиченным,
поэтому образ собирается из чистого checkout'а, без ma7engine рядом.
Ветки dist/* полностью производные: руками не правятся и не мержатся обратно —
правится core/, после чего npm run release гоняется снова.
В самом ma7engine ядро по-прежнему раскладывается копией (npm run update
из соседнего core/) — там источник лежит в том же репозитории, и поддерево
было бы поддеревом самого себя. Доктор различает эти два уклада и проверяет
каждый по своим правилам; в панели он ругается на оставшийся update.sh, потому
что тот затёр бы поддерево.
Проверка соответствия — npm run doctor
Скаффолдер заводит панель по стандарту, доктор отвечает, соответствует ли она ему до сих пор:
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 трогать не нужно.
cp <путь>/ma7engine/scripts/update.sh admin/frontend/scripts/
cd admin/frontend && npm run update # или sh scripts/update.sh
// package.json — обновление руками и перед каждым дев-запуском и сборкой
"scripts": {
"update": "sh scripts/update.sh",
"predev": "npm run update",
"prebuild": "npm run update"
}
// tsconfig.json
"baseUrl": ".",
"paths": {
"@core/*": ["./src/core/*"], // ядро
"@/*": ["./src/*"] // свой код панели
}
// 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/... остаются, меняется только строка резолва.
Точка сборки панели
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 строк:
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 целиком, продуктовые типы и утилиты,
вкладки, бэкенды. Компонент, нужный одной панели, остаётся в ней: обобщать по
одному потребителю рано. Понадобился второй — тогда и переезжает.
Сервер
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
сама из кусков ядра:
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 остались своими.