# CSP: подключение витрины (tenant contract)

Модуль `@gamecore-api/sdk/csp` строит ОБЕ политики витрины из одного
конфига: legacy (host-allowlist + 'unsafe-inline', всегда enforced) и
strict (nonce + strict-dynamic, report-only → enforce на выбранных роутах).
Живой пример подключения: репо gamecore-storefront (шаблон).

## Какой заголовок за что отвечает

| Заголовок | Источник | Режим |
|---|---|---|
| `Content-Security-Policy` (legacy) | `next.config.ts` → `buildLegacyCsp` | enforced, все роуты |
| `Content-Security-Policy-Report-Only` (strict) | edge-прокси → `buildStrictCsp` | report-only, все роуты, пока флаг off |
| `Content-Security-Policy` (strict) | edge-прокси, `CSP_STRICT_ENFORCE=on` | enforced ТОЛЬКО на `STRICT_ENFORCE_PREFIXES` |

При enforce Next ЗАМЕНЯЕТ legacy-заголовок из next.config strict-политикой
(одинаковое имя — middleware выигрывает; проверено эмпирически). На
enforce-роутах strict — ЕДИНСТВЕННАЯ политика, поэтому
`buildStrictCsp(..., {enforced:true})` обязан покрывать все non-script
директивы legacy (superset-тест в SDK). Report-only вариант всегда несёт
`report-uri` — по нему их различают в DevTools.

## Шаги подключения

1. **`src/lib/theme-bootstrap.ts`** — вынести рукописный inline-скрипт
   (theme pre-paint) в экспортируемую константу-строку. Layout рендерит
   `<script>{THEME_BOOTSTRAP_SCRIPT}</script>` — байт-в-байт тот же скрипт.
2. **`src/lib/csp-config.ts`** — `TenantCspConfig` из NEXT_PUBLIC_* env
   (см. шаблон): origins через `resolveOrigin`, sha256-хэш theme-скрипта в
   `inlineScriptHashes`, `STRICT_ENFORCE_PREFIXES`, `isStrictEnforcePath()`,
   `isStrictEnforceEnabled()` (читает `process.env.CSP_STRICT_ENFORCE`
   лениво — НЕ NEXT_PUBLIC_, иначе заинлайнится на билде и откат потребует
   пересборку).
   Хэш пересчитать: `bun -e 'import {THEME_BOOTSTRAP_SCRIPT as s} from
   "./src/lib/theme-bootstrap.ts"; const h=new Bun.CryptoHasher("sha256");
   h.update(s); console.log("sha256-"+h.digest("base64"))'`
   Хэш передаётся в `inlineScriptHashes` БЕЗ кавычек — SDK валидирует формат
   и квотирует сам.
3. **`next.config.ts`** — enforced-политика = `buildLegacyCsp(tenantCspConfig,
   {dev: isDev})` вместо рукописной строки. Сверить байт-в-байт до/после.
4. **Edge-прокси (`src/proxy.ts`)** — на каждый запрос:
   `generateNonce()` → `buildStrictCsp(cfg, nonce, {dev, reportUri:
   "/api/csp-report", enforced})`; `enforced` передавать `true` ТОЛЬКО когда
   заголовок реально уходит как enforced `Content-Security-Policy` (Next
   заменит им legacy — см. таблицу выше), иначе не передавать (report-only
   молча игнорирует `upgrade-insecure-requests`, добавлять его там незачем);
   имя заголовка по роуту (enforce → CSP, иначе Report-Only); заголовок
   ставить и на request (Next читает из него nonce и штампует свои скрипты
   на динамических роутах), и на response; плюс `x-nonce` в request.
5. **CI-чек `check:csp`** (`scripts/check-csp.mjs`, запуск bun'ом после
   build): (а) каждый STRICT_ENFORCE-префикс покрывает только ДИНАМИЧЕСКИЕ
   роуты по `.next/prerender-manifest.json` (статический роут под enforce =
   белый экран); (б) sha256 theme-скрипта == константе конфига (правка
   скрипта без обновления хэша валит CI, не прод).

## Яндекс Метрика (`analytics.yandexMetrika: true`, с 0.53.0)

Включает ПОЛНЫЙ официальный список адресов Метрики
(yandex.ru/support/metrica/code/install-counter-csp.html): семейство
`mc.yandex.<tld>` + `mc.webvisor.*` + `yastatic.net` в script/connect-src,
**wss://-варианты в connect-src** (websocket-транспорт solid.ws; без него
тег молча деградирует до https-поллинга) и `blob:` в frame-src (вебвизор).
Побочный контрактный сдвиг: **`frame-ancestors` меняется с `'none'` на
список доменов интерфейса Метрики** — иначе карта кликов/скроллинга и
вебвизор не могут отрисовать сайт внутри кабинета Метрики. Это осознанное
точечное ослабление прежнего тотального запрета: перечисленным
Yandex-origin'ам фрейминг теперь РАЗРЕШЁН, всем остальным — по-прежнему
запрещён. Без флага `frame-ancestors 'none'` как раньше.

## Правила (нарушение = инцидент)

- **Хэши только в strict.** Хэш в директиве с 'unsafe-inline' ОТКЛЮЧАЕТ
  'unsafe-inline' → падает вся гидрация Next.
- **Не читать `headers()` в корневом layout ради nonce** — все роуты станут
  динамическими, SSG умрёт. Свои inline-скрипты — только через хэш.
- **JSON-LD не требует ни nonce, ни хэша** (неисполняемый type).
- Verbatim `<script src>` третьих сторон в JSX под strict enforce обязан
  получить nonce (Next штампует сам на динамических роутах) — но лучше
  инжектить через createElement/next/script (проходит strict-dynamic).
- Все значения конфига (origins, extra, reportUri) — доверенные build-time
  константы; НИКОГДА не собирать их из request-данных.
- `opts.dev` обязан приходить из NODE_ENV/билд-флага: dev добавляет
  'unsafe-eval' — протечка dev=true в прод тихо ослабляет
  enforced-политику.
- `extra['script-src']` в strict-политике запрещён (бросает ошибку):
  strict-dynamic игнорирует хосты, а keyword-источники ослабляли бы
  политику.
- **`opts.enforced` и имя заголовка — синхронно.** Выбрал
  `Content-Security-Policy` (enforce) → обязан передать `enforced: true`,
  иначе на enforce-роуте тихо пропадает `upgrade-insecure-requests`
  (strict ЗАМЕНЯЕТ legacy, фолбэка нет). Обратный рассинхрон безопасен
  (в report-only action-директивы — no-op). Держи одну переменную
  `enforceHere` для обоих решений.

## Раскатка на тенанте

1. Подключить контракт, флаг off → deploy. Наблюдать `/api/csp-report`.
2. Неделя чистых отчётов на STRICT_ENFORCE-префиксах →
   `CSP_STRICT_ENFORCE=on` в env сервиса + restart.
3. Откат: убрать флаг + restart.

Известный trade-off enforce: браузеры без поддержки strict-dynamic/nonce
(пре-2020) на enforce-роутах теряют host-фолбэк script-src — сторонняя
аналитика (Метрика/GA/Telegram/Opora) там у них не загрузится вовсе
(для них политика строже, не слабее). Выбирай STRICT_ENFORCE_PREFIXES
с учётом этого.

Разрез отчётов за 24ч (на VPS витрины):
`journalctl -u <service> --since '24 hours ago' | grep '\[csp-report\]' |
grep -oE 'doc=\S+' | sort | uniq -c | sort -rn | head -20`
