maenekoandClaude Opus 5 0c7fee73a6 release.sh: split-ветки половин ядра для git subtree
core/browser → dist/browser, core/server → dist/server. Продукты тянут
каждую в свой prefix; ветки производные и руками не правятся.

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

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, не из каталога, где он лежит:

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   # явный путь

Скаффолдер копирует 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

Скаффолдер заводит панель по стандарту, доктор отвечает, соответствует ли она ему до сих пор:

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

S
Description
No description provided
Readme
183 KiB
Languages
TypeScript 58%
JavaScript 20.9%
CSS 10.5%
Shell 8.8%
Dockerfile 1.6%
Other 0.2%