Compare commits

...

27 Commits

Author SHA256 Message Date
AidarKC
89d06d317b Переписать shine_payments и обновить тестовый UI с известным багом state 2026-06-06 16:58:57 +04:00
AidarKC
c5ec32f87a Обновить Telegram-бота, документацию и связанные доработки 2026-06-06 13:45:02 +04:00
AidarKC
ce5c348023 Убрать v2 из economy seed shine_users 2026-06-05 11:42:21 +04:00
AidarKC
832eea5889 Переписать shine_users и shine_login_guard на чистый Rust 2026-06-04 23:05:45 +04:00
AidarKC
60049442f1 Зафиксировать все текущие изменения проекта 2026-06-04 22:27:09 +04:00
AidarKC
624557ebfd Удалить старый путь документа формата PDA 2026-06-04 22:17:33 +04:00
AidarKC
6b0379bfdc Добавить спецификации Solana программ и вынести формат PDA 2026-06-04 22:17:17 +04:00
AidarKC
a9510a6d36 Обновить lazy-import Solana PDA под новый формат 2026-06-04 14:33:42 +04:00
AidarKC
59e4156bb9 Удалить obsolete server UI и подчистить ссылки 2026-06-04 14:25:59 +04:00
AidarKC
de9606519a Починить native Ed25519 update_user_pda без OOM 2026-06-04 13:47:47 +04:00
AidarKC
eeb115584d Добавить диагностику server PDA и баланс device (не проверено) 2026-06-03 16:12:40 +04:00
AidarKC
ee3721dfa4 Исправить DEVNET topup и автоподстановку пароля 2026-06-03 15:57:49 +04:00
AidarKC
239cc231ea Снимок состояния перед фиксом DEVNET topup и автоподстановки пароля 2026-06-03 15:56:17 +04:00
AidarKC
4bd4df7b09 Добавить переход в server UI и DEVNET topup 2026-06-03 15:21:55 +04:00
AidarKC
d12371b84f Перенести server UI в shine-UI и объединить PDA-модуль 2026-06-03 15:11:26 +04:00
AidarKC
c97b3e3ec3 Снимок состояния до переноса серверного UI 2026-06-03 14:49:03 +04:00
AidarKC
2c2aad1355 Убраны непроверенные готовые фичи и перенесён QR-план 2026-06-03 14:20:45 +04:00
AidarKC
9949935bcc Добавить обработку длинных voice/audio в агент-боте 2026-06-03 00:18:30 +04:00
AidarKC
35fc6ebf62 Сделана компактная кнопка показа ключей в регистрации 2026-06-02 16:43:38 +04:00
AidarKC
d2205648e6 Сделана компактная кнопка скрытия ключей в UI 2026-06-02 16:40:58 +04:00
AidarKC
68ed93dd24 Переведены ключи UI в base58 и обновлены deploy defaults 2026-06-02 16:34:37 +04:00
AidarKC
a06b76b800 Обновлён server UI и приватные ключи переведены в base58 2026-06-02 15:52:22 +04:00
AidarKC
67f882b9bc Добавлен контур server-backup для shineup.me и регламент обновляемых бэкапов 2026-06-01 13:05:54 +04:00
AidarKC
17dc4981c6 Поправить Solana-программу регистрации пользователей
Шаг 1 — Rust (users.rs)

- Убран server_key: Pubkey из UserMutableFields и UserRecord.

- Добавлены address_format_type: u8 и address_format_version: u8 в соответствующие структуры.

- Добавлена константа BLOCK_VERSION_1: u8 = 1.

- Обновлен write_server_profile_block: версия блока = 1, убраны 32 байта server_key, добавлены 2 байта формата адреса перед server_address.

- Обновлен deserialize_record_from_pda для BLOCK_TYPE_SERVER_PROFILE: ожидается BLOCK_VERSION_1, чтение server_key убрано, добавлено чтение type/version формата адреса.

- Обновлены конструкторы UserRecord под новые поля.

- Обновлена документация формата: shine-solana/shine/doc/SHiNE-user-format-v.1.0.md.

- Синхронизированы связанные изменения UI/доков и VERSION.properties (client 1.2.109, server 1.2.101).
2026-05-31 22:25:33 +04:00
AidarKC
0179b25d12 Исправить дефолты деплоя на shineup.me
- deployServer: remoteHost по умолчанию = shineup.me.

- deployServer: путь localJar по умолчанию = build/libs/shine-server.jar.

- deployUI: REMOTE_HOST по умолчанию = player@shineup.me.

- VERSION.properties: client 1.2.107, server 1.2.100.
2026-05-31 20:36:34 +04:00
AidarKC
e3c1cbf1c0 Обновить UI каналов, логаут DM и документацию
- Исправлена вкладка Каналы: стабильные режимы Все/Мои, корректные кнопки и навигация назад.

- Зафиксирована доработка по личным сообщениям: при logout очищается локальная база/кеш DM на устройстве.

- Обновлены AGENTS/CLAUDE и документация Personal_Messages.

- Обновлены версии в VERSION.properties (client 1.2.106, server 1.2.99).
2026-05-31 20:30:31 +04:00
AidarKC
5899bd2f77 Убрал long-press меню каналов и обновил deploy-проверку sudo 2026-05-31 19:30:36 +04:00
133 changed files with 13733 additions and 9208 deletions

