Compare commits

..

No commits in common. "89d06d317b6cdfee5071823ca98996906be1b3207c370e94db46d8d9d0d1f295" and "1b0e1cf1d4c2b9898dc1c47ebcdbf1ffc75e39fbb87ecdbc7def2d2595e30edf" have entirely different histories.

133 changed files with 9207 additions and 13732 deletions

4
.gitignore vendored
View File

@ -89,7 +89,3 @@ ESP32/**/*.uf2
ESP32/**/*.o
ESP32/**/*.d
ESP32/**/*.a
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
server-backup/archive/**
!server-backup/archive/.gitkeep

3
.idea/vcs.xml generated
View File

@ -3,6 +3,7 @@
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo" vcs="Git" />
<mapping directory="$PROJECT_DIR$/shine-solana" vcs="Git" />
<mapping directory="$PROJECT_DIR$/tools/understand-anything-lab/upstream" vcs="Git" />
</component>
</project>
</project>

View File

@ -8,15 +8,6 @@
## Примечание
- Если внешний инструмент/интеграция требует английский формат, допускается английский, но рядом желательно дать краткое пояснение на русском.
## Структура проекта (кратко)
- Серверный код SHiNE находится в папке `SHiNE-server/`.
- Код клиентского UI SHiNE находится в папке `shine-UI/`.
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
- точка входа `shine-UI/server-ui.html`;
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
## Сервис агента-кодера
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
@ -36,7 +27,7 @@
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
- `Dev_Docs/Solana_Architecture/README.md`
- Документ формата пользовательской PDA-записи `shine_users` находится в:
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
- `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md`
## Документация блокчейна
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.

View File

@ -1,11 +1,2 @@
@AGENTS.md
@AGENT_DEBUG_RUNBOOK.md
## Обязательно читать при работе с UI
@shine-UI/AGENTS.md
## Справка по подпроектам
- При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.md`.
- При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`.
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`.
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.

View File

@ -1,255 +0,0 @@
# DAO_запуск
Рабочий документ по тому, что ещё нужно сделать для первого запуска DAO-сценария SHiNE.
Логика документа:
- `этап1` — то, без чего нельзя считать сценарий первого запуска собранным даже в тестовом виде;
- `этап2` — то, что полезно и, вероятно, потребуется дальше, но это можно делать после старта `этап1` или параллельно без блокировки первого результата.
Базовая среда первого прохода:
- сеть `Solana devnet`;
- модель синхронизации: `server-to-server`;
- `Solana + Arweave` используются как якорь и архив;
- DAO понимается как стандартный governance/smart-contract контур, который управляет отдельными программами SHiNE, приносящими деньги.
## Краткий вывод
Для первого запуска DAO в тестовом виде текущего списка в целом хватает, но только если понимать запуск как:
- можно развернуть и проверить базовый DAO-контур;
- можно зарегистрировать пользователей и ключевые сущности;
- можно провести тестовую покупку билета через smart contract;
- можно завести тестовый денежный поток в программы, управляемые DAO;
- можно проверить опорную межсерверную синхронизацию и фиксацию состояния в архивный слой.
Если же под "запуском" понимать уже полностью устойчивую production-схему с ротацией ключей, восстановлением любого сервера из архива, железными устройствами подписи и полным циклом администрирования, то текущий список нужно будет ещё расширять.
## Этап1
Цель этапа: собрать минимально жизнеспособный DAO-сценарий в `devnet`, который можно пройти руками от регистрации до базовой экономики и проверки архитектуры.
### 1. Переписать и стабилизировать регистрацию пользователей без Anchor
Что сделать:
- довести `shine_users` в чистом Rust/Solana SDK до рабочего и проверенного состояния;
- убедиться, что `shine_login_guard` и связанный сценарий регистрации совместимы с новым ABI;
- проверить создание и чтение `user_pda`;
- проверить update пользовательской записи и связанные экономические параметры;
- синхронизировать сервер, UI и lazy-import с новым форматом и seed'ами.
Почему это в `этап1`:
- без стабильной пользовательской регистрации дальше нельзя строить ни DAO-сценарий, ни привязку устройств, ни платёжные сценарии.
### 2. Проверить полный сценарий регистрации и базовой Solana-интеграции
Что сделать:
- руками прогнать регистрацию нового пользователя;
- руками прогнать создание и update server PDA там, где это требуется текущему сценарию;
- убедиться, что сервер читает новые PDA без anchor-зависимостей и без старых discriminator'ов;
- зафиксировать, какие именно части сценария уже подтверждены руками, а какие ещё нет.
Почему это в `этап1`:
- сейчас в проекте уже есть признаки перехода на pure Rust, но без ручной проверки это нельзя считать завершённым.
### 3. Создать стандартный DAO smart contract / governance-контур
Что сделать:
- определить и реализовать стандартный DAO-контур, который будет управлять программами SHiNE;
- зафиксировать, какие права сразу передаются DAO, а какие временно остаются на отдельных ключах;
- подготовить тестовую DAO-структуру в `devnet`.
Минимум для первого запуска:
- DAO существует как управляемая сущность;
- DAO может владеть или контролировать ключевые права управления денежными программами;
- есть понятный путь, как DAO влияет на доходные программы SHiNE.
Почему это в `этап1`:
- без этого "DAO-запуск" будет только запуском отдельных Solana-программ, но не запуском управляемой DAO-системы.
### 4. Доработать смарт-контракт выплат с третьей очередью
Что сделать:
- добавить в `shine_payments` третью очередь, о которой уже принято решение;
- проверить совместимость с текущей моделью тикетов, выплат и DAO-управления;
- убедиться, что логика очередей соответствует ожидаемой экономике проекта.
Почему это в `этап1`:
- по текущей постановке это нужно именно для сценария регистрации DAO и дальнейшей экономики.
### 5. Сделать UI для покупки билетов и просмотра очереди
Что сделать:
- добавить UI-сценарий покупки билетов через smart contract;
- показать пользователю, сколько перед ним человек в очереди;
- убедиться, что UI отражает актуальное состояние контрактной логики, а не локальные предположения.
Почему это в `этап1`:
- покупка билетов у тебя обозначена как часть DAO-сценария, а не как побочная функция;
- без UI можно тестировать контракт вручную, но нельзя считать сценарий запуска достаточно собранным для нормальной проверки.
### 6. Реализовать базовую синхронизацию серверов
Что сделать:
- сделать обмен состоянием между серверами по модели `server-to-server`;
- определить минимальный набор данных, который обязан синхронизироваться;
- предусмотреть фиксацию синхронизированного состояния в `Arweave`, а `Solana` использовать как якорь и ссылочный слой;
- описать, какой сервер считается источником истины в спорных случаях или как решается конфликт.
Почему это в `этап1`:
- без межсерверной синхронизации трудно обосновать архитектуру сети как воспроизводимую и переносимую;
- это напрямую связано с идеей, что любой сможет поднять свой сервер.
### 7. Подготовить базовый сценарий архивирования и восстановления
Что сделать:
- описать и частично реализовать схему: серверы синхронизируются между собой, архив состояния уходит в `Arweave`, ссылка/якорь фиксируется через `Solana`;
- определить минимальный сценарий восстановления блоков или состояния из архивного слоя;
- подтвердить, что новый сервер может получить достаточно данных для старта.
Почему это в `этап1`:
- это один из ключевых признаков независимой и воспроизводимой DAO-инфраструктуры.
## Этап2
Цель этапа: усилить безопасность, автономность и удобство системы после того, как минимальный DAO-сценарий уже запустился и проверен в `devnet`.
### 1. Смена ключей цифровой подписи
Что сделать:
- продумать и реализовать смену `root key`, `device key`, `blockchain key`;
- описать ограничения, кто и в каком сценарии может менять каждый тип ключа;
- продумать, как не потерять доступ и как обновлять доверие к новым ключам.
Почему это в `этап2`:
- для production это очень важно;
- для первого тестового запуска можно временно использовать фиксированный набор ключей.
### 2. Полная повторная перепроверка всех сценариев
Что сделать:
- повторно прогнать регистрацию, DAO, выплаты, билеты, синхронизацию и архивирование после стабилизации `этап1`;
- оформить итоговый чек-лист ручной проверки;
- отдельно проверить пограничные сценарии и восстановление после ошибок.
Почему это в `этап2`:
- это обязательный шаг перед переходом от "собрали" к "доверяем".
### 3. Устройство на ESP32 как сабсервер с ключами
Что сделать:
- дописать прошивку, чтобы устройство могло выступать сабсервером с ключами;
- дать ему возможность регистрироваться и подключаться к серверу;
- определить, какие операции устройство подписывает и где хранит ключевой материал.
Почему это в `этап2`:
- это очень сильное развитие архитектуры, но оно не должно блокировать первый DAO-запуск.
### 4. Логин и подпись через коробочки / устройства
Что сделать:
- реализовать сценарий входа через устройство или хотя бы сценарий подписи сообщений и ключей через устройство;
- определить, как это встраивается в регистрацию DAO и подтверждение действий;
- проверить, можно ли через это безопасно регистрировать DAO или подписывать критичные команды.
Почему это в `этап2`:
- это следующий уровень безопасности и UX, но не минимальный блокер первого старта.
### 5. Создание тестового DAO с использованием устройств подписи
Что сделать:
- после готовности устройств собрать тестовый DAO-сценарий уже с аппаратным участием;
- проверить, где устройство достаточно, а где всё ещё нужен обычный кошелёк или управляющий ключ.
Почему это в `этап2`:
- это проверка усиленной модели, а не базового старта.
### 6. Расписание синхронизации серверов
Что сделать:
- определить периодичность и правила фоновой синхронизации;
- продумать ручной и автоматический режим;
- решить, как часто публиковать архивные снимки и якоря.
Почему это в `этап2`:
- сначала важнее добиться самой работающей синхронизации, а потом уже делать её регулярной и автономной.
### 7. Полное восстановление блоков из Solana/Arweave
Что сделать:
- довести процедуру восстановления до сценария "любой может поднять свой сервер";
- определить минимальный bootstrap-набор;
- проверить восстановление на чистом окружении.
Почему это в `этап2`:
- для концепции сети это критично, но как полноценная задача обычно идёт после появления базового архива и первичной синхронизации.
## Что блокирует первый запуск сильнее всего
Если расставить приоритет внутри `этап1`, то самый жёсткий порядок сейчас выглядит так:
1. pure Rust регистрация пользователей и ручная проверка сценария;
2. DAO/gov-контур и его права управления;
3. доработка выплат с третьей очередью;
4. покупка билетов через smart contract и UI-проверка очереди;
5. межсерверная синхронизация;
6. архивирование в `Arweave` с якорем в `Solana`;
7. минимальное восстановление состояния новым сервером.
## Что уже частично похоже на готовое
По текущим документам и следам в проекте уже видно, что:
- переход `shine_users` и `shine_login_guard` на pure Rust уже начат и в значительной степени сделан;
- архитектура DAO, `shine_users` и `shine_payments` уже описана;
- часть Solana-структуры и PDA-форматов уже формализована;
- тема ESP32 уже отдельно присутствует в проекте как направление.
Это хорошо, потому что документ получается не "с нуля", а как сборка того, что уже назрело в коде и планах.
## Вопросы, которые всё ещё стоит уточнить
1. Какой именно стандарт DAO планируется использовать в первом проходе: готовый governance-стек Solana или собственная минимальная обвязка вокруг управляющих кошельков?
2. Третья очередь в `shine_payments` уже точно определена по смыслу, или пока есть только решение "она нужна", но без финальной экономики?
3. Что именно считается единицей синхронизации между серверами: блоки SHiNE, агрегированные снапшоты, PDA-состояния, или смесь этих вариантов?
4. Нужен ли для `этап1` уже полноценный автоматический recovery нового сервера, или достаточно доказать это в полу-ручном сценарии?
5. Покупка билетов должна в первом проходе работать только через web/UI, или также нужен отдельный сценарий из серверного UI или скриптов?
## Рекомендуемый следующий практический шаг
Если идти без распыления, то следующим рабочим фокусом стоит считать:
1. закрыть ручную проверку pure Rust регистрации;
2. после этого формализовать минимальный DAO-контур;
3. затем переходить к третьей очереди выплат и к UI покупки билетов;
4. после этого делать синхронизацию, архив и восстановление.

View File

@ -1,100 +0,0 @@
# Синхронизация блоков и 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 Межсерверное соединение
- Серверы устанавливают постоянное WebSocket-соединение друг с другом.
- Адрес партнёра определяется по `server_address` из его Solana PDA.
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA).
- При разрыве — переподключение с экспоненциальным backoff.
### 4.2 Доставка новых данных (push)
- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам.
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
### 4.3 Начальная синхронизация (backfill)
- При первом подключении к партнёру серверы обмениваются «курсорами» состояния:
последний глобальный номер блока, последний известный DM-ключ.
- Сервер с более полной историей досылает недостающее партнёру.
### 4.4 Разрешение конфликтов
- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока.
Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`).
- DM: конфликтов нет, `message_key` уникален.
## 5. Маршрутизация 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) сохраняет и доставляет блоки.
Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения).
## 6. Безопасность
- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа).
- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
## 7. Статус реализации
| Компонент | Статус |
|-----------|--------|
| Регистрация серверной PDA в Solana | ✅ Реализовано |
| Чтение `sync_servers` из PDA | Нужна реализация |
| Межсерверный WebSocket-канал | Нужна реализация |
| Push новых DM партнёрам | Нужна реализация |
| Push блоков блокчейна партнёрам | Нужна реализация |
| Backfill при первом подключении | Нужна реализация |
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
Текущая версия сервера работает без межсерверной синхронизации.
Синхронизация — задача следующего этапа разработки.

View File

@ -36,12 +36,6 @@
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
- `medium/2026-06-03_подключениеругих_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
- `medium/2026-06-02_сессионные_саб_серверы_в_pda.md` - несколько саб-серверов пользователя как типизированные сессии в PDA с версией записи.
### DAO-запуск
- `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона.
### Дальнее будущее

View File

@ -1,64 +0,0 @@
# ESP32 как аппаратный кошелёк (device-сессия)
## Суть фичи
ESP32 становится аппаратным HSM (hardware security module): хранит ключи, постоянно подключён к SHiNE-серверу как device-сессия, подтверждает операции нажатием на экране. Другие устройства (браузер, телефон) взаимодействуют с ESP32 через сервер — без прямого соединения.
## Два ключевых сценария
### Сценарий 1 — Создание делегированной сессии
1. Браузер/телефон → сервер: «хочу делегированную сессию от имени пользователя X»
2. Сервер → ESP32 (device-сессия): «запрос на одобрение»
3. Пользователь нажимает «Да» на сенсорном экране ESP32
4. ESP32 → сервер: одобрено → сервер создаёт делегированную сессию для браузера
### Сценарий 2 — Подпись транзакции / блока
1. Браузер (через делегированную сессию) → сервер → ESP32: «подпиши вот это»
2. ESP32 показывает запрос на экране, пользователь подтверждает
3. ESP32 подписывает нужным ключом → ответ через сервер → браузер
## Что нужно сделать
### ESP32 (основная работа)
- [ ] Инициализация WiFi (SSID/пароль в NVS)
- [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером
- [ ] Авторизация на сервере: `AuthChallenge``CreateAuthSession` через `deviceKey` (уже есть в NVS), сохранить `sessionId` в NVS
- [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу
- [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет
- [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх)
- [ ] Переподключение при разрыве (reconnect loop)
### Сервер (минимальные изменения)
- [ ] Добавить поле `sessionType` (`USER` / `DEVICE`) в таблицу `active_sessions`
- [ ] Новая операция `DeviceApprovalRequest` — браузер запрашивает одобрение у device-сессии
- [ ] Новая операция `DeviceApprovalResponse` — ESP32 отвечает (одобрено/отклонено)
- [ ] Новые операции `SignRequest` / `SignResponse` — запрос подписи и ответ
- [ ] Роутинг: при получении запроса найти device-сессию через `ActiveConnectionsRegistry.getByLogin(login)` + фильтр по `sessionType=DEVICE`, переслать туда
### Клиент (отдельный этап)
- [ ] Браузерное расширение или UI: создание делегированной сессии, отправка `SignRequest`
## Что уже готово (переиспользуем)
- **Роутинг сообщений**`SendDirectMessage` с `TARGET_ONE_SESSION` и `CallSignalToSession` уже умеют точечно доставлять в конкретный `sessionId`. Механизм готов, нужно добавить только новые op-коды поверх него.
- **Ed25519 на ESP32** — библиотека `<Ed25519.h>` уже используется в скетче. Подписи работают.
- **NVS** — уже хранит логин, мастер-секрет, 3 пары ключей. Расширяется легко.
- **`ActiveConnectionsRegistry`** — поиск по `login` и `sessionId` уже есть на сервере.
- **Аутентификация** — схема `AuthChallenge``CreateAuthSession` через Ed25519 уже полностью реализована.
## Оценка сложности
| Компонент | Сложность |
|---|---|
| ESP32: WiFi + WebSocket-клиент + авторизация | Средняя |
| ESP32: обработчик входящих + UI подтверждений | Средняя |
| Сервер: флаг sessionType + 4 новых op-а + роутинг | Низкая–средняя |
| Браузерное расширение | Высокая (отдельный этап) |
**Итого фазы ESP32 + сервер: ~11.5 недели.**
## С чего начинать
1. Серверная часть проще и быстрее — начать с добавления `sessionType` и `DeviceApprovalRequest/Response`.
2. Затем ESP32: WiFi → WebSocket → авторизация → обработчик входящих → UI.
3. Браузерное расширение — отдельная итерация после того как ESP32 + сервер работают.

View File

@ -1,105 +0,0 @@
# Сессионные саб-серверы в PDA пользователя
- Статус:
`future`
- Горизонт:
`medium`
- Ориентир:
после завершения первого этапа по пользовательским сессиям
- Основание:
Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних саб-серверов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли.
## Зачем нужна фича
У одного пользователя может быть несколько доверенных внутренних саб-серверов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели.
Это нужно, чтобы:
- хранить несколько саб-серверов у одного пользователя одновременно;
- различать обычные клиентские сессии и серверные сессии по явному типу;
- дать расширяемый формат записи с версией;
- использовать единый подход для DM, звонков и внутренних команд между сессиями.
## Целевая идея
В пользовательском PDA должен появиться список записей сессий, где каждая запись содержит как минимум:
- `sessionType` (`u8`);
- `sessionVersion` (`u8`);
- `sessionName`;
- `sessionPubKey`.
Предварительные значения:
- тип `1` - обычная пользовательская сессия;
- тип `100` - саб-сервер пользователя;
- версия `1` - первая рабочая версия формата записи сессии.
На текущем этапе под это уже зарезервирован отдельный блок `SessionsBlock` с `block_type = 55`, а `TrustedStateBlock` остаётся на `50`.
Важно: саб-серверов у одного пользователя может быть несколько.
## Архитектурный принцип
Внутренний протокол взаимодействия должен оставаться транспортным.
То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки саб-сервера, а должен:
- доставлять сообщения между сессиями;
- доставлять сигналы звонков между сессиями;
- хранить и маршрутизировать адресацию;
- не принимать на себя бизнес-логику содержимого внутренних команд.
## Что уже подтверждается текущим кодом
- Личные сообщения уже доставляются по всем сессиям целевого пользователя с отдельным учётом доставки на каждую сессию.
- Подтверждение доставки DM уже идёт отдельно по каждой сессии.
- Вызов звонка уже рассылается по нескольким активным сессиям пользователя.
- Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя.
Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол саб-сервера".
## Что нужно сделать при возврате к задаче
1. Согласовать финальный бинарный формат записи сессии в PDA пользователя.
2. Проверить, не меняет ли это уже опубликованный формат пользовательской PDA-записи.
3. Если формат PDA меняется, заранее предупредить пользователя и получить отдельное подтверждение.
4. Решить, где именно хранится массив сессий:
- в основной записи пользователя;
- в отдельной PDA-структуре расширения;
- или в смешанной схеме с базовой записью и внешними индексами.
5. Зафиксировать ограничения:
- максимальное число сессий;
- максимальную длину `sessionName`;
- правила удаления и обновления записи;
- правила ротации `sessionPubKey`.
6. Продумать, как UI и сервер будут отличать тип `1` и тип `100`.
7. Определить, какие внутренние сообщения саб-сервера останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации.
8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов.
9. После реализации обязательно обновить документацию.
## Что нужно обновить при реализации
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
- `Dev_Docs/Solana_Architecture/README.md`
- `Dev_Docs/Инициализация_Solana_регистрации/README.md`
- `Dev_Docs/Keys/README.md`
- `Dev_Docs/Personal_Messages/README.md`, если изменится адресация DM по типам сессий
- `Dev_Docs/API/`, если появятся новые серверные операции или изменятся ответы
## Что пока не делать
- Не включать это автоматически в основной deploy сервера.
- Не менять сейчас Solana PDA-формат без отдельного подтверждения.
- Не добавлять временные поля в публичный API "на всякий случай".
## С какого места продолжать
Продолжать после завершения первой части:
1. описать минимальный формат записи пользовательской сессии;
2. отдельно решить, живут ли саб-серверы в том же списке, что и обычные сессии;
3. затем уже проектировать операции регистрации, обновления и отключения таких сессий.

View File

@ -1,45 +0,0 @@
# Подключение других устройств через QR
- Горизонт:
`medium`
- Ориентир:
позже, не сейчас
- Статус:
`future`
## Зачем нужна фича
Нужно нормально довести подключение другого устройства через QR-код. Сейчас есть полуготовая заготовка, но сценарий работает нестабильно и требует отдельной доработки.
## Что уже есть
- В UI уже есть экраны:
- `shine-UI/js/pages/connect-device-view.js`
- `shine-UI/js/pages/device-qr-view.js`
- Есть сервис переноса ключей через QR:
- `shine-UI/js/services/qr-key-transfer-service.js`
- Логика частично собрана, но её нельзя считать завершённой или надёжной.
## Что нужно будет сделать потом
1. Проверить и довести формат QR-передачи.
2. Проверить сканирование и ручной ввод QR-текста.
3. Проверить перенос `device`, `blockchain`, `root` ключей только по реальному наличию на исходном устройстве.
4. Проверить, что после переноса очищается старая история нужного логина и не ломается вход.
5. Отдельно проверить сценарий без `BarcodeDetector`.
6. Довести экран подтверждения на втором устройстве.
## Что сейчас важно
- Не считать эту часть готовой.
- Не возвращать её в активную разработку без отдельной команды пользователя.
- Если вернёмся к задаче, сначала нужно понять, что именно уже работает, а что нет, и потом починить целиком.
## Что обновить при возврате
- `Dev_Docs/Pending_Features/README.md`
- `shine-UI/js/pages/connect-device-view.js`
- `shine-UI/js/pages/device-qr-view.js`
- `shine-UI/js/services/qr-key-transfer-service.js`
- документацию по ключам, если формат переноса меняется

View File

@ -1,24 +0,0 @@
# Центр задач Telegram-агента
## Краткое описание
Добавлена первая версия центра задач и предложений внутри `SHiNE-agent-bot-coder`.
Бот хранит задачи и предложения в JSON-файле данных сервиса, умеет показывать список через `/tasks`, создавать задачи для игроков по фразе Айдара, принимать предложения игроков по префиксу `предложение:`, менять статусы и добавлять короткие напоминания после ответов.
## Что проверять
- Айдар пишет `/tasks` и видит текущий список задач и предложений без уже закрытого предложения от Димы.
- Айдар пишет `поставь задачу Милане: проверить описание SHiNE` и задача появляется в списке Миланы.
- Милана пишет `/tasks` и видит назначенную задачу.
- Игрок пишет `предложение: ...`, после чего предложение появляется у Айдара.
- Айдар меняет статус фразами вида `одобрить TC-XXXX`, `доработать TC-XXXX`, `закрыть TC-XXXX`, где `TC-XXXX` - ID существующей задачи или предложения.
- После обычного ответа бота Айдару или игроку появляется короткое напоминание, если у пользователя есть активные задачи.
## Ожидаемый результат
Задачи и предложения сохраняются между перезапусками сервиса, статусы меняются корректно, напоминания не мешают основному ответу Codex.
## Статус
pending

View File

@ -1,31 +0,0 @@
# Рестарты и voice-настройки Telegram-агента
## Краткое описание
Добавлена первая версия безопасного рестарта Telegram-агента:
- `/restart` и `/restart_service` ставят отложенный рестарт после текущей задачи и до взятия следующей;
- `/restart_hard`, `/restart_now`, `/restart_force` выполняют жёсткий рестарт сразу;
- команды рестарта доступны только Айдару;
- voice-ответы включены по умолчанию для новых пользователей;
- адаптация текста перед озвучкой стала ближе к исходному ответу и не должна менять смысл;
- скрыты отдельные команды статуса voice-функций из справки, состояние показывается через `/status`.
## Что проверить
1. Отправить `/restart` во время активной задачи игрока или Айдара.
2. Убедиться, что активная задача завершается, после чего сервис перезапускается до следующей задачи.
3. Отправить `/restart_hard` и убедиться, что сервис перезапускается сразу.
4. Проверить, что игрок не может выполнить команды рестарта.
5. Проверить `/status`: он показывает очередь и состояния голосовых функций.
6. Проверить нового пользователя: voice-ответы должны быть включены по умолчанию.
7. Проверить текстовый запрос пользователя с включённым voice: после текстового ответа должен прийти voice-файл.
8. Проверить, что адаптированная озвучка не превращается в другой ответ, а только убирает длинные технические строки.
## Ожидаемый результат
Сервис можно обновлять без потери текущей задачи через отложенный рестарт. Жёсткий рестарт остаётся аварийной командой Айдара. Voice-ответы работают для текстовых и голосовых запросов, а голосовая версия остаётся близкой к текстовой.
## Статус
pending

View File

@ -1,26 +0,0 @@
# Кнопки вкладки «Каналы»
## Что сделано
Доработана верхняя панель вкладки «Каналы»:
- при открытии нижней кнопкой «Каналы» показывается режим «Все каналы»;
- в режиме «Все каналы» справа доступны кнопка «Мои каналы» и иконка поиска канала;
- в режиме «Мои каналы» доступен переход обратно во «Все каналы», а справа показывается плюсик создания канала.
## Что проверить
1. Открыть вкладку «Каналы» через нижнюю навигацию.
2. Убедиться, что открыт режим «Все каналы», а плюсик создания канала не отображается.
3. Нажать иконку поиска в режиме «Все каналы».
4. Убедиться, что открывается текущий сценарий поиска каналов.
5. Нажать «Мои каналы».
6. Убедиться, что справа появился плюсик создания канала.
7. Нажать «Все каналы» или стрелку назад и проверить возврат к режиму «Все каналы».
## Ожидаемый результат
Кнопки верхней панели соответствуют активному режиму: поиск в «Все каналы», создание только в «Мои каналы».
## Статус
pending

View File

@ -1,13 +0,0 @@
# Длинные voice/audio в Telegram-боте агента
- краткое описание фичи:
Бот теперь умеет обрабатывать длинные voice/audio аккуратнее: учитывает лимит Telegram Bot API на скачивание слишком больших файлов, поддерживает альтернативный `TELEGRAM_API_BASE_URL` для локального `telegram-bot-api`, локально пережимает длинное аудио через `ffmpeg`, режет на куски и отправляет их в OpenAI transcription последовательно.
- что именно проверять:
1. Короткий `voice` по-прежнему распознаётся без заметной задержки.
2. Длинный `audio/voice`, который помещается в скачивание Telegram, успешно пережимается, режется на части и даёт цельную расшифровку.
3. Очень большой файл через обычный `https://api.telegram.org` даёт понятное сообщение про лимит Telegram.
4. После переключения на локальный `telegram-bot-api` такой же большой файл начинает скачиваться и распознаваться.
- ожидаемый результат:
Бот не падает на длинных аудио, даёт либо расшифровку, либо понятное объяснение, какой именно лимит мешает и что нужно включить.
- статус:
pending

View File

@ -1,14 +0,0 @@
# Диагностика больших voice/audio в Telegram-боте
- краткое описание фичи:
- Бот при большом voice/audio больше не отказывается заранее по метаданным Telegram. Теперь он сначала сообщает, что пробует скачать файл, затем отдельно сообщает об успешном скачивании и только после этого переходит к подготовке аудио и распознаванию через OpenAI.
- что именно проверять:
- Отправить в бота большой `voice` или `audio`, который раньше попадал под ранний отказ.
- Проверить, что сначала приходит сообщение о попытке скачать большой файл.
- Проверить два сценария:
- скачивание удалось: бот пишет об успешной загрузке и продолжает распознавание;
- скачивание не удалось: бот пишет именно о неудачном скачивании из Telegram, без ложной привязки к ошибке OpenAI.
- ожидаемый результат:
- Пользователь видит понятную поэтапную диагностику: попытка скачивания, результат скачивания и только потом следующий этап обработки.
- статус:
- pending

View File

@ -1,24 +0,0 @@
# Перенос server UI в shine-UI
- краткое описание фичи:
Веб-панель управления серверной Solana PDA перенесена в `shine-UI/` как отдельные страницы.
Новая точка входа: `shine-UI/server-ui.html`.
Общая логика работы с PDA вынесена в единый модуль `shine-UI/js/services/shine-user-pda-service.js`.
- что именно проверять:
1. Открытие `shine-UI/server-ui.html` и переходы на страницы создания и обновления PDA.
2. Генерацию ключей из логина и пароля на странице создания.
3. Ручной ввод base58-ключей и регистрацию серверного PDA.
4. Загрузку существующей серверной PDA на странице обновления.
5. Обновление `server_address` и `sync_servers` только по `root + device` без blockchain-ключа.
6. Корректное чтение нового формата `ServerProfileBlock` через общий PDA-модуль.
7. То, что актуальной точкой входа остаётся `shine-UI/server-ui.html`.
- ожидаемый результат:
1. Новые страницы открываются без JS-ошибок.
2. Создание серверной PDA проходит через общий модуль и пишет актуальный формат.
3. Обновление серверной PDA переиспользует существующую подпись LastBlockState и не требует blockchain-ключ.
4. Клиентский UI не ломается после перевода общего PDA-слоя на новый формат.
- статус:
pending

View File

@ -1,20 +0,0 @@
# Кнопка настройки сервера и DEVNET topup
- краткое описание фичи:
На экране `entry-settings-view` добавлена кнопка `Настроить свой сервер`, открывающая `server-ui.html` в новой вкладке.
На страницах серверного UI добавлена кнопка открытия `devnet-topup-view` в новой вкладке с автоматической передачей `wallet` из device-адреса.
- что именно проверять:
1. На странице настроек входа есть кнопка `Настроить свой сервер`.
2. Кнопка открывает `shine-UI/server-ui.html` в новой вкладке.
3. На страницах `create-server-pda.html` и `update-server-pda.html` есть кнопка `Открыть пополнение DEVNET`.
4. Если device public key заполнен, новая вкладка открывает `devnet-topup-view?wallet=...` с правильным адресом.
5. Если device-адрес не введён, серверный UI показывает понятную ошибку и не открывает пустую ссылку.
- ожидаемый результат:
1. Переход в серверный UI с клиентской страницы настроек работает.
2. Пополнение devnet из серверного UI открывается сразу на нужный адрес.
3. Основной клиентский UI и серверные страницы не получают JS-ошибок при загрузке.
- статус:
pending

View File

@ -1,15 +0,0 @@
# Фикс DEVNET topup и автоподстановки пароля
- статус: pending
- кратко: исправлена ширина экрана `devnet-topup-view` после успешного пополнения и отключена нежелательная автоподстановка пароля в server UI и на экранах входа/регистрации.
## Что проверять
- Открыть страницу пополнения DEVNET, выполнить пополнение и убедиться, что после появления `Signature` экран не расширяется по ширине.
- Проверить, что кнопки на странице пополнения остаются аккуратными и не разъезжаются.
- Открыть `server-ui/update-server-pda.html`, загрузить PDA и убедиться, что поле пароля остаётся пустым.
- Проверить обычные экраны входа и регистрации: поле пароля не должно самопроизвольно заполняться длинной строкой.
## Ожидаемый результат
- Длинная transaction signature переносится по строкам внутри прежней ширины экрана.
- Кнопки сохраняют компактный mobile-first layout.
- Поля пароля пустые, пока пользователь сам ничего не вводил.

View File

@ -1,17 +0,0 @@
# Диагностика ключей server PDA и баланс device
- статус: pending
- кратко: на странице обновления server PDA добавлена сверка ожидаемых ключей с уже загруженной PDA, предупреждение о неверном пароле, кнопка показа баланса device-аккаунта и уточнение, что create/update оплачиваются с deviceKey.
## Что проверять
- На `update-server-pda.html` загрузить существующую PDA и убедиться, что видны ожидаемые `root/blockchain/device` public key.
- Ввести правильный пароль и нажать `Сгенерировать`: должно появиться сообщение, что ключи совпадают.
- Ввести неверный пароль и нажать `Сгенерировать`: должно появиться сообщение, что ключи не совпали и пароль, вероятно, неверный.
- На `create-server-pda.html` и `update-server-pda.html` нажать `Показать / обновить баланс device` и убедиться, что баланс читается по текущему `devPub`.
- Повторить `update_user_pda` после увеличения `heap frame` и проверить, ушла ли ошибка `memory allocation failed`.
## Ожидаемый результат
- Пользователь видит, какие именно public key должны получиться для загруженной PDA.
- Ошибка неправильного пароля выявляется до отправки транзакции.
- Баланс device-кошелька читается прямо со страницы.
- Если проблема `OOM` была только в размере heap frame/compute budget клиента, `update_user_pda` начинает проходить.

View File

@ -1,15 +0,0 @@
# Lazy-import Solana PDA: актуальный формат
- Краткое описание:
Серверный Java lazy-import пользователя из `shine_users` обновлён под актуальный формат `user_pda`. Убран RPC-фильтр по размеру PDA, добавлен разбор нового `ServerProfileBlock` (`block_type = 30`) без сохранения server-only полей в `solana_users`.
- Что проверять:
1. Взять логин пользователя, который существует в Solana PDA, но отсутствует в локальной таблице `solana_users`.
2. Выполнить вход этим логином через сервер.
3. Убедиться, что lazy-import подтянул пользователя из Solana.
4. Убедиться, что запись в `solana_users` создана с полями `login`, `blockchain_name`, `solana_key`, `blockchain_key`, `device_key`.
5. Убедиться, что отсутствие/наличие server-полей в PDA не ломает импорт.
- Ожидаемый результат:
1. Пользователь успешно находится и импортируется из Solana PDA независимо от фактического размера PDA.
2. Новый `ServerProfileBlock` не ломает парсер.
3. В БД не появляются лишние server-only поля.
- Статус: `pending`

View File

@ -1,33 +0,0 @@
# Pure Rust `shine_users` и `shine_login_guard`
Статус: `pending`
## Что сделано
- `shine_login_guard` переписан без Anchor на чистый Rust/Solana SDK.
- `shine_users` переписан без Anchor на чистый Rust/Solana SDK.
- Для `shine_users` введён новый instruction ABI без Anchor discriminator'ов.
- Для `shine_users` используются новые seed'ы:
- `user_login=` для `user_pda`
- `shine_users_economy_config` для economy PDA
- Формат блоков PDA синхронизирован:
- `SessionsBlock = 50`
- `TrustedStateBlock = 70`
- UI JS-модуль и Java lazy-import обновлены под новые seeds/ABI/коды блоков.
## Что проверить руками
1. В обычном UI выполнить регистрацию нового пользователя в Solana.
2. Проверить, что после регистрации читается новая `user_pda`.
3. В server UI выполнить создание server PDA.
4. В server UI выполнить update server PDA.
5. Проверить, что после update растёт `record_number`.
6. Проверить, что lazy-import на сервере читает новый формат PDA без ошибок.
7. Проверить, что старые Anchor discriminator'ы больше нигде не требуются.
## Ожидаемый результат
- Регистрация и update работают на новых чисто-rust программах.
- UI не использует старый Anchor ABI.
- Серверный Java parser читает новый формат PDA.
- Ошибок `out of memory` и anchor-specific падений больше нет.

View File

@ -1,25 +0,0 @@
# ESP32 Argon2/UI совместимость и экран результата
- краткое описание фичи:
выравнивание derivation на `ESP32` с текущим `UI` по нормализации логина, совместимости `master secret`/`root.key`/`bch.key`/`dev.key`, а также правки экрана результата и progress bar.
- что именно проверять:
1. На `UI` и `ESP32` ввести один и тот же логин в разном регистре, например `Anya24`, и один и тот же непустой пароль.
2. Убедиться, что после нормализации логина на `ESP32` и `UI` получаются одинаковые:
`master secret`, `root`, `blockchain`, `device` в `Base58`.
3. Проверить режим пустого пароля:
`UI` и `ESP32` должны выдать одинаковые ключи в legacy-режиме.
4. Проверить, что пустой логин на `ESP32` не запускает расчёт и показывает сообщение об ошибке.
5. Проверить progress bar:
при непустом пароле полоса должна быть видна и двигаться.
6. Проверить экран результата:
сначала `Login`, затем `Password`, затем `Master secret` и ключи;
свайп вверх/вниз должен прокручивать длинный результат без артефактов.
- ожидаемый результат:
`ESP32` и `UI` считают одинаковый `master secret` и одинаковые ключи для одинаковых входных данных;
progress bar виден;
экран результата читаемый и корректно прокручивается.
- статус:
pending

View File

@ -1,17 +0,0 @@
## Краткое описание
В `SHiNE-agent-bot-coder` для личных чатов добавлен режим одного редактируемого статусного сообщения. Бот принимает запрос, обновляет это сообщение по этапам обработки и в конце превращает его в финальный текстовый ответ. При длинном ответе допускается ещё одно дополнительное текстовое сообщение с продолжением. Голосовой ответ остаётся отдельным сообщением.
## Что проверять
1. Отправить в личный чат короткий текстовый запрос и убедиться, что бот не шлёт цепочку промежуточных сообщений, а редактирует одно сообщение до финального ответа.
2. Отправить в личный чат `voice` или `audio` и убедиться, что в том же сообщении последовательно видны этапы распознавания и выполнения.
3. Проверить длинный ответ, который не помещается в один Telegram message: должно получиться не больше двух текстовых сообщений.
4. Проверить, что `voice`-ответ приходит отдельным новым сообщением после текстового.
5. Проверить, что в `@shine_writing` по-прежнему логируются только итоговые `вопрос -> ответ`, без промежуточных статусов.
## Ожидаемый результат
- В личке основная переписка стала чище: промежуточные этапы живут в одном редактируемом сообщении.
- При длинном ответе бот не разбрасывает ответ на много сообщений.
- Канал `@shine_writing` работает по старой схеме без лишнего шума.
## Статус
`pending`

View File

@ -1,24 +0,0 @@
## Краткое описание
В локальный Telegram-бот `SHiNE-agent-bot-coder` добавлена команда `/settings`, которая сразу показывает текущие персональные настройки пользователя и список доступных команд для их изменения. В `/help` оставлена только ссылка на `/settings` без перечисления самих команд настроек. Также добавлен переключатель режима ответа в личке: один редактируемый статус или отдельные сообщения по этапам.
## Что проверять
1. Отправить `/help` и убедиться, что в справке есть `/settings`, но нет списка команд `/voice_*` и `/single_message_*`.
2. Отправить `/settings` и проверить, что бот показывает текущие значения:
- озвучивание финальных ответов;
- адаптацию текста перед озвучкой;
- режим одного редактируемого сообщения в личке.
3. По очереди переключить:
- `/voice_on` и `/voice_off`;
- `/voice_rewrite_on` и `/voice_rewrite_off`;
- `/single_message_on` и `/single_message_off`.
4. После каждого переключения снова вызвать `/settings` и убедиться, что статус изменился и сохранился.
5. При `/single_message_on` отправить обычный запрос в личку и проверить, что бот ведёт его через одно редактируемое сообщение.
6. При `/single_message_off` отправить обычный запрос в личку и проверить, что бот снова шлёт отдельные сообщения по этапам и отдельный финальный ответ.
## Ожидаемый результат
- `/settings` стал основной точкой входа для пользовательских настроек.
- `/help` стал короче и не дублирует список команд настроек.
- Режим ответа в личке реально переключается персонально для пользователя и сохраняется после перезапуска сервиса.
## Статус
`pending`

View File

@ -0,0 +1,23 @@
# Solana user_pda v2
## Краткое описание
Функции `create_user_pda` и `update_user_pda` в Solana-модуле переведены на блочный формат пользовательской PDA-записи `format_major = 2`.
## Что проверять
- Создание `user_pda` через `create_user_pda`.
- Обновление `user_pda` через `update_user_pda`.
- Проверку root-подписи записи.
- Проверку подписи `LastBlockState` ключом `blockchain_public_key`.
- Корректную запись блоков `RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `TrustedState`.
- Рост `paid_limit_bytes`, `used_bytes` и `last_block_number` без возможности уменьшения.
- Совместимость тестового клиента с актуальной IDL после `anchor build`.
## Ожидаемый результат
Пользовательская PDA создается и обновляется в формате `format_major = 2`, содержит один основной блокчейн `blockchain_type = 1` с именем `<login>-001`, а неверные подписи или попытки уменьшить счетчики отклоняются программой.
## Статус
pending

View File

@ -0,0 +1,26 @@
# Solana: init регистрации + деплой обязательных программ
- дата: 2026-05-24 20:35 (Europe/Moscow)
- статус: `pending`
## Кратко
Добавлена dev-страница в UI для вызова `init_users_economy_config` программы `shine_users` через подключённый кошелёк Phantom.
Задеплоены и зафиксированы адреса двух обязательных программ регистрации: `shine_users` и `shine_login_guard`.
## Что проверять вручную
1. Открыть UI и перейти в `Настройки разработчика`.
2. Нажать `Solana: init регистрации`.
3. Подключить Phantom devnet-кошелёк.
4. Выполнить `init_users_economy_config`.
5. Проверить отображение статуса и хэша транзакции.
6. Повторно нажать init и убедиться, что корректно показывается "уже инициализировано".
7. Выполнить тестовую регистрацию пользователя и убедиться, что CPI-вызов `shine_login_guard` не падает.
## Ожидаемый результат
- Первая транзакция выполняется успешно (если PDA ещё не создан).
- Вторая попытка возвращает ожидаемую ошибку о повторной инициализации.
- UI не падает, статус понятный, Program ID отображается корректно.
- Регистрация пользователя проходит с подключённым `shine_login_guard`.

View File

@ -0,0 +1,26 @@
# Отчёт private-запросов агента в группу
## Что сделано
После успешной обработки задачи из личного чата Айдара Telegram-бот агента отправляет итоговую копию в группу `@shine_writing`:
- первым сообщением исходный запрос;
- вторым сообщением, reply на первое, финальный ответ Codex.
Промежуточные статусы выполнения в группу не дублируются.
## Что проверять
1. Отправить боту личный текстовый запрос.
2. Дождаться полного ответа в личном чате.
3. Проверить, что в `@shine_writing` появилось сообщение с запросом.
4. Проверить, что итоговый ответ опубликован reply на это сообщение.
5. Отправить личный voice-запрос и проверить, что в отчёте есть распознанный текст.
## Ожидаемый результат
Личный чат работает как раньше, а группа получает только итоговую пару сообщений по завершённой задаче.
## Статус
pending

View File

@ -0,0 +1,23 @@
# Отчёт voice/audio-запросов с исходным файлом
## Краткое описание
Публичный отчёт по приватным voice/audio-запросам агента должен отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. `file_id` не должен показываться пользователям в тексте отчёта.
## Что проверить
1. Отправить боту приватный voice-запрос от Айдара.
2. Дождаться обработки Codex.
3. Проверить группу/канал публичных отчётов.
4. Повторить сценарий для audio-файла, если он используется.
## Ожидаемый результат
- В публичном отчёте появляется исходное голосовое/audio-сообщение.
- В подписи к нему есть распознанный текст.
- В отчёте нет строки `Голосовой file_id` и самого `file_id`.
- Итоговый ответ Codex отправляется ответом на сообщение с исходным файлом.
## Статус
pending

View File

@ -0,0 +1,19 @@
# Улучшенная обработка длинных voice/audio
## Что сделано
- Увеличены и вынесены в `.env` тайм-ауты скачивания Telegram-файла и OpenAI-распознавания.
- Добавлены подробные логи стадий распознавания: старт, скачивание, размер файла, завершение, причина ошибки.
- Ошибки voice/audio теперь показываются пользователю как ошибка распознавания с понятной причиной.
## Как проверять
- Отправить короткое голосовое сообщение и убедиться, что оно распознаётся и передаётся в Codex.
- Отправить длинное голосовое сообщение и убедиться, что сервис не падает на прежнем коротком тайм-ауте.
- Смоделировать ошибку распознавания или временно указать неверный OpenAI-ключ и проверить текст ошибки в Telegram.
## Ожидаемый результат
- При успешном распознавании пользователь видит распознанный текст и задача уходит в Codex.
- При ошибке пользователь видит, что не удалось именно распознать voice/audio, и получает конкретную причину.
- В логах сервиса видны стадия и техническая причина сбоя.
## Статус
pending

View File

@ -162,13 +162,9 @@ UI чата строится на этих типах: текстовые соо
## 7) Доставка и backlog
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
- Для офлайн/недоступных сессий остаётся pending-запись доставки в таблице `signed_message_session_delivery`.
- При подключении сессии сервер автоматически вызывает `dispatchPendingForSession`:
- для новой сессии регистрирует все существующие сообщения адресата как «недоставленные»;
- отправляет **все** pending через WebSocket событием `SignedMessageArrived(backlog=true)`;
- лимита на количество сообщений нет — передаётся вся история без ограничений.
- Клиент дедублирует входящие через `knownMessageKeys`: если `messageKey` уже есть локально — игнорирует.
- После получения клиент отправляет `AckSessionDelivery`, чтобы отметить `delivered=1` в таблице доставки.
- Для офлайн/недоступных сессий остаётся pending-запись доставки.
- При подключении сессии сервер догружает pending (`listPendingForSession`) и шлёт `SignedMessageArrived(backlog=true)`.
- После получения клиент должен отправить `AckSessionDelivery`, чтобы отметить `delivered=1`.
## 8) Read-receipt логика
@ -187,63 +183,19 @@ UI чата строится на этих типах: текстовые соо
## 9) Логика UI-клиента
### Хранилище сообщений
- In-memory: `state.chats[chatId]` — массив сообщений по каждому диалогу.
- Персистентно: IndexedDB база `shine-ui-messages-v1`, object store `messages`, ключ `messageKey`.
- `chatId` для `type=1``fromLogin`, для `type=2``toLogin`.
### Жизненный цикл при старте/подключении
1. `hydrateMessagesFromStore()` — читает все сообщения из IndexedDB в `state.chats` (до WebSocket-соединения).
2. После установки WebSocket-сессии сервер присылает backlog (`SignedMessageArrived(backlog=true)`) для всех недоставленных сообщений.
3. Клиент дедублирует через `knownMessageKeys` — уже имеющиеся в IndexedDB игнорируются.
4. Новые сообщения в реальном времени приходят теми же WebSocket-событиями.
### Очистка при выходе и смене пользователя
- При любом логауте (`terminateCurrentSession`) IndexedDB с сообщениями **удаляется полностью**.
- При входе нового пользователя через QR — IndexedDB удаляется явно до вызова `terminateCurrentSession`.
- При входе нового пользователя через логин/пароль — IndexedDB удаляется в `registration-keys-view.js` прямо перед `authorizeSession()`.
- Это гарантирует: при любом способе входа старые сообщения предыдущего пользователя не попадут к следующему.
### UI-поведение
В UI:
- чат хранится в `state.chats[chatId]`;
- `chatId` для `type=1``fromLogin`, для `type=2``toLogin`;
- непрочитанные считаются по `from='in' && unread=true`;
- доставка/прочтение исходящих:
- `firstTick` — сообщение принято сервером,
- `firstTick` — сообщение принято в парный поток,
- `secondTick` — пришло подтверждение прочтения;
- при открытии диалога UI автопрокручивает ленту в самый низ;
- после отправки нового сообщения UI сразу прокручивает ленту вниз.
- после отправки нового сообщения UI сразу автопрокручивает ленту вниз, чтобы новое сообщение было в зоне видимости;
- сообщения дополнительно кешируются в IndexedDB (`shine-ui-messages-v1`, store `messages`).
## 10) Синхронизация личных сообщений между серверами
Когда пользователи зарегистрированы на разных серверах SHiNE, серверы должны синхронизировать DM между собой.
### Общий принцип
- Сервер A получает DM-блок, адресованный пользователю на сервере B.
- Сервер A пересылает этот блок серверу B (межсерверный relay).
- Сервер B сохраняет блок и доставляет его в активные сессии получателя.
- Серверы, между которыми идёт синхронизация, задаются списком `sync_servers` в PDA пользователя-сервера.
### Что синхронизируется
- Все DM-блоки типов `1/2` (текстовые сообщения) и `3/4` (read-receipt).
- Синхронизация двусторонняя: оба сервера должны уметь принимать и пересылать блоки.
### Идемпотентность
- Блоки имеют уникальный `message_key` (`from|to|timeMs|nonce|type`).
- Повторная доставка одного и того же блока безопасна — дедупликация происходит по `message_key`.
### Статус реализации
Межсерверная синхронизация DM **пока не реализована**. Текущая версия работает только в рамках одного сервера. Это задача для следующего этапа.
---
## 11) Инварианты (обязательно соблюдать при доработках)
## 10) Инварианты (обязательно соблюдать при доработках)
1. Пара блоков (1/2 или 3/4) должна оставаться атомарной.
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
@ -251,7 +203,7 @@ UI чата строится на этих типах: текстовые соо
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
## 12) Ключевые файлы реализации
## 11) Ключевые файлы реализации
- UI:
- `shine-UI/js/services/auth-service.js`

View File

@ -26,18 +26,12 @@
Адрес пользовательской PDA вычисляется по логину:
- seed prefix: `user_login=`;
- seed prefix: `login=`;
- второй seed: нормализованный логин в нижнем регистре;
- program id: программа `shine_users`.
Один логин соответствует одной `user_pda`.
## 2.1. Кто оплачивает create/update PDA
- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `device_key`.
- `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer.
- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `device_key`.
## 3. Общие правила кодирования
- Числа кодируются в Little Endian.
@ -90,18 +84,17 @@ UserPdaRecordV1
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
| `30` | `ServerProfileBlock` | Серверные данные пользователя. |
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и саб-серверы. |
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `50` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
Правила:
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`;
- каждый обязательный блок должен встречаться ровно один раз;
- порядок блоков в записи фиксируется для простоты проверки:
`RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`.
`RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `TrustedState`.
## 6. RootKeyBlock
@ -204,7 +197,6 @@ Arweave `tx_id` - обычное поле внутри записи конкре
- `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;
- уменьшать лимит, число блоков или занятый размер нельзя.
@ -238,8 +230,7 @@ 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_key: [u8; 32], только если 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
@ -249,11 +240,9 @@ ServerProfileBlock
- `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-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует блокчейн и личные сообщения. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы.
- `server_address` - строковый адрес сервера в формате, который будет отдельно закреплен на уровне приложения;
- `sync_servers` - логины пользователей системы, через которых этот сервер пытается синхронизироваться. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы.
## 12. AccessServersBlock
@ -274,66 +263,20 @@ AccessServersBlock
- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы;
- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE.
## 13. 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` | Обычная пользовательская сессия. |
| `100` | Саб-сервер пользователя. |
Правила:
- максимум `64` записей на пользователя;
- `session_name` не пустой, максимум `64` байта;
- `session_name` может содержать только символы `[A-Za-z0-9_]`;
- `session_version` сейчас должна быть равна `1`;
- внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`;
- на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически.
## 14. TrustedStateBlock
## 13. TrustedStateBlock
Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик.
```text
TrustedStateBlock
- block_type: u8 = 70
- block_type: u8 = 50
- block_version: u8 = 0
- trusted_count: u8 = 0
```
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
## 15. Подпись user_pda
## 14. Подпись user_pda
Подписывается не вся PDA целиком, а unsigned-часть записи:
@ -350,11 +293,10 @@ signature = Ed25519(root_key, message)
```
Solana-программа проверяет подпись через встроенную Ed25519-инструкцию. Подписантом должен быть `root_key` из `RootKeyBlock`.
Для `shine_users` эта инструкция должна стоять в транзакции сразу перед Ed25519-инструкцией `last_block_signature` и непосредственно перед самой `create/update`-инструкцией программы.
Смену формата подписи сейчас не трогаем.
## 16. Регистрация пользователя
## 15. Регистрация пользователя
При регистрации:
@ -365,14 +307,13 @@ Solana-программа проверяет подпись через встр
- `created_at_ms = updated_at_ms`;
- обязательные блоки присутствуют;
- создается минимум один `BlockchainRecord`;
- новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`;
- стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит;
- `used_bytes <= paid_limit_bytes`;
- пользователь платит регистрационную комиссию;
- если покупается дополнительный лимит, пользователь платит комиссию за этот лимит;
- вся unsigned-часть записи подписана `root_key`.
## 17. Обновление пользователя
## 16. Обновление пользователя
При обновлении:
@ -387,7 +328,7 @@ Solana-программа проверяет подпись через встр
- при увеличении оплаченного лимита пользователь доплачивает комиссию;
- Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует.
## 18. Отличия от старого линейного формата
## 17. Отличия от старого линейного формата
Старый формат после `login` хранил поля линейно:

View File

@ -9,7 +9,7 @@
Связанные документы:
- `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/SHiNE-user-format-v.1.0.md` — точный формат `user_pda` для `shine_users`.
- `shine-solana/shine/doc/FUNDS_FLOW.md` — короткая справка по денежным потокам внутри Solana-модуля.
## Кратко

View File

@ -4,15 +4,10 @@
## Базовый сервер
- SSH: `player@shineup.me`
- SSH: `player@45.136.124.227`
- Домен: `shineup.me`
- Базовый путь: `/home/player`
Для всех рабочих инструкций и скриптов использовать доменное имя `shineup.me`, а не фиксированный IP:
- актуальный IP должен браться через DNS-резолв на момент подключения;
- ручное дублирование IP в документации и deploy-скриптах не поддерживать.
## Локальные команды
- Деплой сервера: `./gradlew deployServer`
@ -31,20 +26,6 @@
- `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`.

View File

@ -0,0 +1,23 @@
# Сервер `45.136.124.227` (`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`

View File

@ -18,7 +18,7 @@
## Статус
- Резервный сервер для SHiNE.
- Основной прод-сервер: `shineup.me` (подключение через `player@shineup.me`, IP определяется через DNS).
- Основной прод-сервер: `45.136.124.227` (`shineup.me`).
## Caddy

View File

@ -1,35 +0,0 @@
# Сервер `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.

View File

@ -82,17 +82,10 @@ anchor deploy -p shine_users
- seed: `shine_users_economy_config`
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
## Кто оплачивает create/update user_pda
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `deviceKey`.
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера.
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `deviceKey`.
## Важно
- `init_users_economy_config` выполняется один раз на программу.
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `deviceKey`, а содержимое записи подписывает `rootKey`.
- Серверные приватные ключи для Solana не используются: подписание делается кошельком пользователя в UI.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
Несовпадение адреса приведёт к ошибке регистрации.

View File

@ -9,7 +9,6 @@
- `audio` — динамик/аудио-кодек (пример `07_ES8311`)
- `hello` — базовый тест экрана (пример `01_HelloWorld`)
- `simple` — простой кастомный тест: экран + touch + запись/проигрывание + наклон (IMU)
- `argon2` — генерация masterSecret через Argon2id с SD-картой как памятью (тест скорости)
Запуск:

View File

@ -1,10 +0,0 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
// BLAKE2b state
struct B2State {
uint64_t h[8], t[2], f[2];
uint8_t buf[128];
size_t buflen, outlen;
};

View File

@ -15,10 +15,9 @@ case "${MODE}" in
widgets) SKETCH_DIR="${DEMO_BASE}/examples/05_LVGL_Widgets" ;;
audio) SKETCH_DIR="${DEMO_BASE}/examples/07_ES8311" ;;
simple) SKETCH_DIR="${ROOT_DIR}/simple_av_test" ;;
argon2) SKETCH_DIR="${ROOT_DIR}/argon2_sd_test" ;;
*)
echo "Unknown mode: ${MODE}" >&2
echo "Use one of: hello, widgets, audio, simple, argon2" >&2
echo "Use one of: hello, widgets, audio, simple" >&2
exit 2
;;
esac

View File

@ -1,71 +0,0 @@
# Задание для Айдара: навести порядок в инструкциях агентов SHiNE
## Кратко
Нужно согласовать и оформить единый порядок инструкций для Codex/Telegram-агентов в проекте SHiNE, чтобы агенты стабильно понимали структуру проекта, границы ответственности и правила работы с сервером, UI, Solana-модулем, Telegram-ботом и игроками.
## Зачем это нужно
Сейчас проект состоит из нескольких связанных, но разных частей:
- основной сервер `SHiNE-server/`;
- UI `shine-UI/`;
- Solana/Anchor-модуль `shine-solana/shine/`;
- Telegram-агент-кодер `SHiNE-agent-bot-coder/`;
- TURN-сервер;
- документация `Dev_Docs/`;
- отдельные рабочие папки игроков `Players/`.
Без явных инструкций агент может путать эти зоны: например, смешать деплой Solana с деплоем сервера, изменить код от имени игрока, не обновить документацию API/DM/блокчейна или неправильно трактовать файл инструкций.
## Что предлагается сделать
1. Утвердить корневой `AGENTS.md` как главный набор правил проекта.
2. Проверить и при необходимости уточнить локальный `AGENTS.md` внутри `shine-solana/shine/`.
3. Оставить отдельные служебные инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENT.md`.
4. Оставить автоматически читаемые инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENTS.md`.
5. Явно закрепить режим игроков:
- игроки могут задавать вопросы, просить анализ, идеи и ТЗ;
- игроки не меняют код проекта напрямую;
- материалы игроков сохраняются только в `Players/<username>/`.
6. Зафиксировать правило: если пользователь говорит «агент MD» или похожую формулировку, считать, что речь про автоматически читаемый `AGENTS.md`.
7. Добавить простой процесс согласования изменений инструкций:
- Дима или другой участник готовит предложение;
- Айдар получает уведомление/заявку;
- Айдар отвечает: одобрить, отклонить или попросить доработать;
- только после одобрения агент вносит изменения в проектные инструкции.
## Предлагаемая логика уведомления Айдару
Минимальный вариант без сложной разработки:
1. Агент готовит текст заявки.
2. Текст отправляется Айдару в Telegram или в общий рабочий чат.
3. В заявке явно указаны варианты ответа:
- `одобрить`;
- `отклонить`;
- `доработать: ...`.
4. После ответа Айдара агент либо выполняет согласованные правки, либо фиксирует, что задача отклонена/нужна доработка.
Более удобный вариант на будущее:
- добавить в Telegram-бота команду или сценарий согласования задач, например:
- `/approve <id>`;
- `/reject <id> причина`;
- `/revise <id> комментарий`.
Но для начала достаточно простого текстового согласования через Telegram.
## Что нужно от Айдара
Подтвердить, что такой порядок подходит:
1. Корневой `AGENTS.md` остается главным правилом проекта.
2. Для Solana, Telegram-агента и игроков сохраняются отдельные локальные правила.
3. Игроки не меняют код напрямую, а готовят материалы и предложения.
4. Изменения инструкций выполняются только после явного одобрения Айдара.
5. Уведомления Айдару на первом этапе можно делать простым текстом в Telegram, без отдельной сложной системы заявок.
## Ожидаемый результат
После одобрения:
- агенты будут стабильнее понимать границы проекта;
- снизится риск случайных изменений не в той части системы;
- появится понятный порядок согласования задач от игроков;
- Айдар будет явно контролировать изменения в инструкциях и правилах работы агентов.

View File

@ -1,7 +1 @@
# SHiNE
## План запуска DAO
План запуска DAO зафиксирован в [DAO_запуск/README.md](DAO_запуск/README.md).
Это рабочий список задач по `этап1` и `этап2`. Дальше ведём его как основной чек-лист запуска DAO и отмечаем в нём выполненные пункты по мере готовности.
# -

View File

@ -32,12 +32,10 @@
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`; для новых пользователей оно включено по умолчанию.
- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает голосовую версию без длинных хэшей, путей, команд и технического шума, сохраняя смысл и порядок исходного ответа.
- Режим личных ответов настраивается персонально командами `/single_message_on`, `/single_message_off`: либо одно редактируемое сообщение по этапам, либо отдельные сообщения как раньше.
- Команда `/settings` должна сразу показывать текущее состояние всех персональных настроек пользователя и список команд для их изменения.
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS даже для текстовых запросов. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать.
- Команда `/status` должна показывать состояние очереди и персональные настройки: voice-ответы, адаптацию текста перед озвучкой и режим одного сообщения в личке.
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`, `/voice_status`.
- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`, `/voice_rewrite_status`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает короткую голосовую версию без длинных хэшей, путей, команд и технического шума.
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать.
- Команда `/status` должна показывать состояние очереди и персональные состояния голосовых функций: включены ли voice-ответы и адаптация текста перед озвучкой.
## Правила голосовой версии ответа
- Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали.
@ -56,21 +54,13 @@
- Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя.
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
## Центр задач и предложений
- Сервис хранит простые задачи и предложения в `data/task_center/items.json`.
- Айдар может смотреть список через `/tasks` или естественные фразы вроде «покажи мои задачи», «покажи задачи Миланы».
- Айдар может ставить задачи игрокам фразой вида «поставь задачу Милане: ...».
- Игроки могут отправлять предложения Айдару фразой вида `предложение: ...`, `идея: ...` или `заявка: ...`.
- Статусы меняются фразами с ID: `одобрить TC-0001`, `отклонить TC-0001`, `доработать TC-0001`, `закрыть TC-0001`.
- После финального ответа в личном чате сервис добавляет короткое напоминание, если у пользователя есть активные задачи или предложения.
## Локальный запуск и systemd
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
- Локальные секреты и параметры должны храниться в `.env`, этот файл не коммитится.
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
- Команда Telegram `/restart` (`/restart_service`) доступна только Айдару и выполняет отложенный рестарт после текущей задачи, до взятия следующей. Аварийный жёсткий рестарт доступен только Айдару командами `/restart_hard`, `/restart_now`, `/restart_force`.
- Команда Telegram `/restart_service` (и алиас `/restart`) доступна только Айдару.
## Правила ответа
- Пиши содержательно и коротко.

View File

@ -8,7 +8,6 @@
- обрабатывает задачи строго последовательно;
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
- вызывает Codex CLI и отправляет ответ в Telegram;
- в личном чате умеет работать в двух персонально переключаемых режимах: через одно редактируемое статусное сообщение или через отдельные сообщения по этапам;
- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS;
- при рестарте восстанавливает незавершённые задачи;
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
@ -33,15 +32,8 @@
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
- `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`.
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
- `TELEGRAM_API_BASE_URL` — базовый URL Bot API; по умолчанию `https://api.telegram.org`. Для очень больших voice/audio можно поднять локальный `telegram-bot-api` и направить бота туда.
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
- `OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES` — безопасный лимит размера одного куска для OpenAI transcription, по умолчанию `24 MiB`.
- `OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS` — максимальная длина одного куска при длинном аудио, по умолчанию `900` секунд.
- `OPENAI_TRANSCRIBE_OVERLAP_SECONDS` — перекрытие соседних кусков для более ровной склейки текста, по умолчанию `2` секунды.
- `OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS` — битрейт локального пережатия длинного аудио через `ffmpeg`, по умолчанию `24`.
- `OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS` — тайм-аут локальной обработки длинного аудио через `ffmpeg`/`ffprobe`, по умолчанию `1800`.
- `FFMPEG_BIN` и `FFPROBE_BIN` — пути к локальным бинарям `ffmpeg`/`ffprobe`, если они не лежат в `PATH`.
- `OPENAI_TTS_MODEL` — модель синтеза речи, по умолчанию `gpt-4o-mini-tts`.
- `OPENAI_TTS_VOICE` — голос синтеза речи, по умолчанию `alloy`.
- `OPENAI_TTS_RESPONSE_FORMAT` — аудиоформат для Telegram voice, по умолчанию `opus`.
@ -55,19 +47,6 @@
python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь одной строкой: Codex работает"
```
## Длинные voice/audio
- Если аудио короткое, бот отправляет его в OpenAI как раньше.
- Если аудио большое или длинное, бот локально пережимает его через `ffmpeg`, при необходимости режет на куски и распознаёт последовательно.
- Если Telegram заранее сообщает большой размер файла, бот больше не отказывается сразу: сначала явно пишет, что пробует скачать файл, затем отдельно сообщает, удалось ли скачивание, и только после успешной загрузки переходит к подготовке аудио и OpenAI.
- Для очень больших файлов упираемся не только в OpenAI, но и в лимит обычного облачного Telegram Bot API на скачивание файла ботом. Для таких случаев нужно использовать локальный `telegram-bot-api` сервер и указать его через `TELEGRAM_API_BASE_URL`.
## Статусы в личке
- Для `private`-чата бот поддерживает персональную настройку режима ответа.
- По умолчанию он старается не засорять переписку промежуточными сообщениями: создаёт одно статусное сообщение и редактирует его по этапам.
- Если включить `/single_message_off`, бот возвращается к старому режиму и отправляет отдельные сообщения по этапам и финальный ответ отдельно.
- Если финальный текст в режиме одного сообщения не помещается целиком, бот оставляет первую часть в отредактированном статусном сообщении и отправляет максимум ещё одно дополнительное текстовое сообщение с хвостом ответа.
- Голосовой ответ, если он включён, всегда приходит отдельным новым сообщением.
## Запуск как systemd-сервис
Файлы для установки:
- `scripts/systemd/shine-agent-bot-coder.service`
@ -85,16 +64,11 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
## Telegram-команды
- `/status` — активная задача и размер очереди.
- `/settings` — текущие пользовательские настройки и команды для их изменения.
- `/queue` — список задач в очереди.
- `/stop` — остановить текущую задачу.
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
- `/new` — архивировать текущую историю и начать новый диалог.
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
- `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой.
- `/single_message_on` — вести ответ в личке через одно редактируемое сообщение.
- `/single_message_off` — слать отдельные сообщения по этапам и отдельный финальный ответ.
- `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара).
- `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара).
- `/voice_status` — показать состояние озвучивания для текущего пользователя.
- `/restart_service` — перезапустить сервис (только для Айдара); systemd должен поднять процесс заново.

File diff suppressed because it is too large Load Diff

View File

@ -1,69 +0,0 @@
# AGENTS.md — SHiNE-server
## Назначение
SHiNE-server — серверная часть мессенджера SHiNE: WebSocket-сервер, хранение блоков блокчейна
пользователей, доставка личных сообщений (DM), звонки.
## Структура папок
- `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера
- `shine-server-net-protocol/` — обработчики операций (RPC и события WS)
- `shine-server-db/` — DAO, SQL-схема, SQLite
- `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна
- `shine-server-crypto/` — криптографические утилиты
- `shine-server-config/` — конфигурация сервера
- `shine-server-log/` — логирование
- `shine-server-geo/` — геолокация IP
## Настройка сервера в Solana (Solana PDA)
Серверный аккаунт SHiNE регистрируется в Solana в виде `user_pda` с флагом `is_server=true`.
В PDA хранятся:
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
с которыми синхронизируются блоки и DM;
- **корневой ключ** сервера (`root_key`).
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
**Управление серверной PDA выполняется через Web-панель администратора:**
```
shine-UI/server-ui.html
```
Страницы:
- `shine-UI/server-ui/create-server-pda.html` — первичная регистрация серверного аккаунта;
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса или списка sync_servers.
Для регистрации нужен полный keyBundle (root + device + blockchain).
Для обновления — только root + device (blockchain-ключ не нужен).
Актуальные адреса программ Solana (devnet):
- `shine_users`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- `shine_payments`: `m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR`
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md`
## Синхронизация с партнёрскими серверами
Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`.
Детали: `Dev_Docs/Blockchain/sync-between-servers.md`
## Деплой
```
./gradlew deployServer
```
Хост по умолчанию: `player@93.170.12.154` (shineup.me).
Логи на проде:
- `/home/player/SHiNE/shine-server/logs/app.log`
- `/home/player/SHiNE/shine-server/logs/call-delivery-events.log`
## Язык
Комментарии в коде, документация и commit-сообщения — на русском языке.

View File

@ -182,7 +182,7 @@ public final class SignedMessagesV2DAO {
}
}
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId, int limit) throws Exception {
try (Connection c = db.getConnection()) {
String fillSql = """
INSERT OR IGNORE INTO signed_message_session_delivery (
@ -210,10 +210,12 @@ public final class SignedMessagesV2DAO {
ON d.message_key = m.message_key
WHERE d.session_id = ? AND d.delivered = 0
ORDER BY m.time_ms ASC, m.created_at_ms ASC
LIMIT ?
""";
List<SignedMessageV2Entry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, sessionId);
ps.setInt(2, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}

View File

@ -25,6 +25,7 @@ public final class SolanaUserPdaImportService {
private static final Logger log = LoggerFactory.getLogger(SolanaUserPdaImportService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final int USER_PDA_SPACE = 768;
private static final String MAGIC = "SHiNE";
private SolanaUserPdaImportService() {}
@ -71,13 +72,14 @@ public final class SolanaUserPdaImportService {
{
"encoding":"base64",
"filters":[
{"dataSize":%d},
{"memcmp":{"offset":61,"bytes":"%s"}},
{"memcmp":{"offset":62,"bytes":"%s"}}
]
}
]
}
""".formatted(SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID, lenB58, loginB58);
""".formatted(SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID, USER_PDA_SPACE, lenB58, loginB58);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(SolanaProgramsConfig.SOLANA_RPC_URL))
@ -169,20 +171,14 @@ public final class SolanaUserPdaImportService {
return null;
}
}
} else if (blockType == 30) {
int isServer = u8(raw, c++);
if (isServer == 1) {
c += 1; // address_format_type
c += 1; // address_format_version
int addrLen = u8(raw, c++);
c += addrLen;
int syncCount = u8(raw, c++);
for (int j = 0; j < syncCount; j++) {
int n = u8(raw, c++);
c += n;
}
} else if (isServer != 0) {
return null;
} else if (blockType == 4) {
c += 1 + 32;
int addrLen = u8(raw, c++);
c += addrLen;
int syncCount = u8(raw, c++);
for (int j = 0; j < syncCount; j++) {
int n = u8(raw, c++);
c += n;
}
} else if (blockType == 40) {
int accessCount = u8(raw, c++);
@ -191,18 +187,6 @@ public final class SolanaUserPdaImportService {
c += n;
}
} else if (blockType == 50) {
int sessionsMode = u8(raw, c++);
if (sessionsMode != 1 && sessionsMode != 10) return null;
int sessionsCount = u8(raw, c++);
if (sessionsCount > 64) return null;
for (int j = 0; j < sessionsCount; j++) {
c += 1; // session_type
c += 1; // session_version
int n = u8(raw, c++);
c += n;
c += 32; // session_pub_key
}
} else if (blockType == 70) {
c += 1;
} else {
return null;
@ -280,3 +264,4 @@ public final class SolanaUserPdaImportService {
long paidLimitBytes
) {}
}

View File

@ -19,6 +19,8 @@ import java.util.List;
public final class SignedMessagesRealtime {
private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int LOGIN_BACKLOG_LIMIT = 500;
private SignedMessagesRealtime() {}
static DeliveryCounters deliverToTargetSessions(
@ -55,7 +57,7 @@ public final class SignedMessagesRealtime {
try {
List<SignedMessageV2Entry> pending = SignedMessagesV2DAO.getInstance()
.listPendingForSession(login, sessionId);
.listPendingForSession(login, sessionId, LOGIN_BACKLOG_LIMIT);
for (SignedMessageV2Entry e : pending) {
sendEventToSessionIfOnline(sessionId, e, true);
}

View File

@ -9,7 +9,6 @@ import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.JsonInboundProcessor;
import java.nio.channels.ClosedChannelException;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
@ -132,19 +131,9 @@ public class BlockchainWsEndpoint {
@OnWebSocketError
public void onError(Throwable cause) {
if (isNormalClosedChannel(cause)) {
log.info("WS channel already closed during normal shutdown: {}", cause.toString());
return;
}
log.error("WS error", cause);
}
private boolean isNormalClosedChannel(Throwable cause) {
if (cause == null) return false;
if (cause instanceof ClosedChannelException) return true;
return String.valueOf(cause).contains("ClosedChannelException");
}
private void trySendJsonError() {
if (session != null && session.isOpen()) {
String resp = "{\"op\":null,\"requestId\":null,\"status\":500,"
@ -163,4 +152,4 @@ public class BlockchainWsEndpoint {
});
}
}
}
}

View File

@ -19,10 +19,10 @@ import java.util.jar.JarFile;
*/
public class IT_DeployBackupCleanAndRunRemoteMain {
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "shineup.me");
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "player");
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247");
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user");
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server");
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server");
private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar";
private static final String REMOTE_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data");
private static final String REMOTE_BACKUP_DIR = System.getProperty("it.remoteBackupDir", REMOTE_DIR + "/backup");

