Skip to content

ТЗ — Web-push (cms/web-push)

Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке

Назначение и возможности

VAPID-подписки браузеров на push-уведомления с ненавязчивым opt-in виджетом и сегментированными рассылками. Новая разработка поверх стандартного Web Push API.

  • Подписка браузера через VAPID-ключи (без сторонних SaaS-провайдеров);
  • opt-in виджет: показ запроса разрешения после действия пользователя (не сразу при заходе);
  • сегментированные рассылки: по группе подписчиков, странице подписки, интересам;
  • provides: notification-channel webpush для cms/notifications-bus;
  • авто-чистка мёртвых подписок (браузер отписался/удалил разрешение — 410/404 от push-сервиса);
  • счётчик доставленных/открытых push для оценки эффективности рассылки.

Зависимости и выключение

requires: cms/notifications-bus · provides: notification-channel webpush · suggests: cms/attack-monitor (события безопасности при срабатывании rate-limit на /subscribe), cms/audit (аудит смены vapid_public_key и массовых деактиваций).

Поведение при выключении: виджет подписки скрывается, ранее сохранённые подписки не удаляются (для быстрого восстановления при повторном включении), рассылки через канал webpush не отправляются. Стоимость внешних вызовов: push-сервисы браузеров (FCM/Mozilla Push и т.п.) бесплатны для отправителя — в отличие от SMS, финансовой квоты у модуля нет (это явно отмечается, чтобы не путать с денежными лимитами других каналов уведомлений).

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

ТаблицаКлючевые поляПримечание
cms_webpush_subscriptionsid, user_id (nullable), endpoint, p256dh_key, auth_key, segment, is_activeподписка браузера

user_id — FK constrained()->nullable()->index() (гостевые подписки допустимы). Уникальный индекс на endpoint — не даёт задвоить подписку одного браузера; индекс на (segment, is_active) под сегментированную рассылку.

Конкурентное редактирование: не применимо — подписка не редактируется в админке построчно (только просмотр и деактивация одним полем is_active), lock_version не требуется.

ПДн-паспорт. endpoint/p256dh_key/auth_key уникально идентифицируют браузер/ устройство и при заполненном user_id связываются с конкретным пользователем — студийная политика трактует их как ПДн (152-ФЗ), даже когда подписка гостевая. Ретеншн: подписка хранится, пока активна; мёртвые записи (410/404 — см. «Крайние случаи») удаляются немедленно, временно неудачные — после prune_after_failures. Модуль реализует хук ядра «забыть по запросу»: при поступлении запроса на удаление данных субъекта — каскадное удаление его подписок по user_id; для гостевых подписок (user_id = null) выгрузка/забывание по субъекту невозможна технически (endpoint не связан с идентифицируемой личностью до подписки на аккаунт) — декларируется отдельно в docs/module.md. В логи и метрики не попадают сырые endpoint/ключи целиком — только subscription_id.

Входные и выходные данные

Входы (whitelist-принцип: всё, что не перечислено ниже, модуль обязан отвергать):

ИсточникПоляЧем валидируется
POST /api/v1/web-push/subscribe (JS виджета, public)endpoint, p256dh_key, auth_key, segment (опционально)FormRequest whitelist: endpoint required|url|max:500, p256dh_key/auth_key required|string, base64url-формат, segment — whitelist из объявленных сегментов настроек
DELETE /api/v1/web-push/subscribe (JS виджета, public)endpointFormRequest: required, должен совпадать с существующей активной записью
Запрос отправки от cms/notifications-bus (канал 3, provides)title, body, url, segment кампаниитипизированный DTO контракта notification-channel webpush, не сырой массив
Filament: деактивация/удаление подпискиsubscription_idpermission web-push.manage, id — только внутри админ-роута (не публичный URL)
Filament: смена vapid_public_keyновая пара ключейpermission web-push.manage + явное подтверждение необратимой операции (см. «UX-требования»)

Выходы:

ПотребительДанныеФормат
Push-сервис браузера (внешний, из очереди)заголовок/текст/url кампании, зашифрованный payloadWeb Push Protocol (HTTP, VAPID JWT)
cms/notifications-busрезультат отправки по каналу webpush (успех/провал)возврат контракта notification-channel
События WebPushSubscribed/WebPushDeliveryFailedсм. «События и обмен»JSON payload, канал 1
Виджет блока (SSR, публичная страница)vapid_public_key, widget_delay_secondsинлайн в HTML/JS блока, без отдельного запроса
Filament список/дашбордподписки по сегментам, % доставки/открытийтаблица админки

Настройки (группа web-push)

