Тема
ТЗ — Скрипты в head/body (cms/head-scripts)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Единственный модуль-«отдушина» для инъекции произвольного доверенного кода клиента в <head> и перед </body> страницы: счётчики верификации (Яндекс.Вебмастер, Google Search Console — метатег-подтверждение), онлайн-чаты, кастомные скрипты/стили, любые сторонние вставки, для которых в CMS нет специализированного модуля. Границу назначения задаёт сверка настроек сайта: реквизиты/контакты/соцсети — группа настроек ядра и виджеты (не этот модуль); пиксели и аналитика (VK Pixel, Top.Mail.Ru, GTM, Я.Метрика, GA4) — свои модули cms/pixels, cms/integration-google, cms/integration-yandex с собственной consent-логикой и CSP-регистрацией доменов. cms/head-scripts — то, что остаётся «бесхозным»: любой прочий сторонний тег, вставляемый вручную студией.
- Именованные снипеты кода с позицией вставки:
head(внутри<head>) илиbody_end(перед</body>). - Условия показа: конкретная страница/раздел (
page_path), город (приcms/multicity), локаль — снипет может быть глобальным или узко нацеленным. - Включение/отключение снипета без удаления, ручной порядок вывода внутри позиции.
- Вставка происходит через швы ядра, не собственным механизмом:
layout.head/layout.body_end(FilterBus) для точки вставки,security.cspдля nonce инлайн-кода — модуль не имеет права писать в разметку layout в обход этих швов. - Редактирование кода — только под ролью studio: код полностью доверенный, ядро его не санитизирует (в отличие от rich-text контента), поэтому это тот же класс риска, что и
{!! !!}в контенте (§11 стандарта). - Аварийный
kill_switch, отключающий все вставки без выключения модуля.
Зависимости и выключение
requires: ядро (cms/core-contracts: швы layout.head/layout.body_end FilterBus, security.csp для nonce — см. «Крайние случаи», п. 10 про текущий статус этих швов в каноническом реестре) · suggests: cms/multicity (условие показа по городу из RequestContext) · suggests: cms/audit (журнал CRUD-правок доверенного кода — критично именно для этого модуля больше, чем для любого другого: изменение снипета — изменение исполняемого на каждой странице JS).
Без cms/multicity измерение city_id — nullable-поле (правило измерений §4 стандарта): снипет с city_id = null показывается во всех городах, деградация без ветвления кода.
Поведение при выключении модуля: ни один снипет не вставляется ни в head, ни перед </body>, публичный сайт рендерится штатно без сторонних вставок; данные снипетов не удаляются — повторное включение восстанавливает прежнее поведение без переввода кода.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_head_scripts_snippets | id, title, position enum(head|body_end), code (longText), is_active, sort_order, page_path (nullable), city_id (nullable), locale (nullable), nonce_required (bool), lock_version, external_id (nullable) | доверенный снипет с условиями показа |
code — longText, доверенный HTML/JS/CSS без санитизации (только studio-роль пишет); position — PHP Enum (Head, BodyEnd); составной индекс (position, is_active, sort_order) под горячую выборку активных снипетов позиции; city_id — constrained()->nullOnDelete()->index() (nullable — правило измерений §4); page_path — строка с точным путём или префиксом раздела (whitelist-формат условия, не произвольный regex — ReDoS-риска нет намеренно, см. «Крайние случаи»); nonce_required — снипет сам содержит <script> и должен получить nonce ядра при вставке; lock_version — optimistic lock (§4 стандарта) на конкурентное редактирование; external_id — уникален, только для legacy-импорта (§16 стандарта).
ПДн-паспорт: собственные таблицы модуля ПДн не хранят (title, code, условия показа — не персональные данные). Явное предупреждение: содержимое code может собирать ПДн посетителей (сторонний трекер, чат с формой контактов) — это ответственность кода, вставленного студией, а не модуля; журналирование правок самого code (кто/когда изменил) — задача cms/audit (suggests), не собственная таблица модуля. Декларация: «ПДн в своих таблицах не храню, код в снипетах может — вне периметра модуля».
Входные и выходные данные
Вход:
| Источник | Поля | Валидация |
|---|---|---|
| Admin CRUD снипета (Filament, только studio) | title, position, code, is_active, sort_order, page_path, city_id, locale, nonce_required, lock_version | FormRequest-whitelist; position — enum; code — лимит head-scripts.max_snippet_size_kb, не проходит rich-text санитайзер (доверенный ввод studio, §11 стандарта); lock_version сверяется — иначе 409 |
Рендер layout.head/layout.body_end (FilterBus, внутренний вызов) | RequestContext (page_path текущего запроса, city/locale) | внутренний сервис-вызов HeadScriptsService, не форма — фильтр не читает БД напрямую, только кеш группы |
Legacy-импорт (cms:head-scripts:import-legacy) | строки донорской таблицы: позиция, код, условие (страница/раздел/город) | маппинг профиля источника; идемпотентность по external_id; --dry-run без записи |
Выход:
| Потребитель | Данные | Формат |
|---|---|---|
layout.head (FilterBus) | HTML активных снипетов позиции head с учётом условий и nonce | добавка к разметке <head>, порядок по sort_order/id |
layout.body_end (FilterBus) | HTML активных снипетов позиции body_end | добавка перед </body> |
| Filament admin | список снипетов, превью в песочнице | keyset-JSON через API |
| Legacy-импорт | отчёт прогона | --json: создано/обновлено/пропущено/ошибки построчно |
Всё, что не перечислено как вход, модуль отвергает (whitelist-принцип §11 стандарта) — в частности, позиция и условие показа принимают только объявленные значения, произвольный page_path-regex не поддерживается намеренно (см. «Крайние случаи»).
Настройки (группа head-scripts)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
head-scripts.enabled | bool | true | да | Общее включение вставки снипетов |
head-scripts.admin_zone_kill_switch | bool | true | нет | Не вставлять снипеты в admin-зоне (/admin/*) — риск инъекции в саму админку |
head-scripts.max_snippet_size_kb | int | 64 | нет | Максимальный размер одного снипета (code) |
head-scripts.kill_switch | bool | false | да | Аварийное отключение всех вставок без выключения модуля |
Лимиты и квоты: единственный количественный лимит — размер снипета (max_snippet_size_kb); внешних платных API нет, квот на число снипетов на сайт нет (ограничение — только человеческий здравый смысл и ревью studio).
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET/POST/PUT/DELETE | /api/v1/admin/head-scripts/snippets… | studio (head-scripts.manage) | CRUD снипетов |
| GET | /api/v1/admin/head-scripts/snippets | admin (head-scripts.view) | Только просмотр списка, без права правки |
Публичного API нет — снипеты не отдаются наружу как ресурс, вывод только инлайн в разметку страницы через layout.head/layout.body_end. Мутации принимают lock_version — рассинхрон отдаёт 409 (конвенции ядра §7 стандарта).
Компоненты
Filament: ресурс снипетов (позиция, условия показа, код в редакторе с подсветкой синтаксиса, превью в изолированном <iframe sandbox> — снипет не выполняется в контексте самой админки при просмотре), afterSave() → инвалидация тега head-scripts; при сохранении с <script в коде и nonce_required = false — предупреждение «похоже на скрипт, включить nonce_required?»; авторизация — только роль studio на запись, admin — только чтение. Демо-контент: HeadScriptsDemoSeeder создаёт один демо-снипет позиции head (безопасный HTML-комментарий, is_active = false) — галерея/playground показывают структуру ресурса без риска исполнения чужого кода в demo-окружении. Команды: cms:head-scripts:import-legacy --source=<профиль> --json.
Фронтенд-бюджет: модуль не переписывает и не добавляет атрибуты async/defer к <script> внутри code — код полностью доверенный, автор (studio) сам управляет собственным тегом; ответственность за отсутствие CLS/блокировки рендера — на авторе снипета, Filament лишь предупреждает эвристикой (см. выше). Собственный HTML модуля (обёртка позиции) — пустой, вставляет ровно содержимое code, без лишней разметки.
События и обмен
Модуль не издаёт доменных событий: снипет — статическая конфигурация вставки, не факт бизнес-процесса. Слушает: RequestContext (city_id, locale, текущий page_path) — сервис-вызов cms/core-contracts. Provides-контрактов не реализует.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
layout.head (FilterBus) | 2 — FilterBus | head-scripts → ядро | модуль регистрирует обработчик, добавляющий HTML активных снипетов позиции head в разметку |
layout.body_end (FilterBus) | 2 — FilterBus | head-scripts → ядро | то же для позиции body_end |
security.csp (FilterBus) | 2 — FilterBus | head-scripts → ядро | снипет с nonce_required = true получает nonce ядра на вставляемый <script>; источники внешних доменов, к которым обращается код снипета, регистрируются студией через тот же фильтр отдельно (модуль их не выводит автоматически из code) |
RequestContext (city/locale/page_path) | 3 — сервис-вызов cms/core-contracts | ядро → head-scripts | выборка активных снипетов учитывает текущий контекст запроса |
cms/multicity (suggests) | ↔ | head-scripts ↔ multicity | включён — условие по city_id активно; не установлен — измерение nullable, показ всем городам |
cms/audit (suggests) | 3 — сервис-вызов | head-scripts → audit | CRUD доверенного кода логируется с diff «было/стало» — повышенный риск требует полной прослеживаемости правок |
Настройка head-scripts.kill_switch | settings-store | head-scripts → рендер | включена — ни один снипет не вставляется независимо от is_active каждого |
Фоновая работа
Джобов и расписания нет: выборка активных снипетов синхронна на каждом рендере страницы, из кеша группы (см. «Производительность»). Legacy-импорт — разовая CLI-команда, не очередь (объём донорских данных мал, десятки-сотни строк).
Производительность и кеш
Ожидаемые объёмы: единицы-десятки снипетов на сайт. Горячий путь — вызов layout.head/layout.body_end на каждом рендере страницы: бюджет — 1 закешированный запрос на позицию (не на снипет), фильтрация по условиям (page_path/city_id/locale) выполняется в PHP над уже выбранным закешированным набором, без дополнительных запросов к БД. Ключ кеша группы настроек включает измерения RequestContext; поскольку page_path — часть условия показа, набор активных снипетов для страницы участвует в ключе page-cache самой страницы (см. ниже).
Критичный индекс: составной (position, is_active, sort_order) под выборку активных снипетов позиции.
Тег кеша head-scripts объявляется через CacheTags (§10 стандарта); инвалидируется при создании/правке/удалении снипета и при изменении настроек группы (enabled, kill_switch, admin_zone_kill_switch). Настройки и список снипетов помечены affectsPageCache: да — правка снипета сбрасывает page-cache страниц, где он мог показываться (по условиям показа, а не весь кеш сайта целиком).
Безопасность
Центральное решение модуля: code — доверенный HTML/JS/CSS, который ядро не санитизирует (в отличие от rich-text блоков контента, где двойной барьер санитайзера обязателен). Поэтому редактирование снипетов доступно только роли studio — тот же гейт, что стандарт требует для {!! !!} в контенте (§11). Компрометация studio-аккаунта или ошибка ревью здесь эквивалентны произвольному JS на каждой странице сайта (кража сессии/cookie, фишинг форм, майнинг в браузере посетителя) — поэтому обязательны 2FA для ролей studio (см. cms-v2/security.md) и запись каждой правки в cms/audit при его наличии.
Вставка идёт исключительно через швы ядра layout.head/layout.body_end (точка вставки) и security.csp (nonce инлайн-кода) — модуль не пишет в шаблон layout напрямую и не управляет собственным nonce. В режиме CSP enforce снипет с nonce_required = true и некорректным/отсутствующим nonce не исполнится браузером — это ожидаемая деградация (защита от инъекции через частично скомпрометированный флоу), не баг модуля; в head при admin_zone_kill_switch = true снипеты не вставляются на /admin/* — исключение на уровне middleware ядра, не на уровне разметки конкретного admin-вида (нельзя забыть в новом виде).
Права: head-scripts.view, head-scripts.manage.
Матрица ролей:
| Действие | Администратор | Менеджер | Редактор | Studio |
|---|---|---|---|---|
Просмотр списка снипетов (view) | ✅ | — | — | ✅ |
Просмотр содержимого code конкретного снипета | ✅ | — | — | ✅ |
Создание/правка/удаление снипета (manage) | — | — | — | ✅ |
head-scripts.kill_switch (аварийное отключение) | — | — | — | ✅ |
Менеджер и редактор не имеют доступа вовсе: доверенный произвольный код — не контентная задача, права на него не расширяются «по умолчанию для админов сайта».
UX-требования
Админ:
- Пустой список снипетов — подсказка «Скриптов не добавлено» с кнопкой «Добавить снипет» и явным пояснением риска перед первым созданием.
- Предупреждение при сохранении снипета: «Код исполняется на каждой странице сайта без проверки — убедитесь в происхождении и безопасности источника» (первое подтверждение при создании, повторно — при значимом изменении
code). - Превью снипета — в изолированном
<iframe sandbox>, снипет не выполняется в контексте реальной страницы админки при простом просмотре. - Ошибка превышения
max_snippet_size_kb— конкретный текст с фактическим и допустимым размером, не generic «Error». - Конфликт редактирования (
lock_version) — «Снипет изменён другим пользователем, обновите страницу», не молчаливая перезапись. - Подсказка при обнаружении
<scriptбезnonce_required = true: «похоже на скрипт — включить nonce?».
Посетитель:
- Снипет не блокирует рендер сервером: вставка — чистая конкатенация HTML на этапе рендера layout, без синхронных внешних вызовов на сервере.
- Клиентское поведение (async/defer, CLS) — на совести автора снипета; модуль ничего не переписывает в теге, чтобы не сломать порядок инициализации стороннего кода.
Крайние случаи и типовые баги
- Снипет ломает вёрстку или блокируется CSP на клиенте → серверный рендер страницы не зависит от исполнения снипета в браузере: сайт остаётся доступным, сбой виден только в консоли разработчика у посетителя (изоляция клиент/сервер).
- Превышение
max_snippet_size_kbпри сохранении → человеческая ошибка с конкретными цифрами, не молчаливое обрезание кода. - Снипет обращается к внешнему домену при активном CSP
enforce→ браузер блокирует запрос домена, не зарегистрированного черезsecurity.csp; Filament подсказывает «добавьте домен через настройки CSP, иначе внешние вызовы снипета будут заблокированы» (сам домен модуль автоматически изcodeне парсит и не регистрирует). head-scripts.kill_switchвключён → ни один снипет не вставляется независимо от собственногоis_active, без выключения модуля и без удаления данных.- Конкурентное редактирование снипета двумя studio-пользователями →
lock_version, конфликт — 409, не «последний победил» молча. - Условие показа по городу без
cms/multicity→city_idnullable, снипет с пустым городом показывается везде (контрактный тест гоняется в режимах «с модулем» и «без»). page_pathне совпадает ни с одной реальной страницей (опечатка) → снипет просто не показывается нигде, не ошибка сохранения; это человеческая ошибка конфигурации, а не баг модуля.- Несколько снипетов одной позиции с одинаковым
sort_order→ детерминированный вторичный порядок поid, чтобы порядок вывода был воспроизводим между запросами при одинаковом кеше. - Выключение модуля с активными снипетами → снипеты не вставляются, данные и условия показа сохраняются, повторное включение восстанавливает прежнее поведение без повторного ввода кода.
- ⚠️ Противоречие: канонический реестр фильтров FilterBus (
phase0-three-axes.md) на момент написания ТЗ перечисляет толькоcontent.render, menu.items, seo.meta, block.render.{type}, sitemap.urls, mail.recipients, security.csp— швовlayout.headиlayout.body_end, на которые опирается этот модуль, в реестре нет. Разрешение: ревизия ядра, вводящая эти два фильтра в канонический реестр (по образцу ревизии №2 provides-контрактов) — обязательное условие перед стартом разработки модуля; пункт дублируется в открытых вопросах. - Легаси-снипет ссылается на домен, не зарегистрированный в CSP целевого сайта →
--dry-runлегаси-импорта не блокирует перенос, но помечает строку предупреждением в отчёте — домен нужно зарегистрировать вручную после переноса. - Снипет с
nonce_required = false, но содержащий<script>→ в режиме CSPenforceбраузер блокирует тег без nonce; это ожидаемое поведение конфигурации, а не баг — Filament заранее предупреждает при сохранении (см. «UX-требования»), но не запрещает сохранение (учебные/статические примеры без исполняемого кода — валидный случай).
Донорский код
Донор: — (новая разработка; функциональность типовая для многих CMS, но конкретный исходный код для переиспользования на площадках студии не выявлен).
Legacy-импорт: cms:head-scripts:import-legacy --source=<профиль> — маппинг старых таблиц «произвольный код в head/footer» донорских проектов (позиция/код/условие страницы/города) на cms_head_scripts_snippets; идемпотентен по external_id (повторный прогон обновляет, не дублирует); --dry-run выводит отчёт расхождений и предупреждений (см. «Крайние случаи», п. 11) без записи. Прогон на копии боевых данных донора — часть приёмки модуля, если у клиента есть донор с боевыми данными.
Тесты и приёмка
- [ ] Контрактный тест: снипет позиции
head/body_endпоявляется в соответствующей точке рендера страницы с учётом условий показа (страница/город/локаль) - [ ] При выключении модуля или
head-scripts.kill_switch = trueни один снипет не вставляется, сайт рендерится без 500 - [ ] Снипет с
nonce_required = trueполучает валидный nonce ядра черезsecurity.csp; без корректного nonce в режимеenforceскрипт не исполняется браузером - [ ]
admin_zone_kill_switchисключает вставку снипетов на/admin/* - [ ] Только роль studio может создавать/редактировать/удалять снипеты; admin — только просмотр списка; матрица ролей покрыта тестом
- [ ] Права
head-scripts.view/head-scripts.manageразграничены корректно - [ ] Конкурентное редактирование снипета двумя studio-пользователями → 409 по
lock_version - [ ] Превышение
head-scripts.max_snippet_size_kb— человеческая ошибка, не 500 и не молчаливое обрезание - [ ] Условие показа по городу работает и при отсутствии
cms/multicity(оба режима) - [ ] Инвалидация page-cache по тегу
head-scriptsпри CRUD снипета и при изменении настроек группы (enabled,kill_switch,admin_zone_kill_switch) - [ ] Нет N+1 при выборке активных снипетов позиции (1 закешированный запрос на позицию)
- [ ] Legacy-импорт идемпотентен по
external_id,--dry-runне пишет в БД - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут (admin CRUD снипетов)
- [ ] Тестовая БД только
head-scripts_test;migrate:fresh/refresh/reset/db:wipeзапрещены