# Платформа и супер-администратор

Экраны владельца установки: список сайтов, создание сайта, смена статуса.
Модуль `modules/platform/superadmin`, `tier = platform` — сборщик выгрузки
вырезает его целиком, и в копии сайта, отданной клиенту, нет ни этого кода,
ни таблицы учёток платформы.

## Отдельный домен

Платформа живёт на `korzilla.platform_host` (`KZ_PLATFORM_HOST`), а не
на домене сайта. Маршруты ограничены доменом — иначе `/sites` открылся бы
на каждом из сотен сайтов установки.

Хост платформы **пропускается мимо резолвера сайта**: в `site_domains` его нет
и быть не должно, а без исключения запрос упирался бы в «неизвестный хост»
раньше, чем дошёл до роутера.

⚠️ **Контекст арендатора при этом чистится явно.** `SiteContext` — scoped,
и в долгоживущем процессе (Octane, воркер, тесты) он пережил бы запрос
предыдущего сайта: платформенный экран получил бы канонизацию чужого домена
и уехал бы 301-м на арендатора. Нашлось тестом.

⚠️ **Хост платформы отвечает только тем, что объявлено для него.** Маршруты
сайта — резолвер страниц, `robots.txt`, карта, поиск, корзина, админка сайта,
обмен с 1С — объявлены без привязки к хосту. Пропущенные на хост платформы,
они доходили до кода, которому нужен сайт, и отвечали 500. **Нашлось по журналу
стенда**: 1842 ошибки «контекст сайта не установлен» за две недели, около
150 в сутки. Стучались боты — `/wp-admin/install.php` через Cloudflare каждые
полтора часа, Amazonbot за `robots.txt`.

Как теперь (`ResolveSite`):

| Адрес на хосте платформы | Ответ |
|---|---|
| маршрут, объявленный через `Route::domain(platform_host)` | как объявлен |
| `/health`, `/up` — сайт им не нужен | как объявлен |
| `/robots.txt` | `Disallow: /` — отсутствующий файл робот читает как «можно всё» |
| всё остальное | 404 |

Маршрут сопоставляется в middleware, до роутера. Список **разрешающий**:
маршрут сайта, объявленный завтра, на хосте платформы закрыт сам. Каждый ответ
платформы несёт `X-Robots-Tag: noindex, nofollow` — страница входа супер-админа
в поиске не нужна.

## Учётки платформы — отдельная таблица

`platform_admins`, объявлена **`platform`**, а не `tenant` и не `global`.

Причина не в удобстве: `admins` — персайтовая таблица, и она целиком уезжает
в выгрузку сайта. Заведи супер-админа строкой там — и учётка владельца
платформы уехала бы клиенту вместе с его копией сайта.

`site_id` у неё быть не может: супер-админ существует до сайтов и поверх
всех сразу.