View File

@ -11,10 +11,10 @@ import java.util.Objects;
public class IT_DeployRestartAndRunRemoteMain {
// ====== НАСТРОЙКИ (можно переопределять systemProperty) ======
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "shineup.me");
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "player");
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247");
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user");
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server");
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server");
private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar";
private static final String REMOTE_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data");

View File

@ -43,10 +43,7 @@ public class IT_DeployRestartNoCleanNoTestsMain {
}
private static void ensureSudoNoPasswordOrThrow() {
// Проверяем именно возможность sudo без пароля.
// systemctl status может возвращать non-zero для inactive/failed сервиса,
// и это не должно считаться проблемой прав доступа.
int code = ssh("sudo -n true");
int code = ssh("sudo -n systemctl status " + SERVICE_NAME + " >/dev/null 2>&1");
if (code == 0) return;
throw new RuntimeException(
"Remote sudo requires password for " + REMOTE_USER + "@" + REMOTE_HOST

View File

@ -1,2 +1,2 @@
client.version=1.2.131
server.version=1.2.123
client.version=1.2.102
server.version=1.2.96

View File

@ -196,11 +196,11 @@ tasks.register('deployServer', JavaExec) {
// можно переопределить при запуске:
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
dependsOn shadowJar
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "shineup.me")
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "45.136.124.227")
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player")
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server")
systemProperty "it.service", System.getProperty("it.service", "shine-server")
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
systemProperty "it.localJar", System.getProperty("it.localJar", "SHiNE-server/build/libs/shine-server.jar")
dependsOn testClasses
}

