Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений
Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство, очередь per-device с фан-аутом без эха отправителю, POST /api/messages с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди, ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении, лимит 30 сообщений в минуту. Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD, sync.js как единственный писатель в IndexedDB, ACK строго после записи, список чатов, экран чата по эталону, разделители дат и «новые», pending и failed с повтором, полоса «нет соединения». ADR-033: у неотправленного есть текст отказа — clock_skew стало видно. ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть до стирания своего присланного, чего «удалить у всех не существует» не допускает. ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel): две вкладки отбирали поток друг у друга и оставались без живой доставки. ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, — иначе потерянный ответ давал у собеседника два сообщения вместо одного. Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка, копия на второе устройство, очередь офлайн-устройству, ACK, подмена from игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста в базе и WAL ноль вхождений. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
+5
-3
@@ -114,6 +114,7 @@ chats key: id // "dm:<peer>" | "room:<roomId>"
|
||||
messages key: id (ULID)
|
||||
index "chat": [chatId, id]
|
||||
{id, chatId, from, text: string|null, ts, status: "pending"|"sent"|"failed",
|
||||
error?: string, // текст отказа у failed
|
||||
undecryptable?: "unknown_key"|"bad_aead"|"key_changed", raw?: Envelope}
|
||||
|
||||
roomKeys key: [roomId, keyId]
|
||||
@@ -126,8 +127,9 @@ peers key: nick
|
||||
|
||||
Правила:
|
||||
|
||||
- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`. Повтор доставки — `put` с тем же `id`, без дублей.
|
||||
- Исходящее пишется со `status: "pending"` и локальным `id`, затем `POST /api/messages`; `202` → `sent`, сетевая ошибка → остаётся `pending` и повторяется при следующем подключении; `4xx` → `failed` с текстом ошибки. При каждой попытке отправки `pending` получает новый ULID (старая запись удаляется, новая пишется): сообщение ещё не покидало устройство, а его время должно совпадать с временем фактической отправки — иначе после долгого офлайна сервер ответит `clock_skew`.
|
||||
- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`.
|
||||
- Входящее сообщение с уже известным `id` игнорируется целиком: ни записи, ни счётчика непрочитанных (ADR-034). Повтор доставки не даёт ни дубля в ленте, ни второго непрочитанного; `id` открыт в конверте, и перезапись отдала бы собеседнику чужую строку истории. Перезапись по `id` остаётся у исходящего: переход `pending → sent/failed`.
|
||||
- Исходящее пишется со `status: "pending"` и локальным `id`, затем `POST /api/messages`; `202` → `sent`, сетевая ошибка и `500` → остаётся `pending` и повторяется при следующем подключении; прочие `4xx` → `failed` с текстом отказа в поле `error` (ADR-033). Повтор отправки идёт с прежним ULID, пока время в нём разошлось с текущим меньше чем на четыре минуты: ответ на `POST` мог потеряться уже после того, как сервер сообщение принял, а повтор с тем же `id` получатель игнорирует (ADR-034). Идентификатор старше запаса заменяется свежим — старая запись удаляется, новая пишется: время в `id` должно совпадать с временем фактической отправки, иначе после долгого офлайна сервер ответит `clock_skew`. `clock_skew` на переиспользованном `id` отменяет переиспользование: попытка идёт второй раз со свежим `id`, ровно один раз; такой же отказ на свежем `id` — `failed` с текстом про часы (ADR-036).
|
||||
- `unread` и `lastReadId` — локальные, на сервер не уходят.
|
||||
- Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`.
|
||||
- Пагинация — курсор по индексу `chat` назад от последнего, по 50.
|
||||
@@ -135,4 +137,4 @@ peers key: nick
|
||||
|
||||
## Экспорт `.bare`
|
||||
|
||||
Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare-<nick>-<YYYY-MM-DD>.bare`.
|
||||
Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw` и `error`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare-<nick>-<YYYY-MM-DD>.bare`.
|
||||
|
||||
Reference in New Issue
Block a user