ma7engine: ядро, шаблон панели, скаффолдер и доктор

Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
2026-08-26 20:39:37 +03:00
co-authored by Claude Opus 5
commit 18bd05bcbb
50 changed files with 8152 additions and 0 deletions
+492
View File
@@ -0,0 +1,492 @@
# ma7engine
Общее ядро админ-панелей: оболочка, реестр вкладок и дизайн-система. Родилось из
трёх форков одного каркаса (`admin-hub`, `admin-ma7`, `awg-ui`), где ~3600 строк
жили в трёх копиях: 77 из 78 CSS-правил и 194 из 197 строк `App.tsx` совпадали
побайтово.
```
core/ ядро; половины разъезжаются по панели врозь
browser/ браузер → <панель>/frontend/src/core
shell/ <Panel config={…} /> — логин, тема, drawer/сайдбар, снэкбар
shell/styles.css — дизайн-система (публичный контракт классов)
tabs/ buildTabs() — валидация манифестов, порядок, права, категории
http/ apiFetch, errText
utils/ bytes, timeAgo, fmtDateTime, copyText, downloadFile
auth/ hasPermission, OWNER_PERMISSION, типы сессии и PageProps
icons/ 10 иконок, совпадавших во всех панелях
server/ node → <панель>/backend/core
index.ts createPanelServer() — сборка всего в правильном порядке
ratelimit.ts лимит попыток логина по IP (с разбором X-Forwarded-For)
password.ts сравнение за постоянное время, scrypt-хэш, fail-closed
session.ts выпуск/проверка JWT, отзыв токена
auth.ts requireAuth, requirePermission
spa.ts раздача собранного фронта с fallback
errors.ts проброс статуса и текста ошибки вышестоящего API
frontend/ шаблон панели: конфиг + четыре вкладки
backend/server.ts шаблон бэкенда: конфиг + три демо-маршрута
scripts/update.sh копирует обе половины ядра в панель (npm run update)
scripts/new-panel.mjs заводит новую панель из шаблона (npm run new-panel)
scripts/doctor.mjs проверяет, что панель по стандарту (npm run doctor)
```
Половины лежат врозь не по вкусу, а потому что это две разные сборки: у них
разные tsconfig (`lib: DOM` + JSX + резолв бандлера против `lib: ES2020` +
CommonJS), разные адресаты копии и разные зависимости — в `core/browser`
импортируются только `react` и `axios`, в `core/server` — только `express`,
`jsonwebtoken` и `node:*`. Пересечение — ноль файлов.
## Что уже общее
Всё перечисленное панель получает готовым и у себя не пишет. Список
исчерпывающий: чего в нём нет — того нет и в ядре, значит это продуктовый код
конкретной панели.
### Оболочка
`<Panel config={…} />` — один и тот же код у панели с ролями и без, разница
только в конфиге:
- **Вход.** Форма логина с брендингом, `POST /login`, разбор ответа: 401 —
«Неверный логин или пароль», 429 — текст лимита, пришедший с сервера,
остальное — «Сервер недоступен».
- **Сессия.** Токен кладётся в `localStorage` и восстанавливается при загрузке;
протухший стирается молча, панель показывает логин.
- **Единый разлогин по 401.** Любой `apiFetch` из любой вкладки, получивший 401,
разлогинивает панель целиком — вкладки этот случай не обрабатывают.
- **Выход.** `POST /logout` (сервер отзывает токен), очистка стораджа и
состояния.
- **Тема.** Кнопка-переключатель, `data-theme` на `<html>`, запоминание выбора.
- **Меню.** Строится из вкладок, доступных правам текущей учётки, сгруппированных
по категориям; пустой список даёт заглушку «вкладок пока нет» или «ни одна
вкладка не доступна твоим правам».
- **Мобильный режим.** Топ-бар с гамбургером, drawer с затемнением, закрытие по
Escape, по клику в затемнение и свайпом влево, блокировка прокрутки фона.
- **Снэкбар.** `showMsg(text)` — сообщение на 3 секунды, общий для вкладок и
слотов.
- **Слоты.** `aboveTab` и `headerExtra` рисуются, если заданы в конфиге. Слот
получает активную вкладку в `ctx.tab` и сам решает, где не рисоваться.
- **Пропсы вкладки.** `token`, `showMsg`, `username`, `userId`, `role`,
`permissions`, `availablePermissions`, `onNavigate`.
Дефолты, когда конфиг молчит: ключи `admin_token` и `admin_theme`,
`POST /login`, `POST /logout`, `GET /api/session`.
### Браузерные модули
| модуль | что даёт |
|---|---|
| `@core/shell` | `Panel`; типы `PanelConfig`, `SlotContext` |
| `@core/tabs` | `buildTabs()`; типы `TabMeta`, `TabDef`, `TabGroup`, `BuiltTabs` |
| `@core/auth` | `hasPermission()`, `OWNER_PERMISSION`, `EMPTY_SESSION`; типы `Session`, `BasePageProps`, `SessionPageProps` |
| `@core/http` | `apiFetch()`, `errText()`, `setUnauthorizedHandler()` |
| `@core/utils` | `fmtDateTime()`, `timeAgo()`, `bytes()`, `downloadFile()`, `copyText()` |
| `@core/icons` | хром `IcoMenu`, `IcoLogout`, `IcoSun`, `IcoMoon`; карта `coreIcons` (`key`, `globe`); отдельные `IcoPlus`, `IcoTrash`, `IcoRefresh`, `IcoCopy`, `IcoGlobe`, `IcoKey` |
Плюс дизайн-система целиком: `core/browser/shell/styles.css` — 28 токенов палитры на `:root`
с переопределением на `:root[data-theme="dark"]` и контракт классов (см.
«Контракт CSS-классов»). Импортит её сама оболочка; локальный `App.css`
подключается после.
### Серверные модули
| модуль | что даёт |
|---|---|
| `createPanelServer()` | сборка всего в фиксированном порядке: json → логин/сессия → маршруты панели → статика с fallback |
| `createLoginRateLimit()`, `clientIp()` | 5 попыток с IP за 15 минут, с разбором `X-Forwarded-For` в проде |
| `createPasswordCheck()`, `hashPassword()`, `timingSafeEquals()` | сравнение за постоянное время, `scrypt`-хэш, fail-closed при старте |
| `createSessionStore()` | выпуск и проверка JWT (TTL 12 ч), отзыв токена |
| `createAuth()`, `bearerToken()` | `requireAuth`, `requirePermission(perm)` |
| `serveSpa()` | раздача `frontend/dist` с fallback; неизвестный `/api/*` — JSON-404, а не `index.html` |
| `forwardError()` | проброс статуса и текста ошибки вышестоящего API |
Чем эти модули строже того, что было в панелях по отдельности, — в разделе
«Что ядро делает строже панелей».
### Чего в списке нет
Три вещи у панелей разные и общими не станут: **конфиг** (`PanelConfig` — имя,
версия, ключи стораджа, эндпоинты, `loadSession`, слоты), **продуктовый код**
(вкладки, свои типы и утилиты, карта `ICONS`, маршруты бэкенда) и **дельта
стилей** в `App.css`. Где проходит граница — в разделе «Что в ядро не идёт».
## Базовая форма — панель без ролей
Ядро по умолчанию — плоский список вкладок, доступных всем: `category`,
`permission` и `devOnly` опциональны. Панель с правами получается включением
слоя, а не наоборот. Это не косметика: панель, которой права не нужны, не тащит
их машинерию и не обязана реализовывать эндпоинт сессии.
## Шаблон новой панели
Ядро здесь же и шаблон: `frontend/` и `backend/` в корне — рабочая минимальная
панель на этом самом ядре, а не отдельный пример, который живёт своей жизнью.
Ломающая правка ядра ломает и её, и это видно сразу.
### Запуск демо
Нужен Node 20+ (проверялось на 22). Одна команда — **из каталога `ma7engine`**,
не из каталога, где он лежит:
```bash
cd ma7engine # у меня это ~/Documents/admins/ma7engine
npm run demo
```
Скрипт при первом запуске сам поставит зависимости обеих половин, поднимет
бэкенд на `:8080` и Vite на `:5173`, а по Ctrl+C погасит оба. Открывать нужно
**`http://localhost:5173`**: Vite отдаёт фронт и проксирует `/login`, `/logout`
и `/api` на бэкенд, поэтому порт нигде не зашит. Логин — `admin` / `demo`
(переопределяется: `ADMIN_USER=root ADMIN_PASS=secret npm run demo`).
Если половины нужны врозь — два терминала, полные пути, потому что `npm start`
терминал не возвращает:
```bash
# терминал 1
cd ma7engine/backend && npm i && ADMIN_PASS=demo ADMIN_JWT_SECRET=dev-secret npm start
# терминал 2
cd ma7engine/frontend && npm i && npm run dev
```
Прод-режим — одним процессом, без Vite:
```bash
cd frontend && npm run build # → frontend/dist
cd backend && npm start # :8080 отдаёт и API, и собранный фронт
```
Ядро в шаблон приезжает копией, как в любую панель: `npm run update` кладёт
`core/browser` в `frontend/src/core`, а `core/server` — в `backend/core`. Руками
его звать не нужно, он вызывается сам перед `npm run dev` и `npm run build`,
поэтому правка в ядре подхватывается следующим запуском.
**Что может пойти не так**
| симптом | причина |
|---|---|
| `пароль панели не задан` при старте | не передан `ADMIN_PASS` — так и задумано, fail-closed |
| `не задан секрет подписи сессий` | не передан `ADMIN_JWT_SECRET` |
| `в frontend/dist нет index.html` | фронт не собран. Для дева это норма — открывай `:5173` |
| порт занят | `PORT=8081 npm start` и `VITE_PROXY_TARGET=http://localhost:8081 npm run dev` |
| `Could not read package.json` | команда запущена не из `ma7engine`, а из каталога уровнем выше |
| логин отвечает 429 | сработал рейт-лимит: 5 неудачных попыток с IP за 15 минут. Перезапусти бэкенд — счётчик в памяти |
Четыре вкладки: **«Инструкция»** — как устроен код панели: что где лежит, как
добавить вкладку, что откуда импортировать, скелет вкладки и маршрута;
**«Кто я»** — без `permission`, видна всем залогиненным, ходит в
`/api/demo/whoami`; **«Секреты»** — закрыта правом `secrets.view`; **«Витрина»** —
живой справочник дизайн-системы: все классы публичного контракта с подписями.
Первая и последняя ничего не грузят и открываются без бэкенда. Переименовал
класс в ядре — увидел это на витрине.
Заодно показано: свой класс в `App.css` и как (не) трогать палитру, расширение
карты иконок (`{ ...coreIcons, star: IcoStar }`), проброс ошибки вышестоящего API.
Чтобы увидеть гейт по правам в работе, задай в `backend/server.ts`
`claims: { permissions: [] }` — вкладка «Секреты» исчезнет из меню, а её маршрут
начнёт отвечать 403.
### Как из него сделать свою панель
Одной командой:
```sh
npm run new-panel -- admin-hub # → ../admin-hub, соседом ma7engine
npm run new-panel -- admin-hub ~/work/hub # явный путь
```
Скаффолдер копирует **`frontend/` и `backend/`**, но не `core/`: ядро в панель
приезжает копией через `npm run update`, а не форком. Панель кладётся соседом
ma7engine — именно там её найдёт перебор кандидатов в `update.sh`; переехав, она
найдёт ядро по `$MA7ENGINE`.
Что он делает за тебя — ровно то, что раньше правилось руками и потому забывалось:
1. **Имена** в `package.json` обеих половин.
2. **Брендинг и ключи `localStorage`** в `App.tsx` — `admin-hub` даёт
`admin_hub_token` и `admin_hub_theme`. Забытые ключи не абстрактны: две
панели на одном домене затирают друг другу токен и тему.
3. **Демо-часть**: вкладки `hello` и `secrets` и маршруты `/api/demo/*` уезжают,
`mountRoutes` остаётся пустым с образцом в комментарии. `docs` и `kit`
остаются — обе самодостаточны, и панель запускается непустой.
4. **`.gitignore`** и `scripts/dev.sh` (обе половины одной командой).
Остаётся только **учётка и секрет** — `ADMIN_USER`, `ADMIN_PASS` (или
`ADMIN_PASS_HASH`), `ADMIN_JWT_SECRET` в окружении бэкенда; без них ядро не даст
процессу подняться.
### Проверка соответствия — `npm run doctor`
Скаффолдер заводит панель по стандарту, доктор отвечает, соответствует ли она
ему до сих пор:
```sh
cd <панель>/frontend && npm run doctor
node scripts/doctor.mjs ../admin-hub # из ma7engine, по чужому пути
```
**Ошибки** (выход 1) — то, из-за чего панель соберётся неправильно или не
соберётся вовсе: пропал `update.sh` или `predev`/`prebuild`; разъехались алиасы
`@core` в `tsconfig.json` и `vite.config.ts`; `core` не закрыт `.gitignore` или,
наоборот, исключён `.dockerignore` (тогда образ соберётся без ядра); у вкладки
нет `metadata.json`, дублируется `id`, иконка не в карте `ICONS`; в бэкенде нет
`express` или `jsonwebtoken`.
Главная проверка — **сверка вендоренной копии с ядром по хэшу дерева**: она
ловит правку `src/core/` руками, то есть ровно ту первопричину, из-за которой
три панели когда-то и разъехались. Ядро ищется тем же перебором, что в
`update.sh`; не найдено — проверка честно печатается как пропущенная.
**Предупреждения** (выход 0, чтобы косметика не ломала CI) — следы шаблона:
`demo_token`, `Demo Panel`, имена `ma7engine-example-*`, разъехавшиеся между
половинами `typescript` и `@types/node`. На самом шаблоне они подавлены.
Доктор едет в панель вместе с ядром — его кладёт `update.sh` рядом с собой, так
что проверки в панели всегда те же, что в ядре.
## Подключение
Это про панель, которая уже есть; новая начинается с шаблона выше.
Ядро вендорится в `src/core` потребителя и подключается алиасом. Копия, а не
алиас на соседнюю папку, потому что контекст сборки образа панели — её
собственный каталог (`COPY frontend/ ./`), и путь выше него внутри контейнера не
разрешится. При копии `./src/core` одинаков в деве, в `tsc --noEmit` и в Docker,
и Dockerfile трогать не нужно.
```bash
cp <путь>/ma7engine/scripts/update.sh admin/frontend/scripts/
cd admin/frontend && npm run update # или sh scripts/update.sh
```
```jsonc
// package.json — обновление руками и перед каждым дев-запуском и сборкой
"scripts": {
"update": "sh scripts/update.sh",
"predev": "npm run update",
"prebuild": "npm run update"
}
```
```jsonc
// tsconfig.json
"baseUrl": ".",
"paths": {
"@core/*": ["./src/core/*"], // ядро
"@/*": ["./src/*"] // свой код панели
}
```
```ts
// vite.config.ts
resolve: { alias: {
'@core': path.resolve(__dirname, 'src/core'),
'@': path.resolve(__dirname, 'src'),
} }
```
Второй алиас — не косметика: вкладка объявлена самодостаточной папкой, но с
`../../lib/shared` внутри она не переносится между панелями и ломается при
изменении вложенности. Конфликта между `@` и `@core` нет ни в Vite, ни в
TypeScript: rollup-алиас матчит строку как `id === key || id.startsWith(key + '/')`,
а `paths` выбирает самый длинный подходящий префикс.
**Внутри самого ядра алиасов быть не должно** — оно импортит себя относительными
путями (`./session`, `../auth`). Иначе вендоренная копия потянет за собой
требование к настройке потребителя, и «скопировал папку — работает» перестанет
быть правдой.
`src/core/` (и `backend/core/`) — в `.gitignore`, но **не** в `.dockerignore`: в
образ они должны приехать вместе с остальными исходниками. Путь к ядру берётся
из `$MA7ENGINE`, иначе перебором кандидатов от ближнего к дальнему — ближний
выигрывает, чтобы панель не утащила молча старую копию из каталога уровнем выше.
Выбранный путь печатается.
Серверная половина кладётся в `<панель>/backend/core`. Панель из одной папки, где
сервер лежит рядом с фронтом (`server.ts` в корне, как в `awg-ui`), получает её в
`<панель>/core`.
В контейнере ядра рядом нет — и это норма: `update.sh` видит уже
синхронизированную копию, печатает это и выходит с нулём, поэтому `prebuild`
внутри образа не падает.
Позже это меняется на настоящую зависимость без правки импортов: спецификаторы
`@core/...` остаются, меняется только строка резолва.
## Точка сборки панели
```tsx
import Panel, { type PanelConfig } from '@core/shell';
import './App.css'; // локальные стили — после стилей ядра
import * as tabs from './tabs';
const config: PanelConfig = {
tabs,
branding: { name: 'MA7 Admin', version: '0.1.0' },
storageKeys: { token: 'admin_token', theme: 'admin_theme' },
// loadSession — по умолчанию GET /api/session; панель без ролей отдаёт
// отсюда permissions: ['*']
// slots — свой хром: aboveTab (над вкладкой), headerExtra (в углу шапки)
};
export default () => <Panel config={config} />;
```
## Реестр вкладок
`import.meta.glob` обязан остаться в приложении: это compile-time фича Vite, её
паттерн резолвится относительно файла, где написан, — уехав в ядро, он сканировал
бы ядро, а не `src/tabs/` приложения. Поэтому в приложении остаётся 8 строк:
```ts
import { buildTabs } from '@core/tabs';
import { ICONS } from '../components/icons';
export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs(
import.meta.glob<{ default: unknown }>('./*/metadata.json', { eager: true }),
import.meta.glob<{ default: any }>('./*/index.tsx', { eager: true }),
ICONS,
);
```
`metadata.json` компилятором не проверяется, поэтому валидируется в рантайме —
кривой манифест падает с внятной ошибкой при сборке `TABS`, а не тихо в UI:
| поле | обяз. | смысл |
|---|---|---|
| `id` | да | ключ активной вкладки, должен быть уникален |
| `label` | да | подпись в меню |
| `icon` | да | имя в карте `ICONS` |
| `category` | нет | группа в меню; без неё — плоский список |
| `permission` | нет | право доступа; без него видна всем залогиненным |
| `order` | нет | порядок по возрастанию, по умолчанию 0 |
| `devOnly` | нет | вырезается из prod-сборки (`import.meta.env.PROD`) |
## Иконки
`coreIcons` — только иконки **вкладок** (`key`, `globe`). Хром оболочки
(`IcoMenu`, `IcoLogout`, `IcoSun`, `IcoMoon`) — именованные экспорты, через карту
не ищется. Приложение расширяет и перекрывает: `{ ...coreIcons, ...myIcons }`.
## Контракт CSS-классов
Имена классов из `core/browser/shell/styles.css` — публичный API: на них опирается код вкладок
во всех панелях, поэтому переименование любого из них ломающее. Полный список — в
шапке самого файла. Коротко: `.btn` с вариантами, `.field`, `.table-card`,
`.chip`, `.toolbar`, `.modal-*`, `.actions`, `.empty`, `.td-mono`, `.td-num`.
Цвета — только через токены `var(--…)`; палитра задаётся на `:root` и
переопределяется на `:root[data-theme="dark"]`.
## Что в ядро не идёт
Брендинг (это поле конфига), карта `ICONS` целиком, продуктовые типы и утилиты,
вкладки, бэкенды. Компонент, нужный **одной** панели, остаётся в ней: обобщать по
одному потребителю рано. Понадобился второй — тогда и переезжает.
## Сервер
```ts
import { createPanelServer } from './core';
const app = createPanelServer({
auth: {
user: process.env.ADMIN_USER ?? 'admin',
password: process.env.ADMIN_PASS, // либо passwordHash
secret: process.env.ADMIN_JWT_SECRET ?? '',
revocable: true, // логаут реально отзывает токен
},
staticDir: '../frontend/dist',
mountRoutes(app, { auth, forwardError }) {
app.get('/api/x', auth.requireAuth, auth.requirePermission('x.view'), handler);
},
});
app.listen(8080);
```
Порядок монтирования фиксирован внутри и не может быть нарушен снаружи:
`json` → логин/сессия → маршруты панели → статика с fallback. Fallback ловит
любой GET, поэтому маршрут, зарегистрированный после него, не сработал бы.
Панель с готовым бэкендом может брать модули по отдельности
(`createSessionStore`, `createAuth`, `createLoginRateLimit`, `serveSpa`) — так
подключён MA7, у которого 38 своих маршрутов уже были написаны.
### Авторизация из коробки, и панель-прокси
`createPanelServer` всегда собирается с авторизацией: учётка задаётся в
`auth: { user, password | passwordHash, secret }`, панель сама выпускает и
проверяет токен, `/login`, `/logout` и `/api/session` монтируются готовыми.
Своей авторизации панель не пишет, а `sessions` и `auth` в контексте
гарантированы — проверять их на существование не нужно.
Панель, у которой собственных учёток нет (логин форвардится наверх, где живут
настоящие учётки), `createPanelServer` не использует — она собирает express
сама из кусков ядра:
```ts
const app = express();
const rateLimit = createLoginRateLimit();
// Тело JSON не парсим: прокси должен переслать сырой поток запроса как есть.
app.post('/login', rateLimit.middleware, createProxyMiddleware({ … }));
// …остальные прокси-маршруты…
serveSpa(app, STATIC_DIR); // последним: fallback ловит любой GET
```
Рейт-лимит и раздачу фронта наверх не вынести: вышестоящий API не видит IP
клиента панели отдельно от IP самого прокси.
### Что ядро делает строже панелей
- **Пароль сравнивается за постоянное время** (SHA-256 + `timingSafeEqual`), а
не через `!==`. Поддержан `scrypt`-хэш — тогда открытого пароля в окружении
нет вовсе, `hashPassword()` его генерирует.
- **Пароль проверяется даже при несовпавшем логине** — иначе по времени ответа
отличается «нет такого пользователя» от «неверный пароль».
- **Fail-closed при старте.** Без пароля или без секрета подписи процесс не
поднимается. Панель, тихо работающая с паролем `admin`, — худший исход, чем
не стартовавшая панель.
- **Рейт-лимит всегда разбирает `X-Forwarded-For`** (в проде; в деве заголовку
доверять нельзя). Панель, бравшая `req.socket.remoteAddress` напрямую, за
nginx схлопывала всех клиентов в один ключ, и пять неудачных попыток кем
угодно блокировали вход всем.
- **Неизвестный `/api/*` отдаёт JSON-404**, а не `index.html` со статусом 200,
на котором клиент падал бы при разборе ответа.
### Ограничения, о которых надо знать
Счётчик попыток и список отозванных токенов — в памяти процесса. Они не
переживают рестарт и не общие для нескольких инстансов за балансировщиком.
Для защиты от перебора этого достаточно; для настоящего отзыва на нескольких
инстансах передай своё `revocationStore`.
`jsonwebtoken` и `express` — peer-зависимости, ставятся в бэкенде панели.
## Кто на нём сидит
Три панели, ради которых ядро и появилось. Полезно как три разных ответа на
вопрос «сколько своего остаётся».
**admin-ma7** — панель с правами и своим бэкендом. Фронт: `App.tsx` — 18 строк
конфига (было 232), реестр вкладок — 20 (было 105), `App.css` — 52 строки
плиток дашборда (было 612, остальное нашлось в ядре). Бэкенд собран
`createPanelServer`, все 38 маршрутов ушли в `mountRoutes`; своя обёртка над
`forwardError` — только ради текста «API MA7 недоступен».
**admin-hub** — панель с правами, логин делегирует наверх. Фронт: `App.tsx` —
23 строки конфига, реестр — 23, `App.css` **удалён**: все 88 его правил
побайтово совпали с дизайн-системой ядра. Бэкенд берёт `createLoginRateLimit` и
`serveSpa` отдельными модулями, а не через `createPanelServer`: своей
авторизации у него нет вовсе, и тянуть ради неё `jsonwebtoken` незачем.
**awg-ui** — панель без ролей, живёт одной папкой (сервер рядом с фронтом).
Ролей нет: `loadSession` ходит в `/health` — это одновременно проверка токена и
данные для сервер-бара — и отдаёт `permissions: ['*']`. Свой хром — в слотах:
сервер-бар в `aboveTab`, кнопка перезапуска AWG в `headerExtra`. Своих стилей —
29 правил (было 700 строк). Сервер собран `createPanelServer`; ключи внешнего
API в SQLite и прокси к `awg-ctrl` остались своими.