View File

@ -2,7 +2,7 @@
set -euo pipefail
SRC_DIR="shine-UI"
REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
REMOTE_HOST="${REMOTE_HOST:-player@45.136.124.227}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/home/player/SHiNE/shine-ui}"
ALLOW_CADDY_MISMATCH="${ALLOW_CADDY_MISMATCH:-0}"

View File

@ -0,0 +1,19 @@
# UI deploy
Актуальный UI-деплой выполняется одной командой:
```bash
./gradlew deployUI
```
По умолчанию:
- хост: `player@93.170.12.154`
- домен: `https://shineup.me`
- путь: `/home/player/SHiNE/SHiNE-UI`
Переопределение при необходимости:
```bash
REMOTE_HOST=player@93.170.12.154 REMOTE_BASE_DIR=/home/player/SHiNE bash deploy_shine-PWA.sh
```

View File

@ -0,0 +1,18 @@
# MVP notes: Web Push
## Временное поведение (сделано для тестового стенда)
- Клиент отправляет push-подписку на сервер при каждом запуске после авторизации, даже если подписка не изменилась.
- Причина: на тестовом сервере/после переустановки БД запись о токене может пропасть, а клиент этого не узнает.
## Что доработать для production
- Вернуть режим "отправлять только при изменении подписки" как основной.
- Добавить безопасный механизм ресинхронизации:
- Вариант 1: периодическая принудительная отправка (например, 1 раз в N дней).
- Вариант 2: endpoint на сервере "есть ли подписка", и отправка только при отсутствии/рассинхроне.
- В логах разделить обычную отправку и принудительную, чтобы видеть лишний трафик.
- Добавить e2e-тесты сценариев:
- Переустановка сервера (потеря токена в БД).
- Смена браузерной подписки.
- Повторный запуск клиента без изменений.

