Убрали старый sync и обновили bundle

Что сделано: вычистили неиспользуемый user-settings sync/DM sync хвост, сохранили сборку, обновили bundle.sh так, чтобы gradle-wrapper.jar всегда попадал в архив.

Проверено: compileJava и deploy на t2 (server + UI).
Не проверяли: полные интеграционные сценарии, ручные UI-флоу и продовый деплой.
This commit is contained in:
AidarKC
2026-08-28 14:51:47 +04:00
parent ef1fd9f579
commit 8a0275d962
56 changed files with 747 additions and 2428 deletions
+2 -1
View File
@@ -6,7 +6,8 @@
- `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
- `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`состояния доставки, retry-воркер, репликация между двумя access-серверами и UI-статусы
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`доставка на
единственный access-сервер, retry-воркер и UI-статусы
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
Исторический устаревший документ сохранён отдельно:
@@ -1,138 +1,84 @@
# Доставка и синхронизация личных сообщений
# Доставка личных сообщений
## 1. Главный принцип
## 1. Инвариант
DM считается доставленным, когда signed-входящую копию сохранил хотя бы один актуальный access-сервер получателя. Доставка на оба сервера не требуется: сервер получателя самостоятельно синхронизирует входящее сообщение со своим вторым сервером.
У каждого пользователя действует ровно один сервер доступа: первый элемент
access_servers[0] в его Solana PDA.
Клиентский `status=200` от `SendMessagePair` означает, что собственный сервер сохранил пару. Это отдельный факт от доставки получателю.
Остальные элементы старой PDA, если они существуют, не участвуют в
маршрутизации и не являются fallback. UI позволяет только заменить текущий
сервер другим.
Формат подписанного контейнера `SHiNE_DM` не меняется. Состояние доставки и флаг синхронизации — изменяемые серверные метаданные.
Межсерверной репликации личной переписки между серверами одного пользователя
нет. При смене сервера история автоматически не переносится.
## 2. Идентификаторы
## 2. Подписанные копии
- `baseKey` связывает входящую и исходящую копии одного логического сообщения;
- `incomingKey` идентифицирует копию получателя;
- `outgoingKey`/`messageKey` идентифицирует копию отправителя и используется для проверки доставки;
- дополнительный публичный `eventId` для доставки не создаётся;
- `syncId` используется только внутри `DmSyncBatch` как ACK конкретной ревизии, потому что редактирование сохраняет прежний `messageKey`.
Формат SHiNE_DM не меняется. Для сообщения клиент создаёт:
## 3. Состояния доставки
- входящую копию для получателя;
- исходящую копию для отправителя.
| Состояние | Смысл | Повторные попытки |
|---|---|---|
| `accepted` | пара сохранена сервером отправителя, но ни один сервер получателя ещё не подтвердил запись | да |
| `delivered` | хотя бы один сервер получателя подтвердил запись | нет |
| `failed` | последняя попытка через час завершилась без доставки | никогда |
Сервер отправителя принимает пару через SendMessagePair, атомарно сохраняет её
и создаёт изменяемое состояние доставки.
Состояние `delivered` терминальное. Сервер отправителя не пытается отдельно добиться второго ACK получателя.
## 3. Основной маршрут
## 4. Обычная отправка
1. UI отправителя вызывает SendMessagePair на своём access-сервере.
2. Сервер сохраняет исходящую копию отправителя и входящую копию, необходимую
для доставки и повторных попыток.
3. Сервер читает единственный маршрут получателя из
user_access_servers_current.
4. Если это другой физический сервер, он вызывает внутреннюю операцию
ReceiveIncomingMessage и передаёт только входящую копию.
5. Сервер получателя проверяет пользовательскую подпись, идемпотентно сохраняет
входящую копию и уведомляет активные сессии.
6. Успешное сохранение означает delivered.
1. Клиент отправляет `SendMessagePair` на один свой access-сервер.
2. Сервер проверяет формат, пользователей, подписи и согласованность пары.
3. Сервер атомарно сохраняет входящую и исходящую копии.
4. В том же request-процессе сервер читает до двух актуальных маршрутов получателя.
5. Оба вызова `ReceiveIncomingMessage` запускаются параллельно.
6. Если хотя бы один вызов успешен, устанавливается `delivered`.
7. После этой попытки сервер передаёт полную пару своему второму access-серверу старой операцией `ReceiveOutcomingMessage`.
8. `SendMessagePair` возвращает существующие ключи и единственное новое поле `deliveryState`.
ReceiveIncomingMessage не является синхронизацией двух серверов одного
пользователя. Это основная доставка между серверами разных пользователей.
Успешный повтор уже сохранённого signed-блока считается ACK. Все операции должны быть идемпотентными.
## 4. Повторные попытки
## 5. Расписание повторов
Попытки привязаны ко времени первоначального принятия:
Воркер запускается каждые 5 секунд и выбирает только due-строки по индексу. Он не сканирует всю таблицу сообщений.
- сразу;
- через 30 секунд;
- через 5 минут;
- через 25 минут;
- через 1 час.
Попытки привязаны к времени первоначального принятия:
Перед каждой попыткой заново читается первый актуальный access-сервер
получателя. После финальной неудачи состояние становится failed.
| Номер | Время от старта | Сначала спросить второй сервер отправителя |
|---:|---:|---|
| 1 | сразу | нет |
| 2 | 30 секунд | нет |
| 3 | 5 минут | да |
| 4 | 25 минут | да |
| 5 | 1 час | да |
Второй сервер отправителя не опрашивается, полная пара ему не передаётся.
Из-за шага воркера повтор может начаться на несколько секунд позже указанного времени. Первая попытка выполняется немедленно и от воркера не зависит.
## 5. Состояния
На 5-й, 25-й и 60-й минутах сервер сначала вызывает у второго сервера отправителя:
- accepted — пара сохранена сервером отправителя;
- delivered — входящая копия сохранена единственным сервером получателя;
- failed — финальная попытка завершилась неудачей;
- read-receipt — отдельное подписанное DM-событие.
```json
{
"op": "GetDmDeliveryStatus",
"payload": {
"messageKey": "alice|bob|1774700000123|123456789|2"
}
}
```
Подписанные DM-контейнеры остаются криптографическими фактами. Сетевое
deliveryState остаётся изменяемой серверной метаданной.
Ответ:
## 6. Удаления
```json
{
"messageKey": "alice|bob|1774700000123|123456789|2",
"known": true,
"delivered": true
}
```
DeleteMessage и DeleteConversation доставляют подписанный tombstone только на
единственные access-серверы обеих сторон. Повторное применение идемпотентно.
Операция read-only. Если peer отвечает `delivered=true`, локальный сервер устанавливает `delivered` и не обращается к серверам получателя. Ошибка или отсутствие операции на старом peer не блокирует собственную попытку.
## 7. Удалённая репликация
Перед каждой попыткой маршруты получателя заново читаются из `user_access_servers_current`. Изменение серверов учитывается только пока сообщение находится в `accepted`. После `delivered` или `failed` состояние больше не открывается.
После перехода на один сервер доступа удалены:
Если последняя проверка и попытка через час не дали ACK, устанавливается `failed`, `next_attempt_at_ms` очищается и сообщение больше никогда автоматически не отправляется.
- ReceiveOutcomingMessage;
- DmSyncBatch;
- GetDmDeliveryStatus;
- dm_sync_outbox;
- dm_sync_peer_state;
- ACK-флаги synced;
- периодический DM-backfill между access-серверами.
## 6. Старая межсерверная доставка
Форматы существующих операций не расширяются данными о результате доставки:
- `ReceiveOutcomingMessage` получает прежнюю пару `incomingBlobB64` + `outgoingBlobB64` и необязательный `sourceServerLogin`;
- `ReceiveIncomingMessage` получает один `incomingBlobB64` и необязательный `sourceServerLogin`.
Второй сервер отправителя после получения пары создаёт собственное локальное состояние доставки и самостоятельно пробует маршруты получателя. Серверы обмениваются результатом только через read-only `GetDmDeliveryStatus`.
## 7. Догоняющая синхронизация двух серверов пользователя
Для каждого владельца сервер ведёт outbox с флагом `synced`:
- исходящая пара отправителя — один элемент с двумя blob в порядке incoming/outgoing;
- входящая копия получателя — один элемент с одним blob;
- read-receipt и tombstone применяются теми же проверенными обработчиками.
`DmSyncBatch` возвращает только элементы с `synced=false`. Получатель проверяет signed-блоки, сохраняет их идемпотентно и в следующем запросе подтверждает `syncId` через `ackSyncIds`. Источник ставит `synced=true` только после ACK.
При обрыве соединения неподтверждённый элемент остаётся `synced=false` и безопасно приходит повторно. Элемент, полученный от peer, локально сразу считается синхронизированным, чтобы не образовалась петля.
`sourceServerLogin` является единственным признаком межсерверного вызова для этих операций: если поле пустое, запрос считается клиентским и его запись не должна сразу переводиться в `synced=true`.
Синхронизация настроек и DM проходит последовательно через один WS-сеанс: сначала настройки, затем все страницы DM. Если второй сервер был выключен, после включения он сам догружает пропущенные элементы.
Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги. После сброса история повторно передаётся как синхронизация, но старые сообщения получателю заново не отправляются: delivery-состояние создаётся с учётом их возраста и не открывает завершённую часовую очередь.
## 8. UI
| Вид | Значение |
|---|---|
| одна серая галочка | собственный сервер принял сообщение (`accepted`) |
| одна светлая галочка | хотя бы один сервер получателя принял сообщение (`delivered`) |
| две светлые галочки | пришёл существующий read-receipt |
| красный `!` и «Сообщение не доставлено» | окончательное состояние `failed` |
Read-receipt имеет приоритет над delivery-индикатором: если сообщение прочитано, оно заведомо было доставлено.
Сервер сообщает поздние изменения через `DmDeliveryStateChanged` с полями `outgoingKey`, `baseKey`, `deliveryState`. При повторном открытии чата то же состояние приходит в `GetDirectMessages`.
## 9. Нагрузка и отказоустойчивость
- воркер выбирает только due-записи по частичному индексу;
- терминальные строки не попадают в рабочую выборку;
- два маршрута первой попытки выполняются параллельно;
- ограниченный пул потоков и очередь защищают сервер при всплеске отправок;
- сетевой lease и сравнение версии строки предотвращают одновременную обработку одной задачи несколькими worker-потоками;
- повторная запись одного signed-блока безопасна;
- отсутствие второго сервера отправителя не мешает собственной доставке;
- отсутствие обоих серверов получателя завершает задачу через час.
## 10. Граница доверия
Межсерверная авторизация пока отложена. `sourceServerLogin` временно принимается на доверии, но каждый контейнер `SHiNE_DM` всё равно проходит проверку пользовательской подписи. `GetDmDeliveryStatus` сообщает только факт локального delivery-state и не изменяет данные.
Блокчейн-синхронизация через server-PDA sync_servers остаётся отдельным
механизмом и продолжает работать.
+138 -635
View File
@@ -1,696 +1,199 @@
# Личные сообщения (DM) — спецификация v1
# Протокол личных сообщений SHiNE DM v1
## Статус документа
## 1. Статус документа
Этот файл — актуальная логическая спецификация DM-протокола SHiNE v1.
Это актуальная спецификация логики личных сообщений. Точный байтовый формат
контейнера описан в Формат_DM_v1.md, а расписание доставки — в
Доставка_и_синхронизация_DM.md.
Важно:
Формат SHiNE_DM не изменён переходом на один access-сервер.
- это актуальная реализованная спецификация;
- код и документы по DM должны изменяться синхронно;
- при любых будущих изменениях DM сначала обновляется эта спецификация и соседний документ формата.
## 2. Инварианты
Документ фиксирует:
- каждый DM-контейнер подписан clientKey автора;
- сервер проверяет формат, пользователей и подпись до сохранения;
- повторная доставка одной ревизии идемпотентна;
- более старая ревизия не заменяет новую;
- у каждого пользователя действует только access_servers[0];
- дополнительные элементы старой PDA игнорируются без fallback;
- DM и настройки не реплицируются между access-серверами одного пользователя;
- sync_servers серверного PDA используются только для синхронизации
пользовательских блокчейнов.
- модель DM и смысл типов сообщений;
- правила редактирования, перешифровки и удаления;
- роли существующих API-методов;
- правила межсерверной маршрутизации через `access_servers`;
- общее поведение сервера и БД.
## 3. Типы DM
Точный байтовый формат контейнера вынесен отдельно:
- type=1 — входящая копия сообщения для получателя;
- type=2 — исходящая копия сообщения для отправителя;
- type=3 — входящий read-receipt;
- type=4 — исходящая копия read-receipt;
- type=5 — удаление своего исходящего сообщения;
- type=6 — удаление входящего сообщения получателем;
- type=7 — удаление переписки отправителем;
- type=8 — удаление переписки получателем.
- `docs/Personal_Messages/Формат_DM_v1.md`
Входящая и исходящая копии являются частями криптографического формата, а не
серверной репликацией. Они остаются даже при единственном access-сервере.
Формат клиентских технических вставок внутри plaintext вынесен отдельно:
## 4. Создание сообщения
- `docs/Personal_Messages/Технические_вставки_DM_v1.md`
Клиент:
Изменяемое состояние доставки, retry-расписание и репликация между двумя access-серверами подробно описаны отдельно:
1. получает актуальные clientKey отправителя и получателя;
2. шифрует входящую копию на ключ получателя;
3. шифрует исходящую копию на ключ отправителя;
4. создаёт два связанных контейнера с общим baseKey;
5. подписывает оба контейнера clientKey отправителя;
6. вызывает SendMessagePair на своём access-сервере.
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`
Сервер отправителя атомарно сохраняет пару. Исходящая копия доступна отправителю,
а входящая хранится для доставки и повторных попыток.
Устаревшая предыдущая версия сохранена отдельно:
## 5. Единственный сервер доступа
- `docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md`
Пользовательская PDA по-прежнему содержит массив access_servers для
совместимости формата. Действующим считается только первый элемент массива.
## 1. Основная модель
Routing-проекция user_access_servers_current содержит не более одной строки на
пользователя и строится строго из элемента с ord=1.
Личное сообщение в SHiNE хранится как две логические копии одного сообщения:
Если первый элемент отсутствует, указывает не на server-PDA или server_address
пуст, маршрут считается отсутствующим. Второй элемент автоматически не
используется.
- `type=1` — входящее сообщение для получателя;
- `type=2` — исходящая копия сообщения для отправителя.
UI позволяет:
Обе копии имеют общий логический идентификатор:
- увидеть текущий сервер;
- заменить его другим сервером;
- записать только один логин в access_servers.
- `baseKey = fromLogin|toLogin|timeMs|nonce`
Смена сервера не переносит сообщения и настройки. Новый сервер начинает с
пустой локальной истории.
Идентификатор конкретной копии:
## 6. Доставка сообщения
- `messageKey = baseKey|messageType`
### 6.1. SendMessagePair
Поля `timeMs` и `nonce` не меняются никогда.
Официальный UI отправляет пару только через SendMessagePair.
`nonce` обязателен, потому что одного `timeMs` недостаточно для гарантированной уникальности.
Сервер:
## 2. Ключи и шифрование
1. проверяет оба контейнера и их связь;
2. сохраняет пару;
3. создаёт delivery-state;
4. до ответа UI выполняет первую попытку доставки;
5. возвращает baseKey, incomingKey, outgoingKey и deliveryState.
### 2.1. Общий принцип
ReceiveOutcomingMessage удалён.
У пользователя в PDA публикуется один публичный `clientKey` в формате `Ed25519`.
### 6.2. ReceiveIncomingMessage
Из того же ключевого материала стандартным способом выводится ключ `X25519` для E2EE-шифрования DM.
Эта операция остаётся внутренней server-to-server операцией. Она нужна, когда
отправитель и получатель используют разные физические серверы.
Отдельный публичный `dmEncKey` в текущей архитектуре не хранится.
Сервер отправителя передаёт только incomingBlobB64 на единственный сервер
получателя. Принимающий сервер:
Подробности стандартного преобразования:
- разрешает получателя через свою routing-проекцию;
- заново проверяет пользовательскую подпись;
- применяет входящую копию идемпотентно;
- обновляет диалог и realtime-сессии;
- возвращает успешный ответ только после сохранения.
- `docs/Протоколы/Преобразование_ED25519_в_X25519.md`
ReceiveIncomingMessage не реплицирует историю на второй сервер пользователя.
### 2.2. Правило шифрования копий
## 7. Повторная доставка
- `type=1` шифруется на ключ получателя;
- `type=2` шифруется на ключ отправителя.
Состояния:
Следствия:
- accepted — пара сохранена сервером отправителя;
- delivered — incoming-копия сохранена сервером получателя;
- failed — закончилась финальная попытка;
- read — подтверждается отдельным type=3/4.
- входящую копию может прочитать только получатель;
- исходящую копию может прочитать только отправитель;
- сервер не должен расшифровывать DM;
- ciphertext у `type=1` и `type=2` обычно разный и не обязан совпадать побайтно.
Попытки выполняются сразу, через 30 секунд, 5 минут, 25 минут и 1 час.
Перед каждой попыткой первый маршрут получателя читается заново.
### 2.3. Что именно знает сервер о `body`
Peer отправителя не получает полную пару и не опрашивается о состоянии.
DmSyncBatch и GetDmDeliveryStatus удалены.
Для контентных DM (`type=1/2`) сервер в v1 должен рассматривать `body` как opaque blob:
## 8. Чтение и realtime
- сервер проверяет внешний контейнер `SHiNE_DM`;
- сервер проверяет подпись;
- сервер проверяет служебные поля envelope;
- сервер проверяет только то, что `bodyLen > 0` и не превышает допустимый лимит;
- сервер не обязан разбирать внутренний crypto-контейнер `body`;
- сервер не обязан знать конкретный алгоритм шифрования `body`.
GetDirectMessages возвращает историю страницами. UI использует стабильные
messageKey/baseKey для дедупликации и отображает более новую ревизию.
Это позволяет альтернативным клиентам использовать совместимый внешний DM-envelope при собственном формате зашифрованного payload внутри `body`.
dm_dialog_state хранит:
Официальный UI SHiNE при приёме такого сообщения:
- последнюю контентную копию;
- время и ключ последнего сообщения;
- unreadCount;
- watermark прочтения.
- пытается расшифровать знакомый формат `EncryptedBody_v1_0`;
- показывает текст сообщения при успешной расшифровке;
- если внутренний формат не поддерживается, показывает `Формат сообщения не поддерживается`;
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
WebSocket push ускоряет отображение, но после переподключения клиент запрашивает
историю у своего единственного access-сервера.
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<S:...>` в начале текста. Legacy-вставки `<SHiNE:...>` также продолжают поддерживаться при чтении.
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
## 9. Подтверждения прочтения
### 2.4. Источник истины по пользователю
Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же
маршрут SendMessagePair → ReceiveIncomingMessage.
Для DM-проверки сервер использует:
Сервер хранит наибольший известный watermark, поэтому переставленные или
повторные receipts не уменьшают состояние прочтения.
- локальную runtime-проекцию пользователей как кэш;
- Solana PDA как источник истины по `clientKey` и `access_servers`.
## 10. Редактирование
Если нужного пользователя нет локально, сервер обязан попытаться lazy-import из Solana PDA:
Редактирование сохраняет логический messageKey исходной копии и увеличивает
revisionTimeMs. Сервер принимает только более новую валидную ревизию.
- в `GetUser`;
- при серверной верификации DM-подписи;
- перед межсерверной маршрутизацией через `access_servers`.
Если доставка исходного сообщения ещё не завершилась, следующая попытка
использует актуальную сохранённую incoming-копию.
## 3. Типы сообщений
## 11. Удаление
В версии v1 используются следующие `messageType`:
DeleteMessage принимает type=5/6. DeleteConversation принимает type=7/8.
- `1` — входящее сообщение;
- `2` — исходящая копия сообщения;
- `3` — входящее подтверждение прочтения;
- `4` — исходящая копия подтверждения прочтения;
- `5` — сообщение удалено отправителем;
- `6` — сообщение удалено получателем;
- `7` — переписка удалена отправителем;
- `8` — переписка удалена получателем.
Удаление является подписанным tombstone. Оно:
Типы `1/2` — это обычные контентные DM, в том числе их последующие ревизии.
- проверяется как обычный DM-контейнер;
- применяется идемпотентно;
- удаляет или блокирует соответствующий локальный контент;
- доставляется только на единственные access-серверы обеих сторон.
Типы `3/4` — служебные подтверждения прочтения.
Если сервер уже знает более новый tombstone, старая ревизия контента не
восстанавливает удалённую историю.
Типы `5/6/7/8` — служебные события удаления без зашифрованного тела.
## 12. Хранение
## 4. Базовые операции
Основные таблицы:
### 4.1. Создание нового сообщения
- signed_messages — подписанные DM-контейнеры;
- dm_dialog_state — материализованный список диалогов;
- dm_delivery_state — изменяемое состояние сетевой доставки;
- user_access_servers_current — единственный действующий маршрут пользователя.
Новое сообщение отправляется парой блоков через:
Удалены:
- `SendMessagePair`
- `ReceiveOutcomingMessage` как алиас
- dm_sync_outbox;
- dm_sync_peer_state;
- user_settings_sync_peer_state;
- synced-флаги репликации.
Пару создаёт автор сообщения.
## 13. Границы текущей версии
Пара содержит:
- межсерверная авторизация ServerHello пока не подписана;
- sourceServerLogin не является криптографическим доказательством сервера;
- пользовательская подпись каждого SHiNE_DM проверяется независимо от
транспорта;
- автоматической миграции истории при смене access-сервера нет;
- резервного fallback-сервера нет.
- одну входящую копию `type=1`;
- одну исходящую копию `type=2`;
- одинаковые `fromLogin`, `toLogin`, `timeMs`, `nonce`;
- одинаковый логический plaintext;
- разные ciphertext для разных владельцев копий.
## 14. Сохранённые механизмы
### 4.2. Редактирование сообщения
Переход на один access-сервер не затрагивает:
Редактирование общего текста делает автор сообщения.
При редактировании:
- `baseKey` остаётся тем же;
- `messageType` остаётся тем же;
- `revisionTimeMs` увеличивается;
- автор заново шифрует обе копии и снова отправляет пару через `SendMessagePair`.
Если `revisionTimeMs = 0`, это исходная версия.
Если `revisionTimeMs > 0`, это новая ревизия сообщения.
### 4.3. Перешифровка
Перешифровка нужна для будущего сценария полной или частичной перепаковки истории сообщений при сохранении тех же логических идентификаторов сообщений.
При перешифровке:
- `baseKey` остаётся прежним;
- `messageKey` остаётся прежним;
- plaintext может остаться тем же;
- ciphertext меняется;
- `reencryptedAtMs` получает ненулевое значение;
- `revisionTimeMs` может остаться прежним, в том числе нулевым, если менялось только шифрование без редактирования текста;
- сервер при выборе актуальной версии обязан учитывать обе метки времени.
Если сообщение уже удалено одним из типов `5/6`, его перешифровывать больше нельзя.
Если переписка уже удалена типом `7/8`, более старые сообщения этой пары тоже перешифровывать нельзя.
### 4.4. Приём входящей копии
Для server-to-server доставки одной входящей копии используется:
- `ReceiveIncomingMessage`
Этот метод должен принимать:
- новые входящие сообщения;
- входящие обновления/редактирования;
- будущие входящие перешифрованные копии.
## 5. Удаление одного сообщения
### 5.1. Общая логика
Удаление одного сообщения в v1 всегда глобальное у обеих сторон.
Локального удаления только у себя в этой версии протокола не вводится.
### 5.2. Типы удаления
- `type=5` — сообщение удалено отправителем;
- `type=6` — сообщение удалено получателем.
Удаляющее сообщение:
- не содержит зашифрованного тела;
- хранится в БД как tombstone;
- терминально закрывает это сообщение;
- не даёт больше принять никакую более позднюю содержательную версию этого же `messageKey`.
### 5.3. Метод удаления
Для удаления одного сообщения нужен отдельный метод, условно:
- `DeleteMessage`
Если сервер впервые получает валидное удаляющее сообщение:
- сохраняет tombstone в БД;
- удаляет или замещает прежнюю версию сообщения tombstone-записью;
- распространяет это же удаление на серверы доступа обеих сторон;
- не принимает в будущем попытки "оживить" это сообщение.
## 6. Удаление всей переписки
### 6.1. Отдельное служебное сообщение
Удаление всей переписки между двумя пользователями — это отдельное служебное сообщение без ciphertext.
Типы:
- `type=7` — переписка удалена отправителем;
- `type=8` — переписка удалена получателем.
### 6.2. Граница удаления
Границей удаления считается:
- `timeMs` самого служебного сообщения удаления переписки
Отдельное `deleteBeforeTimeMs` в этой версии не вводится.
### 6.3. Правило применения
Если сервер получает такое сообщение впервые:
- сохраняет его в БД как tombstone переписки;
- удаляет из БД все сообщения этой пары пользователей с `timeMs` меньше времени служебного сообщения;
- больше не принимает новые или повторно доставленные сообщения с `timeMs` раньше этой границы;
- распространяет это же сообщение удаления переписки на серверы доступа обеих сторон.
### 6.4. Поведение при позднем старом сообщении
Если после удаления переписки приходит старое сообщение, у которого:
- `timeMs < deleteConversationMessage.timeMs`
то сервер:
- не принимает это сообщение;
- распространяет уже известный tombstone удаления переписки на серверы доступа обеих сторон;
- ожидает, что вторая сторона обработает его как обычное уже известное удаление переписки.
### 6.5. Будущие новые сообщения
После удаления всей переписки новые сообщения между этими же пользователями разрешены, если:
- их `timeMs` больше времени служебного сообщения удаления переписки.
## 7. Серверы и маршрутизация
### 7.1. `access_servers`
Для обычного пользователя список серверов доставки и доступа задаётся через:
- `access_servers`
Это:
- сервера доступа пользователя;
- сервера relay;
- сервера, через которые пользователь получает личные сообщения и другие пользовательские операции.
### 7.2. `sync_servers`
`sync_servers` относятся не к обычной пользовательской маршрутизации DM, а к server-to-server партнёрству серверного узла.
Они используются для:
- синхронизации серверных данных;
- синхронизации пользовательских блокчейнов SHiNE;
- общей межсерверной координации.
`sync_servers` не являются списком пользовательских серверов доставки DM.
### 7.3. Два сервера у отправителя и получателя
Актуальный runtime исходит из ограничения:
- у одного пользователя не более двух `access_servers`;
- следовательно, у локального сервера есть не более одного peer для репликации данных пользователя.
Протокол поддерживает ситуацию, когда:
- у отправителя один или два `access_servers`;
- у получателя один или два `access_servers`;
- часть серверов у сторон совпадает;
- часть серверов уникальна.
Из этого следуют требования:
- все DM-операции должны быть идемпотентны;
- повторное получение уже известного события не должно ломать состояние;
- дубль tombstone должен быть безопасен;
- сервер не должен "оживлять" более старую версию сообщения после уже принятого tombstone.
## 8. Методы и их роли
### 8.1. Существующие методы, которые сохраняются
- `SendMessagePair`
- `ReceiveOutcomingMessage`
- `ReceiveIncomingMessage`
- `DeleteMessage`
- `DeleteConversation`
Их роли в v1:
- `SendMessagePair` — новая пара сообщений и редактирование старой пары автором;
- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и входящего read-receipt;
- `ReceiveOutcomingMessage` — алиас `SendMessagePair`.
### 8.2. Межсерверная синхронизация
- `ReceiveOutcomingMessage` — прежняя полная пара второго access-сервера отправителя;
- `ReceiveIncomingMessage` — прежняя одиночная входящая копия;
- `DmSyncBatch` — pull событий с `synced=false` и ACK сохранённой предыдущей страницы;
- `GetDmDeliveryStatus` — единственная новая read-only проверка доставки по существующему `messageKey`.
Delivery-state между серверами не передаётся и не объединяется.
### 8.3. Серверный слой диалогов
Помимо хранения самих DM-сообщений сервер поддерживает материализованный слой состояния диалогов:
- отдельная запись на пару `owner_login` + `peer_login`;
- `relation_flag` со значениями `close_friend`, `contact`, `none`;
- `last_message_blob_b64` как последний контентный signed DM block в base64;
- `last_message_time_ms`;
- `unread_count`;
- `last_read_receipt_time_ms` как watermark последнего подтверждения прочтения.
Ключевые правила:
- `close_friend` всегда имеет приоритет над `contact`;
- если `read receipt` приходит не по порядку, сервер хранит наибольший watermark и не откатывает состояние назад;
- `unread_count` пересчитывается сервером по сообщениям диалога с учётом watermark и `read_at_ms`;
- старые исторические данные восстанавливаются из `signed_messages` при инициализации/миграции;
- UI не должен собирать inbox только из локального кеша, когда ему доступен серверный список диалогов.
## 9. Правила валидации и применения
### 9.1. Общее правило по ревизиям
Для одного и того же контентного сообщения сервер сравнивает пару:
- `revisionTimeMs`;
- `reencryptedAtMs`.
Правило:
- если новый `revisionTimeMs` больше сохранённого, сообщение применяется;
- если `revisionTimeMs` равен, но новый `reencryptedAtMs` больше сохранённого, сообщение применяется;
- если обе величины равны, сообщение не применяется;
- если новая пара (`revisionTimeMs`, `reencryptedAtMs`) меньше или равна сохранённой, сообщение не применяется.
Содержимое `body` при этом сравнении не участвует.
То есть если сервер уже видел ту же пару (`revisionTimeMs`, `reencryptedAtMs`), он считает, что такая версия у него уже есть.
### 9.2. Повторный tombstone
Повторный tombstone означает ситуацию, когда сервер повторно получает то же самое событие удаления:
- того же сообщения;
- или той же переписки.
Это может произойти из-за:
- повторной межсерверной доставки;
- нескольких `access_servers`;
- сетевых retry;
- дублирующей пересылки с разных маршрутов.
Правило:
- повторный tombstone должен быть полностью безопасен;
- если соответствующее удаление уже сохранено, сервер ничего не меняет и просто игнорирует повтор.
### 9.3. Сообщения старше границы удалённой переписки
Если для пары пользователей уже есть сохранённая граница удаления переписки, и приходит:
- обычное сообщение;
- удаление одного сообщения;
- повторное удаление переписки;
- любое другое DM-событие;
у которого `timeMs` меньше этой границы, сервер:
- ничего не меняет в БД;
- не восстанавливает старую историю;
- не применяет это событие повторно.
Если это старое контентное сообщение или входящая копия, сервер дополнительно перерассылает уже известный tombstone удаления переписки на `access_servers` обеих сторон, чтобы отстающие серверы сами подчистили историю.
Такие сообщения считаются частью уже удалённой истории.
### 9.4. Приоритет удаления переписки
Если сначала пришло удаление переписки, а потом удаление одного старого сообщения из этой переписки, сервер должен:
- проигнорировать это удаление одного сообщения;
- не создавать новых изменений поверх уже удалённой истории.
То же правило действует и для обычных сообщений, и для редактирований старых сообщений.
## 10. JSON API v1
### 10.1. `SendMessagePair`
Назначение:
- клиент отправляет новый DM;
- клиент отправляет редактирование старого DM;
- клиент отправляет парную новую ревизию типов `1/2`.
Request:
```json
{
"op": "SendMessagePair",
"requestId": "req-123",
"payload": {
"incomingBlobB64": "...",
"outgoingBlobB64": "..."
}
}
```
Правила:
- клиенту достаточно отправить пару на один любой доступный сервер;
- успешный `status=200` означает локальное сохранение;
- первая сетевая попытка выполняется до ответа клиенту, параллельно для двух серверов получателя;
- сервер после принятия сам отвечает за дальнейшую межсерверную доставку.
Ответ сохраняет прежние `baseKey`, `incomingKey`, `outgoingKey` и счётчики доставки в клиентские сессии. Единственное новое поле — `deliveryState`: `accepted`, `delivered` или `failed`.
### 10.2. `ReceiveIncomingMessage`
Назначение:
- приём одной входящей копии по схеме server-to-server;
- приём входящего редактирования;
- приём входящего read-receipt.
Request:
```json
{
"op": "ReceiveIncomingMessage",
"requestId": "req-456",
"payload": {
"incomingBlobB64": "...",
"sourceServerLogin": "server-a"
}
}
```
`sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Для `ReceiveOutcomingMessage` и `SendMessagePair` пустой `sourceServerLogin` означает клиентский вызов, непустой - peer-вызов. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки.
### 10.3. `DeleteMessage`
Назначение:
- удалить одно сообщение у обеих сторон.
Request:
```json
{
"op": "DeleteMessage",
"requestId": "req-789",
"payload": {
"blobB64": "..."
}
}
```
Ожидается контейнер типа:
- `5`, если удаление инициировал отправитель;
- `6`, если удаление инициировал получатель.
UI-следствие для клиента:
- пользователь может удалить как своё исходящее сообщение, так и входящее;
- для удаления входящего UI должен отправлять вариант, где удаление инициировал получатель (`type=6`).
### 10.4. `DeleteConversation`
Назначение:
- удалить всю переписку до времени самого служебного сообщения.
Request:
```json
{
"op": "DeleteConversation",
"requestId": "req-790",
"payload": {
"blobB64": "..."
}
}
```
Ожидается контейнер типа:
- `7`, если удаление инициировал отправитель;
- `8`, если удаление инициировал получатель.
UI-следствие для клиента:
- действие `Очистить историю` должно отправлять служебное сообщение удаления переписки;
- для обычной клиентской кнопки очистки истории допускается вариант удаления, инициированный текущим получателем (`type=8`);
- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории;
- отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`.
### 10.5. `GetDirectMessages`
Назначение:
- постранично отдавать историю одного личного диалога;
- не вываливать весь старый backlog автоматически в момент логина;
- отдавать клиенту только полезные chat-сообщения, без технических receipt/tombstone блоков.
Правила:
- начиная с 22 июля 2026 года старая история DM больше не должна автоматически пушиться клиенту сразу после входа;
- клиент сам запрашивает первую страницу истории у нужного собеседника;
- последующие страницы клиент запрашивает по курсору `beforeTimeMs` + `beforeMessageKey`;
- realtime-новые сообщения по-прежнему приходят отдельно через `SignedMessageArrived`.
Содержимое страницы:
- сервер возвращает только контентные DM типов `1/2`;
- read-receipt (`3/4`) и tombstone удаления (`5/6/7/8`) в историю страницы не включаются;
- сообщения идут страницами от новых к старым;
- лимит страницы считается по самим chat-сообщениям диалога.
Поле прочтения:
- для каждого контентного сообщения сервер может вернуть `readAtMs`;
- `readAtMs` означает точное время прочтения сообщения, если оно уже известно серверу;
- если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени;
- read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается.
Для исходящих элементов сервер также возвращает единственное поле `deliveryState`.
## 11. Межсерверная доставка
### 11.1. Клиентская сторона
Клиенту достаточно отправить сообщение на:
- любой один доступный сервер.
### 11.2. Серверная сторона
После локального принятия пара получает изменяемое состояние `accepted`. Сервер сразу вызывает до двух текущих маршрутов получателя параллельно. Успех хотя бы одного маршрута переводит сообщение в терминальное `delivered`; ждать второй маршрут не требуется.
После первой попытки полная пара передаётся единственному второму access-серверу отправителя через прежний `ReceiveOutcomingMessage`. Peer самостоятельно пробует актуальные маршруты получателя; delivery-state в запрос не входит.
Идемпотентность обязательна. ACK означает запись в БД; повтор уже известной ревизии считается успешным ACK.
### 11.3. Догоняющая синхронизация истории
Для восстановления пропущенных DM-событий между access-серверами используется pull-операция:
- `DmSyncBatch`
Второй сервер запрашивает outbox-события владельца с `synced=false`, сохраняет их и передаёт подтверждённые `syncId` в `ackSyncIds` следующего запроса. Источник ставит `synced=true` только после ACK. Каждый цикл начинается с начала списка; курсор нужен только для страниц текущего цикла.
Полная пара отправителя передаётся двумя blob. Входящая копия получателя и tombstone передаются одним blob.
Полная пара отправителя передаётся одним элементом с двумя blob в порядке incoming/outgoing. Входящая копия получателя и tombstone передаются одним blob.
При применении событий, полученных через `DmSyncBatch`, сервер:
- проверяет формат `SHiNE_DM`;
- проверяет подпись;
- применяет существующие правила ревизий, read-receipt и tombstone;
- не отправляет realtime/push-уведомления клиентам;
- не запускает повторный fan-out, чтобы не создавать циклы.
Синхронизация настроек и DM выполняется одним периодическим процессом и через
один логический последовательный сеанс поверх постоянного WSS-пула. Закрытие
этого логического сеанса не закрывает физическое соединение с peer. Выборка DM
использует частичный индекс по `synced=false`.
В текущей реализации межсерверная авторизация DM ещё не включена. Принимающий сервер проверяет, что сам является access-сервером `ownerLogin`, и всегда проверяет пользовательские подписи signed-блоков.
### 11.4. Ошибки доставки
Если ни один сервер получателя не подтвердил запись, попытки выполняются сразу, через 30 секунд, 5 минут, 25 минут и 1 час от первоначального принятия.
Перед попытками через 5 минут, 25 минут и час сервер read-only спрашивает peer отправителя через `GetDmDeliveryStatus(messageKey)`. Если peer уже доставил хотя бы на один сервер, сообщение считается доставленным.
После неудачной последней попытки устанавливается `failed`; дальнейших автоматических попыток и кнопки ручного повтора нет.
## 12. Хранение в БД
Основная таблица остаётся:
- `signed_messages`
В ней должны сохраняться:
- обычные контентные DM;
- read-receipt DM;
- tombstone одного сообщения;
- tombstone удаления переписки.
Для контентных сообщений в БД дополнительно должно поддерживаться серверное поле `read_at_ms`, если для этого сообщения уже было принято read-receipt событие.
Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы:
- защищать от повторного приёма старых версий;
- не терять факт удаления;
- корректно синхронизировать событие между серверами.
Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются.
Изменяемая сетевая часть хранится отдельно:
- `dm_delivery_state` — состояние и расписание доставки исходящей пары;
- `dm_sync_outbox` — событие владельца и единственный флаг ACK `synced`;
- частичные индексы содержат только due/unsynced строки.
Legacy-таблица `dm_sync_peer_state` после миграции v12 физически остаётся для безопасной установки ZIP-накладки, но новым DM-кодом не используется.
## 13. Что обязательно должно измениться в коде относительно v0.5
- сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`;
- сервер не должен трактовать `encryptedBody` как обычный UTF-8 текст;
- сервер не должен валидировать внутреннюю crypto-структуру `body` у контентных DM `type=1/2`;
- DM должны реально шифроваться end-to-end;
- удаление одного сообщения должно стать терминальным tombstone;
- удаление всей переписки должно стать отдельным служебным событием;
- межсерверная маршрутизация DM должна идти через `access_servers`;
- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает;
- логика должна быть безопасна для одного или двух серверов у каждой стороны.
## 14. Что в v1 пока не входит
- вложения в DM;
- хранение отдельного `keyId` шифрования в DM;
- ротация `clientKey`;
- финальная конкретная UI-реализация массовой перешифровки;
- межсерверная авторизация DM-синхронизации и доставки.
## 15. UI списка диалогов и загрузки истории
Актуальные правила клиентского интерфейса:
- превью диалога выбирается по самому новому контентному сообщению независимо от направления `in/out`;
- если локально уже есть более новое расшифрованное сообщение, чем в серверной сводке `ListContacts`, клиент использует локальный текст и время до следующего согласования с сервером;
- локальное сообщение сравнивается с `lastMessageTimeMs`, поэтому входящие и исходящие сообщения участвуют в выборе на равных;
- индикатор загрузки истории всегда является компактным элементом фиксированной высоты сверху списка и не растягивается на высоту пустого чата;
- клик по аватару или логину собеседника открывает меню перехода в профиль или связи этого пользователя.
Эти правила относятся только к UI и не изменяют сетевой или байтовый формат DM v1.
- байтовый формат SHiNE_DM;
- шифрование входящей и исходящей копий;
- пользовательские подписи;
- deliveryState и retry-воркер;
- read-receipts;
- синхронизацию пользовательских блокчейнов через server-PDA sync_servers;
- общий постоянный WSS ServerConnectionPool.
+7 -1
View File
@@ -340,7 +340,13 @@ ReadReceiptBody_v1_0
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
Межсерверные операции `ReceiveOutcomingMessage`, `ReceiveIncomingMessage` и `DmSyncBatch` не вводят новый байтовый формат DM. Они передают уже сохранённые raw-контейнеры `SHiNE_DM` в Base64. `ackSyncIds` подтверждает только факт сохранения синхронизированной ревизии; delivery-state между серверами не передаётся. Принимающий сервер заново проверяет подпись и применяет контейнер по его `messageType`.
`SendMessagePair` передаёт входящую и исходящую подписанные копии от UI на
сервер отправителя. Внутренняя операция `ReceiveIncomingMessage` передаёт
только входящую raw-копию в Base64 на единственный access-сервер получателя.
Обе операции используют существующий байтовый формат `SHiNE_DM`.
Межсерверные `ReceiveOutcomingMessage` и `DmSyncBatch` удалены.
Принимающий сервер заново проверяет пользовательскую подпись и применяет
контейнер по его `messageType`.
## 14. Превью в списке диалогов