4
.gitignore vendored
View File

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

1
.idea/vcs.xml generated
View File

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

View File

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

View File

@ -1,2 +1,11 @@
@AGENTS.md @AGENTS.md
@AGENT_DEBUG_RUNBOOK.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`.

255
DAO_запуск/README.md Normal file
View File

@ -0,0 +1,255 @@
# 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

@ -0,0 +1,100 @@
# Синхронизация блоков и 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,6 +36,12 @@
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах. - `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн. - `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений. - `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

@ -0,0 +1,64 @@
# 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

@ -0,0 +1,105 @@
# Сессионные саб-серверы в 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

@ -0,0 +1,45 @@
# Подключение других устройств через 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

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

View File

@ -0,0 +1,31 @@
# Рестарты и 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

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

View File

@ -0,0 +1,13 @@
# Длинные 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

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

View File

@ -0,0 +1,24 @@
# Перенос 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

@ -0,0 +1,20 @@
# Кнопка настройки сервера и 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

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

View File

@ -0,0 +1,17 @@
# Диагностика ключей 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

@ -0,0 +1,15 @@
# 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

@ -0,0 +1,33 @@
# 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

@ -0,0 +1,25 @@
# 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

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

View File

@ -0,0 +1,24 @@
## Краткое описание
В локальный 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

@ -1,23 +0,0 @@
# 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

@ -1,26 +0,0 @@
# 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

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

View File

@ -1,23 +0,0 @@
# Отчёт 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

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

View File

@ -162,9 +162,13 @@ UI чата строится на этих типах: текстовые соо
## 7) Доставка и backlog ## 7) Доставка и backlog
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии. - При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
- Для офлайн/недоступных сессий остаётся pending-запись доставки. - Для офлайн/недоступных сессий остаётся pending-запись доставки в таблице `signed_message_session_delivery`.
- При подключении сессии сервер догружает pending (`listPendingForSession`) и шлёт `SignedMessageArrived(backlog=true)`. - При подключении сессии сервер автоматически вызывает `dispatchPendingForSession`:
- После получения клиент должен отправить `AckSessionDelivery`, чтобы отметить `delivered=1`. - для новой сессии регистрирует все существующие сообщения адресата как «недоставленные»;
- отправляет **все** pending через WebSocket событием `SignedMessageArrived(backlog=true)`;
- лимита на количество сообщений нет — передаётся вся история без ограничений.
- Клиент дедублирует входящие через `knownMessageKeys`: если `messageKey` уже есть локально — игнорирует.
- После получения клиент отправляет `AckSessionDelivery`, чтобы отметить `delivered=1` в таблице доставки.
## 8) Read-receipt логика ## 8) Read-receipt логика
@ -183,19 +187,63 @@ UI чата строится на этих типах: текстовые соо
## 9) Логика UI-клиента ## 9) Логика UI-клиента
В 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-поведение
- чат хранится в `state.chats[chatId]`;
- `chatId` для `type=1``fromLogin`, для `type=2``toLogin`;
- непрочитанные считаются по `from='in' && unread=true`; - непрочитанные считаются по `from='in' && unread=true`;
- доставка/прочтение исходящих: - доставка/прочтение исходящих:
- `firstTick` — сообщение принято в парный поток, - `firstTick` — сообщение принято сервером,
- `secondTick` — пришло подтверждение прочтения; - `secondTick` — пришло подтверждение прочтения;
- при открытии диалога UI автопрокручивает ленту в самый низ; - при открытии диалога UI автопрокручивает ленту в самый низ;
- после отправки нового сообщения UI сразу автопрокручивает ленту вниз, чтобы новое сообщение было в зоне видимости; - после отправки нового сообщения UI сразу прокручивает ленту вниз.
- сообщения дополнительно кешируются в IndexedDB (`shine-ui-messages-v1`, store `messages`).
## 10) Инварианты (обязательно соблюдать при доработках) ## 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) Инварианты (обязательно соблюдать при доработках)
1. Пара блоков (1/2 или 3/4) должна оставаться атомарной. 1. Пара блоков (1/2 или 3/4) должна оставаться атомарной.
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt. 2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
@ -203,7 +251,7 @@ UI чата строится на этих типах: текстовые соо
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`. 4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе. 5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
## 11) Ключевые файлы реализации ## 12) Ключевые файлы реализации
- UI: - UI:
- `shine-UI/js/services/auth-service.js` - `shine-UI/js/services/auth-service.js`

View File

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

View File

@ -9,7 +9,7 @@
Связанные документы: Связанные документы:
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` — single source of truth по деплою и первичной инициализации регистрации пользователей. - `Dev_Docs/Инициализация_Solana_регистрации/README.md` — single source of truth по деплою и первичной инициализации регистрации пользователей.
- `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md` — точный формат `user_pda` для `shine_users`. - `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` — точный формат `user_pda` для `shine_users`.
- `shine-solana/shine/doc/FUNDS_FLOW.md` — короткая справка по денежным потокам внутри Solana-модуля. - `shine-solana/shine/doc/FUNDS_FLOW.md` — короткая справка по денежным потокам внутри Solana-модуля.
## Кратко ## Кратко

View File

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

View File