View File

@ -1,29 +0,0 @@
# AGENTS для server-backup
## Назначение
- Папка `server-backup/` хранит:
- тяжёлые локальные бэкапы сервера (НЕ в git);
- лёгкую схему восстановления (в git), чтобы можно было поднять сервер даже без полного архива.
## Структура
- `archive/YYYY-MM-DD/` — полный бэкап конкретной даты.
- `scheme/shineup.me/` — схема восстановления и ключевые конфиги.
- `backup-version.properties` — версия контура бэкапа.
## Правила
- Полный бэкап складывать только в `server-backup/archive/`.
- `server-backup/archive/**` не коммитить.
- Любое изменение схемы восстановления фиксировать в git.
- После обновления схемы увеличивать `backup.schema.version`.
- После нового полного бэкапа увеличивать `backup.full.version`.
## Как обновлять бэкап
1. Обновить схему:
- `bash server-backup/scheme/shineup.me/scripts/refresh_scheme.sh`
2. Сделать новый полный бэкап:
- `bash server-backup/scheme/shineup.me/scripts/backup_full.sh`
3. Проверить `server-backup/archive/<дата>/MANIFEST.txt`.
4. Поднять версии в `server-backup/backup-version.properties`.
## Как восстанавливать
- Смотреть `server-backup/scheme/shineup.me/docs/RESTORE.md`.

View File

@ -1,7 +0,0 @@
# server-backup
- `archive/` — локальные полные бэкапы по датам (не коммитятся).
- `scheme/` — лёгкая схема восстановления (коммитится).
- `backup-version.properties` — версии схемы и полного бэкапа.
Текущий сервер-источник: `shineup.me`.

View File

@ -1 +0,0 @@

View File

@ -1,3 +0,0 @@
backup.schema.version=1
backup.full.version=1
last.full.backup.date=2026-06-01

View File

@ -1,32 +0,0 @@
vm85188.geo.hosting
---
Linux vm85188.geo.hosting 5.4.0-169-generic #187-Ubuntu SMP Thu Nov 23 14:52:28 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
---
Filesystem Size Used Avail Use% Mounted on
udev 1.9G 0 1.9G 0% /dev
tmpfs 392M 1.4M 391M 1% /run
/dev/vda2 79G 6.2G 69G 9% /
tmpfs 2.0G 0 2.0G 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 2.0G 0 2.0G 0% /sys/fs/cgroup
/dev/loop0 92M 92M 0 100% /snap/lxd/24061
/dev/loop2 41M 41M 0 100% /snap/snapd/20290
/dev/loop4 50M 50M 0 100% /snap/snapd/26865
/dev/loop3 92M 92M 0 100% /snap/lxd/38688
/dev/loop5 64M 64M 0 100% /snap/core20/2769
tmpfs 392M 0 392M 0% /run/user/1000
tmpfs 392M 0 392M 0% /run/user/0
/dev/loop6 64M 64M 0 100% /snap/core20/2866
---
NAME FSTYPE LABEL UUID FSAVAIL FSUSE% MOUNTPOINT
loop0 squashfs 0 100% /snap/lxd/24061
loop1 squashfs
loop2 squashfs 0 100% /snap/snapd/20290
loop3 squashfs 0 100% /snap/lxd/38688
loop4 squashfs 0 100% /snap/snapd/26865
loop5 squashfs 0 100% /snap/core20/2769
loop6 squashfs 0 100% /snap/core20/2866
sr0
vda
├─vda1
└─vda2 ext4 b3a7aca2-1c7b-470b-a2ee-dc7204eba7d3 68.4G 8% /

View File

@ -1,74 +0,0 @@
UNIT FILE STATE VENDOR PRESET
accounts-daemon.service enabled enabled
agent-memory.service enabled enabled
apparmor.service enabled enabled
atd.service enabled enabled
autovt@.service enabled enabled
blk-availability.service enabled enabled
caddy.service enabled enabled
cloud-config.service enabled enabled
cloud-final.service enabled enabled
cloud-init-local.service enabled enabled
cloud-init.service enabled enabled
console-setup.service enabled enabled
containerd.service enabled enabled
coturn.service enabled enabled
cron.service enabled enabled
dbus-org.freedesktop.ModemManager1.service enabled enabled
dbus-org.freedesktop.resolve1.service enabled enabled
dbus-org.freedesktop.thermald.service enabled enabled
dbus-org.freedesktop.timesync1.service enabled enabled
dmesg.service enabled enabled
docker.service enabled enabled
e2scrub_reap.service enabled enabled
finalrd.service enabled enabled
getty@.service enabled enabled
grub-common.service enabled enabled
grub-initrd-fallback.service enabled enabled
irqbalance.service enabled enabled
iscsi.service enabled enabled
keyboard-setup.service enabled enabled
lvm2-monitor.service enabled enabled
lxd-agent-9p.service enabled enabled
lxd-agent.service enabled enabled
ModemManager.service enabled enabled
multipath-tools.service enabled enabled
multipathd.service enabled enabled
networkd-dispatcher.service enabled enabled
networking.service enabled enabled
ondemand.service enabled enabled
open-iscsi.service enabled enabled
open-vm-tools.service enabled enabled
pollinate.service enabled enabled
rsync.service enabled enabled
rsyslog.service enabled enabled
secureboot-db.service enabled enabled
setvtrgb.service enabled enabled
shine-server.service enabled enabled
snap.lxd.activate.service enabled enabled
snapd.apparmor.service enabled enabled
snapd.autoimport.service enabled enabled
snapd.core-fixup.service enabled enabled
snapd.recovery-chooser-trigger.service enabled enabled
snapd.seeded.service enabled enabled
snapd.service enabled enabled
snapd.system-shutdown.service enabled enabled
ssh.service enabled enabled
sshd.service enabled enabled
syslog.service enabled enabled
systemd-networkd-wait-online.service enabled enabled
systemd-networkd.service enabled enabled
systemd-pstore.service enabled enabled
systemd-resolved.service enabled enabled
systemd-timesyncd.service enabled enabled
thermald.service enabled enabled
ua-reboot-cmds.service enabled enabled
ubuntu-advantage.service enabled enabled
ubuntu-fan.service enabled enabled
udisks2.service enabled enabled
ufw.service enabled enabled
unattended-upgrades.service enabled enabled
vgauth.service enabled enabled
vmtoolsd.service enabled enabled
71 unit files listed.

View File

@ -1,40 +0,0 @@
UNIT LOAD ACTIVE SUB DESCRIPTION
accounts-daemon.service loaded active running Accounts Service
agent-memory.service loaded active running agent-memory service
atd.service loaded active running Deferred execution scheduler
caddy.service loaded active running Caddy
containerd.service loaded active running containerd container runtime
coturn.service loaded active running coTURN STUN/TURN Server
cron.service loaded active running Regular background program processing daemon
dbus.service loaded active running D-Bus System Message Bus
docker.service loaded active running Docker Application Container Engine
getty@tty1.service loaded active running Getty on tty1
irqbalance.service loaded active running irqbalance daemon
ModemManager.service loaded active running Modem Manager
multipathd.service loaded active running Device-Mapper Multipath Device Controller
networkd-dispatcher.service loaded active running Dispatcher daemon for systemd-networkd
polkit.service loaded active running Authorization Manager
qemu-guest-agent.service loaded active running QEMU Guest Agent
rsyslog.service loaded active running System Logging Service
shine-server.service loaded active running SHiNE Server
snapd.service loaded active running Snap Daemon
ssh.service loaded active running OpenBSD Secure Shell server
systemd-hostnamed.service loaded active running Hostname Service
systemd-journald.service loaded active running Journal Service
systemd-logind.service loaded active running Login Service
systemd-networkd.service loaded active running Network Service
systemd-resolved.service loaded active running Network Name Resolution
systemd-timesyncd.service loaded active running Network Time Synchronization
systemd-udevd.service loaded active running udev Kernel Device Manager
udisks2.service loaded active running Disk Manager
unattended-upgrades.service loaded active running Unattended Upgrades Shutdown
upower.service loaded active running Daemon for power management
user@0.service loaded active running User Manager for UID 0
user@1000.service loaded active running User Manager for UID 1000
uuidd.service loaded active running Daemon for generating UUIDs
LOAD = Reflects whether the unit definition was properly loaded.
ACTIVE = The high-level unit activation state, i.e. generalization of SUB.
SUB = The low-level unit activation state, values depend on unit type.
33 loaded units listed.

View File

@ -1,50 +0,0 @@
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=114218,fd=13))
LISTEN 0 4096 0.0.0.0:2222 0.0.0.0:* users:(("docker-proxy",pid=195899,fd=4))
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=718,fd=13))
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=63620,fd=3))
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=99))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=94))
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=82))
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=72))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=67))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=66))
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=53))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=49))
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=32))
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=29))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=23))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=22))
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=102))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=97))
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=85))
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=78))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=79))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=69))
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=55))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=51))
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=35))
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=33))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=27))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=26))
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("docker-proxy",pid=195885,fd=4))
LISTEN 0 4096 127.0.0.1:37021 0.0.0.0:* users:(("containerd",pid=188233,fd=11))
LISTEN 0 50 *:7070 *:* users:(("java",pid=2278064,fd=9))
LISTEN 0 4096 [::]:2222 [::]:* users:(("docker-proxy",pid=195906,fd=4))
LISTEN 0 100 *:8080 *:* users:(("java",pid=304246,fd=11))
LISTEN 0 4096 *:80 *:* users:(("caddy",pid=114218,fd=16))
LISTEN 0 100 *:8018 *:* users:(("java",pid=321572,fd=13))
LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=63620,fd=4))
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=38))
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=41))
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=57))
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=84))
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=88))
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=106))
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=40))
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=47))
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=59))
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=90))
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=91))
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=113))
LISTEN 0 4096 *:443 *:* users:(("caddy",pid=114218,fd=12))

View File

@ -1,2 +0,0 @@
NAMES IMAGE STATUS PORTS
gitea gitea/gitea:1.22.6 Up 3 weeks 127.0.0.1:3000->3000/tcp, 0.0.0.0:2222->22/tcp, :::2222->22/tcp

View File

@ -1,6 +0,0 @@
4.0K /home/player/hosts.codex.test
16K /home/player/logs
796K /home/player/sites
15M /home/player/gitea
36M /home/player/agent-memory
200M /home/player/SHiNE

View File

@ -1,12 +0,0 @@
4.0K /var/crash
4.0K /var/local
4.0K /var/mail
4.0K /var/opt
28K /var/spool
60K /var/snap
76K /var/tmp
1.9M /var/backups
280M /var/cache
1.2G /var/lib
1.2G /var/log
2.7G /var

View File

@ -1 +0,0 @@
2026-06-01T08:53:07Z

View File

@ -1,54 +0,0 @@
openmindsoft.io, www.openmindsoft.io {
encode zstd gzip
root * /home/player/sites/OpenMindSoft.io
try_files {path} /index.html
file_server
}
shineup.me {
encode zstd gzip
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:7070
}
handle {
root * /home/player/SHiNE/shine-ui
try_files {path} /index.html
file_server
header -Etag
header {
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Pragma "no-cache"
Expires "0"
}
}
}
:80 {
encode zstd gzip
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:7070
}
handle {
root * /home/player/SHiNE/shine-ui
try_files {path} /index.html
file_server
header -Etag
header {
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Pragma "no-cache"
Expires "0"
}
}
}
git.shineup.me {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
}

View File

@ -1,25 +0,0 @@
[Unit]
Description=agent-memory service
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=player
Group=player
WorkingDirectory=/home/player/agent-memory
ExecStart=/usr/bin/java -jar /home/player/agent-memory/agent-gpt-memory.jar --spring.config.location=optional:file:/home/player/agent-memory/app.properties
Restart=always
RestartSec=3
TimeoutStopSec=20
KillMode=control-group
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/player/agent-memory/data /home/player/agent-memory/logs
[Install]
WantedBy=multi-user.target

View File