КлючТипДефолтaffectsPageCacheОписание
web-push.widget_delay_secondsint30нетЗадержка показа виджета подписки после захода
web-push.vapid_public_keystringдаПубличный VAPID-ключ (приватный — только в .env); влияет на кеш страниц с виджетом — см. «Крайние случаи»
web-push.prune_after_failuresint3нетЧисло подряд временных (5xx/timeout) неудачных доставок до отписки
web-push.subscribe_rate_limit_per_minuteint5нетRate-limit на /subscribe по IP — защита от спама подписками
web-push.kill_switch_enabledboolfalseнетАварийная остановка всех исходящих push-рассылок без выключения модуля (подписка/отписка продолжают работать)

API

МетодПутьДоступНазначение
POST/api/v1/web-push/subscribepublic (rate-limit)Сохранить подписку браузера
DELETE/api/v1/web-push/subscribepublicОтписка (endpoint в теле запроса)

Компоненты

Блоки (BlockRegistry): «Виджет подписки на push» (настраиваемая задержка и текст, demo-props). Демо-сидер заполняет тестовый сегмент и несколько подписок — блок виден в галерее /_gallery и playground без ручного ввода. Frontend-бюджет: скрипт регистрации подписки — минимальный, грузится лениво после widget_delay_seconds, не блокирует LCP и не даёт CLS (виджет — оверлей, не занимает место в потоке страницы); Service Worker — отдельный статический файл темы, кешируется браузером, не инлайнится в блок; форма разрешения — нативный браузерный UI, клавиатурной доступности от модуля не требует.

Filament: список активных подписок по сегментам с фильтрами, статистика доставки/ открытий, массовая деактивация выбранных подписок, экспорт сегмента.

Команды (все с --json): cms:web-push:prune-dead — ручной запуск и по расписанию (см. «Фоновая работа»); диагностика модуля агрегируется в cms:doctor.

События и обмен

СобытиеКогдаPayload
WebPushSubscribedновая подписка браузераsubscription_id, segment
WebPushDeliveryFailedдоставка не удаласьsubscription_id, http_status

Таблица взаимодействий (сущность/модуль → канал → направление → что происходит):

Сущность/модульКаналНаправлениеЧто происходит
cms/notifications-busprovides-контракт (3) notification-channel webpushbus → web-pushшина резолвит реализацию через DI и вызывает send() при рассылке по каналу webpush
cms/notifications-busсервис-вызов requires (4)web-push → busзапись факта отправки в общий журнал уведомлений шины (единая история по всем каналам)
Событие WebPushSubscribedканал 1web-push → любые подписчикифакт новой подписки; например аналитика/CRM могут слушать без ведома web-push
Событие WebPushDeliveryFailedканал 1web-push → cms/healthнакопление сбоев доставки видно в health-агрегате, алерт при превышении порога
cms/attack-monitor (suggests, если включён)канал 1 (событие безопасности)web-push → attack-monitorсрабатывание rate-limit на /subscribe фиксируется как событие атаки
cms/audit (suggests, если включён)канал 1web-push → auditсмена vapid_public_key и массовые деактивации попадают в аудит-лог
BlockRegistry (ядро)вверх, реестр (bootstrap)web-push → ядрорегистрация блока «Виджет подписки» в boot()
SettingsStore / CacheTags / ScheduleRegistrar (ядро)вверх, реестр (bootstrap)web-push → ядродекларация группы настроек web-push, тегов кеша, расписания prune-dead
RequestContext (ядро)вниз, чтение контекстаядро → web-pushтекущий user_id при сохранении подписки — не парсится вручную из запроса
Очередь web-push (канал 5)job → внешний push-сервисweb-push → push-сервис браузерадоставка зашифрованного payload по Web Push Protocol

Слушает: запросы канала webpush от cms/notifications-bus. Provides-контракт notification-channel webpush — потребляется шиной уведомлений наравне с push/sms/telegram/messengers.

Фоновая работа

Именованная очередь web-push для рассылки (внешние HTTP-вызовы к push-сервису браузера — только из очереди, чанками через Bus::batch при большом сегменте — см. «Производительность и кеш») и для cms:web-push:prune-dead (авто-чистка мёртвых подписок после prune_after_failures); джобы идемпотентны. prune-dead дополнительно регистрируется в ScheduleRegistrar на ежедневный прогон — не только ручной запуск.

Мини-ранбук (эксплуатация):