@ -1,23 +0,0 @@
# Сервер `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. - Резервный сервер для SHiNE.
- Основной прод-сервер: `45.136.124.227` (`shineup.me`). - Основной прод-сервер: `shineup.me` (подключение через `player@shineup.me`, IP определяется через DNS).
## Caddy ## Caddy

View File

@ -0,0 +1,35 @@
# Сервер `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,10 +82,17 @@ anchor deploy -p shine_users
- seed: `shine_users_economy_config` - seed: `shine_users_economy_config`
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` - 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` выполняется один раз на программу. - `init_users_economy_config` выполняется один раз на программу.
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение). Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
- Серверные приватные ключи для Solana не используются: подписание делается кошельком пользователя в UI. - Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `deviceKey`, а содержимое записи подписывает `rootKey`.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина. - `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
Несовпадение адреса приведёт к ошибке регистрации. Несовпадение адреса приведёт к ошибке регистрации.

View File

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

View File

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

View File

@ -0,0 +1,71 @@
# Задание для Айдара: навести порядок в инструкциях агентов 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 +1,7 @@
# - # SHiNE
## План запуска DAO
План запуска DAO зафиксирован в [DAO_запуск/README.md](DAO_запуск/README.md).
Это рабочий список задач по `этап1` и `этап2`. Дальше ведём его как основной чек-лист запуска DAO и отмечаем в нём выполненные пункты по мере готовности.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

69
SHiNE-server/AGENTS.md Normal file
View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.JsonInboundProcessor; import server.logic.ws_protocol.JSON.JsonInboundProcessor;
import java.nio.channels.ClosedChannelException;
import java.util.concurrent.*; import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
@ -131,9 +132,19 @@ public class BlockchainWsEndpoint {
@OnWebSocketError @OnWebSocketError
public void onError(Throwable cause) { 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); 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() { private void trySendJsonError() {
if (session != null && session.isOpen()) { if (session != null && session.isOpen()) {
String resp = "{\"op\":null,\"requestId\":null,\"status\":500," String resp = "{\"op\":null,\"requestId\":null,\"status\":500,"

View File

@ -19,10 +19,10 @@ import java.util.jar.JarFile;
*/ */
public class IT_DeployBackupCleanAndRunRemoteMain { public class IT_DeployBackupCleanAndRunRemoteMain {
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247"); private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "shineup.me");
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user"); private static final String REMOTE_USER = System.getProperty("it.remoteUser", "player");
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server"); private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server");
private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar"; 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_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data");
private static final String REMOTE_BACKUP_DIR = System.getProperty("it.remoteBackupDir", REMOTE_DIR + "/backup"); 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 { public class IT_DeployRestartAndRunRemoteMain {
// ====== НАСТРОЙКИ (можно переопределять systemProperty) ====== // ====== НАСТРОЙКИ (можно переопределять systemProperty) ======
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247"); private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "shineup.me");
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user"); private static final String REMOTE_USER = System.getProperty("it.remoteUser", "player");
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server"); private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server");
private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar"; 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_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data");

View File

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

View File

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

View File

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

View File

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

View File

@ -1,19 +0,0 @@
# 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

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

29
server-backup/AGENTS.md Normal file
View File

@ -0,0 +1,29 @@
# 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`.

7
server-backup/README.md Normal file
View File

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

View File

@ -0,0 +1 @@

View File

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

View File

@ -0,0 +1,32 @@
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

@ -0,0 +1,74 @@
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

@ -0,0 +1,40 @@
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

@ -0,0 +1,50 @@
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

@ -0,0 +1,2 @@
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

@ -0,0 +1,6 @@
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

@ -0,0 +1,12 @@
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

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

View File

@ -0,0 +1,54 @@
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

