Files
bare/docs/decisions/046-push-client.md
T
mayatnikovandClaude Opus 5 8f67f4aa4d Этап 4: PWA и пуши — service worker, Web Push с VAPID, баннер установки
Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.

Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.

ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.

Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.

Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.

Чеклист ручной проверки на iPhone и Android — в docs/plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-23 00:50:58 +03:00

4.9 KiB

ADR-046: Клиент пушей — адрес перехода, состояния и жизнь подписки

Уточняет ADR-023.

Контекст

Клиентская половина ADR-023 упирается в четыре места, где документ не договаривает.

Адрес перехода. ADR-023 велит открывать по нажатию на уведомление /#/<chat>. Идентификатор чата — dm:<nick> или room:<id> (docs/storage.md), а маршруты клиента — #/dm/<nick> и #/room/<id> (docs/ui.md). Буквальное /#/dm:marta не разбирается роутером и открывает список: уведомление ведёт не туда, куда обещало.

Состояний больше трёх. docs/ui.md знает три: включены, выключены, запрещены в браузере. Кроме них бывает браузер без Notification и PushManager (iOS вне установленного приложения — как раз такой) и сервер без VAPID-ключа: включить нельзя, а сказать про это нечем.

У кнопки установки нет надписи. «Кнопка, если есть beforeinstallprompt» — а что на ней написано, не сказано.

Подписка переживает то, к чему привязана. Подписка принадлежит устройству (ADR-023), но живёт в браузерном профиле и не знает ни про deviceId, ни про аккаунт. deviceId меняется при конфликте идентификаторов и при чистке IndexedDB (ADR-017), аккаунт на устройстве меняется при выходе. Что делать с подпиской в эти моменты, не записано нигде.

Решение

  • Переход. Service worker переводит идентификатор чата в маршрут: dm:<nick>/#/dm/<nick>, room:<id>/#/room/<id>. Идентификатор не той формы открывает /#/. Формулировка ADR-023 читается так.
  • Состояния. Их по-прежнему три. запрещены в браузере — это отклонённое разрешение, отсутствие Notification или PushManager и пустой vapidPublicKey: включить нельзя, кнопки в этом состоянии нет. выключены — всё, что включается кнопкой. На iOS вне установленного приложения раздел показывает выключены и вместо кнопки текст про установку — тот же, что в баннере.
  • Надписи. Кнопка установки — «установить». Крестик баннера — «×» с подписью «закрыть» для экранного диктора. Тексты записаны в docs/ui.md.
  • Жизнь подписки. При каждом запуске синхронизации клиент переставляет имеющуюся подписку на текущее устройство (PUT /api/devices/{id}/push): запрос идемпотентен, и смена deviceId этим и лечится. Выход из аккаунта снимает подписку и у сервера, и у браузера — сначала DELETE /api/devices/{id}/push, пока сессия жива, потом pushManager.unsubscribe (ADR-049). Удаление аккаунта отписывается только у браузера: строку устройства вместе с подпиской уносит каскад.

Следствия

  • Уведомление открывает тот чат, о котором оно: chat в нагрузке остаётся идентификатором из ADR-023, разбирает его клиент.
  • Пользователь, у которого пушей не бывает вовсе, видит запрещены в браузере и не видит кнопки, которая ничего не даст.
  • Подписка, поставленная не тому устройству, чинится следующим запуском приложения, а не остаётся молчащей навсегда.
  • Устройство, с которого вышли, пушей прежнего аккаунта не получает: адреса подписки у сервера больше нет, даже если отозвать её у push-сервиса не удалось.