@ -1,15 +0,0 @@
[Unit]
Description=SHiNE Server
After=network.target
[Service]
Type=simple
User=player
Group=player
WorkingDirectory=/home/player/SHiNE/shine-server
ExecStart=/usr/bin/java -Dserver.1port=7070 -jar /home/player/SHiNE/shine-server/shine-server.jar
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@ -1,709 +0,0 @@
# Coturn TURN SERVER configuration file
#
# Boolean values note: where boolean value is supposed to be used,
# you can use '0', 'off', 'no', 'false', 'f' as 'false,
# and you can use '1', 'on', 'yes', 'true', 't' as 'true'
# If the value is missed, then it means 'true'.
#
# Listener interface device (optional, Linux only).
# NOT RECOMMENDED.
#
#listening-device=eth0
# TURN listener port for UDP and TCP (Default: 3478).
# Note: actually, TLS & DTLS sessions can connect to the
# "plain" TCP & UDP port(s), too - if allowed by configuration.
#
#listening-port=3478
# TURN listener port for TLS (Default: 5349).
# Note: actually, "plain" TCP & UDP sessions can connect to the TLS & DTLS
# port(s), too - if allowed by configuration. The TURN server
# "automatically" recognizes the type of traffic. Actually, two listening
# endpoints (the "plain" one and the "tls" one) are equivalent in terms of
# functionality; but we keep both endpoints to satisfy the RFC 5766 specs.
# For secure TCP connections, we currently support SSL version 3 and
# TLS version 1.0, 1.1 and 1.2.
# For secure UDP connections, we support DTLS version 1.
#
#tls-listening-port=5349
# Alternative listening port for UDP and TCP listeners;
# default (or zero) value means "listening port plus one".
# This is needed for RFC 5780 support
# (STUN extension specs, NAT behavior discovery). The TURN Server
# supports RFC 5780 only if it is started with more than one
# listening IP address of the same family (IPv4 or IPv6).
# RFC 5780 is supported only by UDP protocol, other protocols
# are listening to that endpoint only for "symmetry".
#
#alt-listening-port=0
# Alternative listening port for TLS and DTLS protocols.
# Default (or zero) value means "TLS listening port plus one".
#
#alt-tls-listening-port=0
# Listener IP address of relay server. Multiple listeners can be specified.
# If no IP(s) specified in the config file or in the command line options,
# then all IPv4 and IPv6 system IPs will be used for listening.
#
#listening-ip=172.17.19.101
#listening-ip=10.207.21.238
#listening-ip=2607:f0d0:1002:51::4
# Auxiliary STUN/TURN server listening endpoint.
# Aux servers have almost full TURN and STUN functionality.
# The (minor) limitations are:
#
# 1) Auxiliary servers do not have alternative ports and
# they do not support STUN RFC 5780 functionality (CHANGE REQUEST).
#
# 2) Auxiliary servers also are never returning ALTERNATIVE-SERVER reply.
#
# Valid formats are 1.2.3.4:5555 for IPv4 and [1:2::3:4]:5555 for IPv6.
#
# There may be multiple aux-server options, each will be used for listening
# to client requests.
#
#aux-server=172.17.19.110:33478
#aux-server=[2607:f0d0:1002:51::4]:33478
# (recommended for older Linuxes only)
# Automatically balance UDP traffic over auxiliary servers (if configured).
# The load balancing is using the ALTERNATE-SERVER mechanism.
# The TURN client must support 300 ALTERNATE-SERVER response for this
# functionality.
#
#udp-self-balance
# Relay interface device for relay sockets (optional, Linux only).
# NOT RECOMMENDED.
#
#relay-device=eth1
# Relay address (the local IP address that will be used to relay the
# packets to the peer).
# Multiple relay addresses may be used.
# The same IP(s) can be used as both listening IP(s) and relay IP(s).
#
# If no relay IP(s) specified, then the turnserver will apply the default
# policy: it will decide itself which relay addresses to be used, and it
# will always be using the client socket IP address as the relay IP address
# of the TURN session (if the requested relay address family is the same
# as the family of the client socket).
#
#relay-ip=172.17.19.105
#relay-ip=2607:f0d0:1002:51::5
# For Amazon EC2 users:
#
# TURN Server public/private address mapping, if the server is behind NAT.
# In that situation, if a -X is used in form "-X <ip>" then that ip will be reported
# as relay IP address of all allocations. This scenario works only in a simple case
# when one single relay address is be used, and no RFC5780 functionality is required.
# That single relay address must be mapped by NAT to the 'external' IP.
# The "external-ip" value, if not empty, is returned in XOR-RELAYED-ADDRESS field.
# For that 'external' IP, NAT must forward ports directly (relayed port 12345
# must be always mapped to the same 'external' port 12345).
#
# In more complex case when more than one IP address is involved,
# that option must be used several times, each entry must
# have form "-X <public-ip/private-ip>", to map all involved addresses.
# RFC5780 NAT discovery STUN functionality will work correctly,
# if the addresses are mapped properly, even when the TURN server itself
# is behind A NAT.
#
# By default, this value is empty, and no address mapping is used.
#
#external-ip=60.70.80.91
#
#OR:
#
#external-ip=60.70.80.91/172.17.19.101
#external-ip=60.70.80.92/172.17.19.102
# Number of the relay threads to handle the established connections
# (in addition to authentication thread and the listener thread).
# If explicitly set to 0 then application runs relay process in a
# single thread, in the same thread with the listener process
# (the authentication thread will still be a separate thread).
#
# If this parameter is not set, then the default OS-dependent
# thread pattern algorithm will be employed. Usually the default
# algorithm is the most optimal, so you have to change this option
# only if you want to make some fine tweaks.
#
# In the older systems (Linux kernel before 3.9),
# the number of UDP threads is always one thread per network listening
# endpoint - including the auxiliary endpoints - unless 0 (zero) or
# 1 (one) value is set.
#
#relay-threads=0
# Lower and upper bounds of the UDP relay endpoints:
# (default values are 49152 and 65535)
#
#min-port=49152
#max-port=65535
# Uncomment to run TURN server in 'normal' 'moderate' verbose mode.
# By default the verbose mode is off.
#verbose
# Uncomment to run TURN server in 'extra' verbose mode.
# This mode is very annoying and produces lots of output.
# Not recommended under any normal circumstances.
#
#Verbose
# Uncomment to use fingerprints in the TURN messages.
# By default the fingerprints are off.
#
#fingerprint
# Uncomment to use long-term credential mechanism.
# By default no credentials mechanism is used (any user allowed).
#
#lt-cred-mech
# This option is opposite to lt-cred-mech.
# (TURN Server with no-auth option allows anonymous access).
# If neither option is defined, and no users are defined,
# then no-auth is default. If at least one user is defined,
# in this file or in command line or in usersdb file, then
# lt-cred-mech is default.
#
#no-auth
# TURN REST API flag.
# (Time Limited Long Term Credential)
# Flag that sets a special authorization option that is based upon authentication secret.
#
# This feature's purpose is to support "TURN Server REST API", see
# "TURN REST API" link in the project's page
# https://github.com/coturn/coturn/
#
# This option is used with timestamp:
#
# usercombo -> "timestamp:userid"
# turn user -> usercombo
# turn password -> base64(hmac(secret key, usercombo))
#
# This allows TURN credentials to be accounted for a specific user id.
# If you don't have a suitable id, the timestamp alone can be used.
# This option is just turning on secret-based authentication.
# The actual value of the secret is defined either by option static-auth-secret,
# or can be found in the turn_secret table in the database (see below).
#
# Read more about it:
# - https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00
# - https://www.ietf.org/proceedings/87/slides/slides-87-behave-10.pdf
#
# Be aware that use-auth-secret overrides some part of lt-cred-mech.
# Notice that this feature depends internally on lt-cred-mech, so if you set
# use-auth-secret then it enables internally automatically lt-cred-mech option
# like if you enable both.
#
# You can use only one of the to auth mechanisms in the same time because,
# both mechanism use the username and password validation in different way.
#
# This way be aware that you can't use both auth mechnaism in the same time!
# Use in config either the lt-cred-mech or the use-auth-secret
# to avoid any confusion.
#
use-auth-secret
# 'Static' authentication secret value (a string) for TURN REST API only.
# If not set, then the turn server
# will try to use the 'dynamic' value in turn_secret table
# in user database (if present). The database-stored value can be changed on-the-fly
# by a separate program, so this is why that other mode is 'dynamic'.
#
#static-auth-secret=north
# Server name used for
# the oAuth authentication purposes.
# The default value is the realm name.
#
#server-name=blackdow.carleon.gov
# Flag that allows oAuth authentication.
#
#oauth
# 'Static' user accounts for long term credentials mechanism, only.
# This option cannot be used with TURN REST API.
# 'Static' user accounts are NOT dynamically checked by the turnserver process,
# so that they can NOT be changed while the turnserver is running.
#
#user=username1:key1
#user=username2:key2
# OR:
#user=username1:password1
#user=username2:password2
#
# Keys must be generated by turnadmin utility. The key value depends
# on user name, realm, and password:
#
# Example:
# $ turnadmin -k -u ninefingers -r north.gov -p youhavetoberealistic
# Output: 0xbc807ee29df3c9ffa736523fb2c4e8ee
# ('0x' in the beginning of the key is what differentiates the key from
# password. If it has 0x then it is a key, otherwise it is a password).
#
# The corresponding user account entry in the config file will be:
#
#user=ninefingers:0xbc807ee29df3c9ffa736523fb2c4e8ee
# Or, equivalently, with open clear password (less secure):
#user=ninefingers:youhavetoberealistic
#
# SQLite database file name.
#
# Default file name is /var/db/turndb or /usr/local/var/db/turndb or
# /var/lib/turn/turndb.
#
#userdb=/var/db/turndb
# PostgreSQL database connection string in the case that we are using PostgreSQL
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
# See http://www.postgresql.org/docs/8.4/static/libpq-connect.html for 8.x PostgreSQL
# versions connection string format, see
# http://www.postgresql.org/docs/9.2/static/libpq-connect.html#LIBPQ-CONNSTRING
# for 9.x and newer connection string formats.
#
#psql-userdb="host=<host> dbname=<database-name> user=<database-user> password=<database-user-password> connect_timeout=30"
# MySQL database connection string in the case that we are using MySQL
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
#
# Optional connection string parameters for the secure communications (SSL):
# ca, capath, cert, key, cipher
# (see http://dev.mysql.com/doc/refman/5.1/en/ssl-options.html for the
# command options description).
#
# Use string format as below (space separated parameters, all optional):
#
#mysql-userdb="host=<host> dbname=<database-name> user=<database-user> password=<database-user-password> port=<port> connect_timeout=<seconds> read_timeout=<seconds>"
# If you want to use in the MySQL connection string the password in encrypted format,
# then set in this option the MySQL password encryption secret key file.
#
# Warning: If this option is set, then mysql password must be set in "mysql-userdb" in encrypted format!
# If you want to use cleartext password then do not set this option!
#
# This is the file path which contain secret key of aes encryption while using password encryption.
#
#secret-key-file=/path/
# MongoDB database connection string in the case that we are using MongoDB
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
# Use string format is described at http://hergert.me/docs/mongo-c-driver/mongoc_uri.html
#
#mongo-userdb="mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]"
# Redis database connection string in the case that we are using Redis
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
# Use string format as below (space separated parameters, all optional):
#
#redis-userdb="ip=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
# Redis status and statistics database connection string, if used (default - empty, no Redis stats DB used).
# This database keeps allocations status information, and it can be also used for publishing
# and delivering traffic and allocation event notifications.
# The connection string has the same parameters as redis-userdb connection string.
# Use string format as below (space separated parameters, all optional):
#
#redis-statsdb="ip=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
# The default realm to be used for the users when no explicit
# origin/realm relationship was found in the database, or if the TURN
# server is not using any database (just the commands-line settings
# and the userdb file). Must be used with long-term credentials
# mechanism or with TURN REST API.
#
# Note: If default realm is not specified at all, then realm falls back to the host domain name.
# If domain name is empty string, or '(None)', then it is initialized to am empty string.
#
#realm=mycompany.org
# The flag that sets the origin consistency
# check: across the session, all requests must have the same
# main ORIGIN attribute value (if the ORIGIN was
# initially used by the session).
#
#check-origin-consistency
# Per-user allocation quota.
# default value is 0 (no quota, unlimited number of sessions per user).
# This option can also be set through the database, for a particular realm.
#
#user-quota=0
# Total allocation quota.
# default value is 0 (no quota).
# This option can also be set through the database, for a particular realm.
#
#total-quota=0
# Max bytes-per-second bandwidth a TURN session is allowed to handle
# (input and output network streams are treated separately). Anything above
# that limit will be dropped or temporary suppressed (within
# the available buffer limits).
# This option can also be set through the database, for a particular realm.
#
#max-bps=0
#
# Maximum server capacity.
# Total bytes-per-second bandwidth the TURN server is allowed to allocate
# for the sessions, combined (input and output network streams are treated separately).
#
# bps-capacity=0
# Uncomment if no UDP client listener is desired.
# By default UDP client listener is always started.
#
#no-udp
# Uncomment if no TCP client listener is desired.
# By default TCP client listener is always started.
#
#no-tcp
# Uncomment if no TLS client listener is desired.
# By default TLS client listener is always started.
#
#no-tls
# Uncomment if no DTLS client listener is desired.
# By default DTLS client listener is always started.
#
#no-dtls
# Uncomment if no UDP relay endpoints are allowed.
# By default UDP relay endpoints are enabled (like in RFC 5766).
#
#no-udp-relay
# Uncomment if no TCP relay endpoints are allowed.
# By default TCP relay endpoints are enabled (like in RFC 6062).
#
#no-tcp-relay
# Uncomment if extra security is desired,
# with nonce value having limited lifetime.
# By default, the nonce value is unique for a session,
# and has unlimited lifetime.
# Set this option to limit the nonce lifetime.
# It defaults to 600 secs (10 min) if no value is provided. After that delay,
# the client will get 438 error and will have to re-authenticate itself.
#
#stale-nonce=600
# Uncomment if you want to set the maximum allocation
# time before it has to be refreshed.
# Default is 3600s.
#
#max-allocate-lifetime=3600
# Uncomment to set the lifetime for the channel.
# Default value is 600 secs (10 minutes).
# This value MUST not be changed for production purposes.
#
#channel-lifetime=600
# Uncomment to set the permission lifetime.
# Default to 300 secs (5 minutes).
# In production this value MUST not be changed,
# however it can be useful for test purposes.
#
#permission-lifetime=300
# Certificate file.
# Use an absolute path or path relative to the
# configuration file.
#
#cert=/usr/local/etc/turn_server_cert.pem
# Private key file.
# Use an absolute path or path relative to the
# configuration file.
# Use PEM file format.
#
#pkey=/usr/local/etc/turn_server_pkey.pem
# Private key file password, if it is in encoded format.
# This option has no default value.
#
#pkey-pwd=...
# Allowed OpenSSL cipher list for TLS/DTLS connections.
# Default value is "DEFAULT".
#
#cipher-list="DEFAULT"
# CA file in OpenSSL format.
# Forces TURN server to verify the client SSL certificates.
# By default it is not set: there is no default value and the client
# certificate is not checked.
#
# Example:
#CA-file=/etc/ssh/id_rsa.cert
# Curve name for EC ciphers, if supported by OpenSSL
# library (TLS and DTLS). The default value is prime256v1,
# if pre-OpenSSL 1.0.2 is used. With OpenSSL 1.0.2+,
# an optimal curve will be automatically calculated, if not defined
# by this option.
#
#ec-curve-name=prime256v1
# Use 566 bits predefined DH TLS key. Default size of the key is 1066.
#
#dh566
# Use 2066 bits predefined DH TLS key. Default size of the key is 1066.
#
#dh2066
# Use custom DH TLS key, stored in PEM format in the file.
# Flags --dh566 and --dh2066 are ignored when the DH key is taken from a file.
#
#dh-file=<DH-PEM-file-name>
# Flag to prevent stdout log messages.
# By default, all log messages are going to both stdout and to
# the configured log file. With this option everything will be
# going to the configured log only (unless the log file itself is stdout).
#
#no-stdout-log
# Option to set the log file name.
# By default, the turnserver tries to open a log file in
# /var/log, /var/tmp, /tmp and current directories directories
# (which open operation succeeds first that file will be used).
# With this option you can set the definite log file name.
# The special names are "stdout" and "-" - they will force everything
# to the stdout. Also, the "syslog" name will force everything to
# the system log (syslog).
# In the runtime, the logfile can be reset with the SIGHUP signal
# to the turnserver process.
#
#log-file=/var/tmp/turn.log
# Option to redirect all log output into system log (syslog).
#
syslog
# This flag means that no log file rollover will be used, and the log file
# name will be constructed as-is, without PID and date appendage.
# This option can be used, for example, together with the logrotate tool.
#
#simple-log
# Option to set the "redirection" mode. The value of this option
# will be the address of the alternate server for UDP & TCP service in form of
# <ip>[:<port>]. The server will send this value in the attribute
# ALTERNATE-SERVER, with error 300, on ALLOCATE request, to the client.
# Client will receive only values with the same address family
# as the client network endpoint address family.
# See RFC 5389 and RFC 5766 for ALTERNATE-SERVER functionality description.
# The client must use the obtained value for subsequent TURN communications.
# If more than one --alternate-server options are provided, then the functionality
# can be more accurately described as "load-balancing" than a mere "redirection".
# If the port number is omitted, then the default port
# number 3478 for the UDP/TCP protocols will be used.
# Colon (:) characters in IPv6 addresses may conflict with the syntax of
# the option. To alleviate this conflict, literal IPv6 addresses are enclosed
# in square brackets in such resource identifiers, for example:
# [2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478 .
# Multiple alternate servers can be set. They will be used in the
# round-robin manner. All servers in the pool are considered of equal weight and
# the load will be distributed equally. For example, if we have 4 alternate servers,
# then each server will receive 25% of ALLOCATE requests. A alternate TURN server
# address can be used more than one time with the alternate-server option, so this
# can emulate "weighting" of the servers.
#
# Examples:
#alternate-server=1.2.3.4:5678
#alternate-server=11.22.33.44:56789
#alternate-server=5.6.7.8
#alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478
# Option to set alternative server for TLS & DTLS services in form of
# <ip>:<port>. If the port number is omitted, then the default port
# number 5349 for the TLS/DTLS protocols will be used. See the previous
# option for the functionality description.
#
# Examples:
#tls-alternate-server=1.2.3.4:5678
#tls-alternate-server=11.22.33.44:56789
#tls-alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478
# Option to suppress TURN functionality, only STUN requests will be processed.
# Run as STUN server only, all TURN requests will be ignored.
# By default, this option is NOT set.
#
#stun-only
# Option to suppress STUN functionality, only TURN requests will be processed.
# Run as TURN server only, all STUN requests will be ignored.
# By default, this option is NOT set.
#
#no-stun
# This is the timestamp/username separator symbol (character) in TURN REST API.
# The default value is ':'.
# rest-api-separator=:
# Flag that can be used to allow peers on the loopback addresses (127.x.x.x and ::1).
# This is an extra security measure.
#
# (To avoid any security issue that allowing loopback access may raise,
# the no-loopback-peers option is replaced by allow-loopback-peers.)
#
# Allow it only for testing in a development environment!
# In production it adds a possible security vulnerability, so for security reasons
# it is not allowed using it together with empty cli-password.
#
#allow-loopback-peers
# Flag that can be used to disallow peers on well-known broadcast addresses (224.0.0.0 and above, and FFXX:*).
# This is an extra security measure.
#
#no-multicast-peers
# Option to set the max time, in seconds, allowed for full allocation establishment.
# Default is 60 seconds.
#
#max-allocate-timeout=60
# Option to allow or ban specific ip addresses or ranges of ip addresses.
# If an ip address is specified as both allowed and denied, then the ip address is
# considered to be allowed. This is useful when you wish to ban a range of ip
# addresses, except for a few specific ips within that range.
#
# This can be used when you do not want users of the turn server to be able to access
# machines reachable by the turn server, but would otherwise be unreachable from the
# internet (e.g. when the turn server is sitting behind a NAT)
#
# Examples:
# denied-peer-ip=83.166.64.0-83.166.95.255
# allowed-peer-ip=83.166.68.45
# File name to store the pid of the process.
# Default is /var/run/turnserver.pid (if superuser account is used) or
# /var/tmp/turnserver.pid .
#
#pidfile="/var/run/turnserver.pid"
# Require authentication of the STUN Binding request.
# By default, the clients are allowed anonymous access to the STUN Binding functionality.
#
#secure-stun
# Mobility with ICE (MICE) specs support.
#
#mobility
# Allocate Address Family according
# If enabled then TURN server allocates address family according the TURN
# Client <=> Server communication address family.
# (By default coTURN works according RFC 6156.)
# !!Warning: Enabling this option breaks RFC6156 section-4.2 (violates use default IPv4)!!
#
#keep-address-family
# User name to run the process. After the initialization, the turnserver process
# will make an attempt to change the current user ID to that user.
#
#proc-user=<user-name>
# Group name to run the process. After the initialization, the turnserver process
# will make an attempt to change the current group ID to that group.
#
#proc-group=<group-name>
# Turn OFF the CLI support.
# By default it is always ON.
# See also options cli-ip and cli-port.
#
#no-cli
#Local system IP address to be used for CLI server endpoint. Default value
# is 127.0.0.1.
#
#cli-ip=127.0.0.1
# CLI server port. Default is 5766.
#
#cli-port=5766
# CLI access password. Default is empty (no password).
# For the security reasons, it is recommended to use the encrypted
# for of the password (see the -P command in the turnadmin utility).
#
# Secure form for password 'qwerty':
#
#cli-password=$5$79a316b350311570$81df9cfb9af7f5e5a76eada31e7097b663a0670f99a3c07ded3f1c8e59c5658a
#
# Or unsecure form for the same password:
#
#cli-password=qwerty
# Enable Web-admin support on https. By default it is Disabled.
# If it is enabled it also enables a http a simple static banner page
# with a small reminder that the admin page is available only on https.
#
#web-admin
# Local system IP address to be used for Web-admin server endpoint. Default value is 127.0.0.1.
#
#web-admin-ip=127.0.0.1
# Web-admin server port. Default is 8080.
#
#web-admin-port=8080
# Web-admin server listen on STUN/TURN worker threads
# By default it is disabled for security resons! (Not recommended in any production environment!)
#
#web-admin-listen-on-workers
# Server relay. NON-STANDARD AND DANGEROUS OPTION.
# Only for those applications when we want to run
# server applications on the relay endpoints.
# This option eliminates the IP permissions check on
# the packets incoming to the relay endpoints.
#
#server-relay
# Maximum number of output sessions in ps CLI command.
# This value can be changed on-the-fly in CLI. The default value is 256.
#
#cli-max-output-sessions
# Set network engine type for the process (for internal purposes).
#
#ne=[1|2|3]
# Do not allow an TLS/DTLS version of protocol
#
#no-tlsv1
#no-tlsv1_1
#no-tlsv1_2
static-auth-secret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106

View File

@ -1,62 +0,0 @@
# Восстановление shineup.me на новый VPS
## 1. Подготовка сервера
- ОС: Ubuntu (рекомендуется близкая версия к текущей).
- Пользователь: `player` с sudo.
- DNS пока не переключать.
## 2. Установка базовых пакетов
```bash
sudo apt update
sudo apt install -y rsync caddy coturn docker.io
```
## 3. Восстановление файлов из полного бэкапа
Предполагается, что полный бэкап лежит локально в `server-backup/archive/YYYY-MM-DD/`.
```bash
rsync -a server-backup/archive/YYYY-MM-DD/home-player/SHiNE/ player@NEW_SERVER:/home/player/SHiNE/
rsync -a server-backup/archive/YYYY-MM-DD/home-player/sites/ player@NEW_SERVER:/home/player/sites/
rsync -a server-backup/archive/YYYY-MM-DD/home-player/gitea/ player@NEW_SERVER:/home/player/gitea/
rsync -a server-backup/archive/YYYY-MM-DD/home-player/agent-memory/ player@NEW_SERVER:/home/player/agent-memory/
rsync -a server-backup/archive/YYYY-MM-DD/etc-system/caddy/ player@NEW_SERVER:/tmp/restore-caddy/
rsync -a server-backup/archive/YYYY-MM-DD/var-lib/caddy/ player@NEW_SERVER:/tmp/restore-var-lib-caddy/
rsync -a server-backup/archive/YYYY-MM-DD/etc-system/turnserver.conf player@NEW_SERVER:/tmp/turnserver.conf
rsync -a server-backup/archive/YYYY-MM-DD/etc-system/*.service player@NEW_SERVER:/tmp/
```
Далее на новом сервере:
```bash
sudo rsync -a /tmp/restore-caddy/ /etc/caddy/
sudo rsync -a /tmp/restore-var-lib-caddy/ /var/lib/caddy/
sudo cp /tmp/turnserver.conf /etc/turnserver.conf
sudo cp /tmp/*.service /etc/systemd/system/
sudo systemctl daemon-reload
```
## 4. Запуск сервисов
```bash
sudo systemctl enable --now caddy
sudo systemctl enable --now coturn
sudo systemctl enable --now docker
sudo systemctl enable --now shine-server
sudo systemctl enable --now agent-memory
```
`SHiNE-promo-solana-devnet.service` не включать: сервис снят с эксплуатации.
## 5. Проверка
```bash
sudo systemctl status caddy --no-pager
sudo systemctl status shine-server --no-pager
sudo ss -ltnp
```
Проверить:
- `https://shineup.me`
- `https://git.shineup.me`
- WebSocket `/ws`
## 6. Переключение DNS
- После полной проверки поменять A-записи доменов на IP нового VPS.

View File

@ -1,55 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
TODAY="$(date +%F)"
DEST_NAME="${DEST_NAME:-${TODAY}}"
DEST_DIR="${ROOT_DIR}/archive/${DEST_NAME}"
RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync")
mkdir -p "${DEST_DIR}"
echo "[1/4] Создаю структуру бэкапа: ${DEST_DIR}"
mkdir -p "${DEST_DIR}/home-player" "${DEST_DIR}/etc-system" "${DEST_DIR}/var-lib/docker-images"
echo "[2/4] Копирую основные данные /home/player"
rsync -aH --delete \
"${RSYNC_REMOTE_SUDO[@]}" \
--exclude='SHiNE/shine-server/logs/' \
--exclude='SHiNE/**/.elaira_logs/' \
--exclude='**/*.log' \
"${REMOTE_HOST}:/home/player/SHiNE/" "${DEST_DIR}/home-player/SHiNE/"
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/sites/" "${DEST_DIR}/home-player/sites/"
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/gitea/" "${DEST_DIR}/home-player/gitea/"
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/agent-memory/" "${DEST_DIR}/home-player/agent-memory/"
echo "[3/4] Копирую системные конфиги"
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/" "${DEST_DIR}/etc-system/caddy/"
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/var/lib/caddy/" "${DEST_DIR}/var-lib/caddy/"
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/turnserver.conf" "${DEST_DIR}/etc-system/turnserver.conf"
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${DEST_DIR}/etc-system/"
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${DEST_DIR}/etc-system/"
ssh "${REMOTE_HOST}" 'sudo -n docker image save gitea/gitea:1.22.6' > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar"
echo "[4/4] Создаю манифест"
{
echo "backup_date=${TODAY}"
echo "remote_host=${REMOTE_HOST}"
echo "created_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo
echo "sizes:"
du -sh "${DEST_DIR}/home-player/SHiNE" || true
du -sh "${DEST_DIR}/home-player/sites" || true
du -sh "${DEST_DIR}/home-player/gitea" || true
du -sh "${DEST_DIR}/home-player/agent-memory" || true
du -sh "${DEST_DIR}/etc-system" || true
du -sh "${DEST_DIR}/var-lib/caddy" || true
du -sh "${DEST_DIR}/var-lib/docker-images" || true
echo
echo "total:"
du -sh "${DEST_DIR}" || true
} > "${DEST_DIR}/MANIFEST.txt"
echo "Готово: ${DEST_DIR}"

View File