@ -0,0 +1,25 @@
[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

@ -0,0 +1,15 @@
[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

@ -0,0 +1,709 @@
# 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

@ -0,0 +1,62 @@
# Восстановление 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

@ -0,0 +1,55 @@
#!/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

@ -0,0 +1,29 @@
#!/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,12 +9,6 @@ const ITEMS = [
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' }, { pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
{ pageId: 'profile-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() { function getTotalUnreadMessages() {
const chats = Object.values(state.chats || {}); const chats = Object.values(state.chats || {});
@ -91,7 +85,7 @@ export function renderToolbar(currentPageId, navigate) {
btn.append(badge); btn.append(badge);
} }
if (item.pageId === 'channels-list') { if (item.pageId === 'channels-list') {
installChannelsHoldSwitcher(btn, navigate); btn.addEventListener('click', () => navigate('channels-list/feed'));
} else { } else {
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate)); btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
} }
@ -100,90 +94,3 @@ export function renderToolbar(currentPageId, navigate) {
return root; 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,6 +11,7 @@ import {
writeChannelNotificationsState, writeChannelNotificationsState,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { makeShineChannelRoute } from '../services/shine-routes.js'; import { makeShineChannelRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' }; export const pageMeta = { id: 'channels-list', title: 'Каналы' };
@ -1176,26 +1177,12 @@ export function render({ navigate, route }) {
const topBarLeft = document.createElement('div'); const topBarLeft = document.createElement('div');
topBarLeft.className = 'channels-top-left'; topBarLeft.className = 'channels-top-left';
const backToFeedBtn = document.createElement('button'); const backBtn = document.createElement('button');
backToFeedBtn.type = 'button'; backBtn.type = 'button';
backToFeedBtn.className = 'icon-btn channels-top-back-btn'; backBtn.className = 'icon-btn channels-top-back-btn';
backToFeedBtn.textContent = '←'; backBtn.textContent = '←';
backToFeedBtn.setAttribute('aria-label', 'Назад'); backBtn.setAttribute('aria-label', 'Назад');
backToFeedBtn.addEventListener('click', () => { backBtn.addEventListener('click', () => navigateBack());
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'); const topTitle = document.createElement('strong');
topTitle.className = 'channels-top-title'; topTitle.className = 'channels-top-title';
@ -1210,6 +1197,16 @@ export function render({ navigate, route }) {
rerenderList(); 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'); const createInMyBtn = document.createElement('button');
createInMyBtn.type = 'button'; createInMyBtn.type = 'button';
createInMyBtn.className = 'icon-btn channels-top-add-btn'; createInMyBtn.className = 'icon-btn channels-top-add-btn';
@ -1217,8 +1214,19 @@ export function render({ navigate, route }) {
createInMyBtn.setAttribute('aria-label', 'Создать канал'); createInMyBtn.setAttribute('aria-label', 'Создать канал');
createInMyBtn.addEventListener('click', () => navigate('add-channel-view')); createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
topBarLeft.append(backToFeedBtn, allChannelsBtn, topTitle, myChannelsBtn); const switchToAllBtn = document.createElement('button');
topBarEl.append(topBarLeft, createInMyBtn); 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);
const bottomCta = document.createElement('button'); const bottomCta = document.createElement('button');
bottomCta.type = 'button'; bottomCta.type = 'button';
@ -1248,17 +1256,21 @@ export function render({ navigate, route }) {
}); });
if (listState.activeTab === 'my' && !isGuest) { if (listState.activeTab === 'my' && !isGuest) {
backToFeedBtn.style.display = '';
allChannelsBtn.style.display = '';
myChannelsBtn.style.display = 'none'; myChannelsBtn.style.display = 'none';
topTitle.textContent = 'Мои каналы'; topTitle.textContent = 'Мои каналы';
findChannelBtn.style.display = 'none';
switchToAllBtn.style.display = '';
createInMyBtn.style.display = ''; createInMyBtn.style.display = '';
if (!switchToAllBtn.isConnected) topBarLeft.append(switchToAllBtn);
if (topTitle.parentElement !== topBarRight) topBarRight.prepend(topTitle);
} else { } else {
backToFeedBtn.style.display = 'none';
allChannelsBtn.style.display = 'none';
myChannelsBtn.style.display = isGuest ? 'none' : ''; myChannelsBtn.style.display = isGuest ? 'none' : '';
topTitle.textContent = 'Каналы'; topTitle.textContent = 'Все каналы';
findChannelBtn.style.display = '';
switchToAllBtn.style.display = 'none';
createInMyBtn.style.display = 'none'; createInMyBtn.style.display = 'none';
if (switchToAllBtn.isConnected) switchToAllBtn.remove();
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
} }
updateBottomCta({ updateBottomCta({
@ -1304,6 +1316,9 @@ export function render({ navigate, route }) {
isTabEmpty: true, isTabEmpty: true,
}); });
// Применяем корректное состояние хедера сразу на первом рендере,
// чтобы не показывать лишние кнопки до первой перерисовки.
rerenderList();
loadFeedAndRender({ screen, listState, contentEl, navigate }); loadFeedAndRender({ screen, listState, contentEl, navigate });
screen.cleanup = () => { screen.cleanup = () => {

View File

@ -1,5 +1,6 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { state } from '../state.js'; import { state } from '../state.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' }; export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
@ -21,6 +22,7 @@ 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-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-blockchain" ${state.deviceConnect.blockchain ? 'checked' : ''} /> blockchain key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-device" checked disabled /> device 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"> <div class="row">
<button class="icon-btn small-btn" type="button" id="tech-help">Техсправка</button> <button class="icon-btn small-btn" type="button" id="tech-help">Техсправка</button>
</div> </div>
@ -33,6 +35,8 @@ export function render({ navigate }) {
const rootToggle = card.querySelector('#connect-root'); const rootToggle = card.querySelector('#connect-root');
const blockchainToggle = card.querySelector('#connect-blockchain'); const blockchainToggle = card.querySelector('#connect-blockchain');
const deviceToggle = card.querySelector('#connect-device'); const deviceToggle = card.querySelector('#connect-device');
const statusEl = card.querySelector('#connect-keys-status');
const openQrBtn = card.querySelector('#open-qr');
deviceToggle.checked = true; deviceToggle.checked = true;
rootToggle.addEventListener('change', () => { rootToggle.addEventListener('change', () => {
@ -85,6 +89,47 @@ export function render({ navigate }) {
card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view')); card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-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) => { helpModal.addEventListener('click', (event) => {
const target = event.target; const target = event.target;
if (target instanceof HTMLElement && target.dataset.close === 'true') { if (target instanceof HTMLElement && target.dataset.close === 'true') {

View File

@ -1,6 +1,11 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { profile } from '../mock-data.js';
import { state } from '../state.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-код' }; export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
@ -8,11 +13,6 @@ export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; 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( screen.append(
renderHeader({ renderHeader({
title: 'Показать QR-код', title: 'Показать QR-код',
@ -23,12 +23,44 @@ export function render({ navigate }) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card stack qr-card'; card.className = 'card stack qr-card';
card.innerHTML = ` card.innerHTML = `
<img class="qr-image" src="img/device-qr-64.svg" width="64" height="64" alt="QR-код для подключения" /> <div class="qr-image" id="device-transfer-qr" aria-label="QR-код для переноса ключей"></div>
<p class="meta-muted">Логин пользователя: ${profile.login}</p> <p class="meta-muted" id="device-transfer-login">Логин: ...</p>
<p class="meta-muted">Передаваемые ключи: ${selectedKeys.join(', ')}</p> <p class="meta-muted" id="device-transfer-keys">Ключи: ...</p>
<p class="status-line is-unavailable" id="device-transfer-status" style="display:none;"></p>
<button class="primary-btn" type="button" id="qr-ok">OK</button> <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')); card.querySelector('#qr-ok').addEventListener('click', () => navigate('connect-device-view'));
screen.append(card); screen.append(card);

View File

@ -36,9 +36,11 @@ export function render({ navigate }) {
actions.className = 'card stack'; actions.className = 'card stack';
actions.innerHTML = ` actions.innerHTML = `
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button> <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> <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')); actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
const sessionsBlock = document.createElement('div'); const sessionsBlock = document.createElement('div');

View File

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

View File

@ -129,6 +129,15 @@ export function render({ navigate }) {
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'auth-footer-actions'; 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'); const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn'; cancelButton.className = 'ghost-btn';
cancelButton.type = 'button'; cancelButton.type = 'button';
@ -144,7 +153,7 @@ export function render({ navigate }) {
navigate('start-view'); navigate('start-view');
}); });
actions.append(cancelButton, saveButton); actions.append(serverUiButton, cancelButton, saveButton);
const help = document.createElement('button'); const help = document.createElement('button');
help.className = 'help-fab'; help.className = 'help-fab';

View File

@ -1,47 +1,238 @@
import { renderHeader } from '../components/header.js'; 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: 'Войти по камере', showAppChrome: false }; 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 function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
clearAuthMessages();
const frame = document.createElement('div'); const frame = document.createElement('div');
frame.className = 'camera-shell'; frame.className = 'camera-shell';
frame.innerHTML = ` frame.innerHTML = `
<video class="camera-video" autoplay playsinline muted></video> <video class="camera-video" autoplay playsinline muted></video>
<div class="camera-frame"></div> <div class="camera-frame"></div>
<div class="camera-hint">Наведите QR-код в рамку</div> <div class="camera-hint">Наведите QR-код переноса ключей в рамку</div>
<div class="camera-error" id="login-camera-error" style="display:none;"></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 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 stream = null;
let detector = null;
let scanTimer = 0;
let scannedTransfer = null;
let stopped = false;
const stopCamera = () => { const stopCamera = () => {
stopped = true;
if (scanTimer) {
window.clearTimeout(scanTimer);
scanTimer = 0;
}
if (stream) { if (stream) {
stream.getTracks().forEach((track) => track.stop()); stream.getTracks().forEach((track) => track.stop());
stream = null; stream = null;
} }
}; };
if (navigator.mediaDevices?.getUserMedia) { const showTransfer = (transfer) => {
navigator.mediaDevices scannedTransfer = transfer;
.getUserMedia({ video: { facingMode: 'environment' }, audio: false }) stopCamera();
.then((nextStream) => { renderParsedTransfer(resultEl, transfer);
stream = nextStream; resultCard.style.display = '';
video.srcObject = nextStream; setStatus(status, '', 'info');
}) };
.catch(() => {
frame.insertAdjacentHTML('beforeend', '<div class="camera-error">Не удалось открыть камеру. Проверьте разрешения браузера.</div>'); const parseTransferText = (text) => {
}); try {
} else { const transfer = parseKeyTransferText(text);
frame.insertAdjacentHTML('beforeend', '<div class="camera-error">Камера не поддерживается в этом браузере.</div>'); 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,
});
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;
}
});
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => { backButton.addEventListener('click', () => {
stopCamera(); stopCamera();
navigate('login-view'); navigate('login-view');
@ -49,7 +240,7 @@ export function render({ navigate }) {
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Войти по камере', title: 'Войти по QR-коду',
leftAction: { leftAction: {
label: '←', label: '←',
onClick: () => { onClick: () => {
@ -59,9 +250,13 @@ export function render({ navigate }) {
}, },
}), }),
frame, frame,
manualCard,
resultCard,
status,
backButton, backButton,
); );
void startCamera();
screen.cleanup = stopCamera; screen.cleanup = stopCamera;
return screen; return screen;
} }

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { state } from '../state.js'; import { state } from '../state.js';
import { bytesToBase64 } from '../services/crypto-utils.js'; import { base64ToBytes, bytesToBase58 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js'; import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js';
export const pageMeta = { id: 'registration-draft-keys-view', title: 'Сгенерированные ключи', showAppChrome: false }; export const pageMeta = { id: 'registration-draft-keys-view', title: 'Сгенерированные ключи', showAppChrome: false };
@ -15,24 +15,43 @@ function makeSecretField({ label, value }) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'inline-input-row'; 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'); const input = document.createElement('input');
input.className = 'input'; input.className = 'input key-input';
input.type = 'password'; input.type = 'password';
input.readOnly = true; input.readOnly = true;
input.value = value; input.value = value;
const toggleBtn = document.createElement('button'); const toggleBtn = document.createElement('button');
toggleBtn.className = 'ghost-btn'; toggleBtn.className = 'icon-btn key-toggle-btn';
toggleBtn.type = 'button'; toggleBtn.type = 'button';
toggleBtn.textContent = 'Показать'; toggleBtn.innerHTML = eyeOffIcon;
toggleBtn.setAttribute('aria-label', 'Показать ключ');
toggleBtn.title = 'Показать ключ';
toggleBtn.addEventListener('click', () => { toggleBtn.addEventListener('click', () => {
if (input.type === 'password') { if (input.type === 'password') {
input.type = 'text'; input.type = 'text';
toggleBtn.textContent = 'Скрыть'; toggleBtn.innerHTML = eyeIcon;
toggleBtn.setAttribute('aria-label', 'Скрыть ключ');
toggleBtn.title = 'Скрыть ключ';
} else { } else {
input.type = 'password'; input.type = 'password';
toggleBtn.textContent = 'Показать'; toggleBtn.innerHTML = eyeOffIcon;
toggleBtn.setAttribute('aria-label', 'Показать ключ');
toggleBtn.title = 'Показать ключ';
} }
}); });
@ -50,7 +69,7 @@ function makePublicField({ label, value }) {
labelEl.textContent = label; labelEl.textContent = label;
const input = document.createElement('input'); const input = document.createElement('input');
input.className = 'input'; input.className = 'input key-input';
input.type = 'text'; input.type = 'text';
input.readOnly = true; input.readOnly = true;
input.value = value; input.value = value;
@ -81,39 +100,56 @@ export function render({ navigate }) {
card.append(warning); card.append(warning);
// Секрет (root key seed) // Master secret
let secretB64 = ''; let secretB58 = '';
try { try {
const rootSeed32 = extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64); secretB58 = bytesToBase58(base64ToBytes(keyBundle.masterSecretB64));
secretB64 = bytesToBase64(rootSeed32);
} catch { } catch {
secretB64 = '(не удалось извлечь)'; secretB58 = '(не удалось извлечь)';
} }
card.append(makeSecretField({ label: 'Секрет (root seed, 32 байта)', value: secretB64 })); card.append(makeSecretField({ label: 'Главный секрет (master secret, base58, 32 байта)', value: secretB58 }));
// Root key // Root key
const rootSep = document.createElement('p'); const rootSep = document.createElement('p');
rootSep.className = 'field-label'; rootSep.className = 'field-label';
rootSep.textContent = 'Root key'; rootSep.textContent = 'Root key';
card.append(rootSep); card.append(rootSep);
card.append(makePublicField({ label: 'Root — публичный', value: keyBundle.rootPair.publicKeyB64 })); card.append(makePublicField({
card.append(makeSecretField({ label: 'Root — приватный (PKCS8)', value: keyBundle.rootPair.privatePkcs8B64 })); label: 'Root — публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Root — приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64)),
}));
// Blockchain key // Blockchain key
const bchSep = document.createElement('p'); const bchSep = document.createElement('p');
bchSep.className = 'field-label'; bchSep.className = 'field-label';
bchSep.textContent = 'Blockchain key'; bchSep.textContent = 'Blockchain key';
card.append(bchSep); card.append(bchSep);
card.append(makePublicField({ label: 'Blockchain — публичный', value: keyBundle.blockchainPair.publicKeyB64 })); card.append(makePublicField({
card.append(makeSecretField({ label: 'Blockchain — приватный (PKCS8)', value: keyBundle.blockchainPair.privatePkcs8B64 })); label: 'Blockchain — публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.blockchainPair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Blockchain — приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.blockchainPair.privatePkcs8B64)),
}));
// Device key // Device key
const devSep = document.createElement('p'); const devSep = document.createElement('p');
devSep.className = 'field-label'; devSep.className = 'field-label';
devSep.textContent = 'Device key (= Solana wallet)'; devSep.textContent = 'Device key (= Solana wallet)';
card.append(devSep); card.append(devSep);
card.append(makePublicField({ label: 'Device — публичный', value: keyBundle.devicePair.publicKeyB64 })); card.append(makePublicField({
card.append(makeSecretField({ label: 'Device — приватный (PKCS8)', value: keyBundle.devicePair.privatePkcs8B64 })); label: 'Device — публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.devicePair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Device — приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64)),
}));
} }
const actions = document.createElement('div'); const actions = document.createElement('div');

View File

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

View File

@ -1,5 +1,7 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { state } from '../state.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'; import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' }; export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
@ -38,40 +40,63 @@ export function render({ navigate }) {
const renderField = (id, label) => { const renderField = (id, label) => {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'key-card stack'; 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 = ` row.innerHTML = `
<div class="row"> <div class="row">
<span class="field-label">${label}</span> <span class="field-label">${label}</span>
<button class="icon-btn small-btn" type="button" data-toggle="${id}">Показать</button> <button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeOffIcon}</button>
</div> </div>
<div class="key-value" data-value="${id}">*****</div> <div class="key-value key-value--compact" data-value="${id}">*****</div>
`; `;
row._eyeIcon = eyeIcon;
row._eyeOffIcon = eyeOffIcon;
return row; return row;
}; };
card.append( card.append(
renderField('root', 'root key'), renderField('root', 'root key (base58)'),
renderField('blockchain', 'blockchain.key'), renderField('blockchain', 'blockchain.key (base58)'),
renderField('device', 'device key'), renderField('device', 'device key (base58)'),
); );
const setMissingState = (id) => { const setMissingState = (id) => {
const valueEl = card.querySelector(`[data-value="${id}"]`); const valueEl = card.querySelector(`[data-value="${id}"]`);
const btnEl = card.querySelector(`[data-toggle="${id}"]`); const btnEl = card.querySelector(`[data-toggle="${id}"]`);
const field = btnEl?.closest('.key-card');
valueEl.textContent = 'нет данных'; valueEl.textContent = 'нет данных';
btnEl.disabled = true; btnEl.disabled = true;
btnEl.textContent = 'Нет'; btnEl.innerHTML = field?._eyeOffIcon || '';
btnEl.setAttribute('aria-label', 'Нет данных');
btnEl.title = 'Нет данных';
}; };
const updateField = (id) => { const updateField = (id) => {
const valueEl = card.querySelector(`[data-value="${id}"]`); const valueEl = card.querySelector(`[data-value="${id}"]`);
const btnEl = card.querySelector(`[data-toggle="${id}"]`); const btnEl = card.querySelector(`[data-toggle="${id}"]`);
const field = btnEl?.closest('.key-card');
if (!keys[id]) { if (!keys[id]) {
setMissingState(id); setMissingState(id);
return; return;
} }
valueEl.textContent = visible[id] ? keys[id] : '*****'; valueEl.textContent = visible[id] ? keys[id] : '*****';
btnEl.disabled = false; btnEl.disabled = false;
btnEl.textContent = visible[id] ? 'Скрыть' : 'Показать'; btnEl.innerHTML = visible[id]
? field?._eyeIcon || ''
: field?._eyeOffIcon || '';
btnEl.setAttribute('aria-label', visible[id] ? 'Скрыть ключ' : 'Показать ключ');
btnEl.title = visible[id] ? 'Скрыть ключ' : 'Показать ключ';
}; };
card.querySelectorAll('[data-toggle]').forEach((button) => { card.querySelectorAll('[data-toggle]').forEach((button) => {
@ -106,9 +131,13 @@ export function render({ navigate }) {
state.session.storagePwdInMemory, state.session.storagePwdInMemory,
); );
keys.root = savedKeys.rootKey || ''; const rootSeed32 = savedKeys.rootKey ? extractSeed32FromPkcs8B64(savedKeys.rootKey) : null;
keys.blockchain = savedKeys.blockchainKey || ''; const blockchainSeed32 = savedKeys.blockchainKey ? extractSeed32FromPkcs8B64(savedKeys.blockchainKey) : null;
keys.device = savedKeys.deviceKey || ''; const deviceSeed32 = savedKeys.deviceKey ? extractSeed32FromPkcs8B64(savedKeys.deviceKey) : null;
keys.root = rootSeed32 ? bytesToBase58(rootSeed32) : '';
keys.blockchain = blockchainSeed32 ? bytesToBase58(blockchainSeed32) : '';
keys.device = deviceSeed32 ? bytesToBase58(deviceSeed32) : '';
if (keys.root || keys.blockchain || keys.device) { if (keys.root || keys.blockchain || keys.device) {
status.textContent = 'Показаны только ключи, сохранённые на этом устройстве.'; status.textContent = 'Показаны только ключи, сохранённые на этом устройстве.';

View File

@ -7,7 +7,6 @@ import {
getBalanceSol, getBalanceSol,
getTopupSiteUrl, getTopupSiteUrl,
getWalletFromStoredDeviceKey, getWalletFromStoredDeviceKey,
requestAirdropSol,
transferSol, transferSol,
} from '../services/solana-wallet-service.js'; } from '../services/solana-wallet-service.js';
import { import {
@ -738,32 +737,7 @@ export function render({ navigate }) {
setStatus('Кошелёк не инициализирован.'); setStatus('Кошелёк не инициализирован.');
return; 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); content.append(backBtn, card, actions, generatedCard);
@ -924,19 +898,22 @@ export function render({ navigate }) {
setStatus('Генерация Arweave-кошелька...'); setStatus('Генерация Arweave-кошелька...');
try { try {
let wasFirstTimeGeneration = false;
arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey({ arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey({
...sessionArgsOrThrow(), ...sessionArgsOrThrow(),
onStatus: (message) => { onStatus: (message) => {
const text = String(message || '').trim(); const text = String(message || '').trim();
if (!text) return; if (!text) return;
if (text.includes('впервые получаем Arweave-кошелёк')) { if (text.includes('впервые получаем Arweave-кошелёк')) {
setStatus('Сейчас мы впервые получаем Arweave-кошелёк из вашего приватного device key. Это может занять немного времени. После этого кошелёк будет храниться только в зашифрованном контейнере этого устройства.'); wasFirstTimeGeneration = true;
setStatus('Подождите — ваш Arweave-ключ вычисляется из device key. Это происходит только один раз, потом будет мгновенно.');
return; return;
} }
setStatus(text); setStatus(text);
}, },
}); });
if (modeToken !== activeModeToken) return; if (modeToken !== activeModeToken) return;
if (wasFirstTimeGeneration) setStatus('');
walletAddress = arweaveWalletCtx.address; walletAddress = arweaveWalletCtx.address;
addressEl.textContent = walletAddress; addressEl.textContent = walletAddress;
await refreshBalance(); await refreshBalance();

View File

@ -8,6 +8,7 @@ import {
exportPkcs8B64, exportPkcs8B64,
generateEd25519Pair, generateEd25519Pair,
importPkcs8Ed25519, importPkcs8Ed25519,
publicKeyB64FromPkcs8Ed25519,
randomBase64, randomBase64,
sha256Bytes, sha256Bytes,
signBytes, signBytes,
@ -749,7 +750,12 @@ export class AuthService {
if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' }); if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' });
const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.key'); const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.key');
const result = { rootPair, blockchainPair, devicePair }; const result = {
masterSecretB64: bytesToBase64(masterSecret),
rootPair,
blockchainPair,
devicePair,
};
this.passwordKeyBundleCache.set(cacheKey, result); this.passwordKeyBundleCache.set(cacheKey, result);
if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' }); if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
return result; return result;
@ -857,6 +863,23 @@ export class AuthService {
return { ...session, keyBundle }; 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 }) { async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
let currentSecrets = {}; let currentSecrets = {};
try { try {

View File

@ -25,6 +25,61 @@ function base64UrlToBase64(value) {
return normalized + '='.repeat(padLen); 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) { export function randomBase64(byteLen = 32) {
const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen)); const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
return bytesToBase64(bytes); return bytesToBase64(bytes);
@ -254,6 +309,13 @@ export async function importPkcs8Ed25519(pkcs8B64) {
return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']); 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) { export async function signBase64(privateKey, text) {
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text)); const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
return bytesToBase64(new Uint8Array(signature)); return bytesToBase64(new Uint8Array(signature));

View File

@ -0,0 +1,87 @@
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 MASTER_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/MASTER';
export const STREAM_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/STREAM'; export const STREAM_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/STREAM';
export const MR_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/MILLER-RABIN'; export const MR_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/MILLER-RABIN';
export const MILLER_RABIN_ROUNDS = 64; export const MILLER_RABIN_ROUNDS = 42;
export const SMALL_PRIME_LIMIT = 10000; export const SMALL_PRIME_LIMIT = 10000;
function getSubtle() { function getSubtle() {

View File

@ -1,491 +1,7 @@
import { importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; export {
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js'; calcLimitTopupPriceLamports,
import { SHINE_PAYMENTS_PROGRAM_ID, SHINE_USERS_ECONOMY_CONFIG_SEED, SHINE_USERS_PROGRAM_ID } from '../solana-programs.js'; getLimitStepBytes,
getShineBlockchainUsage,
const MAGIC = 'SHiNE'; getShineUsersEconomyConfig,
const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK'; updateShineUserPdaOnSolana,
const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault'; } from './shine-user-pda-service.js';
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,16 +1,11 @@
import { sha256Bytes, signBytes, importPkcs8Ed25519, base64ToBytes } from './crypto-utils.js'; import { registerUserOnSolana as registerUserOnSolanaShared } from './shine-user-pda-service.js';
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
import { import {
SHINE_USERS_PROGRAM_ID, SHINE_USERS_PROGRAM_ID,
SHINE_PAYMENTS_PROGRAM_ID,
SHINE_LOGIN_GUARD_PROGRAM_ID, SHINE_LOGIN_GUARD_PROGRAM_ID,
} from '../solana-programs.js'; } 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 CLASSIFY_LOGIN_DISCRIMINATOR = new Uint8Array([112, 97, 152, 32, 255, 73, 108, 86]);
const PRECHECK_SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P'; const PRECHECK_SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P';
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
let solanaLibPromise = null; let solanaLibPromise = null;
function loadSolanaLib() { function loadSolanaLib() {
@ -23,168 +18,19 @@ function pushU32LE(buf, v) {
buf.push(n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF, (n >> 24) & 0xFF); 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 { class BorshBuf {
constructor() { this._b = []; } constructor() { this._b = []; }
u8(v) { this._b.push(v & 0xFF); } u8(v) { this._b.push(v & 0xFF); }
u32(v) { pushU32LE(this._b, v); } 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) { str(s) {
const enc = new TextEncoder().encode(s); const enc = new TextEncoder().encode(s);
this.u32(enc.length); this.u32(enc.length);
for (const x of enc) this._b.push(x); 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); } raw(bytes) { for (const x of bytes) this._b.push(x); }
result() { return new Uint8Array(this._b); } 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) { function serializeClassifyLoginArgs(login) {
const b = new BorshBuf(); const b = new BorshBuf();
b.raw(CLASSIFY_LOGIN_DISCRIMINATOR); b.raw(CLASSIFY_LOGIN_DISCRIMINATOR);
@ -283,98 +129,5 @@ export async function checkLoginExistsOnSolana({ login, solanaEndpoint }) {
} }
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) { export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) {
const solana = await loadSolanaLib(); return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint });
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