СимптомЧто проверитьКоманда
Push не доходят ни одному подписчикуВалиден ли vapid_public_key/приватный ключ в .env, не менялась ли пара недавно (см. «Крайние случаи»)cms:web-push:prune-dead --json --dry-run (диагностика без изменений) + health-чек модуля
Очередь web-push отстаётДлина очереди, состояние воркераcms:doctor --json (агрегирует health модуля)
Резкий рост «мёртвых» подписокИзменения VAPID-ключа, массовая отписка пользователей (обновление ОС/браузера)cms:web-push:prune-dead --json (отчёт: сколько удалено и почему)

Метрики и алерты: доля неудачных доставок за кампанию, число подписок, удалённых prune-dead за прогон, отставание очереди web-push. Алерт — рост доли неудач выше типового фона (видно в Pulse/health).

Бэкап/рестор: таблица cms_webpush_subscriptions — в бэкапе целиком; после рестора ничего не пересоздаётся (нет производных индексов/агрегатов, статистика доставки хранится в самой таблице).

Производительность и кеш

Ожидаемые объёмы: от нескольких сотен до десятков тысяч активных подписок на сайт; рассылка кампании — от единиц до нескольких тысяч push в день для среднего клиента (порядок величины, не e-commerce с миллионами получателей).

Горячие пути: показ виджета подписки на публичной странице — 0 запросов к БД (vapid_public_key и widget_delay_seconds читаются из кеша группы настроек, значение инлайнится в HTML/JS блока при рендере и попадает в page-cache вместе со страницей). Сохранение подписки (POST /subscribe) — мутация вне page-cache, один upsert по уникальному endpoint.

Бюджет запросов: 0 запросов на рендере блока-виджета (значения из кеша настроек), 0 запросов на публичной части сверх стандартного page-cache ядра.

Критичные индексы (см. «Модель данных»): уникальный endpoint — защита от дублей при повторной подписке того же браузера; составной (segment, is_active) — без него выборка получателей кампании по сегменту уходит в full scan таблицы подписок при каждой рассылке.

Теги и инвалидация: web-push:segment:<segment> — инвалидируется при изменении состава сегмента (новая/удалённая/деактивированная подписка); рассылка большому сегменту — чанками через Bus::batch (§9 стандарта), не одним циклом в синхронном job.

Безопасность

Приватный VAPID-ключ хранится только в .envconfig(), не в БД настроек. Публичный web-push.vapid_public_key — настройка с affectsPageCache: да (см. «Крайние случаи» — найденное и разрешённое противоречие). Подписка с невалидными ключами (p256dh/auth) отклоняется при сохранении (FormRequest-whitelist — см. «Входные и выходные данные»).

Rate-limit: /subscribe ограничен web-push.subscribe_rate_limit_per_minute по IP — защита от массового спама подписками (наполнение таблицы мусорными endpoint). /subscribe и /unsubscribe — публичные без аутентификации, вход строго whitelisted.

Матрица ролей:

PermissionАдминМенеджерРедакторStudio
web-push.view (список подписок, статистика)
web-push.manage (деактивация подписки, сегменты, настройки виджета)
смена web-push.vapid_public_key (необратимая операция)✅ (с подтверждением)
web-push.kill_switch_enabled (аварийная остановка)

ПДн: см. «Модель данных» (ретеншн, хук «забыть по запросу»).

UX-требования

Для посетителя: opt-in ненавязчивый — виджет показывается после действия пользователя, задержка настраивается widget_delay_seconds, не спамит повторным запросом разрешения в рамках сессии/после отказа. При отказе браузера дать разрешение (permission denied) — тихий fallback без ошибки в интерфейсе, виджет больше не предлагается автоматически. Мобильность: Web Push поддерживается не всеми мобильными браузерами (iOS Safari — с ограничениями) — виджет скрывается на неподдерживаемых платформах, а не показывает нерабочую форму.

Для админа: пустое состояние списка подписок — подсказка «пока нет подписчиков, добавьте виджет на страницу» со ссылкой на блок. Массовые действия: массовая деактивация выбранных подписок, экспорт сегмента. Человеческие ошибки: попытка запустить рассылку по пустому сегменту — предупреждение до отправки, не тихая пустая кампания. Подтверждение необратимых операций: смена vapid_public_key требует явного подтверждения с предупреждением «все текущие подписки браузеров станут невалидны, пользователям нужно будет подписаться заново» (см. «Крайние случаи»). Видимость в реальном времени: счётчик активных подписок и процент успешных доставок последней кампании — на дашборде модуля (денежной стоимости нет, но здоровье канала важно).

