Перенести документацию в docs и добавить социальный граф

This commit is contained in:
AidarKC
2026-07-13 12:58:41 +04:00
parent c9cfb394d7
commit af12d7b954
91 changed files with 653 additions and 2 deletions
+140
View File
@@ -0,0 +1,140 @@
# API для разработчиков: Общий формат запросов и ответов
Этот файл описывает не конкретные операции, а общий wire-контракт всего API сервера.
Здесь зафиксировано:
- как выглядит любой запрос;
- как выглядит любой успешный ответ;
- как выглядит любой ответ с ошибкой;
- какие поля являются обязательными для всех операций;
- как клиент должен интерпретировать `status`, `ok` и `payload`.
Логика простая: сначала клиент и сервер договариваются о едином формате конверта, и только потом в остальных документах уже описываются конкретные методы и их поля.
## 1. Общий формат запроса
Все запросы по WebSocket используют один и тот же JSON-конверт:
```json
{
"op": "OperationName",
"requestId": "req-001",
"payload": {
}
}
```
### Поля
- `op` — имя операции.
- `requestId` — клиентский идентификатор запроса.
- `payload` — объект параметров операции.
---
## 2. Общий формат успешного ответа
```json
{
"op": "OperationName",
"requestId": "req-001",
"status": 200,
"ok": true,
"payload": {
}
}
```
---
## 3. Общий формат ответа с ошибкой
```json
{
"op": "OperationName",
"requestId": "req-001",
"status": 400,
"ok": false,
"error": "BAD_REQUEST",
"message": "Human readable description",
"payload": {
}
}
```
---
## 4. Обязательные правила
- Сервер возвращает `op` в каждом ответе.
- Сервер возвращает `requestId` в каждом ответе без изменений.
- Сервер возвращает `status` в каждом ответе.
- Сервер возвращает `ok` в каждом ответе.
- Сервер всегда возвращает `payload` как объект.
- Даже при отсутствии данных сервер возвращает `payload: {}`.
- `ok` находится на верхнем уровне ответа, а не внутри `payload`.
---
## 5. Правило интерпретации
Источник истины — `status`.
- если `status` в диапазоне `200..299`, то ответ успешный и `ok` должен быть `true`;
- если `status` вне диапазона `200..299`, то ответ ошибочный и `ok` должен быть `false`.
Запрещённые состояния:
- `status = 200` и `ok = false`;
- `status = 400` и `ok = true`.
---
## 6. Общие правила формата
- Все строки подписи и challenge собираются в UTF-8.
- Временные метки передаются как Unix time в миллисекундах.
- Бинарные поля передаются строками Base64.
- При ошибке `error` — это машинный код причины.
- При ошибке `message` — человекочитаемое описание причины.
---
## 7. Общие коды ошибок
Ниже перечислены коды ошибок, которые не привязаны к одной конкретной операции и могут встречаться в разных местах API.
- `400 / EMPTY_JSON` — клиент отправил пустое или полностью отсутствующее JSON-сообщение.
- `400 / NO_OP` — в корневом объекте не передано поле `op`.
- `400 / UNKNOWN_OP` — сервер не знает такую операцию.
- `400 / NO_PAYLOAD` — в корневом объекте отсутствует `payload`.
- `400 / BAD_PAYLOAD``payload` передан, но это не JSON-объект.
- `400 / BAD_REQUEST_FORMAT` — JSON-конверт формально валиден, но поля операции не удалось распарсить в ожидаемый формат.
- `500 / INTERNAL_HANDLER_ERROR` — в handler конкретной операции случилась непредвиденная серверная ошибка.
- `500 / INTERNAL_ERROR` — произошла внутренняя ошибка на уровне общего JSON-процессора или другого серверного слоя.
Общее правило для dev/test этапа:
- `message` в таких ошибках должен быть коротким, но полезным;
- по возможности сервер добавляет тип исключения и краткую деталь причины;
- это сделано для упрощения интеграционных тестов и отладки;
- позже для production этот уровень детализации может быть уменьшен.
---
## 8. Источник истины по списку операций
Фактический список публичных WebSocket-операций берётся из:
- `shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java`.
Если операция зарегистрирована в `HANDLERS` и `REQUEST_TYPES`, она считается доступной через JSON/WebSocket API. Общий актуальный индекс таких операций поддерживается в `Dev_Docs/API/09_Operations_Index.md`.
---
## 9. Короткое резюме
- Запросы всегда идут как `op + requestId + payload`.
- Ответы всегда идут как `op + requestId + status + ok + payload`.
- Ошибки всегда возвращают `ok: false`, `error`, `message`, `payload: {}`.
+201
View File
@@ -0,0 +1,201 @@
# API для разработчиков: Регистрация пользователя
Этот файл описывает раздел API, связанный с проверкой наличия пользователя на сервере и dev/test операциями.
Сейчас здесь три метода:
- `AddUser` — операция отключена (регистрация только через Solana);
- `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных;
- `SearchUsers` — dev/test поиск логинов по префиксу.
Регистрация выполняется через Solana (`shine_users`). Сервер при входе может лениво импортировать пользователя из Solana PDA в локальную БД, если записи ещё нет.
## Статус документа
Это временная глава API.
Текущая регистрация пользователя и текущая проверка, существует пользователь или нет, пока реализованы как серверные dev/test операции. В будущем и регистрация, и проверка identity должны идти напрямую через Solana.
---
## 1. Операция `AddUser`
### Назначение
Операция отключена. Используется только как явный ответ клиентам старых версий.
### Запрос
```json
{
"op": "AddUser",
"requestId": "reg-001",
"payload": {
"login": "anya",
"blockchainName": "anya-001",
"solanaKey": "BASE64_32_PUBLIC_KEY",
"blockchainKey": "BASE64_32_PUBLIC_KEY",
"clientKey": "BASE64_32_PUBLIC_KEY",
"bchLimit": 1000000
}
}
```
### Пример ответа
```json
{
"op": "AddUser",
"requestId": "reg-001",
"status": 410,
"ok": false,
"error": "ADD_USER_DISABLED",
"message": "Серверная регистрация AddUser отключена. Используйте регистрацию через Solana.",
"payload": {
}
}
```
### Специфические коды ошибок `AddUser`
- `410 / ADD_USER_DISABLED` — серверная регистрация отключена, используйте Solana-first flow.
---
## 2. Операция `GetUser`
### Назначение
Временная серверная проверка, существует пользователь или нет.
Важно:
- это server-side existence-check;
- если пользователя нет в локальной БД, сервер сразу пытается lazy-import из Solana PDA;
- поэтому `GetUser` можно использовать как актуальный способ получить `clientKey` и базовые поля пользователя перед E2EE DM.
### Запрос
```json
{
"op": "GetUser",
"requestId": "user-001",
"payload": {
"login": "anya"
}
}
```
### Успешный ответ: пользователь существует
```json
{
"op": "GetUser",
"requestId": "user-001",
"status": 200,
"ok": true,
"payload": {
"exists": true,
"login": "Anya",
"blockchainName": "anya-001",
"solanaKey": "BASE64_32_PUBLIC_KEY",
"blockchainKey": "BASE64_32_PUBLIC_KEY",
"clientKey": "BASE64_32_PUBLIC_KEY",
"serverLastGlobalNumber": 128,
"serverLastGlobalHash": "4f...ab",
"serverBlockchainSizeBytes": 45212,
"serverBlockchainSizeLimitBytes": 100000
}
}
```
Дополнительные серверные поля в `GetUser`:
- `serverLastGlobalNumber` — номер последнего блока в пользовательском блокчейне на сервере;
- `serverLastGlobalHash` — hash последнего блока (hex-строка 64 символа);
- `serverBlockchainSizeBytes` — текущий размер пользовательского блокчейна на сервере в байтах;
- `serverBlockchainSizeLimitBytes` — текущий лимит размера блокчейна на сервере в байтах;
### Успешный ответ: пользователя нет
```json
{
"op": "GetUser",
"requestId": "user-001",
"status": 200,
"ok": true,
"payload": {
"exists": false
}
}
```
### Пример ошибки
```json
{
"op": "GetUser",
"requestId": "user-001",
"status": 400,
"ok": false,
"error": "BAD_FIELDS",
"message": "Некорректные поля: login",
"payload": {
}
}
```
### Специфические коды ошибок `GetUser`
- `400 / BAD_FIELDS` — не передан или пуст `login`.
- `501 / DB_ERROR` — ошибка БД при поиске пользователя.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 3. Операция `SearchUsers`
### Назначение
Поиск пользователей по префиксу логина. Операция зарегистрирована в серверном API и используется как вспомогательная dev/test операция.
### Запрос
```json
{
"op": "SearchUsers",
"requestId": "search-001",
"payload": {
"prefix": "an"
}
}
```
### Успешный ответ
```json
{
"op": "SearchUsers",
"requestId": "search-001",
"status": 200,
"ok": true,
"payload": {
"logins": ["anya", "andrey"]
}
}
```
### Специфические коды ошибок `SearchUsers`
- `400 / BAD_FIELDS` — некорректный или пустой `prefix`.
- `501 / DB_ERROR` — ошибка БД при поиске.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 4. Короткое резюме
- `AddUser` — отключен (`410 / ADD_USER_DISABLED`).
- `GetUser` — проверка существования пользователя на сервере.
- `SearchUsers` — временный поиск пользователей по префиксу.
- Регистрация выполняется только через Solana.
+341
View File
@@ -0,0 +1,341 @@
# API для разработчиков: Авторизация
Этот файл описывает именно этапы авторизации клиента, то есть как создать новую сессию и как войти в уже существующую.
Здесь четыре базовых метода обычной авторизации:
- `AuthChallenge`
- `CreateAuthSession`
- `SessionChallenge`
- `SessionLogin`
Логика раздела такая:
- сначала клиент либо начинает создание новой сессии через `clientKey`;
- либо начинает вход в уже созданную сессию через `sessionKey`;
- сервер на первом шаге выдаёт challenge/nonce;
- на втором шаге клиент присылает подписанный ответ;
- сервер сверяет актуальные публичные ключи и только потом проверяет подпись.
Новые поля этого раздела:
- `sessionType` — числовой код типа сессии;
- `clientPlatform` — свободная строка платформы клиента.
Текущие поддерживаемые коды `sessionType`:
- `1` — обычный клиент;
- `50` — кошелёк;
- `100` — homeserver.
Правило проверки `sessionType`:
1. если в `Solana PDA` нет записи для `sessionKey`, сервер принимает `sessionType`, присланный клиентом;
2. если запись в `PDA` есть, `sessionType` в запросе должен совпадать с `session_type` из `PDA`;
3. при несовпадении сервер возвращает `460 / SESSION_TYPE_MISMATCH`.
Ниже в документе сначала описан сценарий, а потом зафиксированы точные форматы запросов и ответов.
Отдельно появился новый серверный сценарий pairing через доверенный homeserver/ESP. Он не заменяет обычный вход и описан в:
- `Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md`
Кратко:
- `AuthChallenge/CreateAuthSession` и `SessionChallenge/SessionLogin` остаются каноническими потоками обычной авторизации;
- pairing через ESP идёт отдельными `op` и только подготавливает безопасное добавление новой сессии;
- решение об одобрении pairing принимает любая уже авторизованная доверенная сессия пользователя.
## 1. Поток авторизации
Поддерживаются два сценария:
1. Создание новой сессии:
`AuthChallenge` -> `CreateAuthSession`
2. Вход в существующую сессию:
`SessionChallenge` -> `SessionLogin`
`clientKey` используется для создания новой сессии.
`sessionKey` используется для входа в уже созданную сессию.
`sessionKey` передаётся и хранится целиком одной строкой, например:
```text
ed25519/BASE64_PUBLIC_KEY
```
---
## 2. `AuthChallenge`
### Запрос
```json
{
"op": "AuthChallenge",
"requestId": "auth-001",
"payload": {
"login": "alice"
}
}
```
### Успешный ответ
```json
{
"op": "AuthChallenge",
"requestId": "auth-001",
"status": 200,
"ok": true,
"payload": {
"authNonce": "8f2f0f71-0b1c-4ab2-8f5d-0bc5d6f6aa11"
}
}
```
### Специфические коды ошибок `AuthChallenge`
- `400 / EMPTY_LOGIN` — пустой `login`.
- `400 / ALREADY_AUTHED` — по текущему соединению уже выполнена авторизация.
- `422 / UNKNOWN_USER` — пользователь с таким `login` не найден.
- `501 / SOLANA_IMPORT_FAILED` — сервер не смог проверить/импортировать пользователя из Solana при lazy-import.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера, если появится вне штатного сценария.
---
## 3. `CreateAuthSession`
### Запрос
```json
{
"op": "CreateAuthSession",
"requestId": "create-001",
"payload": {
"login": "alice",
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
"storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET",
"timeMs": 1774600000123,
"authNonce": "nonce",
"clientKey": "BASE64_DEVICE_PUBLIC_KEY",
"signatureB64": "BASE64_SIGNATURE",
"sessionType": 1,
"clientPlatform": "Web",
"clientInfo": "Android 15; Pixel 9"
}
}
```
### Строка для подписи
```text
AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
```
### Дополнительная проверка ключа
Перед проверкой подписи сервер должен:
1. взять актуальный `solana_users.client_key`;
2. сравнить его с `payload.clientKey`;
3. только потом проверять подпись.
Если `clientKey` не совпадает, сервер возвращает ошибку `DEVICE_KEY_NOT_ACTUAL`.
На будущее:
- для ротации `client_key` желательно добавить перепроверку через Solana.
### Успешный ответ
```json
{
"op": "CreateAuthSession",
"requestId": "create-001",
"status": 200,
"ok": true,
"payload": {
"sessionId": "sess_7c5e5c4b"
}
}
```
### Специфические коды ошибок `CreateAuthSession`
- `400 / NO_STEP1_CONTEXT` — для данного соединения не был корректно выполнен `AuthChallenge`.
- `400 / EMPTY_LOGIN` — пустой `login`.
- `400 / LOGIN_MISMATCH``login` не совпадает с тем, для кого был выдан `authNonce`.
- `501 / DB_ERROR_USER_LOOKUP` — ошибка БД при повторном чтении пользователя.
- `422 / USER_NOT_FOUND` — пользователь не найден.
- `501 / NO_LOGIN` — у пользователя на сервере не заполнен `login`.
- `400 / EMPTY_STORAGE_PWD` — пустой `storagePwd`.
- `400 / EMPTY_SESSION_KEY` — пустой `sessionKey`.
- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` или `clientKey` не поддерживается текущим сервером.
- `400 / BAD_BASE64` — неверный Base64 в `sessionKey`, `clientKey` или `signatureB64`.
- `400 / EMPTY_SIGNATURE` — пустая подпись.
- `400 / TIME_SKEW` — время клиента отличается от серверного больше допустимого окна.
- `400 / NO_DEVICE_KEY` — у пользователя в БД отсутствует `clientKey`.
- `400 / EMPTY_AUTH_NONCE` — пустой `authNonce`.
- `400 / AUTH_NONCE_MISMATCH``authNonce` не соответствует значению из `AuthChallenge`.
- `400 / EMPTY_DEVICE_KEY` — в запросе не передан `clientKey`.
- `422 / DEVICE_KEY_NOT_ACTUAL``clientKey` не совпадает с актуальной версией на сервере.
- `422 / BAD_SIGNATURE` — подпись не прошла проверку.
- `460 / SESSION_TYPE_MISMATCH``sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA.
- `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA.
- `501 / DB_ERROR_SESSION_CREATE` — ошибка БД при создании записи активной сессии.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 4. `SessionChallenge`
### Запрос
```json
{
"op": "SessionChallenge",
"requestId": "sch-001",
"payload": {
"sessionId": "sess_7c5e5c4b"
}
}
```
### Успешный ответ
```json
{
"op": "SessionChallenge",
"requestId": "sch-001",
"status": 200,
"ok": true,
"payload": {
"nonce": "0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66"
}
}
```
### Специфические коды ошибок `SessionChallenge`
- `400 / EMPTY_SESSION_ID` — пустой `sessionId`.
- `501 / DB_ERROR` — ошибка БД при чтении сессии.
- `422 / SESSION_NOT_FOUND` — сессия не найдена.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 5. `SessionLogin`
### Запрос
```json
{
"op": "SessionLogin",
"requestId": "slogin-001",
"payload": {
"sessionId": "sess_7c5e5c4b",
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
"timeMs": 1774600010456,
"signatureB64": "BASE64_SIGNATURE",
"sessionType": 1,
"clientPlatform": "Web",
"clientInfo": "Android 15; Pixel 9"
}
}
```
### Строка для подписи
```text
SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
```
### Дополнительная проверка ключа
Перед проверкой подписи сервер должен:
1. взять `active_sessions.session_key`;
2. сравнить его с `payload.sessionKey`;
3. только потом проверять подпись.
Если ключ не совпадает, сервер возвращает ошибку `SESSION_KEY_NOT_ACTUAL`.
### Успешный ответ
```json
{
"op": "SessionLogin",
"requestId": "slogin-001",
"status": 200,
"ok": true,
"payload": {
"storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET"
}
}
```
### Специфические коды ошибок `SessionLogin`
- `400 / EMPTY_SESSION_ID` — пустой `sessionId`.
- `400 / NO_CHALLENGE` — перед `SessionLogin` не был успешно выполнен `SessionChallenge` либо nonce уже истёк.
- `400 / SESSION_ID_MISMATCH` — nonce был выдан для другого `sessionId`.
- `400 / TIME_SKEW` — время клиента отличается от серверного больше допустимого окна.
- `400 / EMPTY_SIGNATURE` — пустая подпись.
- `400 / EMPTY_SESSION_KEY` — пустой `sessionKey`.
- `501 / DB_ERROR` — ошибка БД при чтении сессии.
- `422 / SESSION_NOT_FOUND` — сессия не найдена.
- `501 / NO_SESSION_KEY` — у сессии отсутствует `session_key`.
- `422 / SESSION_KEY_NOT_ACTUAL` — переданный `sessionKey` не совпадает с актуальной версией на сервере.
- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` не поддерживается текущим сервером.
- `400 / BAD_BASE64` — неверный Base64 в `sessionKey` или `signatureB64`.
- `422 / BAD_SIGNATURE` — подпись не прошла проверку.
- `460 / SESSION_TYPE_MISMATCH``sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA.
- `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA.
- `501 / DB_ERROR_USER_LOOKUP` — ошибка БД при чтении пользователя для этой сессии.
- `422 / USER_NOT_FOUND_FOR_SESSION` — пользователь, которому принадлежит сессия, не найден.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 6. Pairing через homeserver/ESP
Новые `op`, относящиеся к этому сценарию:
- `GetTrustedDeviceLoginSettings`
- `UpsertTrustedDeviceLoginSettings`
- `StartTrustedDeviceLogin`
- `ListTrustedDeviceLoginRequests`
- `ApproveTrustedDeviceLogin`
- `RejectTrustedDeviceLogin`
- `CancelTrustedDeviceLogin`
- `GetTrustedDeviceLoginStatus`
В этом потоке:
- новое устройство не владеет `clientKey` и не проходит обычный `CreateAuthSession`;
- пароль проверяется сервером только как фильтр;
- решение об одобрении принимает уже авторизованная доверенная сессия пользователя;
- сервер не расшифровывает `encryptedPayload` и не становится источником приватных ключей.
Точные форматы этих операций см. в `03_Session_Management_API.md` и в протокольном документе:
- `Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md`
---
## 6. Пример ошибки
```json
{
"op": "SessionLogin",
"requestId": "slogin-001",
"status": 403,
"ok": false,
"error": "SESSION_KEY_NOT_ACTUAL",
"message": "session_key не соответствует актуальной версии",
"payload": {
}
}
```
+485
View File
@@ -0,0 +1,485 @@
# API для разработчиков: Управление сессиями
Этот файл описывает методы, которые используются уже после успешной авторизации пользователя в сессию.
Здесь два метода:
- `ListSessions` — получить список активных сессий пользователя;
- `CloseActiveSession` — закрыть одну из активных сессий.
Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя:
- `GetTrustedDeviceLoginSettings`
- `UpsertTrustedDeviceLoginSettings`
- `ListTrustedDeviceLoginRequests`
- `ApproveTrustedDeviceLogin`
- `RejectTrustedDeviceLogin`
- `CancelTrustedDeviceLogin`
Анонимное новое устройство работает с двумя связанными операциями:
- `StartTrustedDeviceLogin`
- `GetTrustedDeviceLoginStatus`
Логика раздела такая:
- сначала пользователь проходит `SessionLogin`;
- после этого сервер считает соединение авторизованным;
- уже в этом состоянии клиент может читать список сессий и управлять ими.
То есть это не этап создания или входа в сессию, а этап последующего контроля уже существующих активных сессий.
## 1. `ListSessions`
Доступно только после успешного `SessionLogin`.
### Запрос
```json
{
"op": "ListSessions",
"requestId": "list-001",
"payload": {
}
}
```
### Успешный ответ
```json
{
"op": "ListSessions",
"requestId": "list-001",
"status": 200,
"ok": true,
"payload": {
"sessions": [
{
"sessionId": "sess_7c5e5c4b",
"sessionType": 1,
"clientPlatform": "Web",
"onlineOnThisServer": true,
"clientInfoFromClient": "Android 15; Pixel 9",
"clientInfoFromRequest": "UA=Java-http-client/17.0.18; remote=127.0.0.1",
"geo": "RU/Moscow",
"lastAuthenticatedAtMs": 1774600010500
}
]
}
}
```
### Специфические коды ошибок `ListSessions`
- `422 / NOT_AUTHENTICATED` — запрос доступен только после успешного `SessionLogin`.
- `501 / DB_ERROR_LIST_SESSIONS` — ошибка БД при чтении списка активных сессий.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
### Поля одной сессии в `ListSessions`
- `sessionId` — идентификатор активной сессии;
- `sessionType` — числовой код типа сессии:
- `1` — клиент;
- `50` — кошелёк;
- `100` — homeserver;
- `clientPlatform` — строка платформы, как её прислал клиент;
- `onlineOnThisServer``true`, если эта сессия сейчас держит живое WebSocket-подключение именно к данному серверу;
- `clientInfoFromClient` — краткая строка клиента;
- `clientInfoFromRequest` — строка, собранная сервером из запроса;
- `geo` — страна/город или fallback-строка;
- `lastAuthenticatedAtMs` — время последней успешной авторизации этой сессии.
---
## 2. `CloseActiveSession`
Доступно только после успешного `SessionLogin`.
### Запрос
```json
{
"op": "CloseActiveSession",
"requestId": "close-001",
"payload": {
"sessionId": "sess_7c5e5c4b"
}
}
```
### Успешный ответ
```json
{
"op": "CloseActiveSession",
"requestId": "close-001",
"status": 200,
"ok": true,
"payload": {
}
}
```
### Специфические коды ошибок `CloseActiveSession`
- `422 / NOT_AUTHENTICATED` — запрос доступен только после успешного `SessionLogin`.
- `400 / NO_SESSION_TO_CLOSE` — сервер не смог определить, какую сессию нужно закрыть.
- `501 / DB_ERROR` — ошибка БД при поиске сессии или её удалении.
- `422 / SESSION_NOT_FOUND` — целевая сессия не найдена.
- `422 / SESSION_OF_ANOTHER_USER` — нельзя закрывать сессию другого пользователя.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 3. Пример ошибки
```json
{
"op": "CloseActiveSession",
"requestId": "close-001",
"status": 403,
"ok": false,
"error": "NOT_AUTHENTICATED",
"message": "Операция доступна только для авторизованных пользователей",
"payload": {
}
}
```
## 4. Формат `sessionId`
Текущее серверное значение `sessionId` генерируется как:
- случайные **32 байта** (`SecureRandom`),
- кодирование в **стандартный Base64 RFC 4648** (алфавит `A-Z a-z 0-9 + /`),
- **без padding** `=`.
Практически это строка длиной около **43 символов** (для 32 байт без `=`).
Пример реального формата:
```
K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
```
Важно: это **не человеко-читаемое имя**, а непрозрачный идентификатор.
Нужно передавать его как есть, без нормализации регистра и без URL-экранирования внутри JSON.
---
## 5. TrustedDeviceLogin через доверенную сессию
Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя.
### 5.1. `GetTrustedDeviceLoginSettings`
Доступно для любой уже авторизованной доверенной сессии пользователя.
### Запрос
```json
{
"op": "GetTrustedDeviceLoginSettings",
"requestId": "trusted-login-get-001",
"payload": {
}
}
```
### Успешный ответ
```json
{
"op": "GetTrustedDeviceLoginSettings",
"requestId": "trusted-login-get-001",
"status": 200,
"ok": true,
"payload": {
"enabled": true,
"hasPassword": false
}
}
```
Если отдельной записи настроек на сервере ещё нет, сервер считает состояние по умолчанию таким:
- `enabled = true`
- `hasPassword = false`
### Ошибки
- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя.
### 5.2. `UpsertTrustedDeviceLoginSettings`
Доступно для любой уже авторизованной доверенной сессии пользователя.
### Запрос
```json
{
"op": "UpsertTrustedDeviceLoginSettings",
"requestId": "esp-set-001",
"payload": {
"enabled": true,
"passwordHash": "sha256$0123abcd..."
}
}
```
Если вход через доверенное устройство должен работать **без доп. пароля**, клиент включает его с пустым `passwordHash`.
Если `enabled = false`, сервер автоматически удаляет пароль и запрещает вход через другое устройство.
Формат непустого `passwordHash`:
```text
sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```
### Успешный ответ
```json
{
"op": "UpsertTrustedDeviceLoginSettings",
"requestId": "esp-set-001",
"status": 200,
"ok": true,
"payload": {
"enabled": true,
"hasPassword": true
}
}
```
### Ошибки
- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя.
### 5.3. `StartTrustedDeviceLogin`
Эта операция доступна без уже существующей пользовательской сессии.
### Запрос
```json
{
"op": "StartTrustedDeviceLogin",
"requestId": "esp-start-001",
"payload": {
"login": "alice",
"passwordHash": "sha256$0123abcd...",
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY",
"requesterSessionType": 1,
"requesterClientPlatform": "Android",
"payloadType": 1
}
}
```
Если на доверённом устройстве вход включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`.
Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку.
Поле `shortCode` теперь содержит `10` цифр. В UI его рекомендуется показывать как `5` пар, например: `49 20 70 91 23`.
TTL заявки фиксирован на сервере и сейчас всегда равен `300` секундам.
### Успешный ответ
```json
{
"op": "StartTrustedDeviceLogin",
"requestId": "esp-start-001",
"status": 200,
"ok": true,
"payload": {
"pairingId": "base64url",
"state": "created",
"shortCode": "4920709123",
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
"expiresAtMs": 1781441990538,
"trustedSessionOnline": true
}
}
```
### Ошибки
- `400 / EMPTY_LOGIN`
- `400 / EMPTY_REQUESTER_SESSION_KEY`
- `400 / BAD_REQUESTER_SESSION_KEY`
- `400 / BAD_SESSION_TYPE`
- `400 / BAD_PAYLOAD_TYPE`
- `422 / PAIRING_NOT_AVAILABLE`
- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля.
- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся.
- `429 / PAIRING_RATE_LIMITED`
### 5.4. `ListTrustedDeviceLoginRequests`
Доступно для любой уже авторизованной доверенной сессии пользователя.
Возвращает только реально активные pending-заявки со `state = created`. Уже `approved` и `rejected` заявки в этот список больше не попадают.
### Успешный ответ
```json
{
"op": "ListTrustedDeviceLoginRequests",
"requestId": "esp-list-001",
"status": 200,
"ok": true,
"payload": {
"requests": [
{
"pairingId": "base64url",
"state": "created",
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY",
"requesterSessionType": 1,
"requesterClientPlatform": "Android",
"payloadType": 1,
"shortCode": "4920709123",
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
"createdAtMs": 1781441810538,
"expiresAtMs": 1781441990538,
"deliveredToHomeserver": true
}
]
}
}
```
### Ошибки
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
### 5.5. `ApproveTrustedDeviceLogin`
Доступно для любой уже авторизованной доверенной сессии пользователя.
### Запрос
```json
{
"op": "ApproveTrustedDeviceLogin",
"requestId": "esp-approve-001",
"payload": {
"pairingId": "base64url",
"encryptedPayload": "BASE64_OR_OTHER_OPAQUE_PAYLOAD"
}
}
```
### Успешный ответ
```json
{
"op": "ApproveTrustedDeviceLogin",
"requestId": "esp-approve-001",
"status": 200,
"ok": true,
"payload": {
"pairingId": "base64url",
"state": "approved"
}
}
```
### Ошибки
- `400 / EMPTY_PAIRING_ID`
- `400 / EMPTY_ENCRYPTED_PAYLOAD`
- `404 / PAIRING_NOT_FOUND`
- `422 / PAIRING_OF_ANOTHER_USER`
- `422 / PAIRING_NOT_PENDING`
- `422 / PAIRING_EXPIRED`
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
### 5.6. `RejectTrustedDeviceLogin`
Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`.
### 5.7. `GetTrustedDeviceLoginStatus`
Операция для нового устройства.
### Запрос
```json
{
"op": "GetTrustedDeviceLoginStatus",
"requestId": "esp-status-001",
"payload": {
"pairingId": "base64url"
}
}
```
### Успешный ответ после approve
```json
{
"op": "GetTrustedDeviceLoginStatus",
"requestId": "esp-status-001",
"status": 200,
"ok": true,
"payload": {
"pairingId": "base64url",
"state": "approved",
"shortCode": "4920709123",
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
"payloadType": 1,
"encryptedPayload": "AQIDBA==",
"expiresAtMs": 1781441990538
}
}
```
### Возможные `state`
- `created`
- `approved`
- `rejected`
- `canceled`
- `expired`
### 5.8. `CancelTrustedDeviceLogin`
Операция для нового устройства, которое уже создало pairing-заявку и хочет принудительно снять ожидание до истечения TTL.
### Запрос
```json
{
"op": "CancelTrustedDeviceLogin",
"requestId": "esp-cancel-001",
"payload": {
"pairingId": "base64url",
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY"
}
}
```
### Успешный ответ
```json
{
"op": "CancelTrustedDeviceLogin",
"requestId": "esp-cancel-001",
"status": 200,
"ok": true,
"payload": {
"pairingId": "base64url",
"state": "canceled"
}
}
```
### Ошибки
- `400 / EMPTY_PAIRING_ID`
- `400 / EMPTY_REQUESTER_SESSION_KEY`
- `400 / BAD_REQUESTER_SESSION_KEY`
- `404 / PAIRING_NOT_FOUND`
- `422 / PAIRING_OF_ANOTHER_REQUESTER`
- `422 / PAIRING_NOT_PENDING`
+239
View File
@@ -0,0 +1,239 @@
# API для разработчиков: 04 — Запись и чтение блока блокчейна
Документ описывает **текущий рабочий формат** сетевых вызовов:
- `AddBlock` — запись любого блока в блокчейн пользователя;
- `GetBlockchainBlock` — публичное чтение одного конкретного блока по имени цепочки и номеру.
`GetBlockchainBlock` нужен в том числе для межсерверной синхронизации и для открытого чтения публичного блокчейна по одному блоку.
> Важный принцип: на уровне JSON API сейчас есть **один универсальный метод** записи — `AddBlock`.
> Конкретный смысл записи задаётся типом самого бинарного блока (`type/subType/version` в заголовке блока).
## 1. Что делает `AddBlock`
`AddBlock`:
- принимает имя блокчейна и base64 бинарного блока;
- проверяет непрерывность цепочки (`blockNumber`, `prevHash`);
- проверяет формат и подпись Ed25519;
- валидирует `body` по правилам типа блока;
- сохраняет блок и обновляет состояние цепочки.
## 2. JSON формат запроса
`op = "AddBlock"`.
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"payload": {
"blockchainName": "alice-001",
"blockNumber": 12,
"prevBlockHash": "ab12...ff",
"blockBytesB64": "AAAB..."
}
}
```
Поля `payload`:
- `blockchainName` — обязательно, формат `login-NNN`.
- `blockNumber` — обязательно (временное legacy-поле для совместимости; должно совпасть с номером внутри бинарного блока).
- `prevBlockHash` — legacy-поле, сейчас сервер использует `prevHash` из бинарного блока и состояние цепочки.
- `blockBytesB64` — обязательно: **полный бинарный блок** (`preimage + sigMarker + signature`) в Base64.
## 3. Успешный ответ
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"status": 200,
"ok": true,
"payload": {
"reasonCode": null,
"serverLastGlobalNumber": 12,
"serverLastGlobalHash": "9f0e...a1"
}
}
```
## 4. Ошибка (единый формат)
При ошибках сервер отдаёт `Net_Exception_Response` со стандартными полями и дополнительно с состоянием сервера для ресинка:
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"status": 400,
"ok": false,
"error": "bad_prev_hash",
"message": "Некорректный prevHash (цепочка не совпадает)",
"payload": {
"serverLastGlobalNumber": 11,
"serverLastGlobalHash": "c3d4...98"
}
}
```
### Основные `reasonCode`
- `empty_blockchain_name`, `bad_blockchain_name`
- `blockchain_state_not_found`
- `bad_block_base64`, `bad_block_format`, `bad_block_body`
- `bad_block_number`, `req_global_mismatch`, `bad_prev_hash`
- `bad_signature`, `signature_verify_failed`
- `prev_line_block_not_found`, `bad_prev_line_hash`
- `limit_exceeded`
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
- `repost_disabled` — репосты временно отключены до будущей реализации
- `internal_error`
## 5. Какие блоки реально можно добавлять через `AddBlock`
Через `AddBlock` можно писать поддержанные форматы, кроме явно отключённых временных фич:
1. **TECH (type=0)**
- `HEADER_COMPAT (subType=0)`
- `TECH_CREATE_CHANNEL (subType=1)`
2. **TEXT (type=1)**
- `TEXT_POST (10)`
- `TEXT_EDIT_POST (11)`
- `TEXT_REPLY (20)`
- `TEXT_EDIT_REPLY (21)`
- `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
3. **REACTION (type=2)**
- `REACTION_LIKE (1)`
4. **CONNECTION (type=3)**
- `CONNECTION_FRIEND (10)`
- `CONNECTION_UNFRIEND (11)`
- `CONNECTION_CONTACT (20)`
- `CONNECTION_UNCONTACT (21)`
- `CONNECTION_FOLLOW (30)`
- `CONNECTION_UNFOLLOW (31)`
- `CONNECTION_SPOUSE (40)`
- `CONNECTION_UNSPOUSE (41)`
- `CONNECTION_PARENT (50)`
- `CONNECTION_UNPARENT (51)`
- `CONNECTION_CHILD (52)`
- `CONNECTION_UNCHILD (53)`
- `CONNECTION_SIBLING (54)`
- `CONNECTION_UNSIBLING (55)`
- `CONNECTION_KNOWN_PERSON (60)`
- `CONNECTION_UNKNOWN_PERSON (61)`
- `CONNECTION_SHINE_CONFIRMED (70)`
- `CONNECTION_SHINE_UNCONFIRMED (71)`
- `CONNECTION_SHINE_SEEN (74)`
- `CONNECTION_SHINE_UNSEEN (75)`
5. **USER_PARAM (type=4)**
- `USER_PARAM_TEXT_TEXT (1)`
## 6. Хватает ли функций сейчас
Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**.
Что есть:
- единый надёжный write-путь `AddBlock`;
- есть `GetFriendsLists` и API по `UserParam`;
- есть унифицированные коды ошибок и поля для ресинхронизации.
Что пока ограничивает продукт:
- нет полноценного read API для каналов/постов/тредов;
- нет API списка подписок с серверными счётчиками непрочитанного;
- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC.
## 7. Рекомендации по клиенту при записи блоков
1. Перед отправкой держать локальный `lastNumber/lastHash`.
2. При `bad_prev_hash` или `bad_block_number`:
- взять `serverLastGlobalNumber/serverLastGlobalHash` из ошибки,
- пересобрать следующий блок на актуальной вершине.
3. Для edit-блоков всегда ссылаться на **оригинальный** блок, а не на предыдущий edit.
4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост.
## 8. USER_PARAM для «личных данных»
Да, на текущем API это можно добавить **без изменения серверного кода**:
- в `UserParam` поле `param` сейчас не ограничено фиксированным справочником;
- сервер хранит пары `param -> value` как строки (при наличии корректной подписи и `time_ms`);
- чтение уже есть через `GetUserParam` и `ListUserParams`.
Рекомендуемый стартовый набор ключей для профиля (MVP):
- `name`
- `last_name`
- `address_physical`
- `address_web`
- `phone`
Практическая рекомендация: заранее зафиксировать единый словарь ключей в клиенте/документации, чтобы избежать дублей вида `lastname` vs `last_name`, `site` vs `address_web` и т.д.
Ограничения, которые важно учесть:
- сейчас нет серверной ACL-политики чтения параметров (в MVP их может читать любой клиент, который знает `login`);
- нет валидации формата значений для конкретных ключей (телефон, URL и т.д. проверяются только на стороне клиента);
- нет отдельного индекса/поиска по этим полям — только точечное чтение и listing по `login`.
---
## 9. `GetBlockchainBlock`
### Назначение
Публичное чтение одного конкретного блока из цепочки.
Нужно для:
- открытого чтения блокчейна по одному блоку;
- межсерверной синхронизации;
- восстановления/докачки отсутствующего хвоста цепочки.
### JSON формат запроса
`op = "GetBlockchainBlock"`.
```json
{
"op": "GetBlockchainBlock",
"requestId": "req-2001",
"payload": {
"blockchainName": "alice-001",
"blockNumber": 12
}
}
```
Поля `payload`:
- `blockchainName` — обязательно, формат `login-NNN`.
- `blockNumber` — обязательно, номер блока в цепочке, `>= 0`.
### Успешный ответ
```json
{
"op": "GetBlockchainBlock",
"requestId": "req-2001",
"status": 200,
"ok": true,
"payload": {
"blockchainName": "alice-001",
"blockNumber": 12,
"blockHash": "9f0eaabbccddeeff00112233445566778899aabbccddeeff0011223344556677",
"blockBytesB64": "AAAB..."
}
}
```
### Ошибки
- `400 / BAD_FIELDS` — некорректные `blockchainName` или `blockNumber`.
- `404 / BLOCK_NOT_FOUND` — такого блока нет.
- `500 / INTERNAL_ERROR` — внутренняя ошибка сервера.
+615
View File
@@ -0,0 +1,615 @@
# API для разработчиков: Технические запросы
Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии.
Сейчас здесь девять методов:
- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения;
- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети;
- `ListBlockchainHeads` — краткая сводка по всем локальным блокчейнам сервера для межсерверной синхронизации;
- `GetSyncUserProfile` — межсерверный профиль пользователя для создания локальной цепочки без Solana RPC;
- `SendSignal` — общий межсессионный технический сигнал в одну конкретную сессию или сразу во все активные сессии пользователя;
- `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков;
- `ClientErrorLog` — отправка клиентской ошибки в серверный лог;
- `ClientDebugLog` — отправка клиентского debug-события в серверный буфер;
- `CallDeliveryReport` — диагностический отчёт клиента о доставке/установке звонка.
Логика раздела такая:
- `Ping` нужен для регулярной проверки, что соединение всё ещё живо;
- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла.
- `ListBlockchainHeads` нужен для сервер-сервер сверки: партнёр получает список heads по всем цепочкам, сравнивает его со своим состоянием и затем добирает недостающие блоки по диапазону.
- `GetSyncUserProfile` нужен для server-to-server режима, когда принимающий сервер хочет создать у себя локальные `solana_users + blockchain_state` без прямого обращения в Solana. Это используется как временный обход ограничений внешнего Solana RPC.
- `SendSignal` нужен для доверенных межсессионных команд одного пользователя. Первое практическое применение — `remote AddBlock via homeserver session`, но формат задуман как общий transport на вырост.
Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов.
## 1. `Ping`
### Назначение
Служебный keep-alive запрос.
Клиент может отправлять его периодически, чтобы:
- поддерживать активное WebSocket-соединение;
- понимать, что сервер отвечает;
- при необходимости получать текущее серверное время.
### Запрос
```json
{
"op": "Ping",
"requestId": "ping-001",
"payload": {
"ts": 1774700000123
}
}
```
Поле `ts` в запросе необязательно для логики сервера. Сервер его не валидирует и не использует для принятия решения.
### Успешный ответ
```json
{
"op": "Ping",
"requestId": "ping-001",
"status": 200,
"ok": true,
"payload": {
"ts": 1774700000456
}
}
```
### Специфические коды ошибок `Ping`
- У `Ping` нет специальных прикладных ошибок.
- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
---
## 2. `GetServerInfo`
### Назначение
Запрос публичной информации о сервере.
Он нужен клиенту для выбора сервера в децентрализованной сети. По этому запросу клиент может:
- проверить, что сервер вообще доступен;
- показать URL и версию сервера;
- показать физический регион или адрес размещения;
- показать описание сервера;
- показать поле `origin` как комментарий о природе этого узла;
- показать дополнительную текстовую информацию.
Этот запрос доступен без авторизации.
### Источник данных
- `version` берётся из Gradle build и подставляется в `application.properties`;
- остальные поля читаются из настроек сервера;
- если значение в конфиге не задано, сервер возвращает пустую строку.
### Запрос
```json
{
"op": "GetServerInfo",
"requestId": "srv-001",
"payload": {
}
}
```
### Успешный ответ
```json
{
"op": "GetServerInfo",
"requestId": "srv-001",
"status": 200,
"ok": true,
"payload": {
"url": "wss://node.example.org/ws",
"version": "1.0",
"physicalRegion": "Грузия, Тбилиси",
"description": "Public community SHiNE node",
"origin": "Community-operated node",
"extraInfo": "IPv4 + IPv6; test federation enabled"
}
}
```
### Поля ответа
- `url` — публичный URL сервера.
- `version` — версия сервера из Gradle build.
- `physicalRegion` — физический регион или адрес размещения сервера.
- `description` — человекочитаемое описание сервера.
- `origin` — комментарий о том, какой это сервер.
- `extraInfo` — любая дополнительная информация о сервере.
### Специфические коды ошибок `GetServerInfo`
- У `GetServerInfo` нет специальных прикладных ошибок при штатной работе.
- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
---
## 3. `ListBlockchainHeads`
### Назначение
Запрос краткой сводки по всем локальным блокчейнам сервера.
Нужен для межсерверной синхронизации. Партнёр может:
- получить список всех блокчейнов;
- сравнить `lastBlockNumber` и `lastBlockHash` со своими значениями;
- понять, какие цепочки нужно догонять;
- затем отдельно запросить недостающие блоки по диапазону.
Этот запрос доступен без авторизации.
### Запрос
```json
{
"op": "ListBlockchainHeads",
"requestId": "heads-001",
"payload": {}
}
```
### Успешный ответ
```json
{
"op": "ListBlockchainHeads",
"requestId": "heads-001",
"status": 200,
"ok": true,
"payload": {
"blockchains": [
{
"blockchainName": "alice_main",
"lastBlockNumber": 124,
"lastBlockHash": "aabbccdd00112233445566778899aabbccddeeff00112233445566778899aabb",
"fileSizeBytes": 58720
}
]
}
}
```
### Поля ответа
- `blockchains` — массив текущих heads всех цепочек сервера.
- `blockchainName` — имя блокчейна.
- `lastBlockNumber` — последний номер блока в этой цепочке.
- `lastBlockHash` — последний хэш блока в HEX-формате `64` символа.
- `fileSizeBytes` — текущий размер файла блокчейна в байтах.
### Специфические коды ошибок `ListBlockchainHeads`
- У `ListBlockchainHeads` нет специальных прикладных ошибок при штатной работе.
- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
---
## 4. `GetSyncUserProfile`
### Назначение
Запрос минимального профиля пользователя для межсерверной синхронизации.
Нужен в сценарии, когда сервер во время periodic sync увидел чужой блокчейн, которого у него локально ещё нет. Вместо обращения в Solana PDA он может запросить у партнёра:
- `login`
- `blockchainName`
- `solanaKey`
- `blockchainKey`
- `clientKey`
- `blockchainSizeLimitBytes`
После этого принимающий сервер может локально создать записи в `solana_users` и `blockchain_state`, а затем уже докачивать блоки через `GetBlockchainBlock`.
Этот запрос доступен без авторизации и предназначен именно для server-to-server sync.
### Запрос
```json
{
"op": "GetSyncUserProfile",
"requestId": "sync-user-001",
"payload": {
"login": "alice"
}
}
```
### Успешный ответ: пользователь не найден
```json
{
"op": "GetSyncUserProfile",
"requestId": "sync-user-001",
"status": 200,
"ok": true,
"payload": {
"exists": false
}
}
```
### Успешный ответ: пользователь найден
```json
{
"op": "GetSyncUserProfile",
"requestId": "sync-user-001",
"status": 200,
"ok": true,
"payload": {
"exists": true,
"login": "alice",
"blockchainName": "alice-001",
"solanaKey": "BASE64_32",
"blockchainKey": "BASE64_32",
"clientKey": "BASE64_32",
"blockchainSizeLimitBytes": 100000
}
}
```
### Поля ответа
- `exists` — найден ли пользователь на сервере-партнёре.
- `login` — канонический login из БД сервера-партнёра.
- `blockchainName` — имя основной цепочки пользователя.
- `solanaKey` — публичный ключ логина.
- `blockchainKey` — публичный ключ блокчейна.
- `clientKey` — публичный клиентский ключ, который в текущей модели используется при создании локальной записи.
- `blockchainSizeLimitBytes` — лимит размера файла блокчейна, который будет записан в локальный `blockchain_state`.
### Специфические коды ошибок `GetSyncUserProfile`
- `400 / BAD_FIELDS` — пустой или некорректный `login`.
- `404 / BLOCKCHAIN_STATE_NOT_FOUND` — пользователь найден, но на сервере-партнёре отсутствует `blockchain_state` для его цепочки.
- При непредвиденной ошибке сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
---
## 5. `SendSignal`
Доступно только после успешной авторизации.
### Назначение
Общий межсессионный технический сигнал.
Этот метод нужен для случаев, когда одна активная сессия пользователя должна быстро передать служебную команду другой сессии того же пользователя или сразу всем его активным сессиям.
Первый целевой сценарий:
- `remote AddBlock via homeserver session`
То есть телефон без локального `blockchain.key` может:
- подготовить только сырой payload операции без текущей вершины цепочки;
- подписать сам `SendSignal` своим `session key`;
- дополнительно подписать его `client key`, чтобы homeserver/ESP32 точно видел, что запрос пришёл от доверенного клиента этого же логина;
- отправить запрос в выбранную `homeserver`-сессию;
- получить от неё ответ после настоящего `AddBlock`, который homeserver соберёт и подпишет уже сама.
### Режимы доставки
- `targetMode = "single_session"` — доставка в одну конкретную `targetSessionId`.
- `targetMode = "all_sessions"` — доставка во все активные сессии указанного логина.
### Важное правило подписи
Сам `SendSignal` не подписывает поле `data` отдельной вложенной подписью. Вместо этого сервер проверяет подписи по общему preimage сигнала, в который входит:
- `fromLogin`
- `fromSessionId`
- `toLogin`
- `targetMode`
- `targetSessionId`
- `signalType`
- `signalRequestId`
- `timeMs`
- `sha256(data)`
Поддерживаются две подписи:
- `sessionSignatureB64` — обязательная подпись текущей авторизованной `session key`;
- `clientSignatureB64` — необязательная подпись `client key`.
Для сценария `remote AddBlock via homeserver` текущая договорённость такая:
- запрос должен идти только своему же логину;
- запрос должен быть подписан и `session key`, и `client key`;
- в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой.
### Запрос в одну сессию
```json
{
"op": "SendSignal",
"requestId": "ws-req-001",
"payload": {
"toLogin": "alice",
"targetMode": "single_session",
"targetSessionId": "sess-hs-001",
"signalType": "remote_addblock_request",
"signalRequestId": "remote-addblock-001",
"data": "{\"operation\":\"remote_addblock_request\",\"signalRequestId\":\"remote-addblock-001\",\"blockchainName\":\"alice_main\",\"blockBodyB64\":\"...\"}",
"timeMs": 1774700000123,
"sessionSignatureB64": "BASE64_64",
"clientSignatureB64": "BASE64_64"
}
}
```
### Успешный ответ
```json
{
"op": "SendSignal",
"requestId": "ws-req-001",
"status": 200,
"ok": true,
"payload": {
"deliveredCount": 1,
"deliveredSessionIds": ["sess-hs-001"]
}
}
```
### Событие на принимающей стороне
```json
{
"op": "IncomingSignal",
"eventId": "evt-001",
"payload": {
"fromLogin": "alice",
"fromSessionId": "sess-phone-001",
"toLogin": "alice",
"targetMode": "single_session",
"targetSessionId": "sess-hs-001",
"signalType": "remote_addblock_request",
"signalRequestId": "remote-addblock-001",
"data": "{\"operation\":\"remote_addblock_request\",\"signalRequestId\":\"remote-addblock-001\",\"blockchainName\":\"alice_main\",\"blockBodyB64\":\"...\"}",
"timeMs": 1774700000123,
"sessionSignatureB64": "BASE64_64",
"clientSignatureB64": "BASE64_64",
"dataSha256B64": "BASE64_32"
}
}
```
### Специфика `remote AddBlock`
Для `remote_addblock_request` поле `data` теперь содержит:
- `blockchainName`
- `blockBodyB64`
Где `blockBodyB64` — это не финальный блок и не почти готовый preimage, а компактный бинарный контейнер:
- `msgType` (`u16`)
- `msgSubType` (`u16`)
- `msgVersion` (`u16`)
- `bodyBytes`
После этого homeserver сама:
- вызывает `GetUser(login)` и получает `serverLastGlobalNumber/serverLastGlobalHash`;
- вычисляет новый `blockNumber = last + 1`;
- подставляет актуальный `prevBlockHash`;
- ставит текущее время;
- досчитывает полный preimage;
- подписывает его своим `blockchain key`;
- и только потом делает настоящий `AddBlock`.
### Специфические коды ошибок `SendSignal`
- `422 / NOT_AUTHENTICATED` — требуется авторизация.
- `400 / BAD_FIELDS` — не хватает обязательных полей или нарушено правило `single_session/all_sessions`.
- `400 / BAD_TARGET_MODE` — передан неизвестный `targetMode`.
- `400 / TIME_SKEW``timeMs` отличается от серверного более чем на 30 секунд.
- `500 / NO_CLIENT_KEY` — для текущего пользователя не найден `client key`.
- `404 / USER_NOT_FOUND` — логин адресата не найден.
- `400 / BAD_DATA` — сервер не смог обработать `data`.
- `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`.
- `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`.
- `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн.
- `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий.
- `404 / DELIVERY_FAILED` — сервер не смог отправить событие ни в одну из целевых сессий.
---
## 6. `GetCallIceConfig`
Доступно только после успешной авторизации.
### Запрос
```json
{
"op": "GetCallIceConfig",
"requestId": "ice-001",
"payload": {
}
}
```
### Успешный ответ
```json
{
"op": "GetCallIceConfig",
"requestId": "ice-001",
"status": 200,
"ok": true,
"payload": {
"stunUrls": ["stun:stun.example.org:3478"],
"turnUrls": ["turn:turn.example.org:3478?transport=udp"],
"turnUsername": "user",
"turnPassword": "password",
"turnServers": [
{
"id": "primary",
"urls": ["turn:turn.example.org:3478?transport=udp"],
"username": "user",
"password": "password"
}
],
"turnEnabled": true,
"generatedAtMs": 1774700000123,
"expiresAtMs": 1774700300123,
"ttlSec": 300
}
}
```
### Специфические коды ошибок `GetCallIceConfig`
- `422 / NOT_AUTHENTICATED` — требуется авторизация.
---
## 7. `ClientErrorLog`
### Запрос
```json
{
"op": "ClientErrorLog",
"requestId": "err-001",
"payload": {
"kind": "global_error",
"message": "TypeError: failed",
"stack": "...",
"sourceUrl": "https://shineup.me/app.js",
"lineNumber": 10,
"columnNumber": 20,
"route": "#/channel-view/own-0",
"href": "https://shineup.me/#/channel-view/own-0",
"userAgent": "...",
"clientTs": 1774700000123,
"requestOp": "GetChannelMessages",
"requestIdRef": "GetChannelMessages-123",
"contextJson": "{\"screen\":\"channels\"}"
}
}
```
### Успешный ответ
```json
{
"op": "ClientErrorLog",
"requestId": "err-001",
"status": 200,
"ok": true,
"payload": {
"serverTs": 1774700000456,
"accepted": true
}
}
```
### Специфические коды ошибок `ClientErrorLog`
- `400 / BAD_FIELDS` — обязательные поля ошибки не заполнены.
---
## 8. `ClientDebugLog`
### Запрос
```json
{
"op": "ClientDebugLog",
"requestId": "dbg-001",
"payload": {
"runId": "ui-run-1",
"level": "info",
"message": "opened channels tab",
"details": "{\"route\":\"#/channels\"}"
}
}
```
### Успешный ответ
```json
{
"op": "ClientDebugLog",
"requestId": "dbg-001",
"status": 200,
"ok": true,
"payload": {
"accepted": true,
"serverTs": 1774700000456
}
}
```
### Специфические коды ошибок `ClientDebugLog`
- `400 / BAD_FIELDS` — поле `message` не заполнено.
---
## 9. `CallDeliveryReport`
### Запрос
```json
{
"op": "CallDeliveryReport",
"requestId": "call-report-001",
"payload": {
"type": "outgoing_failed",
"value": "{\"reason\":\"ice_failed\",\"callId\":\"call-1\"}"
}
}
```
### Успешный ответ
```json
{
"op": "CallDeliveryReport",
"requestId": "call-report-001",
"status": 200,
"ok": true,
"payload": {
"serverTs": 1774700000456,
"accepted": true
}
}
```
### Специфические коды ошибок `CallDeliveryReport`
- `400 / BAD_FIELDS` — поле `type` не заполнено.
---
## 10. Короткое резюме
- `Ping` нужен для keep-alive и проверки, что WebSocket-соединение живо.
- `GetServerInfo` нужен для выбора сервера в сети и показа публичной информации об узле.
- `SendSignal` нужен для доверенных межсессионных сигналов одного пользователя, включая `remote AddBlock via homeserver session`.
- `GetCallIceConfig` нужен для WebRTC-звонков и требует авторизации.
- `ClientErrorLog`, `ClientDebugLog`, `CallDeliveryReport` используются для диагностики клиента и звонков.
+341
View File
@@ -0,0 +1,341 @@
# 06. Channels Read API
## Человеко-читаемое объяснение
Эти функции — это **чтение данных каналов** для UI:
1. `ListSubscriptionsFeed` — отдает данные для экрана списка каналов:
- ваши каналы (личный + созданные вами),
- каналы пользователей, на кого вы подписаны,
- отдельные каналы, на которые вы подписаны напрямую.
2. `GetChannelMessages` — отдает полную ленту одного канала (пока без курсоров, загружается сразу целиком),
включая версии сообщений, лайки и ответы.
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
предки, фокус-сообщение, потомки.
4. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
5. `ListGroupChats200` — отдает список групповых чатов типа `200`.
6. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
---
## 1) ListSubscriptionsFeed
### Request
```json
{
"op": "ListSubscriptionsFeed",
"requestId": "req-1",
"payload": {
"login": "Alice",
"limit": 200
}
}
```
### Response (success)
```json
{
"op": "ListSubscriptionsFeed",
"requestId": "req-1",
"status": 200,
"ok": true,
"payload": {
"login": "Alice",
"ownedChannels": [
{
"channel": {
"ownerLogin": "Alice",
"ownerBlockchainName": "alice-001",
"channelName": "0",
"personal": true,
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
},
"messagesCount": 120,
"lastMessage": {
"messageRef": { "blockNumber": 921, "blockHash": "..." },
"text": "последняя версия текста",
"createdAtMs": 1760000000000,
"authorLogin": "Alice",
"authorBlockchainName": "alice-001"
}
}
],
"followedUsersChannels": [
{
"channel": {
"ownerLogin": "Bob",
"ownerBlockchainName": "bob-001",
"channelName": "0",
"personal": true,
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
},
"messagesCount": 540,
"lastMessage": {
"messageRef": { "blockNumber": 922, "blockHash": "..." },
"text": "последняя версия текста",
"createdAtMs": 1760000100000,
"authorLogin": "Bob",
"authorBlockchainName": "bob-001"
}
}
],
"followedChannels": [
{
"channel": {
"ownerLogin": "Carl",
"ownerBlockchainName": "carl-001",
"channelName": "market",
"personal": false,
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
},
"messagesCount": 90,
"lastMessage": {
"messageRef": { "blockNumber": 1002, "blockHash": "..." },
"text": "актуальный текст",
"createdAtMs": 1760001000000,
"authorLogin": "Carl",
"authorBlockchainName": "carl-001"
}
}
]
}
}
```
---
## 2) GetChannelMessages
### Request
```json
{
"op": "GetChannelMessages",
"requestId": "req-2",
"payload": {
"channel": {
"ownerBlockchainName": "bob-001",
"channelRootBlockNumber": 123,
"channelRootBlockHash": "..."
},
"limit": 200,
"sort": "asc"
}
}
```
### Response (success)
```json
{
"op": "GetChannelMessages",
"requestId": "req-2",
"status": 200,
"ok": true,
"payload": {
"channel": {
"ownerLogin": "Bob",
"ownerBlockchainName": "bob-001",
"channelName": "news",
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
},
"messages": [
{
"messageRef": { "blockNumber": 140, "blockHash": "..." },
"authorLogin": "Bob",
"authorBlockchainName": "bob-001",
"createdAtMs": 1760000000000,
"text": "текущая версия",
"likesCount": 12,
"repliesCount": 3,
"versionsTotal": 4,
"versions": [
{ "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 },
{ "versionIndex": 2, "blockNumber": 155, "blockHash": "...", "text": "v2", "createdAtMs": 1760001000000 },
{ "versionIndex": 3, "blockNumber": 170, "blockHash": "...", "text": "v3", "createdAtMs": 1760002000000 },
{ "versionIndex": 4, "blockNumber": 199, "blockHash": "...", "text": "v4", "createdAtMs": 1760003000000 }
]
}
]
}
}
```
---
## 3) GetMessageThread
### Request
```json
{
"op": "GetMessageThread",
"requestId": "req-3",
"payload": {
"message": {
"blockchainName": "bob-001",
"blockNumber": 333,
"blockHash": "..."
},
"depthUp": 20,
"depthDown": 2,
"limitChildrenPerNode": 50
}
}
```
### Response (success)
```json
{
"op": "GetMessageThread",
"requestId": "req-3",
"status": 200,
"ok": true,
"payload": {
"ancestors": [MessageNode],
"focus": MessageNode,
"descendants": [MessageNodeTree]
}
}
```
### MessageNode (дополнение)
- `MessageNode` расширяет формат сообщения из `GetChannelMessages` и дополнительно содержит:
- `channelInfo` — мета-информация о канале (если применимо);
- `rawBlockB64` — сырой `block_bytes` текущего блока в Base64.
- Поле `rawBlockB64` присутствует у узлов во всех частях ответа `GetMessageThread`: `focus`, `ancestors[]`, `descendants[]`.
- В `GetChannelMessages` поле `rawBlockB64` **не добавляется** (лента канала без сырого блока, чтобы не раздувать ответ).
---
## 4) GetChannelsCounters
### Request
```json
{
"op": "GetChannelsCounters",
"requestId": "req-4",
"payload": {
"login": "Alice"
}
}
```
### Response (success)
```json
{
"op": "GetChannelsCounters",
"requestId": "req-4",
"status": 200,
"ok": true,
"payload": {
"login": "Alice",
"feedCount": 12,
"dialogs100Count": 3,
"groupChats200Count": 4,
"myChannelsCount": 2
}
}
```
---
## 5) ListGroupChats200
### Request
```json
{
"op": "ListGroupChats200",
"requestId": "req-5",
"payload": {
"login": "Alice"
}
}
```
### Response (success)
```json
{
"op": "ListGroupChats200",
"requestId": "req-5",
"status": 200,
"ok": true,
"payload": {
"login": "Alice",
"chats": [
{
"ownerLogin": "Alice",
"ownerBlockchainName": "alice-001",
"channelRootBlockNumber": 123,
"channelRootBlockHash": "...",
"channelName": "team",
"chatTitle": "Team chat",
"membersCount": 3,
"updatedAtMs": 1760000000000
}
]
}
}
```
---
## 6) GetGroupDialog
### Request
```json
{
"op": "GetGroupDialog",
"requestId": "req-6",
"payload": {
"login": "Alice",
"group": {
"ownerBlockchainName": "alice-001",
"channelRootBlockNumber": 123
}
}
}
```
### Response (success)
```json
{
"op": "GetGroupDialog",
"requestId": "req-6",
"status": 200,
"ok": true,
"payload": {
"group": {
"ownerLogin": "Alice",
"ownerBlockchainName": "alice-001",
"channelRootBlockNumber": 123,
"channelName": "team",
"chatTitle": "Team chat"
},
"messages": [
{
"authorLogin": "Bob",
"authorBlockchainName": "bob-001",
"blockNumber": 140,
"blockHash": "...",
"createdAtMs": 1760000000000,
"text": "Привет"
}
]
}
}
```
---
## Reason codes
- `bad_fields`
- `user_not_found`
- `channel_not_found`
- `message_not_found`
- `limit_too_large`
- `channel_name_already_exists`
- `internal_error`
+145
View File
@@ -0,0 +1,145 @@
# 07. Channels Feature Runbook (человеческое описание + диагностика)
## 1) Что уже сделано простыми словами
Сейчас реализован полный минимальный контур для каналов:
1. **Серверные read API**:
- `ListSubscriptionsFeed` — экран списка каналов.
- `GetChannelMessages` — сообщения конкретного канала.
- `GetMessageThread` — дерево обсуждения для сообщения.
2. **UI вкладки Каналы**:
- при открытии пытается загрузить реальный feed с сервера;
- если сервер недоступен — fallback на мок-данные;
- группы каналов выводятся в нужном порядке;
- есть кнопка «Добавить канал», модалки подписки, переход в канал.
3. **Проверка уникальности имени канала на сервере**
- в `AddBlock` при `CreateChannelBody` добавлена проверка;
- при дубле возвращается `409 channel_name_already_exists`.
---
## 2) Что тестировать в первую очередь (быстрый чеклист)
### Базовый smoke
1. Авторизоваться в UI.
2. Открыть вкладку «Каналы».
3. Убедиться, что данные загрузились с сервера (или виден fallback-баннер).
4. Нажать любой канал — должен открыться экран канала с сообщениями.
### API smoke
1. Вызвать `ListSubscriptionsFeed`.
2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`.
3. Для первого `messages[0]` вызвать `GetMessageThread`.
### Ошибки
1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`.
2. `GetChannelMessages` с битым channel payload -> `bad_fields`.
3. `GetMessageThread` с несуществующим block -> `message_not_found`.
4. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`.
---
## 3) Готовые JSON-запросы для ручной диагностики
## 3.1 ListSubscriptionsFeed
```json
{
"op": "ListSubscriptionsFeed",
"requestId": "debug-feed-1",
"payload": {
"login": "TestUser1",
"limit": 200
}
}
```
## 3.2 GetChannelMessages
```json
{
"op": "GetChannelMessages",
"requestId": "debug-ch-1",
"payload": {
"channel": {
"ownerBlockchainName": "TestUser1-001",
"channelRootBlockNumber": 0,
"channelRootBlockHash": ""
},
"limit": 200,
"sort": "asc"
}
}
```
## 3.3 GetMessageThread
```json
{
"op": "GetMessageThread",
"requestId": "debug-thread-1",
"payload": {
"message": {
"blockchainName": "TestUser1-001",
"blockNumber": 123,
"blockHash": "<hash-from-GetChannelMessages>"
},
"depthUp": 20,
"depthDown": 2,
"limitChildrenPerNode": 50
}
}
```
---
## 4) Что смотреть в ответах
### ListSubscriptionsFeed
- `payload.login` — канонический login.
- `ownedChannels / followedUsersChannels / followedChannels` — массивы.
- у каждой записи есть:
- `channel.channelRoot.blockNumber`,
- `messagesCount`,
- `lastMessage` (может быть null, если сообщений нет).
### GetChannelMessages
- `payload.channel` заполнен;
- `payload.messages[]` содержит:
- `likesCount`, `repliesCount`,
- `versionsTotal`, `versions[]`,
- `text` должен быть текущей (последней) версией.
### GetMessageThread
- `payload.ancestors[]`, `payload.focus`, `payload.descendants[]`.
- у узлов должны быть версии и счетчики.
- у каждого узла дополнительно может приходить `rawBlockB64` (Base64 сырого `block_bytes`).
### Важно по совместимости
- `rawBlockB64` добавлен только в `GetMessageThread`.
- `GetChannelMessages` не содержит `rawBlockB64` (без изменений формата ленты).
---
## 5) Частые проблемы и как быстро локализовать
1. **`status != 200`, code=bad_fields**
- проверить вложенность payload и обязательные поля.
2. **`message_not_found` в GetMessageThread**
- обычно передали blockNumber/hash не из `messageRef`.
3. **Пустой список сообщений в GetChannelMessages**
- проверить `ownerBlockchainName` и `channelRootBlockNumber`.
4. **`channel_name_already_exists` при AddBlock**
- это ожидаемо: в этой цепочке уже есть канал с таким именем.
---
## 6) Для будущей доработки
1. Добавить курсоры (пагинацию) для больших каналов.
2. Перевести «Подписаться»/«Добавить канал» в UI с демо-заглушек на реальные write RPC.
3. Добавить batch-агрегации для thread/versions (оптимизация).
4. Добавить полноценные интеграционные тесты на негативные кейсы и нагрузку.
@@ -0,0 +1,162 @@
# MCP: чтение и дозапись персонального публичного чата (type=100)
Документ для реализации MCP-инструмента, который:
- читает переписку между двумя логинами (`from`, `to`);
- добавляет новое сообщение от отправителя через серверный `AddBlock`.
Важно: речь про **персональные публичные** каналы (`channelTypeCode=100`), а не приватные DM.
## 1. Базовые предпосылки
1. У каждого пользователя свой блокчейн (`<login>-001`).
2. Персональный публичный чат хранится как канал типа `100`:
- у `A` канал с `channelName = B`;
- у `B` зеркальный канал с `channelName = A`.
3. Сообщения канала — `TEXT_POST` и `TEXT_REPOST` в линии `line_code = rootBlockNumber` канала.
4. Запись блока возможна только при валидной подписи blockchain-ключом владельца цепочки.
## 2. Что должен уметь MCP-инструмент
Минимальный набор операций:
1. `read_personal_public_dialog(fromLogin, toLogin, limitPerSide=200)`
2. `append_personal_public_message(fromLogin, toLogin, text)`
## 3. Алгоритм чтения переписки
### 3.1 Найти оба канала (прямой и зеркальный)
Для `fromLogin = A`, `toLogin = B`:
1. Запросить `ListSubscriptionsFeed` для `A` и найти owned-канал:
- `ownerLogin == A`
- `channelName == B`
- `channelTypeCode == 100`
2. Запросить `ListSubscriptionsFeed` для `B` и найти owned-канал:
- `ownerLogin == B`
- `channelName == A`
- `channelTypeCode == 100`
Если какой-то из каналов не найден — вернуть частичный результат + флаг отсутствия зеркала.
### 3.2 Вычитать сообщения из каналов
Для каждого найденного канала вызвать `GetChannelMessages`:
```json
{
"op": "GetChannelMessages",
"payload": {
"login": "<текущий-login-сессии>",
"channel": {
"ownerBlockchainName": "...",
"channelRootBlockNumber": 123,
"channelRootBlockHash": "..."
},
"limit": 200,
"sort": "asc"
}
}
```
### 3.3 Склеить в единый диалог
1. Объединить массивы сообщений из `A->B` и `B->A`.
2. Отсортировать по `createdAtMs`, при равенстве — по `messageRef.blockNumber`.
3. Вернуть структуру:
- `messages[]`
- `directChannelFound` / `reverseChannelFound`
- метаданные обоих каналов.
## 4. Алгоритм дозаписи сообщения
Цель: добавить сообщение **от имени `fromLogin`** в его канал `fromLogin -> toLogin`.
### 4.1 Найти канал отправителя
Через `ListSubscriptionsFeed(fromLogin)` найти owned-канал:
- `channelName == toLogin`
- `channelTypeCode == 100`
Если канал не найден — вернуть ошибку `channel_not_found`.
### 4.2 Отправить `AddBlock` с `TEXT_POST`
Использовать клиентский/серверный helper формирования `TEXT_POST` body:
- `lineCode = channelRootBlockNumber`;
- `prevLineNumber/prevLineHash` берутся из последнего сообщения линии;
- подпись — blockchain private key пользователя `fromLogin`.
Если у вас в MCP нет приватного ключа пользователя, дозапись невозможна.
## 5. Контракт MCP (рекомендуемый)
## `read_personal_public_dialog`
Вход:
```json
{
"fromLogin": "alice",
"toLogin": "bob",
"limitPerSide": 200
}
```
Выход:
```json
{
"ok": true,
"directChannelFound": true,
"reverseChannelFound": true,
"messages": [
{
"authorLogin": "alice",
"text": "Привет",
"createdAtMs": 1760000000000,
"channelSide": "alice->bob",
"messageRef": { "blockNumber": 11, "blockHash": "..." }
}
]
}
```
## `append_personal_public_message`
Вход:
```json
{
"fromLogin": "alice",
"toLogin": "bob",
"text": "Тест"
}
```
Выход:
```json
{
"ok": true,
"serverLastGlobalNumber": 321,
"serverLastGlobalHash": "..."
}
```
## 6. Ограничения и безопасность
1. Персональный канал типа `100` сейчас публичный по модели чтения (не E2E DM).
2. Нельзя дозаписать блок в чужой блокчейн без приватного ключа владельца (проверка подписи сервером).
3. Для прод-инструмента нужно:
- строгая авторизация MCP-вызовов;
- аудит, кто и от чьего имени запрашивал чтение/запись;
- лимиты/квоты на запись.
## 7. Мини-чеклист для реализации MCP
1. Реализовать helper поиска канала `findOwnedPersonalChannel(ownerLogin, peerLogin)`.
2. Реализовать чтение двух сторон и merge/sort.
3. Реализовать отправку `TEXT_POST` в найденный канал отправителя.
4. Добавить понятные ошибки:
- `user_not_found`
- `channel_not_found`
- `reverse_channel_not_found`
- `signature_required`
- `add_block_failed`
+75
View File
@@ -0,0 +1,75 @@
# API для разработчиков: индекс операций
Этот файл фиксирует полный список публичных JSON/WebSocket операций, зарегистрированных в коде сервера.
Источник истины на момент актуализации:
- `shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java`.
Если операция есть в `HANDLERS` и `REQUEST_TYPES`, клиент может отправлять её как `op` в общем JSON-конверте из `00_Common_API_Format.md`.
## Актуальные операции
| Операция | Раздел документации | Кратко |
| --- | --- | --- |
| `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) |
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна |
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара |
| `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave |
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
| `SessionLogin` | `02_Authentication_API.md` | вход в существующую сессию |
| `GetTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | чтение текущего режима входа через доверенное устройство |
| `UpsertTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией |
| `StartTrustedDeviceLogin` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства |
| `ListTrustedDeviceLoginRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии |
| `ApproveTrustedDeviceLogin` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией |
| `RejectTrustedDeviceLogin` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией |
| `CancelTrustedDeviceLogin` | `03_Session_Management_API.md` | отмена pairing-заявки со стороны нового ожидающего устройства |
| `GetTrustedDeviceLoginStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки |
| `ListSessions` | `03_Session_Management_API.md` | список активных сессий |
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
| `Ping` | `05_Technical_Requests_API.md` | keep-alive |
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
| `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов |
| `GetSyncUserProfile` | `05_Technical_Requests_API.md` | межсерверный профиль пользователя для синхронизации |
| `SendSignal` | `05_Technical_Requests_API.md` | общий межсессионный технический сигнал в одну или все сессии пользователя |
| `GetCallIceConfig` | `05_Technical_Requests_API.md` | STUN/TURN конфигурация звонков |
| `ClientErrorLog` | `05_Technical_Requests_API.md` | логирование клиентской ошибки |
| `ClientDebugLog` | `05_Technical_Requests_API.md` | клиентский debug-лог |
| `CallDeliveryReport` | `05_Technical_Requests_API.md` | диагностика доставки/установки звонков |
| `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
| `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала |
| `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения |
| `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
| `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя |
| `GetUserParam` | `10_User_Params_API.md` | чтение одного параметра пользователя |
| `ListUserParams` | `10_User_Params_API.md` | список параметров пользователя |
| `GetFriendsLists` | `11_Connections_API.md` | входящие/исходящие друзья |
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
| `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | алиас `SendMessagePair` |
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
| `CallSignalToSession` | `12_Direct_Messages_Push_Calls_API.md` | сигнал звонка в конкретную сессию |
## Важные замечания
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
- Legacy-операция `SendDirectMessage` больше не зарегистрирована и не должна использоваться для DM v1.
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
+129
View File
@@ -0,0 +1,129 @@
# API для разработчиков: параметры пользователя
Документ описывает операции для записи и чтения пользовательских параметров.
Текущие операции:
- `UpsertUserParam`
- `GetUserParam`
- `ListUserParams`
## 1. `UpsertUserParam`
### Запрос
```json
{
"op": "UpsertUserParam",
"requestId": "param-upsert-001",
"payload": {
"login": "alice",
"param": "display_name",
"time_ms": 1774700000123,
"value": "Alice",
"client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE"
}
}
```
### Успешный ответ
```json
{
"op": "UpsertUserParam",
"requestId": "param-upsert-001",
"status": 200,
"ok": true,
"payload": {
}
}
```
### Типовые ошибки
- `400 / BAD_FIELDS` — некорректные обязательные поля.
- `422 / BAD_SIGNATURE` — подпись не прошла проверку.
- `501 / DB_ERROR` — ошибка БД.
---
## 2. `GetUserParam`
### Запрос
```json
{
"op": "GetUserParam",
"requestId": "param-get-001",
"payload": {
"login": "alice",
"param": "display_name"
}
}
```
### Успешный ответ
```json
{
"op": "GetUserParam",
"requestId": "param-get-001",
"status": 200,
"ok": true,
"payload": {
"login": "alice",
"param": "display_name",
"time_ms": 1774700000123,
"value": "Alice",
"client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE"
}
}
```
Если параметр не найден, сервер возвращает `404` с пустым `payload`; отдельный прикладной код ошибки текущий handler не задаёт.
---
## 3. `ListUserParams`
### Запрос
```json
{
"op": "ListUserParams",
"requestId": "param-list-001",
"payload": {
"login": "alice"
}
}
```
### Успешный ответ
```json
{
"op": "ListUserParams",
"requestId": "param-list-001",
"status": 200,
"ok": true,
"payload": {
"login": "alice",
"params": [
{
"login": "alice",
"param": "display_name",
"time_ms": 1774700000123,
"value": "Alice",
"client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE"
}
]
}
}
```
## Примечание
Имена JSON-полей `time_ms` и `client_key` сейчас соответствуют Java-модели ответа/запроса и должны передаваться именно в таком виде.
+174
View File
@@ -0,0 +1,174 @@
# API для разработчиков: связи пользователей
Документ описывает операции чтения и записи пользовательских связей.
Текущие операции:
- `GetFriendsLists`
- `ListContacts`
- `GetUserConnectionsGraph`
- `AddCloseFriend`
## 1. `GetFriendsLists`
### Запрос
```json
{
"op": "GetFriendsLists",
"requestId": "friends-001",
"payload": {
"login": "alice"
}
}
```
### Успешный ответ
```json
{
"op": "GetFriendsLists",
"requestId": "friends-001",
"status": 200,
"ok": true,
"payload": {
"login": "Alice",
"out_friends": ["Bob"],
"in_friends": ["Kate"]
}
}
```
---
## 2. `ListContacts`
`ListContacts` использует текущую авторизованную сессию. В payload нет дополнительных полей.
### Запрос
```json
{
"op": "ListContacts",
"requestId": "contacts-001",
"payload": {
}
}
```
### Успешный ответ
```json
{
"op": "ListContacts",
"requestId": "contacts-001",
"status": 200,
"ok": true,
"payload": {
"login": "Alice",
"contacts": ["Bob", "Kate"]
}
}
```
---
## 3. `GetUserConnectionsGraph`
### Запрос
```json
{
"op": "GetUserConnectionsGraph",
"requestId": "graph-001",
"payload": {
"login": "alice"
}
}
```
### Успешный ответ
```json
{
"op": "GetUserConnectionsGraph",
"requestId": "graph-001",
"status": 200,
"ok": true,
"payload": {
"login": "Alice",
"outFriends": ["Bob"],
"inFriends": ["Kate"],
"outContacts": [],
"inContacts": [],
"outFollows": [],
"inFollows": [],
"outSpouses": [],
"inSpouses": [],
"outParents": [],
"inParents": [],
"outChildren": [],
"inChildren": [],
"outSiblings": [],
"inSiblings": [],
"outKnownPersons": [],
"inKnownPersons": [],
"outShineConfirmed": [],
"inShineConfirmed": [],
"outShineSeen": [],
"inShineSeen": [],
"parents": [],
"children": [],
"siblings": [],
"spouses": [],
"allUsers": [
{
"login": "Bob",
"official": false,
"shine": true,
"officialLabel": "",
"shineLabel": "shine",
"avatar": { "ar": "..." }
}
]
}
}
```
### Примечание
Поля `known_person`, `shine_confirmed`, `shine_seen` в UI считаются недопроверенной зоной проекта; при изменениях этой логики нужна ручная end-to-end проверка.
---
## 4. `AddCloseFriend`
`AddCloseFriend` использует текущую авторизованную сессию как источник `login`.
### Запрос
```json
{
"op": "AddCloseFriend",
"requestId": "close-friend-001",
"payload": {
"toLogin": "bob"
}
}
```
### Успешный ответ
```json
{
"op": "AddCloseFriend",
"requestId": "close-friend-001",
"status": 200,
"ok": true,
"payload": {
"login": "Alice",
"toLogin": "Bob",
"relation": "close_friend"
}
}
```
@@ -0,0 +1,235 @@
# API для разработчиков: DM, push и сигналы звонков
Документ описывает публичные операции, связанные с личными сообщениями, WebPush и сигналами звонков.
Подробная логика DM и бинарного формата:
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`
- `Dev_Docs/Personal_Messages/Формат_DM_v1.md`
Важно:
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
- для DM v1 нужно использовать только `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`.
## 1. `UpsertPushToken`
Требует авторизации.
### Запрос
```json
{
"op": "UpsertPushToken",
"requestId": "push-upsert-001",
"payload": {
"sessionId": "SESSION_ID",
"endpoint": "https://push.example/...",
"p256dhKey": "BASE64",
"authKey": "BASE64",
"platform": "web",
"userAgent": "Mozilla/5.0 ..."
}
}
```
### Успешный ответ
```json
{
"op": "UpsertPushToken",
"requestId": "push-upsert-001",
"status": 200,
"ok": true,
"payload": {
"tokenId": "token-1",
"updatedAtMs": 1774700000123
}
}
```
## 2. `SendTestWebPush`
Требует авторизации.
### Запрос
```json
{
"op": "SendTestWebPush",
"requestId": "push-test-001",
"payload": {
"login": "alice",
"sessionId": "SESSION_ID",
"title": "Test",
"text": "Push body"
}
}
```
## 3. `SendMessagePair` и `ReceiveOutcomingMessage`
`ReceiveOutcomingMessage` — алиас `SendMessagePair`.
### Назначение
Передаёт пару signed DM-блоков:
- `incomingBlobB64` — блок `type=1` или `type=3`
- `outgoingBlobB64` — блок `type=2` или `type=4`
Все типы в этой паре используют бинарный формат `SHiNE_DM`.
### Запрос
```json
{
"op": "SendMessagePair",
"requestId": "dm-pair-001",
"payload": {
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK",
"outgoingBlobB64": "BASE64_OUTGOING_SIGNED_BLOCK"
}
}
```
### Успешный ответ
```json
{
"op": "SendMessagePair",
"requestId": "dm-pair-001",
"status": 200,
"ok": true,
"payload": {
"baseKey": "from|to|time|nonce",
"incomingKey": "from|to|time|nonce|1",
"outgoingKey": "from|to|time|nonce|2",
"deliveredWsSessions": 1,
"deliveredWebPushSessions": 0
}
}
```
### Ошибки
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
- `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён
- `400 / BAD_CRYPTO_METHOD` — неподдерживаемый метод шифрования контейнера `EncryptedBody_v1_0`
- `404 / USER_NOT_FOUND` — один из логинов не найден даже после попытки lazy-import из Solana PDA
- `460 / BAD_SIGNATURE` — подпись блока не прошла проверку
## 4. `ReceiveIncomingMessage`
Принимает только один входящий signed DM-блок.
### Назначение
Используется там, где нужно принять только incoming-вариант сообщения.
Принимаемые типы:
- `type=1` — входящая копия сообщения
- `type=3` — входящий read-receipt
### Запрос
```json
{
"op": "ReceiveIncomingMessage",
"requestId": "dm-in-001",
"payload": {
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK"
}
}
```
## 5. `DeleteMessage`
Принимает один signed DM-блок `type=5` или `type=6`.
### Запрос
```json
{
"op": "DeleteMessage",
"requestId": "dm-del-001",
"payload": {
"blobB64": "BASE64_DELETE_BLOCK"
}
}
```
## 6. `DeleteConversation`
Принимает один signed DM-блок `type=7` или `type=8`.
### Запрос
```json
{
"op": "DeleteConversation",
"requestId": "dm-del-all-001",
"payload": {
"blobB64": "BASE64_DELETE_CONVERSATION_BLOCK"
}
}
```
## 7. `AckSessionDelivery`
Требует авторизации. Подтверждает доставку в текущую сессию.
### Запрос
```json
{
"op": "AckSessionDelivery",
"requestId": "ack-001",
"payload": {
"messageKey": "from|to|time|nonce|1"
}
}
```
## 8. Событие `SignedMessageArrived`
Сервер присылает его по WebSocket в активные сессии адресата.
### Payload события
```json
{
"messageKey": "from|to|time|nonce|1",
"baseKey": "from|to|time|nonce",
"fromLogin": "alice",
"toLogin": "bob",
"targetLogin": "bob",
"messageType": 1,
"timeMs": 1774700000123,
"nonce": 123456789,
"blobB64": "BASE64_SIGNED_BLOCK",
"backlog": false
}
```
Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока.
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
## 9. `CallInviteBroadcast`
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
## 10. `CallSignalToSession`
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
## 11. Замечания
- все DM-типы `1..8` используют `SHiNE_DM`
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
- сервер не расшифровывает DM и не использует ciphertext как preview текста
- сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs`
- если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон
- HTTP endpoints для DM-файлов сейчас отсутствуют
+190
View File
@@ -0,0 +1,190 @@
# API для разработчиков: HTTP debug endpoints
Этот файл описывает отдельный HTTP debug API сервера. Он не использует WebSocket-конверт `op/requestId/payload` и включается только настройкой:
- `debug.tempApi.enabled=true`
Источник истины:
- `src/main/java/server/debug/DebugApiConfigurator.java`
- `src/main/java/server/debug/*Servlet.java`
Если `.debug-token` отсутствует или пуст, endpoints возвращают `503 / DEBUG_DISABLED`.
## Авторизация
Для большинства debug endpoints используется Bearer token из `.debug-token`:
```http
Authorization: Bearer <token>
```
Для `POST /debug/ws/ui-reload-all` поддерживается заголовок:
```http
X-Debug-Token: <token>
```
При неверном токене сервер возвращает `401 / UNAUTHORIZED`.
## Формат успешного HTTP-ответа
```json
{
"ok": true,
"payload": {
}
}
```
## Формат HTTP-ошибки
```json
{
"ok": false,
"code": "BAD_JSON",
"message": "Тело запроса должно быть JSON"
}
```
## 1. `GET /debug/ws/clients`
Возвращает список активных WebSocket-клиентов.
### Успешный ответ
```json
{
"ok": true,
"payload": {
"count": 1,
"clients": [
{
"sessionId": "SESSION_ID",
"login": "alice",
"authStatus": 2,
"wsOpen": true,
"remoteAddress": "127.0.0.1",
"ip": "127.0.0.1",
"userAgent": "Mozilla/5.0 ...",
"clientInfoFromClient": "...",
"clientInfoFromRequest": "...",
"userLanguage": "ru",
"sessionCreatedAtMs": 1774700000123
}
]
}
}
```
## 2. `POST /debug/ws/connect`
Запускает debug-сценарий соединения двух активных WS-сессий и отправляет им события:
- `DebugConnectPrepareResponder`
- `DebugConnectStartInitiator`
### Запрос
```json
{
"initiatorSessionId": "SESSION_ID_1",
"responderSessionId": "SESSION_ID_2",
"clearDebugLog": true
}
```
### Успешный ответ
```json
{
"ok": true,
"payload": {
"runId": "dbg-...",
"callId": "debug-call-...",
"accepted": true,
"initiatorSessionId": "SESSION_ID_1",
"responderSessionId": "SESSION_ID_2",
"initiatorLogin": "alice",
"responderLogin": "bob",
"mode": "cross-login"
}
}
```
### Ошибки
- `400 / BAD_JSON` — тело запроса не JSON.
- `400 / BAD_FIELDS` — не заполнены sessionId или переданы одинаковые sessionId.
- `404 / INITIATOR_NOT_FOUND` — сессия инициатора не найдена или неактивна.
- `404 / RESPONDER_NOT_FOUND` — сессия получателя не найдена или неактивна.
## 3. `GET /debug/ws/logs`
Возвращает tail debug-логов из `DebugRunLogBuffer`.
### Query-параметры
- `limit` — количество записей.
- `runId` — фильтр по runId.
### Успешный ответ
```json
{
"ok": true,
"payload": {
"count": 1,
"limit": 100,
"runIdFilter": "ui-run-1",
"logs": [
{
"ts": 1774700000123,
"level": "info",
"runId": "ui-run-1",
"source": "debug-connect",
"sessionId": "SESSION_ID",
"login": "alice",
"message": "opened channels tab",
"details": "{}"
}
]
}
}
```
## 4. `POST /debug/ws/ui-reload-all`
Рассылает активным UI-сессиям debug-событие на reload.
### Запрос
```json
{
"reason": "manual_debug_api",
"reloadAfterMs": 700
}
```
Если `reason` не передан или пустой, сервер использует `manual_debug_api`. `reloadAfterMs` ограничивается диапазоном `100..15000`.
### Успешный ответ
```json
{
"ok": true,
"payload": {
"accepted": true,
"reason": "manual_debug_api",
"reloadAfterMs": 700,
"issuedAtMs": 1774700000123,
"totalConnections": 2,
"sentCount": 2,
"skippedCount": 0
}
}
```
### Ошибки
- `400 / BAD_JSON` — тело запроса не JSON.
+176
View File
@@ -0,0 +1,176 @@
# Временное Test API для бесплатной загрузки аватаров в Arweave
> Статус: **временное тестовое решение**.
> Все операции из этого файла начинаются с `Test...`, чтобы это было видно сразу и в коде, и в UI.
## Назначение
Этот временный API даёт пользователю ограниченную бесплатную загрузку маленьких аватаров в Arweave:
- загрузка идёт через **серверный Arweave-кошелёк**;
- лимит на пользователя: по умолчанию `3` загрузки за всё время;
- лимит хранится в SQLite-таблице `test_free_avatar_uploads`;
- если лимит исчерпан, сервер возвращает понятную ошибку;
- загружать можно только маленький итоговый файл аватара, по умолчанию до `128 KB`.
## Настройки сервера
В `application.properties`:
```properties
test.freeAvatar.enabled=true
test.freeAvatar.gateway=https://arweave.net
test.freeAvatar.limitPerUser=3
test.freeAvatar.maxBytes=131072
test.freeAvatar.walletAddress=
test.freeAvatar.walletJwkPath=
```
Пояснения:
- `test.freeAvatar.enabled` - включить или выключить временный API;
- `test.freeAvatar.gateway` - Arweave gateway для `price/tx/wallet`;
- `test.freeAvatar.limitPerUser` - пожизненный бесплатный лимит на пользователя;
- `test.freeAvatar.maxBytes` - максимальный размер итогового файла;
- `test.freeAvatar.walletAddress` - публичный адрес серверного Arweave-кошелька;
- `test.freeAvatar.walletJwkPath` - путь к приватному JWK-файлу серверного кошелька.
Важно:
- приватный JWK хранится вне кода;
- если `walletAddress` указан и не совпадает с адресом, вычисленным из JWK, сервер вернёт ошибку настройки.
## `TestGetFreeAvatarQuota`
Возвращает остаток бесплатных загрузок для текущего авторизованного пользователя.
### Запрос
```json
{
"op": "TestGetFreeAvatarQuota",
"requestId": "req-test-avatar-quota-1",
"payload": {}
}
```
### Успешный ответ
```json
{
"op": "TestGetFreeAvatarQuota",
"requestId": "req-test-avatar-quota-1",
"status": 200,
"ok": true,
"payload": {
"enabled": true,
"limit": 3,
"usedCount": 1,
"remainingCount": 2,
"maxBytes": 131072
}
}
```
### Поля ответа
- `enabled` - временный API сейчас включён на сервере или нет;
- `limit` - полный лимит бесплатных загрузок;
- `usedCount` - сколько уже израсходовано;
- `remainingCount` - сколько ещё осталось;
- `maxBytes` - максимальный размер итогового файла.
### Ошибки
- `422 NOT_AUTHENTICATED` - требуется авторизация.
## `TestUploadFreeAvatar`
Временная бесплатная загрузка маленькой аватарки в Arweave через серверный кошелёк.
### Правила
- операция требует авторизованную сессию;
- сервер использует текущий login из сессии;
- сервер принимает только:
- `image/jpeg`
- `image/png`
- `image/webp`
- размер итогового файла должен быть не больше `maxBytes` из квоты;
- если пользователь уже сделал `limit` бесплатных загрузок, операция запрещена.
### Запрос
`fileBytesBase64` - это обычный Base64 байт итогового подготовленного файла.
```json
{
"op": "TestUploadFreeAvatar",
"requestId": "req-test-avatar-upload-1",
"payload": {
"contentType": "image/webp",
"fileBytesBase64": "UklGRiQAAABXRUJQVlA4WAoAAAAQAAAA...",
"sha256Hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
}
```
### Успешный ответ
```json
{
"op": "TestUploadFreeAvatar",
"requestId": "req-test-avatar-upload-1",
"status": 200,
"ok": true,
"payload": {
"txId": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"sha256Hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"usedCount": 2,
"remainingCount": 1,
"limit": 3,
"gateway": "https://arweave.net"
}
}
```
### Поля ответа
- `txId` - Arweave Transaction ID загруженного файла;
- `sha256Hex` - SHA-256 загруженного файла;
- `usedCount` - сколько бесплатных загрузок уже израсходовано после этой операции;
- `remainingCount` - сколько бесплатных загрузок осталось;
- `limit` - общий лимит;
- `gateway` - gateway, через который сервер отправлял транзакцию.
### Ошибки
- `422 NOT_AUTHENTICATED` - требуется авторизация;
- `400 BAD_FIELDS` - не переданы `contentType` или `fileBytesBase64`;
- `400 BAD_BASE64` - `fileBytesBase64` не декодируется;
- `400 BAD_AVATAR_FILE` - файл не проходит ограничения сервера;
- `400 FREE_AVATAR_LIMIT_EXHAUSTED` - бесплатный лимит аватарок исчерпан;
- `501 FREE_AVATAR_TEMP_DISABLED` - временная функция выключена или сервер не настроен;
- `500 INTERNAL_ERROR` - внутренняя ошибка сервера.
## Как это используется в UI
На экране редактирования профиля в мастере смены аватара есть временный сценарий:
- `Залить аватар бесплатно`
UI:
1. вызывает `TestGetFreeAvatarQuota`;
2. показывает остаток лимита;
3. локально подготавливает уменьшенный файл аватара;
4. проверяет, что итоговый файл не превышает `maxBytes`;
5. вызывает `TestUploadFreeAvatar`;
6. после получения `txId` обычным путём записывает `avatar.ar` в профиль через `AddBlock`.
## Почему решение временное
- используется общий серверный Arweave-кошелёк;
- лимит хранится отдельной технической таблицей;
- операции имеют префикс `Test...`;
- сценарий нужен как переходный бесплатный путь для маленьких аватаров.
@@ -0,0 +1,25 @@
# Форматы блокчейнов и блоков (индекс раздела)
Этот раздел разбит на несколько файлов, чтобы формат добавляемых блоков было проще читать и сопровождать.
## Структура раздела
- `01_Common_Block_Format.md` — общий бинарный формат блока (Frame v0), правила подписи и проверки.
- `02_Blockchain_Kinds_and_Lines.md` — виды блокчейнов и логические линии внутри цепочки.
- `10_TECH_Blocks.md` — системные блоки (`type=0`).
- `11_TEXT_Blocks.md` — текстовые блоки (`type=1`).
- `12_REACTION_Blocks.md` — реакции (`type=2`).
- `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`).
- `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`).
## Быстрая карта типов
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/REPOST.
- `type=2` — REACTION: LIKE/UNLIKE.
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
- `type=4` — USER_PARAM: key/value-параметры пользователя.
## Примечание
Если нужно добавить новый тип или подтип блока, сначала обновляйте профильный файл этого раздела, затем API-документацию в `Dev_Docs/API`.
@@ -0,0 +1,40 @@
# Типы каналов и CreateChannel
## 1. Формат `CreateChannelBody` (`msg_type=0`, `subType=1`, `version=1`)
Payload включает:
1. line-поля канала (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`);
2. `channelName`;
3. `channelDescription`;
4. `channelType` (`uint16`, 2 байта);
5. `channelTypeVersion` (`uint16`, 2 байта).
## 2. Типы каналов
- `0``stories` (root-канал пользователя).
- `1` — публичный канал.
- `100` — персональный канал.
- `200` — групповой/чатовый канал.
Версия типа (`channelTypeVersion`) сейчас используется со значением `1`.
Важно для MVP:
- `100` и `200` в формате поддерживаются, но в текущем UI не используются.
- В MVP рабочий UI-флоу — каналы `0` и `1`.
## 3. Имя root-канала
- Root-канал (`line_code = 0`) в API/чтении отображается как `stories`.
- Публикации в `stories` разрешены владельцу собственного блокчейна.
## 4. Уникальность имени канала
Проверка уникальности выполняется на сервере по ключу:
`owner_bch_name + channel_type_code + slug(channel_name)`
Это означает:
- одно и то же имя допустимо у одного владельца для разных типов (`1`, `100`, `200`);
- в рамках одного типа и одного владельца имя уникально.
## 5. Персональные каналы (`type=100`)
- Сервер не проверяет бизнес-валидность собеседника по имени канала.
- Проверка существования login для персонального канала выполняется на UI при создании.
- При чтении сервер пытается собрать парный поток `A->B` + `B->A` (если обратный канал существует).
+49
View File
@@ -0,0 +1,49 @@
# Общий формат добавляемого блока (Frame v0)
Этот файл описывает **единый бинарный формат** блока, который клиент отправляет через `AddBlock` в поле `blockBytesB64`.
## 1. Полная структура блока
Блок состоит из двух частей:
1. **PREIMAGE** (подписывается)
2. **TAIL** (маркер подписи + подпись)
### PREIMAGE
- `frameCode (uint16)`
- `prevHash32 (32 bytes)`
- `blockSize (int32)` — размер PREIMAGE
- `blockNumber (int32)`
- `timestamp (int64)`
- `type (uint16)`
- `subType (uint16)`
- `version (uint16)`
- `bodyBytes (N)`
### TAIL
- `sigMarker (uint16)`
- `signature64 (64 bytes, Ed25519)`
## 2. Что проверяет сервер при AddBlock
- `frameCode` должен быть `0x0000`.
- `sigMarker` должен быть `0x0100`.
- `blockNumber` должен идти строго по порядку (`last + 1`).
- `prevHash32` должен совпасть с вершиной цепочки на сервере.
- `body` должен пройти `check()` для конкретного типа.
- подпись должна валидироваться публичным ключом блокчейна.
## 3. Ограничения
- максимальный полный размер блока: до 4 MiB;
- timestamp не должен сильно уходить в будущее;
- `bodyBytes` парсится по `type/subType/version` из заголовка блока.
## 4. Почему это важно
Одинаковый общий формат позволяет:
- передавать разные виды записей через один RPC `AddBlock`;
- валидировать блоки единообразно;
- расширять типы `body`, не ломая каркас блока.
@@ -0,0 +1,47 @@
# Виды блокчейнов и логических линий
## 1. Именованный блокчейн
Базовый идентификатор цепочки пользователя:
- `blockchainName = <login>-<NNN>`
- пример: `alice-001`
Обычно это одна основная цепочка пользователя.
## 2. Логические линии внутри одной цепочки
Физически цепочка одна, но внутри есть независимые логические последовательности (линии), которые ведутся через поля:
- `lineCode`
- `prevLineNumber`
- `prevLineHash32`
- `thisLineNumber`
Линии используются для:
- TECH-событий;
- каналов с текстовыми постами;
- связей и подписок;
- пользовательских параметров.
## 3. Правила line-полей (фактическая серверная валидация)
Line-поля: `lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`.
- Line-поля разрешены только для `msg_type`: `0`, `1`, `3`, `4`.
- Если передано хотя бы одно line-поле, должны быть переданы все 4.
- `prevLineNumber/prevLineHash32` должны указывать на существующий блок этой же цепочки.
- Для первого шага после root (`prevLineNumber == lineCode`):
- `TEXT (msg_type=1)`: `thisLineNumber = 0`;
- `TECH/CONNECTION/USER_PARAM (0/3/4)`: `thisLineNumber = 1`.
- Для обычного шага:
- `TEXT`: `thisLineNumber` допускает `same` или `+1` от предыдущего блока линии;
- `TECH/CONNECTION/USER_PARAM`: строго `+1`.
## 4. Root-идея для каналов и подписок
Для ссылок вида follow/friend/contact принято ссылаться на корневые блоки:
- `HEADER` для базовой сущности пользователя/канала `0`;
- `CREATE_CHANNEL` для пользовательских каналов.
Так ссылки остаются стабильными, даже когда в канале появляются новые сообщения.
+33
View File
@@ -0,0 +1,33 @@
# Командные сообщения каналов
## 1. Общий префикс
Командные сообщения распознаются по префиксу:
`/.`
Пример:
- `/.desc Новый комментарий канала`
## 2. Поддерживаемые команды
### Для всех типов каналов (`0`, `1`, `100`, `200`)
- `/.desc <text>` — смена описания канала.
Примечание:
- Описание канала в чтении определяется последней командой `/.desc` в линии канала.
- Если `/.desc` не было, используется описание из `CreateChannel`.
### Дополнительно для `type=200`
- `/.add <login> <channelName>`
- `/.remove <login> <channelName>`
Формат аргументов фиксирован: через пробел.
## 3. Текущая модель применения
- Команды передаются как обычные `TEXT_POST` сообщения.
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.
## 4. Статус для MVP
- В текущем UI каналы `type=100` и `type=200` не используются.
- Соответственно, `/.add` и `/.remove` считаются запланированными и пока не участвуют в рабочем UI-сценарии.
+18
View File
@@ -0,0 +1,18 @@
# TECH блоки (`type=0`, `version=1`)
TECH-тип покрывает системные записи цепочки.
## Подтипы
1. `subType=0``HEADER_COMPAT`
- стартовый блок цепочки;
- payload: tag `SHiNE` + login владельца.
2. `subType=1``TECH_CREATE_CHANNEL`
- создание нового канала;
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
## Назначение
- инициализация блокчейна;
- управление набором каналов пользователя.
+38
View File
@@ -0,0 +1,38 @@
# TEXT блоки (`type=1`, `version=1`)
TEXT-тип хранит сообщения и редактирования.
## Подтипы
1. `subType=10``TEXT_POST`
- пост в линии канала;
- содержит line-поля + текст.
2. `subType=11``TEXT_EDIT_POST`
- редактирование поста;
- line-поля + target на оригинальный POST + новый текст.
3. `subType=20``TEXT_REPLY`
- ответ на сообщение;
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
4. `subType=21``TEXT_EDIT_REPLY`
- редактирование ответа;
- target на исходный REPLY + новый текст.
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
5. `subType=30``TEXT_REPOST`
- репост сообщения в линию канала;
- содержит line-поля + target на оригинальное сообщение + текст комментария;
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
## Правило для edit
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
## Пустой text в edit
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
- Такой edit трактуется как логическое удаление содержимого сообщения.
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
+14
View File
@@ -0,0 +1,14 @@
# REACTION блоки (`type=2`, `version=1`)
## Подтипы
1. `subType=1``REACTION_LIKE`
- лайк на целевой блок;
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
2. `subType=2``REACTION_UNLIKE`
- снятие лайка с целевого блока;
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
## Назначение
- реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой).
+29
View File
@@ -0,0 +1,29 @@
# CONNECTION блоки (`type=3`, `version=1`)
CONNECTION-тип описывает социальные связи и подписки.
## Подтипы
`10/11``close_friend / unclose_friend` (близкий друг)
`20/21``contact / uncontact` (контакт)
`30/31``follow / unfollow` (подписан)
`40/41``spouse / unspouse` (супруг/супруга)
`50/51``parent / unparent` (родитель)
`52/53``child / unchild` (ребёнок)
`54/55``sibling / unsibling` (брат/сестра)
`60/61``known_person / unknown_person` (знаю этого человека)
`70/71``shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий)
`74/75``shine_seen / shine_unseen` (мало знаком, но видел сияющим)
## Общий формат payload
- line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`)
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`)
## Правила target
- FRIEND/CONTACT обычно указывают на `HEADER` цели (`block 0`).
- FOLLOW указывает на root канала:
- `HEADER` для канала `0`;
- `CREATE_CHANNEL` для пользовательского канала.
- Для остальных типов связи (`SPOUSE/PARENT/CHILD/SIBLING`) используется тот же target-формат.
+14
View File
@@ -0,0 +1,14 @@
# USER_PARAM блоки (`type=4`, `version=1`)
## Подтипы
1. `subType=1``USER_PARAM_TEXT_TEXT`
- хранит line-поля + `paramKey` + `paramValue`.
## Назначение
- сохранение пользовательского состояния (настройки клиента, синк-метки, курсоры чтения и т.д.).
## Практика
Для сложных структур удобно хранить JSON-строку в `paramValue` с версией схемы.
+64
View File
@@ -0,0 +1,64 @@
# История изменений документации блокчейна
# 2026-06-26 17:45:18 +0400
- Базовый коммит-ориентир: `44a1ba0`.
- На `t.shineup.me` подтверждена рабочая схема startup sync и full-resync:
- после рестарта сервер добивает `BlockchainTmpRecovery` и `BlockchainResyncRecovery`;
- `aidartest-001` успешно подтягивается с `shineup.me`;
- итоговое локальное состояние по `aidartest-001` дошло до `last_block_number=13`.
- В `Dev_Docs/Blockchain/sync-between-servers.md` добавлен практический результат ручной проверки на тестовом сервере.
## 2026-06-26 17:03:22 +0400
- Базовый коммит-ориентир: `71fdee0`.
- Обычный `AddBlock` переведён на crash-safe схему через временный кандидат `<blockchainName>.tmp_bch`, sidecar `<blockchainName>.write_check` и marker `<blockchainName>.write_pending`.
- `BlockchainTmpRecoveryOnStartup` теперь разбирает marker-driven recovery для обычной записи блока:
- если marker есть, recovery либо завершает swap tmp -> main, либо удаляет мусор;
- если marker нет, временные артефакты считаются мусором и удаляются.
- В `Dev_Docs/Blockchain/sync-between-servers.md` добавлено описание обычного `AddBlock` recovery и разделение между `write_pending` и `resync_pending`.
## 2026-05-24 11:40:00 +0300
- Базовый коммит-ориентир: `abdce05`.
- `TEXT_REPOST (subType=30)` оставлен как зарезервированный формат, но новые блоки репоста временно отключены на уровне `AddBlock`.
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
- В `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`.
## 2026-05-21 19:05:00 +0300
- Базовый коммит-ориентир: `5344c42`.
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:
- обновлён перечень типов в `11_TEXT_Blocks.md`;
- обновлена быстрая карта типов в `00_Blockchain_Formats_and_Block_Types.md`.
- Уточнено API-описание поддержанных подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`.
- В документе `Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md` зафиксировано, что чтение канала учитывает `TEXT_POST` и `TEXT_REPOST`.
## 2026-05-20 11:34:17 +0300
- Базовый коммит-ориентир: `a53444b`.
- В `13_CONNECTION_Blocks.md` добавлены новые CONNECTION подтипы:
- `60/61``known_person / unknown_person` (знаю этого человека);
- `70/71``shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий);
- `74/75``shine_seen / shine_unseen` (мало знаком, но видел сияющим).
- Обновлён список CONNECTION-подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`.
## 2026-05-19 20:30:21 +0300
- Базовый коммит-ориентир: `7986184`.
- Уточнён документ `11_TEXT_Blocks.md`: для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксировано, что `textLen=0` допустим и трактуется как логическое удаление сообщения.
- Явно закреплено, что отдельного `DELETE`-подтипа нет, удаление выполняется edit-блоком.
## 2026-05-19 00:22:46 +0300
- Базовый коммит-ориентир: `c27da63a3e65`.
- Актуализирован `README.md` как точка входа для MVP-документации по протоколу.
- В документации явно зафиксировано, что `channelType=100` и `channelType=200` присутствуют в формате, но пока не используются в UI.
- Актуализирован перечень REACTION-подтипов: добавлен `REACTION_UNLIKE (subType=2)`.
- Актуализирован перечень CONNECTION-подтипов: добавлены `SPOUSE/PARENT/CHILD/SIBLING` и обратные операции.
- В документ `02_Blockchain_Kinds_and_Lines.md` добавлены фактические серверные правила валидации line-полей.
- Обновлён корневой `AGENTS.md`: формат блокчейна менять только после явного подтверждения пользователя и с предварительным предупреждением.
## 2026-05-13 00:02:32 +0300
- Базовый коммит-ориентир: `f63f40f1eb2f`.
- Добавлен текущий формат `CreateChannelBody` с полями `channelType (2 байта)` и `channelTypeVersion (2 байта)`.
- Зафиксированы типы каналов: `0=stories`, `1=public`, `100=personal`, `200=group`.
- Серверная уникальность имени канала изменена на `owner + type + name(slug)`.
- Root-канал `0` переименован в `stories` на уровне API-чтения.
- Для персонального канала (`type=100`) включена сборка парного потока при чтении (`A->B` + `B->A`, если существует).
- Добавлена поддержка командного префикса `/.` и команды `/.desc` для актуализации описания канала при чтении.
- Зафиксированы команды `/.add` и `/.remove` для каналов `type=200` (зарезервировано под расширение участниками).
- В `AGENTS.md` добавлено обязательное правило актуализации документации в `Dev_Docs/Blockchain/`.
+34
View File
@@ -0,0 +1,34 @@
# Документация блокчейна SHiNE (MVP)
Этот каталог описывает только текущий рабочий формат протокола для MVP.
## Основные документы
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
Единый бинарный формат блока (Frame v0), подпись, базовые проверки.
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
Виды цепочек и правила line-полей.
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
Системные блоки (`msg_type=0`).
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
Текстовые блоки (`msg_type=1`).
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
Реакции (`msg_type=2`).
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
Социальные связи (`msg_type=3`).
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
Параметры пользователя (`msg_type=4`).
8. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
Типы каналов и формат `CreateChannelBody`.
9. [02_Channel_Commands.md](./02_Channel_Commands.md)
Команды в текстовых сообщениях каналов.
10. [CHANGELOG.md](./CHANGELOG.md)
Журнал изменений документации.
## Важные ограничения MVP
- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI.
- Поддерживаемый рабочий сценарий UI на текущем этапе: `stories (type=0)` и `public (type=1)`.
## Обязательное сопровождение
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
- Обычный `AddBlock` сейчас пишет через `<blockchainName>.tmp_bch`, `<blockchainName>.write_check` и `<blockchainName>.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery.
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
+256
View File
@@ -0,0 +1,256 @@
# Синхронизация блоков и DM между серверами SHiNE
Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE.
## 1. Зачем нужна синхронизация
Пользователи SHiNE могут быть «приписаны» к разным серверам.
Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y):
1. Сервер X принимает сообщение;
2. Сервер X должен переслать DM-блок серверу Y;
3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B.
Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться,
чтобы любой партнёрский сервер мог отдать полную историю пользователя.
## 2. Список серверов синхронизации (`sync_servers`)
Каждый сервер регистрирует в своей Solana PDA список `sync_servers`
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
## 3. Что синхронизируется
### 3.1 Личные сообщения (DM)
- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt).
- Сервер-отправитель: при получении пары блоков от клиента перенаправляет их серверу получателя.
- Сервер-получатель: сохраняет блоки в `signed_messages_v2`, доставляет в активные сессии.
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
### 3.2 Блоки пользовательского блокчейна
- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него.
- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`.
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
- Дедупликация по глобальному номеру блока и хэшу.
## 4. Текущая реализованная схема
На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов.
### 4.1 Что уже сделано
1. При старте сервер читает свой `server.SHiNE.login`.
2. По этому логину он загружает из Solana свою server PDA.
3. Из неё вытаскивает список `sync_servers`.
4. Для каждого логина партнёра сервер читает его PDA и сохраняет локально:
- `login`
- `server_address`
5. После этого:
- новые локальные `AddBlock` рассылаются партнёрам в фоне;
- при старте запускается periodic sync;
- periodic sync повторяется каждые `12` часов после старта.
### 4.2 Какие server-to-server API уже используются
- `ListBlockchainHeads` — список heads всех локальных цепочек партнёра;
- `GetBlockchainBlock` — чтение одного конкретного блока партнёра;
- `GetSyncUserProfile` — минимальный профиль пользователя для локального создания `solana_users + blockchain_state` без обращения в Solana RPC.
### 4.3 Как сейчас работает periodic sync
Для каждого сервера из локальной таблицы `sync_servers`:
1. запрашивается `ListBlockchainHeads`;
2. для каждой удалённой цепочки сравниваются:
- `lastBlockNumber`
- `lastBlockHash`
- локальное состояние;
3. если локальная цепочка слабее, сервер по одному блоку вызывает `GetBlockchainBlock`;
4. каждый скачанный блок локально применяется через существующий `AddBlock`;
5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальный `solana_users + blockchain_state`.
6. если во время replay обнаруживается рассинхрон или на одинаковой высоте удалённая цепочка сильнее, запускается полный resync:
- цепочка помечается in-memory как `resync in progress`;
- создаётся marker-file в `data/`;
- в одной SQL-транзакции очищаются локальные данные цепочки и корректируются чужие счётчики;
- удаляются `.bch` и `.tmp_bch`;
- цепочка подтягивается заново с `0` через `GetBlockchainBlock`.
- обычный `AddBlock` на эту цепочку в этот момент возвращает `chain_resync_in_progress`.
### 4.4 Как именно работает full resync
Full resync запускается только тогда, когда:
- локальная chain отстаёт и обычная докачка хвоста упирается в `bad_prev_hash` или `bad_block_number`;
- либо высота цепочек одинаковая, но удалённая версия сильнее по правилу:
- `lastBlockNumber`;
- `fileSizeBytes`;
- `lastBlockHash`.
Порядок действий:
1. Ставится in-memory guard на `blockchainName`.
2. Создаётся marker-file `<blockchainName>.resync_pending`.
3. Обычный `AddBlock` на эту chain временно получает `chain_resync_in_progress`.
4. Вызывается атомарный SQL cleanup одной chain:
- уменьшаются чужие `likes_count` и `replies_count`;
- удаляются локальные derived-state записи этой chain;
- удаляются `blocks` и `blockchain_state` этой chain.
5. Удаляются файлы `<blockchainName>.bch` и `<blockchainName>.tmp_bch`.
6. Локальная chain создаётся заново через `GetSyncUserProfile` или через Solana import, если `sync.importUserProfileFromPartner.enabled=false`.
7. Chain replay-ится с `0` через `GetBlockchainBlock`.
8. Если всё прошло успешно, marker-file удаляется.
9. Если на любом шаге произошёл сбой, marker-file остаётся на диске, и сервер добивает эту chain при следующем старте.
Важно:
- full resync не делает умный rollback по одному блоку;
- full resync не трогает DM-таблицы и `solana_users`;
- висячие cross-chain ссылки считаются допустимым поведением системы.
### 4.5 Как работает обычный `AddBlock` и его recovery
Обычная запись блока теперь тоже идёт через временные артефакты:
1. собирается `<blockchainName>.tmp_bch` как полный кандидат на замену основного файла;
2. пишется маленький sidecar `<blockchainName>.write_check` с `blockNumber` и `blockHash`;
3. только после этого создаётся пустой marker `<blockchainName>.write_pending`;
4. выполняется SQL-транзакция;
5. после `commit` tmp атомарно ставится на место основного `.bch`;
6. marker и sidecar удаляются.
На старте `BlockchainTmpRecoveryOnStartup` смотрит именно на эту пару:
- если `write_pending` есть, recovery проверяет sidecar и БД, а затем либо завершает swap, либо чистит временные файлы;
- если `write_pending` нет, а `tmp_bch` или `write_check` остались, это мусор и он удаляется;
- `resync_pending` сюда не относится, это отдельный recovery-поток.
### 4.6 Startup recovery по marker-file
При старте сервер идёт в таком порядке:
1. `BlockchainTmpRecoveryOnStartup` для `*.write_pending` и orphan `*.tmp_bch` / `*.write_check`;
2. `BlockchainResyncRecoveryOnStartup` для `*.resync_pending`;
3. только потом поднимается обычный сервер и запускается `PeriodicBlockchainSyncService`.
Если marker-file существует:
- сервер не должен начинать обычную работу поверх этой chain;
- recovery снова выполняет cleanup и replay с нуля;
- если recovery не завершился, marker остаётся, и сервер не переходит к обычному режиму для этой chain.
### 4.7 Зачем понадобился `GetSyncUserProfile`
Изначально подготовка локальной цепочки делалась через Solana:
- из `blockchainName` извлекался `login`;
- сервер вызывал import пользователя из Solana PDA;
- по данным PDA локально создавались `solana_users + blockchain_state`.
На практике это упёрлось в ограничение внешнего Solana RPC: при чистом старте и массовой подтяжке чужих цепочек сервер мог получать `HTTP 429`.
Поэтому добавлен отдельный обходной режим:
- настройка `sync.importUserProfileFromPartner.enabled=true`
- в этом режиме сервер **не ходит в Solana RPC** для создания локальной цепочки во время sync;
- вместо этого он запрашивает у сервера-партнёра `GetSyncUserProfile` и создаёт локальную запись по данным партнёра.
- если локальный `solana_users` уже существует, sync восстанавливает только `blockchain_state` и не трогает identity-слой.
Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint.
### 4.8 Что делает настройка `sync.importUserProfileFromPartner.enabled`
- `false` — стандартный режим, подготовка локального пользователя идёт через Solana PDA;
- `true` — sync-режим обхода Solana, локальный пользователь создаётся по server-to-server `GetSyncUserProfile`.
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
## 5. Целевой протокол следующего этапа
### 5.1 Межсерверное соединение
- Серверы устанавливают постоянное WebSocket-соединение друг с другом.
- Адрес партнёра определяется по `server_address` из его Solana PDA.
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA).
- При разрыве — переподключение с экспоненциальным backoff.
### 5.2 Доставка новых данных (push)
- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам.
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
### 5.3 Начальная синхронизация (backfill)
- При первом подключении к партнёру серверы обмениваются «курсорами» состояния:
последний глобальный номер блока, последний известный DM-ключ.
- Сервер с более полной историей досылает недостающее партнёру.
### 5.4 Разрешение конфликтов
- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока.
Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`).
- DM: конфликтов нет, `message_key` уникален.
## 6. Маршрутизация DM между серверами
При отправке DM от пользователя A к пользователю B:
1. Клиент A отправляет пару блоков на свой сервер X.
2. Сервер X определяет, на каком сервере зарегистрирован пользователь B.
- Сначала проверяет локально (если B зарегистрирован на X).
- Иначе читает PDA пользователя B из Solana и смотрит `access_servers`.
- Выбирает первый доступный сервер из `access_servers` и перенаправляет туда DM.
3. Сервер Y (из `access_servers` B) сохраняет и доставляет блоки.
Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения).
## 7. Безопасность
- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа).
- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
## 8. Статус реализации
| Компонент | Статус |
|-----------|--------|
| Регистрация серверной PDA в Solana | ✅ Реализовано |
| Чтение `sync_servers` из PDA | ✅ Реализовано |
| Локальная таблица `sync_servers` | ✅ Реализовано |
| Публичный `ListBlockchainHeads` | ✅ Реализовано |
| Публичный `GetBlockchainBlock` | ✅ Реализовано |
| Публичный `GetSyncUserProfile` | ✅ Реализовано |
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
| Межсерверный постоянный WebSocket-канал | Нужна реализация |
| Push новых DM партнёрам | Нужна реализация |
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
Текущая версия сервера уже умеет базовую синхронизацию блокчейнов между партнёрами.
Не реализованы ещё DM-sync и постоянные server-to-server соединения.
Следующие отдельные шаги после текущего этапа:
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
### 8.1 Практическая проверка на тестовом сервере
Проверка на `t.shineup.me` показала, что текущая схема действительно поднимает цепочку при старте:
- после рестарта сервер сначала проходит `BlockchainTmpRecovery`;
- затем обрабатывает `BlockchainResyncRecovery`;
- после этого сам догружает цепочку `aidartest-001` с `shineup.me`;
- итоговое состояние на тестовом сервере:
- `blockchain_state.last_block_number = 13`
- `blocks` по `aidartest-001` = `14` записей
Это подтверждает, что startup sync и full-resync flow работают в живом сценарии, а не только в коде.
+33
View File
@@ -0,0 +1,33 @@
# Figma
Эта папка хранит рабочие инструкции по переносу экранов SHiNE в Figma и по обратному переносу изменений из Figma в код.
## Что здесь лежит
- `README.md` — точка входа и краткий регламент.
- `TRANSFER_UI_SCREENS.md` — подробная инструкция по переносу экранов UI в Figma и обратно.
## Когда читать
Читать перед любыми задачами вида:
- перенести экран из `shine-UI` в Figma;
- собрать новый Figma-файл для экранов SHiNE;
- перенести изменения из Figma обратно в код;
- уточнить, каким способом переносить экраны: по одному или пачкой.
## Ключевое правило
Для экранов SHiNE безопасный рабочий способ на текущий момент:
- переносить экраны в Figma по одному;
- не пытаться сразу переносить длинный auth-flow пачкой;
- после каждого переноса визуально проверять результат в самой Figma;
- только после удачного одного экрана переходить к следующему.
## Про Miro
Отдельной папки `Miro` пока нет.
Причина:
- практики по Miro в проекте пока мало;
- устойчивого процесса ещё нет;
- как только появится стабильный сценарий работы с Miro, его нужно будет оформить аналогично Figma.
+224
View File
@@ -0,0 +1,224 @@
# Перенос экранов UI в Figma и обратно
## Зачем нужен этот документ
Этот документ фиксирует практический опыт, который уже был получен на переносе стартового экрана, экрана регистрации и дальнейших попытках.
Главная цель:
- чтобы агент не повторял неудачные попытки;
- чтобы переносы делались одинаково;
- чтобы изменения из Figma можно было уверенно переносить назад в `shine-UI`.
## Где находится основной UI
- основной клиентский UI: `shine-UI/`
- маршруты и список pre-auth экранов: `shine-UI/js/router.js`
- экраны: `shine-UI/js/pages/`
- общие стили: `shine-UI/styles/main.css`, `shine-UI/styles/layout.css`, `shine-UI/styles/components.css`
## Что считать успешным переносом в Figma
Успешный перенос экрана в Figma — это не просто фон и прямоугольники.
Нужно, чтобы:
- были видны все ключевые текстовые элементы;
- кнопки были перенесены как отдельные элементы;
- поля ввода были явно видны;
- экран был узнаваем визуально;
- пользователь мог вручную подправить макет в Figma;
- после правок можно было понять, что именно переносить обратно в код.
## Текущий рабочий способ
На текущем проекте лучший практический способ такой:
1. Переносить только один экран за раз.
2. Сначала читать конкретный `js/pages/<screen>.js`.
3. Затем читать связанные стили из `styles/components.css` и `styles/layout.css`.
4. После этого вручную собирать экран в Figma как отдельный frame с явными элементами.
5. Проверять в Figma, что не получился только фон без текста и контролов.
6. Только после успешной проверки переходить к следующему экрану.
## Почему нельзя переносить пачкой
Был получен негативный опыт:
- при переносе сразу многих экранов в Figma часть экранов отображалась как фон без нормальных надписей и элементов;
- длинные экраны с большим количеством текста и форм разваливались;
- автогенерация давала внешний вид, непригодный для ручной доработки.
Поэтому правило такое:
- auth-flow, регистрация, вход, onboarding — переносить по одному экрану;
- после каждого экрана ждать визуального подтверждения пользователя;
- не объединять 5-10 экранов в один проход без отдельного разрешения и без промежуточной проверки.
## Рекомендуемый порядок переноса в Figma
### Вперёд: код -> Figma
1. Определить точный экран.
2. Найти файл экрана в `shine-UI/js/pages/`.
3. Найти используемые CSS-классы через поиск по файлу экрана.
4. Вытащить:
- тексты;
- состав кнопок;
- поля ввода;
- карточки;
- блоки статуса;
- последовательность секций.
5. Если экран длинный, всё равно переносить его как один frame, но собирать блоками сверху вниз.
6. В Figma создавать отдельный экран рядом с уже существующими экранами, а не смешивать всё в одну кучу.
7. После создания экрана проверить метаданные/скриншот Figma, если инструмент это позволяет.
### Назад: Figma -> код
1. Снять актуальный скриншот изменённого Figma-экрана.
2. Получить метаданные узла, если это помогает понять структуру.
3. Сравнить Figma с текущим кодом экрана.
4. Переносить обратно в код только реальные изменения:
- порядок блоков;
- тексты;
- размеры/отступы;
- наличие или отсутствие карточек;
- подписи кнопок;
- видимость блоков.
5. Не придумывать новые UX-решения без отдельного подтверждения пользователя, если их нет в Figma.
6. После правок проверять экран локально или как минимум по коду и зависимостям.
## Что переносить вручную
Вручную, а не автогенерацией, нужно переносить:
- экраны регистрации;
- экраны входа;
- длинные формы;
- экраны с несколькими карточками;
- экраны с длинными объясняющими текстами;
- экраны, где важен порядок блоков.
Причина:
- именно они чаще всего ломаются при слишком автоматическом переносе.
## Какие ошибки уже были
### Ошибка 1. Перенос пачкой
Проблема:
- несколько экранов были добавлены сразу;
- пользователь увидел, что на экранах в Figma «какая-то ерунда».
Вывод:
- переносить по одному.
### Ошибка 2. Видно только фон
Проблема:
- frame создавался, фон и свечения были видны;
- тексты и элементы либо не появлялись, либо получались непригодными.
Вывод:
- при сложных экранах собирать элементы вручную и явно.
### Ошибка 3. Слишком вольная реконструкция
Проблема:
- экран формально был перенесён, но визуально не соответствовал ожиданию пользователя.
Вывод:
- для SHiNE важнее узнаваемый и редактируемый экран, чем «формально похожий» экран.
## Обязательные проверки после переноса в Figma
После каждого нового экрана агент должен проверить:
- виден ли заголовок;
- видны ли кнопки;
- видны ли поля ввода;
- не исчезли ли длинные тексты;
- не сломан ли порядок секций;
- не оказался ли на холсте только фон и пустые прямоугольники.
Если хотя бы один пункт не выполнен:
- не считать перенос завершённым;
- либо переделать экран сразу;
- либо остановиться и показать пользователю только после исправления.
## Правила для длинных экранов
Если экран длинный, например регистрация:
- высота frame может быть больше стандартной мобильной высоты;
- секции должны идти в правильном вертикальном порядке;
- отдельные карточки должны быть вынесены в отдельные блоки;
- тексты лучше упрощённо располагать вручную, чем терять их совсем.
## Правила для экрана регистрации
Экран `register-view` особенно чувствительный.
При переносе нужно отдельно учитывать:
- заголовок и стрелку назад;
- поля логина и пароля;
- переключатель режима 12 слов;
- сетку слов;
- строку статуса длины пароля;
- строку статуса проверки логина;
- кнопку проверки логина;
- отдельную карточку первого сервера;
- отдельную карточку FAQ;
- нижние кнопки `Назад` и `Далее`.
## Правила для экрана входа
Для экранов входа важно не смешивать:
- экран выбора способа входа;
- вход по логину/паролю;
- вход через другое устройство;
- вход по QR.
Каждый из них переносить отдельно.
## Что делать после правок пользователя в Figma
Если пользователь изменил экран в Figma:
1. Считать Figma источником визуальной правки.
2. Сначала понять, что именно изменено:
- тексты;
- порядок блоков;
- наличие блоков;
- размеры;
- отступы;
- логика flow.
3. Переносить эти изменения назад в код минимально необходимыми правками.
4. Если из Figma следует уже не только визуальная, но и UX-логическая правка, отдельно проверить, что она согласована пользователем.
## Когда нужно добавить заметку в Pending_Features
Если после изменения по Figma:
- поменялась логика flow;
- поменялась регистрация/вход;
- нужен реальный прогон на test2;
- затронута интеграция с Solana;
тогда нужно добавить файл в `Dev_Docs/Pending_Features/`.
## Что пока не оформлено для Miro
По Miro пока нет устойчивого процесса.
Из того, что уже понятно:
- пока не стоит обещать такой же отлаженный перенос, как для Figma;
- сначала нужно накопить хотя бы 2-3 реальных сценария работы;
- только после этого оформлять отдельную папку и регламент.
## Краткая памятка для агента
Если задача звучит как:
- «перенеси экран в Figma»;
- «добавь экран в Figma»;
- «я поправил экран в Figma, перенеси назад»;
то агент должен:
1. Прочитать этот документ.
2. Работать по одному экрану.
3. Не переносить auth-flow пачкой.
4. Проверять результат после каждого экрана.
5. При переносе обратно в код не гадать, а опираться на Figma-правки.
+154
View File
@@ -0,0 +1,154 @@
# Деривация секрета и ключей SHiNE (формулы)
> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.**
> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся
> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт.
> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула
> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь
> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
> Роли и назначение ключей описаны отдельно в `Dev_Docs/Keys/README.md` (архитектура).
> Здесь — только механика. Документ намеренно краткий.
---
## 1. Секрет (masterSecret)
`masterSecret` — 32 байта. Два источника:
**А. Из пароля пользователя (основной путь, UI).**
```
login = trim(lowercase(login))
salt = SHA-256("shine-auth-v2|login=" + login + "|suffix=master.secret")[0..16) // первые 16 байт
material = utf8(login + "\n" + password)
masterSecret(32) = Argon2id(material, salt, t=2, m=65536 KiB, p=1, dkLen=32)
```
- Параметры Argon2id фиксированы: `t=2`, `m=65536` (64 МиБ), `p=1`, `dkLen=32`.
- Логин входит и в соль, и в начало `material` (склейка через `\n`).
- Пустой пароль **запрещён**: легаси-fallback без Argon2 удалён, `deriveMasterSecretFromPassword` бросает ошибку на пустом пароле, а форма регистрации в UI блокирует пустой пароль (`register-view.js`).
**Б. Случайный (прошивка ESP32, новый аккаунт без пароля).**
```
masterSecret(32) = 32 случайных байта (esp_random) // хранится на устройстве как base58
```
Дальше деривация ключей одинакова независимо от источника секрета.
---
## 2. Производные ключи
Все ключи выводятся из `masterSecret` по **одной формуле**, отличается только суффикс:
```
material = base64_std(masterSecret) + "|" + <суффикс>
seed(32) = SHA-256(material)
(pub, priv) = Ed25519_keypair_from_seed(seed)
```
- `base64_std` — стандартный base64 (не url-safe).
- Разделитель — символ `|`.
- Суффиксы значимы байт-в-байт (регистр и точки важны).
| Ключ | Суффикс | Назначение (кратко) |
|------|---------|---------------------|
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). |
| device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. |
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
Полные роли каждого ключа — в `Dev_Docs/Keys/README.md`.
---
## 3. Solana-ключ
Отдельного «солана-ключа» нет. На Solana работают два ключа:
- **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`.
Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`.
Пополнять SOL нужно именно на этот адрес.
- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer.
Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1
(«create/update оплачиваются с `client_key`», «root_key — не fee payer»).
Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью
(`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый
кошелёк и плательщик комиссий**. Полное описание ролей — `Dev_Docs/Keys/README.md`.
---
## 4. Ключи homeserver
У пользователя может быть несколько homeserver-ов. Каждый имеет **своё имя** и **свой приватный ключ**,
выведенный из секрета по той же формуле с именованным суффиксом:
```
suffix = "homeserver.key:" + <имя homeserver> // имя по умолчанию: "homeserver1"
material = base64_std(masterSecret) + "|" + suffix
seed(32) = SHA-256(material)
(pub, priv) = Ed25519_keypair_from_seed(seed)
```
Пример для двух homeserver-ов:
```
homeserver.key:home-a -> ключ A
homeserver.key:home-b -> ключ B
```
Публичный ключ homeserver-а публикуется в `SessionsBlock` пользовательской PDA как
`session_pub_key` с `session_type = 100`, имя — в `session_name` (формат PDA §13).
> Это переименование прежней схемы `subserver.key:<имя>` → `homeserver.key:<имя>`.
> Термин «саб-сервер» по проекту заменяется на «homeserver».
---
## 5. Где это в коде
### Деривация секрета и ключей (UI, каноническая)
- `shine-UI/js/services/crypto-utils.js`
- секрет из пароля: `makeArgon2Salt`, `deriveMasterSecretArgon2id`, `deriveMasterSecretFromPassword` (~129218);
- ключ из секрета: `deriveEd25519FromMasterSecret` (~220).
- `shine-UI/js/services/auth-service.js` — набор root/bch/dev из `masterSecret` (~732758).
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147160).
### Solana-ключ / адрес кошелька (UI)
- `shine-UI/js/pages/registration-payment-view.js``deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113).
- `shine-UI/js/pages/topup-view.js``clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`.
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён.
### Деривация ключей (прошивка ESP32)
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
- основной скетч ESP32-проекта `SHiNE`; `deriveKeysFromMasterSecret` (~782), `restoreDerivedKeysFromSecret` (~806), `deriveFreshSecretAndWallet` (~829);
- регистрация/подпись Solana: `registerHomeserverOnSolana` (~1182), `signMessageEd25519` (~1147).
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino`
- старый тестовый вариант; оставлен как legacy-скетч для сравнения и диагностики.
### Формат PDA (куда попадают ключи)
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
`RootKeyBlock` §6, `ClientKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1.
### Сервер (тестовый seed)
- `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) —
выводит ключи как `Ed25519(SHA-256(base64(SHA-256(password)) + suffix))`, **без** Argon2 и **без** разделителя `|`.
Это **не баг**, а точное повторение легаси-пути UI `derivePasswordSeed` (для пустого пароля), у которого тоже нет `|`.
С современным путём `masterSecret`-bundle (Argon2 + `base64(secret)|suffix`) он **не совпадает** by design.
Если потребуется, чтобы seed совпадал с реальными клиентами на Argon2 — нужно отдельно портировать
Argon2id+masterSecret в Java (на сервере Argon2 сейчас нет). Простое добавление `|` было бы **неверным**:
сломало бы совпадение с легаси-путём и всё равно не дало бы совпадения с Argon2-путём.
---
## 6. Правило синхронизации (обязательно)
1. Этот документ — источник истины по деривации секрета и ключей.
2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа,
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес —
**обязательно** отражать здесь в том же изменении.
3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма.
4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости.
+177
View File
@@ -0,0 +1,177 @@
# Ключи SHiNE
Этот документ описывает роли ключей в SHiNE и их связь с Solana, персональным блокчейном, личными сообщениями, сессиями и будущими аппаратными устройствами.
Документ является архитектурной справкой. Он не меняет текущие форматы API, DM-блоков или блокчейна сам по себе.
## Коротко
В SHiNE у пользователя есть несколько уровней ключей:
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`).
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
## `root key`
`root key` - главный ключ пользователя.
Назначение:
- регистрация пользователя в Solana;
- создание и обновление пользовательской PDA-записи;
- вызов критически важных Solana-функций;
- изменение главных настроек пользователя;
- управление остальными ключами;
- подтверждение операций, которые должны иметь максимальный уровень доверия.
`root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя.
Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `Dev_Docs/Keys/DERIVATION.md`, §3.
## `blockchain key`
`blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
Назначение:
- подпись записей в персональном блокчейне пользователя;
- подтверждение действий, которые должны попасть в SHiNE-блокчейн;
- разделение полномочий между главным Solana-ключом и ключом ежедневной записи.
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
- `username-001` - первая ветка;
- `username-002` - вторая ветка;
- `username-003` - третья ветка.
Рабочая логика по умолчанию должна использовать последнюю актуальную ветку. Старые ветки остаются читаемыми и показывают историю смены ключей.
## `client key`
`client key` - общий ключ, который знают доверенные устройства пользователя.
Назначение:
- повседневные входящие и исходящие личные сообщения;
- звонки и связанные с ними сообщения;
- self-messages, то есть внутренние сообщения пользователя самому себе;
- мелкие Solana-расходы на текущие операции;
- derivation Arweave-кошелька;
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
Arweave-кошелёк должен выводиться из `client key` по протоколу:
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md`
Если пользователь теряет только `client key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными.
## `session key`
`session key` - уникальный ключ конкретной сессии или устройства.
Возможные форматы:
- `Ed25519` - предпочтительный современный вариант;
- `RSA` - legacy-вариант, полезный для устройств, где системное защищённое хранилище хорошо поддерживает RSA-ключи и не позволяет извлекать приватный ключ.
Назначение:
- авторизация сессии на сервере;
- привязка устройства к пользователю;
- подтверждение запросов от конкретной сессии;
- доступ к зашифрованному `client key` после успешной авторизации.
Одна и та же сессия может быть пригодна для подключения к нескольким серверам пользователя, если архитектура конкретного пользователя это допускает.
У сессии должны быть:
- имя сессии;
- тип сессии;
- публичная часть ключа;
- ссылка на пользователя;
- информация о сервере или серверах, которым эта сессия доверена.
Имя сессии может создаваться автоматически из названия устройства и короткого случайного идентификатора, например `Android-a1b2c3`, `Ubuntu-f47a90`. Пользователь может переименовать сессию.
## Типы сессий
Базовые типы:
- обычная пользовательская сессия;
- серверная сессия;
- аппаратная или доверенная сессия с доступом к расширенным ключам.
Обычное устройство обычно имеет:
- собственный `session key`;
- зашифрованный `client key`, который открывается после авторизации;
- доступ к DM, звонкам и обычным пользовательским операциям.
Доверенное серверное или аппаратное устройство может иметь:
- `root key`;
- `blockchain key`;
- `client key`;
- собственный `session key`.
Такая сессия может подписывать операции повышенной важности по запросам пользователя.
## Внутренние self-messages
Self-message - это сообщение пользователя самому себе.
Такие сообщения нужны, чтобы обычное устройство могло попросить доверенное устройство выполнить действие:
- подписать запись `blockchain key` и передать её в SHiNE-блокчейн;
- подписать изменение настройки через `root key`;
- обновить ключи;
- сохранить внутреннюю команду или настройку;
- отправить сообщение другому пользователю с сохранением копии себе;
- сохранить сообщение только себе.
Важно: self-message не является публичной командой сервера. Это пользовательская внутренняя команда, которую сервер или доверенное устройство обрабатывает в рамках прав конкретного пользователя.
## Шифрование входящих сообщений
Входящее сообщение может быть зашифровано:
- `client key`;
- `session key`;
- отдельным ключом конкретного чата;
- другим ключом, который уже известен клиенту.
В сообщении не должно быть лишнего раскрытия того, каким именно ключом оно зашифровано. Клиент пробует расшифровать сообщение доступными ключами по порядку. Если расшифровка не удалась, сообщение остаётся непонятным для этого устройства.
## Копии сообщений
Для отправки сообщений нужны несколько режимов:
- сообщение другому пользователю с исходящей копией себе;
- сообщение другому пользователю без локальной исходящей копии;
- сообщение только себе.
Это должно позволить строить обычные DM, внутренние команды, личные заметки и зашифрованные пользовательские чаты поверх одной общей модели сообщений.
## Связанные документы
- `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код).
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений.
- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений.
- `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
- `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств.
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации.
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `client key`.
## Что нужно уточнить перед реализацией
- точный формат записи списка ключей в Solana PDA;
- как именно обозначать активную ветку персонального блокчейна;
- какие операции требуют `root key`, а какие достаточно подписывать `blockchain key`;
- формат self-message-команд;
- порядок перебора ключей при расшифровке входящих сообщений;
- правила ротации `client key` и восстановления доступа после потери устройства;
- какие типы серверных и аппаратных сессий нужны в первой реализации.
@@ -0,0 +1,38 @@
# Поддержать проект Сияние
Статус: `pending`
## Кратко
В `shine-UI/js/pages/wallet-view.js` добавлен новый раздел `Поддержать проект Сияние` с тремя входами:
1. купить билет;
2. посмотреть билет по номеру;
3. сгенерировать новую пару ключей.
## Что проверить
1. Открыть `Кошелёк`.
2. Перейти в `Поддержать проект Сияние`.
3. Проверить экран покупки:
- виден коэффициент;
- виден остаток лимита очереди 1;
- виден расчет в SOL;
- кнопка `Справка` открывает отдельный экран;
- покупка блокируется, если сумма больше остатка лимита.
4. Проверить экран просмотра:
- `12` ищется как билет очереди 1;
- `2-5` и `3 8` ищутся как билеты очередей 2 и 3;
- показываются статус, количество билетов до него и уже выплаченные значения.
5. Проверить генератор ключей:
- генерируется новая пара ключей;
- публичный и секретный ключи показываются;
- можно скопировать и скачать результат;
- дополнительный текст в поле необязателен.
## Ожидаемый результат
- Экран раздела поддержки открывается из `wallet-view`.
- Покупка билета выполняется по текущему курсу и с допуском 3%.
- По номеру билета показывается понятная сводка по очереди.
- Генерация ключей использует безопасный браузерный рандом и не требует сохранения секретного ключа.
@@ -0,0 +1,20 @@
# Promo-логины через продавцов в Solana
- краткое описание:
- в `shine_users` добавлена поддержка PDA продавцов красивых логинов, promo-подписи `shine_promo_v1:<login>` и отдельная автономная страница `shine-UI/promo-code-generator.html` для генерации promo-кода через браузерный кошелёк или через ручной ввод Base58 seed 32 bytes;
- что проверять:
- admin-транзакцией создать или обновить `promo_seller_pda`;
- сгенерировать promo-код на странице `promo-code-generator.html` в режиме wallet extension;
- сгенерировать promo-код на той же странице в режиме ручного Base58 seed;
- открыть HTML локально как отдельный файл без сервера и убедиться, что генерация в режиме Base58 seed продолжает работать;
- зарегистрировать короткий/premium логин по promo-коду;
- убедиться, что без promo-кода тот же логин не проходит `shine_login_guard`;
- убедиться, что после успешной регистрации `remaining_sales` уменьшается на 1;
- проверить отказ при исчерпанной квоте, неверной подписи и логине короче `min_login_length`;
- ожидаемый результат:
- promo-регистрация проходит только при валидной подписи продавца и соблюдении лимитов;
- обычная регистрация без promo-кода продолжает работать как раньше;
- страница генерации выдаёт строку формата `1seller-signatureBase58` в обоих режимах;
- single-file HTML работает локально оффлайн минимум в режиме ручного Base58 seed;
- статус:
- `pending`
@@ -0,0 +1,51 @@
# Синхронизация mainnet-адресов Solana по UI/серверу/ESP32
- статус: `pending`
## Кратко
Во все основные клиентские и серверные точки проекта протянуты актуальные mainnet-адреса программ:
- `shine_login_guard`: `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh`
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v`
Также по умолчанию переключены основные RPC defaults на `https://api.mainnet-beta.solana.com` там, где это нужно для рабочего mainnet-сценария.
## Что проверять
1. Основной UI:
- отображение и использование новых program id;
- регистрация/чтение Solana PDA в mainnet;
- экран кошелька и покупка лимита.
2. Server UI:
- создание server PDA в mainnet;
- чтение PDA;
- обновление PDA;
- корректная блокировка devnet-автопополнения при mainnet endpoint.
3. Browser plugin wallet:
- резолв `user_pda` и `server PDA` через mainnet RPC.
4. Web UI `shine_payments`:
- чтение очередей;
- покупка билета;
- admin/manager/dao tools открываются с новыми program id и mainnet RPC.
5. ESP32 homeserver UI:
- отображаются новые program id;
- дефолтный Solana RPC теперь mainnet.
6. Временный режим регистрации:
- в основном UI логины длиной 5-7 символов без временного кода не проходят;
- в основном UI логины длиной 5-7 символов с временным кодом проходят локальную проверку;
- в основном UI любой другой временный код отклоняется сразу;
- ESP32 и прямой on-chain вызов `shine_users` полагаются на текущий временный `shine_login_guard`, то есть валидные логины длиной от 5 символов допускаются без словарной классификации.
## Ожидаемый результат
- Во всех перечисленных поверхностях используются новые mainnet program id.
- Запросы по умолчанию идут в mainnet, кроме явно devnet-утилит.
- Нет обращений к старым devnet program id.
- Временный UI-барьер для коротких логинов работает так, как задумано, и не ломает обычную регистрацию для логинов от 8 символов.
@@ -0,0 +1,42 @@
# Временный режим коротких логинов
- статус: `pending`
## Кратко
Временно упрощён `shine_login_guard`:
- on-chain допускаются любые валидные логины длиной от `5` символов;
- словарная premium/trademark-классификация временно отключена.
Поверх этого основной UI вводит временное ограничение:
- логины длиной `8+` проходят обычную проверку;
- логины длиной `5..7` допускаются только при вводе временного кода;
- любой другой код отклоняется сразу.
ESP32 в этом временном режиме не ограничивается UI-кодом и использует только on-chain правила.
## Что проверять
1. Основной UI:
- логин длиной `4` символа отклоняется;
- логин длиной `5..7` без временного кода отклоняется;
- логин длиной `5..7` с корректным временным кодом проходит локальную проверку;
- логин длиной `5..7` с любым другим кодом отклоняется;
- логин длиной `8+` проходит без временного кода.
2. Solana on-chain:
- `shine_login_guard` возвращает `CLASS_FREE` для валидных логинов длиной от `5`;
- `shine_login_guard` возвращает `CLASS_PREMIUM` для логинов короче `5` или с невалидными символами.
3. ESP32 / прямой вызов:
- валидный логин длиной `5+` регистрируется без временного кода;
- логин короче `5` не регистрируется.
## Ожидаемый результат
- Основной UI удерживает обычных пользователей от коротких логинов без временного кода.
- Свои пользователи могут временно регистрировать короткие логины через UI-код.
- ESP32 продолжает работать без отдельной промо-логики.
- On-chain логика остаётся простой и компактной.
@@ -0,0 +1,39 @@
# 4 тестовых SHiNE-сервера на одном VPS (`178.208.90.249`)
- статус: `pending`
## Кратко
Поднят отдельный тестовый стенд:
- `t1.shineup.me`
- `t2.shineup.me`
- `t3.shineup.me`
- `t4.shineup.me`
Каждый домен ведёт на свой SHiNE-инстанс под пользователем `player`, все инстансы работают через Solana `devnet`.
## Что проверять
- UI каждого домена открывается без ошибок:
- `https://t1.shineup.me`
- `https://t2.shineup.me`
- `https://t3.shineup.me`
- `https://t4.shineup.me`
- регистрация/логин клиента может работать через каждый из этих серверов
- WebSocket-подключение реально идёт на свой инстанс, а не в соседний
- чтение PDA и server-ui формы по умолчанию используют `devnet`
- межсерверный sync после ручных тестов действительно использует `access_servers/sync_servers`
- не происходит путаницы данных между `t1/t2/t3/t4`
## Ожидаемый результат
- каждый домен работает независимо
- у каждого инстанса своя БД и свои локальные данные
- `Caddy` корректно проксирует `/ws` на `7101..7104`
- серверы не падают после старта
- если devnet RPC временно даёт `429`, последовательный рестарт по одному инстансу позволяет подтянуть `sync_servers`
## Примечание
Подробная схема размещения описана в:
- [Dev_Docs/deploy/test-server/178.208.90.249_quad_devnet.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Dev_Docs/deploy/test-server/178.208.90.249_quad_devnet.md)
@@ -0,0 +1,17 @@
# Итог звонка как DM от инициатора
- краткое описание фичи:
Вместо локальных `call-tech` записей итог звонка теперь отправляется как обычное DM-сообщение только от стороны, которая инициировала звонок.
- что именно проверять:
1. Успешный звонок: после завершения инициатор отправляет в чат DM вида `Звонок: 37с` или `Звонок: 2м 14с`.
2. Неуспешный звонок без ответа: инициатор отправляет `Звонил, но недозвонился: нет ответа`.
3. Неуспешный звонок, когда у адресата нет доставленных сессий: инициатор отправляет `Звонил, но недозвонился: абонент не в сети`.
4. Неуспешный звонок при проблеме соединения: инициатор отправляет `Звонил, но недозвонился: не удалось установить соединение`.
5. Старые локальные `call-tech` bubble про итог звонка больше не появляются ни у инициатора, ни у принимающей стороны.
- ожидаемый результат:
Итог звонка виден обеим сторонам как обычное DM-сообщение от инициатора, а локальные служебные записи про итог звонка больше не используются.
- статус:
pending
@@ -0,0 +1,23 @@
## Краткое описание
Сервер перестал валидировать внутреннюю crypto-структуру `body` у контентных DM `type=1/2`.
UI теперь не отбрасывает такие сообщения: если сообщение не удалось расшифровать, вместо текста показывается заглушка `Неудалось расшифровать сообщение`.
## Что проверять
- Отправка и приём обычных DM между штатными клиентами продолжают работать как раньше.
- Сообщение с незнакомым форматом `body` у `type=1/2` принимается сервером и доходит до клиента.
- В UI такое сообщение отображается в чате одной из ожидаемых заглушек:
- `Формат сообщения не поддерживается`
- `Не удалось расшифровать сообщение`
- `Сообщение повреждено`
- После выхода из аккаунта и повторного входа backlog с такими сообщениями тоже отображается с той же заглушкой.
- ACK доставки по сессии для такого сообщения не зацикливается и сообщение не приходит бесконечно повторно.
## Ожидаемый результат
Сервер выступает транспортом для opaque DM-body, а клиент при неудачной расшифровке показывает безопасный fallback вместо полного пропуска сообщения с различением основных причин.
## Статус
pending
@@ -0,0 +1,28 @@
## Краткое описание
В DM добавлен клиентский формат технических вставок в начале plaintext:
- `<SHiNE:reply;v=1;id=...>`
- `<SHiNE:call;v=1;status=...;...>`
UI скрывает такие вставки из текста сообщения, а call-вставки отображает специальным человекочитаемым видом.
Также добавлена защита от пользовательского текста, начинающегося с `<SHiNE:`, и исправлен безопасный рендер превью последнего сообщения в списке личных диалогов.
## Что проверять
- Если пользователь отправляет обычный текст, начинающийся с `<SHiNE:`, он уходит как `< SHiNE:` и отображается как обычный текст.
- DM с `<SHiNE:call...>` показывается в чате как специальное call-сообщение, а не как сырой техтекст.
- При исходящем звонке короче `5` секунд call-summary не отправляется вообще.
- В списке личных сообщений превью такого сообщения показывает человекочитаемый итог звонка.
- DM с `<SHiNE:reply...>Текст ответа` скрывает техблок и показывает только `Текст ответа`.
- В меню любого DM первым пунктом есть `Ответить`, и отправка из этого режима реально добавляет `<SHiNE:reply...>` в начало plaintext.
- Если reply-цель отсутствует, сообщение всё равно показывается как обычный текст ответа.
- Текст вида `<script>alert(1)</script>` или похожие HTML-теги не исполняются ни в чате, ни в списке личных сообщений, ни в списке каналов.
## Ожидаемый результат
Технические SHiNE-вставки работают только как управляющие метаданные UI, а отображаемый пользователю текст и превью остаются безопасными и не рендерят HTML.
## Статус
pending
+20
View File
@@ -0,0 +1,20 @@
# Недопроверенные фичи
Эта папка хранит список доработок, которые уже реализованы, но ещё не подтверждены ручной проверкой.
## Как использовать
1. При каждом коммите с новыми пользовательскими фичами (если нужна ручная проверка) добавить новый файл:
- формат: `YYYY-MM-DD_HHMM_<short-feature-name>.md`
- название `<short-feature-name>` и текст файла по возможности писать на русском языке
2. В файле указать:
- что сделано;
- как проверять;
- ожидаемый результат;
- текущий статус (`pending` / `in_progress` / `done`).
3. После подтверждения работоспособности — удалить файл фичи из этой папки.
## Важно
- `README.md` не удаляется.
- Количество недопроверенных фич = число файлов `*.md` в этой папке, кроме `README.md`.
@@ -0,0 +1,25 @@
# Регистрация: FAQ и режим пароля из 12 слов
- краткое описание:
- на экране регистрации добавлен блок частых вопросов с переходом на отдельный экран справки;
- добавлен альтернативный режим ввода пароля через 12 полей-слов в кошелёчном формате, которые склеиваются в одну строку без изменения API;
- такой же режим добавлен и на экран входа по логину и паролю.
- что проверять:
- на стартовом экране открыть `Зарегистрироваться`;
- убедиться, что внизу экрана есть кнопки FAQ;
- открыть несколько вопросов и проверить возврат обратно на регистрацию;
- включить галочку `Представить пароль в виде 12 слов`;
- убедиться, что появляется сетка с нумерованными полями в 3 колонки;
- ввести часть слов, перейти дальше и проверить, что шаг подтверждения и генерация ключей работают;
- выключить галочку и проверить, что пароль остаётся собранным в одном поле;
- открыть экран входа по паролю и повторить те же проверки для режима `12 слов`;
- пройти регистрацию до шага оплаты без ошибок интерфейса.
- ожидаемый результат:
- FAQ открывается отдельным экраном и содержит понятные ответы;
- режим `12 слов` не ломает регистрацию и вход и даёт тот же поток, что и обычный пароль;
- пароль не отправляется в новом формате, а продолжает использоваться как одна строка.
- статус:
- pending
@@ -0,0 +1,25 @@
# Временная бесплатная загрузка аватара в Arweave
- краткое описание фичи:
Добавлены два временных `Test...` API для бесплатной загрузки маленьких аватаров в Arweave через серверный кошелёк с лимитом `3` загрузки на пользователя. В UI мастера смены аватара добавлен пункт `Залить аватар бесплатно`.
- что именно проверять:
1. Пользователь с активной сессией открывает редактирование профиля.
2. По нажатию на аватар открывается мастер `Сменить аватар`.
3. В мастере есть пункт `Залить аватар бесплатно`.
4. До первой загрузки UI показывает остаток `3 из 3`.
5. Маленький JPEG/PNG/WebP после уменьшения до файла <= `128 KB` успешно уходит через `TestUploadFreeAvatar`.
6. После загрузки приходит `txId`, и аватар сохраняется в профиль как `avatar.ar`.
7. Остаток уменьшается: `2`, `1`, `0`.
8. На четвёртой попытке сервер отвечает понятной ошибкой про исчерпанный бесплатный лимит.
9. Если итоговый уменьшенный файл всё ещё > `128 KB`, UI не отправляет его и показывает понятную ошибку.
10. Если серверный Arweave JWK/path не настроен, UI получает понятную ошибку временной функции.
- ожидаемый результат:
- первые 3 маленькие аватарки загружаются через серверный Arweave-кошелёк;
- после каждой успешной загрузки `ava` в профиле указывает на новый `txId`;
- после исчерпания лимита дальнейшая бесплатная загрузка блокируется без записи в профиль;
- обычная загрузка через свой Arweave-кошелёк продолжает работать отдельно.
- статус:
pending
@@ -0,0 +1,28 @@
# Общий список каналов без stories
- Краткое описание:
вкладка `Каналы` переведена на единый список без разделения на "мои" и "подписки".
Название канала в списке теперь показывается как `login_владельца/название_канала`.
Служебный канал `stories` скрыт из списка каналов, поиска, подписки и связанных UI-сценариев.
- Что проверять:
1. Открыть вкладку `Каналы`.
2. Убедиться, что сразу показывается один общий список.
3. Проверить, что свои и чужие каналы отображаются вместе.
4. Проверить формат названий: `ownerLogin/channelName`.
5. Открыть свой канал и убедиться, что внутри сохраняется UI владельца.
6. Открыть чужой канал и убедиться, что внутри сохраняется UI подписчика.
7. Проверить, что `stories` не отображается:
- в общем списке;
- в поиске каналов;
- в подписке на канал;
- в списках выбора канала для репоста.
- Ожидаемый результат:
- вкладка `Каналы` больше не делится на два режима;
- все видимые каналы идут единым списком;
- `stories` нигде не виден и не предлагается пользователю;
- переход в канал сохраняет корректный UI в зависимости от владельца.
- Статус:
`pending`
@@ -0,0 +1,47 @@
# Crash-safe запись обычного `AddBlock` через `tmp_bch`
## Кратко
Обычный `AddBlock` переведён на схему:
1. сборка `<blockchainName>.tmp_bch`;
2. запись sidecar `<blockchainName>.write_check` с `blockNumber` и `blockHash`;
3. создание пустого marker `<blockchainName>.write_pending`;
4. SQL-транзакция;
5. атомарная подмена `tmp -> main`;
6. удаление временных файлов.
## Что проверить
1. Обычный `AddBlock` на свежей цепочке.
2. Падение до SQL-commit:
- должны остаться только временные файлы;
- на старте они должны быть удалены.
3. Падение после SQL-commit, но до `atomicReplaceBlockchainFile(...)`:
- на старте recovery должен довести swap до конца.
4. Падение после `atomicReplaceBlockchainFile(...)`, но до удаления marker/sidecar:
- на старте recovery должен просто подчистить хвост.
5. Сценарий без marker:
- `tmp_bch` / `write_check` считаются мусором и удаляются.
## Ожидаемый результат
- БД и файловая версия цепочки остаются согласованными.
- Повторный старт сервера не ломает chain и не требует ручной правки файлов.
- `BlockchainTmpRecoveryOnStartup` корректно обрабатывает и живые остатки, и мусор.
## Статус
`pending`
## Что уже сделано
- В коде есть `tmp_bch`, `write_check` и `write_pending`.
- `BlockchainWriter` пишет обычный `AddBlock` через временные артефакты.
- `BlockchainTmpRecoveryOnStartup` умеет добивать или чистить незавершённую запись.
## Что ещё перепроверить
- ручной crash-test на тестовом сервере;
- совместимость с уже существующими `resync_pending` marker-файлами;
- отсутствие ложных срабатываний на старых временных файлах.
@@ -0,0 +1,29 @@
# Проверка аварийных остановок на разных этапах
## Кратко
Нужно отдельно проверить, как сервер восстанавливается после внезапной остановки:
1. во время обычного `AddBlock` / `tmp_bch`-pipeline;
2. во время `full resync` цепочки;
3. во время startup recovery, если остановка произошла на предыдущем запуске;
4. при обычном апгрейде сервиса без явного crash-сценария.
## Что проверять
1. Остановка сервиса до `commit` БД.
2. Остановка сервиса после `commit`, но до замены `main.bch`.
3. Остановка сервиса во время `BlockchainResyncCleanupDAO`.
4. Остановка сервиса во время повторной загрузки цепочки по `GetBlockchainBlock`.
5. Поведение при обычном `systemctl restart`, когда сервер сам должен добить recovery.
## Ожидаемый результат
- после старта сервер либо дочищает временные артефакты, либо завершает незаконченный `resync`;
- не остаётся битых `.tmp_bch`, `.write_check`, `.write_pending`, `.resync_pending`;
- БД и файлы цепочки остаются согласованными;
- обычная работа сервера не стартует поверх незавершённого recovery.
## Статус
`pending`
+18
View File
@@ -0,0 +1,18 @@
# AGENTS
## Документация DM в этой папке
- Основной актуальный документ по личным сообщениям:
- `README.md`
- Его считать единственным источником истины по текущей реализованной логике DM.
## Черновик будущих вложений
- Файл `Черновик_будущих_DM_вложений.md` не является актуальной спецификацией.
- В нём описан только ранний черновик того, как когда-то планировались:
- формат вложений в DM;
- внешние и внутренние поля вложения;
- предполагаемая механика загрузки файлов.
- Эта схема не была реализована в таком виде и может существенно измениться в будущем.
- Любые решения по текущему коду, протоколу и UI нельзя принимать по этому черновику.
- Если есть расхождение между `README.md` и черновиком вложений, верным всегда считается `README.md`.
+19
View File
@@ -0,0 +1,19 @@
# Личные сообщения SHiNE
Эта папка содержит актуальную документацию по личным сообщениям SHiNE.
Точка входа:
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
- `Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<SHiNE:...>` вставок внутри plaintext DM после расшифровки
Исторический устаревший документ сохранён отдельно:
- `Dev_Docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md`
Правило сопровождения:
- код DM и оба документа `Протокол_DM_v1.md` + `Формат_DM_v1.md` всегда должны обновляться синхронно;
- если меняется поведение DM в коде, в том же наборе изменений обновляется и эта документация.
- локальная таблица пользователей для DM считается кэшем, а источником истины остаётся Solana PDA; если серверная логика DM меняет правила lazy-import пользователей из PDA, это тоже обязательно фиксируется в документации.
@@ -0,0 +1,581 @@
# Личные сообщения (DM) — спецификация v1
## Статус документа
Этот файл — актуальная логическая спецификация DM-протокола SHiNE v1.
Важно:
- это актуальная реализованная спецификация;
- код и документы по DM должны изменяться синхронно;
- при любых будущих изменениях DM сначала обновляется эта спецификация и соседний документ формата.
Документ фиксирует:
- модель DM и смысл типов сообщений;
- правила редактирования, перешифровки и удаления;
- роли существующих API-методов;
- правила межсерверной маршрутизации через `access_servers`;
- общее поведение сервера и БД.
Точный байтовый формат контейнера вынесен отдельно:
- `Dev_Docs/Personal_Messages/Формат_DM_v1.md`
Формат клиентских технических вставок внутри plaintext вынесен отдельно:
- `Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md`
Устаревшая предыдущая версия сохранена отдельно:
- `Dev_Docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md`
## 1. Основная модель
Личное сообщение в SHiNE хранится как две логические копии одного сообщения:
- `type=1` — входящее сообщение для получателя;
- `type=2` — исходящая копия сообщения для отправителя.
Обе копии имеют общий логический идентификатор:
- `baseKey = fromLogin|toLogin|timeMs|nonce`
Идентификатор конкретной копии:
- `messageKey = baseKey|messageType`
Поля `timeMs` и `nonce` не меняются никогда.
`nonce` обязателен, потому что одного `timeMs` недостаточно для гарантированной уникальности.
## 2. Ключи и шифрование
### 2.1. Общий принцип
У пользователя в PDA публикуется один публичный `clientKey` в формате `Ed25519`.
Из того же ключевого материала стандартным способом выводится ключ `X25519` для E2EE-шифрования DM.
Отдельный публичный `dmEncKey` в текущей архитектуре не хранится.
Подробности стандартного преобразования:
- `Dev_Docs/Протоколы/Преобразование_ED25519_в_X25519.md`
### 2.2. Правило шифрования копий
- `type=1` шифруется на ключ получателя;
- `type=2` шифруется на ключ отправителя.
Следствия:
- входящую копию может прочитать только получатель;
- исходящую копию может прочитать только отправитель;
- сервер не должен расшифровывать DM;
- ciphertext у `type=1` и `type=2` обычно разный и не обязан совпадать побайтно.
### 2.3. Что именно знает сервер о `body`
Для контентных DM (`type=1/2`) сервер в v1 должен рассматривать `body` как opaque blob:
- сервер проверяет внешний контейнер `SHiNE_DM`;
- сервер проверяет подпись;
- сервер проверяет служебные поля envelope;
- сервер проверяет только то, что `bodyLen > 0` и не превышает допустимый лимит;
- сервер не обязан разбирать внутренний crypto-контейнер `body`;
- сервер не обязан знать конкретный алгоритм шифрования `body`.
Это позволяет альтернативным клиентам использовать совместимый внешний DM-envelope при собственном формате зашифрованного payload внутри `body`.
Официальный UI SHiNE при приёме такого сообщения:
- пытается расшифровать знакомый формат `EncryptedBody_v1_0`;
- показывает текст сообщения при успешной расшифровке;
- если внутренний формат не поддерживается, показывает `Формат сообщения не поддерживается`;
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<SHiNE:...>` в начале текста.
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
### 2.4. Источник истины по пользователю
Для DM-проверки сервер использует:
- локальную `solana_users` как кэш;
- Solana PDA как источник истины по `clientKey` и `access_servers`.
Если нужного пользователя нет локально, сервер обязан попытаться lazy-import из Solana PDA:
- в `GetUser`;
- при серверной верификации DM-подписи;
- перед межсерверной маршрутизацией через `access_servers`.
## 3. Типы сообщений
В версии v1 используются следующие `messageType`:
- `1` — входящее сообщение;
- `2` — исходящая копия сообщения;
- `3` — входящее подтверждение прочтения;
- `4` — исходящая копия подтверждения прочтения;
- `5` — сообщение удалено отправителем;
- `6` — сообщение удалено получателем;
- `7` — переписка удалена отправителем;
- `8` — переписка удалена получателем.
Типы `1/2` — это обычные контентные DM, в том числе их последующие ревизии.
Типы `3/4` — служебные подтверждения прочтения.
Типы `5/6/7/8` — служебные события удаления без зашифрованного тела.
## 4. Базовые операции
### 4.1. Создание нового сообщения
Новое сообщение отправляется парой блоков через:
- `SendMessagePair`
- `ReceiveOutcomingMessage` как алиас
Пару создаёт автор сообщения.
Пара содержит:
- одну входящую копию `type=1`;
- одну исходящую копию `type=2`;
- одинаковые `fromLogin`, `toLogin`, `timeMs`, `nonce`;
- одинаковый логический plaintext;
- разные ciphertext для разных владельцев копий.
### 4.2. Редактирование сообщения
Редактирование общего текста делает автор сообщения.
При редактировании:
- `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. Несколько серверов у отправителя и получателя
Протокол должен поддерживать ситуацию, когда:
- у отправителя несколько `access_servers`;
- у получателя несколько `access_servers`;
- часть серверов у сторон совпадает;
- часть серверов уникальна.
Из этого следуют требования:
- все DM-операции должны быть идемпотентны;
- повторное получение уже известного события не должно ломать состояние;
- дубль tombstone должен быть безопасен;
- сервер не должен "оживлять" более старую версию сообщения после уже принятого tombstone.
## 8. Методы и их роли
### 8.1. Существующие методы, которые сохраняются
- `SendMessagePair`
- `ReceiveOutcomingMessage`
- `ReceiveIncomingMessage`
- `DeleteMessage`
- `DeleteConversation`
Их роли в v1:
- `SendMessagePair` — новая пара сообщений и редактирование старой пары автором;
- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и входящего read-receipt;
- `ReceiveOutcomingMessage` — алиас `SendMessagePair`.
### 8.2. Новые методы, которые нужны
Отдельный legacy-метод `SendDirectMessage` в DM v1 не используется и должен оставаться отключённым, чтобы не было параллельного старого стека доставки.
## 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": "..."
}
}
```
Правила:
- клиенту достаточно отправить пару на один любой доступный сервер;
- сервер после принятия сам отвечает за дальнейшую межсерверную доставку.
### 10.2. `ReceiveIncomingMessage`
Назначение:
- приём одной входящей копии по схеме server-to-server;
- приём входящего редактирования;
- приём входящего read-receipt.
Request:
```json
{
"op": "ReceiveIncomingMessage",
"requestId": "req-456",
"payload": {
"incomingBlobB64": "..."
}
}
```
### 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`.
## 11. Межсерверная доставка
### 11.1. Клиентская сторона
Клиенту достаточно отправить сообщение на:
- любой один доступный сервер.
### 11.2. Серверная сторона
После принятия валидного события сервер должен отправлять его:
- на все серверы из `access_servers` отправителя;
- на все серверы из `access_servers` получателя.
Если часть серверов совпадает, это допустимо.
Если один и тот же сервер присутствует у обеих сторон, он не должен слать сообщение сам себе повторно, но обязан локально сохранить событие и доставить его в нужные пользовательские сессии.
Идемпотентность обязательна.
### 11.3. Ошибки доставки
Если часть серверов временно недоступна:
- это не должно отменять локальное принятие уже валидного сообщения;
- повторная доставка может делаться отдельным retry-механизмом;
- повторное получение того же события должно быть безопасным.
## 12. Хранение в БД
Основная таблица остаётся:
- `signed_messages_v2`
В ней должны сохраняться:
- обычные контентные DM;
- tombstone одного сообщения;
- tombstone удаления переписки.
Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы:
- защищать от повторного приёма старых версий;
- не терять факт удаления;
- корректно синхронизировать событие между серверами.
Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются.
## 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` совпадает;
- legacy `SendDirectMessage` должен быть отключён;
- логика должна быть безопасна для нескольких серверов у каждой стороны.
## 14. Что в v1 пока не входит
- вложения в DM;
- хранение отдельного `keyId` шифрования в DM;
- ротация `clientKey`;
- финальная конкретная UI-реализация массовой перешифровки;
- физическая полная реализация DM federation в текущем коде.
@@ -0,0 +1,230 @@
# Личные сообщения (DM) — v0.5 устаревшая спецификация
## Статус документа
Этот документ устарел и сохранён только как историческое описание ранее реализованной схемы DM.
Aктуальная целевая спецификация:
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`
Что в этом документе считать устаревшим:
- трактовку `encryptedBody` как фактически одинакового содержимого пары;
- отсутствие нормального E2EE-шифрования DM;
- старую модель удаления через пустую ревизию без терминального tombstone;
- старую трактовку обновления только как общей пары без отдельной будущей модели перешифровки;
- все упоминания legacy-формата read-receipt как части целевой архитектуры следующего этапа.
## Текущее состояние
Сейчас в проекте реализованы:
- новый формат контентных личных сообщений `SHiNE_DM`;
- ревизии сообщений через `revisionTimeMs`;
- редактирование сообщения через повторную отправку той же логической пары;
- удаление сообщения через пустую ревизию;
- `upsert` последней версии сообщения на сервере.
Сейчас в проекте **не реализованы**:
- вложения в DM;
- upload/download файлов для DM;
- UI-кнопка прикрепления файла;
- серверное хранение файловых связей для DM.
Черновик будущих вложений вынесен отдельно:
- `Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md`
## Общая схема
Личное сообщение по-прежнему отправляется парой signed-блоков:
- `type=1` — входящий блок для получателя;
- `type=2` — исходящая копия для отправителя.
Read-receipt пока остаются в legacy-формате:
- `type=3` — входящее подтверждение прочтения;
- `type=4` — исходящая копия подтверждения.
Ключи сообщения:
- `baseKey = fromLogin|toLogin|timeMs|nonce`
- `messageKey = baseKey|messageType`
Логический идентификатор письма задаётся парой:
- `timeMs`
- `nonce`
Эти поля не меняются при редактировании или удалении. Меняется только:
- `revisionTimeMs`
- содержимое `encryptedBody`
Сервер хранит только последнюю версию записи для каждого `messageKey`.
## Формат контентного DM: `SHiNE_DM`
Префикс бинарного блока:
- `SHiNE_DM`
Поля идут в big-endian порядке:
1. `formatVersionMajor` (`u8`) = `1`
2. `formatVersionMinor` (`u8`) = `0`
3. `toLoginLen` (`u8`) + `toLogin` (ASCII, `1..60`)
4. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, `1..60`)
5. `timeMs` (`u64`)
6. `nonce` (`u32`)
7. `messageType` (`u16`) — только `1` или `2`
8. `revisionTimeMs` (`u64`)
9. `attachmentsCount` (`u8`)
10. `encryptedBodyLen` (`u32`)
11. `encryptedBody` (`bytes`)
12. `signature` (`64 bytes`, Ed25519)
### Ограничения
- `attachmentsCount` сейчас всегда должен быть `0`
- `encryptedBodyLen` сейчас ограничен сервером до `16384` байт
- `revisionTimeMs` не может быть отрицательным
Если приходит `attachmentsCount != 0`, сервер отклоняет такой DM как:
- `ATTACHMENTS_DISABLED`
## Legacy read-receipt: `SHiNE_dm2`
Подтверждения прочтения `type=3/4` пока используют старый контейнер `SHiNE_dm2`:
1. `toLoginLen` (`u8`) + `toLogin`
2. `fromLoginLen` (`u8`) + `fromLogin`
3. `timeMs` (`u64`)
4. `nonce` (`u32`)
5. `messageType` (`u16`) — `3` или `4`
6. `payloadLen` (`u16`)
7. `payloadBytes`
8. `signature`
## Редактирование
Редактирование делается новой отправкой той же логической пары сообщения:
- `timeMs` и `nonce` остаются теми же;
- `messageType` остаётся `1/2`;
- `revisionTimeMs` становится больше;
- `encryptedBody` содержит новую версию текста.
Если на сервер приходит более старая ревизия, она игнорируется.
Если приходит та же ревизия и тот же бинарный блок, сервер тоже её не применяет повторно.
## Удаление
Удаление личного сообщения делается как новая ревизия того же сообщения:
- `timeMs` и `nonce` остаются прежними;
- `revisionTimeMs` увеличивается;
- `attachmentsCount = 0`;
- `encryptedBodyLen = 0`;
- `encryptedBody` пустой.
В UI такое сообщение не показывается.
На сервере это не отдельный тип сообщения, а просто последняя пустая ревизия того же `messageKey`.
## Поведение сервера
Для контентных DM сервер:
1. принимает пару signed-блоков `type=1/2`;
2. валидирует формат, подпись и совпадение ключевых полей пары;
3. проверяет, что для обеих сторон пары совпадают:
- `fromLogin`
- `toLogin`
- `timeMs`
- `nonce`
- `revisionTimeMs`
- `encryptedBody`
4. делает `upsert` последней версии в `signed_messages_v2`;
5. сбрасывает pending-доставку по сессиям для новой ревизии;
6. рассылает актуальную версию адресатам через `SignedMessageArrived`.
История старых ревизий сейчас не хранится отдельно: в таблице остаётся только последняя версия по каждому `messageKey`.
## Хранение в БД
Основная таблица:
- `signed_messages_v2`
Для контентных DM в ней используются:
- `message_key`
- `base_key`
- `target_login`
- `from_login`
- `to_login`
- `time_ms`
- `nonce`
- `message_type`
- `revision_time_ms`
- `raw_block`
- `created_at_ms`
Отдельных таблиц файлов для DM сейчас нет.
## События и доставка
Запрос на отправку по WebSocket остаётся прежним:
- `SendMessagePair`
- `ReceiveOutcomingMessage` как алиас
Клиент отправляет:
- `incomingBlobB64`
- `outgoingBlobB64`
Событие в активные сессии:
- `SignedMessageArrived`
Если пришла новая ревизия того же сообщения, `messageKey` остаётся прежним, а внутри `blobB64` будет более новый `revisionTimeMs`.
Подтверждение доставки в сессию:
- `AckSessionDelivery`
WebPush и локальные уведомления сейчас работают так:
- для активной онлайн-сессии приоритет у доставки по WebSocket через `SignedMessageArrived`;
- если целевая сессия не онлайн по WebSocket, сервер может отправить WebPush с `kind=new_message`;
- если вкладка/приложение живы, но страница скрыта (`document.visibilityState !== visible`), UI дополнительно пытается показать системное уведомление через `service worker`;
- для активной видимой страницы UI проигрывает короткий локальный сигнал на каждое новое входящее DM, если браузер ранее разрешил аудио-контекст после пользовательского жеста;
- для скрытой, но живой страницы UI также делает `best effort` сигнал через `vibrate()` и более длинный локальный звук;
- эти локальные сигналы не гарантируются браузером: на мобильных устройствах они зависят от политики Chrome/Android/iOS.
## Правила UI
UI сейчас работает так:
- показывает только текст `encryptedBody`;
- умеет обновлять уже существующее сообщение по тому же `messageKey`;
- не показывает удалённые сообщения;
- позволяет владельцу сообщения вызвать меню `Скопировать как текст / Прочесть / Изменить / Удалить`;
- при редактировании показывает над полем ввода полоску `Редактируем сообщение: ...` с кнопкой отмены;
- после редактирования показывает под временем отдельную строку `изменено: <дата время>`;
- на видимом экране чата/приложения проигрывает короткий локальный звук на новое входящее DM;
- при входящем DM для скрытой, но ещё живой страницы пытается поднять системное уведомление через `service worker`;
- не показывает и не принимает вложения.
## Что обязательно помнить
- вложения в DM сейчас отключены на уровне протокола и UI;
- любые старые описания `/f/...`, `/upload` и файловых таблиц для DM больше не актуальны;
- если позже вложения вернутся, их формат и серверная логика могут быть другими.
@@ -0,0 +1,147 @@
# Технические вставки внутри plaintext DM v1
## Статус документа
Этот файл описывает внутренний формат технических вставок, которые находятся внутри уже расшифрованного plaintext DM.
Важно:
- это не отдельный серверный DM-envelope;
- это часть plaintext контентного DM после E2EE-расшифровки;
- сервер не обязан знать этот формат;
- клиент может использовать эти вставки для специального UI-рендера.
## 1. Общий принцип
Обычное сообщение по-прежнему остаётся обычным текстом.
Если в начале plaintext стоит один или несколько специальных блоков формата:
```text
<SHiNE:...>
```
то клиент трактует их как технические вставки.
Техническими считаются только блоки, которые:
- стоят строго в начале plaintext;
- начинаются с точного префикса `<SHiNE:`;
- заканчиваются первым символом `>`.
Если текст не начинается с `<SHiNE:`, никакие технические вставки не ищутся.
## 2. Правило скрытия
Все корректно распознанные блоки `<SHiNE:...>` в начале plaintext:
- не показываются пользователю как сырой текст;
- используются клиентом для UI-логики;
- неизвестные будущие типы тоже скрываются, если они распознаны как корректный SHiNE-блок.
Если блок битый и не закрыт символом `>`, он не считается техническим и сообщение показывается как обычный текст целиком.
## 3. Защита от случайного пользовательского ввода
Перед отправкой обычного текстового сообщения клиент обязан проверить:
- если пользовательский текст начинается с `<SHiNE:`
то клиент автоматически превращает начало в:
```text
< SHiNE:
```
Такой текст уже не считается техническим блоком и должен отображаться как обычное сообщение.
## 4. Формат блока
Общий вид:
```text
<SHiNE:kind;key=value;key=value;...>
```
Правила:
- `kind` — ASCII-идентификатор типа вставки;
- параметры отделяются `;`;
- ключ и значение отделяются `=`;
- значения не экранируются в v1;
- формат чувствителен к точному префиксу `<SHiNE:`.
## 5. Тип `reply`
Формат:
```text
<SHiNE:reply;v=1;id=user1|user2|1720612345678|77>Текст ответа
```
Где поле `id` — это логический идентификатор сообщения:
```text
fromLogin|toLogin|timeMs|nonce
```
Правила:
- этот блок должен стоять в начале plaintext;
- после него может идти обычный текст ответа;
- официальный UI формирует такой блок при отправке ответа через пункт `Ответить` в меню сообщения;
- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview;
- в таком случае само сообщение отображается как обычный текст без блока `<SHiNE:reply...>`.
## 6. Тип `call`
### Успешный звонок
```text
<SHiNE:call;v=1;status=completed;duration=367>
```
Где:
- `duration` — длительность разговора в секундах.
### Неуспешный звонок
```text
<SHiNE:call;v=1;status=failed;reason=offline>
```
Допустимые причины в v1:
- `offline`
- `no_answer`
- `connect_failed`
- `busy`
- `declined`
Правила:
- call-блок в v1 должен содержать только техническую запись звонка;
- пользовательский текст после такого блока в штатной логике не предполагается;
- UI может рисовать такие сообщения отдельным специальным стилем.
- официальный UI не отправляет call-summary, если от старта исходящего звонка до его завершения прошло меньше `5` секунд.
## 7. Поведение официального UI
Официальный UI SHiNE в v1:
- скрывает все корректные `<SHiNE:...>` блоки в начале plaintext;
- для `call` строит специальный человекочитаемый текст:
- `Звонок: M:SS`
- `Звонок: H:MM:SS`
- `Звонил, но недозвонился: ...`
- для `reply` скрывает сам блок и показывает только текст ответа;
- если исходное reply-сообщение не найдено, reply-preview не показывается.
## 8. Совместимость
Так как это часть plaintext, а не часть серверного envelope:
- сервер не обязан понимать этот формат;
- будущие клиенты могут добавлять новые `kind`;
- клиенты, которые распознают SHiNE-вставки, должны скрывать неизвестные блоки целиком, если они стоят в начале и корректно закрыты.
@@ -0,0 +1,329 @@
# Формат DM v1
## Статус документа
Этот файл фиксирует байтовый формат DM-контейнеров SHiNE v1.
Важно:
- это актуальный формат, реализованный в текущем коде;
- контейнер `SHiNE_DM` используется для всех типов `1..8`;
- при любых будущих изменениях DM этот документ обновляется одновременно с кодом.
Логика протокола, API и поведение сервера описаны отдельно:
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`
## 1. Общие правила
- Числа кодируются в `big-endian`.
- `toLogin` и `fromLogin` — ASCII строки длиной `1..60`.
- Подпись — `Ed25519`, 64 байта.
- `nonce``u32`.
- `messageType``u8`.
- `timeMs`, `revisionTimeMs`, `reencryptedAtMs``u64`.
## 2. Типы сообщений
- `1` — входящее сообщение
- `2` — исходящая копия сообщения
- `3` — входящее подтверждение прочтения
- `4` — исходящая копия подтверждения прочтения
- `5` — сообщение удалено отправителем
- `6` — сообщение удалено получателем
- `7` — переписка удалена отправителем
- `8` — переписка удалена получателем
## 3. Контейнер `SHiNE_DM`
```text
SHiNE_DM
- prefix = "SHiNE_DM"
- formatVersionMajor: u8
- formatVersionMinor: u8
- toLoginLen: u8
- toLogin
- fromLoginLen: u8
- fromLogin
- timeMs: u64
- nonce: u32
- messageType: u8
- revisionTimeMs: u64
- reencryptedAtMs: u64
- bodyLen: u32
- body
- signature: [64]
```
## 4. Смысл полей `SHiNE_DM`
### `timeMs`
Это время исходного создания сообщения.
Для типов `7/8` это время самого служебного сообщения удаления переписки и одновременно граница, раньше которой сообщения этой пары считаются недействительными.
### `nonce`
Дополнительное поле уникальности сообщения.
Используется вместе с `timeMs` для построения стабильного логического ID.
### `messageType`
Определяет смысл контейнера:
- обычное сообщение;
- исходящая копия;
- read-receipt;
- удаление одного сообщения;
- удаление всей переписки.
### `revisionTimeMs`
- `0` для исходной версии контентного сообщения;
- ненулевое значение для новой текстовой ревизии сообщения;
- для типов `5/6` это время tombstone удаления одного сообщения;
- для типов `7/8` всегда `0`.
То есть `revisionTimeMs` отвечает именно за изменение смыслового содержимого сообщения, а не за сам факт перешифровки.
### `reencryptedAtMs`
- `0`, если перешифровки не было;
- ненулевое значение, если копия сообщения была перешифрована.
Для типов `5/6/7/8` должно быть `0`.
`reencryptedAtMs` не требует, чтобы `revisionTimeMs` был ненулевым. Допустим сценарий:
- `revisionTimeMs = 0`
- `reencryptedAtMs > 0`
если исходное сообщение не редактировалось, но было перешифровано позже.
### `bodyLen`
- `> 0` для типов `1/2/3/4`;
- `0` для типов `5/6/7/8`;
- для всех типов используется один и тот же контейнер `SHiNE_DM`.
## 5. Поле `body` для типов `1/2`
Для обычного контентного DM поле `body` содержит не голый ciphertext, а контейнер шифрования:
```text
EncryptedBody_v1_0
- cryptoMethod: u8
- cryptoVersion: u8
- ephemeralPubKeyLen: u8
- ephemeralPubKey: bytes[ephemeralPubKeyLen]
- ivLen: u8
- iv: bytes[ivLen]
- cipherTextLen: u32
- cipherText: bytes[cipherTextLen]
```
### Смысл полей `EncryptedBody_v1_0`
#### `cryptoMethod`
Идентификатор метода шифрования.
В версии v1 резервируется:
- `1``X25519 + HKDF-SHA256 + AES-256-GCM`
#### `cryptoVersion`
Версия конкретного метода шифрования.
Для текущей версии:
- `0`
#### `ephemeralPubKey`
Публичный ephemeral `X25519` ключ отправителя конкретной ревизии.
Практически ожидается длина:
- `32`
#### `iv`
Nonce/IV для `AES-GCM`.
Практически ожидается длина:
- `12`
#### `cipherText`
Зашифрованное содержимое сообщения.
Сервер не должен трактовать это поле как UTF-8 текст и не должен пытаться расшифровывать его в обычной DM-логике.
Важно:
- официальный UI SHiNE сейчас использует именно `EncryptedBody_v1_0`;
- сервер для контентных DM `type=1/2` не должен требовать, чтобы `body` обязательно был распознан как `EncryptedBody_v1_0`;
- сервер должен хранить и пересылать `body` как opaque bytes, если внешний контейнер `SHiNE_DM` валиден.
### Точная схема для `cryptoMethod = 1`, `cryptoVersion = 0`
- публичный ключ получателя для E2EE получается как стандартное преобразование `Ed25519 -> X25519`;
- отправитель генерирует ephemeral `X25519` приватный ключ и кладёт соответствующий `ephemeralPubKey` в контейнер;
- общий секрет вычисляется как `X25519(ephemeralPrivKey, recipientX25519PubKey)`;
- `HKDF-SHA256` использует:
- `salt = ephemeralPubKey || recipientX25519PubKey`
- `info = "SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM"` в ASCII
- `outputLen = 32`
- полученные `32` байта используются как ключ `AES-256-GCM`;
- поле `cipherText` хранит стандартный результат библиотеки `AES-GCM` в виде:
- `ciphertext || tag`
## 6. Поле `body` для типов `3/4`
Для read-receipt типов `3/4` поле `body` содержит открытый контейнер ссылки на исходное сообщение:
```text
ReadReceiptBody_v1_0
- refToLoginLen: u8
- refToLogin
- refFromLoginLen: u8
- refFromLogin
- refTimeMs: u64
- refNonce: u32
```
Смысл:
- `refToLogin`
- `refFromLogin`
- `refTimeMs`
- `refNonce`
однозначно указывают на логическое сообщение по его `baseKey`.
Отдельный `refType` в v1 не нужен, потому что подтверждение прочтения относится к самому логическому сообщению, а не к выбору между копиями `type=1` и `type=2`.
## 7. Контент типов `1/2`
Для типов `1/2`:
- `bodyLen > 0`
- `body` обязан быть контейнером `EncryptedBody_v1_0`
- ciphertext у `type=1` и `type=2` может быть разным
### Новое сообщение
- `revisionTimeMs = 0`
- `reencryptedAtMs = 0`
### Редактирование
- `revisionTimeMs > 0`
- `reencryptedAtMs = 0` или больше нуля, если одновременно произошла перешифровка
### Перешифровка
- `revisionTimeMs = 0` или `> 0`
- `reencryptedAtMs > 0`
## 8. Контент типов `3/4`
Типы:
- `3` — входящее подтверждение прочтения
- `4` — исходящая копия подтверждения прочтения
Правила:
- `bodyLen > 0`
- `body` обязан быть контейнером `ReadReceiptBody_v1_0`
- `revisionTimeMs = 0`
- `reencryptedAtMs = 0`
Тип `3` и тип `4` по стилю полностью подчиняются общему контейнеру `SHiNE_DM`:
- тот же `prefix = "SHiNE_DM"`
- те же `formatVersionMajor = 1`
- те же `formatVersionMinor = 0`
Различается только `messageType` и формат `body`.
## 9. Контент типов `5/6`
Типы:
- `5` — сообщение удалено отправителем
- `6` — сообщение удалено получателем
Правила:
- `bodyLen = 0`
- `body` отсутствует
- `revisionTimeMs > 0`
- `reencryptedAtMs = 0`
Это terminal tombstone конкретного сообщения.
После принятия такого контейнера содержательная версия этого же `messageKey` больше не должна приниматься.
## 10. Контент типов `7/8`
Типы:
- `7` — переписка удалена отправителем
- `8` — переписка удалена получателем
Правила:
- `bodyLen = 0`
- `body` отсутствует
- `revisionTimeMs = 0`
- `reencryptedAtMs = 0`
Здесь роль границы удаления играет:
- `timeMs` самого контейнера
После принятия такого контейнера:
- более старые сообщения этой пары должны быть удалены;
- более старые новые поступления этой пары не должны приниматься.
## 11. Подпись
Поле `signature` всегда подписывает весь контейнер от `prefix` до конца `body` включительно.
Правило подписи по `messageType`:
- `1`, `2`, `5`, `7` подписывает отправитель сообщения;
- `6`, `8` подписывает получатель сообщения.
Практическое UI-следствие:
- клиентский UI может инициировать удаление входящего сообщения через `type=6`;
- клиентский UI может инициировать очистку истории переписки через `type=8`;
- сами контейнеры и байтовый формат при этом не отличаются от уже описанных типов `5/6/7/8`.
## 12. Какие поля должны совпадать у пары `1/2`
При обычной парной отправке через `SendMessagePair` у двух копий должны совпадать:
- `fromLogin`
- `toLogin`
- `timeMs`
- `nonce`
- `revisionTimeMs`
Дополнительно:
- логический plaintext должен быть одинаковым;
- ciphertext может различаться;
- `reencryptedAtMs` при одной парной ревизии тоже должен совпадать.
## 13. Примечание о поддержке
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
+482
View File
@@ -0,0 +1,482 @@
# Solana user_pda: итоговый целевой формат пользовательской записи
Документ описывает целевой формат пользовательской PDA-записи `user_pda` для Solana-программы `shine_users`.
Это не формат основного блокчейна SHiNE и не документация по `AddBlock`. Основной блокчейн SHiNE описан отдельно в `Dev_Docs/Blockchain/`.
Статус документа: итоговый согласованный формат, к которому приведены `create_user_pda`, `update_user_pda` и тестовый сериализатор Solana-модуля.
## 1. Назначение user_pda
`user_pda` хранит публичное состояние пользователя в Solana:
- логин пользователя;
- неизменяемые параметры создания записи;
- публичный recovery-ключ пользователя;
- корневой публичный ключ пользователя;
- клиентский публичный ключ пользователя;
- данные одного или нескольких пользовательских блокчейнов SHiNE;
- серверные данные пользователя, если пользователь выступает сервером;
- серверы доступа пользователя;
- счетчики/лимиты;
- подпись записи.
На первом этапе поддерживается один пользовательский блокчейн SHiNE, но формат блока блокчейна сразу допускает повторение таких блоков в будущем.
## 2. Адрес PDA
Адрес пользовательской PDA вычисляется по логину:
- seed prefix: `user_login=`;
- второй seed: нормализованный логин в нижнем регистре;
- program id: программа `shine_users`.
Один логин соответствует одной `user_pda`.
## 2.1. Кто оплачивает create/update PDA
- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `client_key`.
- `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer.
- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `client_key`.
## 3. Общие правила кодирования
- Числа кодируются в Little Endian.
- `u8`, `u16`, `u32`, `u64` имеют обычный фиксированный размер.
- Публичный ключ Solana/Ed25519: 32 байта.
- Ed25519-подпись: 64 байта.
- SHA-256/Solana hash: 32 байта.
- Строка переменной длины: `len: u8` + `bytes[len]` в UTF-8.
- Arweave `tx_id`: строка переменной длины. Ожидаемая практическая длина base64url tx id - 43 байта, но формат хранит длину явно.
- Все типизированные блоки после фиксированного заголовка начинаются с `block_type: u8` и `block_version: u8`.
- Отдельный `block_len` у типизированных блоков не хранится: блоки парсятся по известным полям, счетчикам и строкам с `len: u8`.
## 4. Верхний формат записи
Первые 9 полей фиксированы и идут строго в указанном порядке. Это общий заголовок записи.
| N | Поле | Тип | Размер | Правило |
|---|------|-----|--------|---------|
| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. |
| 2 | `format_major` | `u8` | 1 | Для первого формата: `1`. |
| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. |
| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. |
| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. |
| 6 | `updated_at_ms` | `u64` | 8 | Время последнего обновления записи. |
| 7 | `record_number` | `u32` | 4 | Номер версии записи пользователя. При создании `0`, при обновлении +1. |
| 8 | `prev_record_hash` | bytes | 32 | Хэш unsigned-части предыдущей записи. При создании 32 нулевых байта. |
| 9 | `login` | string | `1 + len` | Логин пользователя. Не меняется. |
После первых 9 полей идет набор типизированных блоков:
```text
UserPdaRecordV1
- fixed_header: поля 1..9
- blocks_count: u8
- blocks: TypedBlock[blocks_count]
- signature: [u8; 64]
- padding: bytes до размера PDA, если нужен
```
`blocks_count` входит в unsigned-часть записи и подписывается.
## 5. Типы блоков
Зарезервированные значения `block_type`:
| block_type | Блок | Назначение |
|------------|------|------------|
| `0` | `RecoveryKeyBlock` | Ключ восстановления пользователя. |
| `1` | `RootKeyBlock` | Корневой ключ пользователя. |
| `2` | `ClientKeyBlock` | Клиентский ключ пользователя. |
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
| `30` | `ServerProfileBlock` | Серверные данные пользователя. |
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. |
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
Правила:
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`;
- каждый обязательный блок должен встречаться ровно один раз;
- порядок блоков в записи фиксируется для простоты проверки:
`RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`.
## 6. RecoveryKeyBlock
Recovery-ключ нужен для будущих сценариев восстановления и ротации остальных ключей. В текущей версии он только публикуется в записи и не меняется через обычный `update_user_pda`.
```text
RecoveryKeyBlock
- block_type: u8 = 0
- block_version: u8 = 0
- recovery_key: [u8; 32]
```
Правила:
- при создании задается публичный recovery-ключ пользователя;
- при обновлении `recovery_key` должен совпадать с предыдущей записью;
- приватный `recovery.key` в PDA не хранится;
- отдельная ротация recovery-ключа будет отдельным форматом/сценарием в будущем.
## 7. RootKeyBlock
Смена `root_key` пока не проектируется и не реализуется. Блок фиксирует только стадию `0`.
```text
RootKeyBlock
- block_type: u8 = 1
- block_version: u8 = 0
- root_key: [u8; 32]
```
Правила:
- при создании задается корневой публичный ключ пользователя;
- при обновлении `root_key` должен совпадать с предыдущей записью;
- ротация root-key будет отдельным форматом/сценарием в будущем.
## 8. ClientKeyBlock
Смена `client_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один клиентский ключ пользователя.
```text
ClientKeyBlock
- block_type: u8 = 2
- block_version: u8 = 0
- client_key: [u8; 32]
```
Правила:
- при создании задается текущий клиентский публичный ключ пользователя;
- при обновлении `client_key` должен совпадать с предыдущей записью;
- история устройств и несколько клиентских ключей в этом формате не хранятся.
## 9. BlockchainRegistryBlock
Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список.
```text
BlockchainRegistryBlock
- block_type: u8 = 3
- block_version: u8 = 0
- blockchain_count: u8
- blockchain_records: BlockchainRecord[blockchain_count]
```
Правила:
- на первом этапе `blockchain_count = 1`;
- в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`;
- каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн.
## 10. BlockchainRecord
```text
BlockchainRecord
- blockchain_type: u8
- blockchain_name: string
- blockchain_public_key: [u8; 32]
- paid_limit_bytes: u64
- used_bytes: u64
- last_block_number: u32
- last_block_hash: [u8; 32]
- last_block_signature: [u8; 64]
- arweave_present: u8
- arweave_tx_id: string, только если arweave_present = 1
```
`blockchain_type`:
| Значение | Смысл |
|----------|-------|
| `1` | Основной пользовательский SHiNE-блокчейн. |
Поля:
- `blockchain_name` - строковое имя пользовательского блокчейна, например `login-001`. На первом этапе для основного блокчейна пользователя используется имя вида `<login>-001`, потому что это первый блокчейн этого пользователя.
- `blockchain_public_key` - публичный ключ блокчейна пользователя.
- `paid_limit_bytes` - оплаченный лимит хранения/записей в байтах.
- `used_bytes` - сколько байт уже занято в пользовательском SHiNE-блокчейне.
- `last_block_number` - номер последнего известного блока пользовательского блокчейна.
- `last_block_hash` - хэш последнего известного блока.
- `last_block_signature` - подпись хэша специального сообщения о вершине блокчейна ключом `blockchain_public_key`.
- `arweave_present` - `0`, если ссылки нет; `1`, если ссылка есть.
- `arweave_tx_id` - Arweave transaction id, где лежит выгруженный пользовательский канал/состояние.
Arweave `tx_id` - обычное поле внутри записи конкретного блокчейна. Solana-программа не проверяет, что такой Arweave transaction действительно существует и содержит корректные данные; это ответственность клиента/сервера/пользователя.
## 11. Правила обновления BlockchainRecord
При обновлении записи:
- `blockchain_type` для существующей записи не меняется;
- `blockchain_public_key` пока не ротируется автоматически; смена ключа требует отдельного согласованного сценария;
- `paid_limit_bytes` может только увеличиваться или оставаться прежним;
- при увеличении `paid_limit_bytes` пользователь платит комиссию в Solana по тарифам программы;
- `used_bytes` может только увеличиваться или оставаться прежним;
- `last_block_number` может только увеличиваться или оставаться прежним;
- `used_bytes <= paid_limit_bytes`;
- если `last_block_number` увеличился, то должны быть переданы новый `last_block_hash` и новая `last_block_signature`;
- `last_block_signature` проверяется через Ed25519-инструкцию Solana: подпись должна соответствовать хэшу сообщения `LastBlockState` и `blockchain_public_key`;
- в транзакции `create_user_pda` / `update_user_pda` две Ed25519-инструкции должны идти непосредственно перед вызовом `shine_users`: сначала подпись `root_key`, затем подпись `blockchain_public_key`;
- `arweave_tx_id` можно добавить или заменить на новый, если пользователь выгрузил более актуальное состояние в Arweave;
- уменьшать лимит, число блоков или занятый размер нельзя.
Сообщение `LastBlockState`, которое хэшируется и подписывается ключом `blockchain_public_key`:
```text
LastBlockState
- constant: bytes = "SHiNE_LAST_BLOCK"
- login: string
- blockchain_name: string
- last_block_number: u32
- last_block_hash: [u8; 32]
- used_bytes: u64
```
Алгоритм:
```text
message = SHA-256(LastBlockState bytes)
last_block_signature = Ed25519(blockchain_public_key, message)
```
Причина проверки подписи `LastBlockState`: `root_key` управляет Solana-записью пользователя, а `blockchain_public_key` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера.
## 12. ServerProfileBlock
Блок присутствует, если пользователь выступает сервером.
```text
ServerProfileBlock
- block_type: u8 = 30
- block_version: u8 = 0
- is_server: u8
- address_format_type: u8, только если is_server = 1
- address_format_version: u8, только если is_server = 1
- server_address: string, только если is_server = 1
- sync_servers_count: u8, только если is_server = 1
- sync_servers: string[sync_servers_count], только если is_server = 1
```
Правила:
- `is_server = 0` означает, что серверных данных нет;
- `is_server = 1` означает, что пользователь публикует серверный профиль;
- `address_format_type` — тип формата адреса сервера: `1` = URL-строка (например `https://shineup.me/ws`);
- `address_format_version` — версия формата адреса, сейчас `0`;
- `sync_servers_count` максимум `32`;
- `server_address` - строковый адрес сервера в соответствии с `address_format_type`;
- `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует серверные данные и пользовательские блокчейны SHiNE. Это server-to-server список партнёров самого серверного узла, а не список серверов доставки личных сообщений для обычного пользователя. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы.
## 13. AccessServersBlock
Блок хранит серверы доступа/relay для пользователя.
```text
AccessServersBlock
- block_type: u8 = 40
- block_version: u8 = 0
- access_servers_count: u8
- access_servers: string[access_servers_count]
```
Правила:
- блок может отсутствовать, если серверы доступа не заданы;
- список может обновляться при изменении маршрутизации пользователя;
- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay для конкретного пользователя. Через этот список клиентская и серверная логика SHiNE может маршрутизировать доставку личных сообщений, доступ к сессиям и другие пользовательские операции. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы;
- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE.
## 14. SessionsBlock
Блок хранит опубликованные пользовательские сессии. На текущем этапе регистрация пользователя не добавляет туда записи автоматически, поэтому стандартный create/update продолжает работать с пустым списком.
```text
SessionsBlock
- block_type: u8 = 50
- block_version: u8 = 0
- sessions_mode: u8
- sessions_count: u8
- sessions: SessionRecord[sessions_count]
```
`sessions_mode`:
| Значение | Смысл |
|----------|-------|
| `1` | Можно использовать и сессии, зарегистрированные в PDA, и сессии, созданные вне PDA. |
| `10` | Зарезервировано на будущее: можно использовать только сессии, опубликованные в PDA. |
Сейчас рабочий режим по умолчанию: `sessions_mode = 1`. Серверная логика пока не реализует особое поведение для `10`; это задел под будущее расширение.
```text
SessionRecord
- session_type: u8
- session_version: u8
- session_name: string
- session_pub_key: [u8; 32]
```
`session_type`:
| Значение | Смысл |
|----------|-------|
| `1` | Обычная пользовательская сессия. |
| `50` | Кошелёк пользователя. |
| `100` | Homeserver пользователя. |
Правила:
- максимум `64` записей на пользователя;
- `session_name` не пустой, максимум `64` байта;
- `session_name` может содержать только символы `[A-Za-z0-9_]`;
- `session_version` сейчас должна быть равна `1`;
- внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`;
- на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически.
## 15. TrustedStateBlock
Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик.
```text
TrustedStateBlock
- block_type: u8 = 70
- block_version: u8 = 0
- trusted_count: u8 = 0
```
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
## 16. Подпись user_pda
Подписывается не вся PDA целиком, а unsigned-часть записи:
- от `magic` до последнего байта последнего типизированного блока включительно;
- включая `record_len`, `blocks_count`, все заголовки блоков и тела блоков;
- без поля `signature`;
- без padding.
Алгоритм:
```text
message = hash(unsigned_record_bytes)
signature = Ed25519(root_key, message)
```
Solana-программа проверяет подпись через встроенную Ed25519-инструкцию. Подписантом должен быть `root_key` из `RootKeyBlock`.
Для `shine_users` эта инструкция должна стоять в транзакции сразу перед Ed25519-инструкцией `last_block_signature` и непосредственно перед самой `create/update`-инструкцией программы.
Смену формата подписи сейчас не трогаем.
## 17. Регистрация пользователя
При регистрации:
- PDA еще не должна существовать;
- логин проходит проверку формата и login guard;
- `record_number = 0`;
- `prev_record_hash = 0x00...00`;
- `created_at_ms = updated_at_ms`;
- обязательные блоки присутствуют;
- создается минимум один `BlockchainRecord`;
- новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`;
- стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит;
- `used_bytes <= paid_limit_bytes`;
- пользователь платит регистрационную комиссию;
- если покупается дополнительный лимит, пользователь платит комиссию за этот лимит;
- вся unsigned-часть записи подписана `root_key`.
## 18. Обновление пользователя
При обновлении:
- PDA должна существовать;
- `login`, `created_at_ms`, `recovery_key`, `root_key`, `client_key` не меняются;
- `record_number = previous_record_number + 1`;
- `prev_record_hash` равен хэшу unsigned-части предыдущей записи;
- `updated_at_ms` обновляется;
- unsigned-часть новой записи подписана `root_key`;
- лимиты блокчейнов могут только увеличиваться;
- занятый размер и номер последнего блока не могут уменьшаться;
- при увеличении оплаченного лимита пользователь доплачивает комиссию;
- Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует.
## 19. Отличия от старого линейного формата
Старый формат после `login` хранил поля линейно:
- `root_key_status`;
- `root_key`;
- `blockchain_key_status`;
- `blockchain_key`;
- `client_key_status`;
- `client_key`;
- `chain_number`;
- `balance`;
- серверные поля;
- access-серверы;
- `trusted_count`;
- `reserved`;
- `signature`.
Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки:
- recovery-ключ становится отдельным обязательным блоком;
- ключи становятся отдельными блоками;
- данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`;
- серверные данные и access-серверы отделяются от данных блокчейна;
- расширение формата делается добавлением новых версий блоков или новых `block_type`, а не вставкой полей в середину линейной записи.
## 20. Деривация ключей из master secret
Сама Solana-программа не вычисляет ключи из секрета и не хранит приватные ключи. Но текущая согласованная клиентская схема деривации для публичной версии формата фиксируется здесь как reference для UI/ESP32/внешних клиентов.
Базовая формула:
```text
seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || suffix_utf8)
```
Где:
- `master_secret32` — 32-байтовый master secret пользователя;
- `suffix_utf8` — строка назначения ключа.
Согласованные suffix:
```text
"recovery.key"
"root.key"
"blockchain.key"
"client.key"
```
Соответствие:
```text
recovery.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "recovery.key")
root.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "root.key")
blockchain.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "blockchain.key")
client.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "client.key")
```
Далее каждая строка `seed` интерпретируется off-chain как `seed32` для отдельной пары Ed25519.
## 21. Что пока не входит в формат
Пока не проектируем:
- ротацию `recovery_key`;
- ротацию `root_key`;
- сложную ротацию `client_key`;
- ротацию `blockchain_public_key`;
- проверку содержимого Arweave transaction;
- хранение полной истории пользовательского блокчейна внутри Solana;
- подключение Solana-модуля к сборке/деплою основного сервера SHiNE.
+166
View File
@@ -0,0 +1,166 @@
# Архитектура Solana-программ SHiNE
Документ описывает рабочую архитектуру Solana-части SHiNE: три Anchor-программы, DAO, ключи управления, PDA-счета и движение денег.
Это архитектурная справка. Она не меняет код, формат PDA-записи пользователя, серверный API или формат блокчейна SHiNE.
Статус: актуализировано по коду `shine-solana/shine/programs/*` на 2026-05-25.
Связанные документы:
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` — single source of truth по деплою и первичной инициализации регистрации пользователей.
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` — точный формат `user_pda` для `shine_users`.
- `shine-solana/shine/doc/FUNDS_FLOW.md` — короткая справка по денежным потокам внутри Solana-модуля.
## Кратко
В Solana-модуле сейчас три основные программы:
1. `shine_login_guard` — в текущем временном режиме проверяет базовую валидность логина и пропускает on-chain любые валидные логины длиной от 5 символов.
2. `shine_users` — создает и обновляет пользовательскую PDA-запись, проверяет подписи и берет оплату за регистрацию/увеличение лимита.
3. `shine_payments` — принимает входящий поток средств в `inflow_vault`, ведет очереди тикетов, позволяет DAO выдавать лимиты менеджерам и выполняет выплаты.
DAO в текущем виде не является отдельной Anchor-программой SHiNE внутри `programs/`. Это управляющая модель поверх кошельков, governance-скриптов и authority-адресов. Для проектирования ее удобно считать отдельным управляющим блоком: DAO голосует, назначает управляющие ключи, управляет казной и вызывает защищенные методы второй и третьей программ.
## Общая схема
Редактируемая Mermaid-схема находится в [schemes/architecture.mmd](schemes/architecture.mmd).
Картинки:
- [schemes/architecture.svg](schemes/architecture.svg)
- [schemes/architecture.png](schemes/architecture.png)
## Программы и функции
| Блок | Папка/имя | Текущие функции из кода | Основной смысл |
| --- | --- | --- | --- |
| 1 | `shine_login_guard` | `classify_login` | Временная on-chain проверка логина перед регистрацией. |
| 2 | `shine_users` | `init_users_economy_config`, `update_users_economy_config`, `create_user_pda`, `update_user_pda` | Регистрация пользователя, обновление записи, экономика лимита. |
| 3 | `shine_payments` | `init`, `update_coef_limit`, `grant_manager_limits`, `buy_ticket`, `buy_ticket_usd`, `buy_ticket_sol`, `manager_add_ticket`, `step_payout`, `change_ticket_recipient` | Vault, билеты, очереди, выплаты, DAO-настройки, лимиты менеджеров. |
| DAO | governance/authority | Вызовы через governance и управляющие ключи | Управление правами, казной, настройками и будущими обновлениями программ. |
## Актуальные program id
Актуальные адреса заданы одновременно в `Anchor.toml`, `declare_id!` программ и `programs/common/src/deploy_config.rs`:
| Программа | Program ID |
| --- | --- |
| `shine_login_guard` | `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh` |
| `shine_users` | `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` |
| `shine_payments` | `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` |
Если эти адреса меняются, нужно синхронно обновить:
1. `shine-solana/shine/Anchor.toml`
2. `declare_id!` в `programs/*/src/lib.rs`
3. `programs/common/src/deploy_config.rs`
4. UI/серверные константы, перечисленные в `Dev_Docs/Инициализация_Solana_регистрации/README.md`
## Ключи и authority
Для удобного понимания на старте можно считать, что есть четыре группы ключей:
1. `key_1` / authority программы `shine_login_guard`.
- Сейчас программа только выполняет временную базовую проверку логина.
- На первом этапе ее можно оставить под отдельным ключом.
- В будущем право обновления можно передать DAO.
2. `key_2` / authority программы `shine_users`.
- Отвечает за деплой/upgrade второй программы.
- Защищенное обновление economy-конфига в коде уже проверяет `DAO_AUTHORITY`.
- В целевой модели upgrade-authority второй программы нужно передать DAO.
3. `key_3` / authority программы `shine_payments`.
- Отвечает за деплой/upgrade третьей программы.
- Защищенные методы `update_coef_limit` и `grant_manager_limits` проверяют `dao_wallet` из `ConfigState`.
- В целевой модели upgrade-authority третьей программы нужно передать DAO.
4. DAO-ключи.
- Это управляющие кошельки/токены/realm governance.
- DAO может добавлять и отзывать управляющие ключи по голосованию.
- DAO-казна получает деньги от покупки тикетов и DAO-часть выплат из `inflow_vault`.
Адреса program id сейчас берутся из `programs/common/src/deploy_config.rs`. Для production/devnet можно подбирать vanity-адреса с понятным началом вроде `SHi...`, но это отдельная операция генерации ключей и деплоя.
## Счета и PDA
Постоянные PDA и счета:
1. `shine_users`
- `user_pda` — пользовательская запись по seed `login=<login>`, создается для каждого логина.
- `users_economy_config_pda` — общие параметры экономики регистрации и лимита.
2. `shine_payments`
- `config_pda` — хранит `dao_wallet` и адрес `inflow_vault`.
- `coef_limit_pda` — хранит коэффициент выплат, лимит очереди и награду вызывающему `step_payout`.
- `queues_pda` — агрегаты очередей выплат.
- `inflow_vault_pda` — PDA-вольт, куда `shine_users` переводит оплату регистрации и увеличения лимита.
- `ticket_pda` — отдельная PDA-запись тикета на каждую покупку/менеджерскую выдачу.
- `manager_allowance_pda` — PDA лимитов конкретного менеджера.
3. DAO
- `dao_wallet` / treasury — казна DAO.
- governance-аккаунты DAO — realm, governance, proposal/vote records и связанные аккаунты SPL Governance, если используется эта модель.
## Правило разделения с основным сервером
Solana-модуль лежит в основном репозитории как отдельная папка `shine-solana/shine/`, но не подключается автоматически к сборке или деплою основного сервера SHiNE. Команды `deployServer` и `deployUI` не должны деплоить Anchor-программы. Solana build/deploy выполняется отдельно из папки `shine-solana/shine/` по локальным правилам модуля.
## Движение денег
Основные потоки:
1. Регистрация пользователя через `shine_users::create_user_pda`.
- Платит `signer`.
- Деньги идут в `shine_payments::inflow_vault_pda`.
- Сумма состоит из регистрационной комиссии и оплаты дополнительного лимита.
2. Увеличение лимита через `shine_users::update_user_pda`.
- Платит `signer`.
- Деньги идут в тот же `inflow_vault_pda`.
- Сумма равна оплате дополнительного лимита.
3. Покупка тикета через `shine_payments::buy_ticket*`.
- Платит покупатель.
- Деньги сразу идут в `dao_wallet`.
- Одновременно создается тикет на выплату.
4. Выплата через `shine_payments::step_payout`.
- Вызвать может любой подписант.
- Деньги берутся из `inflow_vault_pda`.
- Часть идет получателю тикета.
- Часть идет в `dao_wallet`.
- Небольшая награда идет вызвавшему шаг выплат.
- Если очереди пустые, весь доступный остаток `inflow_vault_pda` переводится в DAO.
## Передача прав DAO
Минимальная целевая модель:
1. `shine_login_guard`
- Пока оставить на отдельном ключе `key_1`.
- Передачу DAO сделать позже, когда временный режим будет заменён на постоянную логику промокодов и/или premium/trademark.
2. `shine_users`
- Economy-настройки уже должны обновляться DAO-authority.
- Upgrade-authority программы после проверки можно передать DAO.
3. `shine_payments`
- DAO уже управляет настройками выплат и лимитами менеджеров через `dao_wallet`.
- Upgrade-authority программы после проверки можно передать DAO.
4. DAO
- Управляет казной.
- Принимает решения голосованием.
- Добавляет/отзывает управляющие ключи.
- Вызывает защищенные методы второй и третьей программ.
- В будущем может принять управление первой программой.
## Детальные файлы
- [details/shine_login_guard.md](details/shine_login_guard.md)
- [details/shine_users.md](details/shine_users.md)
- [details/shine_payments.md](details/shine_payments.md)
- [details/shine_dao.md](details/shine_dao.md)
- [details/accounts_and_money_flow.md](details/accounts_and_money_flow.md)
@@ -0,0 +1,110 @@
# Счета, ключи и движение денег
## Кратко
В архитектуре есть три типа объектов:
1. Ключи программ и DAO.
2. PDA-счета состояния.
3. Денежные счета, через которые проходят SOL/lamports.
## Ключи
Минимальный набор для понимания:
1. `key_1` — deploy/upgrade authority `shine_login_guard`.
2. `key_2` — deploy/upgrade authority `shine_users`.
3. `key_3` — deploy/upgrade authority `shine_payments`.
4. `DAO_AUTHORITY` — адрес, который имеет право менять защищенные настройки.
5. `DAO_TREASURY_WALLET` / `dao_wallet` — казна DAO.
6. `manager_wallet` — кошелек менеджера, которому DAO выдает лимиты на создание тикетов.
7. `user root_key` — корневой ключ пользователя для подписи пользовательской записи.
8. `user client_key` — ключ устройства пользователя.
9. `server_key` — ключ сервера пользователя, если пользователь является сервером.
Текущие адреса из `programs/common/src/deploy_config.rs`:
| Роль | Адрес |
| --- | --- |
| `SHINE_LOGIN_GUARD_PROGRAM_ID` | `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh` |
| `SHINE_USERS_PROGRAM_ID` | `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` |
| `SHINE_PAYMENTS_PROGRAM_ID` | `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` |
| `DAO_AUTHORITY` | `aiShm43fZjm3YkMs22sYL1bpXaL3bVxv7SSraPHzVgq` |
| `DAO_TREASURY_WALLET` | `aiShm43fZjm3YkMs22sYL1bpXaL3bVxv7SSraPHzVgq` |
## Постоянные PDA
`shine_users`:
- `user_pda` — создается для каждого логина, seed `login=` + normalized login.
- `users_economy_config_pda` — один PDA с экономикой регистрации, seed `shine_users_economy_config`.
`shine_payments`:
- `config_pda` — один PDA конфига, seed `shine_payments_config`.
- `coef_limit_pda` — один PDA коэффициента/лимита/награды, seed `shine_payments_coef_limit`.
- `queues_pda` — один PDA агрегатов очередей, seed `shine_payments_queues`.
- `inflow_vault_pda` — один PDA-вольт входящих средств, seed `shine_payments_inflow_vault`.
- `ticket_pda` — много PDA, по одному на тикет, seed `shine_payments_q1_ticket` или `shine_payments_q2_ticket` + индекс.
- `manager_allowance_pda` — много PDA, по одному на менеджера, seed `shine_p_manager_allow` + адрес менеджера.
## Денежные потоки
### Регистрация
```text
user signer -> shine_users::create_user_pda -> shine_payments::inflow_vault_pda
```
Состав платежа:
- регистрационная комиссия;
- оплата `additional_limit`.
### Увеличение лимита
```text
user signer -> shine_users::update_user_pda -> shine_payments::inflow_vault_pda
```
Состав платежа:
- только оплата `additional_limit`.
### Покупка тикета
```text
buyer signer -> shine_payments::buy_ticket* -> dao_wallet
```
При этом создается `ticket_pda`, но деньги в `inflow_vault_pda` на этом шаге не идут.
### Выплата
```text
shine_payments::inflow_vault_pda -> ticket_recipient_wallet
shine_payments::inflow_vault_pda -> dao_wallet
shine_payments::inflow_vault_pda -> step_payout caller
```
Если очереди пустые:
```text
shine_payments::inflow_vault_pda -> dao_wallet
```
## Что нужно создать на старте
Минимально:
1. Три program id для `shine_login_guard`, `shine_users`, `shine_payments`.
2. Три upgrade-authority ключа или один временный deploy-ключ с четким планом передачи прав.
3. DAO authority/treasury.
4. `users_economy_config_pda`.
5. `shine_payments` PDA: `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda`.
Динамически будут создаваться:
- `user_pda` на каждого пользователя;
- `ticket_pda` на каждый тикет;
- `manager_allowance_pda` на каждого менеджера.
@@ -0,0 +1,74 @@
# SHiNE DAO
## Кратко
DAO — управляющий слой Solana-части SHiNE. В текущем коде это не отдельная Anchor-программа в `programs/`, а модель управления через DAO-кошелек, DAO-authority, governance-скрипты и будущую передачу upgrade-authority программ.
## Что DAO должно уметь
1. Управлять казной.
- Принимать средства на `dao_wallet`.
- Выплачивать средства со счета DAO по решениям голосования.
2. Управлять настройками `shine_users`.
- Обновлять регистрационную комиссию.
- Обновлять цену шага лимита.
- Обновлять стартовый бонус лимита.
3. Управлять настройками `shine_payments`.
- Обновлять коэффициент выплат.
- Обновлять лимит очереди.
- Обновлять награду за вызов `step_payout`.
4. Управлять менеджерами.
- Выдавать менеджеру лимит на добавление тикетов.
- Отдельно учитывать лимиты Q1 и Q2.
5. Управлять правами программ.
- Принять upgrade-authority `shine_users`.
- Принять upgrade-authority `shine_payments`.
- Позже принять upgrade-authority `shine_login_guard`, если это потребуется.
6. Управлять ключами DAO.
- Добавлять управляющие ключи.
- Отзывать или сжигать управляющие ключи.
- Делать это через голосование, а не вручную одним админом.
7. Фиксировать решения.
- Делать заявления/решения через governance-механику.
- Привязывать важные изменения к proposal/vote/execute.
## Текущие адреса управления
В общем deploy-конфиге сейчас есть два важных адреса:
- `DAO_AUTHORITY` — используется `shine_users` для проверки права менять economy-конфиг.
- `DAO_TREASURY_WALLET` — используется `shine_payments` как `dao_wallet`.
Сейчас они могут совпадать. В целевой DAO-модели их лучше рассматривать как разные роли:
- authority/governance signer — кто имеет право исполнять управленческие инструкции;
- treasury wallet — счет, куда приходят деньги DAO.
## Передача прав
Рекомендуемый порядок:
1. Сначала стабилизировать и проверить `shine_users` и `shine_payments`.
2. Передать DAO право обновлять настройки, если оно еще не передано.
3. Передать DAO upgrade-authority второй и третьей программ.
4. Оставить `shine_login_guard` на отдельном ключе до стабилизации словарей и правил логинов.
5. После стабилизации решить отдельным голосованием, передавать ли первую программу DAO.
## Важное разделение
Есть два разных типа прав:
1. Право вызвать защищенную функцию программы.
- Например, `update_coef_limit` или `grant_manager_limits`.
- Проверяется внутри программы по `dao_wallet` или `DAO_AUTHORITY`.
2. Право обновить саму программу.
- Это upgrade-authority Solana ProgramData.
- Оно передается отдельной Solana-командой/DAO-транзакцией и не равно обычному PDA-счету.
@@ -0,0 +1,58 @@
# `shine_login_guard`
## Кратко
`shine_login_guard` — первая программа Solana-модуля SHiNE. Она проверяет логин перед регистрацией пользователя и возвращает класс логина.
Папка программы: `shine-solana/shine/programs/shine_login_guard/`.
## Текущая функция
1. `classify_login(login: String)`
- Нормализует логин.
- Проверяет длину и допустимые символы.
- Сравнивает части логина со словарями premium/trademark.
- Возвращает результат через `set_return_data`.
Классы результата:
- `0` — обычный логин, регистрацию можно продолжать.
- `1` — premium-логин.
- `2` — trademark-логин, нужна отдельная проверка/разрешение.
## Правила нормализации и классификации
Текущая логика из `programs/shine_login_guard/src/lib.rs`:
- пустой логин или логин длиннее 20 символов получает класс `premium`;
- `_` при нормализации удаляется;
- допустимы только ASCII-буквы и цифры, остальные символы дают класс `premium`;
- после удаления `_` результат приводится к нижнему регистру;
- логины длиной 7 символов или меньше считаются `premium`;
- логин разбивается максимум на 3 словарных фрагмента;
- если среди найденных фрагментов есть trademark-слово, результат `trademark`;
- если найдены только premium-слова, результат `premium`;
- если разбиение по словарям не найдено, результат `free`.
Словари собираются на этапе build из файлов:
- `programs/shine_login_guard/src/dictionaries/premium/*.txt`
- `programs/shine_login_guard/src/dictionaries/trademarks/*.txt`
## Роль в общей схеме
`shine_users::create_user_pda` вызывает `shine_login_guard` через CPI и продолжает регистрацию только если логин получил класс `0`.
## Ключи и управление
На старте удобно считать, что у программы есть отдельный управляющий ключ `key_1`.
Текущая рекомендация:
- пока оставить `shine_login_guard` под отдельным ключом;
- не передавать ее DAO до стабилизации правил premium/trademark;
- позже можно передать upgrade-authority DAO, чтобы изменения словарей и правил проходили через голосование.
## Счета
Собственных постоянных PDA-счетов у программы сейчас нет. Для проверки нужен только подписант транзакции в `ClassifyLogin`.
@@ -0,0 +1,173 @@
# `shine_payments`
## Кратко
`shine_payments` — третья программа Solana-модуля SHiNE. Она отвечает за vault входящих средств, DAO-казну, покупку тикетов, менеджерские лимиты, очереди выплат и пошаговое исполнение выплат.
Папка программы: `shine-solana/shine/programs/shine_payments/`.
## Текущие функции
1. `init`
- Создает основные PDA: `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda`.
- Записывает `dao_wallet` и стартовые параметры выплат.
2. `update_coef_limit`
- Обновляет коэффициент выплаты, лимит очереди и награду вызвавшему `step_payout`.
- Требует подпись DAO-кошелька из `ConfigState`.
3. `grant_manager_limits`
- DAO выдает менеджеру лимиты на создание тикетов в очередях Q1/Q2.
- Создает или обновляет `manager_allowance_pda`.
4. `buy_ticket`
- Покупка тикета с суммой в lamports, пересчетом через Pyth SOL/USD.
5. `buy_ticket_usd`
- Покупка тикета от USD-центов с защитой по максимальному платежу в lamports.
6. `buy_ticket_sol`
- Покупка тикета в lamports с проверкой минимального ожидаемого USD-эквивалента.
7. `manager_add_ticket`
- Менеджер создает тикет за счет выданного ему DAO-лимита.
8. `step_payout`
- Любой подписант может вызвать шаг выплат.
- Программа выплачивает следующий тикет, DAO-часть и награду вызывающему.
9. `change_ticket_recipient`
- Текущий получатель тикета может поменять адрес получателя, если тикет еще не следующий на выплату.
## Аргументы инструкций
`init` аргументов не принимает.
`update_coef_limit`:
- `coef_ppm: u64`
- `limit_usd_cents: u64`
- `call_reward_lamports: u64`
`grant_manager_limits`:
- `manager_wallet: Pubkey`
- `add_q1_usd_cents: u64`
- `add_q2_usd_cents: u64`
`buy_ticket`:
- `amount_lamports: u64`
- `recipient_wallet: Pubkey`
`buy_ticket_usd`:
- `amount_usd_cents: u64`
- `max_pay_lamports: u64`
- `recipient_wallet: Pubkey`
`buy_ticket_sol`:
- `amount_lamports: u64`
- `min_expected_usd_cents: u64`
- `recipient_wallet: Pubkey`
`manager_add_ticket`:
- `queue_id: u8` — только `1` или `2`
- `recipient_wallet: Pubkey`
- `payout_usd_cents: u64`
`change_ticket_recipient`:
- `new_recipient_wallet: Pubkey`
## Главные PDA
1. `config_pda`
- Seed: `shine_payments_config`.
- Хранит `dao_wallet` и `inflow_vault`.
- Размер PDA: `8 + 160` байт.
2. `coef_limit_pda`
- Seed: `shine_payments_coef_limit`.
- Хранит коэффициент выплат, лимит и награду `step_payout`.
- Размер PDA: `8 + 96` байт.
3. `queues_pda`
- Seed: `shine_payments_queues`.
- Хранит агрегаты очередей Q1/Q2.
- Размер PDA: `8 + 192` байт.
4. `inflow_vault_pda`
- Seed: `shine_payments_inflow_vault`.
- Принимает деньги от `shine_users`.
- Из него выполняются выплаты тикетам, DAO и вызывающему `step_payout`.
- Размер PDA: `8 + 32` байт.
5. `ticket_pda`
- Seed зависит от очереди и индекса тикета.
- Отдельная PDA-запись на каждый тикет.
- Q1 seed: `shine_payments_q1_ticket` + `ticket_index`.
- Q2 seed: `shine_payments_q2_ticket` + `ticket_index`.
- Размер PDA: `8 + 160` байт.
6. `manager_allowance_pda`
- Seed: `shine_p_manager_allow` + адрес менеджера.
- Хранит доступный лимит менеджера по Q1/Q2.
- Размер PDA: `8 + 128` байт.
## Текущие параметры
Параметры initial config из `programs/shine_payments/src/settings.rs`:
| Поле | Значение | Смысл |
| --- | --- | --- |
| `START_COEF_PPM` | `5_000_000` | коэффициент 5.0x в ppm-масштабе |
| `START_LIMIT_USD_CENTS` | `1_000_000` | стартовый лимит Q1: 10_000 USD |
| `START_CALL_REWARD_LAMPORTS` | `8_000_000` | награда вызвавшему `step_payout`, 0.008 SOL |
| `MAX_CALL_REWARD_LAMPORTS` | `10_000_000` | максимум награды, 0.01 SOL |
| `ORACLE_MAX_AGE_SECS` | `120` | максимальный возраст цены Pyth |
Для расчетов используется Pyth SOL/USD:
- feed id: `0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d`
- price update account: `7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE`
## Деньги
Входы:
- из `shine_users` в `inflow_vault_pda` при регистрации и увеличении лимита;
- от покупателя тикета сразу в `dao_wallet` при `buy_ticket*`.
Выходы:
- из `inflow_vault_pda` получателю тикета;
- из `inflow_vault_pda` в `dao_wallet`;
- из `inflow_vault_pda` вызвавшему `step_payout`;
- если очереди пустые, весь доступный остаток `inflow_vault_pda` переводится в DAO.
## Очереди и выплаты
Выплаты идут строго пошагово:
- если есть невыплаченные Q1-тикеты, `step_payout` берет следующий Q1;
- если Q1 пустая, берется следующий Q2;
- для Q1 DAO-часть равна сумме тикета в USD;
- для Q2 DAO-часть равна двойной сумме тикета в USD;
- перед выплатой суммы пересчитываются из USD-центов в lamports по Pyth SOL/USD;
- если в `inflow_vault_pda` не хватает средств на тикет, DAO-часть и награду вызвавшему, шаг отклоняется.
`change_ticket_recipient` запрещает менять получателя у тикета, который является следующим на выплату.
## Ключи и управление
На старте удобно считать, что у программы есть отдельный управляющий ключ `key_3`.
Целевая модель:
- `update_coef_limit` вызывает DAO;
- `grant_manager_limits` вызывает DAO;
- upgrade-authority программы после проверки передается DAO;
- `step_payout` остается открытым для любого подписанта, чтобы выплаты не зависели от одного оператора.
@@ -0,0 +1,136 @@
# `shine_users`
## Кратко
`shine_users` — вторая программа Solana-модуля SHiNE. Она отвечает за создание и обновление пользовательской PDA-записи, проверку подписи записи, проверку логина через `shine_login_guard` и оплату регистрации/дополнительного лимита.
Папка программы: `shine-solana/shine/programs/shine_users/`.
## Текущие функции
1. `init_users_economy_config`
- Создает PDA с экономическими настройками пользователей.
- Записывает стартовую регистрационную комиссию, цену шага лимита и стартовый бонус лимита.
2. `update_users_economy_config`
- Обновляет экономические настройки.
- Требует подпись `DAO_AUTHORITY` из общего deploy-конфига.
3. `create_user_pda`
- Проверяет логин через `shine_login_guard`.
- Проверяет структуру полей пользователя.
- Проверяет подпись записи root-ключом пользователя.
- Создает `user_pda` по seed `login=<normalized_login>`.
- Переводит оплату регистрации и дополнительного лимита в `shine_payments::inflow_vault_pda`.
4. `update_user_pda`
- Проверяет неизменяемые поля пользователя.
- Проверяет `prev_hash`, новую подпись и новое состояние последнего блока.
- При необходимости расширяет PDA.
- Переводит оплату дополнительного лимита в `shine_payments::inflow_vault_pda`.
## Аргументы инструкций
`init_users_economy_config` аргументов не принимает.
`update_users_economy_config`:
- `registration_fee_lamports: u64`
- `lamports_per_limit_step: u64`
- `start_bonus_limit: u64`
`create_user_pda`:
- `login: String`
- `root_key: Pubkey`
- `created_at_ms: u64`
- `additional_limit: u64`
- `fields: UserMutableFields`
- `signature: Vec<u8>`
`update_user_pda`:
- `login: String`
- `root_key: Pubkey`
- `created_at_ms: u64`
- `updated_at_ms: u64`
- `version: u32`
- `prev_hash: Vec<u8>`
- `additional_limit: u64`
- `fields: UserMutableFields`
- `signature: Vec<u8>`
`UserMutableFields`:
- `client_key: Pubkey`
- `blockchain_public_key: Pubkey`
- `blockchain_name: String`
- `used_bytes: u64`
- `last_block_number: u32`
- `last_block_hash: Vec<u8>` — ровно 32 байта
- `last_block_signature: Vec<u8>` — ровно 64 байта
- `arweave_tx_id: String`
- `is_server: bool`
- `server_key: Pubkey`
- `server_address: String`
- `sync_servers: Vec<String>`
- `access_servers: Vec<String>`
- `trusted_count: u8`
## Главные PDA
1. `user_pda`
- PDA записи пользователя.
- Seed: `login=<normalized_login>`.
- Создается отдельно для каждого логина.
- Стартовый размер: `768` байт.
- При обновлении может расширяться через `realloc`, но один auto-realloc ограничен `10_000` байт.
2. `users_economy_config_pda`
- PDA с настройками экономики.
- Seed: `shine_users_economy_config`.
- Хранит регистрационную комиссию, цену шага лимита и стартовый бонус.
- Размер PDA: `8 + 96` байт.
## Текущие параметры экономики
Параметры initial config из `programs/shine_users/src/settings.rs`:
| Поле | Значение | Смысл |
| --- | --- | --- |
| `START_REGISTRATION_FEE_LAMPORTS` | `10_000_000` | стартовая комиссия регистрации, 0.01 SOL |
| `LIMIT_STEP` | `10_000` | шаг `additional_limit` |
| `START_LAMPORTS_PER_LIMIT_STEP` | `100_000` | 0.0001 SOL за один шаг лимита |
| `START_BONUS_LIMIT` | `100_000` | стартовый бесплатный лимит при регистрации |
`additional_limit` в create/update должен быть кратен `LIMIT_STEP`.
## Связь с другими программами
`shine_users` зависит от:
- `shine_login_guard` — для проверки логина при создании пользователя;
- `shine_payments` — для вычисления и проверки `inflow_vault_pda`, куда уходят платежи.
`create_user_pda` делает CPI-вызов `shine_login_guard::classify_login` и принимает только результат `0`. Premium/trademark логины сейчас отклоняются ошибками `PremiumLogin` или `TrademarkLoginRequiresReview`.
Подпись `user_pda` и подпись состояния последнего блока проверяются через встроенную Solana Ed25519-инструкцию, которая должна идти раньше инструкции `shine_users` в той же транзакции.
## Деньги
Деньги из `shine_users` идут только в `inflow_vault_pda` программы `shine_payments`.
Потоки:
- `create_user_pda`: регистрационная комиссия + оплата `additional_limit`;
- `update_user_pda`: оплата `additional_limit`, если она больше нуля.
## Ключи и управление
На старте удобно считать, что у программы есть отдельный управляющий ключ `key_2`.
Целевая модель:
- economy-настройки меняет DAO-authority;
- upgrade-authority программы после проверки передается DAO;
- пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате.
@@ -0,0 +1,54 @@
flowchart LR
U[Пользователь / signer]
B[Покупатель тикета]
M[Менеджер]
C[Любой caller step_payout]
LG[1. shine_login_guard<br/>classify_login]
USERS[2. shine_users<br/>create_user_pda / update_user_pda]
PAY[3. shine_payments<br/>vault / tickets / payouts]
DAO[SHiNE DAO<br/>governance / authority / treasury]
USERPDA[(user_pda<br/>по login)]
ECON[(users_economy_config_pda)]
CONFIG[(config_pda)]
COEF[(coef_limit_pda)]
QUEUES[(queues_pda)]
VAULT[(inflow_vault_pda)]
TICKET[(ticket_pda)]
ALLOW[(manager_allowance_pda)]
U -->|логин| USERS
USERS -->|CPI проверка| LG
USERS -->|создает/обновляет| USERPDA
USERS -->|читает экономику| ECON
U -->|регистрация / лимит| VAULT
DAO -->|update economy| USERS
DAO -->|update coef/limit| PAY
DAO -->|grant manager limits| PAY
DAO -->|создает/отзывает ключи| DAO
PAY --> CONFIG
PAY --> COEF
PAY --> QUEUES
PAY --> VAULT
PAY --> TICKET
PAY --> ALLOW
B -->|buy_ticket*| PAY
B -->|оплата покупки тикета| DAO
PAY -->|создает тикет| TICKET
M -->|manager_add_ticket| PAY
ALLOW -->|лимиты Q1/Q2| M
C -->|step_payout| PAY
VAULT -->|выплата тикета| U
VAULT -->|DAO-часть| DAO
VAULT -->|call reward| C
DAO -. upgrade authority после передачи .-> USERS
DAO -. upgrade authority после передачи .-> PAY
DAO -. позже возможно .-> LG
Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

@@ -0,0 +1,139 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="900" viewBox="0 0 1400 900" role="img" aria-labelledby="title desc">
<title id="title">Архитектура Solana-программ SHiNE</title>
<desc id="desc">Схема трех программ, DAO, PDA-счетов и движения денег.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#2f3a45"/>
</marker>
<marker id="moneyArrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#0a7f62"/>
</marker>
<style>
.bg { fill: #f7f8fa; }
.title { font: 700 30px Arial, sans-serif; fill: #1f2933; }
.subtitle { font: 400 16px Arial, sans-serif; fill: #52606d; }
.box { fill: #ffffff; stroke: #9aa5b1; stroke-width: 2; rx: 8; }
.program { fill: #e8f1ff; stroke: #3465a4; }
.dao { fill: #fff3d6; stroke: #b7791f; }
.pda { fill: #edf7ed; stroke: #2f855a; }
.actor { fill: #f3e8ff; stroke: #805ad5; }
.txt { font: 700 17px Arial, sans-serif; fill: #1f2933; }
.small { font: 400 13px Arial, sans-serif; fill: #3e4c59; }
.line { stroke: #2f3a45; stroke-width: 2.2; fill: none; marker-end: url(#arrow); }
.money { stroke: #0a7f62; stroke-width: 3; fill: none; marker-end: url(#moneyArrow); }
.dashed { stroke-dasharray: 8 7; }
.legend { font: 400 14px Arial, sans-serif; fill: #3e4c59; }
</style>
</defs>
<rect class="bg" x="0" y="0" width="1400" height="900"/>
<text class="title" x="52" y="54">SHiNE Solana: программы, DAO, счета и движение денег</text>
<text class="subtitle" x="52" y="82">Текущая модель: три Anchor-программы, DAO/authority как управляющий слой, inflow vault и DAO treasury.</text>
<rect class="box actor" x="52" y="150" width="210" height="78"/>
<text class="txt" x="72" y="181">Пользователь</text>
<text class="small" x="72" y="206">signer, root_key, client_key</text>
<rect class="box actor" x="52" y="310" width="210" height="78"/>
<text class="txt" x="72" y="341">Покупатель тикета</text>
<text class="small" x="72" y="366">buy_ticket*</text>
<rect class="box actor" x="52" y="470" width="210" height="78"/>
<text class="txt" x="72" y="501">Менеджер</text>
<text class="small" x="72" y="526">manager_add_ticket</text>
<rect class="box actor" x="52" y="630" width="210" height="78"/>
<text class="txt" x="72" y="661">Любой caller</text>
<text class="small" x="72" y="686">step_payout</text>
<rect class="box program" x="360" y="126" width="270" height="96"/>
<text class="txt" x="382" y="160">1. shine_login_guard</text>
<text class="small" x="382" y="186">classify_login</text>
<text class="small" x="382" y="205">free / premium / trademark</text>
<rect class="box program" x="360" y="286" width="270" height="112"/>
<text class="txt" x="382" y="320">2. shine_users</text>
<text class="small" x="382" y="346">create_user_pda</text>
<text class="small" x="382" y="365">update_user_pda</text>
<text class="small" x="382" y="384">economy config</text>
<rect class="box program" x="360" y="518" width="270" height="122"/>
<text class="txt" x="382" y="552">3. shine_payments</text>
<text class="small" x="382" y="578">vault, tickets, queues</text>
<text class="small" x="382" y="597">grant_manager_limits</text>
<text class="small" x="382" y="616">step_payout</text>
<rect class="box dao" x="776" y="126" width="270" height="122"/>
<text class="txt" x="798" y="160">SHiNE DAO</text>
<text class="small" x="798" y="186">governance / authority</text>
<text class="small" x="798" y="205">treasury dao_wallet</text>
<text class="small" x="798" y="224">ключи через голосование</text>
<rect class="box pda" x="776" y="306" width="270" height="84"/>
<text class="txt" x="798" y="340">shine_users PDA</text>
<text class="small" x="798" y="365">user_pda, economy_config</text>
<rect class="box pda" x="776" y="500" width="270" height="150"/>
<text class="txt" x="798" y="534">shine_payments PDA</text>
<text class="small" x="798" y="560">config_pda, coef_limit_pda</text>
<text class="small" x="798" y="579">queues_pda</text>
<text class="small" x="798" y="598">inflow_vault_pda</text>
<text class="small" x="798" y="617">ticket_pda, manager_allowance</text>
<rect class="box pda" x="1134" y="500" width="214" height="88"/>
<text class="txt" x="1156" y="534">inflow_vault</text>
<text class="small" x="1156" y="560">деньги регистрации</text>
<rect class="box dao" x="1134" y="170" width="214" height="88"/>
<text class="txt" x="1156" y="204">DAO treasury</text>
<text class="small" x="1156" y="230">dao_wallet</text>
<path class="line" d="M262 189 C300 189, 318 334, 360 334"/>
<text class="small" x="270" y="286">регистрация / update</text>
<path class="line" d="M360 314 C322 250, 320 176, 360 174"/>
<text class="small" x="330" y="250">CPI login</text>
<path class="line" d="M630 342 L776 342"/>
<text class="small" x="646" y="329">создает/обновляет</text>
<path class="money" d="M262 205 C438 432, 1010 390, 1134 530"/>
<text class="small" x="430" y="430">регистрация и лимит -> inflow_vault</text>
<path class="money" d="M262 349 C540 260, 870 244, 1134 214"/>
<text class="small" x="538" y="270">покупка тикета -> DAO treasury</text>
<path class="line" d="M262 509 L360 579"/>
<text class="small" x="276" y="540">создать тикет</text>
<path class="line" d="M630 579 L776 575"/>
<text class="small" x="648" y="562">PDA состояния</text>
<path class="line" d="M1046 575 L1134 548"/>
<path class="money" d="M1134 560 C970 700, 580 728, 262 669"/>
<text class="small" x="650" y="720">call reward caller</text>
<path class="money" d="M1134 536 C860 754, 426 238, 262 194"/>
<text class="small" x="632" y="760">выплата получателю тикета</text>
<path class="money" d="M1241 500 L1241 258"/>
<text class="small" x="1254" y="380">DAO-часть выплат</text>
<path class="line" d="M776 188 L630 342"/>
<text class="small" x="642" y="250">update economy</text>
<path class="line" d="M776 216 C690 290, 666 516, 630 558"/>
<text class="small" x="654" y="438">settings / managers</text>
<path class="line dashed" d="M910 248 C850 702, 620 720, 520 640"/>
<text class="small" x="690" y="690">upgrade-authority: users/payments; login_guard позже</text>
<rect class="box" x="52" y="808" width="1296" height="54"/>
<line x1="74" y1="835" x2="132" y2="835" class="line"/>
<text class="legend" x="146" y="840">логические вызовы и управление</text>
<line x1="374" y1="835" x2="432" y2="835" class="money"/>
<text class="legend" x="446" y="840">движение SOL/lamports</text>
<line x1="682" y1="835" x2="740" y2="835" class="line dashed"/>
<text class="legend" x="754" y="840">будущая передача upgrade-authority DAO</text>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1,177 @@
# Аудит безопасности Solana-программ SHiNE — выпуск 2 (11.06.2026)
Повторный независимый аудит после исправления всех 4 находок первого отчёта
(`Solana-audit-by-Claude-File5-9июня2026.md`). Код перечитан целиком:
- `shine_login_guard` (183 строки) — stateless-классификатор логинов;
- `shine_users` (1069 строк) — реестр пользователей, PDA-записи, подписи, экономика лимитов;
- `shine_payments` (1381 строка) — очереди тикетов, выплаты из вольта, оракул Pyth.
Перебраны классы атак: подмена аккаунтов/PDA, авторизация и подписи, арифметика и
переполнения, валидация оракула, экономика, реентранси, griefing/DoS, **алиасинг
аккаунтов (передача одного аккаунта в несколько слотов инструкции)**.
## Статус прошлых находок (все закрыты)
- 🔴 Critical #1 (economy-config PDA в `shine_users`) — закрыто: `validate_users_economy_config_pda` проверяет и адрес, и `owner == program_id`, и вызывается перед чтением и в create, и в update.
- 🔴 Critical #2 (singleton-PDA в `shine_payments`) — закрыто: `validate_singleton_state_pda` проверяет точный адрес + `owner == id()` во всех инструкциях (`update_coef_limit`, `grant_manager_limits`, `buy_ticket*`, `manager_add_ticket`, `step_payout`, `change_ticket_recipient`).
- 🟠 Medium (валидация Pyth) — закрыто: пин адреса аккаунта `PYTH_SOL_USD_ACCOUNT`, проверка `owner == pyth_receiver`, разбор официальным `PriceUpdateV2`, `get_price_no_older_than` с проверкой `feed_id`, проверка возраста и доверительного интервала (`ORACLE_MAX_CONFIDENCE_PPM`).
- 🟡 Low (griefing на предсказуемых адресах) — закрыто: `create_pda_account` в обеих программах переведён на «создание поверх предзаполненного» (allocate + assign + добор ренты).
---
## 🔴 HIGH (НОВОЕ) — `shine_payments`: тикет с `recipient_wallet == inflow_vault` навсегда замораживает все выплаты — ✅ ИСПРАВЛЕНО (11.06.2026)
Закрыто: равенство `recipient == inflow_vault` запрещено во всех точках задания
получателя — `buy_ticket_by_purchase_usd` (через `config.inflow_vault`),
`process_manager_add_ticket` и `process_change_ticket_recipient` (через
`find_single_pda(INFLOW_VAULT_SEED)`). Дополнительно в `transfer_from_vault` добавлена
защита по умолчанию `require!(vault.key != recipient.key)`. Документация —
`doc/programs/shine_payments.md` §10.1. Историческое описание находки ниже.
### Где
`transfer_from_vault` (строки 1258–1268) переводит лампорты из вольта прямой
манипуляцией балансами (вольт — PDA без приватного ключа, обычный system-перевод
невозможен):
```rust
fn transfer_from_vault(vault: &AccountInfo, recipient: &AccountInfo, amount: u64) -> ProgramResult {
if amount == 0 { return Ok(()); }
let mut vault_lamports = vault.try_borrow_mut_lamports()?; // займ #1
let mut recipient_lamports = recipient.try_borrow_mut_lamports()?; // займ #2
...
}
```
В `step_payout` (строка 849) получатель — это `ticket.recipient_wallet`:
```rust
transfer_from_vault(inflow_vault_pda, ticket_recipient_wallet, ticket_lamports)?;
```
А `recipient_wallet` нигде не валидируется при создании тикета:
`buy_ticket*` (строки 696/711/725 → 1031), `manager_add_ticket` (строка 765),
`change_ticket_recipient` (строка 900) — берут его «как есть» из аргументов.
### Суть атаки (алиасинг аккаунта)
В Solana, если один и тот же аккаунт передан в инструкцию в нескольких слотах,
рантайм отдаёт для всех слотов **один и тот же** `RefCell` (механизм дублей).
Поэтому если `ticket.recipient_wallet` равен адресу `inflow_vault` PDA, то в
`step_payout` аккаунт вольта попадает и в слот `inflow_vault_pda`, и в слот
`ticket_recipient_wallet`. Тогда внутри `transfer_from_vault`:
- `vault.try_borrow_mut_lamports()` — берёт mutable-займ (успех);
- `recipient.try_borrow_mut_lamports()` — это **тот же** аккаунт → второй
mutable-займ → `Err(AccountBorrowFailed)``?` возвращает ошибку → инструкция
падает.
### Почему это «заморозка всего», а не один тикет
Выплаты идут строго по возрастанию индекса. `step_payout` всегда обслуживает
сначала очередь Q1 (если в ней есть pending), затем Q2, затем Q3, и в каждой —
ровно «следующий неоплаченный» тикет (`paid + 1`). Тикет с `recipient == vault`:
- не может быть оплачен (`step_payout` всегда падает на нём);
- не может быть пропущен (нет механизма «skip»);
- блокирует все тикеты после него в своей очереди;
- если он в Q1 — блокирует обслуживание Q2 и Q3 (до них очередь не доходит);
- лампорты вольта (накопленные регистрационные комиссии) перестают выплачиваться
и не уходят в DAO (слив в DAO происходит только когда `pending == 0` по всем
очередям, а это состояние недостижимо).
### Эксплуатация (тривиальная, перестановочная)
Q1 — публичная очередь (`buy_ticket` доступен любому). Атакующий покупает **один**
дешёвый тикет Q1, указав `recipient_wallet = <адрес inflow_vault PDA>`. Адрес вольта
детерминирован и публичен (`find_single_pda(INFLOW_VAULT_SEED)`). С этого момента вся
подсистема выплат и средства вольта заморожены за стоимость одного тикета + ренты.
Дополнительно: даже при защите на этапе покупки остаётся вектор через
`change_ticket_recipient` (строка 900) — владелец любого своего неоплаченного тикета
может выставить `new_recipient_wallet = vault` позже.
### Класс и серьёзность
Класс: «account aliasing / duplicate-account mutable borrow» + отсутствие
валидации адреса получателя. Прямой кражи средств нет, но это перманентный
отказ в обслуживании (availability) с блокировкой средств вольта, триггер —
копеечный и доступен анонимно. Оценка: **HIGH**.
### Рекомендуемый фикс
Запретить `recipient`, равный адресу вольта, во всех точках, где он задаётся, чтобы
тикет с таким получателем вообще не мог появиться:
1. в `buy_ticket_by_purchase_usd``require!(recipient_wallet != config.inflow_vault, …)`
(config уже прочитан);
2. в `process_manager_add_ticket` — сверять с `find_single_pda(INFLOW_VAULT_SEED).0`;
3. в `process_change_ticket_recipient` — то же для `new_recipient_wallet`.
Дополнительно (defense-in-depth) — в `transfer_from_vault` явно
`require!(vault.key != recipient.key, …)` с понятной ошибкой, чтобы любой будущий
вызов был защищён от алиасинга. Этого `require` недостаточно как единственной меры
(тикет всё равно застрял бы), поэтому основная защита — на входе.
---
## 🟡 LOW / INFO — наблюдения без прямой эксплуатации
### L1. `change_ticket_recipient` и `buy_ticket` не проверяют получателя на «опасные» адреса
Связано с HIGH выше; после фикса основной проблемы стоит заодно зафиксировать
правило «получатель не должен совпадать с системными PDA программы».
### L2. Гонка за логином (first-come) в `shine_users`
Адрес `user_pda` выводится из логина. После закрытия griefing-подсева остаётся
обычное состязание: увидев в мемпуле регистрацию `alice`, атакующий может
зарегистрировать `alice` со своим `root_key` первым. On-chain это решается только
commit-reveal; для текущей модели — приемлемый риск, отметить как известный.
### L3. `step_payout` без slippage-параметра
Выплата считается по текущей цене оракула без верхней границы лампортов. Цена
ограничена возрастом (120с) и доверительным интервалом (10%), аккаунт оракула
запинен — манипуляция маловероятна, но при резком движении цены SOL объём выплаты
в лампортах плавает. Риск низкий; при желании добавить верхнюю границу на шаг.
### L4. Экономическая устойчивость вольта (дизайн, не баг)
Деньги за покупку тикетов (`buy_ticket`) уходят на `dao_wallet`, а выплаты в
`step_payout` идут из `inflow_vault`, который наполняется **регистрационными
комиссиями** `shine_users`. Если поток регистраций меньше обязательств по выплатам,
вольт истощается и выплаты останавливаются (без потери средств, но с остановкой
сервиса). Это свойство экономической модели — стоит явно держать в уме и
мониторить баланс вольта/обязательств.
### L5. Заполнение Q1 до лимита как мягкий DoS
`buy_ticket` блокируется при `q1_sum_total >= limit_usd_cents`. Атакующий может
наполнить Q1 своими тикетами и приостановить покупки. Дорого (тратит SOL в DAO и
ренту) и его же тикеты потом оплачиваются из вольта, поэтому это скорее
экономический, а не дешёвый griefing. Риск низкий.
---
## ✅ Проверено и подтверждено как корректное
- **Подмена singleton-PDA** невозможна: везде сверяется точный адрес и владелец.
- **Авторизация**: `update_coef_limit`/`grant_manager_limits` требуют `signer == config.dao_wallet`; `manager_add_ticket``signer == allowance.manager_wallet`; `change_ticket_recipient``signer == ticket.recipient_wallet`; обновление economy-config — `signer == DAO_AUTHORITY`.
- **Ed25519 в `shine_users`**: строгие относительные индексы (−1/−2), `num_signatures == 1`, все три `ix_index == u16::MAX` (данные внутри самой ed25519-инструкции), сверка pubkey/signature/message по хэшу. Подмена и указание на чужую инструкцию исключены.
- **Цепочка версий записи** (`version == record_number+1`, `prev_hash == hash(old)`) — корректная защита от replay; сигнатура записи завязана на `root_key`, а не на плательщика.
- **Монотонность** `used_bytes`/`last_block_number` и `used_bytes <= paid_limit_bytes`.
- **Арифметика**: повсеместные `checked_*`, `overflow-checks = true`, расчёты оракула в `u128` с `u64::try_from` на сужении.
- **Оракул Pyth**: пин аккаунта + owner + feed_id + возраст + confidence через официальный SDK.
- **Рент-экземпт вольта** сохраняется: `available_vault_lamports` вычитает `minimum_balance`, а суммарная проверка `available >= needed` гарантирует, что после выплат вольт не опустится ниже ренты.
- **Двойная оплата тикета** исключена: `is_paid` + инкремент `*_tickets_paid`, следующий шаг адресует следующий индекс.
- **Реентранси отсутствует**: CPI только в System Program (transfer/allocate/assign) и в stateless `shine_login_guard` (с проверкой возвращённого `program_id`); обратных вызовов в наши программы нет.
- **create_pda_account (новый)**: устойчив к подсеву лампортов; атакующий не может ни выделить данные, ни сменить владельца PDA (нет ключа/seeds), поэтому ветка allocate+assign безопасна.
- **shine_login_guard**: stateless, без аккаунтов и средств; DFS-классификация ограничена (`MAX_WORDS_PER_LOGIN = 3`, длина ≤ 20) — без compute-DoS.
---
## Приоритет действий
1. **HIGH** — запретить `recipient == inflow_vault` в `buy_ticket*`, `manager_add_ticket`,
`change_ticket_recipient`; добавить `require!(vault.key != recipient.key)` в
`transfer_from_vault` как защиту по умолчанию. Закрыть до mainnet.
2. **LOW** — зафиксировать правило «получатель ≠ системные PDA» (L1), оценить
добавление верхней границы выплаты на шаг (L3).
3. **INFO** — формально задокументировать экономику вольта (L4) и known-issue
гонки за логином (L5/L2).
Изменений в код в рамках этого аудита не вносил — это анализ. Готов подготовить патч
по пункту 1, если подтвердите.
@@ -0,0 +1,134 @@
# Аудит безопасности Solana-программ SHiNE — выпуск 3 (12.06.2026)
Тематический аудит с фокусом на **полноту проверок входных аккаунтов**
(signer / owner / каноничный PDA-адрес / system-program / sysvar инструкций /
аккаунт оракула) — отвечает на вопрос «точно ли хватает всех проверок входных
аккаунтов». Код перечитан целиком после исправлений аудита №2
(`Solana-audit-2-by-Claude-11июня2026.md`):
- `shine_login_guard` (183 строки) — stateless-классификатор логинов, аккаунтами не пользуется;
- `shine_users` (1068 строк) — реестр пользователей, PDA-записи, ed25519-подписи, экономика лимитов;
- `shine_payments` (1398 строк) — очереди тикетов, выплаты из вольта, оракул Pyth.
Это ручная (не-Anchor `#[derive(Accounts)]`) реализация на `solana_program`, поэтому
каждая проверка аккаунта выполняется явно в коде handler-а. Перебраны: подмена
аккаунтов/PDA, подмена владельца, bump-seed атаки, отсутствие signer/authority,
подмена system-program и sysvar, подмена аккаунта оракула, неинициализированные/
повторно инициализируемые PDA, «лишние» аккаунты.
## Итоговый вердикт
**Проверок входных аккаунтов достаточно во всех трёх программах.** По каждому
handler присутствуют все требуемые классы проверок; грубых дыр (подмена PDA на
чужой аккаунт, отсутствие owner/signer-проверки, использование пользовательского
bump, подмена аккаунта оракула) не найдено. Все Critical/HIGH из аудитов №1 и №2
закрыты и в этом проходе подтверждены в коде. Новых эксплуатируемых пробелов в
валидации аккаунтов нет; есть несколько LOW/INFO-замечаний «by design».
## Статус прошлых находок (подтверждено в коде на 12.06.2026)
- 🔴 Critical #1 (economy-config PDA, `shine_users`) — закрыто: `validate_users_economy_config_pda` (адрес + `owner == program_id`) вызывается и в create, и в update перед чтением.
- 🔴 Critical #2 (singleton-PDA, `shine_payments`) — закрыто: `validate_singleton_state_pda` (адрес + `owner == id()`) во всех инструкциях.
- 🟠 Medium (валидация Pyth) — закрыто: пин адреса `PYTH_SOL_USD_ACCOUNT`, `owner == pyth_receiver`, `PriceUpdateV2`, `feed_id`, возраст, доверительный интервал.
- 🟡 Low (griefing на предсказуемых адресах) — закрыто: `create_pda_account` создаёт «поверх предзаполненного» в обеих программах.
- 🔴 HIGH аудита №2 (`recipient_wallet == inflow_vault` замораживает выплаты) — закрыто: запрет `recipient == inflow_vault` в `buy_ticket_by_purchase_usd` (стр. 1026), `process_manager_add_ticket` (стр. 747), `process_change_ticket_recipient` (стр. 878) + защита по умолчанию `require!(vault.key != recipient.key)` в `transfer_from_vault` (стр. 1278).
---
## Матрица проверок входных аккаунтов
### shine_users
| Инструкция | signer | owner PDA | адрес/seed PDA | system | sysvar / подпись | прочее |
|---|---|---|---|---|---|---|
| `init_users_economy_config` | ✓ | `owner == system` + `data_is_empty` (анти-reinit) | деривация + сверка | ✓ | — | значения из `settings`, не из ввода |
| `update_users_economy_config` | ✓ + `signer == DAO_AUTHORITY` | `owner == program_id` | деривация + сверка | — | — | `lamports_per_limit_step > 0` |
| `create_user_pda` | ✓ + `signer == client_key` | user_pda `owner == system` + empty; econ_config `owner == program_id` | user_pda, econ_config, inflow_vault, login_guard — все сверены | ✓ | ed25519 (record sig idx 2, last_block idx 1) | `inflow_vault` сверен с PDA `shine_payments`; login_guard сверен дважды |
| `update_user_pda` | ✓ + `signer == client_key` | user_pda `owner == program_id`; econ_config `owner == program_id` | деривация + сверка | ✓ | ed25519 + `version == old+1` + `prev_hash == hash(old)` | immutable-поля сверены с прежней записью |
### shine_payments
| Инструкция | signer | owner / валидация PDA | адрес PDA | system | прочее |
|---|---|---|---|---|---|
| `init` | ✓ payer | все 4 PDA `is_uninitialized` | деривация + сверка | ✓ | `dao_wallet` из `settings`, нет лишних аккаунтов |
| `update_coef_limit` | ✓ + `signer == config.dao_wallet` | config/coef `owner == id()` | деривация + сверка | — | границы coef/limit/reward; нет лишних аккаунтов |
| `grant_manager_limits` | ✓ + `signer == config.dao_wallet` | config `owner == id()`; allowance create/read | allowance из `manager_wallet` | ✓ | `state.manager_wallet == args.manager_wallet` |
| `buy_ticket` / `_usd` / `_sol` | ✓ | config/coef/queues `owner == id()` | ticket деривация + сверка + `is_uninitialized` | ✓ | oracle (key+owner+возраст+confidence), `dao_wallet == config.dao_wallet`, `recipient != inflow_vault`, slippage |
| `manager_add_ticket` | ✓ | allowance/queues `owner == id()` | allowance из `signer`; ticket деривация + сверка + uninit | ✓ | `allowance.manager_wallet == signer`, `queue_id ∈ {1,2,3}`, `recipient != inflow_vault` |
| `step_payout` | ✓ | все singleton-PDA `owner == id()` | ticket деривация + сверка | — | `dao_wallet == config.dao_wallet`, `inflow == config.inflow_vault`, ticket `queue/index/!is_paid/recipient`, oracle |
| `change_ticket_recipient` | ✓ + `signer == ticket.recipient_wallet` | queues + ticket `owner == id()` (через `read_state`) | ticket деривация из своих `queue_id/index` + сверка | — | `!is_paid`, запрет менять «следующий к выплате», `recipient != inflow_vault` |
### shine_login_guard
Аккаунты не используются (`_accounts`); программа stateless, средствами не владеет.
Защита со стороны вызова реализована в `shine_users`: сверяется и адрес вызываемой
программы (`login_guard_program.key == SHINE_LOGIN_GUARD_PROGRAM_ID`), и `program_id`
в `get_return_data`. Подмена/подделка ответа исключены. Отдельных проверок входных
аккаунтов внутри программы не требуется.
---
## 🟡 LOW / INFO — наблюдения без прямой эксплуатации
### L1. Permissionless `init` в обеих программах
`shine_payments::init` и `shine_users::init_users_economy_config` может вызвать кто
угодно первым. Практического эксплойта нет: все значения (включая `dao_wallet` и
`DAO_AUTHORITY`) берутся из констант `settings`, а не из ввода, повторная
инициализация заблокирована проверками `is_uninitialized` / `data_is_empty`. Риск
низкий; при желании привязать init к ожидаемому деплой-кошельку. Совпадает с моделью
«первый init = деплой».
### L2. В `shine_users` нет явной проверки «лишних аккаунтов» — ✅ ИСПРАВЛЕНО (12.06.2026)
`shine_payments` в каждом handler делает `require!(account_iter.next().is_none())`.
В `shine_users` такой проверки не было — лишние аккаунты в конце списка просто
игнорировались (читается строго нужное количество через `next_account_info`). Это
безвредно (на безопасность не влияло), но для симметрии и явности добавлено.
Класс: гигиена, не уязвимость.
Закрыто: во все 4 инструкции `shine_users` (`init_users_economy_config`,
`update_users_economy_config`, `create_user_pda`, `update_user_pda`) после чтения
фиксированного набора аккаунтов добавлено `require!(it.next().is_none(),
ShineUsersError::InvalidInstruction)`. Документация — `doc/programs/shine_users.md` §3.4.
### L3. Гонка за логином (first-come) в `shine_users` — known issue
Адрес `user_pda` детерминирован из логина; после закрытия griefing-подсева остаётся
обычное состязание за регистрацию (front-run в мемпуле). On-chain решается только
commit-reveal; для текущей модели — приемлемый риск, ранее зафиксирован в аудите №2
(L2). К проверкам аккаунтов не относится.
### L4. Экономическая устойчивость вольта (дизайн, не баг)
Деньги за покупку тикетов уходят на `dao_wallet`, а выплаты `step_payout` идут из
`inflow_vault`, наполняемого регистрационными комиссиями `shine_users` (коэффициент
по умолчанию `START_COEF_PPM = 5x`). При недостаточном притоке регистраций вольт
истощается и выплаты останавливаются (без потери средств). Это свойство
экономической модели «очередь/билеты», а не дефект валидации аккаунтов — отмечено
для полноты (ранее L4 в аудите №2). Мониторить баланс вольта vs обязательств.
---
## ✅ Проверено и подтверждено как корректное (по входным аккаунтам)
- **Подмена PDA** невозможна нигде: всюду пара «деривация `find_program_address` + сверка полного адреса». Пользовательский bump не принимается, `create_program_address` с внешним bump не используется — bump-seed атаки исключены.
- **Проверка владельца** при каждом чтении PDA: `read_state` и `validate_singleton_state_pda` (`shine_payments`) требуют `owner == id()`; `validate_users_economy_config_pda` и проверка `user_pda.owner == program_id` (`shine_users`) — перед десериализацией данных.
- **Создаваемые PDA**: проверка `is_uninitialized` / `owner == system && data_is_empty` исключает повторную инициализацию и перезапись чужого аккаунта.
- **signer / authority**: все handler начинают с обязательного `is_signer`; привилегированные операции дополнительно сверяют ключ с авторитетом (`config.dao_wallet`, `DAO_AUTHORITY`, `allowance.manager_wallet`, `ticket.recipient_wallet`, `client_key`).
- **system-program** сверяется с `system_program::ID` там, где идёт создание аккаунта/перевод; **sysvar инструкций** сверяется с `sysvar::instructions::id()` перед ed25519-интроспекцией.
- **Аккаунт оракула**: пин адреса `PYTH_SOL_USD_ACCOUNT` + `owner == pyth_receiver` + `feed_id` + возраст (120 с) + доверительный интервал (10%).
- **Ed25519 в `shine_users`**: относительные индексы −1/−2, `num_signatures == 1`, все три `ix_index == u16::MAX` (offset-данные внутри самой ed25519-инструкции), сверка `program_id == ed25519_program` и pubkey/signature/message по хэшу — указать на чужую инструкцию нельзя.
- **Алиасинг аккаунтов**: `recipient != inflow_vault` запрещён на входе во всех точках задания получателя + `vault.key != recipient.key` в `transfer_from_vault`.
- **`inflow_vault` в `shine_users`** сверяется с PDA, выведенным из `SHINE_PAYMENTS_PROGRAM_ID` и `SHINE_PAYMENTS_INFLOW_VAULT_SEED` — комиссия не может уйти на чужой адрес.
- **Реентранси** отсутствует: CPI только в System Program и в stateless `shine_login_guard` (с проверкой возвращённого `program_id`); обратных вызовов в наши программы нет.
---
## Приоритет действий
1. **LOW** — ✅ выполнено 12.06.2026: добавлено `require!(it.next().is_none(), …)` во
все инструкции `shine_users` для симметрии с `shine_payments` (L2).
2. **INFO** — зафиксировать в эксплуатационной документации known-issue гонки за
логином (L3) и экономику вольта (L4); рассмотреть привязку `init` к ожидаемому
деплой-кошельку (L1).
Критичных и высоких находок по полноте проверок входных аккаунтов в этом проходе
нет. Единственная LOW-правка (L2) применена в рамках этого же изменения; код
`shine_users` собирается успешно (`cargo build -p shine_users`).
@@ -0,0 +1,114 @@
Аудит безопасности Solana-программ SHiNE
Проверены три программы в shine-solana/shine/programs/:
- shine_login_guard (183 строки) — stateless-классификатор логинов
- shine_users (1035 строк) — реестр пользователей, PDA-записи, подписи, экономика лимитов
- shine_payments (1330 строк) — очереди тикетов, выплаты, оракул Pyth
Общая инженерная культура высокая: везде checked_*-арифметика, overflow-checks = true, ручная верификация ed25519 через sysvar инструкций, аккуратный bounds-checked парсинг. Но есть две критические дыры одного класса — отсутствие проверки адреса PDA, которые позволяют обойти всю экономику и украсть средства из вольта.
---
🔴 CRITICAL #1 — shine_users: economy-config PDA не валидируется → бесплатная регистрация и бесконечный лимит
В process_create_user_pda (строка 448) и process_update_user_pda (строка 525):
let economy = read_users_economy_config(users_economy_config_pda)?;
А read_users_economy_config (строки 633–643) не проверяет ни адрес, ни владельца аккаунта — просто читает байты:
fn read_users_economy_config(pda: &AccountInfo) -> Result<...> {
let raw = read_pda_all(pda)?; // try_borrow_data, без проверок
require!(raw.len() >= 25, ...);
Ok(UsersEconomyConfigState { version: raw[0], registration_fee_lamports: ..., ... })
}
Сравните: в init/update_economy_config адрес проверяется через find_users_economy_config_pda (строки 382, 414), а в create/update — нет.
Эксплуатация. Атакующий создаёт любой свой аккаунт с 25 байтами произвольного содержимого и передаёт его как users_economy_config_pda:
- registration_fee_lamports = 0 → регистрация без оплаты;
- start_bonus_limit = u64::MAX → запись пользователя сразу получает гигантский paid_limit_bytes (бесплатная безлимитная квота хранилища/блокчейна);
- lamports_per_limit_step = 0 → бесплатное пополнение лимита на любую величину.
Комиссия (когда она ненулевая) уходит в правильный вольт — validate_inflow_vault это проверяет — но атакующему достаточно обнулить комиссию и накрутить лимит. Это полный обход экономической модели программы.
Фикс: в обеих функциях перед чтением добавить
require_keys_eq!(find_users_economy_config_pda(program_id).0, *users_economy_config_pda.key, ShineUsersError::InvalidPdaAddress);
require!(users_economy_config_pda.owner == program_id, ShineUsersError::InvalidPdaAddress);
---
🔴 CRITICAL #2 — shine_payments: singleton-PDA не привязаны к адресу → кража из вольта в step_payout
ensure_expected_pdas вызывается только в process_init (строка 519). Во всех остальных инструкциях config_pda, coef_limit_pda, queues_pda читаются через read_state, который проверяет только владельца (*pda.owner == id()), но не адрес:
fn read_state<T>(pda) -> ... {
require!(!is_uninitialized_account(pda), ...);
require_keys_eq!(*pda.owner, id(), ...); // только owner, адрес НЕ проверяется
...
}
Программа владеет аккаунтами нескольких типов (config, coef_limit, queues, vault, tickets, manager_allowances), и часть их содержимого атакующий контролирует напрямую. В TicketState поле recipient_wallet (32 байта по смещению 11) — полностью произвольные байты из BuyTicketArgs, это не обязан быть валидный ключ.
Эксплуатация (конкретная, практически реализуемая). В process_step_payout (строки 783846) coef_limit_pda не проверяется по адресу. Раскладка CoefLimitState при декодировании: version=byte0, coef_ppm=[1..9], limit=[9..17], call_reward_lamports=[17..25]. Байты [17..25] тикета попадают на recipient_wallet[6..14] — их атакующий задаёт сам при покупке тикета. То есть:
1. Атакующий покупает тикет с recipient_wallet, чьи байты 6..14 кодируют огромный call_reward_lamports.
2. В step_payout подставляет этот тикет как coef_limit_pda.
3. transfer_from_vault(inflow_vault_pda, signer, coef_limit.call_reward_lamports) (строка 840) переводит подписанту (атакующему) почти весь доступный баланс вольта, который должен был накопиться для DAO.
version тикета = 1, decode значение версии не проверяет, поэтому подстановка проходит.
Подстановка config_pda для обхода DAO-авторизации в update_coef_limit/grant_manager_limits теоретически тоже возможна, но непрактична: там нужный dao_wallet пересекается со структурными полями тикета, и подбор потребовал бы грайндинга ~80 бит ключа. А вот путь через coef_limit/step_payout — реальная кража.
Фикс: во всех инструкциях, принимающих singleton-PDA, проверять адрес, например вызывать ensure_expected_pdas-подобную проверку (require_keys_eq!(find_single_pda(program_id, SEED).0, *pda.key, …)) для config/coef_limit/queues/inflow_vault, а для queues_pda в change_ticket_recipient — тоже.
---
🟠 MEDIUM — shine_payments: слабая валидация оракула Pyth
read_sol_usd_price / parse_pyth_price_update_v2 (строки 10381075):
1. Не проверяется владелец аккаунта цены (что он принадлежит Pyth receiver). Спасает только то, что адрес жёстко закреплён константой PYTH_SOL_USD_ACCOUNT. Это делает подмену невозможной, но защита держится на одном инварианте.
2. feed_id не проверяется. Константа PYTH_SOL_USD_FEED_ID объявлена в settings.rs, но в коде нигде не используется — программа доверяет, что в закреплённом аккаунте лежит именно SOL/USD.
3. Фиксированные смещения (73/89/93) предполагают VerificationLevel::Full. Borsh сериализует enum переменной длиной: Partial{num_signatures} занимает 2 байта вместо 1, что сдвигает все поля на 1 байт и приведёт к чтению мусорной цены. Уровень верификации не проверяется.
4. Confidence (conf) игнорируется — нет защиты от широкого ценового интервала.
Проверка возраста цены (ORACLE_MAX_AGE_SECS = 120) есть и сделана корректно. Рекомендация: проверять владельца аккаунта, сверять feed_id с константой и валидировать verification_level == Full (или парсить через официальный pyth_solana_receiver_sdk, который уже завендорен в .vendor/).
---
🟡 LOW — DoS через предсказуемые адреса тикетов — ✅ ИСПРАВЛЕНО (11.06.2026)
Закрыто: `create_pda_account` в `shine_payments` и `shine_users` переведён на паттерн
«создание поверх предзаполненного» (allocate + assign + добор ренты вместо строгого
`system_instruction::create_account`). «Подсев» лампортов на заранее известный адрес
тикета или пользовательской записи больше не блокирует создание PDA. Проверка
`is_uninitialized_account` в payments перестала зависеть от нулевого баланса. Тот же фикс
закрывает аналогичный сквоттинг логинов в `shine_users` (адрес выводится из логина).
Подробности — в `doc/programs/shine_payments.md` §3.4 и `doc/programs/shine_users.md` §3.3.
Историческое описание находки ниже.
is_uninitialized_account (строка 1195) считает аккаунт неинициализированным только если lamports() == 0. Адреса тикетов детерминированы (queue_seed + index), а индекс последователен и предсказуем. Любой может заранее перевести немного лампортов на адрес следующего тикета — тогда create_pda_account упадёт (PdaAlreadyExists / ошибка create_account), заблокировав покупку/добавление тикета. Это griefing-DoS, не кража. Митигировать можно паттерном «create поверх предзаполненного» (allocate + assign + добор ренты) вместо system_instruction::create_account.
---
✅ Что проверено и сделано корректно
- Верификация подписей ed25519 в shine_users (строки 885–922): строго пинятся относительные индексы инструкций (−1/−2), требуется num_signatures == 1, все три ix-index == u16::MAX (данные внутри самой ed25519-инструкции — нельзя указать на чужую инструкцию), и сверяются pubkey/signature/message. Сделано грамотно.
- Цепочка версий записи (version == record_number+1, prev_hash == hash(old)) — корректная защита от replay (строки 535536).
- Авторизация обновления записи завязана на ed25519-подпись root_key, а не на подписанта-плательщика — случайный аккаунт обновить чужую запись не может.
- Монотонность used_bytes/last_block_number и used_bytes <= paid_limit_bytes (строки 979986).
- inflow_vault валидируется по derive из программы payments (строки 988993).
- transfer_from_vault сохраняет рент-экземпт (вычитает minimum_balance через available_vault_lamports).
- init обеих программ безопасен к front-run: значения берутся из констант, а не от вызывающего; повторная инициализация заблокирована проверкой «uninitialized».
- Арифметика: overflow-checks = true в release-профиле + повсеместные checked_add/sub/mul. Парсинг везде с проверкой границ.
- manager_allowance PDA — единственный из payments, чей адрес проверяется корректно во всех путях (строки 645646, 739740).
- shine_login_guard — stateless, без аккаунтов и средств; рисков безопасности не несёт.
---
Приоритет действий
1. Critical #1 — добавить проверку адреса+владельца economy-config в create/update shine_users. Тривиальный фикс, помощник find_users_economy_config_pda уже есть.
2. Critical #2 — добавить проверку адресов всех singleton-PDA во все инструкции shine_payments (минимум coef_limit_pda/config_pda/queues_pda в step_payout и change_ticket_recipient).
3. Medium — ужесточить парсинг Pyth (owner + feed_id + verification_level), либо перейти на завендоренный SDK.
4. Low — учесть griefing-DoS на предсказуемых адресах тикетов.
Обе критические находки относятся к одному классу (Solana «missing ownership/address check» — самая частая категория эксплойтов), их стоит закрыть до любого деплоя в mainnet. Изменений в код я не вносил — это только анализ; готов подготовить патчи на оба критических пункта, если подтвердите.
+109
View File
@@ -0,0 +1,109 @@
# Деплой SHiNE (шаблон)
Этот раздел хранит актуальные инструкции по деплою.
## Базовый сервер
- SSH: `player@shineup.me`
- Домен: `shineup.me`
- Базовый путь: `/home/player`
Для всех рабочих инструкций и скриптов использовать доменное имя `shineup.me`, а не фиксированный IP:
- актуальный IP должен браться через DNS-резолв на момент подключения;
- ручное дублирование IP в документации и deploy-скриптах не поддерживать.
## Контуры деплоя
- Production:
- SSH: `player@shineup.me`
- Домен: `shineup.me`
- IP: `185.229.109.118`
- Main test:
- SSH: `player@193.8.215.70`
- Домен: `t.shineup.me`
- IP: `193.8.215.70`
- Reserve test:
- SSH: `player@93.170.12.154`
- Домен: `test.shineup.me`
- IP: `93.170.12.154`
## Локальные команды
- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`
- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`
- Production server deploy: `./gradlew deployServerProduction`
- Production UI deploy: `./gradlew deployUIProduction`
- Reserve test server deploy: `./gradlew deployServerTest`
- Reserve test UI deploy: `./gradlew deployUITest`
- Локальный запуск: `./gradlew startLocal`
## Отдельные тестовые VPS
- Отдельный стенд с 4 devnet-серверами на одном VPS описан в:
- `Dev_Docs/deploy/test-server/README.md`
## Политика подтверждений
- `shineup.me` — production.
- Любые изменения на `shineup.me`, включая deploy сервера, deploy UI, конфиги, перезапуски и миграции, делать только после отдельного явного подтверждения пользователя.
- Если пользователь пишет просто `задеплой` без уточнения, по умолчанию это означает deploy на `t.shineup.me`.
## Main test deploy (`t.shineup.me`)
- Это основной сервер для тестов.
- `deployServer` и `deployUI` по умолчанию направлены именно сюда.
- Серверный deploy не запускает JUnit/IT-тесты на удалённом сервере.
- `deployServer` / `deployServerTest2` делают:
- сборку fat-jar локально;
- синхронизацию `data/` и `shine.sqlite` с production `shineup.me`;
- перенос `application.properties` с production с поправкой `server.ui.indexPath` на `/home/player/SHiNE/shine-ui/index.html`;
- установку `systemd` unit на `193.8.215.70`;
- перезапуск `shine-server.service`;
- установку/проверку Caddy для `t.shineup.me`.
- `deployUI` / `deployUITest2` публикуют UI в `/home/player/SHiNE/shine-ui` на `193.8.215.70`.
## Reserve test deploy (`test.shineup.me`)
- `test.shineup.me` считается резервным тестовым сервером.
- Его настройки и адрес не менять без отдельной задачи.
- Задачи `deployServerTest` и `deployUITest` считаются резервными и требуют отдельной причины.
## UI-деплой и Caddy (обязательно)
- Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`.
- `Caddyfile` на сервере должен смотреть в ту же директорию через `root * /home/player/SHiNE/shine-ui`.
- В `deploy_shine-PWA.sh` добавлена проверка: скрипт ищет блок `shineup.me { ... }` (или значение `EXPECTED_CADDY_SITE`) и проверяет `root` внутри этого блока.
- Если `root` внутри целевого блока не совпадает, деплой прерывается с ошибкой.
- Для ручного обхода проверки (только осознанно): `ALLOW_CADDY_MISMATCH=1 ./gradlew deployUI`.
- При необходимости можно явно переопределить путь деплоя:
- `REMOTE_UI_DIR=/нужный/путь ./gradlew deployUI`
- `EXPECTED_CADDY_UI_ROOT=/нужный/путь ./gradlew deployUI`
- `EXPECTED_CADDY_SITE=example.com ./gradlew deployUI`
## Временные тестовые сайты Solana tickets
- Для HTML UI программы `shine_payments` используется отдельный временный тестовый сайт.
- Основной каталог публикации:
- `/home/player/sites/test-solana-tickets.shineup.me`
- Рабочие домены:
- `https://test-solana-tickets.shineup.me`
- `https://test-solana-tickets.shiningpeople.ru`
- Назначение:
- ручная проверка сценариев покупки билетов;
- проверка DAO-инструментов и лимитов менеджеров;
- проверка ручного добавления билетов и `step_payout`.
- Эти сайты не считать основным UI SHiNE; это отдельная тестовая публикация под Solana-часть.
### Важно для локального UI (history-router / Ctrl+F5)
- Локальный UI **обязательно** поднимать только через `./gradlew startLocal`.
- Эта задача запускает `scripts/local_spa_server.py`, который делает SPA fallback: любой неизвестный путь (`/m/...`, `/channel/...`) возвращает `index.html`.
- Это обязательно для корректной работы `Ctrl+F5` на внутренних роутов без `404`.
- Рабочий URL выводится задачей в консоль в формате: `http://localhost:<WEB_PORT>/?localWsPort=<WS_PORT>`.
## Обязательные правила
1. Перед серверным деплоем проверить локально.
2. При нестандартном деплое (другой хост, другая структура, ручные шаги) обязательно уточнить у пользователя, нужно ли обновить этот шаблон.
3. Если деплой-процесс изменился, этот файл и файлы в `servers/` обновлять в том же коммите.
@@ -0,0 +1,36 @@
# Локальный деплой SHiNE-agent-bot-coder (systemd, пользователь ai)
## Где находится сервис
- Папка сервиса: `SHiNE-agent-bot-coder/`
- Systemd unit: `SHiNE-agent-bot-coder/scripts/systemd/shine-agent-bot-coder.service`
- Скрипт установки: `SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh`
## Предусловия
1. Заполнен `.env` на основе `.env.example`.
2. Доступен рабочий Codex CLI:
- `/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl`
3. На машине установлен `systemd --user`.
## Установка
Из корня репозитория:
```bash
bash SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh
```
Скрипт:
1. проверяет наличие `python3`;
2. копирует unit в `~/.config/systemd/user/`;
3. делает `systemctl --user daemon-reload`;
4. включает автозапуск и стартует сервис.
## Проверка
```bash
systemctl --user status shine-agent-bot-coder --no-pager
journalctl --user -u shine-agent-bot-coder -f
```
## Перезапуск после изменений
```bash
systemctl --user restart shine-agent-bot-coder
```
@@ -0,0 +1,43 @@
# Сервер `193.8.215.70` — основной test (`t.shineup.me`)
- Пользователь: `player`
- Домен: `t.shineup.me`
- Логин сервера: `tshineupme`
- Каталог SHiNE: `/home/player/SHiNE`
- UI публикация для Caddy: `/home/player/SHiNE/shine-ui`
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
- Данные: `/home/player/SHiNE/shine-server/data/`
- `shine.sqlite`
- `*.bch`
- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
## Сервисы
- `shine-server.service` (systemd)
- `caddy.service` (systemd)
## Статус
- Это основной сервер для тестов SHiNE.
- Default deploy по умолчанию должен идти сюда.
- Источник данных для тестовой БД: production `shineup.me`.
## Caddy
- Конфиг: `/etc/caddy/Caddyfile`
- Сайты:
- `t.shineup.me`
- `agent.shiningpeople.ru`
- Для `t.shineup.me`:
- `root * /home/player/SHiNE/shine-ui`
- `try_files {path} /index.html`
- `reverse_proxy /ws* -> 127.0.0.1:7070`
## Deploy
- Default server deploy:
- `./gradlew deployServer`
- `./gradlew deployServerTest2`
- Default UI deploy:
- `./gradlew deployUI`
- `./gradlew deployUITest2`
@@ -0,0 +1,40 @@
# Сервер `93.170.12.154` — резервный test (`test.shineup.me`)
- Пользователь: `player`
- Каталог SHiNE: `/home/player/SHiNE`
- Домен: `test.shineup.me`
- Роль: резервный тестовый сервер
- UI публикация для Caddy: `/home/player/SHiNE/shine-ui`
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
- Данные: `/home/player/SHiNE/shine-server/data/`
- `shine.sqlite`
- `*.bch`
- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
## Сервисы
- `shine-server.service` (systemd)
- `caddy.service` (systemd)
## Статус
- Резервный тестовый сервер для SHiNE.
- Источник данных для тестовой БД: production `shineup.me`.
- Пока не использовать для обычного deploy.
- Основной прод-сервер: `shineup.me` (`185.229.109.118`).
## Caddy
- Конфиг: `/etc/caddy/Caddyfile`
- Настройки:
- `no-store/no-cache` заголовки;
- `try_files {path} /index.html` (SPA fallback);
- `reverse_proxy /ws* -> 127.0.0.1:7070`;
- целевой сайт: `test.shineup.me`.
## Deploy
- Резервные задачи:
- `./gradlew deployServerTest`
- `./gradlew deployUITest`
- Эти задачи пока не использовать без отдельной причины.
+47
View File
@@ -0,0 +1,47 @@
# Сервер `shineup.me` — основной
- SSH: `player@shineup.me`
- Определение IP: через DNS-резолв домена `shineup.me` на момент подключения
- Пользователь: `player`
- Базовый путь: `/home/player`
- Каталог SHiNE: `/home/player/SHiNE`
- UI публикация: `/home/player/SHiNE/shine-ui`
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
- Данные: `/home/player/SHiNE/shine-server/data/`
- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
## Сервисы
- `shine-server.service` (systemd)
- `caddy.service` (systemd)
## Caddy
- Активный конфиг (через systemd `ExecStart`): `/home/player/SHiNE/caddy/Caddyfile`
- Для UI:
- `root * /home/player/SHiNE/shine-ui`
- `try_files {path} /index.html` (SPA fallback)
- no-cache заголовки
- `reverse_proxy /ws* -> 127.0.0.1:7070`
## Дополнительно
- Для отдельной админки `shine_payments` используется каталог:
- `/home/player/sites/test-solana-tickets.shineup.me`
- Эта публикация используется как временный тестовый сайт для сценариев покупки билетов и выплат `shine_payments`.
- Домены этой публикации:
- `https://test-solana-tickets.shineup.me`
- `https://test-solana-tickets.shiningpeople.ru`
- Для всех deploy-скриптов и инструкций использовать именно `player@shineup.me`, без жёсткой фиксации IP.
## Правило изменений
- `shineup.me` — production.
- Любые изменения на этом сервере делать только после отдельного явного подтверждения пользователя.
## Deploy
- Production deploy-задачи:
- `./gradlew deployServerProduction`
- `./gradlew deployUIProduction`
- Default deploy-задачи `./gradlew deployServer` и `./gradlew deployUI` сюда больше не относятся.
@@ -0,0 +1,173 @@
# VPS `178.208.90.249` (`player`) — 4 тестовых SHiNE-сервера на devnet
## Назначение
Этот VPS используется как отдельный тестовый стенд для одновременного запуска 4 SHiNE-серверов:
- `server_t1``https://t1.shineup.me`
- `server_t2``https://t2.shineup.me`
- `server_t3``https://t3.shineup.me`
- `server_t4``https://t4.shineup.me`
Все 4 инстанса работают через Solana `devnet`.
## Каталоги
Структура в домашней папке пользователя `player`:
- `/home/player/t1/server`
- `/home/player/t1/UI`
- `/home/player/t2/server`
- `/home/player/t2/UI`
- `/home/player/t3/server`
- `/home/player/t3/UI`
- `/home/player/t4/server`
- `/home/player/t4/UI`
Дополнительно:
- подробная памятка на самом сервере: `/home/player/Agents.md`
## Порты и systemd
Соответствие инстансов:
- `t1` → localhost `7101``shine-t1.service`
- `t2` → localhost `7102``shine-t2.service`
- `t3` → localhost `7103``shine-t3.service`
- `t4` → localhost `7104``shine-t4.service`
Каждый unit запускает один и тот же jar:
- `/home/player/tX/server/shine-server.jar`
Рабочая директория каждого unit:
- `/home/player/tX/server`
Это важно, потому что сервер читает внешний `application.properties` именно из текущей рабочей директории.
## Конфиги сервера
У каждого инстанса свой файл:
- `/home/player/tX/server/application.properties`
Ключевые параметры в нём:
- `server.port=710X`
- `server.SHiNE.login=server_tX`
- `db.path=data/shine.sqlite`
- `solana.cluster=devnet`
- `solana.rpcUrl=https://api.devnet.solana.com`
- `server.ui.indexPath=/home/player/tX/UI/index.html`
- `server.info.url=https://tX.shineup.me`
- `server.info.origin=devnet`
Важно:
- Program ID SHiNE одинаковые для mainnet и devnet.
- Поэтому для перевода инстанса на devnet достаточно правильных `solana.cluster` и `solana.rpcUrl`.
## Конфиги UI
У каждого домена лежит отдельная копия `shine-UI`.
В каждой копии меняется `js/deploy-config.js`:
- `defaultServerLogin=server_tX`
- `defaultServerAddress=tX.shineup.me`
- `defaultSolanaCluster=devnet`
- `defaultSolanaEndpoint=https://api.devnet.solana.com`
За счёт этого:
- UI по умолчанию открывает именно свой сервер
- `server-ui` формы по умолчанию используют именно `devnet`
## Caddy
Конфиг:
- `/etc/caddy/Caddyfile`
Логика по каждому сайту одинаковая:
- статические файлы берутся из `/home/player/tX/UI`
- путь `/ws` проксируется на `127.0.0.1:710X`
Права доступа:
- каталог `/home/player` и `/home/player/tX` должен быть доступен на `+x` для чтения через Caddy
- все каталоги и файлы в `/home/player/tX/UI` должны быть читаемы пользователем `caddy`
## Что уже проверено
После настройки было подтверждено:
- `https://t1.shineup.me` отвечает `HTTP 200`
- `https://t2.shineup.me` отвечает `HTTP 200`
- `https://t3.shineup.me` отвечает `HTTP 200`
- `https://t4.shineup.me` отвечает `HTTP 200`
- `Caddy` успешно выпустил сертификаты Let's Encrypt на все 4 домена
- все 4 процесса Java слушают свои порты `7101..7104`
- в логах есть строка `WS сервер запущен на ws://localhost:710X/ws`
## Важный operational-нюанс
Официальный `https://api.devnet.solana.com` может отдавать `HTTP 429`, если несколько инстансов одновременно делают bootstrap PDA/sync-серверов на старте.
На практике это уже проявилось:
- `t3` и `t4` подтянули `sync_servers` сразу
- `t1` и `t2` на первом одновременном старте словили `429`
- после последовательного рестарта `shine-t1` и `shine-t2` они тоже успешно сохранили `sync_servers`
Если после очередного деплоя какой-то инстанс не подтянул `sync_servers`, сначала делать не массовый рестарт, а по одному:
```bash
sudo systemctl restart shine-t1
sleep 8
sudo systemctl restart shine-t2
sleep 8
sudo systemctl restart shine-t3
sleep 8
sudo systemctl restart shine-t4
```
## Как обновлять сервер
1. Локально собрать jar:
```bash
./gradlew shadowJar
```
2. Залить `SHiNE-server/build/libs/shine-server.jar` в каждый нужный каталог:
- `/home/player/t1/server/shine-server.jar`
- `/home/player/t2/server/shine-server.jar`
- `/home/player/t3/server/shine-server.jar`
- `/home/player/t4/server/shine-server.jar`
3. Если менялся runtime-конфиг, обновить соответствующий `application.properties`.
4. Перезапускать сервисы лучше по одному, с паузой, чтобы снизить шанс `429` от devnet RPC.
## Как обновлять UI
1. Взять свежую локальную папку `shine-UI/`.
2. Для каждой копии подставить свой `deploy-config.js`.
3. Скопировать файлы в `/home/player/tX/UI/`.
4. Проверить права чтения для Caddy.
## Полезные команды на VPS
Проверка сервисов:
```bash
sudo systemctl --no-pager --full status caddy shine-t1 shine-t2 shine-t3 shine-t4
```
Перезапуск одного инстанса:
```bash
sudo systemctl restart shine-t1
```
Логи одного инстанса:
```bash
tail -n 200 /home/player/t1/server/logs/app.log
sudo journalctl -u shine-t1 -n 200 --no-pager
```
Проверка Caddy:
```bash
sudo caddy validate --config /etc/caddy/Caddyfile
curl -I https://t1.shineup.me
```
+12
View File
@@ -0,0 +1,12 @@
# Тестовый VPS с 4 SHiNE-серверами
В этой папке описан отдельный тестовый VPS `178.208.90.249`, на котором подняты 4 независимых SHiNE-инстанса под доменами `t1.shineup.me` ... `t4.shineup.me`.
Текущая основная схема:
- пользователь на VPS: `player`
- все 4 инстанса работают через `devnet`
- каждый инстанс имеет свой каталог `server`, свой каталог `UI`, свой `systemd` unit и свой localhost-порт
- внешний HTTPS и маршрутизацию `/ws` обслуживает один общий `Caddy`
Детальное описание:
- [178.208.90.249_quad_devnet.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Dev_Docs/deploy/test-server/178.208.90.249_quad_devnet.md)
+75
View File
@@ -0,0 +1,75 @@
# Установка TURN сервера (coturn)
## 1. Что нужно
- Сервер с публичным IP (в примере: `37.214.58.208`).
- Доступ `root` по SSH.
- Открытые порты в firewall:
- `3478/tcp`
- `3478/udp`
- диапазон relay-портов, например `49160-49200/udp`
## 2. Быстрая установка (рекомендуется)
В проекте уже есть готовый скрипт:
```bash
sudo bash scripts/setup_turn_coturn.sh \
--secret "CHANGE_ME_LONG_RANDOM_SECRET" \
--realm "shineup.me"
```
Что делает скрипт:
- ставит `coturn`;
- включает режим `use-auth-secret` (временные логин/пароль);
- пишет `/etc/turnserver.conf`;
- включает и перезапускает сервис `coturn`.
## 3. Проверка
Проверить статус:
```bash
sudo systemctl status coturn --no-pager
```
Проверить, что порт слушается:
```bash
sudo ss -lntup | grep 3478
```
## 4. Ручная установка (если без скрипта)
```bash
sudo apt-get update
sudo apt-get install -y coturn
```
Пример `/etc/turnserver.conf`:
```conf
listening-port=3478
fingerprint
lt-cred-mech
use-auth-secret
static-auth-secret=CHANGE_ME_LONG_RANDOM_SECRET
realm=shineup.me
external-ip=37.214.58.208
listening-ip=0.0.0.0
relay-ip=37.214.58.208
min-port=49160
max-port=49200
no-cli
simple-log
```
В `/etc/default/coturn`:
```conf
TURNSERVER_ENABLED=1
```
Запуск:
```bash
sudo systemctl enable coturn
sudo systemctl restart coturn
```
@@ -0,0 +1,48 @@
# Подключение TURN к SHiNE-серверу
Начиная с текущей версии, клиент звонков запрашивает ICE-конфиг у backend через WS-операцию `GetCallIceConfig` и использует её для `RTCPeerConnection`.
## 1. Настройки backend
Файл: `src/main/resources/application.properties`
Ключи:
```properties
call.ice.stun.urls=stun:stun.l.google.com:19302
call.ice.turn.urls=turn:37.214.58.208:3478?transport=udp,turn:37.214.58.208:3478?transport=tcp
call.ice.turn.ttlSec=600
call.ice.turn.userPrefix=shine
call.ice.turn.sharedSecret=CHANGE_ME_LONG_RANDOM_SECRET
# fallback (если не используете shared-secret)
call.ice.turn.username=
call.ice.turn.password=
```
## 2. Рекомендуемый режим (временные credentials)
- На coturn и на SHiNE-сервере должен быть **одинаковый** secret:
- coturn: `static-auth-secret=...`
- SHiNE: `call.ice.turn.sharedSecret=...`
- Тогда SHiNE выдаёт короткоживущие `turnUsername/turnPassword` (TTL).
## 3. Fallback режим (статический логин/пароль)
Если временные credentials не используются:
```properties
call.ice.turn.sharedSecret=
call.ice.turn.username=turn_user
call.ice.turn.password=turn_password
```
## 4. Деплой после изменения
```bash
./gradlew deployServerNoCleanNoTests
./gradlew deployWEB
```
## 5. Проверка
1. Авторизоваться двумя клиентами.
2. Запустить звонок.
3. Проверить, что звонок устанавливается даже в сети, где прямой P2P затруднён.
4. Если TURN недоступен, клиент автоматически откатится к STUN-конфигу по умолчанию.
+1
View File
@@ -0,0 +1 @@
Данная документация местами устарела и не соответствует реальному коду
+17
View File
@@ -0,0 +1,17 @@
shine-server-bd — это библиотека реалезующая всю работу с БД:
хранит пользователей/сессии/параметры/кэш IP→гео и данные блокчейна (состояние + блоки), предоставляя единый SqliteDbController для соединений, набор DAO под каждую таблицу (Singleton, методы с Connection для транзакций и без Connection — сами открывают/закрывают), и простые entity-модели как контейнеры данных для маппинга ResultSet↔Java.
Логика структуры классов (в двух словах):
shine.db.SqliteDbController — один вход в БД: читает db.path, при отсутствии файла создаёт БД, выдаёт новые Connection и настраивает PRAGMA.
shine.db.DatabaseInitializer — разовая сборка схемы (таблицы + индексы).
shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getClientKeyByte()).
shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO; плюс “сервисные” DAO:
UserCreateDAO — атомарная регистрация пользователя в транзакции (BEGIN IMMEDIATE + rollback/commit).
// Временное решение позволяющее регистрировать новых пользователей
// атомарно и добавляет запись и в solana_users и в BlockchainState
+85
View File
@@ -0,0 +1,85 @@
shine-server-blockchain — это библиотека, которая задаёт формат блока, правила парсинга/валидации тела, крипто-проверку (hash+Ed25519) и безопасную работу с файлами блокчейна (data/<name>.bch через временный .tmp_bch).
Как устроена структура и логика работы
1) “Блок” как центральный объект (ядро)
BchBlockEntry — единая модель блока “как лежит на диске/в сети”:
читает/собирает байты в формате RAW + signature64 + hash32
сразу парсит body через BodyRecordParser
сразу проверяет что lineIndex совпадает с тем, что ожидает конкретный тип body (expectedLineIndex())
То есть: всё, что считается “блоком”, обязано быть самодостаточно валидным уже на этапе создания объекта.
2) “Body” как плагины по типам (расширяемая часть) ! <-- Новые типы записей добавлть сюда !
BodyRecord — интерфейс контракта для всех тел:
type/version — идентификаторы формата
expectedLineIndex() — жёсткое правило “в какой линии может жить”
check() — логическая валидация содержимого
toBytes() — сериализация обратно в бинарь
BodyRecordParser — диспетчер: читает первые 4 байта (type+ver) и выбирает нужный класс:
HeaderBody (lineIndex=0)
TextBody (lineIndex=1)
ReactionBody (lineIndex=2)
Добавление нового типа = добавить новый класс XxxBody + кейс в BodyRecordParser.
3) Криптография как отдельный слой проверки
BchCryptoVerifier отвечает за “как получить хэш и как проверить подпись”:
строит preimage = "SHiNE" + login + prevGlobalHash32 + prevLineHash32 + rawBytes
считает sha256(preimage) и сравнивает с hash32 внутри блока
проверяет Ed25519 подпись над hash32
Важно: BchBlockEntry не проверяет подпись — он проверяет структуру блока и правильность body/lineIndex, а криптопроверка вынесена отдельно.
4) Утилиты вокруг имени и файлов
BlockchainNameUtil — извлекает login из blockchainName (отрезает 3 символа суффикса).
FileStoreUtil — безопасное файловое хранилище:
5) Объяснение структуры работы
Типичный сценарий: пришёл блок → проверить → принять
Шаг 0. Контекст (что у нас уже есть снаружи)
Снаружи библиотеки (в сервере) у тебя уже известны:
userLogin — владелец блокчейна
publicKey32 — публичный ключ пользователя
prevGlobalHash32 — хэш предыдущего блока по глобальной цепи
prevLineHash32 — хэш предыдущего блока по текущей линии
Библиотека не хранит это сама, она ожидает, что сервер это передаст.
Шаг 1. Парсинг блока (структура + логика)
BchBlockEntry block = new BchBlockEntry(fullBytes);
Что происходит здесь автоматически:
проверяется длина блока
проверяется recordSize
парсится RAW-заголовок
парсится body через BodyRecordParser
проверяется, что lineIndex соответствует типу body
(HEADER → line 0, TEXT → line 1, REACTION → line 2 и т.д.)
❗ На этом шаге никакой криптографии ещё нет — только структура и логика формата.
Если тут не упало исключение → блок структурно корректен.
Шаг 2. Подготовка данных для криптопроверки (они получаются просто из частей байтов полного блокас подписью)
byte[] rawBytes = block.getRawBytes();
byte[] signature64 = block.getSignature64();
byte[] hash32FromTail = block.getHash32();
Важно:
rawBytes — это ровно те байты, которые участвовали в хэшировании
hash32FromTail — это то, что автор блока положил внутрь блока
Шаг 3. Криптографическая проверка (ключевой вызов)
boolean ok = BchCryptoVerifier.verifyAll(
userLogin,
prevGlobalHash32,
prevLineHash32,
rawBytes,
signature64,
publicKey32,
hash32FromTail
);
@@ -0,0 +1,48 @@
Общая структура блока
Блок — это бинарная запись фиксированного формата:
[ RAW ][ signature64 ][ hash32 ]
RAW — данные блока (участвуют в хэшировании и подписи)
signature64 — Ed25519-подпись над hash32
hash32 — SHA-256 от preimage (привязан к цепочке и владельцу)
RAW-часть (BigEndian)
recordSize int32 — размер RAW (без signature+hash)
recordNumber int32 — глобальный номер блока
timestamp int64 — unix time (seconds)
lineIndex int16 — индекс линии
lineNumber int32 — номер блока внутри линии
bodyBytes bytes — тело блока (type+version+payload)
Общая структура блокчейна
Блокчейн — это:
линейная цепочка блоков (по recordNumber)
внутри неё — параллельные логические линии (lineIndex)
каждая линия имеет собственную нумерацию (lineNumber) и prevLineHash
вся цепочка связана ещё и prevGlobalHash
👉 Таким образом, каждый блок:
связан с предыдущим глобальным блоком
и с предыдущим блоком своей линии
Это даёт:
строгий порядок всей истории
и независимую валидацию логических потоков
Криптографический смысл блока
Хэш блока считается от:
"SHiNE" +
login +
prevGlobalHash32 +
prevLineHash32 +
RAW
Это означает:
блок жёстко привязан к владельцу (login)
блок невозможно перенести в другую цепочку
подмена предыдущего блока ломает всю цепь
Подпись Ed25519 делается над этим хэшем.
@@ -0,0 +1,36 @@
Формат и смысл существующих Body
1) HeaderBody (type=0, ver=1)
Линия: lineIndex = 0
Смысл:
Генезис-блок блокчейна, объявляет формат и владельца.
Содержит:
сигнатуру формата "SHiNE"
login владельца блокчейна
👉 Всегда первый блок, всегда в линии 0.
2) TextBody (type=1, ver=1)
Линия: lineIndex = 1
Смысл:
Основной контент — текстовые записи (посты, сообщения, дневник).
Содержит:
UTF-8 текст произвольной длины
👉 Это “основная история” блокчейна пользователя.
3) ReactionBody (type=2, ver=1)
Линия: lineIndex = 2
Смысл:
Связь с другим блокчейном или блоком (реакция, ответ, лайк, ссылка).
Содержит:
код реакции
имя целевого блокчейна
globalNumber целевого блока
hash32 целевого блока
👉 Это механизм межблокчейн-связей без изменения чужих цепочек.
+7
View File
@@ -0,0 +1,7 @@
shine-server-config
Минимальная библиотека конфигурации, предоставляющая потокобезопасный singleton-доступ к параметрам из application.properties.
Настройки:
server.port=7070 — порт запуска сервера
db.path=data/shine.sqlite — путь к SQLite базе данных
+8
View File
@@ -0,0 +1,8 @@
shine-server-crypto
О чём: базовые крипто-утилиты для SHA-256 и Ed25519 (BouncyCastle) + проверка подписи/хэша для .bch сущностей и маленький self-test.
Внешние методы, которые вызываются:
BchCryptoVerifier.verifyAll(), BchCryptoVerifier.buildPreimage(), Ed25519Util.generatePrivateKey(), Ed25519Util.generatePrivateKeyFromString(), Ed25519Util.derivePublicKey(), Ed25519Util.sign(), Ed25519Util.verify(), Ed25519Util.keyToBase64(), Ed25519Util.keyFromBase64(),
HashSHA256Util.sha256()
HashSHA256Util.loginToLoginId(), HashSHA256Util.loginIdFromLogin(),
+19
View File
@@ -0,0 +1,19 @@
shine-server-geo
Назначение: утилиты для получения “кто подключился” (IP/UA/язык) и геолокации по IP (с опциональным кэшем в БД).
Классы:
ClientInfoService — собирает строку UA/ch-ua/platform/mobile/remoteIP, вытаскивает реальный IP (приоритет: X-Forwarded-For → X-Real-IP → remoteAddress), парсит первый Accept-Language.
GeoLookupService — геолокация по IP через внешний API (ip-api.com), умеет вариант без кэша и с кэшем в таблице ip_geo_cache через IpGeoCacheDAO (пишет даже unknown), плюс метод получения внешнего IP через api.ipify.org.
GeoLookupTestMain — консольный тест: берёт IP из аргумента или определяет внешний, вызывает геолокацию и печатает результат + время.
Внешние (публично используемые) методы:
ClientInfoService.buildClientInfoString(Session) — формирует строку с User-Agent, client-hints и реальным IP клиента
ClientInfoService.extractClientIp(Session) — извлекает реальный IP (X-Forwarded-For / X-Real-IP / remoteAddress)
ClientInfoService.extractPreferredLanguageTag(Session) — возвращает основной язык клиента из Accept-Language
GeoLookupService.resolveCountryCityOrIp(String ip) — геолокация по IP без кэша (Country, City или unknown)
GeoLookupService.resolveCountryCityOrIpWithCache(String ip) — геолокация по IP с кэшированием в БД
GeoLookupService.fetchPublicIpOrDefault(String fallbackIp) — получение внешнего IP текущей машины
+10
View File
@@ -0,0 +1,10 @@
shine-log (BlockchainAdminNotifier)
Суть (1 предложение): единая точка для “красного” оповещения админа о критических проблемах консистентности, сейчас — через максимально заметный log.error, позже — через Telegram/email/webhook и т.п.
Структура (очень кратко):
BlockchainAdminNotifier (final utility)
BlockchainAdminNotifier.critical(String message)
BlockchainAdminNotifier.critical(String message, Throwable t)
+52
View File
@@ -0,0 +1,52 @@
shine-server-protocol
Библиотека JSON-протокол поверх WebSocket для взаимодействия с клиентами.
Всё общение — JSON поверх WebSocket
Формат всегда один:
request: op + requestId + payload
response: op + requestId + status + payload
Net_Event / Net_Request / Net_Response
Базовые классы протокола.
requestId связывает запрос и ответ, status = результат.
Хэндлер = логика операции
Каждый op обрабатывается своим JsonMessageHandler.
Entities (Request / Response)
DTO-классы для Jackson:
Net_Xxx_Request — что приходит от клиента
Net_Xxx_Response — что уходит клиенту
JsonHandlerRegistry
Связывает:
op → RequestClass
op → Handler
JsonInboundProcessor
Единая точка входа:
парсит JSON → маппит payload → вызывает handler → собирает ответ JSON.
Папки по темам
auth/ — авторизация и сессии
(AuthChallenge → CreateAuthSession → Refresh / List / Close)
blockchain/ — AddBlock
tempToTest/ — AddUser (временный, потом уйдёт в блокчейн-логику)
ConnectionContext
Состояние одного WebSocket-подключения (login, session, authStatus).
ActiveConnectionsRegistry
Глобальный реестр активных авторизованных соединений
(нужно для закрытия других сессий).
@@ -0,0 +1,103 @@
# Деплой и инициализация Solana-регистрации
## Коротко
Для рабочей регистрации пользователя нужны три программы:
1. `shine_users` — хранение и обновление `user_pda`, economy-конфиг, логика регистрации.
2. `shine_login_guard` — проверка/классификация логина через CPI из `shine_users`.
3. `shine_payments` — приём платежей за регистрацию и докупку лимита через `inflow_vault PDA`.
Если `shine_users` или `shine_login_guard` не совпадают по адресам с UI/сервером, регистрация неработоспособна.
## Актуальные адреса (mainnet)
- `shine_users`:
`SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
- `shine_login_guard`:
`SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh`
- `shine_payments`:
`SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v`
Временный on-chain admin для `shine_users` и `shine_payments` до перевода управления на DAO:
- `aiShm43fZjm3YkMs22sYL1bpXaL3bVxv7SSraPHzVgq`
## Подтверждение текущего mainnet deploy
- Сеть: `https://api.mainnet-beta.solana.com`
- `shine_login_guard`:
- `Program ID`: `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh`
- TX deploy: `5gCxrw5gj7ig5C3HFf4AUrHq2pMW1LxgfLUj4jtmQvTqXnR7xp1iySf7m63S3MaimpeUyYhQR1zpPFJyPi6NTAv7`
- `shine_users`:
- `Program ID`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
- TX deploy: `5Cwp53EegPv6aV69Ztxj7nXomHt9H9ZaXSgWvDfLPeR5EAaU38ADsQjn9h8xShU34AgttYGwWaVWiicF2vLwJXU9`
- `shine_payments`:
- `Program ID`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v`
- TX deploy: `2vzsM85bTwSxdYvzWKZMpAg7Gqxs8R7CC5T5LTm7cRFizhr11QZvgDKqP77b8n2mcmA6DF4RqwrUrPzQ8bsQhvCn`
## Как адреса связаны между собой
- `shine_users` внутри кода вызывает `shine_login_guard`.
- `shine_users` внутри кода знает `shine_payments` и переводит оплату в `inflow_vault PDA` этой программы.
- `shine_users` и `shine_payments` пока управляются временным адресом `aiShm...`.
- Позже эти hardcoded authority-адреса планируется заменить на адреса DAO через upgrade программ.
## Куда вписаны адреса в проекте
### UI
- `shine-UI/js/solana-programs.js`
- `shine-UI/js/pages/wallet-view.js`
- `shine-UI/js/services/solana-register-service.js`
- `shine-UI/server-ui/*.html`
### Browser plugin wallet
- `SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js`
### Сервер
- `SHiNE-server/shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java`
### Solana / Anchor
- `shine-solana/shine/Anchor.toml`
- `shine-solana/shine/programs/shine_users/src/lib.rs`
- `shine-solana/shine/programs/shine_users/src/settings.rs`
- `shine-solana/shine/programs/shine_login_guard/src/lib.rs`
- `shine-solana/shine/programs/shine_payments/src/lib.rs`
- `shine-solana/shine/programs/shine_payments/src/settings.rs`
### Дополнительные потребители
- `shine-solana/shine/programs/shine_payments/web/*.html`
- `ESP32/.../shine_homeserver_main.ino`
- `ESP32/.../shine_homeserver_ui.ino`
## Как запустить инициализацию economy PDA
1. Открыть UI.
2. Перейти: `Профиль -> Настройки -> Настройки разработчика -> Solana: init регистрации`.
3. Подключить mainnet-кошелёк.
4. Нажать `Запустить init_users_economy_config`.
5. Дождаться статуса `Успешно`.
Страница вычисляет PDA `users_economy_config` по seed:
- seed: `shine_users_economy_config`
- program: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
## Кто оплачивает create/update user_pda
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`.
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя или сервера.
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`.
## Важно
- `init_users_economy_config` выполняется один раз на программу. Если PDA уже создан, повторный вызов вернёт ошибку `already initialized`.
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `clientKey`, а содержимое записи подписывает `rootKey`.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
- При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet.
@@ -0,0 +1,118 @@
# ESP Pairing и режимы подключения
Этот документ фиксирует актуальные режимы входа/подключения в SHiNE. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя.
## 1. Текущие режимы
### 1. Создание новой сессии через `clientKey`
Поток:
`AuthChallenge -> CreateAuthSession`
Смысл:
- новое устройство уже владеет приватным `clientKey`;
- сервер проверяет подпись `clientKey`;
- создаётся обычная активная сессия пользователя;
- этот поток остаётся без изменений.
### 2. Повторный вход в существующую сессию через `sessionKey`
Поток:
`SessionChallenge -> SessionLogin`
Смысл:
- устройство уже владеет приватным `sessionKey`;
- сервер проверяет подпись `sessionKey`;
- соединение снова входит в существующую сессию;
- этот поток тоже остаётся без изменений.
## 2. Добавление сессии через доверенное устройство пользователя
Новый поток не заменяет обычный логин, а живёт рядом с ним.
Цель:
- новое устройство знает `login`, а `pairing password` используется только если он включён на доверённом устройстве;
- сервер использует пароль только как фильтр от мусора;
- реальное доверие даёт любая уже онлайн доверенная сессия пользователя;
- сервер не выдаёт приватные ключи сам от себя.
Текущий поток:
1. Любая доверенная сессия пользователя создаёт на сервере pairing-настройку:
`UpsertEspPairingSettings`
2. Новое устройство создаёт pending-заявку:
`StartEspPairing`
3. Онлайн доверенная сессия видит список активных заявок:
`ListEspPairingRequests`
4. Доверенная сессия либо подтверждает заявку:
`ApproveEspPairing`
5. Либо отклоняет:
`RejectEspPairing`
6. Новое устройство читает результат:
`GetEspPairingStatus`
## 3. Что именно делает сервер
- хранит включённость pairing и optional `passwordHash` в формате `sha256$<hex>`;
- хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`;
- рассчитывает короткий код `shortCode` из `10` цифр;
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое.
## 4. Чего сервер в этом режиме не делает
- не передаёт приватный `clientKey`;
- не расшифровывает `encryptedPayload`;
- не проверяет криптографию содержимого payload;
- не делает клиентский UI;
- не навязывает конкретную схему `Ed25519 -> X25519` в коде сервера.
Это намеренно: сервер остаётся безопасным каркасом маршрутизации и состояния, а E2E-логика упаковки ключей живёт на клиентах и ESP-устройствах.
## 5. Роли и ограничения
- любая уже авторизованная доверенная сессия пользователя может вызывать:
- `UpsertEspPairingSettings`
- `ListEspPairingRequests`
- `ApproveEspPairing`
- `RejectEspPairing`
- новое устройство может вызвать `StartEspPairing` и `GetEspPairingStatus` без уже существующей авторизованной сессии;
- `payloadType` поддерживается в вариантах:
- `1` — минимальный пакет
- `2` — расширенный пакет
- `3` — полный пакет
Сервер не интерпретирует эти три типа глубже, а только фиксирует их в состоянии заявки.
## 6. Статусы pairing-заявки
- `created` — заявка создана и ждёт решения доверенной сессии;
- `approved` — доверенная сессия подтвердила и приложила `encryptedPayload`;
- `rejected` — доверенная сессия отклонила заявку;
- `expired` — TTL заявки истёк до подтверждения.
## 7. Практический смысл
Эта схема даёт нужное разделение доверия:
- пароль на сервере, если он включён, только отсеивает лишних;
- онлайн доверенная сессия решает, добавлять ли новую сессию;
- сервер остаётся маршрутизатором и хранилищем состояния, а не владельцем секретов.
Текущий формат pairing-пароля:
```text
sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```
## 8. Связанный документ по внешнему кошельку
Для отдельного RPC-взаимодействия между браузерным wallet-расширением и ESP32 см. документ:
- [Формат_взаимодействия_внешнего_кошелька_и_ESP32.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md)
@@ -0,0 +1,104 @@
# SHiNE Arweave Wallet Derivation v1
Сокращение: **SAWD-v1**.
## Назначение
Из 32-байтного `clientKey32` пользователя получить один и тот же нативный Arweave RSA-4096 JWK wallet и один и тот же Arweave address.
## Вход
- `clientKey32`: ровно 32 байта.
- Если исходный `client.key` хранится как Ed25519 PKCS8 base64, нужно извлечь последние 32 байта из PKCS8.
- Если используется Solana keypair JSON на 64 байта, используются только `bytes[0..31]`.
## Выход
```json
{
"derivation": "SAWD-v1",
"jwk": {
"kty": "RSA",
"e": "AQAB",
"n": "...",
"d": "...",
"p": "...",
"q": "...",
"dp": "...",
"dq": "...",
"qi": "..."
},
"owner": "...",
"address": "..."
}
```
Где:
- `owner = jwk.n`
- `address = base64url_no_padding(SHA-256(unsigned_big_endian_bytes(n)))`
## Константы
- `DERIVATION_NAME = "SAWD-v1"`
- `MASTER_LABEL = "SHINE/ARWEAVE/RSA4096/SAWD-v1/MASTER"`
- `STREAM_LABEL = "SHINE/ARWEAVE/RSA4096/SAWD-v1/STREAM"`
- `MR_LABEL = "SHINE/ARWEAVE/RSA4096/SAWD-v1/MILLER-RABIN"`
- `RSA_BITS = 4096`
- `PRIME_BITS = 2048`
- `PUBLIC_EXPONENT = 65537`
- `MILLER_RABIN_ROUNDS = 64`
- `SMALL_PRIME_LIMIT = 10000`
## Алгоритм
1. Проверить `clientKey32.length == 32`.
2. `masterSeed32 = HMAC-SHA256(key = UTF8(MASTER_LABEL), message = clientKey32)`.
3. Реализовать `deriveBytes(label, length)`:
- `output = empty`
- `counter = 0`
- while `output.length < length`:
- `block = HMAC-SHA256(key = masterSeed32, message = UTF8(STREAM_LABEL) || UTF8("/") || UTF8(label) || UTF8("/") || uint64_be(counter))`
- `output = output || block`
- `counter++`
- вернуть первые `length` байт.
4. Для `p` и `q`:
- `raw = deriveBytes(label + "/" + index, 256)`
- `candidate = unsigned_big_endian_integer(raw)`
- `candidate = candidate OR 2^2047`
- `candidate = candidate OR 1`
- Проверить:
- `bitLength(candidate) == 2048`
- `candidate odd`
- не делится на малые простые `<= 10000`
- `gcd(candidate - 1, 65537) == 1`
- проходит Miller-Rabin `64 rounds`
5. Базы Miller-Rabin детерминированные:
- `baseBytes = HMAC-SHA256(key = masterSeed32, message = UTF8(MR_LABEL) || UTF8("/") || UTF8(label) || UTF8("/") || uint64_be(index) || UTF8("/") || uint32_be(round))`
- `a = 2 + (unsigned_big_endian_integer(baseBytes) mod (candidate - 3))`
6. `p = derivePrime("p")`, `q = derivePrime("q")`.
7. Если `p == q`, продолжить поиск `q`.
8. Если `p > q`, поменять местами. В SAWD-v1 всегда `p < q`.
9. `n = p * q`
10. `e = 65537`
11. `lambda = lcm(p - 1, q - 1)`
12. `d = modular_inverse(e, lambda)`
13. `dp = d mod (p - 1)`
14. `dq = d mod (q - 1)`
15. `qi = modular_inverse(q, p)`
16. Сформировать JWK:
- `kty = "RSA"`
- `e = "AQAB"`
- `n,d,p,q,dp,dq,qi = base64url unsigned big-endian integer without padding`
17. `owner = jwk.n`
18. `address = base64url_no_padding(SHA-256(unsigned_big_endian_bytes(n)))`
## Запрещено
- `crypto.generateKeyPair`
- `WebCrypto generateKey`
- `KeyPairGenerator`
- `SecureRandom(seed)`
- `Math.random`
- системный `random`
- ArDrive CLI
- Turbo
- внешний API для генерации ключа
- сохранение приватного JWK
## Версионирование стандарта
Если меняется любая константа или шаг алгоритма — это уже **SAWD-v2**.
Пользователи, созданные на SAWD-v1, должны продолжать восстанавливаться через SAWD-v1.
@@ -0,0 +1,151 @@
# Преобразование Ed25519 в X25519
## Назначение
Этот документ фиксирует единое правило проекта SHiNE:
- публичная идентичность пользователя задаётся одним `clientKey` в формате `Ed25519`;
- подпись пользовательских сообщений и команд выполняется ключом `Ed25519`;
- шифрование личных сообщений использует ключ `X25519`, получаемый детерминированно из того же ключевого материала;
- для преобразования используется только стандартное преобразование `Ed25519 <-> X25519`;
- самодельные формулы, альтернативные хэши и нестандартные схемы преобразования в проекте не допускаются.
Цель документа: исключить путаницу между ключом подписи и ключом шифрования и зафиксировать один общий способ преобразования для клиента, сервера и будущих реализаций.
## Краткое правило
Если у пользователя есть:
- приватный ключ `Ed25519 private`;
- публичный ключ `Ed25519 public` (`clientKey`);
то для шифрования личных сообщений из них получаются:
- `X25519 private` для расшифровки своих копий;
- `X25519 public` для шифрования сообщений на этого пользователя.
При этом:
- в профиле пользователя по-прежнему публикуется только `clientKey`;
- отдельный публичный `dmEncKey` не требуется;
- владелец устройства локально может использовать один и тот же исходный секрет и для подписи, и для вывода ключа шифрования.
## Что считается стандартным преобразованием
Под "стандартным преобразованием" в проекте понимается совместимое с общепринятой практикой libsodium/NaCl преобразование:
- из `Ed25519 private` получается `X25519 private` через стандартную процедуру на основе SHA-512 и clamping;
- из `Ed25519 public` получается `X25519 public` через стандартное преобразование точки Edwards в точку Montgomery.
Иными словами:
- приватный ключ шифрования должен получаться из приватного ключа подписи стандартным способом;
- публичный ключ шифрования должен получаться из публичного `clientKey` стандартным способом;
- обе стороны должны получать одинаковый `X25519 public` по одному и тому же `clientKey`.
## Преобразование приватного ключа
Исходные данные:
- `Ed25519 private` в SHiNE рассматривается как 32-байтный seed приватного ключа.
Стандартная процедура:
1. Вычислить `SHA-512(seed32)`.
2. Взять первые 32 байта результата.
3. Применить стандартный clamping для `X25519 private`.
4. Полученный результат использовать как `X25519 private`.
Замечания:
- нельзя использовать для `X25519 private` просто исходный 32-байтный `Ed25519 seed` без преобразования;
- нельзя заменять `SHA-512` на `SHA-256`, `HMAC`, `HKDF` или произвольную пользовательскую формулу;
- clamping обязателен.
## Преобразование публичного ключа
Исходные данные:
- `clientKey` пользователя в формате `Ed25519 public` (32 байта).
Стандартная процедура:
1. Декодировать публичную точку Edwards из `Ed25519 public`.
2. Выполнить стандартное преобразование Edwards -> Montgomery.
3. Полученное значение использовать как `X25519 public`.
На уровне математики используется стандартное отображение:
- `u = (1 + y) / (1 - y)`
где `y` берётся из публичной точки Ed25519, а результат `u` кодируется как публичный ключ `X25519`.
Замечания:
- нельзя получать `X25519 public` простым хэшированием `clientKey`;
- нельзя получать `X25519 public` "через приватный ключ на сервере";
- любой клиент должен уметь вывести `X25519 public` другого пользователя, имея только его `clientKey`.
## Что хранится в проекте
На текущем архитектурном уровне:
- в публичном профиле пользователя хранится только `clientKey` (`Ed25519 public`);
- приватная часть `clientKey` хранится только у владельца устройства;
- `X25519 private` и `X25519 public` считаются производными величинами и могут вычисляться по мере необходимости;
- отдельный `keyId` для DM пока не вводится.
Это означает:
- если в будущем пользователь сменит `clientKey`, старые DM должны будут быть перешифрованы;
- массовая перешифровка истории рассматривается как отдельная функция более позднего этапа;
- текущая архитектура должна оставлять возможность такой перешифровки, но не обязана реализовывать её прямо сейчас.
## Как это применяется в DM
Для личных сообщений SHiNE:
- входящая копия сообщения шифруется на `X25519 public`, полученный из `clientKey` получателя;
- исходящая копия сообщения шифруется на `X25519 public`, полученный из `clientKey` отправителя;
- расшифровать входящую копию может только владелец приватного ключа получателя;
- расшифровать исходящую копию может только владелец приватного ключа отправителя.
Следствия:
- `encryptedBody` у входящей и исходящей копии одного логического сообщения обычно разный;
- сервер не должен требовать побайтного совпадения ciphertext у пары;
- сервер не должен уметь расшифровывать содержимое DM;
- подпись и шифрование используют один корневой секрет пользователя, но разные стандартно выведенные представления ключа.
## Ограничения и компромиссы
Эта архитектура осознанно имеет следующие свойства:
- компрометация приватного `clientKey` одновременно даёт злоумышленнику возможность и подписывать от имени пользователя, и расшифровывать его личные сообщения;
- отдельное разделение ключа подписи и ключа шифрования пока не используется;
- простота пользовательской модели и минимизация числа публичных ключей считаются в проекте более важными, чем разделение этих ролей на текущем этапе.
Если позже проекту понадобится более строгая модель безопасности, допускается переход на отдельный публичный ключ шифрования, но только как отдельная версия протокола.
## Что запрещено
В проекте запрещено:
- придумывать собственное "детерминированное преобразование" `Ed25519 -> X25519`;
- хэшировать `clientKey` произвольным образом и считать результат `X25519 public`;
- использовать `Ed25519 public` напрямую как ключ ECDH;
- использовать сырой `Ed25519 seed` как `X25519 private` без стандартной процедуры;
- смешивать разные способы преобразования в разных клиентах.
## Требование к реализациям
Любая реализация SHiNE, которая:
- подписывает DM;
- шифрует DM;
- расшифровывает DM;
- проверяет совместимость личных сообщений между устройствами;
обязана использовать именно этот подход.
Если библиотека уже предоставляет готовые совместимые функции преобразования `Ed25519 -> X25519`, нужно использовать их вместо самодельной реализации.
@@ -0,0 +1,311 @@
# Формат взаимодействия внешнего кошелька и ESP32
Этот документ фиксирует актуальный формат взаимодействия между внешним браузерным wallet-расширением SHiNE и устройством `ESP32-S3-Touch-AMOLED-2.16`.
Документ описывает:
- как расширение получает текущий активный публичный ключ кошелька с ESP32;
- как расширение отправляет на ESP32 запрос подписи транзакции;
- что именно считается активным кошельком на ESP32;
- какие проверки и UI-реакции ожидаются в браузерном расширении и на устройстве;
- какие ограничения действуют в текущей версии протокола.
## 1. Общая идея
Устройство ESP32 хранит `master secret` пользователя и локально умеет выводить несколько кошельков из одного секрета.
На устройстве в UI пользователь выбирает текущий активный кошелёк:
- `client.key`
- `root.key`
- `custom`
Для `custom` используется derivation:
```text
sha256(base64(secret32) + "|wallet." + customName)
```
Браузерное расширение не указывает ESP32, какой кошелёк нужно вернуть в первом запросе. Оно просто спрашивает:
```text
какой кошелёк сейчас активен на устройстве
```
ESP32 возвращает:
- тип текущего активного кошелька;
- его публичный ключ `Base58`.
## 2. Транспорт и маршрут
Текущий формат использует уже существующую `wallet-session` браузерного расширения.
Схема маршрута:
`browser extension -> SHiNE server -> homeserver session on ESP32 -> SHiNE server -> browser extension`
В текущем формате:
- отдельная цифровая подпись payload не добавляется;
- отдельное E2E-шифрование для wallet RPC не добавляется;
- используется существующая авторизованная `wallet-session`, транспорт `WSS` и server-side маршрут через уже существующую операцию `CallSignalToSession`.
## 3. Запрос текущего публичного ключа кошелька
### 3.1. Назначение
Операция нужна, чтобы браузерное расширение могло узнать, какой кошелёк сейчас выбран на ESP32, и показать его пользователю перед дальнейшими действиями.
### 3.2. Формат запроса
```json
{
"v": 1,
"operation": "get_wallet_public_key",
"requestId": "1718998123456-482193",
"timeMs": 1718998123456
}
```
### 3.3. Поля запроса
- `v` — версия формата wallet RPC. Для текущего варианта: `1`.
- `operation` — строка операции. Для текущего запроса: `get_wallet_public_key`.
- `requestId` — идентификатор запроса, уникальный в пределах сеанса расширения. Рекомендуемый формат:
`timeMs-random`.
- `timeMs` — локальное время отправителя в миллисекундах.
### 3.4. Поведение ESP32
При получении такого запроса ESP32:
1. смотрит, какой кошелёк сейчас выбран в локальном UI;
2. вычисляет или берёт уже подготовленный публичный ключ именно этого активного кошелька;
3. возвращает тип кошелька и его `publicKeyBase58`.
Запрос не содержит:
- `walletSelector`;
- `customName`;
- `targetSessionName`.
Они намеренно не входят в текущий формат этого запроса.
## 4. Формат ответа
```json
{
"v": 1,
"op": "get_wallet_public_key_result",
"requestId": "1718998123456-482193",
"ok": true,
"wallet": {
"type": "custom",
"publicKeyBase58": "...."
},
"timeMs": 1718998123999
}
```
## 5. Поля ответа
- `v` — версия формата ответа. Сейчас `1`.
- `op` — строка результата операции. Сейчас `get_wallet_public_key_result`.
- `requestId` — должен совпадать с `requestId` исходного запроса.
- `ok` — признак успешного результата.
- `wallet.type` — тип активного кошелька:
- `client.key`
- `root.key`
- `custom`
- `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`.
- `timeMs` — время формирования ответа на стороне ESP32 в миллисекундах.
## 6. Ошибки текущего формата
Минимальный формат ошибки допускается таким:
```json
{
"v": 1,
"op": "get_wallet_public_key_result",
"requestId": "1718998123456-482193",
"ok": false,
"error": "wallet_unavailable",
"timeMs": 1718998123999
}
```
Рекомендуемые коды ошибок:
- `wallet_unavailable` — на устройстве нельзя получить текущий кошелёк;
- `secret_not_configured` — на устройстве ещё нет корректно сохранённого секрета;
- `wallet_type_unknown` — выбранный локальный тип кошелька не распознан;
- `internal_error` — прочая локальная ошибка устройства.
## 7. Правила для браузерного расширения
После ответа `ok=true` расширение должно:
1. показать пользователю тип кошелька;
2. показать полный `publicKeyBase58`;
3. дать кнопку копирования ключа в буфер;
4. сохранить этот ключ как текущий ключ устройства для следующей операции подписи.
### 7.1. Проверка через PDA Solana
Расширение уже знает публичные ключи пользователя из Solana PDA. Поэтому оно может дополнительно проверить ответ ESP32:
- если `wallet.type = client.key`, то `publicKeyBase58` должен совпасть с `clientKey`, прочитанным из PDA;
- если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA;
- если `wallet.type = custom`, такой проверки по PDA пока нет.
При несовпадении для `client.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA.
## 8. Ожидаемое поведение UI расширения
### 8.1. Общий вид popup
Popup браузерного расширения должен быть узким и вытянутым по вертикали.
### 8.2. Состояние без подключения
Если `wallet-session` ещё не подключена:
- показывается кнопка `Подключить`;
- по нажатию открывается экран подключения, близкий по смыслу к сценарию `Войти через другое устройство`;
- пользователь вводит логин устройства и получает код подключения.
### 8.3. Состояние после подключения
Если `wallet-session` уже подключена:
- показывается статус `Подключено`;
- остаётся выбор homeserver;
- появляется кнопка запроса текущего кошелька;
- появляется кнопка `Отключить`.
### 8.4. Подключение кошелька с сайта
Когда сайт просит подключить кошелёк через расширение, расширение должно вести себя как обычный wallet extension:
1. показать пользователю подтверждение подключения;
2. показать, какой именно кошелёк будет подключён;
3. после подтверждения пользователя завершить подключение;
4. если пользователь отказался, не подключать кошелёк к сайту.
## 9. Запрос подписи транзакции
### 9.1. Назначение
Операция нужна, чтобы браузерное расширение могло запросить у ESP32 подпись Solana-транзакции текущим активным кошельком.
Расширение передаёт:
- публичный ключ, которым ожидается подпись;
- сериализованную транзакцию;
- комментарий, который должен быть показан на экране ESP32.
ESP32:
1. показывает пользователю запрос подтверждения;
2. показывает комментарий к подписи;
3. после нажатия `APPROVE` или `REJECT` возвращает ответ в расширение.
### 9.2. Формат запроса
```json
{
"v": 1,
"operation": "sign_transaction",
"requestId": "1718998123456-482193",
"timeMs": 1718998123456,
"publicKeyBase58": "....",
"transactionBase64": "....",
"comment": "Site https://example.com requested transaction signature"
}
```
### 9.3. Поля запроса
- `v` — версия wallet RPC. Сейчас `1`.
- `operation` — строка операции: `sign_transaction`.
- `requestId` — идентификатор запроса.
- `timeMs` — время отправки на стороне расширения.
- `publicKeyBase58` — публичный ключ, от которого ожидается подпись.
- `transactionBase64` — сериализованная Solana transaction в `base64`.
- `comment` — короткое текстовое описание, которое ESP32 показывает пользователю при запросе подписи.
### 9.4. Поведение ESP32
При получении такого запроса ESP32:
1. сравнивает `publicKeyBase58` с публичным ключом текущего активного выбранного кошелька;
2. если ключ не совпадает, сразу возвращает ошибку `wallet_mismatch`;
3. если ключ совпадает, показывает отдельный экран подтверждения подписи;
4. на экране показывает:
- каким кошельком будет выполнена подпись;
- комментарий `comment`;
- кнопки `APPROVE` и `REJECT`;
5. если пользователь подтверждает подпись, ESP32 подписывает транзакцию и возвращает результат;
6. если пользователь отклоняет, ESP32 возвращает `rejected_by_user`.
## 10. Формат ответа на подпись
```json
{
"v": 1,
"op": "sign_transaction_result",
"requestId": "1718998123456-482193",
"ok": true,
"publicKeyBase58": "....",
"signatureBase58": "....",
"signedTransactionBase64": "....",
"timeMs": 1718998123999
}
```
Если пользователь отклонил запрос:
```json
{
"v": 1,
"op": "sign_transaction_result",
"requestId": "1718998123456-482193",
"ok": false,
"error": "rejected_by_user",
"timeMs": 1718998123999
}
```
Рекомендуемые ошибки для `sign_transaction`:
- `rejected_by_user`
- `wallet_unavailable`
- `wallet_mismatch`
- `transaction_base64_invalid`
- `transaction_sign_failed`
- `bad_request`
## 11. Подключение кошелька с сайта
При вызове сайта `connect wallet` расширение должно вести себя как обычный wallet extension:
1. запросить подтверждение у пользователя в браузере;
2. получить текущий публичный ключ с ESP32;
3. вернуть сайту `publicKey` текущего активного кошелька.
Для `signTransaction` расширение:
1. получает транзакцию от сайта;
2. пересылает её на ESP32 через `sign_transaction`;
3. ждёт решение пользователя на устройстве;
4. возвращает браузеру уже подписанную транзакцию.
## 12. Ограничения текущей версии
- запрос возвращает только текущий активный кошелёк, а не список всех кошельков;
- выбор типа кошелька делается только на самом ESP32;
- отдельная цифровая подпись ответа пока не используется;
- отдельное E2E-шифрование wallet RPC пока не используется;
- `custom`-кошельки пока не сверяются с PDA.
@@ -0,0 +1,651 @@
# Социальные связи и социальный граф сети «Сияние»
## 1. Общая концепция
Социальная сеть «Сияние» формирует открытый социальный граф на основании данных, которые пользователи указывают о себе и о своих отношениях с другими пользователями.
В социальном графе учитываются:
- семейные связи;
- близкие друзья;
- друзья;
- статус официального аккаунта;
- статус сияющего;
- оценки, которые пользователи дают друг другу.
Каждый пользователь самостоятельно указывает свои связи с другими людьми. Связь может существовать только с одной стороны или быть подтверждена встречной связью второго пользователя.
Система показывает фактически заявленные пользователями отношения, не пытаясь автоматически определять, какие из них являются истинными.
---
## 2. Типы аккаунтов
Каждый аккаунт может иметь один из следующих типов:
- не указано;
- человек;
- искусственный интеллект;
- бот;
- организация или сообщество.
### 2.1. Человек
Аккаунт представляет конкретного реального человека.
Для человека дополнительно может быть указано:
- мужчина;
- женщина;
- пол не указан.
### 2.2. Искусственный интеллект
Аккаунт представляет искусственный интеллект или цифровую личность, способную самостоятельно общаться и взаимодействовать с пользователями.
### 2.3. Бот
Аккаунт представляет автоматизированную программу или сервис.
### 2.4. Организация или сообщество
Аккаунт представляет организацию, компанию, проект, коллектив, сообщество, общественное объединение, инициативу или другую группу людей.
---
## 3. Официальный аккаунт
Пользователь может указать, является ли его аккаунт официальным.
Для человека действует принцип:
> Один реальный человек может иметь только один официальный аккаунт.
У человека могут быть дополнительные творческие, профессиональные, тематические, анонимные или технические аккаунты, но только один аккаунт должен обозначаться как официальный аккаунт этого человека.
### 3.1. Официальный аккаунт и голосование
Официальный аккаунт человека предназначен для механизмов голосования, в которых действует принцип:
> Один реальный человек — один голос.
Именно официальный аккаунт подтверждённого реального человека может учитываться как один голос.
Неофициальные и дополнительные аккаунты того же человека не должны давать дополнительные голоса.
Аккаунты искусственного интеллекта, ботов, организаций и сообществ не учитываются как голоса отдельных людей.
---
## 4. Подтверждение официального аккаунта
Официальность аккаунта состоит из двух независимых элементов:
1. владелец сам указывает, что аккаунт является официальным;
2. другие пользователи могут подтвердить его подлинность.
При этом существуют два разных вида подтверждения:
- подтверждение реального человека;
- подтверждение реального аккаунта организации или сообщества.
Эти оценки имеют разное назначение и не должны смешиваться.
---
## 5. Статус сияющего
Пользователь может самостоятельно указать своё отношение к направлению «Сияние».
Возможны следующие состояния:
- не указано;
- сияющий;
- не интересуется духовным направлением.
Для статуса сияющего отдельно предусмотрены пять признаков сияния. Они описаны в другом документе.
### 5.1. Не указано
Пользователь пока не сообщил своего отношения.
Это состояние не означает ни «сияющий», ни «не сияющий».
### 5.2. Сияющий
Пользователь считает себя сияющим и может отметить соответствующие признаки сияния.
Сияющие пользователи визуально выделяются в социальном графе кругом, свечением или другим визуальным элементом.
### 5.3. Не интересуется духовным направлением
Пользователь прямо указывает, что не интересуется духовным направлением сети «Сияние».
Это не является отрицательной оценкой человека и не ограничивает его возможности в социальной сети.
---
## 6. Основные виды социальных связей
Социальные связи делятся на две категории:
1. семейные связи;
2. дружеские связи.
Каждая связь указывается пользователем самостоятельно.
Связь может быть:
- односторонней;
- подтверждённой встречной связью.
---
## 7. Семейные связи
В системе предусмотрены следующие семейные связи:
- родитель;
- ребёнок;
- брат или сестра;
- супруг или супруга.
Более дальние степени родства отдельно не отображаются.
Пользователь может переходить по семейному графу от одного родственника к другому. Например, перейти к родителю своего родителя.
---
## 8. Односторонние семейные связи
Пользователь может указать семейную связь, даже если второй пользователь ещё не указал встречную связь.
Например:
- Анна указала Ивана как своего родителя;
- Иван пока не указал Анну как своего ребёнка.
При просмотре графа Анны Иван отображается как её родитель.
При просмотре графа Ивана Анна не отображается как его ребёнок, пока Иван сам не добавит эту связь.
Неподтверждённая связь отображается пунктирной линией.
---
## 9. Подтверждённые семейные связи
Семейная связь считается подтверждённой, если существует соответствующая встречная связь.
Например:
- Анна указала Ивана как родителя;
- Иван указал Анну как ребёнка.
После этого связь отображается сплошной линией.
Для симметричных отношений применяется тот же принцип. Например, связь супругов считается подтверждённой, если оба пользователя указали соответствующую связь.
---
## 10. Дружеские связи
Существует два уровня дружеских связей:
- друг;
- близкий друг.
Близкий друг автоматически считается другом.
Связь «близкий друг» является более высоким уровнем дружеской связи.
---
## 11. Односторонняя дружеская связь
Если пользователь A указал пользователя B как друга или близкого друга, но пользователь B не указал дружескую связь с A, связь является односторонней.
При просмотре графа A пользователь B отображается в соответствии с тем типом связи, который указал A.
При просмотре графа B пользователь A не отображается среди его друзей.
Односторонняя связь показывается пунктирной линией.
---
## 12. Подтверждённая дружеская связь
Дружеская связь считается подтверждённой, если второй пользователь также указал дружескую связь в ответ.
Уровень дружбы не обязан совпадать.
Например:
- A считает B другом;
- B считает A близким другом.
Такая связь считается подтверждённой.
Для подтверждения достаточно встречной связи типа «друг» или «близкий друг».
Подтверждённая связь отображается сплошной линией.
---
## 13. Отображение связи с точки зрения пользователя
При просмотре социального графа отображается именно тот тип связи, который указал пользователь, чей профиль открыт.
Например:
- A считает B другом;
- B считает A близким другом.
При просмотре графа A пользователь B отображается как друг.
При просмотре графа B пользователь A отображается как близкий друг.
Встречная связь влияет только на вид линии:
- есть встречная дружеская связь — сплошная линия;
- встречной дружеской связи нет — пунктирная линия.
---
## 14. Основные состояния дружеских связей
### 14.1. Односторонний друг
A считает B другом, но B не указал дружескую связь с A.
Связь в графе A показывается пунктирной линией.
### 14.2. Односторонний близкий друг
A считает B близким другом, но B не указал дружескую связь с A.
Связь в графе A показывается пунктирной линией.
### 14.3. Подтверждённая обычная дружба
A считает B другом, B считает A другом.
Связь показывается сплошной линией.
### 14.4. Подтверждённая дружба разного уровня
A считает B другом, B считает A близким другом.
При просмотре A пользователь B отображается как друг.
При просмотре B пользователь A отображается как близкий друг.
В обоих случаях линия сплошная.
### 14.5. Взаимные близкие друзья
Оба пользователя считают друг друга близкими друзьями.
Связь отображается как подтверждённая связь близких друзей.
---
## 15. Основной социальный граф
При открытии профиля пользователь располагается в центре графа.
Вокруг него отображаются:
- семья;
- близкие друзья;
- друзья.
Пользователь может переходить от одного человека к другому и продолжать движение по социальному графу.
---
## 16. Отображение связей
В социальном графе можно отдельно включать и выключать отображение следующих видов связей:
- семья;
- близкие друзья;
- друзья.
Можно включить один, несколько или все виды связей.
Выбранные настройки сохраняются при переходе от одного пользователя к другому.
---
## 17. Отображение сияющих
Отдельный переключатель позволяет включить режим «Только сияющие».
Если режим включён, среди выбранных социальных связей отображаются только сияющие пользователи.
Если режим выключен, отображаются все пользователи.
Сияющие пользователи дополнительно выделяются кругом или эффектом сияния.
---
## 18. Оценки пользователей
Пользователи могут ставить друг другу следующие оценки:
- лайк;
- хорошо знаю и уверен, что человек сияющий;
- мало знаком, но видел человека сияющим;
- подтверждение реального человека;
- подтверждение реального аккаунта организации или сообщества.
Эти оценки существуют отдельно от семейных и дружеских связей.
---
## 19. Лайк
Лайк означает общее положительное отношение к пользователю.
Его смысл:
> Мне нравится этот человек или аккаунт.
Лайк не означает дружбу, родство, статус сияющего или подтверждение подлинности аккаунта.
---
## 20. Сильная оценка сияния
Пользователь может поставить оценку:
> Я хорошо знаю этого человека и уверен, что он сияющий.
Эта отметка предполагает хорошее знакомство с человеком и уверенность в его качествах.
Она не обязательно означает дружбу.
---
## 21. Осторожная оценка сияния
Пользователь может поставить оценку:
> Я мало знаком с этим человеком, но видел его сияющим.
Эта отметка означает положительное впечатление без заявления о близком знакомстве.
Сильные и осторожные оценки сияния учитываются отдельно.
---
## 22. Подтверждение реального человека
Эта оценка означает:
> Я подтверждаю, что данный официальный аккаунт принадлежит реальному уникальному человеку.
Она предназначена для учёта голосов по принципу:
> Один реальный человек — один голос.
Один человек должен иметь только один официальный аккаунт, который может учитываться как его голос.
Подтверждение реального человека не означает, что пользователь является сияющим.
---
## 23. Подтверждение реального аккаунта организации или сообщества
Эта оценка означает:
> Я подтверждаю, что это настоящий официальный аккаунт данной организации, компании, проекта или сообщества.
Она подтверждает подлинность аккаунта организации или сообщества, но не даёт ему голос реального человека.
Такой аккаунт может быть настоящим и подтверждённым, но не учитывается в голосованиях по принципу «один человек — один голос».
---
## 24. Просмотр статистики пользователя
При открытии профиля можно посмотреть доступную статистику:
- количество лайков;
- количество сильных оценок сияния;
- количество осторожных оценок сияния;
- количество подтверждений реального человека;
- количество подтверждений реального аккаунта организации или сообщества;
- количество семейных связей;
- количество близких друзей;
- количество друзей;
- количество подтверждённых и неподтверждённых связей.
Также можно открыть социальный граф пользователя и посмотреть, кого он считает членом семьи, близким другом или другом.
---
## 25. Текущие ограничения статистики
В текущей реализации нет единого рейтинга пользователей и единого центра вторичной аналитики.
Сейчас доступны:
- просмотр профиля;
- просмотр социальных связей;
- включение и выключение видов связей;
- фильтр «Только сияющие»;
- просмотр основных количественных показателей;
- добавление собственных оценок.
Подробного аналитического центра, показывающего, кто именно и какие оценки ставил, пока нет.
---
## 26. Предупреждение пользователю об оценках
В интерфейсе рекомендуется показывать предупреждение:
> В настоящий момент сеть сохраняет ваши социальные связи и оценки, но ещё не формирует единый рейтинг пользователей. В дальнейшем открытые данные могут анализироваться различными независимыми системами. Поэтому рекомендуется указывать связи и ставить оценки честно и обдуманно.
Пользователь должен понимать, что его отметки в будущем могут учитываться при анализе:
- подлинности аккаунтов;
- уникальности голосов;
- достоверности социального графа;
- качества подтверждений;
- его собственной репутации.
---
## 27. Будущие аналитические системы
По мере развития сети могут появляться системы, анализирующие открытые данные социального графа.
Они смогут учитывать:
- кто поставил оценку;
- тип аккаунта оценивающего пользователя;
- официальный ли это аккаунт;
- какие у него социальные связи;
- насколько естественно выглядит его сеть;
- не участвует ли он во взаимной накрутке;
- насколько последовательно он действует со временем.
На основе этого могут рассчитываться:
- показатели доверия;
- показатели подлинности;
- вероятность того, что аккаунт принадлежит уникальному человеку;
- качество подтверждений;
- признаки искусственной накрутки.
---
## 28. Отсутствие единого центра аналитики
Поскольку данные находятся в открытом блокчейне, аналитику могут предоставлять разные независимые системы.
Одна система может учитывать взаимные связи и подтверждения родственников.
Другая — возраст аккаунта, историю действий и независимость групп пользователей.
Третья — использовать собственную модель доверия.
Таким образом, ни одна система не получает исключительного права определять, кто является настоящим, честным или заслуживающим доверия пользователем.
---
## 29. Честные и ложные отметки
На начальном этапе система не обязана автоматически проверять каждую связь и оценку.
Большинство настоящих пользователей заинтересованы указывать реальные данные, поскольку их действия связаны с публичной репутацией.
Если пользователь намеренно:
- указывает ложные родственные связи;
- подтверждает фиктивные аккаунты;
- создаёт несколько официальных аккаунтов;
- участвует во взаимной накрутке;
- ставит заведомо ложные оценки,
это может быть выявлено позднее аналитическими системами.
Системы проверки будут развиваться по мере появления реальных способов обмана.
---
## 30. Теория игр и публичная ответственность
Открытость и неизменяемость данных создают стимул действовать честно.
Пользователь понимает, что его отметки могут быть проанализированы в будущем.
Если он систематически подтверждает ложные аккаунты или участвует в накрутке, это может повлиять и на доверие к нему самому.
С точки зрения теории игр для большинства пользователей долгосрочная выгода от честного поведения выше, чем выгода от очевидной и легко обнаружимой лжи.
Неизменяемость данных не исключает обман полностью, но делает систематическую ложь более заметной и рискованной.
---
## 31. Изменение данных пользователя
Пользователь может изменять:
- тип аккаунта;
- официальный статус;
- статус сияющего;
- признаки сияния;
- отношение к духовному направлению;
- пол;
- социальные связи.
Например:
- друг может стать близким другом;
- близкий друг может стать обычным другом;
- связь может быть удалена;
- может быть добавлена новая семейная связь.
В интерфейсе отображается текущее состояние данных.
---
## 32. Текущая структура профиля
### Данные аккаунта
- имя или название;
- фотография или изображение;
- тип аккаунта;
- пол, если указан;
- официальный или неофициальный аккаунт;
- статус сияющего;
- выбранные признаки сияния.
### Социальные связи
- семья;
- близкие друзья;
- друзья;
- подтверждённые связи;
- неподтверждённые связи.
### Статистика
- количество лайков;
- количество сильных оценок сияния;
- количество осторожных оценок сияния;
- количество подтверждений реального человека;
- количество подтверждений реального аккаунта организации или сообщества;
- другие доступные показатели.
### Управление графом
- включение и выключение семьи;
- включение и выключение близких друзей;
- включение и выключение друзей;
- переключатель «Только сияющие»;
- переход к другому пользователю;
- сохранение выбранных настроек отображения.
---
## 33. Основные визуальные правила графа
- пользователь, чей профиль открыт, находится в центре;
- связанные пользователи располагаются вокруг него;
- семья, близкие друзья и друзья могут отображаться на разном расстоянии;
- неподтверждённая связь показывается пунктирной линией;
- подтверждённая связь показывается сплошной линией;
- тип связи определяется мнением пользователя, чей профиль открыт;
- сияющий пользователь выделяется кругом или свечением;
- отключённые виды связей временно не отображаются;
- настройки отображения сохраняются при переходе по графу.
---
## 34. Итоговые принципы
1. Каждый пользователь самостоятельно указывает данные о себе.
2. Каждый пользователь самостоятельно указывает свои связи с другими людьми.
3. Социальный граф показывает связи с точки зрения пользователя, чей профиль открыт.
4. Неподтверждённая связь отображается пунктирной линией.
5. Подтверждённая встречной связью связь отображается сплошной линией.
6. Для подтверждения дружбы достаточно встречной дружеской связи любого уровня.
7. Близкий друг автоматически является другом.
8. Семейные связи включают родителей, детей, братьев, сестёр, супругов и супруг.
9. Более дальние родственники отдельно не отображаются, но к ним можно переходить по графу.
10. Можно отдельно включать и выключать отображение семьи, близких друзей и друзей.
11. Можно включить режим отображения только сияющих пользователей.
12. Выбранные настройки сохраняются при перемещении по графу.
13. Сияющие пользователи визуально выделяются.
14. Лайк, две оценки сияния, подтверждение реального человека и подтверждение реального аккаунта организации или сообщества являются отдельными видами оценок.
15. Один человек может иметь только один официальный аккаунт.
16. Официальный аккаунт подтверждённого реального человека может учитываться как один голос.
17. Подтверждённый аккаунт организации или сообщества является настоящим аккаунтом, но не считается голосом человека.
18. В текущей реализации единого рейтинга нет; в будущем открытые данные смогут анализироваться разными независимыми системами.
19. Пользователям рекомендуется ставить честные оценки, поскольку их действия могут влиять на их собственную репутацию.
20. Системы проверки будут развиваться по мере появления реальных способов обмана и накрутки.