Тема
ТЗ — Cookie-consent (cms/cookie-consent)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Баннер согласия на cookie по категориям с блокировкой скриптов до явного выбора пользователя. Совместим с page-cache — решение принимается на клиенте, не влияет на кешируемую разметку страницы.
- Категории cookie: необходимые (всегда включены), аналитика, маркетинг;
- баннер с выбором по категориям и кнопкой «принять всё»/«только необходимые»;
- хранение выбора: first-party cookie для гостя, журнал согласий для авторизованных (интеграция с
cms/consents); - блокировка скриптов категорий до согласия:
cms/analyticsиcms/pixelsподключаются только после выбора; - настройка текста и темы баннера через Filament без деплоя;
- повторный показ баннера при истечении срока хранения выбора.
Зависимости и выключение
requires: ядро · suggests: cms/consents, cms/analytics, cms/pixels, провайдер provides-контракта geo-provider (реестр §2 стандарта, например cms/geoip)
Поведение при выключении: баннер не показывается, все категории cookie считаются разрешёнными по умолчанию (аналитика/маркетинг грузятся сразу — деградация в сторону «как будто согласие дано», нужно учитывать для юрисдикций, где это недопустимо).
Геозависимость (152-ФЗ vs GDPR). Настройка cookie-consent.jurisdiction_profile (см. «Настройки») при значении auto резолвит юридический профиль баннера через provides-контракт geo-provider (канал 3, ревизия ядра 14.07.2026, п.9) — без установленной/отвечающей реализации контракта резолв невозможен, модуль обязан выбирать безопасный дефолт gdpr_strict, а не мягкий ru_152fz (см. «Крайние случаи»). Синхронный вызов на горячем пути GET /config — с таймаутом и graceful fallback, по аналогии с резолвом geo-provider в cms/session.
Стоимость внешних API: не применимо — сам cookie-consent не вызывает платных внешних сервисов; стоимость и лимиты запросов к провайдеру геолокации — зона ответственности модуля-реализации контракта geo-provider (например cms/geoip), не потребителя контракта.
Модель данных
Своих таблиц нет — использует cms_consent_records модуля cms/consents для авторизованных пользователей; для гостей — только first-party cookie на клиенте.
ПДн-паспорт (матрица v2.2). Собственных таблиц с ПДн нет: авторизованные — записи в cms_consent_records модуля cms/consents (владелец хуков «выгрузить всё по субъекту» и «забыть по запросу» — там же; cookie-consent собственных ПДн-хуков не реализует, участие в 152-ФЗ-каскадах — исключительно делегированием); гости — только first-party cookie на устройстве посетителя, вне периметра сервера и ядра. Ретеншн журнала согласий — настройка cms/consents, не этого модуля.
Входные и выходные данные
Входы:
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
POST /cookie-consent/choice | categories[] (подмножество из cookie-consent.categories), subject_id (неявно, из сессии) | FormRequest: categories — whitelist по настройке categories, necessary всегда принудительно включена независимо от переданного значения |
GET /cookie-consent/config | — (без параметров) | публичный, без входных данных, кроме RequestContext (locale) |
| Filament: настройка баннера | categories[], cookie_ttl_days, banner_theme, banner_text, jurisdiction_profile | FormRequest: banner_text — rich-text санитизация по whitelist-тегам (двойной барьер), banner_theme/jurisdiction_profile — whitelist значений |
Гео-провайдер (geo-provider, provides-контракт, канал 3) | ip → country/region | резолв реализации через DI только при jurisdiction_profile=auto; таймаут + graceful fallback на gdpr_strict; 0 реализаций контракта — тот же безопасный дефолт |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Блок «Cookie-баннер» (клиент) | categories[], banner_theme, banner_text | JSON-конфиг для рендера баннера |
cms/analytics, cms/pixels (слушатели) | categories[] из события | payload события CookieConsentGiven |
cms/consents (при включении) | categories[], subject_id | запись в журнал согласий через событие |
| First-party cookie (клиент) | выбор категорий, cookie_ttl_days | клиентский cookie, не запрос к серверу |
Настройки (группа cookie-consent)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
cookie-consent.categories | array | ["necessary","analytics","marketing"] | нет | Список категорий баннера |
cookie-consent.cookie_ttl_days | int | 180 | нет | Срок хранения выбора |
cookie-consent.banner_theme | string | light | нет | Тема оформления баннера |
cookie-consent.banner_text | string | — | нет | Текст баннера (локализуемый) |
cookie-consent.default_on_disabled | enum(allow_all/deny_optional) | allow_all | нет | Поведение при выключенном модуле: разрешить все категории или считать только необходимые разрешёнными |
cookie-consent.block_scripts_by_default | bool | true | нет | Скрипты категорий аналитика/маркетинг не инициализируются до явного согласия (контрактное требование, не только рекомендация) |
cookie-consent.jurisdiction_profile | enum(ru_152fz/gdpr_strict/auto) | ru_152fz | нет | Юридический профиль баннера: gdpr_strict — обязательный opt-in даже для аналитики, ни одна необязательная категория не разрешена неявно; auto — резолв через geo-provider по гео посетителя, при недоступности провайдера — безопасный дефолт gdpr_strict |
Лимит стандарта (§6). cookie_ttl_days — явная квота на срок действия выбора (180 дней по умолчанию); достижение — не ошибка, а штатный повторный показ баннера (см. «Крайние случаи»), не 500 и не тихое продление без ведома пользователя.
Kill-switch (§6). default_on_disabled уже является механизмом контроля риска при выключении модуля (allow_all/deny_optional) — отдельный kill-switch избыточен поверх уже штатной настройки деградации.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/cookie-consent/choice | public | Сохранить выбор категорий (для авторизованных — журнал) |
| GET | /api/v1/cookie-consent/config | public | Конфиг баннера (категории, текст, тема) |
Компоненты
Блоки (BlockRegistry): «Cookie-баннер» (рендерится глобально, не привязан к конкретной странице). Filament: настройка категорий, текста, темы и юридического профиля баннера.
Блок «Cookie-баннер» не объявляет personalized в контракте BlockRegistry (ревизия ядра 14.07.2026, п.7) — HTML одинаков для всех посетителей, персонализация (показ/скрытие, состояние чекбоксов) целиком на клиентском JS поверх cookie, без фрагмент-кеша и без острова ядра (детали — «Производительность и кеш»).
Фронтенд-бюджет: JS-модуль баннера — единственный тяжёлый ассет модуля, без карт/видео/слайдеров, поэтому lazy-load по видимости не применяется — грузится сразу (баннер обязан блокировать скрипты категорий до выбора). CLS не создаёт: баннер — плавающий блок поверх контента, не резервирует место в потоке страницы.
Демо-контент: сидер demo-настроек баннера (категории, текст, тема) — блок в галерее /_gallery и playground показывается сразу без ручного заполнения Filament.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
CookieConsentGiven | пользователь сделал выбор | categories[], subject_id (nullable) |
Слушает: —, но cms/analytics и cms/pixels слушают CookieConsentGiven перед подключением скриптов; при включённом cms/consents выбор авторизованного пользователя дополнительно пишется в его журнал согласий.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/analytics (suggests со стороны analytics) | событие CookieConsentGiven | out | скрипты аналитики инициализируются только после получения события с нужной категорией |
cms/pixels (suggests со стороны pixels) | событие CookieConsentGiven | out | пиксели маркетинга — аналогично, gate по категории marketing |
cms/consents (suggests) | событие CookieConsentGiven → запись в журнал | out | выбор авторизованного пользователя дублируется как согласие типа cookie, если модуль включён |
| Блок «Cookie-баннер» (компонент) | сервис своего модуля | in | блок получает конфиг из сервиса модуля, не запросами в БД из шаблона (§ «Компонент ↔ модуль» data-exchange) |
Гео-провайдер (geo-provider, provides-контракт) | сервис-вызов (канал 3) | in | резолв юрисдикции при jurisdiction_profile=auto: GET /config синхронно спрашивает страну/регион посетителя, таймаут + fallback на gdpr_strict |
Фоновая работа
Фоновой работы нет — выбор категорий и раздача конфига баннера синхронны в рамках HTTP-запроса.
Эксплуатация (матрица v2.2, ранбук §15 стандарта). Метрик и алертов у модуля немного (синхронный горячий путь без очередей), но есть типовые инциденты:
| Симптом | Что проверить | Команда/действие |
|---|---|---|
| Баннер не показывается на части страниц | зарегистрирован ли блок «Cookie-баннер» в теме/BlockRegistry | cms:doctor --json → секция блоков; добавить блок в шаблон/тему при отсутствии |
| Жалоба на несоответствие юрисдикции (баннер РФ-мягкий там, где нужен GDPR-strict) | текущий jurisdiction_profile, доступность geo-provider (см. «Зависимости») | при auto — health-чек установленного geo-provider; при 0 реализаций/таймауте — временно выставить jurisdiction_profile=gdpr_strict вручную |
После смены состава categories скрипты категории не грузятся | инвалидирован ли кеш cookie-consent:config после сохранения настроек | пересохранить настройки баннера в Filament (триггерит afterSave()) либо принудительно сбросить тег кеша cookie-consent |
Восстановительных команд (recount/reindex/repair) нет — состояние модуля полностью выводится из настроек и кеша, пересчитывать нечего. Бэкап: в бэкап попадают только настройки группы cookie-consent (часть общего бэкапа настроек ядра); после рестора отдельных действий не требуется — кеш cookie-consent:config самовосстанавливается на первый запрос.
Производительность и кеш
- Ожидаемый объём:
GET /config— на каждый первый визит без активного cookie, до тысяч запросов в сутки на посещаемом сайте;POST /choice— реже (одно решение на визит с истёкшим TTL); - горячий путь
GET /config— бюджет 0 запросов к БД: конфиг баннера читается из кеша группыcookie-consent:config, не из настроек напрямую; POST /choiceдля авторизованного — ≤1 запрос (вставка в журналcms/consents, если включён; для гостя — 0 запросов, только установка cookie);- кешируется:
cookie-consent:config— инвалидируется при сохранении настроек баннера (afterSave()в Filament); - баннер не влияет на общий page-cache — решение принимается на клиенте (JS читает cookie и решает, показывать баннер или нет), HTML страницы одинаков для всех посетителей независимо от их выбора; сегментация page-cache по факту согласия не требуется и намеренно не делается (иначе схлопывается выгода от общего кеша);
- связь с механизмом ядра (ревизия ядра 14.07.2026, п.7): блок мог бы объявить себя
personalizedв контракте BlockRegistry — тогда страница выпадала бы из общего page-cache целиком, кешировался бы только каркас, а сам баннер рендерился отдельно островом/фрагмент-кешем ядра. Cookie-баннер сознательно этого не делает: разметка не зависит от посетителя, поэтомуpersonalizedне объявляется, а решение целиком выносится на клиентский JS поверх cookie — так достигается нулевая стоимость для page-cache вместо покупки корректности ценой фрагмент-кеша/острова.
Безопасность
Необходимые cookie всегда разрешены, чекбокс недоступен для снятия — проверяется на FormRequest-уровне при сохранении выбора. Текст/тема баннера санитизируются при сохранении (rich-text через whitelist-теги, двойной барьер). Векторы, специфичные для модуля:
- подмена категорий в
POST /choice— whitelist поcookie-consent.categories, неизвестная категория в payload отклоняется (422), не игнорируется молча;necessaryпринудительноtrueнезависимо от переданного значения; - скрипты аналитики/маркетинга, загруженные до согласия — это основной риск соответствия (GDPR/152-ФЗ):
block_scripts_by_default=true— контрактное требование, проверяется тестом, а не только описанием;cms/analytics/cms/pixelsобязаны инициализироваться исключительно по событию, не по факту загрузки страницы; - XSS через
banner_text— санитизация rich-text на входе и выходе (двойной барьер),{!! !!}только для санитизированного поля.
Матрица ролей:
| Permission | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
cookie-consent.view (просмотр настроек) | ✅ | ✅ | ✅ | ✅ |
cookie-consent.manage (категории, текст, тема, юрисдикция баннера) | ✅ | ✅ | — | ✅ |
Текст и состав категорий баннера — юридически значимый контент (последствия некорректной формулировки — риск для комплаенса сайта), поэтому manage не выдаётся редактору по умолчанию — только менеджеру/админу, в отличие от типового UI-текста блока.
Доказуемость согласия — задокументированное ограничение (матрица v2.2). Для авторизованных пользователей факт согласия проверяем: запись в cms_consent_records модуля cms/consents с версией показанного текста. Для гостей серверного журнала нет — единственное доказательство существует в first-party cookie на устройстве посетителя и может быть удалено пользователем/браузером в любой момент. Это осознанный компромисс модели «без своих ПДн-таблиц» (см. «Модель данных»), не баг; сайтам, где юридически требуется доказуемость согласия анонимных посетителей, следует включать cms/consents и авторизовывать посетителя до выбора категорий (вне зоны ответственности cookie-consent).
UX-требования
Админ:
- предпросмотр баннера прямо в форме настроек (тема + текст) — не нужно уходить на сайт, чтобы проверить внешний вид;
- ошибка «текст баннера не заполнен для локали X» — понятная подсказка при мультиязычном сайте, а не пустой баннер на проде;
- подтверждение при смене состава
categories(удаление категории, на которую уже завязаныcms/analytics/cms/pixels) — предупреждение о возможной поломке интеграции; - индикатор в форме настроек, каким фактически профилем сейчас руководствуется баннер при
jurisdiction_profile=auto(резолвлен черезgeo-provider/ фактический дефолтgdpr_strictиз-за недоступности провайдера) — администратор не должен гадать.
Посетитель:
- баннер не блокирует чтение контента страницы (не модальное перекрытие всего экрана без возможности закрыть/выбрать «только необходимые»);
- кнопка «только необходимые» так же заметна, как «принять всё» — не тёмный паттерн (мелкая ссылка против крупной кнопки);
- игнорирование баннера пользователем: если пользователь закрывает баннер крестиком или уходит без выбора — трактуется как «только необходимые» (запрет по умолчанию, не разрешение), баннер показывается повторно при следующем визите до явного выбора;
- изменить выбор позже (ссылка «настройки cookie» в футере) доступно без повторной установки баннера с нуля.
Крайние случаи и типовые баги
- скрипты аналитики грузятся до согласия → контрактный тест: чистая загрузка страницы без cookie не инициирует ни один скрипт категорий кроме
necessary;cms/analytics/cms/pixelsобязаны gate-ить инициализацию событием, а не «подключать и потом отключать» (последнее — утечка данных до отзыва); - баннер и page-cache → HTML не сегментируется по выбору — решение полностью на клиенте; контрактный тест: два запроса с разными cookie-выборами получают побайтово одинаковый HTML от page-cache;
- пользователь игнорирует баннер → трактуется как отказ от необязательных категорий (не согласие по умолчанию), баннер показывается повторно, пока нет явного решения — иначе «отсутствие ответа = да» нарушает принцип явного согласия;
- истечение
cookie_ttl_days→ повторный визит без активного cookie показывает баннер снова, даже если пользователь ранее делал выбор — обновление согласия периодическое, не разовое; - выключение модуля посреди активных подписок на аналитику →
default_on_disabledопределяет исход:allow_all(по умолчанию) — скрипты грузятся без баннера,deny_optional— только необходимые; ⚠️ Противоречие: текущее описание («все категории считаются разрешёнными») не согласовано с юрисдикциями, где это недопустимо (см. исходный текст раздела «Зависимости»). Разрешение: параметрdefault_on_disabledделает это явной настройкой сайта, а не хардкодом одного поведения — дефолтallow_allдокументируется как риск, требующий сознательного выбора при настройке сайта под строгую юрисдикцию; - отсутствие
cms/consents(suggests не установлен) → выбор авторизованного пользователя не попадает в журнал согласий, событиеCookieConsentGivenиздаётся как обычно (слушателя просто нет) — деградация, не ошибка; - отсутствие
cms/analytics/cms/pixels→ событиеCookieConsentGivenиздаётся без слушателей, баннер иPOST /choiceработают штатно — модуль cookie-consent не зависит от их наличия; - параллельные вкладки, выбор в одной, баннер открыт в другой → выбор в первой вкладке (cookie установлен) должен быть подхвачен второй при следующем действии (не обязательно live-sync между вкладками, но повторный визит/навигация читает актуальный cookie, не «залипший» старый выбор в памяти вкладки);
- пустая конфигурация категорий (
categories = []по ошибке администратора) → Filament обязан требовать минимум категориюnecessary, пустой список — ошибка валидации настроек, не пустой баннер на проде; - измерение locale отсутствует/site отсутствует →
banner_textлокализуется черезlang/-механизм ядра при мультиязычии, при одноязычном сайте — один текст без ветвления кода (nullable-измерение, не отдельная реализация); - добавлена новая категория cookie в уже работающий баннер → пользователи с ранее сохранённым выбором (сделанным до появления новой категории) не имеют решения по ней — новая категория трактуется как неопрошенная (запрет по умолчанию, баннер показывается повторно с выделенной новой категорией), а не как автоматически разрешённая наравне со старым выбором;
jurisdiction_profile=auto, посетитель из юрисдикции GDPR,geo-providerнедоступен/не установлен → безопасный дефолтgdpr_strict(строгий opt-in), никогда не наоборот — недоступность геоданных не повод ослаблять защиту посетителя;jurisdiction_profile=gdpr_strict→ в отличие от мягкого российского дефолта ни одна необязательная категория не считается разрешённой неявно ни при каких условиях, включаяdefault_on_disabled=allow_allпри выключенном модуле — это конфликт настроек,gdpr_strictимеет приоритет: лучше повторно показать баннер, чем нарушить обязательный opt-in;- конкурентное редактирование настроек баннера двумя админами → единственная редактируемая сущность — запись настроек группы
cookie-consent, правится стандартнымlock_versionядра как любая настройка (§4 стандарта); отдельного модульного сценария нет; - виджет баннера отрисован, но JS не выполнился (блокировщик скриптов, медленная сеть) → отсутствие явного выбора эквивалентно «пользователь ещё не ответил»: скрипты аналитики/маркетинга не грузятся при отсутствии подтверждённого cookie-выбора — деградация в сторону строгости, не в сторону «раз баннер не показался, значит можно грузить».
Донорский код
Донор: — (новая разработка).
Легаси-импорт (§16 стандарта): не применимо. У модуля нет собственных данных для переноса — на стороне сервера ничего не хранится (client-side cookie для гостей, делегирование в cms/consents для авторизованных), поэтому мигрировать с донорского сайта нечего; выбор посетителей старого сайта не переносится, баннер показывается заново после перехода на новую платформу — ожидаемое поведение, не пробел.
Тесты и приёмка
- [ ] Контрактный тест: скрипты категорий аналитика/маркетинг не грузятся до
CookieConsentGivenс этой категорией; - [ ] Баннер не влияет на page-cache — HTML одинаковый для всех, решение только на клиенте;
- [ ] Повторный визит без активного выбора (истёк
cookie_ttl_days) показывает баннер снова; - [ ] Выбор авторизованного пользователя попадает в журнал
cms/consents, если модуль включён; - [ ] Необходимые cookie всегда разрешены, чекбокс недоступен для снятия;
- [ ] Настройки текста/темы санитизируются при сохранении (rich-text через whitelist-теги);
- [ ] Игнорирование баннера (закрытие без выбора) трактуется как отказ от необязательных категорий;
- [ ]
POST /choiceс неизвестной категорией в payload отклоняется 422; - [ ] Filament отклоняет сохранение настроек с пустым списком категорий;
- [ ] Деградация при выключении соответствует
default_on_disabled(обе ветки протестированы); - [ ] Блок «Cookie-баннер» не объявляет
personalizedв BlockRegistry — HTML баннера побайтово одинаков независимо от cookie-выбора посетителя (ревизия ядра 14.07.2026, п.7); - [ ]
jurisdiction_profile=auto+ доступныйgeo-provider: посетитель из ЕС получает строгий opt-in (gdpr_strict), посетитель из РФ — мягкий дефолт (ru_152fz); - [ ]
jurisdiction_profile=auto+geo-providerнедоступен/не установлен: дефолтgdpr_strict, не мягкий профиль; - [ ] Контрактный набор
cms-testingзелёный, testbench-изоляция пакета, feature-тест на каждый роут; - [ ] Тестовая БД только
cookie-consent_test;migrate:fresh/refresh/reset,db:wipeзапрещены.