Тема
ТЗ — 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_subscriptions | id, 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) | endpoint | FormRequest: required, должен совпадать с существующей активной записью |
Запрос отправки от cms/notifications-bus (канал 3, provides) | title, body, url, segment кампании | типизированный DTO контракта notification-channel webpush, не сырой массив |
| Filament: деактивация/удаление подписки | subscription_id | permission web-push.manage, id — только внутри админ-роута (не публичный URL) |
Filament: смена vapid_public_key | новая пара ключей | permission web-push.manage + явное подтверждение необратимой операции (см. «UX-требования») |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Push-сервис браузера (внешний, из очереди) | заголовок/текст/url кампании, зашифрованный payload | Web 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_seconds | int | 30 | нет | Задержка показа виджета подписки после захода |
web-push.vapid_public_key | string | — | да | Публичный VAPID-ключ (приватный — только в .env); влияет на кеш страниц с виджетом — см. «Крайние случаи» |
web-push.prune_after_failures | int | 3 | нет | Число подряд временных (5xx/timeout) неудачных доставок до отписки |
web-push.subscribe_rate_limit_per_minute | int | 5 | нет | Rate-limit на /subscribe по IP — защита от спама подписками |
web-push.kill_switch_enabled | bool | false | нет | Аварийная остановка всех исходящих push-рассылок без выключения модуля (подписка/отписка продолжают работать) |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/web-push/subscribe | public (rate-limit) | Сохранить подписку браузера |
| DELETE | /api/v1/web-push/subscribe | public | Отписка (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-bus | provides-контракт (3) notification-channel webpush | bus → web-push | шина резолвит реализацию через DI и вызывает send() при рассылке по каналу webpush |
cms/notifications-bus | сервис-вызов requires (4) | web-push → bus | запись факта отправки в общий журнал уведомлений шины (единая история по всем каналам) |
Событие WebPushSubscribed | канал 1 | web-push → любые подписчики | факт новой подписки; например аналитика/CRM могут слушать без ведома web-push |
Событие WebPushDeliveryFailed | канал 1 | web-push → cms/health | накопление сбоев доставки видно в health-агрегате, алерт при превышении порога |
cms/attack-monitor (suggests, если включён) | канал 1 (событие безопасности) | web-push → attack-monitor | срабатывание rate-limit на /subscribe фиксируется как событие атаки |
cms/audit (suggests, если включён) | канал 1 | web-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-ключ хранится только в .env → config(), не в БД настроек. Публичный 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-ключ хранится только в
.env→config(), не в БД настроек; - [ ] 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запрещены.