Этап 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
This commit is contained in:
2026-08-23 00:50:58 +03:00
co-authored by Claude Opus 5
parent 05586218e1
commit 8f67f4aa4d
42 changed files with 3047 additions and 92 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ ADR-005 задаёт «Argon2id, сессия в httpOnly cookie» без пар
- остальные изменяющие запросы — 60 в минуту на пользователя.
Превышение — `429` с `Retry-After`. IP берётся из `X-Real-IP`, только если соединение с `127.0.0.1` (nginx, ADR-022).
**Размеры.** Тело запроса — до 32 КиБ, текст сообщения — до 4000 символов, имя комнаты — до 64, ник — до 32.
**Размеры.** Тело запроса — до 32 КиБ, текст сообщения — до 4000 символов, имя комнаты — до 64, ник — до 32. Имя комнаты вдобавок не бывает пустым, из одних пробелов, с управляющими символами и с переопределениями направления письма (U+202A…U+202E, U+2066…U+2069): с этапа 4 оно уходит в заголовок системного уведомления (ADR-045), а там перевод строки и разворот текста выдают чужое имя за сообщение системы.
## Следствия
@@ -1,5 +1,7 @@
# ADR-023: Правила пушей и service worker
Кому уходит пуш и тексты уведомления — [ADR-045](045-push-addressed-to-recipient.md). Адрес перехода, состояния настроек и жизнь подписки на клиенте — [ADR-046](046-push-client.md).
## Контекст
ADR-011 задаёт принцип «пуш — сигнал». Не определено, когда именно слать пуш, как он привязан к устройству и что кэширует service worker.
@@ -10,7 +12,7 @@ ADR-011 задаёт принцип «пуш — сигнал». Не опред
- Пуш отправляется при постановке сообщения в очередь устройства, если выполняются оба условия: устройство не подключено по SSE и у устройства не висит неотработанный пуш (`push_pending = 0`). После отправки `push_pending = 1`; сбрасывается при подключении SSE. Одно молчащее устройство получает один пуш, не ленту.
- Полезная нагрузка: `{title, body: "новое сообщение", chat}``title` это `@nick` или `#имя комнаты`, `chat` — идентификатор для перехода. `TTL` 24 часа, urgency `normal`. Ответы 404/410 от push-сервиса удаляют подписку.
- Service worker: `push``showNotification` с `tag = chat` (новое уведомление заменяет старое в том же чате); `notificationclick` → фокус открытого окна или открытие `/#/<chat>`.
- Кэш: stale-while-revalidate для оболочки (`/`, `/app.css`, `/js/*`, `/icons/*`), никогда — для `/api/*`. Имя кэша содержит версию, версия задаётся константой в `sw.js` и меняется при релизе. Сервер отдаёт статику с `ETag` и `Cache-Control: no-cache`.
- Кэш: stale-while-revalidate для оболочки (`/`, `/app.css`, `/manifest.json`, `/js/*`, `/icons/*`), никогда — для `/api/*`. Имя кэша содержит версию, версия задаётся константой в `sw.js` и меняется при релизе. Сервер отдаёт статику с `ETag` и `Cache-Control: no-cache`.
- Разрешение на уведомления запрашивается после первого отправленного сообщения (ADR-011). На iOS вне установленного PWA вместо запроса показывается баннер установки.
## Следствия
@@ -0,0 +1,23 @@
# ADR-045: Пуш адресован получателю
Уточняет [ADR-023](023-push-and-service-worker.md).
## Контекст
ADR-023 задаёт правило отправки: пуш уходит при постановке сообщения в очередь устройства, если устройство не подключено по SSE и неотработанного пуша у него нет. Но конверт кладётся в очередь и другим устройствам отправителя (ADR-017) — по букве правила молчащий второй телефон автора получал бы пуш о собственном сообщении.
Полезная нагрузка от этого рассыпается. Заголовок — `@nick` отправителя, адрес чата — `dm:<nick>`: и то и другое собрано с точки зрения получателя. У отправителя тот же чат называется именем собеседника, а уведомление «@marta: новое сообщение» на телефоне самой marta не значит ничего.
Тексты уведомления при этом живут в ADR-023, а не в `docs/ui.md`, где место всему, что видит человек.
## Решение
- Пуш уходит только устройствам получателей. Устройства отправителя — и то, с которого он писал, и все остальные — пуша не получают; сообщение они забирают очередью, как и раньше.
- Заголовок и адрес чата собираются для получателя: `@nick` отправителя и `dm:<nick>` в личном чате, `#имя комнаты` и `room:<id>` в комнате. В комнате адресация одна для всех получателей, в личном чате получатель один — значит, у сообщения одна нагрузка на всех.
- Тексты уведомления записаны в `docs/ui.md`, «Уведомления».
## Следствия
- Правило ADR-023 читается как «пуш уходит устройствам получателей, если …». Одно молчащее устройство получателя — один пуш.
- Сервер собирает пуш из того, что и так знает: ник отправителя, имя комнаты, идентификатор чата. Плейнтекста он не знает, шифротекст не пересылает — в пуше нет ни того ни другого.
- Автор, отошедший от одного своего устройства к другому, узнаёт о собственном сообщении не пушем, а очередью при открытии. Осознанно.
+29
View File
@@ -0,0 +1,29 @@
# ADR-046: Клиент пушей — адрес перехода, состояния и жизнь подписки
Уточняет [ADR-023](023-push-and-service-worker.md).
## Контекст
Клиентская половина 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-сервиса не удалось.
+27
View File
@@ -0,0 +1,27 @@
# ADR-047: Исходящий запрос к push-сервису
Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md).
## Контекст
Адрес push-сервиса выбирает браузер получателя: клиент присылает `endpoint` из `PushSubscription`, сервер хранит его и на каждое сообщение сам открывает к нему соединение. Это единственное место, где сервер ходит наружу по адресу, который назвал пользователь. Свойство появилось на этапе 4, и в модели угроз его не было.
Проверки «endpoint — абсолютный https-url» для него мало. `http.Client` по умолчанию идёт за редиректами: один ответ `307` с настоящего https-хоста уводит запрос на plain http и на любой внутренний адрес — вместе с заголовком `Authorization: vapid`. Адрес может указывать внутрь и сразу: `https://127.0.0.1:…`, `https://169.254.169.254/…`, `https://10.0.0.1/`. Ответ наружу не пересылается, но `404` и `410` снимают подписку, а это видно в `GET /api/devices` полем `hasPush`: получается побитовое сканирование внутренней сети двумя своими аккаунтами.
Рядом — две недопроверки формы. Длина `endpoint` не ограничена ничем, кроме общего предела тела: адрес на 20 КиБ ложился в базу. `p256dh` проверялся только по длине, хотя 65 случайных байт точкой кривой не являются: отправка на такую подписку падает при каждом сообщении, а устройство остаётся с ней навсегда.
И журнал: адрес подписки уходил в строку отказа. Развернуть `*url.Error` мало — host и DNS-имя остаются внутри `*net.OpError` и ошибки резолвера, а `docs/deploy.md` обещает, что данных пользователя в журнале нет.
## Решение
- Редиректы не выполняются: `CheckRedirect` возвращает `http.ErrUseLastResponse`. Push-сервисы редиректов не шлют, а без этого требование https не значит ничего.
- Соединение возможно только с публичным адресом. Проверка стоит на `Control` диалера, то есть на уже разрешённом адресе: имя, указывающее внутрь, не помогает. Непубличные — loopback, приватные сети (RFC 1918 и RFC 4193), link-local, multicast и неопределённый адрес.
- `PUT /api/devices/{id}/push` отвергает `400 invalid` литеральный непубличный адрес и `endpoint` длиннее 2 КиБ, а `p256dh` разбирает как точку P-256. Это ранний отсев формы; решает всё равно проверка при соединении.
- Отказ отправки пишется в журнал классом: «таймаут», «имя не разрешилось», «адрес подписки не публичный», «отправка не удалась». Текст ошибки транспорта не печатается вовсе — внутри него адрес подписки.
- Разрешение ходить на непубличные адреса есть в конфигурации, но из окружения не читается и в работе всегда выключено. Оно нужно тестам, где push-сервис вендора подменён сервером на `127.0.0.1`.
## Следствия
- Сервер остаётся отправителем пушей и не становится инструментом запросов внутрь периметра: оракула `hasPush` по внутренним адресам больше нет.
- Свой push-сервис на внутреннем адресе работать не будет. Для v1 это верно: подписку выдаёт браузер, а вендоры живут в интернете.
- Остаток риска записан в `docs/threat-model.md`: сервер по-прежнему открывает соединение к адресу, который назвал браузер получателя, и белого списка вендоров у нас нет.
+27
View File
@@ -0,0 +1,27 @@
# ADR-048: Пределы отправки пушей
Уточняет [ADR-023](023-push-and-service-worker.md).
## Контекст
ADR-023 говорит, кому и когда уходит пуш, но про пределы отправки не говорит ничего. Этап 4 сделал общую очередь на 256 заданий и четыре отправщика с таймаутом 10 секунд, без изоляции между аккаунтами. Прогон показал цену: аккаунт с сотней устройств на не отвечающем эндпоинте занимает всех отправщиков на минуты, и пуши посторонних пользователей в это время отбрасываются. Того же эффекта добивается не злой умысел, а медленный вендор.
Рядом две лишние работы. В очередь ставились и устройства без подписки — отправить им нечего, а место они занимали. И на каждое отброшенное задание писалась строка в журнал, прямо из обработчика `POST /api/messages`: одно сообщение давало сотню строк — готовый усилитель для заливки журнала.
Отдельно — само правило «пуш только молчащему устройству». Подключение проверялось в обработчике запроса, а право на пуш забиралось позже, в отправщике. Между этими моментами устройство успевает подключиться: подключение сбрасывает `push_pending`, отправщик тут же забирает его снова и шлёт пуш подключённому. Хуже последствие: право висит всю SSE-сессию и съедает первый пуш после ухода в офлайн.
## Решение
- Пуш ставится в очередь только устройству с подпиской: признак берётся тем же запросом, что и список устройств доставки.
- Заданий одного аккаунта в очереди и в работе — не больше четырёх. Лишние отбрасываются сразу, не занимая отправщика.
- Отправщиков восемь, таймаут запроса — 5 секунд, соединения — 3: вендоры отвечают за секунды, а таймаут задаёт потолок пропускной способности.
- Отброшенные пуши считаются, а не пишутся строкой каждый: в журнал уходит счётчик, не чаще раза в минуту.
- Подключение устройства проверяется в отправщике: до захвата права, сразу после захвата и после успешной отправки. Подключённому устройству право возвращается.
- Потолка на число устройств у аккаунта не вводим. Доля в отправке ограничена, устройства без подписки в очередь не попадают, а экран «устройства» — этап 5.
## Следствия
- Аккаунт с сотней молчащих устройств занимает не больше половины отправщиков: пуш постороннего уходит сразу.
- Отброшенный пуш не теряется навсегда: право на него не забиралось, `push_pending` устройства остался нулём, и следующее сообщение попробует снова.
- Подключённое устройство пуша не получает, а его право не остаётся висеть до конца сессии.
- Пропускная способность отправки — восемь заданий на пять секунд в худшем случае. Для маленького сервера это приемлемо; понадобится больше — менять числа, а не устройство.
+25
View File
@@ -0,0 +1,25 @@
# ADR-049: Выключенные уведомления остаются выключенными
Уточняет [ADR-046](046-push-client.md).
## Контекст
Кнопка «выключить» снимала подписку у push-сервиса и у сервера, но следа о решении человека не оставляла. Дальше подписку возвращали два автоматических пути: `askOnce` после первого отправленного сообщения (разрешение уже дано — значит, ставим подписку) и `refresh` при каждом запуске приложения (подписка в браузере уцелела — переставим её на сервер). Человек нажимал «выключить», а уведомления включались обратно сами и молча.
Рядом состояние «включены», которое считалось по одному факту наличия подписки. Подписка под прежней парой VAPID-ключей не работает: push-сервис отвечает на неё `403`, а это не `404` и не `410`, и сервер её не снимет. В настройках при этом написано «включены», а уведомлений нет.
И выход из аккаунта. ADR-046 велел снимать подписку только у браузера, «сервер не спрашивая: сессии к этому моменту уже нет». Сессия на момент нажатия «выйти» ещё жива, а отписка у push-сервиса может не пройти — сети нет, вендор недоступен. Тогда строка `devices.push_subscription` остаётся живой, и пуши прежнего аккаунта рисуются на экране блокировки устройства, где уже вошёл другой человек.
## Решение
- В `meta` появляется `notificationsOff` — явный отказ. Его ставит «выключить», снимает «включить». При взведённом флаге `askOnce` и `refresh` не делают ничего, а раздел настроек показывает «выключены».
- «Включить» закрывает и вопрос о разрешении: `notificationsAsked` ставится здесь же — человек уже решил всё сам.
- Состояние «включены» требует подписки под текущим `vapidPublicKey`. Подписка под прежним ключом — «выключены», и кнопка «включить» переподпишет устройство. По той же причине `refresh` не переставляет на сервер подписку под чужим ключом.
- Выход из аккаунта сначала снимает подписку на сервере (`DELETE /api/devices/{id}/push`, пока сессия жива), потом закрывает сессию, потом отписывается у push-сервиса. Удаление аккаунта в этом не нуждается: строка устройства уходит каскадом вместе с подпиской.
## Следствия
- Выключенные уведомления включаются только кнопкой.
- Смена пары VAPID-ключей на сервере видна человеку как «выключены», а не как молчание при надписи «включены».
- Устройство, с которого вышли, пушей прежнего аккаунта не получает, даже если отписаться у push-сервиса не удалось: адреса подписки у сервера больше нет.
- В `meta` на один ключ больше — он записан в `docs/storage.md`.
+4 -1
View File
@@ -115,6 +115,8 @@ sudo systemctl daemon-reload && sudo systemctl enable --now bare
## Обновление — `scripts/deploy.sh`
Перед сборкой: если менялись `index.html`, `app.css`, `js/*`, `manifest.json` или иконки — сменить `VERSION` в `web/sw.js` (ADR-023). Без этого установленные приложения получат новую оболочку только вторым открытием, по ETag.
```sh
#!/bin/sh
set -eu
@@ -129,6 +131,7 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud
- `curl -I https://bare.xmatic.team/` — 200, заголовки CSP и nosniff.
- `curl -N https://bare.xmatic.team/api/events` — 401 (без cookie), без буферизации.
- `curl -s https://bare.xmatic.team/sw.js | grep VERSION` — версия та, что в репозитории.
- `journalctl -u bare -f` — старт, применённые миграции, нет ошибок.
## Бэкап
@@ -137,4 +140,4 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud
## Логи
Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ник — только для ошибок аутентификации по лимитам; IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. journald хранит по своим правилам.
Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ник — только для ошибок аутентификации по лимитам; IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам.
+26
View File
@@ -32,6 +32,8 @@ web/
js/api.js fetch-обёртки, SSE, ACK
js/crypto.js всё из crypto.md
js/db.js IndexedDB из storage.md
js/sync.js устройство, поток событий, приём и отправка
js/pwa.js service worker, подписка на пуши, установка
js/ulid.js ULID
js/ui/*.js экраны из ui.md
js/export.js .bare
@@ -84,6 +86,30 @@ Go — последняя стабильная версия, маршрутиз
Готово, когда закрытое PWA на iPhone и Android получает пуш и открывается на нужном чате; повторные сообщения до открытия пуш не порождают.
### Чеклист ручной проверки на устройствах
Автоматически проверено всё, что проверяется без настоящих устройств: правило «одно
молчащее устройство — один пуш», сброс `push_pending` при подключении SSE, удаление
подписки на 404/410, расшифровка пуша по RFC 8291 в тесте, отсутствие плейнтекста
в нагрузке, кэш оболочки без `/api/*`, отказ ходить на непубличные адреса. Осталось
то, что требует рук и телефона:
- [ ] **iPhone, установленное на «Домой» приложение**: пуш приходит при закрытом
приложении, нажатие открывает нужный чат.
- [ ] **Android Chrome, закрытое приложение**: то же самое.
- [ ] **Клик по системному уведомлению** в обоих случаях: в уже открытое окно
(фокус и переход) и при закрытом приложении (`/#/dm/<nick>`, `/#/room/<id>`).
- [ ] **Повторные сообщения до открытия**: второе и третье пуша не порождают.
- [ ] **iOS вне PWA**: баннер установки над списком чатов, текст, крестик и то,
что он больше не появляется; в настройках «уведомления» — текст про установку
вместо кнопки.
- [ ] **`beforeinstallprompt`** в обычном Chrome: раздел «установить приложение»
появляется, после нажатия исчезает целиком.
- [ ] **Системный запрос разрешения** после первого отправленного сообщения:
headless-Chrome отвечает `denied` сам, живой диалог не проверялся.
- [ ] **Прогон сценариев «готово, когда»** этапов 15 в Safari (iOS и десктоп)
и Firefox — автоматика гоняла только Chrome.
## Этап 5 — история
- Экспорт и импорт `.bare` по `crypto.md` и `storage.md`; идемпотентность; «архив создан другим аккаунтом».
+3 -3
View File
@@ -68,9 +68,9 @@ WrappedKey { to: nick, iv: string, ct: string }
`DELETE /api/devices/{id}``204`. Удаляет очередь, подписку и сессии, привязанные к устройству. Подключённому по SSE устройству поток закрывается; его следующий запрос получает `401`.
`PUT /api/devices/{id}/push {subscription}``204`. `subscription` — объект `PushSubscription.toJSON()`. Сбрасывает `push_pending`.
`PUT /api/devices/{id}/push {subscription}``204`. `subscription` — объект `PushSubscription.toJSON()`: `endpoint` — абсолютный `https`-адрес до 2 КиБ на публичный адрес (литеральные loopback, link-local и приватные адреса — `400 invalid`, ADR-047), `keys.p256dh` — точка кривой P-256 в 65 байтах base64url, `keys.auth` — 16 байт base64url; прочие поля, включая `expirationTime`, сервер не хранит. Сбрасывает `push_pending`. Устройство в пути, как и `X-Device`, обязано принадлежать пользователю сессии.
`DELETE /api/devices/{id}/push``204`.
`DELETE /api/devices/{id}/push``204`; подписки не было — тот же `204`, чужое устройство — `403 unknown_device`.
## Контакты
@@ -86,7 +86,7 @@ WrappedKey { to: nick, iv: string, ct: string }
Проверки по порядку: формат полей (`400 invalid`); время ULID в пределах ±5 минут от серверного (`400 clock_skew`); для `dm` — существование ника (`404 unknown_user`), не себе (`400 self`); для `room` — членство (`403 not_member`), `keyId` среди ключей комнаты (`400 unknown_key`); лимит (`429`).
Сервер в одной транзакции: для `dm` создаёт недостающие строки `contacts` в обе стороны; вычисляет получателей (оба ника или все участники); для каждого устройства получателей, кроме `X-Device`, вставляет строку в `queue`; после коммита отдаёт конверт подключённым устройствам и шлёт пуши по правилам ADR-023.
Сервер в одной транзакции: для `dm` создаёт недостающие строки `contacts` в обе стороны; вычисляет получателей (оба ника или все участники); для каждого устройства получателей, кроме `X-Device`, вставляет строку в `queue`; после коммита отдаёт конверт подключённым устройствам и шлёт пуши устройствам получателей по правилам ADR-023 и ADR-045.
`POST /api/ack {ids: string[]}``204`. До 500 идентификаторов. Удаляет из `queue` строки устройства `X-Device`.
+1 -1
View File
@@ -115,7 +115,7 @@ meta key: string → value
deviceId, nick, publicKey (JWK), fingerprint,
privateKey (CryptoKey ECDH, non-extractable),
accountSecret (CryptoKey HKDF, non-extractable),
notificationsAsked (bool), installBannerDismissed (bool)
notificationsAsked (bool), notificationsOff (bool), installBannerDismissed (bool)
chats key: id // "dm:<peer>" | "room:<roomId>"
{id, type: "dm"|"room", title, peer?, roomId?, owner?, members?: nick[],
+2
View File
@@ -41,3 +41,5 @@
**Вышедший участник до rekey.** После выхода участника сервер перестаёт доставлять ему сообщения, а новый ключ комнаты создаёт владелец при следующем появлении. В промежутке вышедший участник знает действующий ключ; прочитать новые сообщения он может только в сговоре с сервером.
**Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша.
**Сервер сам ходит по адресу, который выбрал браузер получателя.** Адрес push-сервиса приходит в подписке от клиента, и на каждое сообщение сервер открывает к нему исходящее соединение. Белого списка вендоров нет и не будет: адреса вендоров меняются, а подписку выдаёт браузер. Ограничения — ADR-047: только `https`, только публичные адреса (проверяется уже разрешённый адрес соединения), без следования за редиректами, адрес подписки в журнал не пишется. Остаток риска принят: аутентифицированный пользователь может заставить сервер обратиться к произвольному публичному адресу — один POST на сообщение, в пределах общих лимитов.
+7 -3
View File
@@ -57,8 +57,8 @@
## Настройки (`#/settings`)
- «ты: @nick», свой отпечаток.
- «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка включить/выключить. На iOS вне PWA — текст про установку.
- «установить приложение»: кнопка, если есть `beforeinstallprompt`; на iOS — инструкция «поделиться → на экран «домой»».
- «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка «включить» или «выключить». `запрещены в браузере` — разрешение отклонено или уведомлений в браузере нет вовсе; кнопки в этом состоянии нет (ADR-046). На iOS вне PWA — состояние `выключены` и вместо кнопки текст про установку, тот же, что в баннере.
- «установить приложение»: кнопка «установить», если есть `beforeinstallprompt`; на iOS вне PWA — инструкция «поделиться → на экран «домой»». Устанавливать нечего — раздела нет.
- «устройства»: список `id` (первые 8 символов), дата, «это устройство», «удалить».
- «история»: «занято N МБ»; «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён».
- «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». Ответ — «пароль изменён».
@@ -67,12 +67,16 @@
## Баннер установки (iOS)
Показывается при `iPhone|iPad` и `navigator.standalone !== true`, над списком чатов: «уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»». Крестик — `installBannerDismissed`, повтор не показывается.
Показывается при `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`.