Сервер: пакет 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
15 KiB
Интерфейс
Визуальная система — docs/identity/brief.md, эталон экрана чата — docs/identity/screens.html. Здесь — состав экранов, поведение и тексты. Все тексты — русские, строчными, как в моке; заглавная только в начале предложений из нескольких слов.
Каркас
Одна страница index.html, роутинг по hash: #/ — список (на десктопе — первый чат), #/dm/<nick>, #/room/<id>, #/room/<id>/members, #/contact/<nick>, #/settings, #/new. Десктоп (≥ 760 px): сайдбар 224 px + чат. Мобильный: один экран за раз, «назад» — в шапке слева.
Без inline-стилей и inline-скриптов (CSP). Рендер — document.createElement и textContent; innerHTML не используется нигде: сообщения — пользовательские данные.
Вход и регистрация
Одна страница, два режима переключателем «вход / регистрация». Логотип-знак и bare сверху.
Поля: ник, пароль. В регистрации дополнительно инвайт-код, если config.inviteRequired, и текст под паролем:
пароль — это ключ шифрования, а не запись в базе. восстановления нет. не короче 12 символов; лучше — фраза из нескольких слов.
Кнопка одна, в стиле строки ввода. Пока идёт PBKDF2 — состояние «вычисляем ключ…», кнопка заблокирована. Ошибки — строкой под формой цветом mark: «неверный ник или пароль», «ник занят», «ник: 2–32 символа, a–z, 0–9, _», «нужен инвайт-код», «инвайт-код не подходит». Форму ника и длину пароля клиент проверяет сам, до PBKDF2, в обоих режимах. Остальные состояния — «Тексты состояний».
Если на устройстве лежат ключи другого ника, до вычисления ключа — подтверждение «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «удалить» и «отмена» (ADR-029). База стирается после успешного входа или регистрации; отказ сервера её не трогает.
Список чатов (сайдбар)
Секции «каналы» и «личные», как в моке. Активный чат — инверсия (ink на bone). Непрочитанные — число цветом mark справа. Порядок — по lastId по убыванию. Внизу — «ты: @nick», по нажатию — настройки. Над секциями — строка + новый чат.
Новый чат (#/new)
Две строки ввода: @ник → открыть личный чат; #имя комнаты → создать комнату. Ошибки: «такого ника нет», «нельзя писать себе».
Чат
Шапка: имя (#general / @marta), по нажатию — участники или карточка контакта. Без темы и «N онлайн».
Лента: десктоп — сетка «автор 132 px + текст», подряд идущие сообщения одного автора — без повтора автора; мобильный — автор над группой. Свой ник в колонке автора — цветом mark. Разделители дат — линия с датой; «новые» — линия цветом mark перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом stone; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — ts в локальной зоне, ЧЧ:ММ.
Ввод: рамка 1 px ink, слева > цветом mark, placeholder «сообщение в #general» / «сообщение». Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка > справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500.
Предупреждение о ключе — полоса над вводом цветом mark: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038).
Комната, из состава которой нас больше нет (вышли сами, убрал владелец, комната удалена), — та же полоса цветом mark: «вы больше не участник комнаты». Ввод заблокирован, лента остаётся. Эта полоса перебивает и предупреждение о ключе (ADR-044).
Отказ отправки — та же полоса над вводом цветом mark с текстом из поля error последнего неотправленного сообщения (ADR-033): «проверьте часы на устройстве: расхождение больше 5 минут», «слишком часто, попробуйте позже», «сервер не справился, попробуйте позже». Полоса исчезает при следующей попытке. Ввод не блокируется.
Первое отправленное сообщение за всю историю устройства → запрос разрешения на уведомления (см. «Уведомления»).
Карточка контакта (#/contact/<nick>)
@nick, отпечаток 64 hex группами по 4 в две строки, строка «сверьте с собеседником голосом или лично». Если есть pending — оба отпечатка с пометками «старый» и «новый» и кнопка «доверять новому ключу». Кнопка «убрать из списка».
pending снимает и сервер, снова отдавший доверенный ключ: смены ключа не случилось, состояние закрывается само и молча (ADR-040).
Участники (#/room/<id>/members)
Список ников; у владельца — пометка «владелец». Владельцу: строка ввода @ник + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил needsRekey и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038).
Настройки (#/settings)
- «ты: @nick», свой отпечаток.
- «уведомления»: состояние (
включены/выключены/запрещены в браузере), кнопка «включить» или «выключить».запрещены в браузере— разрешение отклонено или уведомлений в браузере нет вовсе; кнопки в этом состоянии нет (ADR-046). На iOS вне PWA — состояниевыключеныи вместо кнопки текст про установку, тот же, что в баннере. - «установить приложение»: кнопка «установить», если есть
beforeinstallprompt; на iOS вне PWA — инструкция «поделиться → на экран «домой»». Устанавливать нечего — раздела нет. - «устройства»: список
id(первые 8 символов), дата, «это устройство», «удалить». - «история»: «занято N МБ»; «экспорт» → скачивание
.bare; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён». - «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». Ответ — «пароль изменён».
- «выйти»: подтверждение «история на этом устройстве будет удалена. экспортировать сначала?» с кнопками «экспортировать», «выйти», «отмена».
- «удалить аккаунт»: пароль + подтверждение «аккаунт и вся история будут удалены навсегда.» с кнопками «удалить» и «отмена».
Баннер установки (iOS)
Показывается при iPhone|iPad и navigator.standalone !== true, над списком чатов: «уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»». Крестик — «×» с подписью «закрыть» для экранного диктора — ставит installBannerDismissed, повтор не показывается.
Уведомления
Запрос разрешения — после первого успешно отправленного сообщения, один раз (notificationsAsked). После granted — pushManager.subscribe с vapidPublicKey и PUT /api/devices/{id}/push. Отказ — молча; включить можно в настройках.
Выключенные кнопкой уведомления сами не включаются: ни первым сообщением, ни запуском приложения. Обратно их включает только кнопка (ADR-049).
Уведомление: заголовок — @nick отправителя или #имя комнаты, текст — «новое сообщение», нажатие открывает этот чат. Содержимого сообщения в уведомлении нет: сервер его не знает (ADR-011). Пуш о собственном сообщении не приходит (ADR-045).
Сеть и состояния
- SSE переподключается браузером; после
readyклиент перечитывает комнаты и контакты и повторяетpending. - Вкладок одного профиля бывает несколько; поток событий держит одна из них, остальные получают изменения от неё и выглядят так же (ADR-035).
- Без сети: полоса «нет соединения» цветом
stoneнад вводом; ввод не блокируется — сообщения уходят вpending. clock_skew— «проверьте часы на устройстве: расхождение больше 5 минут».401 unauthenticatedна любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). Исключение одно: служебный выход перед повторным входом при смене пароля и удалении аккаунта (ADR-031) — там этот ответ означает, что сессии и так нет.
Тексты состояний
Общие для всех форм строки (ADR-028). Ошибка — цветом mark, ответ об успехе — цветом mute, место одно.
| состояние | текст |
|---|---|
| запрос не дошёл | «нет соединения» |
код ответа, на который нет сценария (internal, too_large, прочее) |
«сервер не справился, попробуйте позже» |
429 rate_limited |
«слишком часто, попробуйте позже» |
| пароль короче 12 символов | «пароль: не короче 12 символов» |
| новый пароль и повтор различаются | «пароли не совпадают» |
| ключевой блоб не разобран, не расшифрован или не соответствует публичному ключу | «ключ аккаунта повреждён» |
iter блоба не равен ответу GET /api/kdf |
«параметры ключа не совпали» |
401 invalid_credentials в настройках |
«неверный пароль» |
Доступность
Семантика: nav, main, form, button, ul/li для списков; aria-live="polite" на ленте; фокус в строку ввода при открытии чата на десктопе; контраст ink/bone и mark/bone не ниже 4.5:1; цели нажатия на мобильном не меньше 44 px.