4 Commits
Author SHA1 Message Date
maenekoandClaude Opus 5 bb56e9dc3b Панель как надстройка: образ, subtree и разделение двух укладов
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
2026-08-26 20:46:26 +03:00
maenekoandClaude Opus 5 7977ea6738 release.sh: переставлять split-ветки принудительно
git subtree split -b отказывается двигать существующую ветку, если новый
коммит ей не потомок — после reset в ядре ветка молча оставалась старой,
и продукт тянул устаревшую половину. Ветки производные, поэтому branch -f.

Поймано doctor'ом: сверка поддерева с ядром показала расхождение серверной
половины при совпадающей браузерной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:45:08 +03:00
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
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
51 changed files with 7064 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Что не должно попадать в контекст сборки.
node_modules
**/node_modules
.git
.DS_Store
*.log
# Локальная сборка фронта: в образе он собирается заново.
frontend/dist
# Вендоренная копия ядра здесь НЕ исключается и исключаться не должна: в образ
# она обязана приехать вместе с исходниками, иначе сборка встанет на импорте
# @core. Ровно эту ошибку проверяет `npm run doctor`.
+13
View File
@@ -0,0 +1,13 @@
node_modules/
.DS_Store
# Сборка шаблона
frontend/dist
# Вендоренная копия ядра в шаблоне (npm run update)
frontend/src/core
backend/core
# Доктор в шаблоне — копия scripts/doctor.mjs, её кладёт тот же update.sh.
# В настоящей панели он, наоборот, коммитится: там ma7engine рядом может не быть.
frontend/scripts/doctor.mjs
+56
View File
@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1
# Панель — один процесс и один образ: express отдаёт и API, и собранный фронт
# (core/server/spa.ts), отдельный nginx ей не нужен.
#
# Контекст сборки — каталог самой панели, и ничего выше него не требуется:
# вендоренная копия ядра закоммичена вместе с исходниками, поэтому образ
# собирается из чистого checkout'а, без ma7engine рядом. Обновляется копия
# отдельно и осознанно — `npm run update` во фронте, диффом в ревью.
#
# docker build -t admin-hub .
# docker run -p 8080:8080 -e ADMIN_PASS=… -e ADMIN_JWT_SECRET=… admin-hub
# ─── фронт: собирается и остаётся только как dist ───────────────────────────
FROM node:24-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# prebuild зовёт update.sh. Ядра рядом в образе нет, и скрипт штатно берёт уже
# синхронизированную копию из src/core — это его нормальная ветка, не ошибка.
RUN npm run build
# ─── рантайм: бэкенд плюс собранный фронт ───────────────────────────────────
FROM node:24-alpine AS runtime
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json ./
# tsx лежит в dependencies намеренно: сервер и в деве, и в образе стартует одной
# и той же командой. Компиляция в JS дала бы образ меньше, но развела бы дев и
# прод, а в этой панели пути и команды всюду одинаковы — на том же основании
# ядро ездит копией, а не алиасом.
RUN npm ci --omit=dev
COPY backend/ ./
# staticDir по умолчанию — ../frontend/dist от каталога сервера.
COPY --from=frontend /app/frontend/dist /app/frontend/dist
ENV NODE_ENV=production
ENV PORT=8080
EXPOSE 8080
# NODE_ENV=production включает разбор X-Forwarded-For в рейт-лимите: за прокси
# без него все клиенты схлопываются в один ключ и блокируют вход всем сразу.
#
# ADMIN_PASS (или ADMIN_PASS_HASH) и ADMIN_JWT_SECRET значений по умолчанию не
# имеют — без них процесс не поднимется. Это не недосмотр: тихо работающая
# панель с паролем по умолчанию хуже не стартовавшей.
USER node
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${PORT}/" >/dev/null 2>&1 || exit 1
CMD ["npm", "start"]
+532
View File
@@ -0,0 +1,532 @@
# 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`**,
не из каталога, где он лежит:
```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 # явный путь
```
Панель — надстройка: она лежит подкаталогом продуктового репозитория
(`ghosthub/admin`, `awg-control/admin`), а не самостоятельным проектом.
Скаффолдер копирует **`frontend/` и `backend/`**, но не `core/`: ядро приходит
отдельно, через `git subtree` — см. следующий раздел.
Что он делает за тебя — ровно то, что раньше правилось руками и потому забывалось:
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`**, `Dockerfile`, `.dockerignore` и `scripts/dev.sh`.
Остаётся только **учётка и секрет** — `ADMIN_USER`, `ADMIN_PASS` (или
`ADMIN_PASS_HASH`), `ADMIN_JWT_SECRET` в окружении бэкенда; без них ядро не даст
процессу подняться.
### Ядро в продукт — `git subtree`
У ядра две половины и два адресата, а `git subtree` работает с одним `prefix` за
раз, поэтому каждая половина выделяется в свою производную ветку:
```sh
npm run release # core/browser → dist/browser, core/server → dist/server
```
В репозитории продукта, где панель лежит подкаталогом `admin/`:
```sh
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`
Скаффолдер заводит панель по стандарту, доктор отвечает, соответствует ли она
ему до сих пор:
```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` остались своими.
+1629
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "ma7engine-example-backend",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "tsx server.ts",
"dev": "tsx watch server.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"express": "^4.19.0",
"jsonwebtoken": "^9.0.0",
"tsx": "^4.7.0"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^26.3.0",
"typescript": "^5.4.0"
}
}
+63
View File
@@ -0,0 +1,63 @@
// Шаблон бэкенда панели — с этого начинается новый. Копируется вместе с
// ../frontend; ядро в свою панель приезжает через `npm run update`.
//
// Всё, что здесь есть: конфиг и свои маршруты. Логин, сессия, права,
// рейт-лимит, раздача SPA и порядок их монтирования — в createPanelServer.
//
// Запуск:
// ADMIN_PASS=... ADMIN_JWT_SECRET=... npm start
//
// Панель-прокси, у которой своих учёток нет (авторизация живёт в основном
// продукте), createPanelServer не использует и собирает express сама из кусков
// ядра — см. раздел про панель-прокси в README.
import path from 'node:path';
import type { Request, Response } from 'express';
// Ядро — вендоренная копия в ./core: её кладёт `npm run update`, запущенный во
// фронте (он же predev/prebuild). Поэтому express и jsonwebtoken резолвятся из
// backend/node_modules, как в любой панели.
import { createPanelServer } from './core';
const PORT = Number(process.env.PORT) || 8080;
const app = createPanelServer({
auth: {
user: process.env.ADMIN_USER ?? 'admin',
password: process.env.ADMIN_PASS,
passwordHash: process.env.ADMIN_PASS_HASH,
secret: process.env.ADMIN_JWT_SECRET ?? '',
ttl: '12h',
revocable: true,
// claims не задан — вошедший получает permissions: ['*'], то есть
// видит все вкладки. Появятся учётки с разным доступом — размечать
// здесь.
},
// Раскладка как в панелях: <панель>/backend рядом с <панель>/frontend.
staticDir: process.env.STATIC_DIR ?? path.join(__dirname, '..', 'frontend', 'dist'),
mountRoutes(app, { auth, forwardError }) {
const { requireAuth, requirePermission, getSession } = auth;
// Открыт любому залогиненному — у вкладки в metadata.json нет permission.
app.get('/api/demo/whoami', requireAuth, (req: Request, res: Response) => {
res.json(getSession(req));
});
// Закрыт правом. Гейт на фронте (permission во вкладке) — это только
// UX; настоящая граница здесь.
app.get('/api/demo/secrets', requireAuth, requirePermission('secrets.view'),
(_req: Request, res: Response) => {
res.json({ items: ['первый', 'второй'] });
});
// Поход в вышестоящий API: его статус и текст пробрасываются клиенту,
// а не превращаются в 500.
app.get('/api/demo/upstream', requireAuth, async (_req: Request, res: Response) => {
try {
throw { response: { status: 404, data: { message: 'Не найдено наверху' } } };
} catch (e) { forwardError(res, e); }
});
},
});
app.listen(PORT, () => console.log(`demo-panel: listening on :${PORT}`));
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["server.ts"]
}
+86
View File
@@ -0,0 +1,86 @@
// Мидлвары авторизации: «пускать ли вообще» и «пускать ли к этому разделу».
//
// Проверка прав на фронте (permission в metadata.json вкладки) — это только
// UX: не показывать то, что всё равно отклонит сервер. Настоящая граница здесь,
// и роут без requirePermission открыт всем залогиненным независимо от того, как
// размечена вкладка.
//
// Откуда берутся права — развилка без единственно верного ответа, поэтому она
// вынесена в конфиг (loadPermissions):
// из клеймов токена — дёшево, но снятое право действует к истечению TTL;
// из БД на запрос — дороже, зато отзыв мгновенный.
import type { NextFunction, Request, Response } from 'express';
import type { SessionClaims, SessionStore } from './session';
export const OWNER_PERMISSION = '*';
// Ключ на объекте запроса. Symbol, а не req.session, чтобы не столкнуться с
// express-session и другими мидлварами, занимающими это имя.
const SESSION = Symbol.for('ma7engine.session');
export type Middleware = (req: Request, res: Response, next: NextFunction) => void;
export interface AuthOptions {
sessions: SessionStore;
/**
* Права на конкретный запрос. По умолчанию — из клеймов токена.
* Верни свежие права из БД, если нужен мгновенный отзыв.
*/
loadPermissions?: (claims: SessionClaims, req: Request) => string[] | Promise<string[]>;
/** Тексты отказов — панели отдают ошибки как {message}. */
messages?: { unauthorized?: string; forbidden?: string };
}
export interface Auth {
/** Пускает только с живым токеном; кладёт клеймы в запрос. */
requireAuth: Middleware;
/** Пускает только с указанным правом (или '*'). Ставится после requireAuth. */
requirePermission: (permission: string) => Middleware;
/** Клеймы текущего запроса. Осмысленны только после requireAuth. */
getSession: (req: Request) => SessionClaims;
/** Bearer-токен запроса — нужен, например, чтобы отозвать его на логауте. */
bearerToken: (req: Request) => string;
}
export function bearerToken(req: Request): string {
const header = req.headers.authorization ?? '';
return header.startsWith('Bearer ') ? header.slice(7) : '';
}
export function createAuth(options: AuthOptions): Auth {
const { sessions, loadPermissions } = options;
const unauthorized = options.messages?.unauthorized ?? 'Не авторизован';
const forbidden = options.messages?.forbidden ?? 'Недостаточно прав';
const getSession = (req: Request): SessionClaims =>
(req as Request & { [SESSION]?: SessionClaims })[SESSION] as SessionClaims;
const requireAuth: Middleware = (req, res, next) => {
const claims = sessions.verify(bearerToken(req));
if (!claims) {
res.status(401).json({ message: unauthorized });
return;
}
(req as Request & { [SESSION]?: SessionClaims })[SESSION] = claims;
next();
};
const requirePermission = (permission: string): Middleware => (req, res, next) => {
const claims = getSession(req);
if (!claims) {
res.status(401).json({ message: unauthorized });
return;
}
Promise.resolve(loadPermissions ? loadPermissions(claims, req) : claims.permissions)
.then((permissions) => {
if (permissions.includes(OWNER_PERMISSION) || permissions.includes(permission)) {
next();
return;
}
res.status(403).json({ message: forbidden });
})
.catch(next);
};
return { requireAuth, requirePermission, getSession, bearerToken };
}
+37
View File
@@ -0,0 +1,37 @@
// Проброс ошибок из вышестоящего API наружу.
//
// Панель, которая ходит за данными в основной API продукта, должна отдать
// клиенту его статус и текст, а не превратить всё в 500: «пользователь не
// найден» и «API лежит» — разные ситуации для оператора.
//
// Форма ошибки axios здесь проверяется по структуре, а не через
// axios.isAxiosError: ядру не нужна зависимость от axios ради одной проверки,
// а панель может ходить наверх и через fetch.
import type { Response } from 'express';
interface UpstreamError {
response?: {
status?: number;
data?: { message?: string; error?: string };
};
}
export interface ForwardErrorOptions {
/** Текст, когда вышестоящий API недоступен вовсе. */
unavailable?: string;
/** Текст, когда API ответил ошибкой без внятного сообщения. */
fallback?: string;
}
export function forwardError(res: Response, error: unknown, options: ForwardErrorOptions = {}) {
const unavailable = options.unavailable ?? 'Вышестоящий API недоступен';
const fallback = options.fallback ?? 'Ошибка API';
const response = (error as UpstreamError)?.response;
if (response && typeof response.status === 'number') {
const data = response.data;
res.status(response.status).json({ message: data?.message ?? data?.error ?? fallback });
return;
}
res.status(502).json({ message: unavailable });
}
+128
View File
@@ -0,0 +1,128 @@
// Серверная половина ядра панели: логин, сессия, права, рейт-лимит и раздача
// собранного фронта. Всё, что во всех панелях было одинаковым, — здесь;
// продуктовые маршруты остаются в самой панели.
//
// Авторизация есть всегда и приезжает готовой: задай
// auth: { user, password | passwordHash, secret } — и панель сама выпускает и
// проверяет токен, монтирует /login, /logout и /api/session. Своей авторизации
// панель не пишет.
//
// Панель, у которой собственных учёток нет вовсе (логин форвардится в основной
// продукт, где живут настоящие учётки), createPanelServer не использует —
// она собирает express сама из кусков ядра: createLoginRateLimit на маршрут
// логина, свои прокси-маршруты, serveSpa последним. См. раздел про
// панель-прокси в README.
//
// Порядок монтирования фиксирован и потому не может быть нарушен снаружи:
// json → логин/сессия → маршруты панели → статика с fallback. Fallback ловит
// любой GET, поэтому маршрут после него уже не сработал бы.
import express, { type Express, type Request, type Response } from 'express';
import { createLoginRateLimit, type LoginRateLimit, type RateLimitOptions } from './ratelimit';
import { createPasswordCheck, type PasswordOptions } from './password';
import { createSessionStore, type SessionClaims, type SessionOptions, type SessionStore } from './session';
import { createAuth, type Auth } from './auth';
import { forwardError } from './errors';
import { serveSpa } from './spa';
const LOGIN_PATH = '/login';
const LOGOUT_PATH = '/logout';
const SESSION_PATH = '/api/session';
export interface LocalAuthOptions extends PasswordOptions, Omit<SessionOptions, 'secret'> {
/** Логин единственной учётки панели. */
user: string;
/** Секрет подписи сессий — свой, отдельный от секретов основного продукта. */
secret: string;
/**
* Клеймы, которые получит вошедший. По умолчанию — владелец: permissions
* ['*'], то есть доступ ко всем вкладкам. Появится несколько учёток —
* здесь и появится разметка.
*/
claims?: Partial<Omit<SessionClaims, 'user'>>;
}
export interface PanelServerOptions {
/** Учётка панели. Обязательна: панель без авторизации ядром не собирается. */
auth: LocalAuthOptions;
/** Каталог со сборкой фронта. Без него статика не монтируется. */
staticDir?: string;
rateLimit?: RateLimitOptions;
/** Маршруты самой панели. Монтируются после логина и до статики. */
mountRoutes?: (app: Express, ctx: PanelServerContext) => void;
}
export interface PanelServerContext {
app: Express;
rateLimit: LoginRateLimit;
sessions: SessionStore;
auth: Auth;
forwardError: typeof forwardError;
}
export function createPanelServer(options: PanelServerOptions): Express {
const app = express();
const cfg = options.auth;
app.use(express.json());
const rateLimit = createLoginRateLimit(options.rateLimit);
// Обе проверки бросают при негодной конфигурации — панель не поднимется
// с паролем по умолчанию или с известным секретом.
const password = createPasswordCheck(cfg);
const sessions = createSessionStore({
secret: cfg.secret,
ttl: cfg.ttl,
revocable: cfg.revocable,
revocationStore: cfg.revocationStore,
});
const auth = createAuth({ sessions });
app.post(LOGIN_PATH, rateLimit.middleware, (req: Request, res: Response) => {
const { user, pass } = (req.body ?? {}) as { user?: string; pass?: string };
// Пароль проверяется всегда, даже когда логин уже не совпал: иначе
// по времени ответа отличается «нет такого пользователя» от
// «пароль неверный».
const passOk = password.verify(pass ?? '');
const userOk = typeof user === 'string' && user === cfg.user;
if (!userOk || !passOk) {
res.status(401).json({ message: 'Неверный логин или пароль' });
return;
}
rateLimit.clear(req);
res.json({
token: sessions.issue({
user: cfg.user,
userId: cfg.claims?.userId ?? '1',
role: cfg.claims?.role ?? 'admin',
permissions: cfg.claims?.permissions ?? ['*'],
}),
});
});
app.post(LOGOUT_PATH, auth.requireAuth, (req: Request, res: Response) => {
sessions.revoke(auth.bearerToken(req));
res.status(204).end();
});
app.get(SESSION_PATH, auth.requireAuth, (req: Request, res: Response) => {
res.json(auth.getSession(req));
});
options.mountRoutes?.(app, { app, rateLimit, sessions, auth, forwardError });
if (options.staticDir) serveSpa(app, options.staticDir);
return app;
}
export { createLoginRateLimit, clientIp } from './ratelimit';
export { createPasswordCheck, hashPassword, timingSafeEquals } from './password';
export { createSessionStore } from './session';
export { createAuth, bearerToken, OWNER_PERMISSION } from './auth';
export { forwardError } from './errors';
export { serveSpa } from './spa';
export type { RateLimitOptions, LoginRateLimit } from './ratelimit';
export type { PasswordOptions, PasswordCheck } from './password';
export type { SessionClaims, SessionOptions, SessionStore, RevocationStore } from './session';
export type { Auth, AuthOptions, Middleware } from './auth';
+82
View File
@@ -0,0 +1,82 @@
// Проверка пароля единственной учётки панели.
//
// Две вещи, которые в панелях были сделаны по-разному, здесь сведены к одному
// поведению:
//
// 1. Сравнение. Везде было плоское `pass !== ADMIN_PASS` — время сравнения
// строк зависит от длины общего префикса. Здесь сравниваются SHA-256
// дайджесты через timingSafeEqual: одинаковая длина (иначе timingSafeEqual
// бросает) и постоянное время.
//
// 2. Ненастроенный пароль. Одна панель падала в открытую (дефолт "admin" и
// предупреждение в консоль), другая — в закрытую (вход запрещён). Ядро
// падает в закрытую и максимально рано: не на первом логине, а при старте
// процесса. Тихо работающая панель с паролем по умолчанию — худший из
// возможных исходов.
import crypto from 'node:crypto';
/** Сравнение за постоянное время. Строки произвольной длины. */
export function timingSafeEquals(a: string, b: string): boolean {
const ha = crypto.createHash('sha256').update(a).digest();
const hb = crypto.createHash('sha256').update(b).digest();
return crypto.timingSafeEqual(ha, hb);
}
export interface PasswordOptions {
/** Пароль открытым текстом (обычно из ADMIN_PASS). */
password?: string;
/**
* Либо хэш вида `scrypt:<salt-hex>:<key-hex>` (обычно из ADMIN_PASS_HASH) —
* тогда открытый пароль не хранится в окружении вовсе.
* Сгенерировать: `node -e "…"` — см. hashPassword ниже.
*/
passwordHash?: string;
}
export interface PasswordCheck {
verify: (input: string) => boolean;
/** Как настроен пароль — для строки в логе при старте. */
kind: 'plain' | 'scrypt';
}
const SCRYPT_PREFIX = 'scrypt:';
/** Готовый ADMIN_PASS_HASH для указанного пароля. */
export function hashPassword(password: string): string {
const salt = crypto.randomBytes(16);
const key = crypto.scryptSync(password, salt, 32);
return `${SCRYPT_PREFIX}${salt.toString('hex')}:${key.toString('hex')}`;
}
export function createPasswordCheck(options: PasswordOptions): PasswordCheck {
const { password, passwordHash } = options;
if (passwordHash) {
if (!passwordHash.startsWith(SCRYPT_PREFIX)) {
throw new Error('panel-server: passwordHash должен начинаться с "scrypt:" — см. hashPassword()');
}
const [, saltHex, keyHex] = passwordHash.split(':');
if (!saltHex || !keyHex) {
throw new Error('panel-server: passwordHash повреждён, ожидается scrypt:<salt-hex>:<key-hex>');
}
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(keyHex, 'hex');
return {
kind: 'scrypt',
verify(input) {
const actual = crypto.scryptSync(input, salt, expected.length);
return crypto.timingSafeEqual(actual, expected);
},
};
}
if (!password) {
throw new Error(
'panel-server: пароль панели не задан.\n' +
' Укажи ADMIN_PASS (или ADMIN_PASS_HASH — тогда открытого пароля в окружении не будет).\n' +
' Значения по умолчанию здесь намеренно нет: панель с паролем "admin" опаснее, чем не поднявшаяся панель.',
);
}
return { kind: 'plain', verify: (input) => timingSafeEquals(input, password) };
}
+83
View File
@@ -0,0 +1,83 @@
// Ограничение перебора пароля на /login. Во всех трёх панелях этот код был
// скопирован дословно (те же 5 попыток за 15 минут), но в одной из них —
// с багом: IP брался как req.socket.remoteAddress без разбора X-Forwarded-For.
// За обратным прокси это адрес самого прокси, все клиенты схлопываются в один
// ключ, и пять неудачных попыток кем угодно блокируют вход всем.
//
// Счётчик — Map в памяти: он не переживает рестарт и не общий для нескольких
// инстансов за балансировщиком. Для защиты от перебора этого достаточно
// (атакующему всё равно приходится укладываться в лимит на каждом инстансе),
// но знать об этом надо — распределённый лимит требует общего хранилища.
import type { NextFunction, Request, Response } from 'express';
export interface RateLimitOptions {
/** Сколько неудачных попыток разрешено в окне. По умолчанию 5. */
limit?: number;
/** Длина окна в миллисекундах. По умолчанию 15 минут. */
windowMs?: number;
/**
* Доверять ли заголовку X-Forwarded-For. По умолчанию — только в проде:
* в деве перед сервером прокси нет, и подставить чужой IP заголовком мог
* бы кто угодно, кто достучался напрямую.
*/
trustProxy?: boolean;
/** Текст ответа. Получает количество секунд до сброса. */
message?: (retryAfter: number) => string;
}
interface RateEntry { count: number; resetAt: number }
type IpSource = Pick<Request, 'headers'> & { socket: { remoteAddress?: string } };
export function clientIp(req: IpSource, trustProxy: boolean): string {
if (trustProxy) {
const forwarded = req.headers['x-forwarded-for'];
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded;
if (first) return first.split(',')[0].trim();
}
return req.socket.remoteAddress ?? 'unknown';
}
export interface LoginRateLimit {
/** Мидлвар на маршрут логина. */
middleware: (req: Request, res: Response, next: NextFunction) => void;
/**
* Снять счётчик после успешного входа — иначе три опечатки подряд перед
* удачным логином всё ещё считались бы попытками перебора.
*/
clear: (req: IpSource) => void;
/** IP запроса по тем же правилам, что использует лимитер. */
ipOf: (req: IpSource) => string;
}
export function createLoginRateLimit(options: RateLimitOptions = {}): LoginRateLimit {
const limit = options.limit ?? 5;
const windowMs = options.windowMs ?? 15 * 60 * 1000;
const trustProxy = options.trustProxy ?? process.env.NODE_ENV === 'production';
const message = options.message
?? ((retryAfter: number) => `Слишком много попыток. Повтори через ${retryAfter} сек.`);
const attempts = new Map<string, RateEntry>();
const ipOf = (req: IpSource) => clientIp(req, trustProxy);
return {
ipOf,
clear: (req) => { attempts.delete(ipOf(req)); },
middleware(req, res, next) {
const ip = ipOf(req);
const now = Date.now();
const entry = attempts.get(ip);
if (!entry || entry.resetAt < now) {
attempts.set(ip, { count: 1, resetAt: now + windowMs });
} else if (entry.count >= limit) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
res.setHeader('Retry-After', String(retryAfter));
res.status(429).json({ error: message(retryAfter) });
return;
} else {
entry.count++;
}
next();
},
};
}
+93
View File
@@ -0,0 +1,93 @@
// Сессия оператора панели: выпуск и проверка токена.
//
// Реализаций в панелях было две — подписанный JWT через jsonwebtoken и
// самописный HMAC на node:crypto. Ядро оставляет первую: алгоритм тот же, но
// краевые случаи (разбор exp/nbf, подмена alg) уже разобраны в библиотеке.
// jsonwebtoken объявлен peer-зависимостью — его надо поставить в бэкенд панели.
//
// Отзыв токена. JWT самодостаточен: сервер не хранит выданные токены и потому
// не может их отменить — логаут по умолчанию чисто клиентский. Опция
// `revocable` включает список отозванных подписей в памяти, и тогда логаут
// действительно убивает токен. Цена: список не переживает рестарт и не общий
// для нескольких инстансов за балансировщиком — после рестарта отозванный
// токен снова станет валидным до истечения TTL. Нужен настоящий отзыв на
// нескольких инстансах — нужно общее хранилище (Redis и т.п.); тогда передай
// свой store вместо встроенного.
import jwt from 'jsonwebtoken';
export interface SessionClaims {
user: string;
userId: string;
role: string;
permissions: string[];
}
/** Хранилище отозванных токенов. По умолчанию — Set в памяти. */
export interface RevocationStore {
revoke: (signature: string) => void;
isRevoked: (signature: string) => boolean;
}
export interface SessionOptions {
/** Секрет подписи. Свой, отдельный от секретов основного продукта. */
secret: string;
/** Время жизни токена. По умолчанию 12 часов. */
ttl?: string | number;
/** Включить отзыв токенов на логауте. По умолчанию выключено. */
revocable?: boolean;
/** Своё хранилище отозванных — например, общее на несколько инстансов. */
revocationStore?: RevocationStore;
}
export interface SessionStore {
issue: (claims: SessionClaims) => string;
/** Клеймы живого токена, либо null — протух, подделан или отозван. */
verify: (token: string) => SessionClaims | null;
/** Отозвать токен (если отзыв включён). */
revoke: (token: string) => void;
revocable: boolean;
}
function memoryRevocationStore(): RevocationStore {
const revoked = new Set<string>();
return {
revoke: (sig) => { revoked.add(sig); },
isRevoked: (sig) => revoked.has(sig),
};
}
// Подпись однозначно идентифицирует токен и короче его самого.
const signatureOf = (token: string) => token.split('.')[2] ?? token;
export function createSessionStore(options: SessionOptions): SessionStore {
const { secret } = options;
if (!secret) {
throw new Error(
'panel-server: не задан секрет подписи сессий (ADMIN_JWT_SECRET).\n' +
' Значения по умолчанию нет намеренно: с известным секретом токен подделывается кем угодно.',
);
}
const ttl = options.ttl ?? '12h';
const revocable = options.revocable ?? false;
const store = options.revocationStore ?? memoryRevocationStore();
return {
revocable,
issue: (claims) => jwt.sign(claims, secret, { expiresIn: ttl } as jwt.SignOptions),
verify(token) {
if (!token) return null;
if (revocable && store.isRevoked(signatureOf(token))) return null;
try {
// algorithms задан явно: без него подошёл бы токен, подписанный
// другим алгоритмом из заголовка самого токена.
const claims = jwt.verify(token, secret, { algorithms: ['HS256'] });
return claims as SessionClaims & jwt.JwtPayload;
} catch {
return null;
}
},
revoke(token) {
if (revocable && token) store.revoke(signatureOf(token));
},
};
}
+32
View File
@@ -0,0 +1,32 @@
// Раздача собранного фронта. Панель — один процесс и один образ: сервер сам
// отдаёт frontend/dist, отдельный nginx ей не нужен.
//
// Монтируется последним, после всех API-маршрутов: fallback отвечает на любой
// GET, поэтому маршрут, зарегистрированный после него, уже не сработает.
import express, { type Express, type Request, type Response } from 'express';
import fs from 'node:fs';
import path from 'node:path';
// Пути под этими префиксами получают 404, а не index.html: иначе опечатка в
// адресе API возвращает HTML со статусом 200, и клиент падает на разборе JSON
// вместо внятной ошибки.
const API_PREFIXES = ['/api'];
export function serveSpa(app: Express, dir: string) {
const root = path.resolve(dir);
if (!fs.existsSync(path.join(root, 'index.html'))) {
console.warn(`panel-server: в ${root} нет index.html — фронт не собран (npm run build) ` +
'или STATIC_DIR указывает не туда');
}
app.use(express.static(root));
app.get('*', (req: Request, res: Response) => {
if (API_PREFIXES.some(p => req.path.startsWith(p))) {
res.status(404).json({ message: 'Не найдено' });
return;
}
res.sendFile(path.join(root, 'index.html'));
});
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2117
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "ma7engine-example-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"predev": "npm run update",
"dev": "vite",
"update": "sh scripts/update.sh",
"prebuild": "npm run update",
"build": "vite build",
"typecheck": "tsc --noEmit",
"doctor": "node scripts/doctor.mjs"
},
"dependencies": {
"axios": "^1.7.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@types/node": "^26.3.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.4.0",
"vite": "^5.4.0"
}
}
+116
View File
@@ -0,0 +1,116 @@
#!/bin/sh
# Обновляет вендоренную копию ядра (ma7engine) в панели:
# ma7engine/core/browser → <панель>/frontend/src/core (браузерная половина)
# ma7engine/core/server → <панель>/backend/core (серверная половина)
#
# Панель из одной папки, где сервер лежит рядом с фронтом (server.ts в корне,
# как в awg-ui), получает серверную часть в <панель>/core.
#
# Зачем копия, а не алиас прямо на соседнюю папку: контекст сборки образа
# панели — её собственный каталог (COPY frontend/ ./ в Dockerfile), и путь,
# уходящий выше него, внутри контейнера не разрешится. При копии пути
# ./src/core и ./core одинаковы в деве, в tsc --noEmit и в Docker, и Dockerfile
# трогать не нужно вообще.
#
# Файл кладётся в репозиторий потребителя (frontend/scripts/), потому что
# вызывать его нужно и тогда, когда ma7engine рядом нет.
#
# Использование: npm run update (или sh scripts/update.sh)
# Где искать ядро: $MA7ENGINE, иначе перебор кандидатов ниже — первый
# существующий выигрывает, выбранный путь печатается.
set -eu
abspath() { (CDPATH= cd -- "$1" 2>/dev/null && pwd); }
here=$(abspath "$(dirname -- "$0")")
app=$(dirname "$here") # .../<панель>/frontend или корень панели из одной папки
panel=$(dirname "$app") # .../<панель> или каталог, где лежат панели
# Ядро может лежать где угодно относительно панели: рядом с продуктовым
# репозиторием, рядом с остальными панелями или внутри самого репозитория.
# Перебираем, вместо того чтобы зашивать одну раскладку — переезд ядра иначе
# молча уводит синхронизацию на старую копию.
#
# Порядок — от ближнего к дальнему: панель, у которой ядро лежит рядом, не
# должна утащить копию из каталога уровнем выше (такая копия обычно старая —
# именно от неё и уезжает синхронизация молча).
CANDIDATES="
$app/../ma7engine
$app/../../ma7engine
$app/../../../ma7engine
$app/../../../admins/ma7engine
"
find_engine() {
if [ -n "${MA7ENGINE:-}" ]; then
if [ ! -d "$MA7ENGINE/core/browser" ]; then
echo "update: в MA7ENGINE=$MA7ENGINE нет каталога core/browser" >&2
exit 1
fi
abspath "$MA7ENGINE"
return
fi
for candidate in $CANDIDATES; do
if [ -d "$candidate/core/browser" ]; then
abspath "$candidate"
return
fi
done
}
# Куда класть серверную половину. Панели без своего сервера не получают её вовсе.
server_target() {
if [ -d "$panel/backend" ]; then echo "$panel/backend/core"
elif [ -f "$app/server.ts" ]; then echo "$app/core"
fi
}
# Скрипт обязан запускаться из фронта панели: $app определяется как каталог
# уровнем выше scripts/, и запущенный не оттуда он молча создаёт постороннюю
# src/core рядом с чем угодно. index.html — маркер vite-фронта.
if [ ! -f "$app/index.html" ]; then
echo "update: $app не похож на фронт панели (нет index.html)." >&2
echo " Скрипт запускается из <панель>/frontend: npm run update" >&2
exit 1
fi
front_to=$app/src/core
server_to=$(server_target)
engine=$(find_engine)
if [ -z "$engine" ]; then
# В контейнере ядра нет — оно уже приехало внутри src/core (и backend/core)
# вместе с остальными исходниками. Это норма, а не ошибка сборки.
if [ -d "$front_to" ] || { [ -n "$server_to" ] && [ -d "$server_to" ]; }; then
echo "update: ma7engine рядом не найден, использую уже синхронизированные копии"
exit 0
fi
echo "update: ma7engine не найден ни в одном из мест:" >&2
for candidate in $CANDIDATES; do echo " $candidate" >&2; done
echo " Укажи путь через MA7ENGINE=/путь/к/ma7engine" >&2
exit 1
fi
echo "update: ядро — $engine"
copy() { # copy <откуда> <куда> <подпись>
[ -d "$1" ] || return 0
rm -rf "$2"
mkdir -p "$(dirname "$2")"
cp -R "$1" "$2"
echo " $3: $1 → $2"
}
copy "$engine/core/browser" "$front_to" "фронт"
if [ -n "$server_to" ]; then
copy "$engine/core/server" "$server_to" "сервер"
fi
# Доктор едет вместе с ядром: проверки в панели должны быть те же, что в ядре,
# иначе панель отчитывается о соответствии по устаревшему списку. Кладётся
# рядом с этим скриптом — оттуда его зовёт `npm run doctor`.
if [ -f "$engine/scripts/doctor.mjs" ]; then
cp "$engine/scripts/doctor.mjs" "$here/doctor.mjs"
echo " доктор: $engine/scripts/doctor.mjs → $here/doctor.mjs"
fi
+21
View File
@@ -0,0 +1,21 @@
/* Стили панели поверх дизайн-системы ядра (@core/shell/styles.css). Файл
подключается после неё, поэтому здесь и добавляются свои классы, и — если
очень нужно — переопределяются токены палитры.
Шаблон палитру НЕ трогает: он показывает дизайн-систему как есть. Свой акцент
задаётся так, и обязательно в обеих темах — тёмная переопределяет те же
токены у себя, иначе цвет виден только в светлой:
:root { --primary: #2f6f4f; }
:root[data-theme="dark"] { --primary: #7fc4a0; }
Но менять стоит всю связку токенов акцента (--primary, --primary-container,
--on-primary-container, --state-primary), иначе кнопка станет своего цвета,
а тональная кнопка и активный пункт меню останутся прежними. */
/* Своё правило: имя с префиксом панели, чтобы не столкнуться с контрактом ядра. */
.demo-note {
margin-top: 12px;
color: var(--neutral-text);
font-size: 13px;
}
+36
View File
@@ -0,0 +1,36 @@
// Шаблон фронта панели — с этого начинается новый. Копируется вместе с
// ../backend; ядро в свою панель приезжает через `npm run update`.
//
// Всё, что здесь есть: конфиг. Логин, тема, drawer/сайдбар, снэкбар, сессия,
// меню по правам и переключение вкладок — в <Panel />.
//
// Парная половина — backend/server.ts: вкладки ниже ходят в его
// маршруты /api/demo/*, так что две половины запускаются вместе и работают.
import Panel, { type PanelConfig } from '@core/shell';
import './App.css'; // локальные стили — после стилей ядра
import * as tabs from '@/tabs';
const config: PanelConfig = {
tabs,
branding: { name: 'Demo Panel', version: '0.1.0' },
// Ключи localStorage разводятся по панелям: две панели на одном домене
// иначе затирают друг другу токен и тему.
storageKeys: { token: 'demo_token', theme: 'demo_theme' },
// loadSession не задан — ядро берёт GET /api/session. Панель без ролей
// задаёт его сама и отдаёт permissions: ['*'], тогда эндпоинт сессии
// реализовывать не нужно:
//
// loadSession: async () => ({ user: null, userId: null, role: '', permissions: ['*'] }),
// slots — свой хром вокруг вкладки: aboveTab (над вкладкой) и headerExtra
// (в углу шапки). Слот получает активную вкладку в ctx.tab, поэтому сам
// решает, где ему не рисоваться:
//
// slots: { aboveTab: ctx => ctx.tab?.id === 'docs' ? null : <ServerBar /> },
};
export default function App() {
return <Panel config={config} />;
}
+33
View File
@@ -0,0 +1,33 @@
// Иконки панели. Общие приезжают из ядра и реэкспортятся отсюда, чтобы вкладки
// брали все иконки из одного места; здесь остаётся только своё.
import { coreIcons } from '@core/icons';
export * from '@core/icons';
export const IcoStar = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
<path d="M12 3l2.7 5.5 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1L3.2 9.4l6.1-.9z"/>
</svg>
);
export const IcoGrid = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/>
<rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>
</svg>
);
export const IcoBook = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
<path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v18H6.5A2.5 2.5 0 0 0 4 22z"/><path d="M4 19.5h16"/>
</svg>
);
// Карта иконок вкладок: имя из metadata.json → компонент. Своё перекрывает
// одноимённое из ядра.
export const ICONS: Record<string, () => JSX.Element> = {
...coreIcons, // key, globe
star: IcoStar,
grid: IcoGrid,
book: IcoBook,
};
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+56
View File
@@ -0,0 +1,56 @@
/* Стили только этой вкладки. Всё, что можно было взять из дизайн-системы ядра
(.table-card, .chip, .empty), взято оттуда; здесь — типографика документации
и блоки кода. */
.doc-section { margin-bottom: 32px; max-width: 900px; }
.doc-section > h3 {
font-size: 15px;
font-weight: 500;
color: var(--on-surface);
margin-bottom: 8px;
}
.doc-section > p,
.doc-section > ul {
font-size: 13px;
line-height: 1.6;
color: var(--neutral-text);
margin-bottom: 10px;
}
.doc-section > ul { padding-left: 18px; }
.doc-section > ul > li { margin-bottom: 4px; }
/* Блок кода: моноширинный, со скроллом по горизонтали — длинная строка не
растягивает страницу. */
.doc-code {
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline);
border-radius: var(--r-card);
padding: 12px 14px;
margin-bottom: 12px;
overflow-x: auto;
white-space: pre;
}
.doc-inline {
font-family: var(--mono);
font-size: 12px;
color: var(--primary);
}
/* Предупреждение — то, что ломается молча, если сделать иначе. */
.doc-warn {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.55;
color: var(--on-surface);
background: var(--error-bg);
border-radius: var(--r-card);
padding: 10px 14px;
margin-bottom: 12px;
}
+461
View File
@@ -0,0 +1,461 @@
// Инструкция к коду панели — прямо в панели. Не пересказ README ядра целиком,
// а то, что нужно под рукой, когда пишешь вкладку: как запустить, куда класть
// файлы, что откуда импортировать, где границы ядра и на чём тут спотыкаются.
//
// Ничего не грузит: открывается без бэкенда.
import type { SessionPageProps } from '@core/auth';
import './docs.css';
const RUN = `cd ma7engine # именно из него, не из каталога уровнем выше
npm run demo
# ставит зависимости при первом запуске, поднимает бэкенд :8080 и Vite :5173,
# по Ctrl+C гасит обе половины. Открывать :5173, логин admin / demo.`;
const RUN_SPLIT = `# терминал 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`;
const RUN_PROD = `cd frontend && npm run build # → frontend/dist
cd backend && npm start # :8080 отдаёт и API, и собранный фронт`;
const TREE = `frontend/
src/App.tsx конфиг панели: брендинг, ключи стораджа, слоты
src/App.css своё поверх дизайн-системы ядра
src/components/icons.tsx свои иконки + реэкспорт ядерных, карта ICONS
src/tabs/index.ts import.meta.glob → buildTabs (трогать не нужно)
src/tabs/<name>/ вкладка: index.tsx + metadata.json + <name>.css
src/core/ ЯДРО — копия, правки затираются (npm run update)
backend/
server.ts конфиг сервера + свои маршруты
core/ ЯДРО — копия, правки затираются`;
const NEW_TAB = `src/tabs/reports/
metadata.json { "id": "reports", "label": "Отчёты", "icon": "grid", "order": 40 }
index.tsx export default function ReportsPage({ token, showMsg }: SessionPageProps) { … }
reports.css стили только этой вкладки (импортит сам index.tsx)`;
const TAB_CODE = `import { useCallback, useEffect, useState } from 'react';
import type { SessionPageProps } from '@core/auth';
import { apiFetch, errText } from '@core/http';
import { IcoRefresh } from '@/components/icons';
import './reports.css';
export default function ReportsPage({ token, showMsg }: SessionPageProps) {
const [rows, setRows] = useState<Row[]>([]);
const load = useCallback(async () => {
try {
setRows(await apiFetch('GET', '/api/reports', token));
} catch (e) {
showMsg(errText(e)); // 401 ловить не надо — ядро разлогинит само
}
}, [token, showMsg]);
useEffect(() => { load(); }, [load]);
return rows.length === 0 ? <p className="empty">Пусто</p> : ( … );
}`;
const SESSION_FLOW = `1. Логин POST /login { user, pass } → { token }
2. Токен localStorage[storageKeys.token]
3. Кто вошёл loadSession(token) → { user, userId, role, permissions }
по умолчанию GET /api/session
4. Меню вкладки фильтруются по permissions
5. Запросы вкладок apiFetch(...) с Authorization: Bearer <token>
6. Любой 401 ядро чистит сторадж и показывает логин — во всей панели сразу
7. Логаут POST /logout, сервер отзывает токен`;
const SLOTS_CODE = `slots: {
// над контентом вкладки
aboveTab: ctx => <ServerBar token={ctx.token} showMsg={ctx.showMsg} />,
// в углу шапки, рядом с переключателем темы
headerExtra: ctx => <button className="btn btn--tonal">Действие</button>,
}
// ctx: { token, showMsg, session, tab }
// Слот гасит себя сам: aboveTab: ctx => ctx.tab?.id === 'docs' ? null : <ServerBar />`;
const ROUTE_CODE = `mountRoutes(app, { auth, forwardError }) {
// Открыт любому залогиненному — у вкладки нет permission.
app.get('/api/reports', auth.requireAuth, async (req, res) => {
try { res.json(await db.reports()); }
catch (e) { forwardError(res, e); }
});
// Закрыт правом. Гейт на фронте — только UX, граница здесь.
app.post('/api/reports', auth.requireAuth,
auth.requirePermission('reports.manage'), handler);
}`;
const HASH_CODE = `# сгенерировать scrypt-хэш — из backend/
npx tsx -e "import { hashPassword } from './core/password'; console.log(hashPassword('s3cret'))"
# → scrypt:a62e2888…:82651449…
# запуск без открытого пароля в окружении
ADMIN_USER=root ADMIN_PASS_HASH='scrypt:…' ADMIN_JWT_SECRET=… npm start`;
const CONFIG_CODE = `const config: PanelConfig = {
tabs, // результат buildTabs()
branding: { name: 'Demo Panel', version: '0.1.0' },
storageKeys: { token: 'demo_token', theme: 'demo_theme' },
endpoints: { login: '/login', logout: '/logout' },
loadSession: async token => ({ … }), // по умолчанию GET /api/session
slots: { aboveTab, headerExtra },
};`;
const NO_ROLES_CODE = `// Панель без ролей: эндпоинт сессии реализовывать не нужно —
// отдаём владельца прямо из конфига, и все вкладки видны.
loadSession: async () => ({
user: null, userId: null, role: '', permissions: ['*'],
}),`;
const META = [
['id', 'да', 'ключ активной вкладки, должен быть уникален'],
['label', 'да', 'подпись в меню и заголовок раздела'],
['icon', 'да', 'имя из карты ICONS в src/components/icons.tsx'],
['category', 'нет', 'группа в меню; без неё — плоский список'],
['permission', 'нет', 'право доступа; без него вкладка видна всем залогиненным'],
['order', 'нет', 'порядок по возрастанию, по умолчанию 0'],
['devOnly', 'нет', 'вырезается из прод-сборки (import.meta.env.PROD)'],
];
const IMPORTS = [
['@core/auth', 'SessionPageProps, BasePageProps, hasPermission, OWNER_PERMISSION'],
['@core/http', 'apiFetch, errText'],
['@core/utils', 'fmtDateTime, timeAgo, bytes, downloadFile, copyText'],
['@core/icons', 'coreIcons и Ico* — но вкладки берут их через @/components/icons'],
['@core/shell', 'Panel, PanelConfig, SlotContext — только для App.tsx'],
['@core/tabs', 'buildTabs — только для src/tabs/index.ts'],
['@/…', 'свой код панели: @/components/icons, @/lib/…'],
];
const PROPS = [
['token', 'строка для apiFetch'],
['showMsg(text)', 'снэкбар на 3 секунды'],
['username, userId, role', 'кто вошёл'],
['permissions', 'права текущей учётки; hasPermission() из @core/auth'],
['availablePermissions', 'все permission всех вкладок — нужно вкладке выдачи прав'],
['onNavigate(tabId)', 'переключить активную вкладку (роутера в панели нет)'],
];
const ENV = [
['ADMIN_USER', 'нет', 'логин; по умолчанию admin'],
['ADMIN_PASS', 'да', 'пароль открытым текстом — либо ADMIN_PASS_HASH'],
['ADMIN_PASS_HASH', 'нет', 'scrypt-хэш: тогда открытого пароля в окружении нет'],
['ADMIN_JWT_SECRET','да', 'секрет подписи сессий; без него процесс не стартует'],
['PORT', 'нет', 'порт бэкенда, по умолчанию 8080'],
['STATIC_DIR', 'нет', 'где лежит собранный фронт, по умолчанию ../frontend/dist'],
];
const TOKENS = [
['акцент', '--primary --on-primary --primary-container --on-primary-container'],
['фон', '--surface --surface-bright --surface-container --surface-hi --on-surface'],
['границы', '--outline --outline-variant'],
['статусы', '--success-bg/-text/-dot --neutral-bg/-text/-dot --error-bg/-text/-dot'],
['опасное', '--danger-text --danger-border'],
['прочее', '--label --state-primary --state-row --state-neutral --scrim --shadow'],
['типографика', '--font --mono'],
['радиусы', '--r-pill --r-card --r-table --r-logo'],
];
const MISTAKES: [string, string][] = [
['Правка в src/core или backend/core',
'затрётся при следующем npm run update. Нужно поведение иначе — новое поле в PanelConfig ядра либо своё в панели.'],
['Иконка не зарегистрирована в ICONS',
'сборка TABS падает с внятной ошибкой при старте — это не баг, а валидация манифеста.'],
['Импорт через ../../lib/…',
'папку вкладки нельзя перенести. Только @core/* и @/*.'],
['Свой цвет задан одним :root',
'в тёмной теме его не видно: она переопределяет те же токены. Задавай в обеих.'],
['permission у вкладки без проверки на маршруте',
'доступ не закрыт: гейт на фронте только прячет пункт меню.'],
['Панель не стартует без ADMIN_PASS',
'так задумано (fail-closed): панель с паролем по умолчанию хуже не поднявшейся.'],
];
export default function DocsPage(_props: SessionPageProps) {
return (
<>
<section className="doc-section">
<h3>Запуск демо</h3>
<p>
Шаблон — рабочая панель на ядре. Две половины запускаются отдельно: бэкенд отдаёт
API, Vite — фронт и проксирует на бэкенд <span className="doc-inline">/login</span>,{' '}
<span className="doc-inline">/logout</span> и <span className="doc-inline">/api</span>.
</p>
<div className="doc-code">{RUN}</div>
<p>
Если половины нужны врозь — два терминала, полными путями:{' '}
<span className="doc-inline">npm start</span> терминал не возвращает.
</p>
<div className="doc-code">{RUN_SPLIT}</div>
<p>Прод-режим — одним процессом, без Vite:</p>
<div className="doc-code">{RUN_PROD}</div>
<p>
Занят порт — <span className="doc-inline">PORT=8081 npm start</span> и{' '}
<span className="doc-inline">VITE_PROXY_TARGET=http://localhost:8081 npm run dev</span>.
Ядро приезжает копией: <span className="doc-inline">npm run update</span> кладёт его в{' '}
<span className="doc-inline">src/core</span> и{' '}
<span className="doc-inline">backend/core</span>. Руками звать не нужно — он
вызывается сам перед <span className="doc-inline">dev</span> и{' '}
<span className="doc-inline">build</span>.
</p>
</section>
<section className="doc-section">
<h3>Что где лежит</h3>
<p>
Панель — это конфиг плюс вкладки. Оболочка (логин, тема, меню, сессия),
дизайн-система и сборка реестра вкладок живут в ядре и приезжают копией.
</p>
<div className="doc-code">{TREE}</div>
<div className="doc-warn">
<span>⚠</span>
<span>
В <span className="doc-inline">src/core</span> и{' '}
<span className="doc-inline">backend/core</span> писать нельзя: это копия,
её затирает <span className="doc-inline">npm run update</span>. Нужно поведение
иначе — либо новое поле в <span className="doc-inline">PanelConfig</span> ядра,
либо своё в панели (слот, свой класс, свой маршрут).
</span>
</div>
</section>
<section className="doc-section">
<h3>Как добавить вкладку</h3>
<p>
Создай папку с тремя файлами — реестр найдёт её сам через{' '}
<span className="doc-inline">import.meta.glob</span>, править{' '}
<span className="doc-inline">src/tabs/index.ts</span> не нужно.
</p>
<div className="doc-code">{NEW_TAB}</div>
<div className="table-card">
<table>
<thead><tr><th>поле metadata.json</th><th>обяз.</th><th>смысл</th></tr></thead>
<tbody>
{META.map(([field, req, meaning]) => (
<tr key={field}>
<td className="td-mono">{field}</td>
<td>
<span className={`chip chip--${req === 'да' ? 'online' : 'offline'}`}>
<span className="chip-dot" />{req}
</span>
</td>
<td>{meaning}</td>
</tr>
))}
</tbody>
</table>
</div>
<p>
Манифест — JSON, компилятор его не проверяет, поэтому он валидируется в рантайме:
опечатка или незарегистрированная иконка падают с внятной ошибкой при сборке{' '}
<span className="doc-inline">TABS</span>, а не тихо в UI.
</p>
</section>
<section className="doc-section">
<h3>Скелет вкладки</h3>
<div className="doc-code">{TAB_CODE}</div>
<div className="table-card">
<table>
<thead><tr><th>проп</th><th>что это</th></tr></thead>
<tbody>
{PROPS.map(([name, meaning]) => (
<tr key={name}><td className="td-mono">{name}</td><td>{meaning}</td></tr>
))}
</tbody>
</table>
</div>
<p>
Панель без ролей объявляет <span className="doc-inline">BasePageProps</span> — там
только <span className="doc-inline">token</span> и{' '}
<span className="doc-inline">showMsg</span>.
</p>
</section>
<section className="doc-section">
<h3>Откуда что импортировать</h3>
<p>
Только через алиасы: <span className="doc-inline">@core/*</span> — ядро,{' '}
<span className="doc-inline">@/*</span> — свой код. Относительные{' '}
<span className="doc-inline">../../</span> ломаются при переносе папки вкладки.
</p>
<div className="table-card">
<table>
<thead><tr><th>модуль</th><th>что берут</th></tr></thead>
<tbody>
{IMPORTS.map(([mod, what]) => (
<tr key={mod}><td className="td-mono">{mod}</td><td>{what}</td></tr>
))}
</tbody>
</table>
</div>
</section>
<section className="doc-section">
<h3>Жизненный цикл сессии</h3>
<p>Всё это делает оболочка — вкладке остаётся только ходить с токеном в свои маршруты.</p>
<div className="doc-code">{SESSION_FLOW}</div>
</section>
<section className="doc-section">
<h3>Права — два слоя</h3>
<p>
<b>Фронт:</b> <span className="doc-inline">permission</span> в{' '}
<span className="doc-inline">metadata.json</span> прячет вкладку из меню, а{' '}
<span className="doc-inline">hasPermission(permissions, 'x')</span> — отдельные кнопки
внутри неё. Это UX.
</p>
<p>
<b>Бэкенд:</b> <span className="doc-inline">auth.requirePermission('x')</span> на
маршруте. Это граница. Первое без второго — не защита: запрос можно послать руками.
</p>
<p>
<span className="doc-inline">'*'</span> в{' '}
<span className="doc-inline">permissions</span> — владелец, проходит любую проверку.
Панель без ролей просто отдаёт его всем:
</p>
<div className="doc-code">{NO_ROLES_CODE}</div>
</section>
<section className="doc-section">
<h3>Свой хром — слоты</h3>
<p>
Если нужен общий для вкладок элемент (панель выбора сервера, кнопка действия в шапке),
он не копируется по вкладкам, а отдаётся оболочке слотом.
</p>
<div className="doc-code">{SLOTS_CODE}</div>
</section>
<section className="doc-section">
<h3>Конфиг панели — App.tsx</h3>
<p>Всё, чем панели отличаются друг от друга, живёт здесь и больше нигде.</p>
<div className="doc-code">{CONFIG_CODE}</div>
</section>
<section className="doc-section">
<h3>Маршруты — backend/server.ts</h3>
<p>
Логин, сессия, права, рейт-лимит (5 попыток с IP за 15 минут) и раздача фронта — в{' '}
<span className="doc-inline">createPanelServer</span>; свои маршруты монтируются
между логином и статикой. Порядок фиксирован внутри: статика отвечает на любой GET,
поэтому маршрут после неё не сработал бы.
</p>
<div className="doc-code">{ROUTE_CODE}</div>
<div className="table-card">
<table>
<thead><tr><th>переменная</th><th>обяз.</th><th>смысл</th></tr></thead>
<tbody>
{ENV.map(([name, req, meaning]) => (
<tr key={name}>
<td className="td-mono">{name}</td>
<td>
<span className={`chip chip--${req === 'да' ? 'online' : 'offline'}`}>
<span className="chip-dot" />{req}
</span>
</td>
<td>{meaning}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="doc-warn">
<span>⚠</span>
<span>
Без <span className="doc-inline">ADMIN_PASS</span> и{' '}
<span className="doc-inline">ADMIN_JWT_SECRET</span> процесс не поднимется — это
не баг: панель, тихо работающая с паролем по умолчанию, хуже не стартовавшей.
</span>
</div>
</section>
<section className="doc-section">
<h3>Учётка: логин и пароль</h3>
<p>
Учётка в панели одна и живёт в окружении бэкенда, а не в базе:{' '}
<span className="doc-inline">ADMIN_USER</span> (по умолчанию{' '}
<span className="doc-inline">admin</span>) и{' '}
<span className="doc-inline">ADMIN_PASS</span>. В демо пароль —{' '}
<span className="doc-inline">demo</span>, его подставляет скрипт запуска.
</p>
<p>
Пароль сравнивается за постоянное время и проверяется даже при несовпавшем
логине — иначе по времени ответа «нет такого пользователя» отличается от
«неверный пароль». Чтобы открытого пароля не было в окружении вовсе, вместо{' '}
<span className="doc-inline">ADMIN_PASS</span> задаётся{' '}
<span className="doc-inline">ADMIN_PASS_HASH</span>:
</p>
<div className="doc-code">{HASH_CODE}</div>
<p>
После входа панель кладёт JWT (TTL 12 часов) в{' '}
<span className="doc-inline">localStorage</span> под ключом из{' '}
<span className="doc-inline">storageKeys.token</span>; логаут отзывает токен на
сервере. Учёток нужно несколько — либо размечай их сам и выдавай разные{' '}
<span className="doc-inline">claims</span>, либо не заводи учёток в панели
вовсе: тогда она собирает сервер сама из кусков ядра, а логин форвардит в
основной продукт, где настоящие учётки уже есть.
</p>
</section>
<section className="doc-section">
<h3>Стили и токены</h3>
<p>
Имена классов ядра — публичный контракт, все они с подписями на вкладке «Витрина».
Цвета — только через <span className="doc-inline">var(--…)</span>: палитра задана на{' '}
<span className="doc-inline">:root</span> и переопределена на{' '}
<span className="doc-inline">:root[data-theme="dark"]</span>, поэтому свой цвет
задаётся сразу в обеих темах. Несуществующий токен не ошибка — правило просто молча
не применится, отсюда список ниже.
</p>
<div className="table-card">
<table>
<thead><tr><th>группа</th><th>токены</th></tr></thead>
<tbody>
{TOKENS.map(([group, list]) => (
<tr key={group}><td>{group}</td><td className="td-mono">{list}</td></tr>
))}
</tbody>
</table>
</div>
<p>
Стили вкладки лежат в её папке и импортятся её же компонентом; общий{' '}
<span className="doc-inline">App.css</span> подключается после стилей ядра.
</p>
</section>
<section className="doc-section">
<h3>Обновление ядра</h3>
<p>
<span className="doc-inline">npm run update</span> во фронте копирует обе половины
ядра в панель: <span className="doc-inline">core/browser</span> →{' '}
<span className="doc-inline">frontend/src/core</span> и{' '}
<span className="doc-inline">core/server</span> →{' '}
<span className="doc-inline">backend/core</span>. Он же вызывается сам перед{' '}
<span className="doc-inline">npm run dev</span> и{' '}
<span className="doc-inline">npm run build</span>. В контейнере ядра рядом нет —
скрипт видит уже синхронизированную копию и молча выходит с нулём. Путь к ядру можно
задать явно: <span className="doc-inline">MA7ENGINE=/путь/к/ma7engine</span>.
</p>
</section>
<section className="doc-section">
<h3>На чём тут спотыкаются</h3>
<div className="table-card">
<table>
<thead><tr><th>что сделал</th><th>что будет</th></tr></thead>
<tbody>
{MISTAKES.map(([what, result]) => (
<tr key={what}><td>{what}</td><td>{result}</td></tr>
))}
</tbody>
</table>
</div>
</section>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "docs",
"label": "Инструкция",
"icon": "book",
"order": 5
}
+62
View File
@@ -0,0 +1,62 @@
// Вкладка без permission — видна любому залогиненному. Ходит в /api/demo/whoami
// из backend/server.ts и показывает клеймы текущей сессии.
//
// Пропсы приходят от оболочки: SessionPageProps — это token, showMsg и данные
// сессии. Панель без ролей объявляет BasePageProps: только token и showMsg.
import { useCallback, useEffect, useState } from 'react';
import type { SessionPageProps } from '@core/auth';
import { apiFetch, errText } from '@core/http';
import { IcoRefresh } from '@/components/icons';
export default function HelloPage({ token, showMsg, username, role }: SessionPageProps) {
const [session, setSession] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setSession(await apiFetch('GET', '/api/demo/whoami', token));
} catch (e) {
// Ловить 401 не нужно: ядро на него разлогинивает панель само.
showMsg(errText(e));
} finally {
setLoading(false);
}
}, [token, showMsg]);
useEffect(() => { load(); }, [load]);
return (
<>
<div className="toolbar">
<span className="chip chip--online">
<span className="chip-dot" />{username || '—'} · {role || 'без роли'}
</span>
<div className="toolbar-btns">
<button className="btn btn--tonal" onClick={load} disabled={loading}>
<IcoRefresh /> {loading ? 'Обновляю…' : 'Обновить'}
</button>
</div>
</div>
<div className="table-card">
<table>
<thead><tr><th>Поле</th><th>Значение</th></tr></thead>
<tbody>
{Object.entries(session ?? {}).map(([key, value]) => (
<tr key={key}>
<td className="td-mono">{key}</td>
<td className="td-mono">{JSON.stringify(value)}</td>
</tr>
))}
</tbody>
</table>
{!session && !loading && <p className="empty">Сессия не загружена</p>}
</div>
<p className="demo-note">
Классы выше — из дизайн-системы ядра; <code>.demo-note</code> — своё, из App.css.
</p>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "hello",
"label": "Кто я",
"icon": "star",
"order": 10
}
+15
View File
@@ -0,0 +1,15 @@
// Реестр вкладок. Сборка (валидация metadata.json, порядок, категории, права,
// devOnly) живёт в ядре — в панели остаётся только поиск папок: import.meta.glob
// это compile-time фича Vite, её паттерн резолвится относительно файла, где
// написан, и уехав в ядро она сканировала бы ядро, а не src/tabs/ панели.
//
// Добавить вкладку = создать папку с index.tsx и metadata.json. Этот файл
// трогать не нужно.
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,
);
+151
View File
@@ -0,0 +1,151 @@
// Витрина дизайн-системы ядра: все классы публичного контракта из
// @core/shell/styles.css в одном месте, с подписями. Ничего не грузит —
// смотреть можно и без бэкенда.
//
// Заодно это тест на ломающие правки: переименовал класс в ядре — увидел здесь.
import { useState } from 'react';
import type { SessionPageProps } from '@core/auth';
import { IcoCopy, IcoPlus, IcoRefresh, IcoTrash } from '@/components/icons';
import './kit.css';
// Подпись с именем класса под каждым образцом.
function Item({ name, children }: { name: string; children: React.ReactNode }) {
return (
<div className="kit-item">
{children}
<span className="kit-name">{name}</span>
</div>
);
}
export default function KitPage({ showMsg }: SessionPageProps) {
const [modal, setModal] = useState(false);
return (
<>
<section className="kit-section">
<h3>Кнопки</h3>
<div className="kit-row">
<Item name=".btn .btn--primary"><button className="btn btn--primary">Сохранить</button></Item>
<Item name=".btn .btn--tonal"><button className="btn btn--tonal"><IcoRefresh /> Обновить</button></Item>
<Item name=".btn .btn--outline"><button className="btn btn--outline">Отмена</button></Item>
<Item name=".btn .btn--danger"><button className="btn btn--danger"><IcoTrash /> Удалить</button></Item>
<Item name=":disabled"><button className="btn btn--primary" disabled>Недоступно</button></Item>
<Item name=".btn--sq"><button className="btn btn--primary btn--sq" aria-label="Добавить"><IcoPlus /></button></Item>
</div>
</section>
<section className="kit-section">
<h3>Кнопки-иконки — для действий в строке таблицы</h3>
<div className="kit-row">
<Item name=".btn-icon"><button className="btn-icon" aria-label="Копировать"><IcoCopy /></button></Item>
<Item name=".btn-icon--neutral"><button className="btn-icon btn-icon--neutral" aria-label="Обновить"><IcoRefresh /></button></Item>
<Item name=".btn-icon--danger"><button className="btn-icon btn-icon--danger" aria-label="Удалить"><IcoTrash /></button></Item>
</div>
</section>
<section className="kit-section">
<h3>Поля</h3>
<div className="kit-row">
<Item name=".field"><input className="field" placeholder="Обычное поле" /></Item>
<Item name=".field type=password"><input className="field" type="password" placeholder="Пароль" /></Item>
<Item name=".field :disabled"><input className="field" placeholder="Недоступно" disabled /></Item>
</div>
<div className="kit-row" style={{ marginTop: 12 }}>
<Item name=".btn--full"><button className="btn btn--primary btn--full">Во всю ширину</button></Item>
</div>
</section>
<section className="kit-section">
<h3>Тулбар — поле растягивается, кнопки прижаты вправо</h3>
<div className="toolbar">
<input className="field" placeholder="Поиск" />
<div className="toolbar-btns">
<button className="btn btn--tonal"><IcoRefresh /> Обновить</button>
<button className="btn btn--primary"><IcoPlus /> Добавить</button>
</div>
</div>
<span className="kit-name">.toolbar · .toolbar-btns</span>
</section>
<section className="kit-section">
<h3>Чипы — статус одним взглядом</h3>
<div className="kit-row">
<Item name=".chip--online"><span className="chip chip--online"><span className="chip-dot" />онлайн</span></Item>
<Item name=".chip--offline"><span className="chip chip--offline"><span className="chip-dot" />офлайн</span></Item>
<Item name=".chip--error"><span className="chip chip--error"><span className="chip-dot" />ошибка</span></Item>
</div>
</section>
<section className="kit-section">
<h3>Таблица</h3>
<div className="table-card">
<table>
<thead>
<tr><th>#</th><th>Имя</th><th>Ключ</th><th>Статус</th><th /></tr>
</thead>
<tbody>
<tr>
<td className="td-num">1</td>
<td>Первая запись</td>
<td className="td-mono">a1b2c3d4</td>
<td><span className="chip chip--online"><span className="chip-dot" />онлайн</span></td>
<td className="td-actions">
<div className="actions">
<button className="btn-icon" aria-label="Копировать"><IcoCopy /></button>
<button className="btn-icon btn-icon--danger" aria-label="Удалить"><IcoTrash /></button>
</div>
</td>
</tr>
<tr>
<td className="td-num">2</td>
<td>Вторая запись</td>
<td className="td-mono">e5f6a7b8</td>
<td><span className="chip chip--offline"><span className="chip-dot" />офлайн</span></td>
<td className="td-actions">
<div className="actions">
<button className="btn-icon" aria-label="Копировать"><IcoCopy /></button>
<button className="btn-icon btn-icon--danger" aria-label="Удалить"><IcoTrash /></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<span className="kit-name">.table-card · .td-num · .td-mono · .td-actions · .actions</span>
</section>
<section className="kit-section">
<h3>Пустое состояние и снэкбар</h3>
<div className="table-card">
<p className="empty">Ничего не найдено</p>
</div>
<div className="kit-row" style={{ marginTop: 10 }}>
<Item name="showMsg() → .snack">
<button className="btn btn--outline" onClick={() => showMsg('Сообщение живёт 3 секунды')}>
Показать снэкбар
</button>
</Item>
<Item name=".modal-*">
<button className="btn btn--outline" onClick={() => setModal(true)}>Открыть модалку</button>
</Item>
</div>
</section>
{modal && (
<div className="modal-backdrop" onClick={() => setModal(false)}>
<div className="modal-card" onClick={e => e.stopPropagation()}>
<span className="modal-title">Удалить запись?</span>
<p className="modal-hint">Действие необратимо.</p>
<div className="modal-actions">
<button className="btn btn--outline" onClick={() => setModal(false)}>Отмена</button>
<button className="btn btn--danger" onClick={() => { setModal(false); showMsg('Удалено'); }}>
Удалить
</button>
</div>
</div>
</div>
)}
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
/* Стили только этой вкладки — живут в её папке и импортятся её же компонентом.
Всё, что показано ниже, нарисовано классами ядра; здесь только раскладка
витрины и подписи с именами классов. */
.kit-section {
margin-bottom: 28px;
}
.kit-section > h3 {
font-size: 13px;
font-weight: 500;
color: var(--neutral-text);
margin-bottom: 10px;
}
.kit-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
/* Подпись с именем класса — чтобы витрина была ещё и справочником. */
.kit-name {
font-family: var(--mono);
font-size: 11px;
color: var(--neutral-dot);
}
.kit-item {
display: flex;
flex-direction: column;
gap: 4px;
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "kit",
"label": "Витрина",
"icon": "grid",
"order": 30
}
+36
View File
@@ -0,0 +1,36 @@
// Вкладка с permission: без права secrets.view её нет в меню. Гейт на фронте —
// это только UX; настоящая граница на маршруте бэкенда (requirePermission в
// backend/server.ts).
import { useCallback, useEffect, useState } from 'react';
import type { SessionPageProps } from '@core/auth';
import { apiFetch, errText } from '@core/http';
export default function SecretsPage({ token, showMsg }: SessionPageProps) {
const [items, setItems] = useState<string[]>([]);
const load = useCallback(async () => {
try {
const data = await apiFetch('GET', '/api/demo/secrets', token);
setItems(data.items ?? []);
} catch (e) {
showMsg(errText(e));
}
}, [token, showMsg]);
useEffect(() => { load(); }, [load]);
if (items.length === 0) return <p className="empty">Пусто</p>;
return (
<div className="table-card">
<table>
<thead><tr><th>#</th><th>Секрет</th></tr></thead>
<tbody>
{items.map((item, i) => (
<tr key={item}><td className="td-num">{i + 1}</td><td>{item}</td></tr>
))}
</tbody>
</table>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "secrets",
"label": "Секреты",
"icon": "key",
"permission": "secrets.view",
"order": 20
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["vite/client", "node"], // node — для vite.config.ts (__dirname, node:path)
"baseUrl": ".",
"paths": {
"@core/*": ["./src/core/*"],
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
// В деве Vite (:5173) проксирует запросы на бэкенд (:8080) — фронт всегда ходит
// по относительным путям, порт нигде не зашит.
const target = process.env.VITE_PROXY_TARGET ?? 'http://localhost:8080';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// Ядро — вендоренная копия: `npm run update` (он же predev/prebuild)
// кладёт сюда ma7engine/core/browser. Копия, а не путь в соседнюю
// папку, потому что в образе панели пути выше её каталога нет.
'@core': path.resolve(__dirname, 'src/core'),
'@': path.resolve(__dirname, 'src'),
},
},
server: {
proxy: {
'/api': target,
'/login': target,
'/logout': target,
},
},
});
+45
View File
@@ -0,0 +1,45 @@
{
"name": "ma7engine",
"version": "0.1.0",
"private": true,
"description": "Общее ядро админ-панелей: оболочка, реестр вкладок, дизайн-система",
"scripts": {
"demo": "sh scripts/demo.sh",
"new-panel": "node scripts/new-panel.mjs",
"release": "sh scripts/release.sh",
"typecheck": "tsc --noEmit",
"typecheck:server": "tsc --noEmit -p tsconfig.server.json"
},
"peerDependencies": {
"axios": "^1.7.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"express": "^4.19.0",
"jsonwebtoken": "^9.0.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"axios": "^1.7.0",
"react": "^18.3.0",
"typescript": "^5.4.0",
"vite": "^5.4.0"
},
"peerDependenciesMeta": {
"express": {
"optional": true
},
"jsonwebtoken": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"axios": {
"optional": true
}
}
}
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане.
# Ctrl+C гасит обе половины.
#
# npm run demo (из корня ma7engine)
# sh scripts/demo.sh (откуда угодно — пути считаются от самого скрипта)
#
# Учётка по умолчанию — admin / demo. Переопределяется окружением:
# ADMIN_USER=root ADMIN_PASS=secret npm run demo
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root=$(dirname "$here")
# Пароль и секрет обязательны для ядра (fail-closed), поэтому у демо есть
# заведомо непригодные для прода значения по умолчанию.
ADMIN_PASS=${ADMIN_PASS:-demo}
ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET:-dev-secret}
export ADMIN_PASS ADMIN_JWT_SECRET
for half in backend frontend; do
if [ ! -d "$root/$half/node_modules" ]; then
echo "demo: ставлю зависимости в $half…"
(cd "$root/$half" && npm i)
fi
done
(cd "$root/backend" && npm start) &
backend=$!
# Гасим бэкенд вместе с собой — иначе он останется висеть на :8080. Идём по
# всему дереву: между нами и слушающим порт node стоят подоболочка, npm и sh,
# и сигнал в верхний процесс до нижнего сам не доходит — тот просто осиротеет.
kill_tree() {
for child in $(pgrep -P "$1" 2>/dev/null); do kill_tree "$child"; done
kill "$1" 2>/dev/null || true
}
cleanup() { kill_tree "$backend"; }
trap cleanup EXIT INT TERM
echo "demo: бэкенд на :8080, фронт поднимается на :5173 — открывать его"
echo "demo: логин admin / ${ADMIN_PASS}"
cd "$root/frontend" && npm run dev
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env node
// Проверяет, что панель собрана и осталась по стандарту ma7engine. Отвечает на
// вопрос, на который раньше ответа не было: «эта панель ещё соответствует ядру,
// или уже уехала?» — именно расхождение трёх форков и породило само ядро.
//
// npm run doctor (из <панель>/frontend)
// node scripts/doctor.mjs ../admin-hub (из ma7engine, по чужому пути)
//
// Ошибки (выход 1) — то, из-за чего панель соберётся неправильно или не
// соберётся вовсе. Предупреждения (выход 0) — следы шаблона, оставшиеся после
// копирования: они не ломают сборку, поэтому не должны ломать и CI.
//
// Скрипт распространяется в панели вместе с ядром — его кладёт update.sh рядом
// с собой, так что проверки в панели всегда те же, что в ядре.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
// ─── где панель ─────────────────────────────────────────────────────────────
// Аргументом — путь к панели; без аргумента корнем считается ближайший предок,
// в котором лежит frontend/package.json. Скрипт живёт либо в
// <панель>/frontend/scripts (куда его кладёт update.sh), либо в
// ma7engine/scripts — оба случая этот подъём разбирает одинаково, и он не
// зависит от того, какие ещё каталоги оказались рядом.
function resolvePanel() {
const arg = process.argv[2];
if (arg) return path.resolve(arg);
for (let dir = HERE; ; dir = path.dirname(dir)) {
if (fs.existsSync(path.join(dir, 'frontend/package.json'))) return dir;
if (dir === path.dirname(dir)) break;
}
return path.dirname(HERE);
}
const PANEL = resolvePanel();
const FRONT = path.join(PANEL, 'frontend');
// Панель из одной папки (сервер рядом с фронтом, как было в awg-ui) держит
// серверную половину ядра в <панель>/core, а не в <панель>/backend/core.
const BACK = fs.existsSync(path.join(PANEL, 'backend')) ? path.join(PANEL, 'backend') : PANEL;
const SERVER_CORE = path.join(BACK, 'core');
// Сам шаблон ma7engine — панель по всем структурным признакам, но demo_token и
// «Demo Panel» в нём норма, а не недоделка. Узнаётся по каталогу core/ рядом.
const IS_TEMPLATE = fs.existsSync(path.join(PANEL, 'core', 'browser'));
// Ядро попадает в панель одним из двух способов, и проверки у них разные:
//
// subtree — панель лежит в git-репозитории продукта, ядро подтянуто
// `git subtree add` и потому отслеживается git. Обновляется
// `git subtree pull`; update.sh здесь не нужен и вреден — он
// сносит каталог целиком и затёр бы поддерево.
//
// copy — ядро раскладывает update.sh из соседнего ma7engine. Так устроен
// сам шаблон, и так живёт панель вне git.
function coreIsTracked(dir) {
try {
const out = execFileSync('git', ['ls-files', '--', dir],
{ cwd: PANEL, stdio: ['ignore', 'pipe', 'ignore'] });
return out.toString().trim() !== '';
} catch { return false; }
}
const MODE = !IS_TEMPLATE && coreIsTracked(path.join(FRONT, 'src/core')) ? 'subtree' : 'copy';
// ─── накопитель результатов ─────────────────────────────────────────────────
const errors = [], warns = [], skips = [], oks = [];
const ok = (m) => oks.push(m);
const err = (m, w) => errors.push(w ? `${m}\n ${w}` : m);
const warn = (m, w) => warns.push(w ? `${m}\n ${w}` : m);
const skip = (m) => skips.push(m);
const read = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
// Проверка «файл есть и в нём встречается X»; возвращает текст либо null.
function need(file, label) {
const text = read(file);
if (text === null) err(`${label}: нет файла ${path.relative(PANEL, file)}`);
return text;
}
// ─── 1. раскладка и сборка ──────────────────────────────────────────────────
if (!fs.existsSync(FRONT)) {
console.error(`doctor: в ${PANEL} нет каталога frontend — это не панель ma7engine`);
process.exit(1);
}
const updateSh = path.join(FRONT, 'scripts/update.sh');
if (MODE === 'subtree') {
if (fs.existsSync(updateSh)) {
err('в subtree-панели остался frontend/scripts/update.sh',
'он сносит каталог ядра целиком (rm -rf) и затрёт подтянутое поддерево — ' +
'обновление здесь делается git subtree pull');
} else ok('ядро приходит через git subtree (update.sh не нужен)');
} else if (fs.existsSync(updateSh)) ok('update.sh на месте');
else err('нет frontend/scripts/update.sh', 'без него ядро в панели не обновляется');
const frontPkgText = need(path.join(FRONT, 'package.json'), 'фронт');
const frontPkg = frontPkgText ? JSON.parse(frontPkgText) : null;
if (frontPkg) {
const s = frontPkg.scripts ?? {};
if (MODE === 'subtree') {
const stale = ['update', 'predev', 'prebuild'].filter(h => /update/.test(s[h] ?? ''));
if (stale.length) err(`frontend/package.json: остались хуки копирования (${stale.join(', ')})`,
'ядро приходит поддеревом — эти скрипты его затрут');
else ok('в package.json нет хуков копирования ядра');
} else {
if (/update\.sh/.test(s.update ?? '')) ok('скрипт update ведёт на scripts/update.sh');
else err('frontend/package.json: скрипт "update" не зовёт scripts/update.sh');
for (const hook of ['predev', 'prebuild']) {
if (/update/.test(s[hook] ?? '')) ok(`${hook} обновляет ядро`);
else err(`frontend/package.json: нет "${hook}": "npm run update"`,
'без него dev и build идут на устаревшей копии ядра');
}
}
}
// tsconfig.json шаблона содержит //-комментарий — JSON.parse на нём падает,
// поэтому конфиги проверяются регулярками, а не разбором.
const tsconf = need(path.join(FRONT, 'tsconfig.json'), 'фронт');
if (tsconf) {
const pairs = [[/"@core\/\*"\s*:\s*\[\s*"\.\/src\/core\/\*"\s*\]/, '@core/* → ./src/core/*'],
[/"@\/\*"\s*:\s*\[\s*"\.\/src\/\*"\s*\]/, '@/* → ./src/*']];
for (const [re, label] of pairs) {
if (re.test(tsconf)) ok(`tsconfig: ${label}`);
else err(`tsconfig.json: нет пути ${label}`, 'вкладки перестанут резолвить импорты ядра');
}
}
const viteConf = need(path.join(FRONT, 'vite.config.ts'), 'фронт');
if (viteConf) {
for (const [re, label] of [[/'@core'\s*:/, '@core'], [/'@'\s*:/, '@']]) {
if (re.test(viteConf)) ok(`vite: алиас ${label}`);
else err(`vite.config.ts: нет алиаса ${label}`, 'сборка не найдёт то, что находит tsc');
}
}
// ─── 2. вендоренная копия ядра ──────────────────────────────────────────────
const BROWSER_CORE = path.join(FRONT, 'src/core');
if (!fs.existsSync(BROWSER_CORE)) {
err('нет frontend/src/core', 'ядро ни разу не приезжало — запусти npm run update');
}
// Хэш дерева: имена файлов и содержимое, в устойчивом порядке.
function treeHash(dir) {
if (!fs.existsSync(dir)) return null;
const h = crypto.createHash('sha256');
const walk = (d, prefix) => {
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
if (e.name === '.DS_Store') continue; // Finder кладёт его в любую папку
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p, `${prefix}${e.name}/`);
else { h.update(`${prefix}${e.name}\0`); h.update(fs.readFileSync(p)); }
}
};
walk(dir, '');
return h.digest('hex');
}
// Ядро ищется тем же перебором, что в update.sh — порядок от ближнего к дальнему.
function findEngine() {
if (process.env.MA7ENGINE) return process.env.MA7ENGINE;
if (IS_TEMPLATE) return PANEL;
for (const c of [path.join(FRONT, '../ma7engine'),
path.join(FRONT, '../../ma7engine'),
path.join(FRONT, '../../../ma7engine'),
path.join(FRONT, '../../../admins/ma7engine')]) {
if (fs.existsSync(path.join(c, 'core/browser'))) return path.resolve(c);
}
return null;
}
const engine = findEngine();
if (!engine) {
skip('сверка копии ядра — ma7engine рядом не найден (укажи MA7ENGINE=/путь)');
} else {
for (const [copy, src, label] of [[BROWSER_CORE, path.join(engine, 'core/browser'), 'браузерная'],
[SERVER_CORE, path.join(engine, 'core/server'), 'серверная']]) {
if (!fs.existsSync(copy)) { skip(`сверка: ${label} половина ядра в панель не приезжала`); continue; }
if (treeHash(copy) === treeHash(src)) ok(`копия ядра совпадает с ядром (${label})`);
else err(`копия ядра разошлась с ma7engine (${label}: ${path.relative(PANEL, copy)})`,
'ядро правили руками либо копия устарела — npm run update перезальёт её');
}
}
// Ядро обязано ходить относительными путями: с @core внутри себя вендоренная
// копия потянула бы за собой требование к настройке потребителя.
for (const [dir, label] of [[BROWSER_CORE, 'browser'], [SERVER_CORE, 'server']]) {
if (!fs.existsSync(dir)) continue;
const bad = [];
const walk = (d) => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p);
else if (/\.tsx?$/.test(e.name) && /from\s+'@(core)?\//.test(read(p) ?? '')) bad.push(p);
}
};
walk(dir);
if (bad.length) err(`внутри копии ядра есть алиасные импорты (${label}): ` +
bad.map(p => path.relative(PANEL, p)).join(', '),
'ядро должно импортировать себя относительными путями');
else ok(`копия ядра ходит относительными путями (${label})`);
}
// ─── 3. игнор-файлы ─────────────────────────────────────────────────────────
// Копия ядра обязана попадать и в репозиторий панели, и в образ: контекст
// сборки — каталог панели, ma7engine рядом при сборке нет.
//
// В самом ma7engine наоборот: там копия — артефакт `npm run update`, источник
// лежит в соседнем core/, и коммитить её значило бы держать ядро в одном
// репозитории трижды.
const gitignore = read(path.join(PANEL, '.gitignore')) ?? read(path.join(FRONT, '.gitignore'));
if (gitignore === null) {
skip('.gitignore — файла нет, проверять нечего');
} else {
const ignoresCore = /(^|\/)(frontend\/)?src\/core\s*$/m.test(gitignore)
|| /(^|\/)backend\/core\s*$/m.test(gitignore)
|| /^core\/?\s*$/m.test(gitignore);
if (IS_TEMPLATE) {
if (ignoresCore) ok('.gitignore закрывает копию ядра (шаблон — источник рядом в core/)');
else warn('шаблон: .gitignore не закрывает копию ядра',
'в ma7engine она артефакт npm run update, а источник — соседний core/');
} else if (ignoresCore) {
err('.gitignore исключает копию ядра из репозитория панели',
'образ собирается из чистого checkout\'а, где ma7engine рядом нет — ' +
'без закоммиченной копии сборка встанет на импорте @core');
} else {
ok('копия ядра коммитится вместе с панелью');
}
}
// ─── 3b. образ ──────────────────────────────────────────────────────────────
const dockerfile = read(path.join(PANEL, 'Dockerfile'));
if (dockerfile === null) {
warn('нет Dockerfile', 'панель — один процесс и один образ; шаблон везёт готовый');
} else {
if (/COPY --from=frontend .*dist/.test(dockerfile)) ok('Dockerfile кладёт собранный фронт в образ');
else warn('Dockerfile не копирует frontend/dist из стадии сборки',
'сервер отдаёт фронт сам — без dist панель поднимется пустой');
}
const dockerignore = read(path.join(PANEL, '.dockerignore'));
if (dockerignore === null) {
skip('.dockerignore — файла нет, проверять нечего');
} else if (/(^|\/|\*)core\/?\s*$/m.test(dockerignore)) {
err('.dockerignore исключает core', 'в образ копия ядра должна приехать с исходниками — ' +
'иначе сборка встанет на импорте @core');
} else {
ok('.dockerignore не исключает копию ядра');
}
// ─── 4. вкладки ─────────────────────────────────────────────────────────────
// Те же инварианты, что buildTabs проверяет в рантайме, но до сборки.
const tabsDir = path.join(FRONT, 'src/tabs');
if (!fs.existsSync(tabsDir)) {
warn('нет src/tabs', 'панель без вкладок покажет заглушку');
} else {
// Имена иконок берутся только из тел карт ICONS и coreIcons — разбирать
// файл целиком значило бы ловить в известные и случайные `что-то: Ico…`,
// а ложная ошибка здесь хуже пропущенной: buildTabs всё равно поймает
// незарегистрированную иконку в рантайме.
const mapBody = (text, name) => {
const start = text.indexOf(`export const ${name}`);
if (start === -1) return null;
const open = text.indexOf('{', start), close = text.indexOf('\n};', open);
return open === -1 || close === -1 ? null : text.slice(open, close);
};
const bodies = [mapBody(read(path.join(FRONT, 'src/components/icons.tsx')) ?? '', 'ICONS'),
mapBody(read(path.join(BROWSER_CORE, 'icons/index.tsx')) ?? '', 'coreIcons')];
const known = bodies.some(b => b === null)
? null // карту не нашли — проверку пропускаем
: new Set([...bodies.join('\n').matchAll(/(\w+)\s*:\s*Ico\w+/g)].map(m => m[1]));
if (known === null) skip('сверка иконок вкладок — карта ICONS не разобрана');
const ids = new Map();
let clean = true;
for (const e of fs.readdirSync(tabsDir, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
const dir = path.join(tabsDir, e.name);
const metaPath = path.join(dir, 'metadata.json');
if (!fs.existsSync(path.join(dir, 'index.tsx'))) { err(`tabs/${e.name}: нет index.tsx`); clean = false; continue; }
if (!fs.existsSync(metaPath)) { err(`tabs/${e.name}: нет metadata.json`); clean = false; continue; }
let meta;
try { meta = JSON.parse(read(metaPath)); }
catch { err(`tabs/${e.name}/metadata.json: не разбирается как JSON`); clean = false; continue; }
for (const key of ['id', 'label', 'icon']) {
if (typeof meta[key] !== 'string' || !meta[key].trim()) {
err(`tabs/${e.name}/metadata.json: поле "${key}" должно быть непустой строкой`); clean = false;
}
}
if (meta.id) {
if (ids.has(meta.id)) { err(`дублирующийся id вкладки "${meta.id}": ${ids.get(meta.id)} и ${e.name}`); clean = false; }
else ids.set(meta.id, e.name);
}
if (meta.icon && known && !known.has(meta.icon)) {
err(`tabs/${e.name}: иконка "${meta.icon}" не найдена в карте ICONS`); clean = false;
}
}
if (clean && ids.size) ok(`вкладки в порядке (${ids.size} шт.)`);
}
// ─── 5. бэкенд ──────────────────────────────────────────────────────────────
const serverTs = read(path.join(BACK, 'server.ts'));
if (serverTs === null) {
skip('server.ts не найден — панель, видимо, без своего бэкенда');
} else {
if (/from\s+'\.\/core'/.test(serverTs)) ok("server.ts импортирует './core'");
else warn("server.ts не импортирует './core'", 'панель собирает сервер сама — это допустимо для панели-прокси');
const backPkgText = read(path.join(BACK, 'package.json'));
if (backPkgText) {
const deps = JSON.parse(backPkgText).dependencies ?? {};
for (const dep of ['express', 'jsonwebtoken']) {
if (deps[dep]) ok(`бэкенд: ${dep} в зависимостях`);
else err(`backend/package.json: нет зависимости ${dep}`,
'серверная половина ядра объявляет её peer-зависимостью');
}
}
}
// ─── 6. следы шаблона (предупреждения) ──────────────────────────────────────
if (!IS_TEMPLATE) {
const app = read(path.join(FRONT, 'src/App.tsx')) ?? '';
if (/demo_token|demo_theme/.test(app)) {
warn('storageKeys остались демонстрационными (demo_token/demo_theme)',
'две панели на одном домене затрут друг другу токен и тему');
}
if (/name:\s*'Demo Panel'/.test(app)) warn("branding.name остался 'Demo Panel'");
// Заголовок вкладки браузера живёт отдельно от branding.name и виден до
// того, как смонтируется React, — забывается чаще всего.
const html = read(path.join(FRONT, 'index.html')) ?? '';
if (/<title>Demo Panel<\/title>/.test(html)) warn("index.html: <title> остался 'Demo Panel'");
for (const [pkgPath, label] of [[path.join(FRONT, 'package.json'), 'frontend'],
[path.join(BACK, 'package.json'), 'backend']]) {
const t = read(pkgPath);
if (t && /"ma7engine-example-/.test(t)) warn(`${label}/package.json: имя осталось от шаблона`);
}
}
// Половины ставят TypeScript и @types/node врозь — расхождение мажоров значит,
// что tsc во фронте и в бэкенде проверяют код по разным правилам.
{
const major = (r) => (r ?? '').replace(/^[^\d]*/, '').split('.')[0];
const f = frontPkg?.devDependencies ?? {};
const bText = read(path.join(BACK, 'package.json'));
const b = bText ? (JSON.parse(bText).devDependencies ?? {}) : {};
for (const dep of ['typescript', '@types/node']) {
if (f[dep] && b[dep] && major(f[dep]) !== major(b[dep])) {
warn(`${dep} разъехался между половинами: ${f[dep]} и ${b[dep]}`);
}
}
}
// ─── вывод ──────────────────────────────────────────────────────────────────
const rel = path.relative(process.cwd(), PANEL) || '.';
console.log(`doctor: ${rel}${IS_TEMPLATE ? ' (шаблон ma7engine)' : ` (ядро: ${MODE})`}\n`);
for (const m of oks) console.log(` ok ${m}`);
for (const m of skips) console.log(` — ${m}`);
for (const m of warns) console.log(` warn ${m}`);
for (const m of errors) console.log(` ОШИБКА ${m}`);
console.log();
if (errors.length) {
console.log(`doctor: ошибок ${errors.length}, предупреждений ${warns.length}`);
process.exit(1);
}
console.log(warns.length
? `doctor: ошибок нет, предупреждений ${warns.length}`
: 'doctor: панель соответствует стандарту');
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env node
// Заводит новую панель на этом ядре — копирует обе половины шаблона и
// подставляет то, что README раньше предписывал править руками: имена в
// package.json, брендинг и ключи localStorage. Забытые ключи — не мелочь: две
// панели на одном домене затирают друг другу токен и тему.
//
// npm run new-panel -- admin-hub # → ../admin-hub
// npm run new-panel -- admin-hub ~/work/hub # явный путь
//
// По умолчанию панель кладётся соседом ma7engine: именно там её найдёт перебор
// кандидатов в scripts/update.sh, и `npm run update` заработает без $MA7ENGINE.
//
// Не sh, в отличие от update.sh: здесь многострочная правка исходников, а не
// копирование каталогов, и sed по номерам строк ломается от любой правки
// шаблона. Node гарантированно есть — обе половины и так npm-проекты.
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.dirname(HERE); // …/ma7engine
const die = (msg) => { console.error(`new-panel: ${msg}`); process.exit(1); };
// ─── аргументы ──────────────────────────────────────────────────────────────
const [slug, dirArg] = process.argv.slice(2);
if (!slug || slug === '--help' || slug === '-h') {
console.log('Использование: npm run new-panel -- <slug> [каталог]\n' +
' slug — имя панели строчными буквами через дефис, например admin-hub');
process.exit(slug ? 0 : 1);
}
if (!/^[a-z][a-z0-9-]*$/.test(slug)) {
die(`slug "${slug}" негоден — строчные латинские буквы, цифры и дефис, начинается с буквы`);
}
const target = path.resolve(dirArg ?? path.join(path.dirname(ROOT), slug));
if (fs.existsSync(target) && fs.readdirSync(target).length > 0) {
die(`каталог ${target} существует и не пуст`);
}
// admin-hub → admin_hub (ключи стораджа) и «Admin Hub» (брендинг)
const keyPrefix = slug.replace(/-/g, '_');
const display = slug.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' ');
// ─── копирование половин ────────────────────────────────────────────────────
// Вендоренная копия ядра не копируется: она приедет своим `npm run update`.
// Так новая панель гарантированно получает актуальное ядро, а не слепок чужого.
const SKIP = new Set(['node_modules', 'dist', 'core', 'package-lock.json']);
function copyTree(from, to) {
fs.mkdirSync(to, { recursive: true });
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
if (SKIP.has(entry.name)) continue;
const src = path.join(from, entry.name);
const dst = path.join(to, entry.name);
if (entry.isDirectory()) copyTree(src, dst);
else fs.copyFileSync(src, dst);
}
}
copyTree(path.join(ROOT, 'frontend'), path.join(target, 'frontend'));
copyTree(path.join(ROOT, 'backend'), path.join(target, 'backend'));
// ─── подстановки ────────────────────────────────────────────────────────────
// Каждая замена обязана сработать: молча пропущенная — это ровно тот случай,
// когда панель уезжает с чужим ключом стораджа и ловится потом руками.
function edit(relPath, pairs) {
const file = path.join(target, relPath);
let text = fs.readFileSync(file, 'utf8');
for (const [from, to] of pairs) {
if (!text.includes(from)) die(`в ${relPath} не найдено для замены:\n---\n${from}\n---`);
text = text.replace(from, to);
}
fs.writeFileSync(file, text);
}
edit('frontend/package.json', [
['"ma7engine-example-frontend"', `"${slug}-frontend"`],
// В панели ядро приезжает через git subtree, а не копированием: хуки
// predev/prebuild и сам update.sh здесь не нужны и вредны — update.sh
// сносит каталог целиком (rm -rf) и затёр бы подтянутое поддерево.
[` "predev": "npm run update",\n`, ''],
[` "update": "sh scripts/update.sh",\n`, ''],
[` "prebuild": "npm run update",\n`, ''],
]);
// Заголовок вкладки браузера — не то же, что branding.name в App.tsx: его видно
// до того, как React смонтируется, и в закладках.
edit('frontend/index.html', [['<title>Demo Panel</title>', `<title>${display}</title>`]]);
edit('backend/package.json', [['"ma7engine-example-backend"', `"${slug}-backend"`]]);
edit('frontend/src/App.tsx', [
[`// Шаблон фронта панели — с этого начинается новый. Копируется вместе с
// ../backend; ядро в свою панель приезжает через \`npm run update\`.
//
// Всё, что здесь есть: конфиг. Логин, тема, drawer/сайдбар, снэкбар, сессия,
// меню по правам и переключение вкладок — в <Panel />.
//
// Парная половина — backend/server.ts: вкладки ниже ходят в его
// маршруты /api/demo/*, так что две половины запускаются вместе и работают.`,
`// Фронт панели ${display}. Всё, что здесь есть: конфиг. Логин, тема,
// drawer/сайдбар, снэкбар, сессия, меню по правам и переключение вкладок —
// в <Panel /> из ядра.
//
// Ядро приезжает копией в src/core через \`npm run update\` (его зовут predev
// и prebuild). Парная половина — backend/server.ts.`],
[`branding: { name: 'Demo Panel', version: '0.1.0' },`,
`branding: { name: '${display}', version: '0.1.0' },`],
[`storageKeys: { token: 'demo_token', theme: 'demo_theme' },`,
`storageKeys: { token: '${keyPrefix}_token', theme: '${keyPrefix}_theme' },`],
[` // slots: { aboveTab: ctx => ctx.tab?.id === 'docs' ? null : <ServerBar /> },`,
` // slots: { aboveTab: ctx => ctx.tab?.id === 'kit' ? null : <ServerBar /> },`],
]);
// ─── бэкенд: шапка, лог и вырезание демо-маршрутов ──────────────────────────
const serverFile = path.join(target, 'backend/server.ts');
let server = fs.readFileSync(serverFile, 'utf8');
const headOld = `// Шаблон бэкенда панели — с этого начинается новый. Копируется вместе с
// ../frontend; ядро в свою панель приезжает через \`npm run update\`.
//
// Всё, что здесь есть: конфиг и свои маршруты. Логин, сессия, права,
// рейт-лимит, раздача SPA и порядок их монтирования — в createPanelServer.`;
const headNew = `// Бэкенд панели ${display}. Всё, что здесь есть: конфиг и свои маршруты.
// Логин, сессия, права, рейт-лимит, раздача SPA и порядок их монтирования —
// в createPanelServer. Ядро приезжает копией в ./core через \`npm run update\`,
// запущенный во фронте.`;
if (!server.includes(headOld)) die('backend/server.ts: не найдена шапка шаблона');
server = server.replace(headOld, headNew);
// mountRoutes с демо-маршрутами уезжает целиком: три роута /api/demo/* нужны
// только вкладкам hello и secrets, которых в новой панели не будет. Образец
// остаётся комментарием — вместе со строкой импорта, которая ему понадобится.
const startMark = '\n mountRoutes(app, { auth, forwardError }) {\n';
const endMark = '\n },\n});\n';
const start = server.indexOf(startMark);
const end = server.indexOf(endMark, start);
if (start === -1 || end === -1) die('backend/server.ts: не найден блок mountRoutes');
server = server.slice(0, start) + `
// Маршруты панели. Монтируются ядром после логина и до статики — порядок
// зафиксирован внутри createPanelServer и снаружи не нарушается.
//
// import type { Request, Response } from 'express';
//
// mountRoutes(app, { auth, forwardError }) {
// // Открыт любому залогиненному — у вкладки нет permission.
// app.get('/api/reports', auth.requireAuth, async (req: Request, res: Response) => {
// try { res.json(await db.reports()); }
// catch (e) { forwardError(res, e); }
// });
//
// // Закрыт правом. Гейт на фронте (permission во вкладке) — это
// // только UX; настоящая граница здесь.
// app.post('/api/reports', auth.requireAuth,
// auth.requirePermission('reports.manage'), handler);
// },
});
` + server.slice(end + endMark.length);
// Request/Response остались бы висеть неиспользованными — образец выше
// показывает, как вернуть импорт вместе с маршрутом.
const importLine = "import type { Request, Response } from 'express';\n";
if (server.includes(importLine)) server = server.replace(importLine, '');
if (!server.includes('demo-panel: listening on')) die('backend/server.ts: не найдена строка лога');
server = server.replace('demo-panel: listening on', `${slug}: listening on`);
fs.writeFileSync(serverFile, server);
// ─── демо-вкладки ───────────────────────────────────────────────────────────
// hello и secrets ходят в /api/demo/*, которых больше нет — уезжают. docs
// (справочник по ядру) и kit (живой каталог классов дизайн-системы)
// самодостаточны и остаются: новая панель запускается непустой.
for (const tab of ['hello', 'secrets']) {
fs.rmSync(path.join(target, 'frontend/src/tabs', tab), { recursive: true, force: true });
}
// ─── файлы уровня панели ────────────────────────────────────────────────────
// Копия ядра в панели — закоммиченный исходник, а не артефакт сборки: образ
// собирается из чистого checkout'а, где ma7engine рядом нет. Обновляется она
// осознанно, через `npm run update`, и приходит в ревью диффом; расхождение с
// ядром ловит `npm run doctor`.
fs.writeFileSync(path.join(target, '.gitignore'),
`node_modules/
# Сборка фронта — в образе он собирается заново.
frontend/dist
# frontend/src/core и backend/core здесь НЕ игнорируются намеренно: копия ядра
# едет в репозиторий панели, иначе образ не собрать без ma7engine рядом.
`);
// Dockerfile и .dockerignore едут из шаблона как есть: контекст сборки — каталог
// панели, поэтому ни одного пути в них править не нужно.
for (const f of ['Dockerfile', '.dockerignore']) {
fs.copyFileSync(path.join(ROOT, f), path.join(target, f));
}
// Доктор берётся из scripts/, а не тот, что мог приехать внутри frontend/:
// в шаблоне frontend/scripts/doctor.mjs — вендоренная копия, и она бывает
// старше исходника. Панель должна получить актуальные проверки.
fs.mkdirSync(path.join(target, 'frontend/scripts'), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'scripts/doctor.mjs'),
path.join(target, 'frontend/scripts/doctor.mjs'));
fs.mkdirSync(path.join(target, 'scripts'), { recursive: true });
const dev = fs.readFileSync(path.join(ROOT, 'scripts/demo.sh'), 'utf8')
.replace('# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане.',
`# Поднимает панель ${display} целиком: бэкенд в фоне, Vite на переднем плане.`)
.replace('# npm run demo (из корня ma7engine)\n', '')
.replace('# sh scripts/demo.sh (откуда угодно — пути считаются от самого скрипта)',
'# sh scripts/dev.sh (откуда угодно — пути считаются от самого скрипта)')
.replace('# Учётка по умолчанию — admin / demo. Переопределяется окружением:\n' +
'# ADMIN_USER=root ADMIN_PASS=secret npm run demo',
'# Учётка по умолчанию — admin / demo, только для дева. Переопределяется\n' +
'# окружением: ADMIN_USER=root ADMIN_PASS=secret sh scripts/dev.sh')
.replaceAll('demo: ', `${slug}: `);
fs.writeFileSync(path.join(target, 'scripts/dev.sh'), dev, { mode: 0o755 });
// ─── ядро ───────────────────────────────────────────────────────────────────
// Ядро сюда не копируется: его приносит `git subtree add` — командами ниже.
// Копия и поддерево не уживаются, поэтому update.sh в панель не едет вовсе.
fs.rmSync(path.join(target, 'frontend/scripts/update.sh'), { force: true });
// ─── что дальше ─────────────────────────────────────────────────────────────
// Панель — надстройка: она обычно лежит подкаталогом продуктового репозитория,
// и prefix для subtree считается от его корня. Угадать корень отсюда нельзя,
// поэтому в подсказке путь даётся относительно каталога панели.
const prefix = `${path.basename(target)}/`;
console.log(`
new-panel: панель ${display} создана в ${target}
Дальше:
cd ${target}/frontend && npm i
cd ${target}/backend && npm i
cd ${target} && sh scripts/dev.sh # обе половины, фронт на :5173
Пароль и секрет обязательны — без них ядро не даст процессу подняться:
ADMIN_PASS=... ADMIN_JWT_SECRET=...
Ядро приносит git subtree — из каталога панели в её репозитории:
git subtree add --prefix=${prefix}frontend/src/core ${ROOT} dist/browser --squash
git subtree add --prefix=${prefix}backend/core ${ROOT} dist/server --squash
Дальше обновляется тем же git subtree pull. Копировать ядро руками не нужно —
и не стоит: поддерево умеет merge и покажет конфликт, а копия молча затрёт.
Проверить, что панель собрана по стандарту:
cd ${target}/frontend && npm run doctor`);
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
# Готовит ветки, из которых продукты тянут ядро через `git subtree pull`.
#
# sh scripts/release.sh (или npm run release)
#
# У ядра две половины и два адресата, а `git subtree` работает с одним prefix
# за раз — поэтому каждая половина выделяется в свою ветку с собственной
# историей:
#
# core/browser → ветка dist/browser → <панель>/frontend/src/core
# core/server → ветка dist/server → <панель>/backend/core
#
# `git subtree split` переписывает историю выбранного каталога так, будто он
# всегда был корнем репозитория. Ветки полностью производные: их не правят
# руками и не мержат обратно — правится core/, после чего скрипт гоняется снова.
#
# Почему subtree, а не копирование: копия затирает любую локальную правку и не
# оставляет истории, поэтому «ядро в панели разошлось» выясняется постфактум.
# subtree делает настоящий merge — расхождение видно конфликтом в момент
# обновления, а правку, сделанную в продукте, можно отдать обратно в ядро
# (`git subtree push --prefix=<путь> <ядро> <ветка>`).
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root=$(dirname "$here")
cd "$root"
if [ -n "$(git status --porcelain)" ]; then
echo "release: рабочее дерево грязное — закоммить или спрячь правки" >&2
git status --short >&2
exit 1
fi
# `git subtree split -b <ветка>` умеет только создать ветку и отказывается
# двигать существующую, если новый коммит ей не потомок, — а после любого
# reset/rebase в ядре это ровно так. Поэтому split считает коммит, а ветка
# переставляется принудительно: ветки производные, своей истории у них нет.
split() { # split <каталог> <ветка>
printf 'release: %s → %s\n' "$1" "$2"
rev=$(git subtree split --prefix="$1" -q)
git branch -f "$2" "$rev"
printf ' %s\n' "$(git log -1 --format='%h %s' "$2")"
}
split core/browser dist/browser
split core/server dist/server
cat <<TXT
Готово. В продукте, где панель лежит подкаталогом:
# первый раз
git subtree add --prefix=<панель>/frontend/src/core $root dist/browser --squash
git subtree add --prefix=<панель>/backend/core $root dist/server --squash
# дальше
git subtree pull --prefix=<панель>/frontend/src/core $root dist/browser --squash
git subtree pull --prefix=<панель>/backend/core $root dist/server --squash
TXT
+116
View File
@@ -0,0 +1,116 @@
#!/bin/sh
# Обновляет вендоренную копию ядра (ma7engine) в панели:
# ma7engine/core/browser → <панель>/frontend/src/core (браузерная половина)
# ma7engine/core/server → <панель>/backend/core (серверная половина)
#
# Панель из одной папки, где сервер лежит рядом с фронтом (server.ts в корне,
# как в awg-ui), получает серверную часть в <панель>/core.
#
# Зачем копия, а не алиас прямо на соседнюю папку: контекст сборки образа
# панели — её собственный каталог (COPY frontend/ ./ в Dockerfile), и путь,
# уходящий выше него, внутри контейнера не разрешится. При копии пути
# ./src/core и ./core одинаковы в деве, в tsc --noEmit и в Docker, и Dockerfile
# трогать не нужно вообще.
#
# Файл кладётся в репозиторий потребителя (frontend/scripts/), потому что
# вызывать его нужно и тогда, когда ma7engine рядом нет.
#
# Использование: npm run update (или sh scripts/update.sh)
# Где искать ядро: $MA7ENGINE, иначе перебор кандидатов ниже — первый
# существующий выигрывает, выбранный путь печатается.
set -eu
abspath() { (CDPATH= cd -- "$1" 2>/dev/null && pwd); }
here=$(abspath "$(dirname -- "$0")")
app=$(dirname "$here") # .../<панель>/frontend или корень панели из одной папки
panel=$(dirname "$app") # .../<панель> или каталог, где лежат панели
# Ядро может лежать где угодно относительно панели: рядом с продуктовым
# репозиторием, рядом с остальными панелями или внутри самого репозитория.
# Перебираем, вместо того чтобы зашивать одну раскладку — переезд ядра иначе
# молча уводит синхронизацию на старую копию.
#
# Порядок — от ближнего к дальнему: панель, у которой ядро лежит рядом, не
# должна утащить копию из каталога уровнем выше (такая копия обычно старая —
# именно от неё и уезжает синхронизация молча).
CANDIDATES="
$app/../ma7engine
$app/../../ma7engine
$app/../../../ma7engine
$app/../../../admins/ma7engine
"
find_engine() {
if [ -n "${MA7ENGINE:-}" ]; then
if [ ! -d "$MA7ENGINE/core/browser" ]; then
echo "update: в MA7ENGINE=$MA7ENGINE нет каталога core/browser" >&2
exit 1
fi
abspath "$MA7ENGINE"
return
fi
for candidate in $CANDIDATES; do
if [ -d "$candidate/core/browser" ]; then
abspath "$candidate"
return
fi
done
}
# Куда класть серверную половину. Панели без своего сервера не получают её вовсе.
server_target() {
if [ -d "$panel/backend" ]; then echo "$panel/backend/core"
elif [ -f "$app/server.ts" ]; then echo "$app/core"
fi
}
# Скрипт обязан запускаться из фронта панели: $app определяется как каталог
# уровнем выше scripts/, и запущенный не оттуда он молча создаёт постороннюю
# src/core рядом с чем угодно. index.html — маркер vite-фронта.
if [ ! -f "$app/index.html" ]; then
echo "update: $app не похож на фронт панели (нет index.html)." >&2
echo " Скрипт запускается из <панель>/frontend: npm run update" >&2
exit 1
fi
front_to=$app/src/core
server_to=$(server_target)
engine=$(find_engine)
if [ -z "$engine" ]; then
# В контейнере ядра нет — оно уже приехало внутри src/core (и backend/core)
# вместе с остальными исходниками. Это норма, а не ошибка сборки.
if [ -d "$front_to" ] || { [ -n "$server_to" ] && [ -d "$server_to" ]; }; then
echo "update: ma7engine рядом не найден, использую уже синхронизированные копии"
exit 0
fi
echo "update: ma7engine не найден ни в одном из мест:" >&2
for candidate in $CANDIDATES; do echo " $candidate" >&2; done
echo " Укажи путь через MA7ENGINE=/путь/к/ma7engine" >&2
exit 1
fi
echo "update: ядро — $engine"
copy() { # copy <откуда> <куда> <подпись>
[ -d "$1" ] || return 0
rm -rf "$2"
mkdir -p "$(dirname "$2")"
cp -R "$1" "$2"
echo " $3: $1 → $2"
}
copy "$engine/core/browser" "$front_to" "фронт"
if [ -n "$server_to" ]; then
copy "$engine/core/server" "$server_to" "сервер"
fi
# Доктор едет вместе с ядром: проверки в панели должны быть те же, что в ядре,
# иначе панель отчитывается о соответствии по устаревшему списку. Кладётся
# рядом с этим скриптом — оттуда его зовёт `npm run doctor`.
if [ -f "$engine/scripts/doctor.mjs" ]; then
cp "$engine/scripts/doctor.mjs" "$here/doctor.mjs"
echo " доктор: $engine/scripts/doctor.mjs → $here/doctor.mjs"
fi
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["vite/client"]
},
"include": ["core/browser"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["core/server"]
}