@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
SCHEME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CAP_DIR="${SCHEME_DIR}/captures"
CFG_DIR="${SCHEME_DIR}/configs"
mkdir -p "${CAP_DIR}" "${CFG_DIR}/systemd"
echo "[1/3] Обновляю инвентарь"
ssh "${REMOTE_HOST}" 'hostnamectl --static; echo ---; uname -a; echo ---; df -h; echo ---; lsblk -f' > "${CAP_DIR}/01_host_disk.txt"
ssh "${REMOTE_HOST}" 'systemctl list-unit-files --type=service --state=enabled --no-pager' > "${CAP_DIR}/02_enabled_services.txt"
ssh "${REMOTE_HOST}" 'sudo -n systemctl list-units --type=service --state=running --no-pager' > "${CAP_DIR}/03_running_services.txt"
ssh "${REMOTE_HOST}" 'sudo -n ss -ltnp' > "${CAP_DIR}/04_listen_ports.txt"
ssh "${REMOTE_HOST}" 'sudo -n docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"' > "${CAP_DIR}/05_docker_ps.txt"
ssh "${REMOTE_HOST}" 'du -sh /home/player/* 2>/dev/null | sort -h' > "${CAP_DIR}/06_home_player_sizes.txt"
ssh "${REMOTE_HOST}" 'sudo -n du -xhd1 /var 2>/dev/null | sort -h' > "${CAP_DIR}/07_var_sizes.txt"
echo "[2/3] Обновляю ключевые конфиги"
rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
rsync -a "${REMOTE_HOST}:/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf"
rsync -a "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/"
rsync -a "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/"
echo "[3/3] Метка обновления"
date -u +%Y-%m-%dT%H:%M:%SZ > "${CAP_DIR}/UPDATED_AT_UTC.txt"
echo "Готово: ${SCHEME_DIR}"

View File