⚠️ Изначально она была объявлена `global` — по той же логике «`site_id` у неё
нет». Первый живой прогон выгрузки показал цену этой логики: `global` означает
«справочник, который копии **нужен**, и он едет целиком», и почта с bcrypt-хэшем
пароля владельца установки оказались в дампе первого же сайта. Появилась
категория `platform` — «не едет вовсе», и тест-инвариант теперь читает миграции
`modules/platform/*` и требует её для каждой их таблицы.
Разбор — [export.md](export.md#platform--категория-которой-не-было).

## Вход

Устроен как гвард админки сайта и по тем же причинам:

- **сессия помечена учёткой и отпечатком пароля** — отключение учётки закрывает
  её сессии немедленно, смена пароля разлогинивает прочие устройства;
- **memo привязан к объекту запроса** — иначе scoped-гвард пережил бы запрос;
- **ошибка входа одна на все случаи**, и хэш считается даже без учётки: иначе
  текст ошибки и время ответа выдают, какие адреса заведены.

Первого супер-админа заводит **консоль**:

```bash
php artisan superadmin:create you@example.com --password=…
```

Формы регистрации нет намеренно: она означала бы, что установка стоит
с открытой дверью до тех пор, пока кто-нибудь не зарегистрируется первым.
Ровно по этой же причине первого владельца сайта заводит `admin:create`.

## Создание сайта

Идёт через то же действие `CreateSite`, что и консольная команда: сайт без
главной страницы, зон и дев-домена нерабочий, и собирать его повторно
в контроллере значило бы завести второй набор правил.

Сайт создаётся **черновиком**: доступен только по девелоперскому адресу
`{логин}.kzla.ru` и закрыт от индексации целиком (см. [seo.md](seo.md)).

Владельца можно завести сразу — почтой и паролем в той же форме; без них
его заводит `admin:create`.

## Копия из шаблона

Владелец платформы держит один-два сайта-шаблона («стоматология», «магазин
запчастей») и заводит клиента копией, а не сборкой с нуля. Собирать одно и то
же дерево, зоны и блоки руками на каждом из 300–500 сайтов — ровно та работа,
ради устранения которой платформа и пишется.

Отдельного признака «это шаблон» нет намеренно: шаблоном становится тот сайт,
с которого копируют. Флаг потребовал бы ещё и экрана его переключения.

| Копируется | Не копируется никогда | Почему |
|---|---|---|
| разделы, раскладки (страница и шаблоны карточки), зоны, блоки с правилами | домены | чужой домен в копии увёл бы трафик |
| настройки, SEO-шаблоны | администраторы | у клиента свой владелец, а не владелец шаблона |
| медиатека вместе с файлами | покупатели, заказы, корзины, заявки | это люди и их обращения, а не устройство |
| содержимое компонентов (по флажку) | токены приложения | выданы конкретным людям |
|  | каталог | сто тысяч товаров в шаблоне не нужны никому |

⚠️ **Идентификаторы пересобираются.** У раздела есть `parent_id`
и `card_layout_id`, у зоны — `layout_id`, у блока — `zone_id`, `parent_id`
(вкладки и колонка) и `source_section_id`, у SEO и у настройки с областью
«раздел» — `section_id`/`scope_id`. Раскладки копируются первыми: на них
ссылаются и зоны, и разделы. Без карты «старый id → новый» копия получила бы ссылки
на разделы сайта-шаблона: блок «объекты раздела» выводил бы чужие новости,
а настройка правила бы чужую страницу. Это была бы не копия, а межарендаторная
утечка, и проверяет это отдельный тест — «ничто в копии не смотрит в шаблон».

Заготовка от `CreateSite` (главная страница и зоны) убирается перед копированием:
оставленная рядом «Главная» дала бы сайт с двумя корневыми страницами.

Живой прогон: копия сайта на 4 раздела, 3 зоны, 8 блоков, SEO и новость —
создана с нулём администраторов и одним собственным dev-доменом, открывается
со своим названием в заголовке.

## Подключение модулей сайту

До сих пор все модули работали на всех сайтах установки: сайт визитки получал
корзину и импорт из 1С, а сайт магазина — портфолио. На 300–500 арендаторах
это лишние пункты меню, лишние адреса и невозможность продать модуль отдельно.

Отключаемым модуль объявляет себя сам — полем `optional` в `module.json`.
Ядро и то, без чего сайта не существует (разделы, страницы, новости),
не отключается вовсе: иначе владелец оставил бы себя без главной страницы
одним переключателем.

⚠️ **Отсутствие строки в `site_modules` означает «как заведено», а не
«выключено».** Иначе появление нового модуля в коде требовало бы вставки строки
каждому из сотен сайтов, и любой пропуск ломал бы сайт молча. Продажа
«включается после оплаты» — отдельное решение и отдельный флаг, а не поведение
по умолчанию.

Что делает отключение:

| Где | Что происходит | Почему так |
|---|---|---|
| адреса модуля | 404 | для посетителя это несуществующий адрес, а не запертая дверь; «здесь мог бы быть магазин» — реклама чужому клиенту |
| пункты меню админки | исчезают | ссылка, ведущая в 404, читается администратором как поломка системы |
| кнопки покупки в каталоге | исчезают | иначе витрина зовёт в корзину, которой у сайта нет |
| данные модуля | остаются в базе | включение возвращает всё как было |

⚠️ **Проверка модуля идёт РАНЬШЕ авторизации** (`prependToPriorityList`).
Иначе гость, попросивший корзину у сайта без корзины, получал 401
«представьтесь» вместо 404: система предлагала войти ради адреса, которого
у этого сайта нет.

⚠️ **Реестры помнят, чей это модуль.** Кнопки покупки, блоки и провайдеры поиска
регистрируются при загрузке приложения — когда сайт ещё неизвестен. Поэтому
регистрация несёт ключ модуля, а спрашивают о нём в момент использования.
Живой прогон поймал ровно это: адреса корзины уже отвечали 404, а каталог
всё ещё рисовал кнопку «в корзину».

## Персайтовый код

Клиент нанял разработчика, тот пишет код только для его сайта — не трогая
ни ядро, ни модули. Каталог `overrides/{ключ}/`, разбор — [overrides/README.md](../overrides/README.md).

Легаси держал такое в `/b/{login}`: наборе php-файлов, которые платформа
подключала в произвольных точках шаблона. Понять, откуда взялась строчка
на странице, можно было только поиском по всему каталогу клиента. Здесь
у переопределения один вход (`Booter::boot()`), явный контракт и
предсказуемый момент вызова — сразу после того, как подняты контекст сайта,
диск и порядок шаблонов, и до того, как роутер начал разбирать адрес.

**Никакого `eval`**: файл подключается как обычный класс.

⚠️ **Резолвер страниц стал фолбэком.** Обычный catch-all `/{path?}`
перехватывал адрес раньше всего, что объявлено позже, — и персайтовый маршрут
не работал вовсе. Фолбэк Laravel матчит последним независимо от порядка
объявления, и это ровно то, чем резолвер дерева и является: он разбирает то,
что не разобрал никто другой.

⚠️ **Маршрут переопределения привязан к своему сайту.** Таблица роутера живёт
столько же, сколько процесс: в Octane, воркере и тестах адрес, объявленный
одним арендатором, отвечал бы на домене другого. Найдено тестом «переопределение
одного сайта не трогает другой».

⚠️ **Ошибка в переопределении не роняет установку**: в продакшене уходит
в журнал, на разработке пробрасывается. Беда одного арендатора не должна класть
остальные 299, а молчаливое проглатывание — это часы поиска «почему мой код
не работает».

Живой прогон: страница `/akciya-nedeli` отвечает 200 на своём сайте, 404
на соседнем, остальные адреса не задеты.

**Админку своего сайта переопределение расширяет** тремя необязательными
интерфейсами на том же `Booter` — это замена легаси-манифестов
`/b/{login}/manifest/*.json`: `ExtendsAdminMenu` (свои пункты меню),
`DeclaresSettings` (свои экраны настроек без вёрстки), `DeclaresAdminRoutes`
(свои адреса `/admin/…` сразу под входом). Всё это видно только своему сайту:
реестр меню, реестр схем и таблица роутера общие на процесс, поэтому
персайтовое помечено владельцем и спрашивается по ключу текущего сайта.
Разбор с примером — [overrides/README.md](../overrides/README.md#админка-сайта).

Своих Vue-экранов у переопределения нет: бандл админки собирается один
на установку, и страница клиента уехала бы всем сайтам и в каждую выгрузку.

## Кабинет разработчика

Правка персайтового кода прямо из браузера: список файлов переопределения,
редактор, удаление. Экран платформы — `/sites/{id}/overrides`.

⚠️ **Только супер-администратор, и это не вопрос удобства.** Редактор пишет PHP
на сервер, то есть исполняет произвольный код установки. Отдай его владельцу
сайта — и клиент, купивший визитку, получит доступ ко всем остальным 299
сайтам.

Три защиты, и каждая закрывает свой сценарий:

| Что | Почему |
|---|---|
| путь нормализуется и сверяется с корнем | имя файла приходит из формы: без проверки `../../.env` записался бы так же охотно, как `Booter.php` |
| правятся только `.php` и `.blade.php` | иначе редактор превращается в файловый менеджер с загрузкой чего угодно |
| синтаксис проверяется ДО записи | файл с опечаткой кладёт сайт клиента целиком, а редактор — единственное место, где это видно заранее |

Синтаксис проверяется `token_get_all` с `TOKEN_PARSE`: он бросает `ParseError`
на битом коде и при этом **ничего не исполняет** — ровно то, что нужно проверке
данных из формы. Шаблоны Blade так не проверяются: `@if` для парсера PHP —
просто текст, и проверка запретила бы директивы.

Живой прогон: битый код отклонён и на диск не попал, рабочий сохранён
и заработал на сайте следующим же запросом.

## Экраны на Blade, а не на Vue

У платформы три экрана. Тащить ради них второй бандл Inertia — со своей
сборкой, своей аутентификацией и своим деплоем — дороже, чем написать три
шаблона.

## Чего ещё нет

| Что | Когда |
|---|---|
| Вход в админку сайта от имени владельца | вместе с журналом действий |
| Журнал действий супер-админа | вместе с входом от имени |
| Двухфакторная аутентификация платформы | до первого живого клиента |
