# Мультиарендность

Один код, одна база, сотни сайтов. Этот узел отвечает на два вопроса: какой
сайт обслуживает текущий запрос и как гарантировать, что он не увидит чужие
данные.

## Модель данных

```
sites          id, login, name, status, theme, override_key, locale, timezone,
               primary_domain_id
site_domains   id, site_id, host UNIQUE, kind, force_https, www,
               redirect_to_primary, is_active
```

`login` — ключ арендатора: имя каталога данных `/a/{login}` и основа
девелоперского домена. Валидируется как имя поддомена и как имя каталога
одновременно: латиница, цифры, дефис, 3–48 символов, без дефиса по краям.

`override_key` отвязан от `login` намеренно: логин может смениться, а каталог
персайтовых переопределений при этом переезжать не должен.

### Типы доменов

| `kind` | Роль |
|---|---|
| `primary` | боевой адрес, канонический для поисковых систем |
| `alias` | зеркало, по умолчанию 301 на основной домен |
| `dev` | `{login}.kzla.ru` — девелоперский адрес |

**Девелоперский домен не редиректит на боевой ни при каких настройках.** Иначе
разработчик теряет доступ к сайту сразу после публикации. Он всегда отдаётся
с `X-Robots-Tag: noindex, nofollow`.

### Статусы сайта

| Статус | Поведение |
|---|---|
| `draft` | обслуживается, но всегда `noindex` |
| `active` | обслуживается и индексируется (на основном домене) |
| `suspended` | 503 с заглушкой |
| `archived` | 404, как несуществующий |

## Резолв хоста

`SiteResolver::resolve(string $host): ?ResolvedSite`

1. Хост нормализуется `HostNormalizer`: lowercase, без схемы, без пути, без
   порта, без завершающей точки, IDN → punycode. **Одна и та же функция**
   применяется при записи домена и при резолве запроса — иначе уникальный
   индекс не спасёт.
2. Чтение из кэша `kz:host:{host}`, TTL час.
3. Промах — один запрос: `site_domains` JOIN `sites` LEFT JOIN канонический
   домен сайта.
4. Неизвестный хост → `null` → платформенная 404. **Никогда контент другого
   сайта.**

Хост платформы (`KZ_PLATFORM_HOST`) в резолвер не попадает вовсе: сайта у него
нет. Он отвечает только маршрутами, объявленными для него, и адресами, которым
сайт не нужен (`/health`, `/up`); `robots.txt` там запрещает всё, остальное —
404. Разбор — [platform.md](platform.md#отдельный-домен).

Результат — плоский `ResolvedSite`, целиком укладывающийся в один ключ кэша:
идентификатор и логин сайта, статус, тема, локаль, таймзона, ключ
переопределений, тип домена, политика www и целевой домен канонизации.

### Режим single

`config('korzilla.mode') === 'single'` — автономная копия одного сайта.
Резолвер отдаёт единственный сайт на любой хост и в `site_domains` не ходит
вовсе. Так мультисайтовые ветки кода убираются **конфигом, а не правкой
исходников**: платформенные модули в выгруженной копии просто отсутствуют.

## Конвейер middleware

Стек глобальный, а не в группе `web` — иначе он не отработает на несовпавшем
маршруте, и 404 останется без темы сайта и без редиректа на основной домен.

```
ResolveSite → EnforceCanonicalHost → BootSiteContext → (группа web: сессия, CSRF…)
```

### ResolveSite

Определяет сайт, кладёт в `SiteContext`. Неизвестный хост и архивный сайт —
платформенная 404; приостановленный — 503.

### EnforceCanonicalHost

Канонизация **ровно одним 301**. Схема, политика `www` и переход зеркала на
основной домен считаются вместе:

```
http://www.staroe.test/page.html  →  https://alpha.test/page.html
```

Цепочки `http → https → www → primary` недопустимы: каждый шаг стоит RTT
и штрафуется поисковыми системами. Путь и query сохраняются.

### BootSiteContext

Донастраивает приложение под сайт:

- локаль, таймзона, `app.url`, `URL::forceRootUrl()`;
- **домен куки сессии** — иначе сессия одного арендатора будет видна другому;
- файловый диск `site` с корнем `storage/app/sites/{login}` и публичным
  префиксом `/a/{login}`;
- приоритет поиска шаблонов:
  `overrides/{key}/views` → `themes/{theme}` → views модулей → база;
- `X-Robots-Tag: noindex, nofollow` для всего, что не является активным
  основным доменом.

## SiteContext

Граница арендатора. Любой код, которому нужен `site_id`, берёт его отсюда.

```php
$context->id();                  // site_id
$context->login();               // ключ арендатора
$context->theme();
$context->storagePath('img');    // абсолютный путь в каталоге сайта
$context->publicMediaPrefix();   // /a/{login}
$context->site();                // ленивая загрузка модели Site
```

Зарегистрирован как **`scoped`**, а не `singleton`, и сбрасывается по событию
`JobProcessing` вместе с `forgetScopedInstances()`. Долгоживущий воркер
очередей — классический вектор утечки между арендаторами; джоба обязана сама
поднять контекст из своего `site_id`.

Обращение к `resolved()` без установленного контекста бросает исключение
с внятным текстом, а не отдаёт `null`, который потом всплывёт как `site_id = 0`.

## Изоляция данных

Три уровня защиты:

1. **Резолв хоста** — неизвестный домен не получает ничей контент.
2. **Запросы стартуют с `site_id`** — все составные индексы ведут с него,
   компоненты обязаны фильтровать по нему в `resolveBySlug`.
3. **Тест инварианта** — ни одна пользовательская таблица не может появиться
   без `site_id`.

> В легаси резолв объекта по слагу шёл **без** фильтра по `Catalogue_ID`,
> а нужная строка выбиралась сортировкой в PHP. Это межарендаторная утечка
> by design, прикрытая только порядком сортировки.

Тесты фиксируют это явно: одинаковый слаг и одинаковый путь раздела на двух
сайтах резолвятся каждый в свой объект.

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

`CreateSite` — единственная точка. Сайт всегда рождается сразу с:

- девелоperским доменом `{login}.kzla.ru`;
- каталогом данных `storage/app/sites/{login}/{img,doc,imp,cache}`;
- главной страницей (раздел с пустым слагом и путём `/`);
- стартовой раскладкой: зоны шапки, контента и подвала.

Состояния «сайт есть, а открыть его негде» не существует.

## Кэш и инвалидация

| Что | Ключ | TTL | Сбрасывается |
|---|---|---|---|
| хост → сайт | `kz:host:{host}` | 1 ч | сохранение или удаление домена |

Ключ `kz:host:*` глобальный, а не персайтовый, поэтому счётчики версий к нему
неприменимы — он забывается точечно наблюдателями `SiteDomainObserver`
и `SiteObserver`. Наблюдатель домена забывает и **старый** хост тоже: иначе
переименованный домен продолжал бы резолвиться ещё час.

## Локальная проверка

В `hosts`:

```
127.0.0.1  alpha.kzla.test  beta.kzla.test  alpha.ru
```

```bash
php artisan serve --host=127.0.0.1 --port=8123
curl -H "Host: alpha.ru" http://127.0.0.1:8123/
```

Заголовок `Host` позволяет проверить резолв без правки `hosts`.