@ -9,6 +9,12 @@ const ITEMS = [
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' },
];
const CHANNEL_HOLD_MS = 260;
const CHANNEL_MODES = Object.freeze([
{ key: 'feed', label: 'Каналы' },
{ key: 'dialogs', label: 'Чаты' },
{ key: 'my', label: 'Мои' },
]);
function getTotalUnreadMessages() {
const chats = Object.values(state.chats || {});
@ -85,7 +91,7 @@ export function renderToolbar(currentPageId, navigate) {
btn.append(badge);
}
if (item.pageId === 'channels-list') {
btn.addEventListener('click', () => navigate('channels-list/feed'));
installChannelsHoldSwitcher(btn, navigate);
} else {
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
}
@ -94,3 +100,90 @@ export function renderToolbar(currentPageId, navigate) {
return root;
}
function installChannelsHoldSwitcher(button, navigate) {
let holdTimer = 0;
let pressed = false;
let holdActive = false;
let overlay = null;
let selectedMode = 'feed';
const clearTimer = () => {
if (holdTimer) {
window.clearTimeout(holdTimer);
holdTimer = 0;
}
};
const closeOverlay = () => {
if (overlay) overlay.remove();
overlay = null;
holdActive = false;
};
const setSelectedModeByX = (clientX) => {
if (!overlay) return;
const rect = overlay.getBoundingClientRect();
const part = rect.width / 3;
const localX = Math.max(0, Math.min(rect.width - 1, clientX - rect.left));
const index = Math.max(0, Math.min(2, Math.floor(localX / Math.max(1, part))));
selectedMode = CHANNEL_MODES[index].key;
const buttons = overlay.querySelectorAll('.toolbar-channels-hold-item');
buttons.forEach((el, idx) => {
el.classList.toggle('is-active', idx === index);
});
};
const openOverlay = () => {
const rect = button.getBoundingClientRect();
overlay = document.createElement('div');
overlay.className = 'toolbar-channels-hold-overlay';
overlay.innerHTML = CHANNEL_MODES.map((mode) => (
`<button type="button" class="toolbar-channels-hold-item${mode.key === selectedMode ? ' is-active' : ''}" data-mode="${mode.key}">${mode.label}</button>`
)).join('');
overlay.style.left = `${Math.round(rect.left + rect.width / 2)}px`;
overlay.style.top = `${Math.round(rect.top - 12)}px`;
document.body.append(overlay);
holdActive = true;
};
button.addEventListener('pointerdown', (event) => {
pressed = true;
holdActive = false;
selectedMode = 'feed';
clearTimer();
holdTimer = window.setTimeout(() => {
if (!pressed) return;
openOverlay();
setSelectedModeByX(event.clientX);
}, CHANNEL_HOLD_MS);
});
button.addEventListener('pointermove', (event) => {
if (holdActive) setSelectedModeByX(event.clientX);
});
button.addEventListener('pointerup', () => {
clearTimer();
const wasHold = holdActive;
const mode = selectedMode;
pressed = false;
closeOverlay();
if (wasHold) {
navigate(`channels-list/${mode}`);
return;
}
navigate('channels-list/feed');
});
button.addEventListener('pointercancel', () => {
clearTimer();
pressed = false;
closeOverlay();
});
button.addEventListener('contextmenu', (event) => {
event.preventDefault();
});
}

View File

@ -11,7 +11,6 @@ import {
writeChannelNotificationsState,
} from '../services/channels-ux.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
@ -1177,12 +1176,26 @@ export function render({ navigate, route }) {
const topBarLeft = document.createElement('div');
topBarLeft.className = 'channels-top-left';
const backBtn = document.createElement('button');
backBtn.type = 'button';
backBtn.className = 'icon-btn channels-top-back-btn';
backBtn.textContent = '←';
backBtn.setAttribute('aria-label', 'Назад');
backBtn.addEventListener('click', () => navigateBack());
const backToFeedBtn = document.createElement('button');
backToFeedBtn.type = 'button';
backToFeedBtn.className = 'icon-btn channels-top-back-btn';
backToFeedBtn.textContent = '←';
backToFeedBtn.setAttribute('aria-label', 'Назад');
backToFeedBtn.addEventListener('click', () => {
if (listState.activeTab === 'feed') return;
listState.activeTab = 'feed';
rerenderList();
});
const allChannelsBtn = document.createElement('button');
allChannelsBtn.type = 'button';
allChannelsBtn.className = 'secondary-btn channels-top-switch-btn';
allChannelsBtn.textContent = 'Все каналы';
allChannelsBtn.addEventListener('click', () => {
if (listState.activeTab === 'feed') return;
listState.activeTab = 'feed';
rerenderList();
});
const topTitle = document.createElement('strong');
topTitle.className = 'channels-top-title';
@ -1197,16 +1210,6 @@ export function render({ navigate, route }) {
rerenderList();
});
const topBarRight = document.createElement('div');
topBarRight.className = 'channels-top-right';
const findChannelBtn = document.createElement('button');
findChannelBtn.type = 'button';
findChannelBtn.className = 'icon-btn channels-top-search-btn';
findChannelBtn.textContent = '🔎';
findChannelBtn.setAttribute('aria-label', 'Найти канал');
findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate }));
const createInMyBtn = document.createElement('button');
createInMyBtn.type = 'button';
createInMyBtn.className = 'icon-btn channels-top-add-btn';
@ -1214,19 +1217,8 @@ export function render({ navigate, route }) {
createInMyBtn.setAttribute('aria-label', 'Создать канал');
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
const switchToAllBtn = document.createElement('button');
switchToAllBtn.type = 'button';
switchToAllBtn.className = 'secondary-btn channels-top-switch-btn';
switchToAllBtn.textContent = 'Все каналы';
switchToAllBtn.addEventListener('click', () => {
if (listState.activeTab === 'feed') return;
listState.activeTab = 'feed';
rerenderList();
});
topBarLeft.append(backBtn, topTitle);
topBarRight.append(myChannelsBtn, findChannelBtn, createInMyBtn);
topBarEl.append(topBarLeft, topBarRight);
topBarLeft.append(backToFeedBtn, allChannelsBtn, topTitle, myChannelsBtn);
topBarEl.append(topBarLeft, createInMyBtn);
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
@ -1256,21 +1248,17 @@ export function render({ navigate, route }) {
});
if (listState.activeTab === 'my' && !isGuest) {
backToFeedBtn.style.display = '';
allChannelsBtn.style.display = '';
myChannelsBtn.style.display = 'none';
topTitle.textContent = 'Мои каналы';
findChannelBtn.style.display = 'none';
switchToAllBtn.style.display = '';
createInMyBtn.style.display = '';
if (!switchToAllBtn.isConnected) topBarLeft.append(switchToAllBtn);
if (topTitle.parentElement !== topBarRight) topBarRight.prepend(topTitle);
} else {
backToFeedBtn.style.display = 'none';
allChannelsBtn.style.display = 'none';
myChannelsBtn.style.display = isGuest ? 'none' : '';
topTitle.textContent = 'Все каналы';
findChannelBtn.style.display = '';
switchToAllBtn.style.display = 'none';
topTitle.textContent = 'Каналы';
createInMyBtn.style.display = 'none';
if (switchToAllBtn.isConnected) switchToAllBtn.remove();
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
}
updateBottomCta({
@ -1316,9 +1304,6 @@ export function render({ navigate, route }) {
isTabEmpty: true,
});
// Применяем корректное состояние хедера сразу на первом рендере,
// чтобы не показывать лишние кнопки до первой перерисовки.
rerenderList();
loadFeedAndRender({ screen, listState, contentEl, navigate });
screen.cleanup = () => {

View File

@ -1,6 +1,5 @@
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
@ -22,7 +21,6 @@ export function render({ navigate }) {
<label class="checkbox-row"><input type="checkbox" id="connect-root" ${state.deviceConnect.root ? 'checked' : ''} /> root key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-blockchain" ${state.deviceConnect.blockchain ? 'checked' : ''} /> blockchain key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-device" checked disabled /> device key</label>
<p class="meta-muted" id="connect-keys-status">Проверяем ключи на этом устройстве...</p>
<div class="row">
<button class="icon-btn small-btn" type="button" id="tech-help">Техсправка</button>
</div>
@ -35,8 +33,6 @@ export function render({ navigate }) {
const rootToggle = card.querySelector('#connect-root');
const blockchainToggle = card.querySelector('#connect-blockchain');
const deviceToggle = card.querySelector('#connect-device');
const statusEl = card.querySelector('#connect-keys-status');
const openQrBtn = card.querySelector('#open-qr');
deviceToggle.checked = true;
rootToggle.addEventListener('change', () => {
@ -89,47 +85,6 @@ export function render({ navigate }) {
card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-view'));
(async () => {
try {
if (!state.session.login || !state.session.storagePwdInMemory) {
throw new Error('Нет активной сессии');
}
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const hasRoot = Boolean(savedKeys.rootKey);
const hasBlockchain = Boolean(savedKeys.blockchainKey);
const hasDevice = Boolean(savedKeys.deviceKey);
rootToggle.disabled = !hasRoot;
blockchainToggle.disabled = !hasBlockchain;
deviceToggle.disabled = true;
state.deviceConnect.root = hasRoot && rootToggle.checked;
state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked;
state.deviceConnect.device = hasDevice;
rootToggle.checked = state.deviceConnect.root;
blockchainToggle.checked = state.deviceConnect.blockchain;
deviceToggle.checked = hasDevice;
openQrBtn.disabled = !hasDevice;
const available = [
hasDevice ? 'device' : '',
hasBlockchain ? 'blockchain' : '',
hasRoot ? 'root' : '',
].filter(Boolean);
statusEl.textContent = available.length
? `На этом устройстве доступны: ${available.join(', ')}.`
: 'На этом устройстве нет сохранённых ключей для передачи.';
} catch {
rootToggle.disabled = true;
blockchainToggle.disabled = true;
deviceToggle.checked = false;
state.deviceConnect.root = false;
state.deviceConnect.blockchain = false;
state.deviceConnect.device = false;
openQrBtn.disabled = true;
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
}
})();
helpModal.addEventListener('click', (event) => {
const target = event.target;
if (target instanceof HTMLElement && target.dataset.close === 'true') {

View File

@ -1,11 +1,6 @@
import { renderHeader } from '../components/header.js';
import { profile } from '../mock-data.js';
import { state } from '../state.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import {
describeTransferKeys,
makeKeyTransferText,
renderQrSvg,
} from '../services/qr-key-transfer-service.js';
export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
@ -13,6 +8,11 @@ export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const selectedKeys = [];
if (state.deviceConnect.root) selectedKeys.push('root key');
if (state.deviceConnect.blockchain) selectedKeys.push('blockchain key');
if (state.deviceConnect.device) selectedKeys.push('device key');
screen.append(
renderHeader({
title: 'Показать QR-код',
@ -23,44 +23,12 @@ export function render({ navigate }) {
const card = document.createElement('div');
card.className = 'card stack qr-card';
card.innerHTML = `
<div class="qr-image" id="device-transfer-qr" aria-label="QR-код для переноса ключей"></div>
<p class="meta-muted" id="device-transfer-login">Логин: ...</p>
<p class="meta-muted" id="device-transfer-keys">Ключи: ...</p>
<p class="status-line is-unavailable" id="device-transfer-status" style="display:none;"></p>
<img class="qr-image" src="img/device-qr-64.svg" width="64" height="64" alt="QR-код для подключения" />
<p class="meta-muted">Логин пользователя: ${profile.login}</p>
<p class="meta-muted">Передаваемые ключи: ${selectedKeys.join(', ')}</p>
<button class="primary-btn" type="button" id="qr-ok">OK</button>
`;
const qrEl = card.querySelector('#device-transfer-qr');
const loginEl = card.querySelector('#device-transfer-login');
const keysEl = card.querySelector('#device-transfer-keys');
const statusEl = card.querySelector('#device-transfer-status');
(async () => {
try {
if (!state.session.login || !state.session.storagePwdInMemory) {
throw new Error('Нет активной сессии для чтения ключей');
}
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const keys = {
deviceKey: savedKeys.deviceKey || '',
blockchainKey: state.deviceConnect.blockchain ? (savedKeys.blockchainKey || '') : '',
rootKey: state.deviceConnect.root ? (savedKeys.rootKey || '') : '',
};
if (!keys.deviceKey) throw new Error('На этом устройстве нет device key');
const qrText = makeKeyTransferText({ login: state.session.login, keys });
qrEl.innerHTML = renderQrSvg(qrText);
loginEl.textContent = `Логин: ${state.session.login}`;
keysEl.textContent = `Ключи: ${describeTransferKeys(keys).join(', ')}`;
} catch (error) {
qrEl.textContent = '';
loginEl.textContent = 'Логин: нет данных';
keysEl.textContent = 'Ключи: нет данных';
statusEl.textContent = error?.message || 'Не удалось подготовить QR-код.';
statusEl.style.display = '';
}
})();
card.querySelector('#qr-ok').addEventListener('click', () => navigate('connect-device-view'));
screen.append(card);

View File

@ -36,11 +36,9 @@ export function render({ navigate }) {
actions.className = 'card stack';
actions.innerHTML = `
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
<button class="ghost-btn" type="button" id="connect-device-btn">Подключить устройство</button>
<button class="text-btn" type="button" id="show-keys-btn">Показать ключи</button>
`;
actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view'));
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
const sessionsBlock = document.createElement('div');

View File

@ -19,14 +19,11 @@ function readWalletFromUrl() {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.style.width = '100%';
screen.style.justifyItems = 'center';
const targetWallet = readWalletFromUrl();
const senderBox = document.createElement('div');
senderBox.className = 'card stack';
senderBox.style.width = 'min(100%, 320px)';
senderBox.innerHTML = `
<strong>Тестовый DEVNET-кошелёк</strong>
<p class="meta-muted" id="devnet-topup-sender-address">Адрес: ...</p>
@ -35,7 +32,6 @@ export function render({ navigate }) {
const targetBox = document.createElement('div');
targetBox.className = 'card stack';
targetBox.style.width = 'min(100%, 320px)';
targetBox.innerHTML = `
<strong>Кошелёк получателя</strong>
<p class="meta-muted" style="word-break:break-all;">${targetWallet || 'Не передан параметр wallet'}</p>
@ -44,10 +40,6 @@ export function render({ navigate }) {
const status = document.createElement('p');
status.className = 'meta-muted';
status.style.width = 'min(100%, 320px)';
status.style.overflowWrap = 'anywhere';
status.style.wordBreak = 'break-word';
status.style.whiteSpace = 'pre-wrap';
status.textContent = 'Готово к пополнению.';
const fillBtn = document.createElement('button');
@ -63,8 +55,6 @@ export function render({ navigate }) {
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
actions.style.width = 'min(100%, 320px)';
actions.style.justifySelf = 'center';
actions.append(fillBtn, backBtn);
let senderAddress = '';
@ -100,7 +90,7 @@ export function render({ navigate }) {
amountSol: TRANSFER_AMOUNT_SOL,
});
await updateSenderBalance();
status.textContent = `Готово.\nSignature: ${tx.signature}`;
status.textContent = `Готово. Signature: ${tx.signature}`;
} catch (error) {
status.textContent = `Ошибка перевода: ${error?.message || 'unknown'}`;
} finally {

View File

@ -129,15 +129,6 @@ export function render({ navigate }) {
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const serverUiButton = document.createElement('button');
serverUiButton.className = 'ghost-btn';
serverUiButton.type = 'button';
serverUiButton.textContent = 'Настроить свой сервер';
serverUiButton.addEventListener('click', () => {
const url = new URL('server-ui.html', window.location.href);
window.open(url.toString(), '_blank', 'noopener');
});
const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn';
cancelButton.type = 'button';
@ -153,7 +144,7 @@ export function render({ navigate }) {
navigate('start-view');
});
actions.append(serverUiButton, cancelButton, saveButton);
actions.append(cancelButton, saveButton);
const help = document.createElement('button');
help.className = 'help-fab';

View File

@ -1,238 +1,47 @@
import { renderHeader } from '../components/header.js';
import {
authService,
authorizeSession,
clearAuthMessages,
clearBrowserClientData,
refreshSessions,
setAuthBusy,
setAuthError,
setAuthInfo,
state,
terminateCurrentSession,
} from '../state.js';
import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-vault.js';
import { clearStoredMessages } from '../services/message-store.js';
import {
describeTransferKeys,
parseKeyTransferText,
} from '../services/qr-key-transfer-service.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'login-camera-view', title: 'Войти по QR-коду', showAppChrome: false };
function canUseBarcodeDetector() {
return typeof window.BarcodeDetector === 'function';
}
async function createQrDetector() {
if (!canUseBarcodeDetector()) return null;
try {
const formats = await window.BarcodeDetector.getSupportedFormats?.();
if (Array.isArray(formats) && !formats.includes('qr_code')) return null;
} catch {
// Некоторые браузеры не реализуют getSupportedFormats, но сам detector работает.
}
return new window.BarcodeDetector({ formats: ['qr_code'] });
}
function setStatus(statusEl, message, kind = 'info') {
statusEl.classList.toggle('is-unavailable', kind === 'error');
statusEl.classList.toggle('is-available', kind !== 'error');
statusEl.textContent = message;
statusEl.style.display = message ? '' : 'none';
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderParsedTransfer(resultEl, transfer) {
const keys = describeTransferKeys(transfer.keys);
resultEl.innerHTML = `
<p class="meta-muted">Отсканированный логин: <strong>${escapeHtml(transfer.login)}</strong></p>
<p class="meta-muted">Получены ключи: <strong>${escapeHtml(keys.join(', '))}</strong></p>
<p class="meta-muted">Войти под этим логином и очистить локальную историю старого логина?</p>
`;
}
export const pageMeta = { id: 'login-camera-view', title: 'Войти по камере', showAppChrome: false };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
clearAuthMessages();
const frame = document.createElement('div');
frame.className = 'camera-shell';
frame.innerHTML = `
<video class="camera-video" autoplay playsinline muted></video>
<div class="camera-frame"></div>
<div class="camera-hint">Наведите QR-код переноса ключей в рамку</div>
<div class="camera-error" id="login-camera-error" style="display:none;"></div>
<div class="camera-hint">Наведите QR-код в рамку</div>
`;
const manualCard = document.createElement('details');
manualCard.className = 'card stack';
manualCard.innerHTML = `
<summary>Ввести QR-текст вручную</summary>
<textarea class="input" id="login-qr-manual" rows="4" placeholder="shine-key-transfer-v1:..."></textarea>
<button class="ghost-btn" type="button" id="login-qr-manual-parse">Проверить QR-текст</button>
`;
const resultCard = document.createElement('div');
resultCard.className = 'card stack';
resultCard.style.display = 'none';
resultCard.innerHTML = `
<div id="login-qr-result"></div>
<div class="row">
<button class="ghost-btn" type="button" id="login-qr-cancel">Нет</button>
<button class="primary-btn" type="button" id="login-qr-confirm">Да</button>
</div>
`;
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
const video = frame.querySelector('video');
const cameraError = frame.querySelector('#login-camera-error');
const manualInput = manualCard.querySelector('#login-qr-manual');
const parseManualButton = manualCard.querySelector('#login-qr-manual-parse');
const resultEl = resultCard.querySelector('#login-qr-result');
const cancelButton = resultCard.querySelector('#login-qr-cancel');
const confirmButton = resultCard.querySelector('#login-qr-confirm');
let stream = null;
let detector = null;
let scanTimer = 0;
let scannedTransfer = null;
let stopped = false;
const stopCamera = () => {
stopped = true;
if (scanTimer) {
window.clearTimeout(scanTimer);
scanTimer = 0;
}
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
};
const showTransfer = (transfer) => {
scannedTransfer = transfer;
stopCamera();
renderParsedTransfer(resultEl, transfer);
resultCard.style.display = '';
setStatus(status, '', 'info');
};
const parseTransferText = (text) => {
try {
const transfer = parseKeyTransferText(text);
if (!transfer.keys.deviceKey) {
throw new Error('В QR-коде нет device key для входа');
}
showTransfer(transfer);
} catch (error) {
setStatus(status, error?.message || 'Не удалось прочитать QR-код.', 'error');
}
};
const scanLoop = async () => {
if (stopped || !detector || !video || video.readyState < 2) {
if (!stopped) scanTimer = window.setTimeout(scanLoop, 250);
return;
}
try {
const codes = await detector.detect(video);
const text = String(codes?.[0]?.rawValue || '').trim();
if (text) {
parseTransferText(text);
return;
}
} catch {
// Ошибки отдельных кадров игнорируем, камера продолжит сканирование.
}
if (!stopped) scanTimer = window.setTimeout(scanLoop, 300);
};
const startCamera = async () => {
try {
detector = await createQrDetector();
if (!detector) {
throw new Error('Этот браузер не поддерживает сканирование QR через камеру. Используйте ручной ввод QR-текста.');
}
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('Камера не поддерживается в этом браузере.');
}
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
video.srcObject = stream;
await video.play?.();
scanLoop();
} catch (error) {
cameraError.textContent = error?.message || 'Не удалось открыть камеру. Проверьте разрешения браузера.';
cameraError.style.display = '';
setStatus(status, cameraError.textContent, 'error');
}
};
parseManualButton.addEventListener('click', () => parseTransferText(manualInput.value));
cancelButton.addEventListener('click', () => {
scannedTransfer = null;
resultCard.style.display = 'none';
stopped = false;
void startCamera();
});
confirmButton.addEventListener('click', async () => {
if (!scannedTransfer) return;
confirmButton.disabled = true;
cancelButton.disabled = true;
setAuthBusy(true);
setAuthError('');
setStatus(status, 'Входим по QR-коду...', 'info');
try {
await authService.reconnect(state.entrySettings.shineServer);
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
await clearStoredMessages().catch(() => {});
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
await terminateCurrentSession();
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
const resumed = await authService.resumeSession(session.login, session.sessionId);
authorizeSession({
login: resumed.login || session.login,
sessionId: resumed.sessionId || session.sessionId,
storagePwd: resumed.storagePwd || session.storagePwd,
if (navigator.mediaDevices?.getUserMedia) {
navigator.mediaDevices
.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
.then((nextStream) => {
stream = nextStream;
video.srcObject = nextStream;
})
.catch(() => {
frame.insertAdjacentHTML('beforeend', '<div class="camera-error">Не удалось открыть камеру. Проверьте разрешения браузера.</div>');
});
state.loginDraft.login = resumed.login || session.login;
state.loginDraft.password = '';
await refreshSessions();
setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`);
navigate('profile-view');
} catch (error) {
const message = toUserMessage(error, 'Не удалось войти по QR-коду.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
setAuthBusy(false);
confirmButton.disabled = false;
cancelButton.disabled = false;
}
});
} else {
frame.insertAdjacentHTML('beforeend', '<div class="camera-error">Камера не поддерживается в этом браузере.</div>');
}
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => {
stopCamera();
navigate('login-view');
@ -240,7 +49,7 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Войти по QR-коду',
title: 'Войти по камере',
leftAction: {
label: '←',
onClick: () => {
@ -250,13 +59,9 @@ export function render({ navigate }) {
},
}),
frame,
manualCard,
resultCard,
status,
backButton,
);
void startCamera();
screen.cleanup = stopCamera;
return screen;
}

View File

@ -22,19 +22,12 @@ export function render({ navigate }) {
const loginInput = document.createElement('input');
loginInput.className = 'input';
loginInput.type = 'text';
loginInput.autocomplete = 'off';
loginInput.autocapitalize = 'off';
loginInput.spellcheck = false;
loginInput.value = state.loginDraft.login;
loginInput.placeholder = 'Введите логин';
const passwordInput = document.createElement('input');
passwordInput.className = 'input';
passwordInput.type = 'password';
passwordInput.name = 'shine-login-password';
passwordInput.autocomplete = 'new-password';
passwordInput.autocapitalize = 'off';
passwordInput.spellcheck = false;
passwordInput.value = state.loginDraft.password;
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';
@ -46,10 +39,10 @@ export function render({ navigate }) {
advanced.className = 'card stack';
advanced.innerHTML = `
<summary>Расширенные</summary>
<p class="meta-muted">Схема derivation ключей: логин нормализуется как `trim().toLowerCase()`. При непустом пароле используется Argon2id, затем из результата строится секрет для root/bch/dev ключей.</p>
<p class="meta-muted">Если пароль пустой используется прежний детерминированный режим совместимости.</p>
<p class="meta-muted">Для тестов можно оставить пустой пароль.</p>
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
<p class="meta-muted">Если пароль пустой используется прежний тестовый режим совместимости (старый детерминированный вариант).</p>
<p class="meta-muted">Для тесто оставьте пустой пароль.</p>
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=3, m=262144 KiB (256 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
`;
const status = document.createElement('p');

View File

@ -39,7 +39,7 @@ export function render({ navigate }) {
const cameraButton = document.createElement('button');
cameraButton.className = 'primary-btn';
cameraButton.type = 'button';
cameraButton.textContent = 'Отсканировать QR-код';
cameraButton.textContent = 'Войти по камере';
cameraButton.addEventListener('click', () => navigate('login-camera-view'));
const loginButton = document.createElement('button');

View File

@ -21,19 +21,12 @@ export function render({ navigate }) {
const loginInput = document.createElement('input');
loginInput.className = 'input';
loginInput.type = 'text';
loginInput.autocomplete = 'off';
loginInput.autocapitalize = 'off';
loginInput.spellcheck = false;
loginInput.value = state.registrationDraft.login;
loginInput.placeholder = 'Введите логин';
const passwordInput = document.createElement('input');
passwordInput.className = 'input';
passwordInput.type = 'password';
passwordInput.name = 'shine-register-password';
passwordInput.autocomplete = 'new-password';
passwordInput.autocapitalize = 'off';
passwordInput.spellcheck = false;
passwordInput.value = state.registrationDraft.password;
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';

View File

@ -1,6 +1,6 @@
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
import { base64ToBytes, bytesToBase58 } from '../services/crypto-utils.js';
import { bytesToBase64 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js';
export const pageMeta = { id: 'registration-draft-keys-view', title: 'Сгенерированные ключи', showAppChrome: false };
@ -15,43 +15,24 @@ function makeSecretField({ label, value }) {
const row = document.createElement('div');
row.className = 'inline-input-row';
const eyeIcon = `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>
`;
const eyeOffIcon = `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
`;
const input = document.createElement('input');
input.className = 'input key-input';
input.className = 'input';
input.type = 'password';
input.readOnly = true;
input.value = value;
const toggleBtn = document.createElement('button');
toggleBtn.className = 'icon-btn key-toggle-btn';
toggleBtn.className = 'ghost-btn';
toggleBtn.type = 'button';
toggleBtn.innerHTML = eyeOffIcon;
toggleBtn.setAttribute('aria-label', 'Показать ключ');
toggleBtn.title = 'Показать ключ';
toggleBtn.textContent = 'Показать';
toggleBtn.addEventListener('click', () => {
if (input.type === 'password') {
input.type = 'text';
toggleBtn.innerHTML = eyeIcon;
toggleBtn.setAttribute('aria-label', 'Скрыть ключ');
toggleBtn.title = 'Скрыть ключ';
toggleBtn.textContent = 'Скрыть';
} else {
input.type = 'password';
toggleBtn.innerHTML = eyeOffIcon;
toggleBtn.setAttribute('aria-label', 'Показать ключ');
toggleBtn.title = 'Показать ключ';
toggleBtn.textContent = 'Показать';
}
});
@ -69,7 +50,7 @@ function makePublicField({ label, value }) {
labelEl.textContent = label;
const input = document.createElement('input');
input.className = 'input key-input';
input.className = 'input';
input.type = 'text';
input.readOnly = true;
input.value = value;
@ -100,56 +81,39 @@ export function render({ navigate }) {
card.append(warning);
// Master secret
let secretB58 = '';
// Секрет (root key seed)
let secretB64 = '';
try {
secretB58 = bytesToBase58(base64ToBytes(keyBundle.masterSecretB64));
const rootSeed32 = extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64);
secretB64 = bytesToBase64(rootSeed32);
} catch {
secretB58 = '(не удалось извлечь)';
secretB64 = '(не удалось извлечь)';
}
card.append(makeSecretField({ label: 'Главный секрет (master secret, base58, 32 байта)', value: secretB58 }));
card.append(makeSecretField({ label: 'Секрет (root seed, 32 байта)', value: secretB64 }));
// Root key
const rootSep = document.createElement('p');
rootSep.className = 'field-label';
rootSep.textContent = 'Root key';
card.append(rootSep);
card.append(makePublicField({
label: 'Root — публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Root — приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64)),
}));
card.append(makePublicField({ label: 'Root — публичный', value: keyBundle.rootPair.publicKeyB64 }));
card.append(makeSecretField({ label: 'Root — приватный (PKCS8)', value: keyBundle.rootPair.privatePkcs8B64 }));
// Blockchain key
const bchSep = document.createElement('p');
bchSep.className = 'field-label';
bchSep.textContent = 'Blockchain key';
card.append(bchSep);
card.append(makePublicField({
label: 'Blockchain — публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.blockchainPair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Blockchain — приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.blockchainPair.privatePkcs8B64)),
}));
card.append(makePublicField({ label: 'Blockchain — публичный', value: keyBundle.blockchainPair.publicKeyB64 }));
card.append(makeSecretField({ label: 'Blockchain — приватный (PKCS8)', value: keyBundle.blockchainPair.privatePkcs8B64 }));
// Device key
const devSep = document.createElement('p');
devSep.className = 'field-label';
devSep.textContent = 'Device key (= Solana wallet)';
card.append(devSep);
card.append(makePublicField({
label: 'Device — публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.devicePair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Device — приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64)),
}));
card.append(makePublicField({ label: 'Device — публичный', value: keyBundle.devicePair.publicKeyB64 }));
card.append(makeSecretField({ label: 'Device — приватный (PKCS8)', value: keyBundle.devicePair.privatePkcs8B64 }));
}
const actions = document.createElement('div');

View File

@ -7,7 +7,6 @@ import {
setAuthInfo,
state,
} from '../state.js';
import { clearStoredMessages } from '../services/message-store.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
@ -112,8 +111,6 @@ export function render({ navigate }) {
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
}
await clearStoredMessages().catch(() => {});
authorizeSession({
login: state.registrationDraft.login,
sessionId: state.registrationDraft.sessionId,

View File

@ -1,7 +1,5 @@
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
import { bytesToBase58 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
@ -40,63 +38,40 @@ export function render({ navigate }) {
const renderField = (id, label) => {
const row = document.createElement('div');
row.className = 'key-card stack';
const eyeIcon = `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>
`;
const eyeOffIcon = `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
`;
row.innerHTML = `
<div class="row">
<span class="field-label">${label}</span>
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeOffIcon}</button>
<button class="icon-btn small-btn" type="button" data-toggle="${id}">Показать</button>
</div>
<div class="key-value key-value--compact" data-value="${id}">*****</div>
<div class="key-value" data-value="${id}">*****</div>
`;
row._eyeIcon = eyeIcon;
row._eyeOffIcon = eyeOffIcon;
return row;
};
card.append(
renderField('root', 'root key (base58)'),
renderField('blockchain', 'blockchain.key (base58)'),
renderField('device', 'device key (base58)'),
renderField('root', 'root key'),
renderField('blockchain', 'blockchain.key'),
renderField('device', 'device key'),
);
const setMissingState = (id) => {
const valueEl = card.querySelector(`[data-value="${id}"]`);
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
const field = btnEl?.closest('.key-card');
valueEl.textContent = 'нет данных';
btnEl.disabled = true;
btnEl.innerHTML = field?._eyeOffIcon || '';
btnEl.setAttribute('aria-label', 'Нет данных');
btnEl.title = 'Нет данных';
btnEl.textContent = 'Нет';
};
const updateField = (id) => {
const valueEl = card.querySelector(`[data-value="${id}"]`);
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
const field = btnEl?.closest('.key-card');
if (!keys[id]) {
setMissingState(id);
return;
}
valueEl.textContent = visible[id] ? keys[id] : '*****';
btnEl.disabled = false;
btnEl.innerHTML = visible[id]
? field?._eyeIcon || ''
: field?._eyeOffIcon || '';
btnEl.setAttribute('aria-label', visible[id] ? 'Скрыть ключ' : 'Показать ключ');
btnEl.title = visible[id] ? 'Скрыть ключ' : 'Показать ключ';
btnEl.textContent = visible[id] ? 'Скрыть' : 'Показать';
};
card.querySelectorAll('[data-toggle]').forEach((button) => {
@ -131,13 +106,9 @@ export function render({ navigate }) {
state.session.storagePwdInMemory,
);
const rootSeed32 = savedKeys.rootKey ? extractSeed32FromPkcs8B64(savedKeys.rootKey) : null;
const blockchainSeed32 = savedKeys.blockchainKey ? extractSeed32FromPkcs8B64(savedKeys.blockchainKey) : null;
const deviceSeed32 = savedKeys.deviceKey ? extractSeed32FromPkcs8B64(savedKeys.deviceKey) : null;
keys.root = rootSeed32 ? bytesToBase58(rootSeed32) : '';
keys.blockchain = blockchainSeed32 ? bytesToBase58(blockchainSeed32) : '';
keys.device = deviceSeed32 ? bytesToBase58(deviceSeed32) : '';
keys.root = savedKeys.rootKey || '';
keys.blockchain = savedKeys.blockchainKey || '';
keys.device = savedKeys.deviceKey || '';
if (keys.root || keys.blockchain || keys.device) {
status.textContent = 'Показаны только ключи, сохранённые на этом устройстве.';

View File

@ -7,6 +7,7 @@ import {
getBalanceSol,
getTopupSiteUrl,
getWalletFromStoredDeviceKey,
requestAirdropSol,
transferSol,
} from '../services/solana-wallet-service.js';
import {
@ -737,7 +738,32 @@ export function render({ navigate }) {
setStatus('Кошелёк не инициализирован.');
return;
}
window.location.assign(getTopupSiteUrl(walletAddress));
const openSite = window.confirm(
'Кнопка будет переводить на отдельный сайт пополнения.\n\nНажмите OK, чтобы открыть сайт.\nНажмите Отмена, чтобы выполнить тестовое пополнение (airdrop 1 SOL на DevNet).',
);
if (openSite) {
window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer');
setStatus('Открыт сайт пополнения. Пока также доступно тестовое пополнение.');
return;
}
topupBtn.disabled = true;
try {
const drop = await requestAirdropSol({
endpoint: state.entrySettings.solanaServer,
address: walletAddress,
amountSol: 1,
});
if (modeToken !== activeModeToken) return;
setStatus(`Тестовое пополнение выполнено (airdrop 1 SOL). Signature: ${drop.signature}`);
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка тестового пополнения: ${error?.message || 'unknown'}`);
} finally {
topupBtn.disabled = false;
}
});
content.append(backBtn, card, actions, generatedCard);
@ -898,22 +924,19 @@ export function render({ navigate }) {
setStatus('Генерация Arweave-кошелька...');
try {
let wasFirstTimeGeneration = false;
arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey({
...sessionArgsOrThrow(),
onStatus: (message) => {
const text = String(message || '').trim();
if (!text) return;
if (text.includes('впервые получаем Arweave-кошелёк')) {
wasFirstTimeGeneration = true;
setStatus('Подождите — ваш Arweave-ключ вычисляется из device key. Это происходит только один раз, потом будет мгновенно.');
setStatus('Сейчас мы впервые получаем Arweave-кошелёк из вашего приватного device key. Это может занять немного времени. После этого кошелёк будет храниться только в зашифрованном контейнере этого устройства.');
return;
}
setStatus(text);
},
});
if (modeToken !== activeModeToken) return;
if (wasFirstTimeGeneration) setStatus('');
walletAddress = arweaveWalletCtx.address;
addressEl.textContent = walletAddress;
await refreshBalance();

View File

@ -8,7 +8,6 @@ import {
exportPkcs8B64,
generateEd25519Pair,
importPkcs8Ed25519,
publicKeyB64FromPkcs8Ed25519,
randomBase64,
sha256Bytes,
signBytes,
@ -750,12 +749,7 @@ export class AuthService {
if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' });
const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.key');
const result = {
masterSecretB64: bytesToBase64(masterSecret),
rootPair,
blockchainPair,
devicePair,
};
const result = { rootPair, blockchainPair, devicePair };
this.passwordKeyBundleCache.set(cacheKey, result);
if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
return result;
@ -863,23 +857,6 @@ export class AuthService {
return { ...session, keyBundle };
}
async createSessionFromImportedSecrets(login, secrets) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('В QR-коде нет логина');
const deviceKey = String(secrets?.deviceKey || '').trim();
if (!deviceKey) throw new Error('В QR-коде нет device key для входа');
const privateKey = await importPkcs8Ed25519(deviceKey);
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(deviceKey);
const session = await this.createAuthSession(cleanLogin, {
devicePair: {
privateKey,
publicKeyB64,
},
});
return session;
}
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
let currentSecrets = {};
try {

View File

@ -25,61 +25,6 @@ function base64UrlToBase64(value) {
return normalized + '='.repeat(padLen);
}
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
export function bytesToBase58(bytes) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
if (input.length === 0) return '';
const digits = [];
for (let i = 0; i < input.length; i += 1) {
let carry = input[i];
for (let j = 0; j < digits.length; j += 1) {
const value = (digits[j] * 256) + carry;
digits[j] = value % 58;
carry = Math.floor(value / 58);
}
while (carry > 0) {
digits.push(carry % 58);
carry = Math.floor(carry / 58);
}
}
for (let i = 0; i < input.length && input[i] === 0; i += 1) {
digits.push(0);
}
return digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join('');
}
export function base58ToBytes(value) {
const text = String(value || '').trim();
if (!text) return new Uint8Array();
const digits = [];
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
const index = BASE58_ALPHABET.indexOf(char);
if (index < 0) throw new Error(`Недопустимый символ base58: ${char}`);
let carry = index;
for (let j = 0; j < digits.length; j += 1) {
const acc = (digits[j] * 58) + carry;
digits[j] = acc & 0xff;
carry = acc >> 8;
}
while (carry > 0) {
digits.push(carry & 0xff);
carry >>= 8;
}
}
for (let i = 0; i < text.length && text[i] === '1'; i += 1) {
digits.push(0);
}
return new Uint8Array(digits.reverse());
}
export function randomBase64(byteLen = 32) {
const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
return bytesToBase64(bytes);
@ -309,13 +254,6 @@ export async function importPkcs8Ed25519(pkcs8B64) {
return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
}
export async function publicKeyB64FromPkcs8Ed25519(pkcs8B64) {
const privateKey = await getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, true, ['sign']);
const jwk = await getSubtleApi().exportKey('jwk', privateKey);
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
return bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x)));
}
export async function signBase64(privateKey, text) {
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
return bytesToBase64(new Uint8Array(signature));

View File

@ -1,87 +0,0 @@
import qrcode from '../vendor-qrcode-generator.js';
const TRANSFER_PREFIX = 'shine-key-transfer-v1:';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function bytesToBase64Url(bytes) {
let binary = '';
bytes.forEach((b) => {
binary += String.fromCharCode(b);
});
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlToBytes(value) {
const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
export function keyLabel(id) {
if (id === 'root') return 'root';
if (id === 'blockchain') return 'blockchain';
if (id === 'device') return 'device';
return id;
}
export function describeTransferKeys(keys = {}) {
const out = [];
if (keys.deviceKey) out.push('device');
if (keys.blockchainKey) out.push('blockchain');
if (keys.rootKey) out.push('root');
return out;
}
export function makeKeyTransferText({ login, keys }) {
const payload = {
v: 1,
type: 'shine-key-transfer',
login: String(login || '').trim(),
keys: {
deviceKey: String(keys?.deviceKey || ''),
blockchainKey: String(keys?.blockchainKey || ''),
rootKey: String(keys?.rootKey || ''),
},
createdAtMs: Date.now(),
};
const json = JSON.stringify(payload);
return `${TRANSFER_PREFIX}${bytesToBase64Url(encoder.encode(json))}`;
}
export function parseKeyTransferText(text) {
const raw = String(text || '').trim();
if (!raw.startsWith(TRANSFER_PREFIX)) {
throw new Error('Это не QR-код переноса ключей SHiNE');
}
const json = decoder.decode(base64UrlToBytes(raw.slice(TRANSFER_PREFIX.length)));
const payload = JSON.parse(json);
if (payload?.v !== 1 || payload?.type !== 'shine-key-transfer') {
throw new Error('Неподдерживаемый формат QR-кода');
}
const login = String(payload.login || '').trim();
if (!login) throw new Error('В QR-коде нет логина');
const keys = payload.keys && typeof payload.keys === 'object' ? payload.keys : {};
if (!keys.deviceKey && !keys.blockchainKey && !keys.rootKey) {
throw new Error('В QR-коде нет ключей');
}
return {
login,
keys: {
deviceKey: String(keys.deviceKey || ''),
blockchainKey: String(keys.blockchainKey || ''),
rootKey: String(keys.rootKey || ''),
},
keyTypes: describeTransferKeys(keys),
};
}
export function renderQrSvg(text, { cellSize = 4, margin = 4 } = {}) {
const qr = qrcode(0, 'L');
qr.addData(String(text || ''), 'Byte');
qr.make();
return qr.createSvgTag(cellSize, margin);
}

View File

@ -7,7 +7,7 @@ export const DERIVATION_NAME = 'SAWD-v1';
export const MASTER_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/MASTER';
export const STREAM_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/STREAM';
export const MR_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/MILLER-RABIN';
export const MILLER_RABIN_ROUNDS = 42;
export const MILLER_RABIN_ROUNDS = 64;
export const SMALL_PRIME_LIMIT = 10000;
function getSubtle() {

View File

@ -1,7 +1,491 @@
export {
calcLimitTopupPriceLamports,
getLimitStepBytes,
getShineBlockchainUsage,
getShineUsersEconomyConfig,
updateShineUserPdaOnSolana,
} from './shine-user-pda-service.js';
import { importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
import { SHINE_PAYMENTS_PROGRAM_ID, SHINE_USERS_ECONOMY_CONFIG_SEED, SHINE_USERS_PROGRAM_ID } from '../solana-programs.js';
const MAGIC = 'SHiNE';
const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK';
const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault';
const LIMIT_STEP = 10_000n;
const UPDATE_USER_PDA_DISCRIMINATOR = new Uint8Array([42, 133, 114, 232, 38, 245, 167, 234]);
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
const BLOCK_TYPE_ROOT_KEY = 1;
const BLOCK_TYPE_DEVICE_KEY = 2;
const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3;
const BLOCK_TYPE_SERVER_PROFILE = 30;
const BLOCK_TYPE_ACCESS_SERVERS = 40;
const BLOCK_TYPE_TRUSTED_STATE = 50;
let solanaLibPromise = null;
function loadSolanaLib() {
if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
return solanaLibPromise;
}
function pushU32LE(buf, v) {
const n = Number(v) >>> 0;
buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
}
function pushU64LE(buf, v) {
const b = BigInt(v);
const lo = Number(b & 0xffffffffn) >>> 0;
const hi = Number((b >> 32n) & 0xffffffffn) >>> 0;
pushU32LE(buf, lo);
pushU32LE(buf, hi);
}
function pushStrU8(buf, value) {
const bytes = new TextEncoder().encode(String(value || ''));
if (bytes.length > 255) throw new Error('Слишком длинная строка для формата U8');
buf.push(bytes.length);
for (const x of bytes) buf.push(x);
}
function pushStrU32(buf, value) {
const bytes = new TextEncoder().encode(String(value || ''));
pushU32LE(buf, bytes.length);
for (const x of bytes) buf.push(x);
}
function pushVecU8(buf, bytes) {
const data = bytes || new Uint8Array();
pushU32LE(buf, data.length);
for (const x of data) buf.push(x);
}
function pushVecStrU32(buf, values) {
const arr = Array.isArray(values) ? values : [];
pushU32LE(buf, arr.length);
for (const s of arr) pushStrU32(buf, s);
}
function makeReader(bytes) {
let o = 0;
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ensure = (n) => { if (o + n > bytes.length) throw new Error('Повреждённый формат PDA'); };
const readU8 = () => { ensure(1); const v = dv.getUint8(o); o += 1; return v; };
const readU16 = () => { ensure(2); const v = dv.getUint16(o, true); o += 2; return v; };
const readU32 = () => { ensure(4); const v = dv.getUint32(o, true); o += 4; return v; };
const readU64 = () => { ensure(8); const v = dv.getBigUint64(o, true); o += 8; return v; };
const readBytes = (n) => { ensure(n); const out = bytes.slice(o, o + n); o += n; return out; };
const readStrU8 = () => {
const len = readU8();
return new TextDecoder().decode(readBytes(len));
};
return { readU8, readU16, readU32, readU64, readBytes, readStrU8 };
}
function parseShineUserPda(dataBytes) {
const r = makeReader(dataBytes);
const magic = new TextDecoder().decode(r.readBytes(5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA');
r.readU8();
r.readU8();
r.readU16();
const createdAtMs = r.readU64();
const updatedAtMs = r.readU64();
const recordNumber = r.readU32();
const prevRecordHash = r.readBytes(32);
const login = r.readStrU8();
const blocksCount = r.readU8();
const out = {
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
login,
rootKey: null,
deviceKey: null,
blockchain: null,
isServer: false,
serverKey: new Uint8Array(32),
serverAddress: '',
syncServers: [],
accessServers: [],
trustedCount: 0,
};
for (let i = 0; i < blocksCount; i += 1) {
const type = r.readU8();
r.readU8();
if (type === BLOCK_TYPE_ROOT_KEY) { out.rootKey = r.readBytes(32); continue; }
if (type === BLOCK_TYPE_DEVICE_KEY) { out.deviceKey = r.readBytes(32); continue; }
if (type === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
const count = r.readU8();
for (let j = 0; j < count; j += 1) {
const blockchainType = r.readU8();
const blockchainName = r.readStrU8();
const blockchainPublicKey = r.readBytes(32);
const paidLimitBytes = r.readU64();
const usedBytes = r.readU64();
const lastBlockNumber = r.readU32();
const lastBlockHash = r.readBytes(32);
const lastBlockSignature = r.readBytes(64);
const arPresent = r.readU8();
const arweaveTxId = arPresent ? r.readStrU8() : '';
if (!out.blockchain) {
out.blockchain = {
blockchainType,
blockchainName,
blockchainPublicKey,
paidLimitBytes,
usedBytes,
lastBlockNumber,
lastBlockHash,
lastBlockSignature,
arweaveTxId,
};
}
}
continue;
}
if (type === BLOCK_TYPE_SERVER_PROFILE) {
out.isServer = r.readU8() === 1;
out.serverKey = r.readBytes(32);
out.serverAddress = r.readStrU8();
const syncCount = r.readU8();
out.syncServers = [];
for (let k = 0; k < syncCount; k += 1) out.syncServers.push(r.readStrU8());
continue;
}
if (type === BLOCK_TYPE_ACCESS_SERVERS) {
const accessCount = r.readU8();
out.accessServers = [];
for (let k = 0; k < accessCount; k += 1) out.accessServers.push(r.readStrU8());
continue;
}
if (type === BLOCK_TYPE_TRUSTED_STATE) {
out.trustedCount = r.readU8();
continue;
}
throw new Error(`Неизвестный блок PDA: ${type}`);
}
if (!out.rootKey || !out.deviceKey || !out.blockchain) {
throw new Error('В PDA отсутствуют обязательные блоки');
}
return out;
}
function serializeUnsignedRecordFromState(stateLike) {
const buf = [];
const login = String(stateLike.login || '');
const bch = stateLike.blockchain;
buf.push(0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0);
pushU64LE(buf, stateLike.createdAtMs);
pushU64LE(buf, stateLike.updatedAtMs);
pushU32LE(buf, stateLike.recordNumber);
for (const x of stateLike.prevRecordHash) buf.push(x);
pushStrU8(buf, login);
const blocksCount = stateLike.isServer ? 6 : 5;
buf.push(blocksCount);
buf.push(BLOCK_TYPE_ROOT_KEY, 0);
for (const x of stateLike.rootKey) buf.push(x);
buf.push(BLOCK_TYPE_DEVICE_KEY, 0);
for (const x of stateLike.deviceKey) buf.push(x);
buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1);
buf.push(bch.blockchainType);
pushStrU8(buf, bch.blockchainName);
for (const x of bch.blockchainPublicKey) buf.push(x);
pushU64LE(buf, bch.paidLimitBytes);
pushU64LE(buf, bch.usedBytes);
pushU32LE(buf, bch.lastBlockNumber);
for (const x of bch.lastBlockHash) buf.push(x);
for (const x of bch.lastBlockSignature) buf.push(x);
if (String(bch.arweaveTxId || '').trim()) {
buf.push(1);
pushStrU8(buf, bch.arweaveTxId);
} else {
buf.push(0);
}
if (stateLike.isServer) {
buf.push(BLOCK_TYPE_SERVER_PROFILE, 0, 1);
for (const x of stateLike.serverKey) buf.push(x);
pushStrU8(buf, stateLike.serverAddress);
const sync = Array.isArray(stateLike.syncServers) ? stateLike.syncServers : [];
buf.push(sync.length & 0xff);
for (const s of sync) pushStrU8(buf, s);
}
buf.push(BLOCK_TYPE_ACCESS_SERVERS, 0);
const access = Array.isArray(stateLike.accessServers) ? stateLike.accessServers : [];
buf.push(access.length & 0xff);
for (const s of access) pushStrU8(buf, s);
buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, Number(stateLike.trustedCount || 0) & 0xff);
const recLen = buf.length + 64;
buf[7] = recLen & 0xff;
buf[8] = (recLen >>> 8) & 0xff;
return new Uint8Array(buf);
}
function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber, lastBlockHash32, usedBytes) {
const buf = [];
for (const x of new TextEncoder().encode(LAST_BLOCK_STATE_PREFIX)) buf.push(x);
pushStrU8(buf, login);
pushStrU8(buf, blockchainName);
pushU32LE(buf, lastBlockNumber);
for (const x of lastBlockHash32) buf.push(x);
pushU64LE(buf, usedBytes);
return new Uint8Array(buf);
}
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
const sigOff = 16;
const pkOff = sigOff + 64;
const msgOff = pkOff + 32;
const data = new Uint8Array(msgOff + 32);
const v = new DataView(data.buffer);
data[0] = 1;
data[1] = 0;
v.setUint16(2, sigOff, true);
v.setUint16(4, 0xffff, true);
v.setUint16(6, pkOff, true);
v.setUint16(8, 0xffff, true);
v.setUint16(10, msgOff, true);
v.setUint16(12, 32, true);
v.setUint16(14, 0xffff, true);
data.set(sig64, sigOff);
data.set(pubkey32, pkOff);
data.set(msgHash32, msgOff);
return data;
}
function serializeUpdateUserPdaArgs(args) {
const b = [];
for (const x of UPDATE_USER_PDA_DISCRIMINATOR) b.push(x);
pushStrU32(b, args.login);
for (const x of args.rootKey32) b.push(x);
pushU64LE(b, args.createdAtMs);
pushU64LE(b, args.updatedAtMs);
pushU32LE(b, args.version);
pushVecU8(b, args.prevHash32);
pushU64LE(b, args.additionalLimitBytes);
for (const x of args.deviceKey32) b.push(x);
for (const x of args.blockchainPublicKey32) b.push(x);
pushStrU32(b, args.blockchainName);
pushU64LE(b, args.usedBytes);
pushU32LE(b, args.lastBlockNumber);
pushVecU8(b, args.lastBlockHash32);
pushVecU8(b, args.lastBlockSignature64);
pushStrU32(b, args.arweaveTxId);
b.push(args.isServer ? 1 : 0);
for (const x of args.serverKey32) b.push(x);
pushStrU32(b, args.serverAddress);
pushVecStrU32(b, args.syncServers);
pushVecStrU32(b, args.accessServers);
b.push(Number(args.trustedCount || 0) & 0xff);
pushVecU8(b, args.rootSignature64);
return new Uint8Array(b);
}
function parseUsersEconomyConfig(dataBytes) {
const v = new DataView(dataBytes.buffer, dataBytes.byteOffset, dataBytes.byteLength);
if (dataBytes.byteLength < 25) throw new Error('Некорректный economy config');
return {
version: v.getUint8(0),
registrationFeeLamports: v.getBigUint64(1, true),
lamportsPerLimitStep: v.getBigUint64(9, true),
startBonusLimit: v.getBigUint64(17, true),
};
}
export async function getShineUsersEconomyConfig({ solanaEndpoint }) {
const endpoint = String(solanaEndpoint || '').trim();
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const [economyPda] = solana.PublicKey.findProgramAddressSync(
[new TextEncoder().encode(SHINE_USERS_ECONOMY_CONFIG_SEED)],
usersProgram,
);
const ai = await connection.getAccountInfo(economyPda, 'confirmed');
if (!ai?.data) throw new Error('Economy config PDA не найден');
const economy = parseUsersEconomyConfig(ai.data);
return { endpoint, economyPda: economyPda.toBase58(), ...economy };
}
export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
const cleanLogin = String(login || '').trim().toLowerCase();
const endpoint = String(solanaEndpoint || '').trim();
if (!cleanLogin) throw new Error('Не указан логин');
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const enc = new TextEncoder();
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
const ai = await connection.getAccountInfo(userPda, 'confirmed');
if (!ai?.data) throw new Error('Пользовательский PDA не найден в Solana');
const parsed = parseShineUserPda(ai.data);
const bch = parsed.blockchain;
const leftBytes = bch.paidLimitBytes > bch.usedBytes ? (bch.paidLimitBytes - bch.usedBytes) : 0n;
return {
endpoint,
userPda: userPda.toBase58(),
login: parsed.login,
recordNumber: parsed.recordNumber,
paidLimitBytes: bch.paidLimitBytes,
usedBytes: bch.usedBytes,
leftBytes,
lastBlockNumber: bch.lastBlockNumber,
lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
};
}
export async function updateShineUserPdaOnSolana({
login,
solanaEndpoint,
rootPrivatePkcs8B64,
devicePrivatePkcs8B64,
blockchainPrivatePkcs8B64,
additionalLimitBytes = 0n,
nextUsedBytes,
nextLastBlockNumber,
nextLastBlockHashHex,
}) {
const cleanLogin = String(login || '').trim().toLowerCase();
if (!cleanLogin) throw new Error('Не указан логин');
const endpoint = String(solanaEndpoint || '').trim();
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID);
const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID);
const enc = new TextEncoder();
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
const [economyPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram);
const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram);
const userAi = await connection.getAccountInfo(userPda, 'confirmed');
if (!userAi?.data) throw new Error('PDA пользователя не найден');
const current = parseShineUserPda(userAi.data);
const currentBch = current.blockchain;
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
const effectiveLastHash = nextLastBlockHashHex
? Uint8Array.from(String(nextLastBlockHashHex).match(/.{1,2}/g).map((h) => parseInt(h, 16)))
: currentBch.lastBlockHash;
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
const addLimit = BigInt(additionalLimitBytes || 0);
if (addLimit < 0n) throw new Error('Нельзя уменьшать лимит');
if (addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`);
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
const bchPriv = await importPkcs8Ed25519(blockchainPrivatePkcs8B64);
const deviceSeed32 = extractSeed32FromPkcs8B64(devicePrivatePkcs8B64);
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
const updatedAtMs = BigInt(Date.now());
const newPaid = currentBch.paidLimitBytes + addLimit;
const newRecordNumber = current.recordNumber + 1;
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, currentBch.blockchainName, effectiveLastNum, effectiveLastHash, effectiveUsed);
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
const lastBlockSig64 = await signBytes(bchPriv, lastBlockStateHash);
const nextState = {
...current,
updatedAtMs,
recordNumber: newRecordNumber,
prevRecordHash: prevHash,
blockchain: {
...currentBch,
paidLimitBytes: newPaid,
usedBytes: effectiveUsed,
lastBlockNumber: effectiveLastNum,
lastBlockHash: effectiveLastHash,
lastBlockSignature: lastBlockSig64,
},
};
const unsignedNext = serializeUnsignedRecordFromState(nextState);
const unsignedNextHash = await sha256Bytes(unsignedNext);
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
const ixData = serializeUpdateUserPdaArgs({
login: cleanLogin,
rootKey32: current.rootKey,
createdAtMs: current.createdAtMs,
updatedAtMs,
version: newRecordNumber,
prevHash32: prevHash,
additionalLimitBytes: addLimit,
deviceKey32: current.deviceKey,
blockchainPublicKey32: currentBch.blockchainPublicKey,
blockchainName: currentBch.blockchainName,
usedBytes: effectiveUsed,
lastBlockNumber: effectiveLastNum,
lastBlockHash32: effectiveLastHash,
lastBlockSignature64: lastBlockSig64,
arweaveTxId: currentBch.arweaveTxId,
isServer: current.isServer,
serverKey32: current.serverKey,
serverAddress: current.serverAddress,
syncServers: current.syncServers,
accessServers: current.accessServers,
trustedCount: current.trustedCount,
rootSignature64: rootSig64,
});
const edIxRoot = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(rootSig64, current.rootKey, unsignedNextHash),
});
const edIxBch = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(lastBlockSig64, currentBch.blockchainPublicKey, lastBlockStateHash),
});
const updIx = new solana.TransactionInstruction({
programId: usersProgram,
keys: [
{ pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: userPda, isSigner: false, isWritable: true },
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: inflowVault, isSigner: false, isWritable: true },
{ pubkey: sysvarInstructions, isSigner: false, isWritable: false },
{ pubkey: economyPda, isSigner: false, isWritable: false },
],
data: ixData,
});
const computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 });
const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 131_072 });
const signature = await solana.sendAndConfirmTransaction(
connection,
new solana.Transaction().add(computeIx, heapIx, edIxRoot, edIxBch, updIx),
[deviceKeypair],
{ commitment: 'confirmed' },
);
return {
signature,
userPda: userPda.toBase58(),
paidLimitBytes: newPaid,
usedBytes: effectiveUsed,
leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n,
lastBlockNumber: effectiveLastNum,
lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
};
}
export function calcLimitTopupPriceLamports(additionalLimitBytes, lamportsPerLimitStep) {
const add = BigInt(additionalLimitBytes || 0);
const pricePerStep = BigInt(lamportsPerLimitStep || 0);
if (add < 0n) throw new Error('Некорректный размер увеличения лимита');
if (add % LIMIT_STEP !== 0n) throw new Error(`Увеличение лимита должно быть кратно ${LIMIT_STEP} байт`);
return (add / LIMIT_STEP) * pricePerStep;
}
export function getLimitStepBytes() {
return LIMIT_STEP;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,16 @@
import { registerUserOnSolana as registerUserOnSolanaShared } from './shine-user-pda-service.js';
import { sha256Bytes, signBytes, importPkcs8Ed25519, base64ToBytes } from './crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
import {
SHINE_USERS_PROGRAM_ID,
SHINE_PAYMENTS_PROGRAM_ID,
SHINE_LOGIN_GUARD_PROGRAM_ID,
} from '../solana-programs.js';
const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]);
const CLASSIFY_LOGIN_DISCRIMINATOR = new Uint8Array([112, 97, 152, 32, 255, 73, 108, 86]);
const PRECHECK_SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P';
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
let solanaLibPromise = null;
function loadSolanaLib() {
@ -18,19 +23,168 @@ function pushU32LE(buf, v) {
buf.push(n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF, (n >> 24) & 0xFF);
}
function pushU64LE(buf, bigV) {
const b = typeof bigV === 'bigint' ? bigV : BigInt(bigV);
const lo = Number(b & 0xFFFFFFFFn) >>> 0;
const hi = Number((b >> 32n) & 0xFFFFFFFFn) >>> 0;
pushU32LE(buf, lo);
pushU32LE(buf, hi);
}
class BorshBuf {
constructor() { this._b = []; }
u8(v) { this._b.push(v & 0xFF); }
u32(v) { pushU32LE(this._b, v); }
u64(v) { pushU64LE(this._b, v); }
bool(v) { this.u8(v ? 1 : 0); }
bytes32(b) { for (const x of b) this._b.push(x); }
vecU8(b) { this.u32(b.length); for (const x of b) this._b.push(x); }
str(s) {
const enc = new TextEncoder().encode(s);
this.u32(enc.length);
for (const x of enc) this._b.push(x);
}
vecStr(arr) {
this.u32(arr.length);
for (const s of arr) this.str(s);
}
raw(bytes) { for (const x of bytes) this._b.push(x); }
result() { return new Uint8Array(this._b); }
}
// Matches Rust serialize_last_block_state (initial zero state)
function buildLastBlockStateBytes(login, blockchainName) {
const enc = new TextEncoder();
const prefix = enc.encode('SHiNE_LAST_BLOCK');
const loginB = enc.encode(login);
const bchB = enc.encode(blockchainName);
const buf = [];
for (const x of prefix) buf.push(x);
buf.push(loginB.length);
for (const x of loginB) buf.push(x);
buf.push(bchB.length);
for (const x of bchB) buf.push(x);
pushU32LE(buf, 0); // last_block_number = 0
for (let i = 0; i < 32; i++) buf.push(0); // last_block_hash = [0;32]
pushU64LE(buf, 0n); // used_bytes = 0
return new Uint8Array(buf);
}
// Matches Rust serialize_unsigned_record for initial registration
function buildUnsignedRecordBytes(
login, createdAtMs, rootKey32, deviceKey32, blockchainKey32,
blockchainName, paidLimitBytes, lastBlockSig64,
) {
const enc = new TextEncoder();
const loginB = enc.encode(login);
const bchB = enc.encode(blockchainName);
const accessB = enc.encode('shineup.me');
const buf = [];
// Fixed header: MAGIC(5) + FORMAT_MAJOR(1) + FORMAT_MINOR(1) + record_len_placeholder(2)
buf.push(0x53, 0x48, 0x69, 0x4E, 0x45, 1, 0, 0, 0); // indices 0..8
pushU64LE(buf, createdAtMs); // created_at_ms
pushU64LE(buf, createdAtMs); // updated_at_ms = same
pushU32LE(buf, 0); // record_number = 0
for (let i = 0; i < 32; i++) buf.push(0); // prev_record_hash = [0;32]
buf.push(loginB.length);
for (const x of loginB) buf.push(x);
buf.push(5); // blocks_count (non-server)
// RootKeyBlock (type=1, ver=0)
buf.push(1, 0);
for (const x of rootKey32) buf.push(x);
// DeviceKeyBlock (type=2, ver=0)
buf.push(2, 0);
for (const x of deviceKey32) buf.push(x);
// BlockchainRegistryBlock (type=3, ver=0, count=1)
buf.push(3, 0, 1, 1); // type, ver, count=1, blockchain_type=1(MAIN_USER)
buf.push(bchB.length);
for (const x of bchB) buf.push(x);
for (const x of blockchainKey32) buf.push(x);
pushU64LE(buf, paidLimitBytes); // paid_limit_bytes
pushU64LE(buf, 0n); // used_bytes = 0
pushU32LE(buf, 0); // last_block_number = 0
for (let i = 0; i < 32; i++) buf.push(0); // last_block_hash = [0;32]
for (const x of lastBlockSig64) buf.push(x); // last_block_signature
buf.push(0); // arweave_present = 0
// AccessServersBlock (type=40, ver=0)
buf.push(40, 0, 1, accessB.length);
for (const x of accessB) buf.push(x);
// TrustedStateBlock (type=50, ver=0, trusted_count=0)
buf.push(50, 0, 0);
// Patch record_len at indices 7-8: total = buf.length + 64 (signature)
const recLen = buf.length + 64;
buf[7] = recLen & 0xFF;
buf[8] = (recLen >> 8) & 0xFF;
return new Uint8Array(buf);
}
// Builds Ed25519 program instruction data for one signature
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
const sigOff = 16;
const pkOff = sigOff + 64; // 80
const msgOff = pkOff + 32; // 112
const data = new Uint8Array(msgOff + 32); // 144 bytes total
const v = new DataView(data.buffer);
data[0] = 1; data[1] = 0; // num_signatures=1, padding
v.setUint16(2, sigOff, true);
v.setUint16(4, 0xFFFF, true); // same instruction
v.setUint16(6, pkOff, true);
v.setUint16(8, 0xFFFF, true);
v.setUint16(10, msgOff, true);
v.setUint16(12, 32, true); // message_data_size = 32
v.setUint16(14, 0xFFFF, true);
data.set(sig64, sigOff);
data.set(pubkey32, pkOff);
data.set(msgHash32, msgOff);
return data;
}
function readStartBonusLimit(data) {
// Borsh: version(u8=1) + reg_fee(u64=8) + lamports_per_step(u64=8) + start_bonus_limit(u64=8)
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
return v.getBigUint64(17, true);
}
function serializeCreateUserPdaArgs(
login, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
blockchainName, lastBlockSig64, rootSig64,
) {
const b = new BorshBuf();
b.raw(CREATE_USER_PDA_DISCRIMINATOR);
b.str(login);
b.bytes32(rootKey32);
b.u64(createdAtMs);
b.u64(0n); // additional_limit
// UserMutableFields:
b.bytes32(deviceKey32);
b.bytes32(blockchainKey32);
b.str(blockchainName);
b.u64(0n); // used_bytes
b.u32(0); // last_block_number
b.vecU8(new Uint8Array(32)); // last_block_hash
b.vecU8(lastBlockSig64); // last_block_signature
b.str(''); // arweave_tx_id
b.bool(false); // is_server
b.bytes32(new Uint8Array(32)); // server_key (default)
b.str(''); // server_address
b.vecStr([]); // sync_servers
b.vecStr(['shineup.me']); // access_servers
b.u8(0); // trusted_count
b.vecU8(rootSig64); // signature
return b.result();
}
function serializeClassifyLoginArgs(login) {
const b = new BorshBuf();
b.raw(CLASSIFY_LOGIN_DISCRIMINATOR);
@ -129,5 +283,98 @@ export async function checkLoginExistsOnSolana({ login, solanaEndpoint }) {
}
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) {
return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint });
const solana = await loadSolanaLib();
const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
const loginGuardProgram = new solana.PublicKey(SHINE_LOGIN_GUARD_PROGRAM_ID);
const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID);
const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID);
const enc = new TextEncoder();
const loginNorm = login.toLowerCase();
const blockchainName = `${loginNorm}-001`;
const [userPda] = solana.PublicKey.findProgramAddressSync(
[enc.encode('login='), enc.encode(loginNorm)],
usersProgram,
);
const [economyConfigPda] = solana.PublicKey.findProgramAddressSync(
[enc.encode('shine_users_economy_config')],
usersProgram,
);
const [inflowVault] = solana.PublicKey.findProgramAddressSync(
[enc.encode('shine_payments_inflow_vault')],
paymentsProgram,
);
const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64);
const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
const deviceKey32 = base64ToBytes(keyBundle.devicePair.publicKeyB64);
const rootPrivKey = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64);
const bchPrivKey = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64);
const deviceSeed32 = extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64);
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
const ecoAccount = await connection.getAccountInfo(economyConfigPda);
if (!ecoAccount) {
throw new Error('Economy config не инициализирован. Запустите init_users_economy_config.');
}
const startBonusLimit = readStartBonusLimit(ecoAccount.data);
const createdAtMs = BigInt(Date.now());
// Sign LastBlockState with blockchain key
const lbsBytes = buildLastBlockStateBytes(loginNorm, blockchainName);
const lbsHash = await sha256Bytes(lbsBytes);
const lastBlockSig64 = await signBytes(bchPrivKey, lbsHash);
// Build and sign unsigned PDA record with root key
const unsignedRecord = buildUnsignedRecordBytes(
loginNorm, createdAtMs, rootKey32, deviceKey32, blockchainKey32,
blockchainName, startBonusLimit, lastBlockSig64,
);
const unsignedHash = await sha256Bytes(unsignedRecord);
const rootSig64 = await signBytes(rootPrivKey, unsignedHash);
const ixData = serializeCreateUserPdaArgs(
loginNorm, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
blockchainName, lastBlockSig64, rootSig64,
);
// Ed25519 instructions must precede create_user_pda
const ed25519RootIx = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(rootSig64, rootKey32, unsignedHash),
});
const ed25519BchIx = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash),
});
const createUserIx = new solana.TransactionInstruction({
programId: usersProgram,
keys: [
{ pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: userPda, isSigner: false, isWritable: true },
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: inflowVault, isSigner: false, isWritable: true },
{ pubkey: sysvarInstructions, isSigner: false, isWritable: false },
{ pubkey: economyConfigPda, isSigner: false, isWritable: false },
{ pubkey: loginGuardProgram, isSigner: false, isWritable: false },
],
data: ixData,
});
const sig = await solana.sendAndConfirmTransaction(
connection,
new solana.Transaction().add(ed25519RootIx, ed25519BchIx, createUserIx),
[deviceKeypair],
{ commitment: 'confirmed' },
);
return { signature: sig, blockchainName };
}

Some files were not shown because too many files have changed in this diff Show More