Крайние случаи и типовые баги

  • Push-сервис браузера вернул 410 (Gone) или 404 (Not Found) → подписка удаляется немедленно, без ожидания порога prune_after_failures — это финальный сигнал «браузер больше не существует/отписался», в отличие от временных 5xx-ошибок.
  • Push-сервис вернул временную 5xx-ошибку или таймаут → инкремент счётчика неудач конкретной подписки, отписка — только после prune_after_failures подряд таких неудач, не одной.
  • Смена web-push.vapid_public_key → все существующие подписки браузеров разом становятся криптографически невалидными (пара ключей сменилась) — это не баг, а прямое следствие смены ключа; редкая необратимая операция, требующая явного подтверждения в админке перед сохранением (см. «UX-требования»).
  • ⚠️ Противоречие: настройка web-push.vapid_public_key в исходном ТЗ была помечена affectsPageCache: нет, но значение рендерится инлайн в HTML/JS блока виджета на публичной странице — при смене ключа закешированная страница отдаёт подписчику старый публичный ключ, пока кеш не истечёт, и браузер сформирует подписку под устаревшую пару. Разрешение: поле переведено на affectsPageCache: да (см. «Настройки») — смена ключа обязана инвалидировать страницы с блоком виджета, как любая другая настройка, влияющая на разметку.
  • Двойная подписка одного браузера (повторный subscribe с тем же endpoint) → upsert по уникальному индексу endpoint, а не дублирующая запись.
  • Гонка отписки: пользователь отписался через DELETE /subscribe, но push-сервис ещё доставляет ранее поставленное в очередь сообщение на тот же endpoint → доставка на уже отписанный endpoint обрабатывается идемпотентно (см. запись в БД как источник истины), повторный провал не создаёт лишних алертов.
  • cms/notifications-bus выключен посреди рассылки → провайдер webpush остаётся зарегистрирован в реестре, но шина не инициирует новые отправки; уже поставленные в очередь web-push джобы перед вызовом push-сервиса проверяют, что канал всё ещё востребован, и мягко пропускаются, если нет — не тратят ресурс молча.
  • Отсутствие suggests-модуля cms/attack-monitor → rate-limit на /subscribe продолжает работать (встроенная защита модуля не зависит от attack-monitor), просто события срабатывания не попадают в общий монитор атак — деградация видимости, не функциональности.
  • Пустой сегмент при запуске кампании → job рассылки завершается без ошибки, «0 отправлено» отражается в статистике кампании, а не проглатывается тихо.
  • Огромный сегмент (десятки тысяч подписок) → рассылка чанками через Bus::batch, не одним циклом в синхронном запросе — иначе таймаут HTTP-запроса на инициацию кампании и риск дублей при ретрае целиком.
  • Тихие часы (quiet hours): в текущем ТЗ модуля настройки тихих часов нет — «не применимо» для базовой версии. Если появится потребность (не слать push ночью по часовому поясу получателя), реализовывать как общий фильтр в cms/notifications-bus (единый для всех каналов через FilterBus), а не отдельной настройкой внутри cms/web-push — избегает дублирования логики по каждому каналу.

Донорский код

Донор: — (новая разработка). §16 стандарта («Миграция legacy-данных») — не применимо: донора с боевыми данными нет, легаси-импортёр не требуется.

Тесты и приёмка

  • [ ] Контрактный тест: подписка с невалидными ключами (p256dh/auth) отклоняется при сохранении;
  • [ ] Приватный VAPID-ключ хранится только в .envconfig(), не в БД настроек;
  • [ ] 410/404 от push-сервиса отписывают подписку немедленно; 5xx/timeout — только инкремент счётчика, отписка после prune_after_failures;
  • [ ] Смена vapid_public_key инвалидирует page-cache страниц с виджетом (проверка affectsPageCache: да);
  • [ ] Виджет подписки не показывается сразу при заходе — соблюдает widget_delay_seconds;
  • [ ] Деградация при выключении не удаляет существующие подписки из БД;
  • [ ] Rate-limit subscribe_rate_limit_per_minute на эндпоинт подписки (защита от массового спама подписками);
  • [ ] kill_switch_enabled останавливает рассылку, не затрагивая приём новых подписок/отписок;
  • [ ] Матрица ролей: смена vapid_public_key доступна только повышенным ролям (админ/studio с подтверждением);
  • [ ] Рассылка по пустому сегменту завершается видимым «0 отправлено», не молчаливым no-op;
  • [ ] Хук «забыть по запросу» удаляет подписки пользователя по user_id при запросе на удаление ПДн;
  • [ ] Демо-сидер виджета присутствует в галерее /_gallery и playground;
  • [ ] Контрактный набор cms-testing зелёный, testbench-изоляция пакета, feature-тест на каждый роут;
  • [ ] Тестовая БД только web-push_test; migrate:fresh/refresh/reset, db:wipe запрещены.

Внутренняя база знаний студии