Compare commits

...

943 Commits

Author SHA1 Message Date
coopops 5714e3090b test: синхронизировать electron-smoke с smoke.test.ts (login реализован)
SDK cross-runtime / cross-runtime (pull_request) Successful in 3m0s
Typecheck / desktop (pull_request) Successful in 14m16s
Typecheck / controller (pull_request) Successful in 13m44s
electron-smoke ассертил login/getAccessToken → NotImplemented (старый скелет Story 1.2), но login реализован (flow-executor Story 11.2): в stub-окружении даёт AuthV2Error(network_error)/(wallet_locked). Node/browser smoke уже обновлены — ждут любой валидный код AuthV2ErrorCode. Привёл electron к тому же; конкретный код не пиннингуем. Это была единственная упавшая из 6 проверок electron.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:41:27 +00:00
coopops 2a167ee458 ci: electron-зависимости (GTK/ATK/ALSA) в sdk-cross-runtime
SDK cross-runtime / cross-runtime (pull_request) Failing after 3m11s
Typecheck / desktop (pull_request) Successful in 14m4s
Typecheck / controller (pull_request) Has been cancelled
После фикса pnpm-версии cross-runtime дошёл до test:electron и упал: electron не стартует без libgtk-3.so.0. playwright --with-deps chromium их не ставит (chromium на другом тулките). Добавлен поштучный apt-install GTK/ATK/ALSA-библиотек (noble t64-имена, || true чтобы расхождение имени не валило шаг). Node+browser прогоны cross-runtime уже зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:17:00 +00:00
coopops da08e22686 merge: dev → coopID + регенерация SDK-клиента и фиксы typecheck CI
SDK cross-runtime / cross-runtime (pull_request) Failing after 2m43s
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Слияние dev в консолидированную ветку CoopID (PR #148). Разрешение 5 конфликтов кодогена: 4 zeus-файла (controller/zeus + sdk/src/zeus) перегенерированы через generate-schema+generate-client из union-схемы; sdk/src/selectors/index.ts — union (criticalActions + expense). Фиксы красного CI:
- controller typecheck: добавлен deleteExpired в моки IAccessRulesRepository (ability.factory/capability-set.service/policy.service spec) — интерфейс получил метод в истории cron-уборки access_rules, моки отстали.
- desktop vue-tsc: store.ts — типизирован wallet (Awaited<ReturnType<unlockWithPin>>); DeletePaymentMethodButton PropType-фикс пришёл из dev.
- SDK cross-runtime: pnpm 9.15.9 → 10.33.0 в sdk-cross-runtime.yaml (mismatch с packageManager pnpm@10.33.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:04:12 +00:00
ant 7ab0c31878 Merge pull request '[C28-28..C28-34] feat: шасси системы расходов (MVP-SINGLE) + интеграция Капитала через inter-порт' (#61) from feat/expense-chassis-mvp into dev
Build bootstrap container / build (push) Successful in 3m51s
Reviewed-on: #61
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-15 13:11:52 +00:00
coopops c200a834eb [C28-67][@ant] fix: spec адаптера расходов — убраны стейл-кейсы authExp/declineExp
Typecheck / desktop (pull_request) Successful in 14m23s
Typecheck / controller (pull_request) Successful in 13m43s
Контроллерный tsc --noEmit (CI) падал: spec тестировал adapter.authExp /
adapter.declineExp, которых нет в ExpensesBlockchainAdapter по дизайну.
authexp/declexp исполняет контракт soviet как callbacks решения совета —
backend-адаптер несёт только 6 прямых actions (createexp/payexp/reportexp/
returnexp/overspendexp/closeexp). Зафиксировано в port, mutations-service и
комментарии адаптера. Прав код — приводим тест к дизайну, удаляя два кейса.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:04:28 +00:00
coopops 56121c2346 [C28-66][@ant] fix: type-errors шасси расходов — vue-tsc в CI
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
CI впервые дошёл до vue-tsc по ветке и вскрыл накопленный долг типов
(локально vue-tsc не гоняем). Чиним точечно, без смены логики:

- uploaded_at — Zeus-скаляр даты типизирован как {}: new Date(String(...))
  и date:String(...) в ProgramExpensePage, ExpenseDetailPage и трёх
  Payment-panel (Attach/Settlement/Report).
- ExpenseDetailPage: loadPayments options требует sortOrder → 'DESC';
  :key платежа допускает null → ключ pay.hash ?? idx.
- PaymentsPage: routeUsername null → undefined под prop :username.
- expenses/model: снят реэкспорт несуществующего IAuthorizeProposalDraft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:53:27 +00:00
ant cabc589ce8 Merge pull request 'fix(desktop): UTF-8 (bit-11) в именах файлов zip-пакета документов' (#147) from fix/zip-utf8-flag into dev
Reviewed-on: #147
2026-06-15 11:38:01 +00:00
coopops c3086277f4 fix(desktop): zip-пакет документов — флаг UTF-8 (bit-11) в именах файлов
Typecheck / desktop (pull_request) Successful in 13m56s
Typecheck / controller (pull_request) Successful in 13m22s
buildZipArchive писал имена в UTF-8, но general-purpose bit flag = 0 → читатели
(включая верификатор на fflate) декодировали кириллицу как latin1 (мохибейк), и
manifest.json (корректный UTF-8) не находил записи → верификатор «в пакете нет
файлов». Ставим bit-11 в local и central заголовках. Архив теперь корректно
открывается любым ZIP-ридером (Explorer/macOS/fflate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:58:55 +00:00
coopops a8882bdc2f [105-14][@ant] feat: подсказка про пароль при первом входе на экране инвайта — новый пайщик задаёт пароль единым менеджером миграции, ключ+пароль через один vault-путь без дублирования логики в мастере
SDK cross-runtime / cross-runtime (pull_request) Failing after 16s
Typecheck / desktop (pull_request) Failing after 9m57s
Typecheck / controller (pull_request) Failing after 8m32s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:45:08 +00:00
coopops 4a51f69a2f [105-14][@ant] feat: вход по паролю и встроенный менеджер миграции «ключ→пароль» в форме входа + карточка установки пароля в настройках — перевести действующих пайщиков на пароль без потери доступа, не ломая вход по ключу (Stories 11.5/11.6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:42:11 +00:00
coopops 179c9eb803 [105-14][@ant] feat: PIN-код устройства на столе пайщика + запрос PIN при авто-локе и перезагрузке — дать пайщику необязательный барьер от посторонних поверх входа по паролю (уточнённая модель PIN Эпика 7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:35:32 +00:00
coopops 903926e34c [105-14][@ant] feat: страница «Настройки» на столе пайщика с управлением активными сессиями — дать пайщику видеть устройства входа и завершать чужие сессии без обращения в поддержку (Story 3.7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:27:58 +00:00
coopops 2575e9545d [105-10][@ant] feat: персистентность токенов CoopID + recovery как потребитель моста подписи (Эпик 7) — чтобы CoopID-сессия переживала перезагрузку не хуже легаси и восстановленный пайщик сразу входил
@coopenomics/auth: configureTokenStorage + restoreSession — пара токенов сессии
персистится в StorageAdapter (frontend — IndexedDB) и восстанавливается на boot;
setSession пишет копию, refresh её обновляет, clearSession стирает (logout). Без
этого CoopID-сессия теряла токен на F5 и была слабее легаси. Тесты oidc-tokens
10/10 (персист/restore/refresh-update/clear), tsc 0, ESLint 0, dist пересобран.

Desktop: session.init восстанавливает токены CoopID (configureTokenStorage+
restoreSession) перед establishCoopIdSession — сессия переживает reload (токены из
IndexedDB + ключ из PIN-кэша). Recovery — первый потребитель моста: confirmRecovery
строит CoopID-сессию поверх keystore (у восстановленного пайщика легаси-WIF нет) +
PIN-кэш; RecoverConfirm ведёт по каноническому boot-пути на рабочий стол вместо
тупикового signin (который требует WIF). ESLint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:43:50 +00:00
coopops 3064e4b7ae [C28-65][@ant] refactor: платежи расхода — один запрос по proposal_hash
Typecheck / desktop (pull_request) Failing after 8m27s
Typecheck / controller (pull_request) Failing after 8m22s
ExpenseDetailPage переведён со сбора платежей по реконструированным хэшам на
один запрос getPayments({ coopname, proposal_hash }) — фильтр из C28-64. Убрана
скопированная серверная формула settlementPaymentHash/sha256Hex из
shared/lib/expenses: фронт больше не дублирует деривацию хэшей расчётных
платёжек. Запрос type-agnostic — ловит все связанные платежи любого типа,
устойчив к добавлению новых видов. Сортировка: выдача/оплата раньше расчётных.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:17:16 +00:00
coopops 435634bfd9 [C28-64][@ant] feat: фильтр платежей по proposal_hash в gateway + regen SDK
Добавлен фильтр PaymentFiltersInput.proposal_hash — возвращает все платежи,
связанные с расходом (служебной запиской). Связь платёж→расход уже зашита
бэкендом в json-поле blockchain_data.proposal_hash (и у платежа выдачи аванса/
оплаты организации, и у расчётных платёжек возврата/доплаты); фильтр извлекает
его через json-оператор ->> в typeorm-репозитории. Так связанные платежи
достаются одним запросом по расходу, без реконструкции хэшей на фронте.

regen: schema → generate-client → sdk build (zeus-клиент controller+sdk).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:17:06 +00:00
coopops d1d7410731 [105-10][@ant] feat: мост подписи CoopID на десктопе — WalletPluginCoopId + CoopID-сессия в session.init (Эпик 7) — чтобы подписывать он-чейн без извлечения ключа из keystore
WalletPluginCoopId (AbstractWalletPlugin) считает signing-дайджест и делегирует
подпись в @coopenomics/auth.signChainDigest — приватный ключ из keystore не
выходит, как у Ledger/Anchor. session-store: establishCoopIdSession строит
wharfkit Session поверх keystore (без globalStore.wif); ensureUnlocked — единая
точка авто-unlock по PIN-кэшу перед каждой подписью; авто-лок RAM 30 мин
(скользящий); username/isAuth fallback на CoopID-аккаунт; close затирает ключ и
PIN-кэш. session.init: ветка CoopID после легаси — строго аддитивно, при
отсутствии CoopID-кэша no-op, легаси-путь байт-в-байт не изменён. StorageAdapter
поверх IndexedDB (createCoopIdStorage) + deleteFromIndexedDB. ESLint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:07:07 +00:00
coopops 0203bf2405 [C28-63][@ant] feat: движения платежей в «Истории состояний» расхода
Typecheck / desktop (pull_request) Failing after 8m34s
Typecheck / controller (pull_request) Has been cancelled
В историю состояний расхода добавлены события платежей: выдача аванса/оплата
организации, возврат недорасхода, доплата перерасхода, отклонения. Берутся из тех
же linkedPayments (C28-61), без отдельного запроса. Тип/иконка/текст по статусу
(исполнен / оплачен кассой / создан / отклонён), сумма и причина отказа — в
описании; сортируются в общую ленту по дате updated_at.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:54:19 +00:00
coopops 0ba0d4c382 [105-10][@ant] feat: вернуть PIN-кэш ключа в @coopenomics/auth (Эпик 7, уточнённая at-rest модель) — чтобы повторно не вводить пароль, а разблокировать локальный кэш ПИН
Супердит «без PIN» из Story 11.8. Двухуровневая защита: серверный vault шифруется
паролём (расшифровка один раз при входе), локальный кэш — ПИН тем же
Argon2id+AES-256-GCM (pin.ts: savePinProtected/loadPinProtected/hasPinProtected/
clearPinProtected, AAD pin|<account>). Обвязка в wallet: persistPinCache (после
входа), unlockWithPin (reload/авто-лок без пароля), hasPinCache/clearPinCache;
DEFAULT_PIN='000000' делает разблокировку прозрачной. Модель угроз: ПИН — анти-
«дурак», от кражи блоба защищает пароль. Тесты pin.test.ts 7/7, tsc 0, eslint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:52:47 +00:00
coopops 91cfc0f0af [C28-62][@ant] feat: кнопка «Назад» (к реестру расходов) в детали расхода
Из детали расхода нельзя было вернуться в реестр расходов. Добавлен canon
back-link под шапкой (как в MeetDetails/DocumentDetails): router.push на
expenses-registry. Виден всегда, даже пока расход грузится.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:52:28 +00:00
coopops 882646d6aa [C28-61][@ant] feat: платежи по расходу в деталях СЗ + переход в реестр платежей
Typecheck / desktop (pull_request) Failing after 8m23s
Typecheck / controller (pull_request) Has been cancelled
В детали расхода (стол совета → реестр расходов → расход) добавлена секция
«Платежи по расходу»: выдача аванса/оплата организации и, если был перерасчёт по
чекам, расчётная платёжка (возврат недорасхода / доплата перерасхода). Реквизиты
«куда уходил платёж», назначение и причина отклонения — внутри PaymentDetails.

Платежи собираются по ДЕТЕРМИНИРОВАННЫМ хэшам, а не по username: у позиции-
организации платёж принадлежит кооперативу, у аванса — пайщику-получателю, и у
каждой позиции свой получатель — единого владельца нет. Хэш выдачи = item_hash,
расчётные = sha256('expense-settlement:coop:item:kind') (новый settlementPaymentHash
в shared/lib/expenses, точная копия серверного generateHashFromString). Точечный
hash-фильтр gateway не требует листать общий реестр и нового бэкенд-поля.

Клик «Открыть в реестре платежей» → реестр кассира, отфильтрованный по владельцу
(:username? в PaymentsPage), с фокусом на платёж (виджет раскрывает его по ?focus=hash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:25:26 +00:00
coopops 9d830e7b91 [C28-60][@ant] fix: показывать причину отклонения платежа (CANCELLED)
Кассир пишет причину в диалоге «Отклонить», бэкенд сохраняет её в payment.message
и ставит статус CANCELLED — но PaymentDetails показывал message только при FAILED,
поэтому причина нигде не отображалась. Добавлен canon-баннер (.banner--neg) с
причиной для CANCELLED: видно сразу при раскрытии платежа. Компонент общий —
работает и на столе совета, и на столе пайщика.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:25:06 +00:00
coopops c5085b2707 [105-10][@ant] feat: SDK signChainDigest — keystore подписывает дайджест транзакции, приватный ключ не покидает RAM-keystore (мост подписи CoopID, Эпик 7)
Первый кирпич моста подписи CoopID: тот же паттерн, что у signDocument/signTimestamp —
ключ берётся через пакет-внутреннюю readUnlockedKey(), наружу уходит только SIG_K1_.
Десктопный WalletPluginCoopId (следующий шаг) делегирует сюда wharfkit Session.sign,
чтобы транзакции подписывались без выдачи WIF (как Ledger/Anchor-плагины).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:07:37 +00:00
coopops 4453dd9cfd [C28-59][@ant] feat: кликабельная ссылка «Основание» → раскрыть платёж выдачи аванса
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Failing after 7m58s
В блоке «Основание расчёта» (C28-58) добавлена кнопка «Открыть платёж выдачи аванса»:
кассир жмёт → реестр раскрывает исходный платёж-аванс (его hash == item_hash расчётной
платёжки) и прокручивает к нему. Видно, сколько выдавалось и что в чеке, без поиска
строки того же пайщика вручную. id навешены на tr/pay-card для scrollIntoView; если
платёж аванса не на текущей странице — подсказка.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:37:59 +00:00
coopops e0202b49dd [C28-58][@ant] feat: блок «Основание расчёта» у settlement-платёжки в реестре
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Has been cancelled
Кассир (и пайщик) при раскрытии расчётной платёжки (возврат недорасхода / доплата
перерасхода) видит основание прямо в строке — без поиска исходного аванса среди
сотен строк реестра:
- ссылка на СЗ (№), что оплачивали, выдано авансом, заявлено по чекам, сумма расчёта
- список подтверждающих документов (чеки REPORT_FILE) со ссылками на открытие

Новый feature ExpenseSettlementBasis (self-contained: грузит позицию СЗ + файлы по
proposal_hash/item_hash из blockchain_data платёжки). Подключён в ListOfPaymentsWidget
для EXPENSE_RETURN/EXPENSE_OVERSPEND на обоих столах (desktop+mobile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:26:23 +00:00
coopops ca9fd8f8de [C28-57][@ant] fix: запретить повторный отчёт по авансу + показывать заявленный факт
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Корень: позиция on-chain остаётся PAID до подтверждения расчётной платёжки кассой
(reportexp отложен), поэтому отчёт можно было подать многократно на разные суммы.
Идемпотентность createSettlementPayment — лишь по виду (возврат/доплата), из-за чего
отчёт 400→200 плодил две платёжки, а 500 молча возвращал старую.

Backend (авторитетный фикс):
- reportExpenseItem: guard по report_state платежа аванса — SETTLEMENT_PENDING/CLOSED
  → BadRequestException «Отчёт по этой позиции уже подан»
- markAdvanceReportState сохраняет reported_amount (заявленный факт) рядом со статусом
- тест на guard + обновлён ассерт SETTLEMENT_PENDING

Frontend:
- ReportExpenseAdvancePanel: новые props report-state/reported-amount; форма
  (AmountInput+кнопка) доступна только пока отчёт не подан (canReport); после подачи —
  «Отчёт подан на N ₽, ждём расчёт», остаётся лишь загрузка доп.документов
- ListOfPaymentsWidget: прокидывает report_state/reported_amount в панели на обоих столах

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:21:50 +00:00
coopops f2489fc592 [C28-56][@ant] fix: сузить поле суммы в отчёте пайщика + поправить назначение возврата
Typecheck / desktop (pull_request) Failing after 8m18s
Typecheck / controller (pull_request) Failing after 7m58s
- ReportExpenseAdvancePanel: AmountInput в .report-advance__amount (max-width 240px),
  чтобы значение не висело во всю ширину панели с разрывом «лейбл … сумма»
- memo возврата: «выданных под аванс под отчёт» → «выданных авансом под отчёт»

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 05:45:21 +00:00
coopops ade9fe20b2 [105-15][@ant] feat: десктоп-восстановление доступа CoopID по magic-link — экран запроса и подтверждения (TOTP+новый пароль) поверх SDK loginWithMagicLink (Story 12.3)
Coopname-scoped magic-link URL :coopname/auth/recover/:token (как invite), новый
feature RecoverAccess + widget/page (канон AuthCard/OtpInput/Base*), вход 'Потеряли
ключ?' ведёт на CoopID-recover. Полный вход в приложение после recovery упирается в
мост подписи CoopID (session.init строит wharfkit-Session из globalStore.wif) — 11.5/Эпик-7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 05:23:14 +00:00
coopops 9ac810f34a [C28-55][@ant] fix: вернуть «Направление» в реестр (перспектива стола) + универсальные уведомления о платеже
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Failing after 8m0s
Направление платежа (ListOfPaymentsWidget):
- колонка «Направление» снова показывается на ОБОИХ столах;
- on-chain direction всегда относительно кооператива (INCOMING = в кооператив).
  Стол совета (!hideActions) так и видит; на личном столе пайщика (hideActions)
  перспектива обратная — direction инвертируется (исходящий из кооператива =
  поступление пайщику). Хелперы displayDirection/directionLabel/directionHint,
  tooltip с пояснением («В кооператив»/«Из кооператива» vs «Поступление вам»/
  «Списание с вас»). colspan/skeleton/min-width поправлены под доп. колонку.

Уведомления о платеже — без привязки к типу:
- workflow payment-refunded (любой исходящий PAID пайщику) больше не «Возврат
  взноса выполнен», а универсальное «Платёж выполнен» (тем же каналом идут
  аванс под отчёт, доплата по перерасходу и пр. — тип платежа неизвестен).
  payment-paid уже был универсальным («Платёж принят» для входящего).
- комментарий в payment-notification.service приведён в соответствие.

ESLint ✓, notifications build ✓ (id platyozh-vypolnen резолвится в каталоге).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 05:13:00 +00:00
coopops fed20d5fba [105-15][@ant] feat: confirm восстановления отдаёт username и AAD vault'а делаю account-независимым — убрать лишний whoami-by-token и дать вход по magic-link без знания аккаунта заранее (Story 12.1/12.2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 05:09:52 +00:00
coopops 975cc509ae [105-12][@ant] feat: Prometheus /metrics endpoint и auth-метрики входа CoopID — наблюдаемость и основа алертов oidc (Story 9.11)
@willsoto/nestjs-prometheus + prom-client → GET /metrics в exposition-формате
и процессные метрики Node на глобальном реестре. AuthMetricsService даёт
доменные счётчики auth_login_attempts_total / auth_login_success_total /
auth_errors_total{contour,error_code} (success_rate = производное PromQL,
связка для alert Story 7.12). Провязка в единой login-границе verify-timestamp:
попытка/успех/ошибка по типизированному коду, side-effect-only, вход не валит.
Cross-cutting части AC (HTTP-latency-интерсептор, Redis/PG-gauge) отложены —
не вшиваю в общий app вслепую. Тесты auth-metrics 6 + verify-timestamp 28.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:09:53 +00:00
coopops d59dc7b796 [105-9][@ant] feat: cron-уборка истёкших access_rules — гигиена таблицы прав без влияния на доступ (Story 6.7)
Точечные права с TTL (expires_at) при истечении уже инертны — read-path
findForPrincipal/findForCapabilitySets исключает их (expires_at <= now), доступа
они не дают. Но мёртвые строки копились вечно. Добавлен AccessRulesCleanupService
(@Cron ежедневно, прецедент CriticalActionsService.expireStale) + порт-метод
deleteExpired + DELETE ... WHERE expires_at IS NOT NULL AND expires_at <= now
RETURNING (детерминированный подсчёт, как в capability-sets). Удаление != отзыв:
ничьи фактические права не меняются → без инвалидации сессий и аудита.
Зачем: завершает TTL-фичу (6.7) — таблица прав не растёт бесконечно, выборка
прав не деградирует; безопасный не-визуальный бэкенд-слайс заблокированной 6.7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:32:16 +00:00
coopops 4221ca707e [105-15][@ant] feat: реализовать loginWithMagicLink в SDK @coopenomics/auth — восстановление доступа по magic-link
Был notImplemented-stub. Теперь полный confirm-флоу + повторный вход: генерация
новой пары (старый ключ при восстановлении утрачен), шифрование приватного новым
паролём в vault (AAD=субъект, наружу не уходит), POST /coop/recovery/confirm
{token, TOTP, public_key, vault, password} → сервер (12.1) ставит пароль в
authentik, сохраняет vault, ротирует active-ключ и отзывает сессии; затем authentik
новым паролём → unlockWallet (round-trip нового блоба) → timestamp-handshake.
Зачем: без этого фронт-recovery (12.3) нечем подтверждать — пайщик не мог
завершить восстановление и войти под новым ключом/паролём.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:03:42 +00:00
coopops f240778162 [105-15][@ant] feat: писать новый пароль в authentik при восстановлении доступа — иначе пайщик залочен после recovery
Story 12.1 (Эпик 12, CoopID-восстановление). RecoveryFinalizationService раньше
молча игнорировал новый пароль (помечено «Эпик 5»): после recovery vault уже
зашифрован новым паролём, а authentik помнил старый → пайщик не мог войти ни
старым (vault не расшифровать), ни новым (authentik отвергает) паролём. Теперь
финализация пишет пароль через admin set_password (порт из 11.1, тот же, что
использует миграция 11.4).

Порядок записей setPassword → vault.store → changekey выбран по матрице частичных
сбоев трёх независимых хранилищ (authentik / vault-БД / on-chain): запись во
внешний IdP — самый вероятный отказ (недоступность, политика пароля), поэтому
идёт ПЕРВОЙ — её сбой не трогает vault и цепь, пайщик остаётся на старых кредах и
чисто повторяет восстановление. vault — ДО changekey (новый приватный ключ живёт
только в блобе, on-chain переключение коммитит его последним и ретраится).

findUserPk + guard: учётка authentik в recovery обязана существовать (recovery
требует включённого TOTP, а TOTP — authenticator authentik); null → защитный
throw (рассинхрон состояния), молча не создаём (нет email-контекста). Пароль
прозрачно уходит в authentik (единственный store паролей), не логируется/не
хранится на стороне controller'а; регресс-тест проверяет, что он не попадает в
аудит KeyRotated.

Тесты: 7/7 (вкл. порядок, null-guard, сбой setPassword=откат, без утечки пароля),
ESLint 0, тип-чек через ts-jest (полная типизация). DI байт-в-байт как MigrationService.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:34:04 +00:00
coopops 412a6d268a [105-14][@ant] refactor: снять PIN-слой из @coopenomics/auth (Story 11.8) — модель CoopID «без PIN», ключ в RAM только на сессию и стирается на логауте
Удалён wallet/pin.ts и вся обвязка: persistPin/unlockWithPin в wallet, pinStorage
в logout, публичные экспорты unlockWithPin/clearPinProtected, PIN-тесты в
wallet/logout. StorageAdapter (локальная копия vault'а 11.3) и крипто-ядро
encryptWithPassword остаются. Решение «без PIN» зафиксировано в архитектуре;
StorageAdapter был заранее вынесен из pin.ts в 11.3 ради этого снятия.

Desktop PIN не использовал (проверено) — публичная поверхность для desktop
(configureCoopId/getAccessToken/migrate/configureOidc) не затронута. tsc 0,
vitest wallet+logout 8/8. Пред-существующие lint-ошибки encrypt.ts/wallet.test.ts
(import-sort/brace-style/lowercase-title из ранних коммитов) не трогал — вне scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:32:51 +00:00
coopops e02dae4ec7 [105-14][@ant] feat: добавить SDK unit-тест паритета легаси-токенов — зафиксировать инвариант FR29, что выданные access-токены работают до логаута при включённом CoopID
Чистый unit без бэкенда: мок fetch проверяет, что при брошенном accessTokenProvider
(нет CoopID-сессии) SDK отправляет легаси-bearer из setToken, а при успешном провайдере
— CoopID-токен. Доказывает D1/Эпик 7: провайдер можно ставить безусловно, не ломая
действующих пайщиков.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:59:34 +00:00
coopops ff50881fd0 [105-14][@ant] feat: desktop-логика миграции «ключ→пароль» и детект WIF — инфра-независимый seam для формы входа Эпика 11
Story 11.6 (логическая часть, без вёрстки). Не-визуальный, аддитивный seam,
который вёрстка LoginForm/мастера потом просто наденет:

- looksLikeWif(value) в shared/lib/utils — авторитетный детект ключа через
  WharfKit-парсер (5…/PVT_K1_…, отсекает пароли). Триггер «вставили ключ →
  предложить миграцию», а не вход ключом как раньше.
- useLoginUser().migrateAndLogin({email, privateKey, newPassword}): SDK migrate()
  (Story 11.4 — подпись против COOPOS + set_password authentik + шифр ключа
  паролём в server-vault) → затем легаси-вход тем же ключом. Пайщик переходит на
  пароль и СРАЗУ остаётся в системе, без потери доступа и без зависимости от
  готовности OIDC-инфраструктуры authentik. Легаси login(email,wif) не тронут.

Вёрстка (LoginForm email+пароль, мастер, баннер) и вход-по-паролю (нужен
публичный OIDC-клиент authentik + резолв account из сессии) — отдельным заходом
с визуальной проверкой/инфрой.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:28:33 +00:00
coopops 6c82544b80 [105-14][@ant] feat: boot-wiring контура CoopID на desktop — configureOidc/configureCoopId + access-token-provider, чтобы подключить вход по паролю не ломая легаси-токены
Story 11.7 (фундамент). Подключает SDK @coopenomics/auth к desktop и
конфигурирует контур CoopID на старте, оставаясь чисто аддитивным:

- @coopenomics/auth добавлен в зависимости desktop (+ pnpm-lock).
- boot/coopid.ts (только клиент): configureCoopId(apiUrl=BACKEND_URL) всегда
  (нужно миграции/vault/recovery без OIDC-клиента); setAccessTokenProvider
  безусловно (при легаси-сессии getAccessToken бросает → SDK откатывается на
  legacy-bearer из client.setToken — инвариант «легаси-токены живут до логаута»
  сохраняется конструктивно); configureOidc под env-гейтом COOPID_ISSUER+CLIENT_ID.
- env COOPID_ISSUER/COOPID_CLIENT_ID (опциональны) в Environment + createEnvObject;
  пока не заданы — desktop остаётся на легаси-входе по ключу.
- boot 'coopid' зарегистрирован до 'init' (провайдер выставлен до первых запросов).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:04:56 +00:00
coopops eaf4125f21 [105-14][@ant] feat: SDK migrate() и контракт username — подпись ключом + POST /coop/migration + saveToVault, чтобы пайщик задал пароль и зашифровал ключ за один шаг
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:36:06 +00:00
coopops 2e16e7d852 [105-14][@ant] feat: backend-эндпоинт миграции «ключ→пароль» — бессессионная проверка подписи против COOPOS + set_password authentik, чтобы действующие пайщики задали пароль без потери доступа
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:31:34 +00:00
coopops 0c68759455 [105-14][@ant] feat: SDK password-vault — POST /coop/vault, saveToVault и локальная копия шифроблоба — чтобы мигрировать ключ под пароль и входить офлайн без round-trip к узлу
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:02:31 +00:00
coopops 5c14bdb0a4 [C28-54][@ant] fix(desktop): пул расходов = баланс реального кошелька + загрузка доп. документов после отчёта
Typecheck / desktop (pull_request) Failing after 8m26s
Typecheck / controller (pull_request) Failing after 7m59s
Q1 (расхождение 900 vs 50): на странице программных расходов «Доступно»
теперь = остаток кошелька w.cap.pgexp (source_wallet, с которого payexp
реально списывает оплату), а не счётчик state.program_expense_pool. Счётчик
двигался на резерве, кошелёк — на оплате, поэтому кассир видел одну сумму, а
оплата обламывалась на другой. Карточка «Зарезервировано» (счётчик) убрана,
чтобы не смешивать два контура учёта на одной карточке кошелька.

Q2 (нет поля для доп. документов после «Отчёт принят»): FileUploader в
ReportExpenseAdvancePanel вынесен из ветки isAwaitingReport — теперь доступен
и в состоянии REPORTED («Приложите дополнительный документ»), как и обещает
текст «дополнительные документы дополнят его автоматически».

ESLint ✓. Визуальная проверка — по скриншотам.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:54:16 +00:00
coopops 4fc6eaf986 [105-14][@ant] feat: встроенный фактор-1 входа через flow-executor authentik вместо signinPopup — чтобы клиент видел пароль для password-vault, не нарушая FR29 (грант остаётся code+PKCE)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:48:33 +00:00
coopops ab9b8ddc66 [C28-53][@ant] fix(desktop): перевёрстка диалога пополнения пула + реестра платежей кассира
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Has been cancelled
Диалог «Пополнение пула программных расходов»:
- убрана тёмная плашка под кнопками (footer-bar на --p-canvas) — кнопки на
  поверхности диалога через штатный BaseDialog __foot;
- снят двойной паддинг (.form поверх body) и uppercase-eyebrow («прыгающие шрифты»);
- BaseInput → AmountInput с суффиксом валюты в узкой обёртке (не во всю ширину).

Реестр платежей (стол кассира), последовательная подача:
- AttachExpenseProofPanel и ReportExpenseAdvancePanel получили опциональный
  проп step (номер+заголовок) — Этап 1 «Подтвердите оплату», Этап 2 «Отчёт
  пайщика»; обёрнуты в .expense-flow с хайрлайн-разделителем. Пустых этапов у
  DIRECT нет (панель отчёта сама скрывается);
- в отчёте пайщика порядок изменён на «сумма → чек» (сначала AmountInput, видно
  недо/перерасход, затем приложить чек, потом кнопка);
- дружелюбные подписи, больше воздуха (gap --p-3/--p-4), снята плотность.

ESLint ✓. Визуальная проверка — по скриншотам пользователя.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:44:34 +00:00
coopops 6415db7085 [105-14][@ant] feat: добавить admin-API адаптер authentik (ensureUser/set_password) — разблокировать миграцию пайщиков «ключ→пароль» и установку пароля при восстановлении доступа
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:26:20 +00:00
coopops b21df2b017 [C28-52][@ant] chore(sdk): regen schema/client после doc_data-перестройки 2010/2011
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Failing after 8m49s
generate-schema + generate-client + sdk build после C28-51. Zeus-клиент
(controller/zeus + sdk/src/zeus) знает ExpenseProposalSignedItemInput +
doc_data_hash на signed-meta 2010; ExpenseProposalDecisionItemInput (2011)
без requisites/payment_purpose/recipient_name. schema.gql — gitignored-артефакт.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:49:23 +00:00
coopops 0fcb8abd39 [C28-51][@ant] feat(expenses): реквизиты СЗ-2010/2011 off-chain через doc_data (не публикуем в блокчейн)
Typecheck / desktop (pull_request) Failing after 8m23s
Typecheck / controller (pull_request) Has been cancelled
Реквизиты получателя, имя и назначение платежа больше не уходят в blockchain
meta документа. Перестроено на паттерн doc_data из marketplace2 (epic5):
приватный payload сохраняется off-chain в DocDataService фабрики, в meta едет
только doc_data_hash; при генерации/регенерации фабрика подмешивает приватную
часть в позиции по number — полнотекстовый документ не страдает.

2010 (СЗ-смета):
- cooptypes: IExpenseItem → публичный (number/description/amount/recipient_type/
  mechanics); новые IExpensePrivateItem/PrivateData/IExpenseRenderItem; Action
  extends IDocDataRef + doc_data_hash; Model.items = render-позиции.
- factory: Action подгружает PrivateData по doc_data_hash и склеивает по number;
  meta строится из публичной data; ExpenseRenderItemSchema для модели рендера.
- controller: DTO расщеплён — вход генерации «богатый» (приватные поля → сервер
  кладёт в doc_data), подписываемая meta = публичные позиции + doc_data_hash.
  Сервис: saveDocData → Action с публичными items. Десктоп не меняется (подпись
  идёт по meta, возвращённой сервером).

2011 (Решение совета): латентный канал закрыт структурно — item-DTO лишён
requisites/payment_purpose/recipient_name; шаблон протокола их и не рендерил.
Публичная ExpenseItemSchema (5 полей) для модели 2011.

cooptypes build ✓, factory build ✓, ESLint ✓, unit 11/11 ✓.
Codegen (schema/client/sdk) — отдельным шагом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:35:57 +00:00
coopops 1a97e1208c [105-10][@ant] feat: SDK CoopID-логин-фасад — handshake bind→sign→verify, lifecycle токенов и REST /coop/refresh — чтобы вход шёл по новому контуру при равноправии легаси-токенов (Эпик 7, фаза 3)
D1: фасад в @coopenomics/auth (handshake/токены/recover), @coopenomics/sdk оборачивает
через Client.setAccessTokenProvider (bearer в слое SDK, без импорта auth — защита Node-потребителей).
D2: /coop/session/bind отдаёт binding_token в теле (+ httpOnly-cookie как fallback).
Бэк: новый REST /coop/refresh (та же токен-машинерия, что и legacy GraphQL-refresh).
Инвариант равноправия токенов закреплён token-coexistence.spec (оба контура — один
generateAuthTokens/config.jwt.secret/guard, без маркера контура). Authorization Code + PKCE (FR29),
ROPC запрещён. Тесты: auth vitest 16/16, controller jest 6/6, tsc(auth+sdk) EXIT0, ESLint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:58:28 +00:00
coopops c69f0f24d8 [C28-50][@ant] feat(factory): перенести инфру doc_data из marketplace2 (приватные данные документа off-chain)
Typecheck / desktop (pull_request) Failing after 8m40s
Typecheck / controller (pull_request) Failing after 8m0s
Выборочный перенос переиспользуемого паттерна doc_data из marketplace2
(исходный коммит 10158b5b33, 598-8) — БЕЗ marketplace-документов 1102/1103,
которых нет в dev (их cooptypes-реестры и controller-сервисы тянули конфликты
modify/delete и сломали бы сборку). 1102/1103 придут штатно при мерже marketplace2.

Что такое doc_data: приватные поля документа (реквизиты/ПДн) сохраняются в Mongo
(коллекция doc_private_data) через DocDataService.save(payload, registry_id) →
{hash}; on-chain в meta публикуется только doc_data_hash (sha256). При генерации
фабрика подгружает payload по хэшу и отдаёт шаблону под зарезервированной
переменной {{ doc_data.* }}. Так реквизиты в блокчейн не попадают, но документ
регенерируем и верифицируем по слепку.

Перенесено (идентично marketplace2 — будущий мерж без конфликтов):
- factory: Services/DocData + обвязка Factory/index.ts (saveDocData/getDocData) +
  реэкспорт в Services/Databazor + Generator.saveDocData/getDocData в src/index.ts;
- controller: GeneratorPort/GeneratorInfrastructureService/DocumentDomainService —
  проброс saveDocData/getDocData;
- cooptypes: IDocDataRef { doc_data_hash } в модели документа.

cooptypes build + factory typecheck/build зелёные. Контур: следующим шагом
перенастроить СЗ-2010 на doc_data для реквизитов (отдельной задачей).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:15:15 +00:00
coopops 245a8fbd1d [C28-49][@ant] feat(expense): расшить отчёт по авансу — статус отчёта, отчёт за пайщика, закрывающие документы
Typecheck / desktop (pull_request) Failing after 8m22s
Typecheck / controller (pull_request) Failing after 8m3s
Разводит три независимых акта вокруг аванса под отчёт, которые UI прежде смешивал:
- Платёж (кассир + платёжка PAYMENT_PROOF) — без изменений.
- Отчёт по авансу (факт + чек REPORT_FILE → reportexp) — пайщик ИЛИ кассир «за пайщика».
- Закрывающие документы организации (новый ExpenseFileKind.CLOSING_DOC) — только DIRECT.

Бэкенд:
- ExpenseReportState (AWAITING/SETTLEMENT_PENDING/CLOSED/NOT_REQUIRED) зеркалится в
  payment.blockchain_data.report_state: в reportExpenseItem (CLOSED/SETTLEMENT_PENDING)
  и в inter-адаптере reportItem в момент on-chain reportexp (CLOSED). AWAITING — дефолт.
- CLOSING_DOC + valuesMap-описания видов файлов; SDK regen.

Desktop:
- Личный стол: убран кассирский значок «Платёжка приложена» (чужая бухгалтерия),
  добавлен бейдж статуса отчёта рядом со статусом платежа.
- Стол совета: кассир может отчитаться за пайщика (ReportExpenseAdvancePanel onBehalf,
  самоскрытие для DIRECT) + отдельная секция закрывающих документов для оплат организациям.

Авторизация reportExpenseItem уже допускала совет по любой строке — путь «за пайщика»
работает без правок guard'ов. Unit 11/11, ESLint чист, SDK typecheck зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:47:02 +00:00
coopops a7f1398529 [105-12][@ant] feat: устойчивость COOPOS RPC — пул с failover, finalized-only чтения ключей и M-of-N консенсус кэша — чтобы вход CoopID переживал падение/компрометацию узла без downtime (Stories 9.4/9.6/9.7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:33:18 +00:00
coopops a0005362e0 [C28-48][@ant] fix(expense): UX расчёта аванса — суммы 2 знака, реквизиты, направление
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Failing after 7m56s
Правки по тест-прогону недо-/перерасхода:

- Суммы в реестре платежей (таблица+карточки) и инпут факта — formatAsset2Digits
  (2 знака), а не сырой on-chain precision=4.
- «Направление» (входящий/исходящий — относительно кооператива) скрыто на личном
  столе пайщика (hideActions): на его столе семантика обратная и путала. На столе
  совета остаётся (там перспектива кооператива корректна).
- PaymentDetails: убран дамп «Данные блокчейна» (JSON); реквизиты показываются при
  любом направлении (входящий возврат — реквизиты кооператива, исходящая
  доплата — реквизиты пайщика); «Сумма к переводу» — 2 знака.
- Settlement-платёжка несёт payment_details с реквизитами: возврат → банк
  кооператива (куда платит пайщик), доплата → снимок реквизитов пайщика (куда
  платит кооператив). Назначение: «Возврат неиспользованных средств, выданных под
  аванс под отчёт» / «Доплата по перерасходу аванса под отчёт».
- После отчёта реестр перезагружается и сразу раскрывает заведённую платёжку
  расчёта (по settlement_payment_hash) — пайщик видит реквизиты, не догадываясь
  нажать «развернуть».

Юнит-тесты расходов зелёные (11/11). GraphQL-схема не менялась — SDK regen не нужен.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:04:05 +00:00
coopops 3ab773c4b6 [C28-47][@ant] feat(expense): SDK-regen + desktop — инпут факта и расчёт разницы
Typecheck / desktop (pull_request) Failing after 8m24s
Typecheck / controller (pull_request) Failing after 8m17s
Фронт-слой контура недо-/перерасхода аванса.

- payment-domain.entity: exhaustive typeLabels по 2 новым типам платежей.
- SDK regen (Zeus controller+sdk) под reportExpenseItem → ExpenseReportResult,
  optional actual_amount и enum ExpenseReportOutcome.
- SDK: селектор expenseReportResultSelector (outcome / settlement_amount /
  settlement_payment_hash / transaction); reportExpenseItem использует его,
  api панели возвращает результат.
- desktop ReportExpenseAdvancePanel: инпут «Фактически потрачено по чекам»
  (префилл выданным авансом), подсказка о недо-/перерасходе, кнопка «Отчитаться»
  вместо авто-сабмита по первому чеку. По исходу: CLOSED — «Отчёт принят»;
  RETURN_PENDING/OVERSPEND_PENDING — пояснение про платёжку расчёта.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:56:06 +00:00
coopops c739c4e10a [C28-46][@ant] feat(expense): расчёт недо-/перерасхода аванса через платёжки
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Has been cancelled
Замыкаю контур факт→возврат/доплата для аванса под отчёт. Контракт уже имел
returnexp/overspendexp с двусторонними ledger2-проводками, но фронт/бэкенд их не
звали: отчёт слепо закрывал позицию на actual=аванс без сверки факта (дыра
недорасхода — пайщик мог недоотчитаться и оставить разницу).

Бэкенд-слой:
- 2 типа платежей: EXPENSE_RETURN (входящий возврат недорасхода) и
  EXPENSE_OVERSPEND (исходящая доплата перерасхода) + labels + direction-списки.
- inter-порт шасси: returnItem/overspendItem/reportItem (зеркало payItem),
  адаптер реализует через returnExp/overspendExp/reportExp.
- gateway-ветки: подтверждение кассиром EXPENSE_RETURN (income) → returnexp +
  reportexp; EXPENSE_OVERSPEND (outcome) → overspendexp + reportexp. proposal_hash
  и item_hash берутся из blockchain_data (hash платёжки уникальный, не item_hash).
- reportExpenseItem(actual_amount?): факт==аванс → reportexp сразу (CLOSED);
  недорасход → входящая платёжка на |разницу| (RETURN_PENDING); перерасход →
  исходящая (OVERSPEND_PENDING); reportexp отложен до подтверждения кассиром
  (контракт принимает settlement только на PAID-позиции). Дельта в минорных
  единицах по precision. Идемпотентность платёжки по детерминированному хэшу.
- Новый дискриминированный результат ExpenseReportResultDTO + enum
  ExpenseReportOutcome. Юнит-тесты на все три исхода + идемпотентность.

Контракт expense не изменён. Авторитет на сумму факта — пайщик (по чекам).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:43:55 +00:00
coopops f5bcaa3a69 [105-9][@ant] refactor: перевести account self-service auth-v2 с REST на GraphQL/SDK — единый типизированный фасад фронта
Корзина C Фазы 2: sessions/2fa/recovery-strategy/security-not-me →
AccountSecurityResolver (9 операций, GqlJwtAuthGuard), а
critical-actions/keys/force-recovery → CriticalActionsResolver (6 операций,
тот же CASL @CheckAbility+AuthorizationGuard). Движок Эпика 6 не тронут —
резолверы поверх тех же сервисов. 5 REST-контролёров сняты целиком, 2 урезаны
до magic-link :token (корзина D). SDK-домены AccountSecurity/CriticalActions +
codegen (SDL 62 резолвера) + Zeus закоммичен. Транспорт (IP/refresh-токен) —
request-meta декораторы, не GraphQL-переменные. Unit 24/24, ESLint 0.

Зачем: наружу на фронт смотрит только @coopenomics/sdk — нового способа
взаимодействия с бэкендом не появляется, bearer живёт только в SDK. OIDC
(.well-known), webhook (coop/internal) и login-контур (Фаза 3) остаются REST.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:22:15 +00:00
coopops e70b368163 [C28-45][@ant] revert: вернуть MyAdvancesPage как каркас стола кассира (не удалять scaffold)
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Failing after 8m13s
Откат удаления из C28-44: страницы воркспейса expenses (MyAdvances/Cashier/AdminApprove) — намеренный рабочий КАРКАС будущего стола кассира, а не мёртвый код. Решено их не удалять, а сохранить: соберём стол кассира из них позже, тогда «Мои авансы» выведем туда. Сейчас воркспейс не привязан к столу → в меню их нет и ничего на них не ведёт (то, что и нужно).

— восстановлены MyAdvancesPage.vue + роут expenses-my-advances + экспорт из barrel.
— в install.ts добавлен комментарий-маркер: это каркас, не удалять, ничего на него не ведёт намеренно; рабочие ссылки — на /:coopname/user/payments.
— ссылку напоминателя об авансах НЕ откатываем: остаётся на личные «Платежи» (/user/payments), как просил пользователь — всё ведёт через страницу платежей/реестр платежей, не на каркасные страницы.
— README приведён в соответствие (каркас, а не «удалена»).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 05:10:40 +00:00
coopops 832ea5fd96 [C28-44][@ant] chore: удалить мёртвую страницу «Мои авансы» + перецелить ссылку напоминателя
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
MyAdvancesPage была смонтирована только в воркспейсе expenses, который ни к одному столу не привязан (меню столов приходит с бэкенда; setRoutes вешает пункт лишь если воркспейс есть в столе). Страница открывалась только по прямому URL — пользователь её не видел ни разу. Авансы пайщика-получателя и так видны на его личной странице «Платежи» (/:coopname/user/payments, ListOfPaymentsWidget + ReportExpenseAdvancePanel) — отдельная страница дублировала и была мёртвой.

— desktop: удалён MyAdvancesPage.vue, его роут expenses-my-advances и экспорт из barrel; импорт из install.ts.
— controller: напоминатель об авансах при нескольких авансах вёл на /expenses/my/advances → перецелен на /:coopname/user/payments (личные «Платежи»); один аванс по-прежнему ведёт на сам расход.
— notifications: комментарий схемы payload обновлён.
— README/E2E: ссылки и таблица страниц обновлены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 05:06:28 +00:00
coopops 2fbf08c19a [C28-43][@ant] fix: исходящий платёж организации больше не висит в личном реестре инициатора СЗ
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Has been cancelled
Симптом: пайщик создаёт расход с получателем-организацией (не он сам) — и этот исходящий платёж появляется у него в личном кабинете на странице платежей, хотя он лишь инициатор, а деньги идут не к нему.

Корень: expense-payments.listener создавал gateway-платёж по позиции расхода с username = entity.username (создатель СЗ) для org-получателя. Личный реестр платежей фильтрует по username (loadPayments({username: me})), поэтому платёж кооператива организации оказывался на столе инициатора.

Фикс: владелец org-платежа = сам КООПЕРАТИВ (entity.coopname), а не инициатор. В личных реестрах пайщиков (фильтр по username) он больше не виден; кассир видит его в общем реестре платежей кооператива (loadPayments без username). Аванс под отчёт по-прежнему принадлежит пайщику-получателю (item.recipient) — он у него и отображается. На on-chain payexp правка не влияет: проводка берёт coopname/proposal_hash/item_hash, username не участвует (см. gateway.interactor.processOutgoingPayment).

Касается только НОВЫХ платежей; уже созданные org-платежи с username=создатель не переписываются.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 04:54:41 +00:00
coopops 7155551837 [C28-42][@ant] fix: суммы в карточках расходов программы Благорост — формат asset (1 000,00 RUB)
Typecheck / desktop (pull_request) Failing after 8m34s
Typecheck / controller (pull_request) Failing after 8m2s
Карточки списка показывали сырой total_planned «1000.0000 RUB». ExpenseProposalListRow.total_planned по контракту типа — уже отформатированная строка; страница подавала сырьё. Оборачиваем в formatAsset2Digits в listRows ProgramExpensesPage (как суммы в сводке/детали).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 04:32:04 +00:00
coopops d4a34bb067 [105-9][@ant] refactor: перевести capability-sets/access/certificate с REST на GraphQL/SDK — на фронт наружу должен смотреть только @coopenomics/sdk
Решение владельца: SDK — единственный типизированный фасад фронта; нового способа
взаимодействия с бэкендом на фронте появляться не должно, bearer-токен живёт только в SDK.
auth-v2 как параллельный REST-контракт, вызываемый напрямую с фронта, отвергнут.

- Бэкенд: AuthorizationResolver (capability-sets/access) + CertificateResolver поверх тех же
  сервисов; AuthorizationGuard/@CheckAbility уже GraphQL-aware. Удалены REST-контроллеры
  capability-set/access/certificate. DTO snake_case, резолвер маппит camelCase→snake_case.
- SDK: Queries.Authorization.*/Mutations.Authorization.*/Queries.Certificate.getMyCertificate
  + селекторы; codegen прогнан, Zeus-клиент закоммичен.
- Фронт: Personnel(api/model)+useCoopAccess+ProfilePage(api) на client.Query/Mutation, типы из
  SDK; прямого /coop/* REST на desktop не осталось.
- Проверки: SDL (новые типы/операции), SDK build+tsc+ESLint 0, unit 12/12, ESLint 0 везде.

Фазы 2/3 (остальной auth-v2 CRUD → GraphQL; SDK логин-фасад) — отдельными задачами.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:26:40 +00:00
coopops 65b792e015 [C28-41][@ant] feat: история состояний в детали расхода Совета + страница расходов программы → «Расходы»
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Failing after 7m56s
— ExpenseDetailPage (стол Совета): добавлена секция «История состояний» (ActivityTimeline, group-by-date) — как на странице расхода программы Благорост. Лента собирается из фактов в данных: создание СЗ, подпись заявления, утверждение/отклонение советом, приложенные документы, подача отчёта, закрытие расхода; актор — ФИО создателя. Отдельной журнальной таблицы у шасси нет.
— capital/install.ts: страница расходов программы переименована «Расходы программы» → «Расходы» (title в meta). Доступ только совету — roles ['chairman','member'] уже стояли. Попутно заменена запрещённая каноном FontAwesome-иконка fa-solid fa-receipt → Material receipt_long на обоих роутах (список + деталь).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:54:04 +00:00
coopops 04c0b1195a [C28-40][@ant] fix: таблицы расходов схлопывались и рвали текст посимвольно — !important на min-width (как в реестре платежей)
Typecheck / desktop (pull_request) Failing after 8m23s
Typecheck / controller (pull_request) Failing after 8m10s
— симптом: колонка «Пайщик» (ФИО) в реестре расходов рвалась по буквам в столбик.
— причина: глобальный канон components.css форсит `.table{min-width:0!important}`; мой `.table{min-width:…}` без !important проигрывал → колонки схлопывались уже контента и слова ломались посимвольно. Плюс `overflow-wrap: anywhere` на ячейках добивал.
— фикс (эталон — ListOfPaymentsWidget): `.table{table-layout:fixed!important; min-width:…!important}` + `overflow-wrap: break-word` (не anywhere) на текстовых ячейках. При нехватке ширины таблица скроллится в .table-scroll, а не ломает слова. Применено и к таблице позиций в детали (тот же латентный баг).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:54:57 +00:00
coopops 22e30672a3 [C28-39][@ant] fix: реестр и деталь расхода — чистка шапок, ФИО вместо аккаунта, кликабельные чеки, мелочи вёрстки
Typecheck / desktop (pull_request) Failing after 8m27s
Typecheck / controller (pull_request) Failing after 7m59s
По скриншот-ревью пользователя.

Реестр расходов (ExpensesRegistryPage):
— убрана шапка PageHead («Шасси расходов» / «Реестр расходов» / подзаголовок) — сразу таблица.
— колонка «Пайщик»: ФИО первой строкой (из сертификата подписанта СЗ — её подписывает создатель, доп. запрос не нужен), ниже имя аккаунта мелким моно с копированием по клику (copyToClipboard).
— добавлена колонка-шеврон справа (chevron_right) — видно, что строка открывается; подсвечивается на hover.

Деталь расхода (ExpenseDetailPage):
— убрана шапка PageHead; статус — чипом вверху справа (канон detail-страниц). Переверстана одноколоночно секциями с заголовками-эйбрау (Сводка / Документы / Строки расходов / Чеки и подтверждения) — как на странице расхода программы.
— Сводка: «Пайщик» = ФИО (из сертификата подписанта), отдельная строка «Аккаунт» (моно, копируемая); «Хеш» (рус. ярлык вместо «Hash») переносится и копируется (DataRow mono, vertical).
— «Строки расходов»: счётчик с правильным русским множественным («1 строка / 2 строки / 5 строк») вместо «1 строк».
— «Чеки и подтверждения»: имя файла — гиперссылка, клик открывает документ в новой вкладке (read_url короткоживущий — запрашиваем свежий по id через новый getExpenseFileReadUrl, как в capital); кто приложил — ФИО (из карты подписей СЗ+протокола), а не имя аккаунта; убраны storage_key/размер.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:21:12 +00:00
coopops 844725d660 [105-9][@ant] feat: добавить страницу «Персонал» и эндпоинт эффективного доступа — дать председателю выдавать пайщикам роли-наборы через UI и заложить основание гейтинга столов/страниц по правам (Story 6.11)
Фронт-интеграция назначаемых наборов возможностей по канону desktop. auth-v2
(CoopID) endpoints зовём по REST (sendGET/sendPOST, как coop/certificate) —
codegen/Zeus не нужен; гейтинг прав на guard'е бэкенда (@CheckAbility manage
CapabilitySet).

Бэкенд:
- capability-set.service.listSets теперь обогащает каждый набор его грантами
  (action+resource из access_rules) — UI показывает «эта роль открывает …».
- AccessController GET /coop/access/me + service.getMyAccess: эффективный доступ
  пайщика (активные наборы + плоские allow-гранты из собранной Ability) — это
  ОСНОВАНИЕ гейтинга столов/страниц по выданным правам. Та же модель
  resource:action, что и grants marketplace2 (CoopID-side seam, при мердже
  сводятся, провайдер не дублируем).
- порт: AccessGrant / CapabilitySetWithGrants / ParticipantAccess.

Фронт (components/desktop):
- features/Personnel (api REST + model useCapabilitySets): каталог наборов,
  назначения пайщика, назначить/снять.
- shared/lib/access/useCoopAccess: singleton-композабл, GET /coop/access/me +
  can(action,resource)/hasSet — столы/страницы консультируются для видимости.
- pages/Cooperative/Personnel: страница «Персонал» (канон — q-table :grid,
  Base*-компоненты, токены --p-*): таблица пайщиков + диалог управления ролями
  (chips назначенных + селект добавления + показ что роль открывает).
- extensions/soviet/install.ts: маршрут personnel на Столе Совета,
  meta.roles=['chairman'].

Self-review: бэкенд unit 6/6 (listSets-гранты + getMyAccess добавлены), ESLint 0
бэкенд+фронт. Вёрстку визуально НЕ самопроверял — жду скриншот (канон петли).

Архитектурное (честно): полный механизм grants (getDesktop.grants + провайдер)
и стол бухгалтера живут на ветке marketplace2, не на dev/coopid — здесь видимость
столов по meta.roles. Поэтому «набор → автопоказ стола бухгалтера» не вшит (стола
тут нет); заложено ОСНОВАНИЕ (useCoopAccess.can), которым стол/страница гейтятся,
и которое сводится с grants marketplace2 при мердже. Страница «Персонал» и выдача
ролей полностью рабочие и проверяемы (назначить «Бухгалтер» пайщику → запись +
аудит + его /coop/access/me содержит read AccountingDesk).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:10:14 +00:00
coopops ae80b7f00b [C28-38][@ant] feat: стол совета «Расходы» → реестр всех расходов кооператива (вместо страницы пулов)
Typecheck / desktop (pull_request) Failing after 8m41s
Typecheck / controller (pull_request) Failing after 8m1s
— зачем: на столе совета «Расходы» показывал карточки кошельков-пулов и проваливался с редиректом на страницу программы Благорост. Нужен реестр-наблюдение: единая таблица ВСЕХ расходов кооператива по всем пулам без фильтра, с колонкой кошелька-источника; клик по строке → деталь расхода (та же информация, что на странице расхода). Фильтр по конкретному пулу остаётся на странице расходов программы.

— soviet/install.ts: монтирует ExpensesRegistryPage (title «Реестр расходов», маршрут soviet-expenses-registry) вместо ExpenseWalletsPage; редиректа на программу больше нет. Воркспейс expenses активен → generic-роут expenses-detail резолвится глобально.
— ExpensesRegistryPage: + колонки «Назначение» (описание первой позиции) и «Кошелёк (пул)» (резолв кода source_wallet → человеческое имя через listExpenseWallets(), fallback — сам код); суммы план/факт через formatAsset2Digits (2 знака); убрана колонка «Хеш» (шум, есть в детали).
— ExpenseDetailPage (generic, куда ведёт реестр): поднят до канона страницы расхода — документы СЗ/протокол рендерятся каноном ExpenseProposalDocuments (раскрывающийся BaseDocument с подписями) вместо голых хешей; суммы сводки и позиций через formatAsset2Digits; источник средств показан как имя пула; убрано техническое поле «Действие» (operation_code).
— ExpenseWalletsPage остаётся в barrel'е (не смонтирован); listExpenseWallets/registerExpenseWallet теперь служат картой код-кошелька→имя для реестра. README обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:45:44 +00:00
coopops 8d63baa53a [105-9][@ant] feat: добавить бэкенд назначаемых наборов возможностей (расширяемые роли) — дать председателю выдавать пайщикам роли бухгалтера/кассира поверх трёх базовых core-ролей (Story 6.11)
Реализация решения владельца 2026-06-13 по #16. Только бэкенд — страницу
«Персонал» стола совета сейчас не делаем (явное указание), готовим всё нужное
для управления этими правами. Движок CASL не трогаем: добавлен один ИСТОЧНИК
правил. AbilityFactory.createForParticipantWithRules теперь собирает Ability из
coreRoles (+персональные гранты) ∪ активных наборов пайщика.

- migrations/V2.4.12 — capability_sets (реестр шаблонов set_key/title/builtin/
  coopname) + participant_capability_sets (назначение, UNIQUE username+set_key,
  revoked_at/expires_at). Правила набора — в СУЩЕСТВУЮЩЕЙ access_rules с новым
  subject_type='capability_set' (переиспользует allow/deny/conditions/TTL/Redis-
  инвалидацию). Seed: accountant (read AccountingDesk — стол бухгалтера уже есть)
  + cashier (read+confirm PaymentRegistry — реестр платежей, гранулярно не manage).
- capability-sets.port + access-rules.port (+AccessRulePrincipalKind.CapabilitySet,
  +findForCapabilitySets). PostgresCapabilitySetsRepository (lazy DS; assign=
  ON CONFLICT DO UPDATE идемпотентно оживляет отозванный; revoke=UPDATE RETURNING).
- CapabilitySetService (assign/revoke/listSets/listForParticipant + валидация
  набора + аудит CapabilitySetAssigned/Revoked + инвалидация по пайщику).
- CapabilitySetController coop/capability-sets под @CheckAbility('read'|'manage',
  'CapabilitySet') + HttpJwtAuthGuard+AuthorizationGuard. Chairman L1 +manage
  CapabilitySet. ability.types +CapabilitySet +AccountingDesk/PaymentRegistry.

Различие осей: назначаемые роли (accountant/cashier/auditor — выдаёт председатель)
≠ вычисляемые (оператор ПВЗ/председатель КУ — выводятся из контекста на своих
столах, в этот субстрат не входят).

Self-review: unit 27 зелёных (ability.factory +2 set-merge, capability-set.service
5, policy регресс); ESLint 0; SQL миграции + запросы репозитория + join
AbilityFactory проверены на реальном postgres:18 (идемпотентность seed, цикл
назначение→merge(cashier read+confirm)→revoke RETURNING→0 активных, кириллица).

Отложено честно: полный boot coopback (DI runtime) + migration:run-в-стеке —
CoopID-стек был выключен, поднимать полный backend на машине с 3 рабочими
coopback'ами непропорционально идемпотентной DDL; DI-проводка сверена чтением
(идентична рабочей ACCESS_RULES_REPOSITORY), SQL доказан на PG → следующий
подъём/CI. Имена grant-субъектов столов (AccountingDesk/PaymentRegistry) — на
согласование при разводке desktop-gating. Разблокирует модель 6.6/6.7, питает
Эпик 10 (Story 10.4: ключ = ещё один принципал того же субстрата).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:45:34 +00:00
coopops 6778561228 [C28-37][@ant] fix: лист документа в диалоге — центрируем в col-md-8, не на всю ширину — зачем: A4 на широком экране растягивался во весь макси-диалог. Обёртка .row.justify-center > .col-12.col-md-8 (канон ComplexDocument/ExpandableDocument)
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Failing after 7m57s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:16:47 +00:00
coopops 311ce09c2e [C28-36][@ant] fix: документы расхода рендерим каноном BaseDocument, не голым DocumentPreview — зачем: статья «голый html» расползалась без стилей и без подписей. ExpenseProposalDocuments теперь во всплывашке (BaseDialog maximized) показывает BaseDocument — ShadowHtml со стилями документа/таблиц + раскрывающийся блок DocumentSignatures (кто подписал). Пропы статья/протокол типизированы IDocumentAggregate, statement_doc/decision_doc ложатся напрямую (как decisionAggregate в ExpandableDocument у собраний)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:12:46 +00:00
coopops 1498be5213 [105-11][@ant] docs: перечислить реализованные OIDC-аудит-события в event-schema — синхронизировать схему событий с фактическим маппингом Story 8.3 для читателей кода
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:05:50 +00:00
coopops 6fbce27242 [105-11][@ant] feat: писать OIDC-операции и native-события authentik в audit_events через webhook — дать кооперативу единый аудит входов/выдач токенов для compliance и расследований (Story 8.3)
Все OIDC-операции выполняет authentik — контроллер узнаёт о них только через
native-события, доставляемые webhook'ом на /coop/internal/authentik-events
(механизм Story 1.5, shared-токен constant-time). Расширяем его на OIDC.

- infra/coopid/authentik/blueprints/coopid-oidc-audit.yaml — event-matcher +
  notification-rule + policy-binding на каждое действие (login/logout/
  authorize_application/login_failed/suspicious_request) + webhook body-mapping
  (action/user/client_ip/app/created) + transport на тот же endpoint. Поля
  сверены со /blueprints/schema.json образа 2026.2.
- authentik-events.controller.ts — mapAuthentikEvent расширен: login→OidcLoginSuccess,
  logout→OidcLogout, authorize_application→OidcTokenIssued (семантические Oidc*);
  прочие native-события → Authentik<Action> (login_failed→AuthentikLoginFailed,
  suspicious_request→AuthentikSuspiciousRequest, failure-result). ip=client_ip,
  context={authentik_action,app,authentik_created}. policy_execution (Story 1.5)
  без изменений. Контекст проходит secret-blacklist аудита.
- authentik-events.controller.spec.ts — 10 кейсов (weak-password, Oidc*, Authentik*,
  null, secret-blacklist).

Доставка идёт по членам destination_group: пустая группа НЕ доставляет (транспорт
не вызывается), поэтому выделенная группа coopid-audit с неактивным сервис-членом
coopid-audit-sink — надёжная доставка webhook'ом без спама реальных админов.

Live-валидация (стек поднят, потом потушен): blueprint применился (successful,
объекты+членство в БД); прямой webhook login→OidcLoginSuccess и login_failed→
AuthentikLoginFailed записались в audit_events; РЕАЛЬНЫЙ login_failed в authentik
прошёл всю цепочку (event→rule→webhook по docker-сети→контроллер→audit_events:
AuthentikLoginFailed, ip, user=akadmin). Unit 10/10, ESLint 0.

Отклонение (честно): OidcTokenRevoked/refresh не подключены — в authentik 2026.2 нет
надёжного native-action для отзыва/refresh OAuth-токена (enum eventmatcher не
содержит); endpoint'ы revoke/introspect работают, их аудит — отдельный механизм
при подключении потребителей. Отложено.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:05:09 +00:00
coopops 875ca6edee [C28-35][@ant] feat: документы расхода (СЗ + протокол) на странице расхода + правки лейблов/формата — зачем: со страницы расхода нужно открывать заявление и протокол решения совета, единообразно везде. Доменный канон ExpenseProposalDocuments (shared/ui/domain): клик по строке СЗ/протокола открывает документ во всплывашке (BaseDialog maximized + DocumentPreview, html уже приходит в statement_doc/decision_doc.rawDocument). На ProgramExpensePage: секция «Документы» после сводки; суммы (по смете/фактически/план/факт позиций) — через formatAsset2Digits (2 знака); «Первичные документы» → «Подтверждающие документы»; в таймлайне «Платёжка: приложен документ» → «Приложен документ». Канон статусов: REPORTED «Отчитана» → «Отчёт получен» (shared/lib/expenses)
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Failing after 7m56s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:48:33 +00:00
coopops 28c4164a61 [105-8][@ant] feat: поднять authentik OIDC-провайдер кооператива и корневой discovery — дать CoopID работать как стандартный OIDC-провайдер, чтобы любой внешний сервис подключался конфигом без правок кода (Story 5.1, 5.2, 5.4)
Сторона провайдера (IdP), потребители НЕ настраиваются (решение владельца:
«делаем только CoopID чтоб OIDC работал стандартно, потребителей накинем позже» —
это универсально).

- infra/coopid/authentik/blueprints/coopid-oidc-provider.yaml — декларативный
  blueprint: OAuth2/OpenID-провайдер + приложение `coopid`. client_type
  confidential, RS256 signing-key (5.1), PKCE S256 (5.2), per_provider issuer
  (5.4, per-coop через домен), sub_mode user_uuid, scope-mappings openid/email/
  profile + кастомный coop:verification_types (coopname+типы верификации через
  /userinfo и id_token).
- infra/coopid/caddy/Caddyfile — rewrite корневого /.well-known/openid-configuration
  и /.well-known/jwks.json на authentik app-эндпоинты: RP получают стандартный
  корневой discovery, Host сохраняется → issuer/endpoints строятся по домену коопа.
- infra/coopid/scripts/coopid-oidc-smoke.sh — smoke против поднятого стека:
  все required-поля discovery, PKCE S256, RS256 jwks, алиас jwks.json.

Live-валидация: стек поднят, blueprint применился (статус successful, провайдер+
приложение+scope в БД), smoke через caddy зелёный (issuer/authorize/token/userinfo/
jwks/end-session/introspect/revoke + RS256). Стек возвращён в выключенное состояние.

Отклонения (честно): issuer = https://<домен>/application/o/coopid/ (authentik
всегда включает путь, корневой iss из AC 5.4 ломал бы валидацию у RP; per-coop —
доменом). 5.2 hard-block implicit/ROPC: authentik рекламирует их в discovery
глобально (это возможности сервера, не per-provider), per-client убрать нельзя;
провайдер confidential+code, безопасный путь обеспечен PKCE.

За рамками (потребительская часть/follow-up): 5.3 transport participant_certificate
ES256K, 5.5 COOPOS-верификация в SDK, 5.6 Gitea-тест, 5.7 полный conformance-suite,
8.3 аудит OIDC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 09:27:01 +00:00
coopops 666672d43e [105-6][@ant] feat: подключить финализацию восстановления доступа через registrator::changekey и аудит KeyRotated — дать пайщику реально вернуть доступ ротацией ключа кооперативом вместо 503-заглушки (Story 3.3, 8.4)
RecoveryFinalizationPlaceholder (кидал 503) заменён на RecoveryFinalizationService.
Ротация active-ключа — существующим путём registrator::changekey (registrator владеет
аккаунтами пайщиков; подпись ключом кооператива из vault), без нового authentik-пути.
Порядок: новый vault-блоб → changekey → revokeAll сессий → audit KeyRotated
{trigger,old_pubkey,new_pubkey,initiator_id} (Story 8.4) → уведомление пайщику
SecurityEventKind.KeyRotated. vault сохраняется ДО on-chain переключения (новый
приватный ключ живёт только в блобе — иначе сбой залочил бы пайщика). Пароль authentik
в recovery НЕ трогается — это Эпик 5 (контроллер пока только читает authentik). Мультисиг
для self-recovery не нужен (подтверждён факторами пайщика); защита от единоличного захвата —
только force-recovery (6.9). Тесты: recovery-finalization 4 + регресс recovery-confirm 9 =
13/13. ESLint 0; coopback: Nest application successfully started, без DI-ошибок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 08:25:29 +00:00
coopops e6090d9200 [C28-34][@ant] feat: фоновый напоминатель об отчёте по авансу под отчёт — зачем: закрыть момент, когда пайщик получил аванс и не отчитался. @Interval-воркер в extensions/expenses сканирует зеркало расходов, находит ADVANCE-позиции в PAID без отчёта, шлёт пайщику-получателю недельный агрегированный дайджест (workflow ExpenseAdvanceReportReminder) со ссылкой на сам расход (/:coopname/expenses/:hash) или список «Мои авансы»; только получателю, кассиру/совету ничего, ORG пропускаются. Троттлинг раз в неделю — через идемпотентность Центра уведомлений (поле period=ISO-неделя в payload), без своей таблицы. Грейс до первого письма и интервал тика — env (EXPENSE_ADVANCE_REMINDER_*, дефолты 3 дня / 6 ч). Точка отсчёта — proposal.updated_at; deadline из формы пока не персистится в цепи — отмечено follow-up в README
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Failing after 8m2s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 05:52:04 +00:00
coopops 865b5eb550 [105-12][@ant] test: добавить cross-runtime smoke-тесты SDK auth для Node, браузера и electron + CI-гейт — не дать релизу SDK сломать клиентов ни в одном рантайме (Story 9.13)
Smoke против публичного API @coopenomics/auth на трёх рантаймах: vault round-trip
(Argon2id+AES-GCM WebCrypto), signDocument→verifyDocumentOffline, WalletLocked,
типизированный not_implemented для login/getAccessToken (скелет Story 1.2). Node и
browser — vitest (общий smoke.test.ts), electron — main-process против собранного
dist (electron-main.cjs + run-electron.mjs c xvfb). Скрипты test:node/browser/
electron/cross-runtime + workflow sdk-cross-runtime.yaml. KDF один раз в beforeAll —
в chromium pure-JS Argon2id это минуты, per-test упирался в таймауты.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 05:43:22 +00:00
coopops 999eee2d84 [C28-33][@ant] docs: README по UI-виджетам шасси расходов и рецепту подключения пула — зачем: стол КУ собирается по документу без возврата в контекст Благороста
Typecheck / desktop (pull_request) Failing after 8m31s
Typecheck / controller (pull_request) Failing after 8m5s
2026-06-12 18:10:33 +00:00
coopops ed52abe86c [C28-33][@ant] refactor: виджеты шасси расходов — в shared/ui/domain — зачем: стол КУ должен собираться из готовых компонентов без возврата в контекст Благороста. ExpenseCreateDialog (форма + черновик в localStorage + генерация и подпись СЗ; пул-источник, ключ черновика и подача на чейн — пропы) и ExpenseProposalList (список СЗ пула, данные подаёт страница) — общие доменные виджеты; подписи статусов/механик/файлов — src/shared/lib/expenses; capital переведён на них первым потребителем (тонкая обёртка CreateProgramExpenseDialog + listRows на ProgramExpensesPage), старые labels — реэкспорт
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-12 18:05:32 +00:00
coopops 06c922abf1 [C28-33][@ant] feat: фабричная переиспользуемость шасси расходов + кошельки расходов на столе совета + закрытие расхода + UX отчёта — зачем: шасси должно настраиваться на любой пул без правок контракта. EXPENSE_OPERATION_SETS в ledger2 (source_wallet → 5 кодов операций, compile-time валидация) — в expense.cpp нет захардкоженных кодов; реестр registerExpenseWallet: capital регистрирует пул из install.ts, стол совета рендерит страницу «Расходы» (WalletCard + проваливание в реестр пула); кнопка «Закрыть расход» (совет, REPORT_SUBMITTED → closeexp), роль мутации chairman/member; файлы расхода: регистронезависимый маппинг item_hash (чейн UPPER vs storage lower — документы «исчезали»), секция «Прочие документы»; отчёт: выбор файла = мгновенная загрузка + автоподача reportexp, дозагрузка дополняет отчёт, у кассира платёжка грузится без кнопки; source_wallet в СЗ-документе — w.cap.pgexp
Typecheck / desktop (pull_request) Failing after 8m30s
Typecheck / controller (pull_request) Failing after 7m54s
2026-06-12 17:15:46 +00:00
coopops 8346d97326 [C28-33][@ant] fix: локаторы процессов p.cap.pgexp и p.exp.expns в process-hash-locator — зачем: fail-fast валидатор контроллера падал на старте после добавления операций пула расходов в cooptypes
Typecheck / desktop (pull_request) Failing after 8m38s
Typecheck / controller (pull_request) Failing after 8m6s
2026-06-12 13:40:45 +00:00
coopops 23bb2ad51f [C28-33][@ant] fix!: оплата СЗ — из кооперативного пула расходов w.cap.pgexp, не с личного кошелька пайщика — зачем: payexp списывал w.cap.blago (USER_SHARED) с L3 инициатора, уменьшая его паевые взносы; topupprogexp теперь наполняет кошелёк пула (o.cap.pgtop), подотчёт w.exp.adv числится на получателе аванса; имена o.exp.*/p.exp.expns в cooptypes-словари (названия операций в UI); форма: пайщик-получатель только физлицо, поле ФИО по канону; страница расхода: кнопка «К расходам», без дубля суммы, позиции отклонённой СЗ не «ожидают оплаты»
Typecheck / desktop (pull_request) Failing after 8m39s
Typecheck / controller (pull_request) Failing after 8m12s
2026-06-12 13:19:20 +00:00
coopops 23cd31861a [C28-33][@ant] feat: страница расхода (позиции+реквизиты+файлы+история состояний по канону), выбор пайщика-получателя по ФИО, черновик формы в localStorage, отчёт пайщика по авансу чеком из реестра платежей, создание СЗ только совету — зачем: наполнение карточки расхода и полный цикл аванса под отчёт
Typecheck / desktop (pull_request) Failing after 8m30s
Typecheck / controller (pull_request) Failing after 8m5s
2026-06-12 12:15:01 +00:00
coopops 918f30073a [C28-33][@ant] fix: реестр платежей — компактные DataRow с копированием вместо растянутых CopyableInput, оригинальное имя файла платёжки + рабочее открытие по свежему read_url, отметка «платёжка приложена» у исходящих расходных платежей (proof_count в blockchain_data)
Typecheck / desktop (pull_request) Failing after 8m36s
Typecheck / controller (pull_request) Failing after 8m1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:59:02 +00:00
coopops f9d09a74cc [C28-33][@ant] feat: исходящие платежи расходов в реестре (тип EXPENSE: листенер на authexp + payexp при подтверждении кассой, реквизиты и назначение из снимка), протокол 2011 по канону решений совета (кворум/голоса/повестка через decision_id), подтверждение оплаты файлом в реестре, карточка расхода без механики и с № СЗ
Typecheck / desktop (pull_request) Failing after 8m28s
Typecheck / controller (pull_request) Failing after 8m0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:58:28 +00:00
coopops cb0056f716 [C28-33][@ant] fix: кнопки расходов в топбар через canon Teleport (#header-actions-host), фикс генерации решения 2011 (payment_purpose/deadline/fund_name в DTO), СЗ 2010 — место и дата под заголовком, подпись с отступом, фон футера диалога убран
Typecheck / desktop (pull_request) Failing after 8m22s
Typecheck / controller (pull_request) Failing after 8m0s
2026-06-12 07:15:06 +00:00
coopops 7a7e5a04f0 [C28-33][@ant] feat: кнопки расходов в PageHead (создать → пополнить), короткий № СЗ и formatAsset по канону фабрики, автоназначение платежа «Аванс под отчёт» для пайщика — описание позиции переназвано «Что оплачиваем», пункты документа без списка
Typecheck / desktop (pull_request) Failing after 8m32s
Typecheck / controller (pull_request) Failing after 8m7s
2026-06-12 06:45:07 +00:00
coopops c8a15511ee [C28-33][@ant] feat: СЗ 2010 по канону вёрстки реестра + назначение платежа отдельным полем после реквизитов — фиксируется в снимке для кассира; «оплата по счету», «Организация/ИП», алерт «передано в совет», хинт механики убран
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Failing after 7m57s
2026-06-12 06:07:44 +00:00
coopops 61038a6f8c [C28-33][@ant] feat: срок «в срок до» и фонд списания в СЗ 2010 + инвариант механики оплаты — пайщику только аванс под отчёт, организации только прямая оплата (форма/бэкенд/контракт); фикс 422 на пустом recipient организации
Typecheck / desktop (pull_request) Failing after 8m28s
Typecheck / controller (pull_request) Failing after 8m1s
2026-06-11 20:28:35 +00:00
coopops dfe73601e8 [C28-33][@ant] feat: реквизиты получателей по payment_method со снимком в БД + СЗ 2010 по бумажному образцу — полные реквизиты в документ подставляет сервер, фронт видит сокращённые; вычищены осиротевшие authorizeExpenseReport/declineExpenseReport
Typecheck / desktop (pull_request) Failing after 8m31s
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 20:01:43 +00:00
coopops 1f3e9d93c7 [C28-33][@ant] feat: утверждение программного расхода через решение совета — createexp ставит вопрос в повестку (soviet::createagenda), authexp/declexp стали callback'ами совета; кнопка председателя убрана
Typecheck / desktop (pull_request) Failing after 9m36s
Typecheck / controller (pull_request) Successful in 13m38s
2026-06-11 19:20:18 +00:00
coopops 67dde7cf45 [C28-33][@ant] feat(capital): рассмотрение программного расхода председателем
Typecheck / desktop (pull_request) Failing after 8m22s
Typecheck / controller (pull_request) Successful in 13m19s
На карточке расхода в статусе «Создан» председатель видит кнопку «Рассмотреть» —
открывается диалог авторизации СЗ (утвердить с протоколом 2011 / отклонить с
причиной). Фиксы диалога: отклонение теперь идёт через declineExpenseReport →
declexp (раньше ошибочно через authexp, который безусловно ставил AUTHORIZED);
убраны ссылки на удалённый operation_code (назначение = перечень позиций).
Инициатор в карточке — ФИО (creator_name). Тексты карточек пулов укорочены.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:26:19 +00:00
coopops 06a5cf2464 [C28-33][@ant] feat(capital): creator_name в выводе программного расхода
В карточке показывался username (ant) — канон требует ФИО/название организации.
Бэкенд резолвит имя через ACCOUNT_DATA_PORT.getDisplayName (батчем по уникальным
creators, при ошибке остаётся username). Codegen: schema + zeus + sdk selector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:26:19 +00:00
coopops 8073eb8642 [C28-33][@ant] fix(desktop): WalletCard — перенос заголовков вместо marquee
Бегущая строка убрана полностью (раздражала и читалась плохо): заголовок и
подпись переносятся максимум на две строки с многоточием, полный текст — в
title-тултипе. Выпилены measure/ResizeObserver/tabindex и keyframes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:26:18 +00:00
coopops 986b5e0cf2 [C28-33][@ant] fix(expenses): callback.data нормализуется в hex-строку при синке зеркала
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Has been cancelled
On-chain callback.data — vector<char>; парсер десериализует в Uint8Array, который
в jsonb-зеркале становится {} (пустой) или {"0":..}. GraphQL-поле data — String,
сериализация ответа capitalProgramExpenses падала «String cannot represent value: {}»
и UI показывал пустой список при живой строке в зеркале. Нормализуем в hex ещё
в дельта-маппере (на записи), существующая строка поправлена UPDATE'ом.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:02:38 +00:00
coopops 8c09d9f2ae [C28-33][@ant] feat(boot): pnpm run reboot:blago — перезапуск dev-среды Благороста одной командой
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
extra_reboot (чистая цепь + совет + dev-shortcut онбординга capital — без него
после reboot снова 5 решений совета руками) → ожидание готовности controller →
seed-capital --up-to=04-contributor (программы, проекты, регистрация ant).
Глубина сида настраивается: SEED_UP_TO=08-investments pnpm run reboot:blago.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:57:00 +00:00
coopops 3532245012 [C28-33][@ant] fix(desktop): укорочен locked-label на странице программных расходов
Typecheck / desktop (pull_request) Failing after 8m31s
Typecheck / controller (pull_request) Successful in 13m27s
Канон-класс .wallet__locked-line — white-space:nowrap + inline-flex, рассчитан
на короткий лейбл; длинный «Зарезервировано под активные расходы» не переносился
и вылезал за карточку. Лейбл → «Зарезервировано» (смысл ясен из контекста
страницы), общий компонент не трогаем.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:28:28 +00:00
coopops a08cb7b2b6 [C28-33][@ant] fix(controller): идемпотентное сохранение blockchain-action
global_sequence — глобально-уникальный монотонный id действия в истории цепи.
При re-scan/replay redis-стрима то же действие доставляется повторно → INSERT
падал на unique-индексе (23505) → consumer не ACK'ал сообщение и зацикливал
recoverOwnPending/reclaimStalePending (дикий спам duplicate-key в логах). Теперь
дубль трактуется как «уже сохранено»: возвращаем существующую запись, не бросаем.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:28:28 +00:00
coopops 8b2b9893df [C28-33][@ant] fix(expense): программные расходы зеркалятся в БД и видны в списке
Парсер не был подписан на контракт `expense` (нет в subscribedContracts) →
дельты `expense::proposals` не эмитились → postgres-зеркало `expense_proposals`
оставалось пустым → список «Программных расходов пока нет», хотя на цепи СЗ и
резерв создавались. Добавил `expense` в subscribedContracts парсера.

Плюс: `coopname` добавлен явным полем в таблицу `proposals` контракта (struct +
EOSLIB_SERIALIZE + проставление в createexp) и в cooptypes IProposal. Таблица
scoped по coopname, но зеркало требует coopname в строке — дублируем полем по
канону (как capital/soviet), а не выводим из scope дельты.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:28:16 +00:00
coopops 0a3e1c97b7 [C28-33][@ant] fix(expense): proposal_hash в meta документа 2010 — приведено к канону 2011 (поле в Action/Model cooptypes + Base-meta DTO + JSONSchema шаблона + фабрика кладёт в combinedData + фронт передаёт при генерации); раньше proposal_hash был только в signed-DTO → подписанный документ не проходил валидацию «Field proposal_hash not provided»
Typecheck / desktop (pull_request) Failing after 8m40s
Typecheck / controller (pull_request) Successful in 13m25s
2026-06-11 16:57:50 +00:00
coopops dd5d61b0b4 [C28-33][@ant] fix(cooptypes): шаблон 2010 — nunjucks-тег {% elif %} вместо liquid-овского {% elsif %} (движок рендера nunjucks, как в шаблонах 100/101); рендер падал «unknown block tag: elsif»
Typecheck / desktop (pull_request) Failing after 8m24s
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 16:40:05 +00:00
coopops 44e9bb24a9 [C28-33][@ant] fix(factory): фабрики 2010/2011 сами тянут coop/vars/user из хранилища (resolveParallel-канон) — раньше ждали их в data и валидация падала «должно иметь обязательное поле coop»
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-11 16:34:13 +00:00
coopops 1fcf8523df [C28-33][@ant] feat(expense): per-позиционная механика оплаты — operation_code удалён из proposal/createexp/createpgexp, ledger2-код выводится из item.mechanics при payexp; в одном СЗ смешиваются аванс и прямая оплата; UI — селект способа на каждой позиции
Typecheck / desktop (pull_request) Failing after 8m39s
Typecheck / controller (pull_request) Successful in 13m15s
2026-06-11 13:22:34 +00:00
coopops 42c179d5d0 [C28-33][@ant] fix(desktop): marquee WalletCard — бежит только строка под курсором/фокусом (не вся карточка), скорость одинаковая у всех строк (длительность цикла из величины переполнения, linear)
Typecheck / desktop (pull_request) Successful in 14m17s
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 12:48:47 +00:00
coopops 794cb43de6 [C28-33][@ant] fix(factory): зарегистрировать фабрики 2010/2011 (ExpenseProposalStatement/Decision) в Registry и карте factories — шаблоны были написаны и экспортированы, но генератор их не находил («Фабрика для документа #2010 не найдена»)
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-11 12:42:17 +00:00
coopops bae8bdb52f [C28-33][@ant] fix(desktop): marquee кошельков — бег обеих строк только на hover/фокус (в покое «…», tabindex лишь при переполнении для тапа), иконка удаления позиции расхода — в слот #icon-left (icon-only не рендерит дефолтный слот, иконка была невидима)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 12:19:55 +00:00
coopops 1759c2ac4e [C28-33][@ant] feat(capital): балансы пулов на странице программных расходов + чистка диалога — карточки «Глобальный инвест-пул» и «Пул программных расходов» (чтение живьём с цепи через capitalState: добавлены поля program_expense_pool/reserved в GraphQL DTO и interactor, без участия БД), из диалога пополнения убраны технические идентификаторы пулов
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 12:13:11 +00:00
coopops 50ac2bffc3 [105-12][@ant] feat: структурировать логи в JSON для prod и распространить redaction 8.7 на путь в Sentry — дать агрегируемые stdout-логи без секретов/ПДн (NFR, 152-ФЗ, Story 9.12)
buildLogFormat(isProduction): в production winston.format.json() с полем service
и redaction после splat() (инвариант 8.8), в dev — прежний pretty-printf без
изменений. scrubSensitiveDataFromSentryEvent дополнен redactSensitive по
event.extra/contexts — секреты в Sentry маскируются той же утилитой 8.7. Тесты:
logger-format 7 (обе ветки + маскировка) + sentry-scrub 4. 28/28 зелёных с регрессом.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:31:25 +00:00
coopops e14e34a36e [105-11][@ant] test: добавить тест чистоты логов и сканер утечек секретов, исправить порядок форматов logger — доказать что в test-run логи не содержат ПДн/секретов (NFR9, 152-ФЗ, Story 8.8)
Сканер src/config/log-purity.ts (SENSITIVE_TEST_PATTERNS, findSensitiveLeaks,
assertNoSensitiveLeaks) + интеграционный тест на боевом winston-логгере. Тест
вскрыл дефект 8.7: redactionFormat стоял до splat(), splat() повторно вмёрживал
сырой meta из info[SPLAT] поверх маскировки. Фикс: redactionFormat перенесён
после splat() (перед printf). 19/19 тестов зелёные (7 от 8.8 + 12 регресс 8.7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:56:02 +00:00
coopops 913153285b [105-11][@ant] feat: добавить лог-санитайзер Winston и ESLint-запрет no-sensitive-in-log — чтобы секреты не утекали в production-логи даже при ошибке разработчика (NFR9, 152-ФЗ, Story 8.7)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:56:11 +00:00
coopops 95ec869f79 [105-11][@ant] feat: добавить авто-аудит admin-действий через @AuditAction interceptor — исключить пропуск admin-события из-за человеческого фактора
Story 8.5: декоратор @AuditAction помечает резолвер, AuditActionInterceptor
автоматически пишет audit_events (event=coopid.<category>.<handler>, subject_id из
target_id/id, result success/failure, metadata с выкинутыми секретами в _redacted),
извлекая user/args из GraphQL и HTTP контекста как AuthorizationGuard. Точечный
@UseInterceptors, не глобальный; первые потребители — admin-резолверы 6.6/6.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:36:53 +00:00
coopops c0b7da9f40 [105-11][@ant] feat: структурировать audit-поля с user_agent и каноном схемы — обеспечить полный форензик-контекст 152-ФЗ
Story 8.2: AuditService получил первоклассное поле userAgent (пишется в колонку
user_agent из V2.4.11) вместо обходного хранения в context; DeviceTrackingService
переведён на него. Добавлена каноническая дока event-schema.md (колонки, конвенция
explicit-null-with-reason, secret-blacklist, каталог событий) рядом с кодом аудита,
т.к. components/controller/docs gitignored под генерённый сайт.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:22:13 +00:00
coopops 0fd5d098be [105-11][@ant] feat: добавить миграцию audit_events (user_agent + read-only роль) и починить порядок access_rules — закрыть схему аудита 152-ФЗ для Story 8.1
Story 8.1: append-only audit_events с партициями и триггерами создан ещё в V2.4.0;
V2.4.11 добавляет колонку user_agent (форензика, наполняет 8.2), выделенную read-only
роль coop_audit_reader (graceful-degrade без CREATEROLE), форвард-роллинг партиций и
re-assert append-only грантов. Побочно: перенос guard сноса плейсхолдера в начало V2.4.7
чинит ordering-баг (column subject_type does not exist), из-за которого вся цепочка
миграций 2.4.2-2.4.11 не применялась в dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:57:49 +00:00
coopops 323e1e94a9 [105-7][@ant] feat: добавить ручной отзыв скомпрометированного ключа пайщика председателем — чтобы пресечь использование ключа по сообщению о компрометации
Story 4.7: председатель отзывает ключ через POST /coop/keys/revoke под CASL
update Participant; MVP фиксирует durable pending-state в revoked_keys (вместо
on-chain updateauth, как допускает AC), гасит все активные сессии пайщика и
пишет audit KeyRevokedManually с reason и chairman_id. Пайщик далее обязан
пройти recovery Эпика 3 для получения нового ключа; разблокирована Эпиком 6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:21:47 +00:00
coopops bd4839b67f [105-9][@ant] feat: добавить полную атрибуцию критических действий и audit-trail пайщика — чтобы контролирующий орган мог расследовать злоупотребления
Story 6.10: разделяю в audit инициатора (с timestamp инициации) и подтверждающих
совета (с timestamps), фиксирую payload_hash sha256 для невозможности подмены
содержимого действия в журнале. Добавляю repo.listByTarget и сервис getAuditTrail,
отдающий все критические действия пайщика с полной атрибуцией через эндпоинт
GET /coop/critical-actions/audit-trail/:targetId под @CheckAbility read CriticalAction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 06:57:06 +00:00
coopops 5eb7db9bf3 [105-9][@ant] feat: запретить единоличный force-recovery без согласия пайщика или решения собрания — защита от враждебного захвата аккаунта председателем
Story 6.9: force-recovery гейтится двумя независимыми каналами согласия —
magic-link пайщика (Redis single-use токен, Lua GET→DEL) либо on-chain решение
общего собрания; при стратегии «решение совета» дополнительно требуется
подтверждённый critical action 6.8 (кворум 2 подписей). Отказ → 403 +
audit ForceRecoveryDenied, разрешение → audit triggered_by:chairman.
Порт + Redis-store/notifier (ioredis только в infrastructure), сервис-гейт,
контроллер /coop/force-recovery с @CheckAbility поверх AuthorizationGuard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 06:47:28 +00:00
coopops 35bfe887bb [105-9][@ant] feat: добавить сервис multi-party критических действий с двумя подписями — чтобы исключение пайщика, смена ролей совета и force-recovery не выполнялись одним человеком в одиночку
Story 6.8 Эпика 6 (Подход 4 «Управление и авторизация», CoopID компонент 42).

Таблица pending_critical_actions (V2.4.9), порт + Postgres-репозиторий coop_domain_db,
Redis-нотификатор совета (publish события, фан-аут — notification-center). CriticalActionsService:
инициатор-председатель ставит действие в pending со своей подписью и окном 24ч, член совета
(отличный от инициатора, уникальный) подтверждает; на 2 подписях — финализация с аудитом
обоих подписантов и payload_hash; @Cron ежедневно отменяет истёкшие. Контроллер — первое боевое
применение @CheckAbility + AuthorizationGuard: create/confirm CriticalAction поверх JWT-guard.

Гейтинг прав на guard, кворум/окно/различимость подписантов в сервисе. Тесты: 8 unit; coopback
рестартнул чисто, роуты смаплены, @Cron зарегистрирован. Разблокирует Story 4.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 06:33:13 +00:00
coopops d7ccbda283 [105-9][@ant] feat: запретить @AuthRoles в auth-v2 через ESLint-правило no-authroles-in-authv2 — чтобы новый контур авторизации не откатывался на роле-ориентированный подход вместо capability-ориентированного @CheckAbility
Story 6.5 Эпика 6 (Подход 4 «Управление и авторизация», CoopID компонент 42).

Новый overrides-блок в .eslintrc.json (scope auth-v2/**) через no-restricted-syntax с двумя
селекторами: импорт символа AuthRoles и применение декоратора @AuthRoles(...) дают error.
no-restricted-syntax выбран намеренно — no-restricted-imports в соседнем override занят
wharfkit-баном, разные ключи правил мерджатся аддитивно. legacy auth/ сохраняет @AuthRoles
до Phase-3 cleanup. Тест через ESLint Node API проверяет срабатывание в auth-v2 и допуск в auth/.

Дрейф (прав код): auth-v2 на REST-контроллерах без @AuthRoles, миграция вакуумна; первые
боевые @CheckAbility придут в админ-эндпоинтах 6.6/6.7 и critical-actions 6.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 06:19:46 +00:00
coopops 3d98728694 [105-9][@ant] feat: добавить единый AuthorizationGuard и PolicyService для четырёх слоёв CASL — чтобы вся авторизация auth-v2 проходила через один детерминированный контур без дублирующей логики
Story 6.4 Эпика 6 (Подход 4 «Управление и авторизация», CoopID компонент 42).

PolicyService.ensure — общий вычислитель: Layer 1+2 (свежая Ability с access_rules,
instance-level ownership через asSubject), Layer 3 (именованная политика после Ability).
AuthorizationGuard читает @CheckAbility, обходит по server-secret, извлекает user и ресурс
из GraphQL и HTTP REST контекстов и делегирует ensure — guard и императивный путь делят
одну реализацию. Отказ — 403 с обобщённым authorization_denied, точная причина (enum) только
в server-log (security: не раскрываем слой/правило). Guard точечный, не APP_GUARD.

Тесты: 6 policy.service + 5 guard unit; coopback рестартнул чисто без DI-ошибок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 06:11:12 +00:00
coopops 6e66c5c086 [105-9][@ant] feat: добавить CASL Layer 3 — реестр политик с DB-доступом и фикс схемы access_rules — чтобы сложная авторизация (голосование только в своём кооперативе) решалась через runtime DB-lookup вне статической матрицы
Story 6.3 Эпика 6 (Подход 4 «Управление и авторизация», CoopID компонент 42).

PolicyHandler-контракт (IPolicyHandler {name; evaluate(ctx)}), декораторы @PolicyHandler(name)
и @CheckAbility(action,subject,{policy?}), PolicyRegistry на DiscoveryService (fail-closed на
неизвестное имя, throw на дубль), политика-образец SameCoopVotingPolicy с DB-lookup членства
по participant-vault. Wiring через DiscoveryModule в AuthorizationModule.

Фикс субстрата Layer 2: forward-миграция V2.4.8 реконсилирует access_rules — init V2.4.0 создавал
плейсхолдер со старой схемой, V2.4.7 (6.2) был no-op → repo упал бы на column subject_type does
not exist. DROP пустого плейсхолдера + CREATE правильной схемы, идемпотентно, канон append-only.

Тесты: 4 registry + 5 policy unit + 1 integration с реальной coop_domain_db; coopback рестартнул
чисто, PolicyRegistry обнаружил same-coop-voting через DiscoveryService.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 06:03:17 +00:00
coopops d6f12ed7e9 [105-9][@ant] feat: CASL Layer 2 access_rules с merge поверх статики и Redis-инвалидацией — давать точечные права декларативно из БД без правки кода
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 05:33:03 +00:00
coopops 1a4876a0cd [105-9][@ant] feat: реальный @casl/ability-фундамент авторизации и Layer 1 static ability — переиспользовать зачаток marketplace2 и доделать капабилити-модель платформы
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 05:22:06 +00:00
coopops 5ed8a63839 [105-7][@ant] feat: политика версий схемы удостоверения через well-known + сверка в verifyOffline — отвергать офлайн устаревшие схемы claims, не принимая их как валидные
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 04:52:24 +00:00
coopops 7bddb72fa1 [C28-33][@ant] fix(desktop-capital): заголовок карточки расхода — описание первой позиции; поля description на уровне расхода нет ни в шасси, ни в inter-порте, vue-tsc валил CI после regen
Typecheck / desktop (pull_request) Successful in 13m53s
Typecheck / controller (pull_request) Successful in 14m1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 04:51:02 +00:00
coopops 7f35ef3710 [C28-29][@ant] merge: актуальный dev в ветку шасси — синхронизация перед объединённым PR
Typecheck / desktop (pull_request) Failing after 8m32s
Typecheck / controller (pull_request) Has been cancelled
# Conflicts:
#	components/contracts/cpp/lib/core/ledger2/wallets.hpp
#	components/controller/schema.gql
#	components/controller/zeus/const.ts
#	components/controller/zeus/index.ts
#	components/cooptypes/src/ledger2/wallets.generated.ts
#	components/sdk/src/zeus/const.ts
#	components/sdk/src/zeus/index.ts
2026-06-11 04:30:51 +00:00
coopops bfa5c7e00b [105-7][@ant] feat: SDK-гейт полного экспорта CoopID и запрет anonymous-формы в типах — не дать выпустить PII-полный сертификат под видом proof_of_membership
В MVP удостоверение только полное (с PII): exportToQR заменён на exportFullQR
с двумя барьерами — разблокированный vault и явный high-stakes consent. Метод
exportProofQR намеренно не объявлен, его вызов даёт ошибку компиляции; anonymous
proof_of_membership без PII (exp<=24ч) отложен в Growth. Добавлен клиентский
код ошибки ConsentRequired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 04:21:33 +00:00
coopops fe915bd1d5 [C28-33][@ant] merge: интеграция Капитал → шасси расходов (PR #67) в зонтичную ветку шасси — объединение двух PR в один 2026-06-11 04:20:20 +00:00
coopops fad6aadf20 [105-7][@ant] feat: retention-claims в участниковом сертификате CoopID и публичная политика — закрепить 152-ФЗ-обязательство RP удалить данные пайщика
В participant_certificate добавлены claims data_retention_contract=erase_on_exclusion
и retention_deadline_ts=iat+30д; значения вынесены в общий retention-policy.ts
и публикуются через GET /.well-known/coopid-claims-policy.json со ссылкой на договор
присоединения. SDK-декодер зеркалит оба claim. Enforcement (отзыв OAuth-клиента
при нарушении) — admin-действие Эпика 6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 04:14:33 +00:00
coopops 662ee9e64f [105-7][@ant] feat: короткий TTL участникового сертификата CoopID с silent-renewal — ограничить окно атаки при компрометации ключа в офлайн-MVP
Срок жизни participant_certificate вынесен в настройку кооператива
coop_domain_db.coop_settings.cert_ttl_seconds (миграция V2.4.6, default 3600=1ч),
зажат в [60с,24ч] на чтении и записи; cert.exp=iat+ttl. SDK получил
scheduleCertificateRenewal/computeRenewalDelayMs для бесшовного перезапроса
за 5 мин до exp через refresh_token. Эндпоинт смены TTL председателем — после CASL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:52:12 +00:00
coopops 7c703abe24 [105-7][@ant] feat: degraded-вход CoopID при недоступном COOPOS через chain_manifests_cache — не блокировать пайщика сбоем узла, впуская по свежему снимку ключей
Контроллерный chain_manifests_cache (порт + Redis-стор, наполнение активными
ключами на каждом живом входе). При RpcUnavailable verify-timestamp фолбэкается
на кэш: восстановленный ключ сверяется со снимком через blockchainPort.hasActiveKey
(псевдо-аккаунт, без wharfkit в application), совпал → degraded-вход (флаг +
enum DegradedAuthReason + audit coopid.auth.degraded), иначе fail-closed
CooposDegraded. Read-only scope токена вынесен в Epic 5 (нет scope в платформенных
токенах). 18 unit-тестов (4 новых).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:21:21 +00:00
coopops a3fcc4b063 [105-7][@ant] feat: офлайн-верификация удостоверения verifyOffline() в SDK — дать держателю чужого CoopID проверить подлинность без сети до trust-anchor ano
verifyOffline(jws, options?): fail-closed офлайн-проверка structure/alg/exp,
укоренение coop_chain в trust-anchor ano, сверка звеньев с локальным
chain_manifests_cache, подпись издателя через wharfkit + low-S нормализация
(cert от jose/Node может быть high-S). trust-anchor реконсилирован в
Antelope-формат (TRUST_ANCHOR_ANO_CERT_PUBKEY, скелет↔реальность). Отзыв офлайн
вне MVP (Story 4.7/Growth). 11 SDK-тестов (вкл. high-S, no-network).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:52:31 +00:00
coopops f991ba21d8 [105-7][@ant] feat: структурный verification_types claim в удостоверении сквозь controller-SDK-desktop — дать RP тип, время и основание подтверждения вместо плоского списка строк
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:22:15 +00:00
coopops e2608c9e2a [105-7][@ant] feat: per-coop правила применения типов верификации с guard'ом — дать кооперативу задавать обязательный уровень доверия для действий и отклонять недостаточно верифицированных пайщиков на 403
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:53:56 +00:00
coopops bf48d7a8bd [105-7][@ant] feat: каталог типов верификации coop_baseline из реального членства — дать пайщику базовое подтверждение без отдельного процесса и привязать его к решению кооператива о приёме
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 00:51:56 +00:00
coopops 2c74db214b [105-6][@ant] feat: нарастающая блокировка escalating lockout с 24h-cooldown recovery — сделать перебор доступа непрактичным и эскалировать блок злоупотреблений восстановлением до суток 2026-06-10 22:53:41 +00:00
claude 75cd0976e2 fix(desktop): PropType<BaseButtonSize> в DeletePaymentMethodButton — разблокировка desktop-CI всех PR в dev (#128) 2026-06-10 22:30:58 +00:00
coopops 372d30e430 [105-6][@ant] feat: флаг «Это не я» с массовым отзывом сессий — дать пайщику мгновенно выкинуть злоумышленника из аккаунта
SecurityIncidentService.report/reportByToken переиспользует SessionsService.revokeAll
(3.7) + пишет audit coopid.security.suspicious_login_reported. Два входа: POST
coop/security/not-me (JWT, из настроек) и not-me/:token (one-click из письма, без auth —
своя сессия скомпрометирована, авторизация single-use 256-бит токеном NOT_ME_TOKEN_STORE,
TTL 7д, consume Lua GET-DEL). Письмо new-device (3.9) теперь несёт notMeUrl — закрыт
отложенный one-click endpoint. Force password change + ротация ключа отложены в 3.3
(AC относит rotation к 3.3; флаг без enforcement = мёртвое состояние). 15 unit-тестов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:19:49 +00:00
coopops e6522e65c6 [105-6][@ant] feat: просмотр и отзыв активных сессий CoopID — дать пайщику выкинуть доступ с потерянного устройства
Сессия = persistent refresh-токен платформенного стора (им финализирует вход
verify-timestamp); GET/DELETE coop/sessions под JWT-guard; отзыв = удаление строки
токена (реально инвалидирует refresh). Метаданные device/IP — Redis side-store за
SESSION_METADATA_PORT (ключ sha256(refresh), TTL=refresh, токен не хранится), запись
best-effort на входе. Дрейф AC↔код: authentik oauth_tokens/oauth-revoke → наш
refresh-токен, история не заблокирована на authentik admin-токене. 27 unit-тестов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:53:35 +00:00
coopops 51c8f2abf1 [105-6][@ant] feat: уведомления о критичных событиях безопасности — дать пайщику заметить чужие изменения 2FA и способа восстановления 2026-06-10 21:18:27 +00:00
coopops 364215bcf1 [105-6][@ant] feat: уведомление о входе с нового устройства — дать пайщику шанс быстро отреагировать на угон аккаунта 2026-06-10 20:53:09 +00:00
coopops 0cef265a2c [105-6][@ant] feat: device tracking при входе — дать пайщику возможность обнаружить незнакомый вход в аккаунт
На финализации входа (этап 2, verify-timestamp) фиксируем устройство: audit-событие
coopid.login.successful (subject_id, ip, user_agent, accept_language, признак нового
устройства) + обновление Redis-набора известных устройств пайщика. Fingerprint =
sha256(user_agent + Accept-Language) — server-side; признак isNewDevice — основа
уведомления о новом устройстве (Story 3.9). Tracking best-effort: сбой не валит вход.
Отступления: screen-resolution (нет в контракте входа) и geo-IP (нет провайдера) отложены.
17 unit-тестов зелёные.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:21:24 +00:00
coopops 37127cc8ec [105-6][@ant] feat: переключение recovery-стратегии пайщика — дать контроль над каналом восстановления при компрометации email
Настройка recovery_strategy (email_magic_link по умолчанию | offline_code | council)
на пайщика гейтит входные каналы восстановления: requestByEmail (3.1) тихо no-op'ит,
requestByOfflineCode (3.4) бросает InvalidOfflineCode, если их канал не выбран; council
отключает оба (восстановление только через approval-flow 6.9). Смена — под JWT-guard со
step-up TOTP (вместо пароля: в authentik нет password-verify, паттерн 2FA-disable) + audit.
GET/POST /coop/recovery/strategy; таблица recovery_strategy (V2.4.4); 19 unit-тестов зелёные.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 19:50:20 +00:00
coopops 7ccb2e844f [105-6][@ant] feat: альтернативный recovery по offline-коду — чтобы пайщик без доступа к email мог начать восстановление печатным single-use кодом с переходом на TOTP-подтверждение 2026-06-10 19:27:05 +00:00
coopops d50b619f75 [C28-32][@ant] fix(desktop-expenses): String(uploaded_at) для formatDate — zeus-скаляр Date типизирован как {}, vue-tsc валил CI
Typecheck / desktop (pull_request) Successful in 13m38s
Typecheck / controller (pull_request) Successful in 13m13s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:24:32 +00:00
coopops 714d618320 [C28-32][@ant] fix(desktop-expenses): String(uploaded_at) для formatDate — zeus-скаляр Date типизирован как {}, vue-tsc валил CI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:24:26 +00:00
coopops dd0097b56c [C28-31][@ant] fix(expenses-spec): спека trigger-сервиса отстала от конструктора с EventEmitter2 — TS2554 валил CI controller; тест проверяет эмит EXPENSE_CAPITALIZATION_REQUIRED вместо снесённого TODO-лога
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Successful in 13m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:12:03 +00:00
coopops 773ecd89c2 [C28-31][@ant] fix(expenses-spec): спека trigger-сервиса отстала от конструктора с EventEmitter2 — TS2554 валил CI controller; тест проверяет эмит EXPENSE_CAPITALIZATION_REQUIRED вместо снесённого TODO-лога
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:11:58 +00:00
coopops 1effd2f898 [105-6][@ant] feat: двухканальное подтверждение recovery (magic-link + TOTP) — чтобы смена ключа требовала второй независимый фактор и пароль только на финальном шаге без хранения 2026-06-10 19:10:39 +00:00
coopops aeadf60239 [C28-32][@ant] fix(desktop-expenses): SuccessAlert принимает одну строку + Number(size_bytes) — vue-tsc валил CI desktop
Typecheck / desktop (pull_request) Failing after 8m55s
Typecheck / controller (pull_request) Failing after 8m4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:50:06 +00:00
coopops e678d62e82 [C28-33][@ant] fix(desktop-capital): SuccessAlert принимает одну строку — те же сигнатурные ошибки vue-tsc в диалогах программных расходов
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:49:52 +00:00
coopops 5cb8ef3175 [C28-32][@ant] fix(desktop-expenses): SuccessAlert принимает одну строку + Number(size_bytes) — vue-tsc валил CI desktop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:49:48 +00:00
coopops 4f9df9012a [C28-33][@ant] fix(desktop-capital): механика следует из способа оплаты СЗ + явный резолв получателя по типу — смешение ADVANCE/DIRECT в одном СЗ режется контрактом, а recipient уходил пустой строкой из-за ?? при заполненном recipient_name
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:42:46 +00:00
coopops 73fc79fa88 [C28-33][@ant] fix(boot): деплой контракта expense — аккаунт + code permissions; без него шасси не поднимается на свежем стенде
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:42:45 +00:00
coopops e073a6d9bc [C28-33][@ant] fix(expense+capital): авторизация inline-связки и state machine шасси — e2e-цепочка не проходила ни по одному сценарию
- expense::createexp принимает контракты-инициаторы по contracts_whitelist
  (capital@eosio.code не имеет coopname@active — inline падал по authority);
  capital::createpgexp шлёт inline от _capital@active.
- capital::onpgexpdone: require_auth(_expense) вместо _capital — callback
  шасси шёл с authority expense@active и всегда падал.
- payexp: cap actual<=plan + DIRECT-item сразу REPORTED с пересчётом статуса
  proposal — DIRECT-only СЗ навсегда зависал в PARTIALLY_PAID.
- returnexp/overspendexp: settlement-семантика по PRD — статус item остаётся
  PAID, отчёт закрывает item штатным reportexp (раньше RETURNED/OVERSPENT
  были терминальными тупиками: перерасход блокировал closeexp навсегда).
- declexp: только CREATED/AUTHORIZED — decline после оплат разъезжался
  с учётом пула в capital (возвращал весь резерв при ушедших деньгах).
- onpgexpdone CLOSED: перерасход сверх резерва списывается из
  program_expense_pool (spend_program_expense_pool).
- names.hpp: redefinition CREATE_PROGRAM_EXPENSE + несуществующий
  Names::Capital::Callbacks — контракты не компилировались вовсе.
- verify_document_or_fail(statement, {creator}) в createpgexp; снос
  мёртвых set_program_approved/set_program_authorized.

Оба контракта собраны: expense.wasm + capital.wasm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:42:28 +00:00
coopops 4ed652ef3e [105-6][@ant] feat: второй фактор TOTP (Google Authenticator) для входа и recovery — независимый от почты канал защиты пайщика
Собственный TOTP-движок (RFC 6238, node:crypto, без npm-зависимости, проверен
тест-векторами RFC), таблица two_factor в coop_domain_db (секрет зашифрован
server-key — не ключ пайщика, инвариант vault цел), enroll/activate/disable
под JWT-guard, узкий TWO_FACTOR_VERIFIER для recovery (3.2) и 2FA-входа.
Решение владельца: TOTP — базовый 2-й канал MVP вместо magic-link-toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:36:15 +00:00
coopops ec5b5ee403 fix(desktop): SSR-гидратация ломала ВСЕ маршруты — пустые страницы до F5
Найдена реальная причина «белого экрана» на первом SSR-заходе (доказано
DOM-диагностикой + __INITIAL_STATE__ прода: 87 сериализованных "component"
в desktops.workspaces[].routes):

SSR-сервер кладёт RouteRecordRaw вместе с component в Pinia-стор; Quasar
сериализует стейт в __INITIAL_STATE__; Vue-компонент не переживает JSON
(render-функция выпадает). Клиент гидратируется мёртвыми маршрутами,
registerWorkspaceMenus регистрирует их в router (routes=108 сразу),
initExtensions живые не добавляет («маршрут уже есть») → каждая страница
рендерится пустой при работающем layout/меню. F5 «лечит», потому что SW
отдаёт SPA-shell без гидратации (Config.js-путь, routes=13 → живые 108).
Локально не воспроизводилось: dev-режим SPA, без SSR.

Фикс: на клиенте перед loadDesktop зачищаем гидратированные routes из
workspaces — живые добавит useInitExtensionsProcess, ровно как при
SPA-заходе. Диагностика [BOOTRACE] остаётся до подтверждения на проде.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:34:29 +00:00
coopops 787b2ca786 [C28-33][@ant] merge: база feat/expense-chassis-mvp (zeus fix) в ветку интеграции 2026-06-10 18:22:51 +00:00
coopops 4eb72309d4 [C28-31][@ant] fix(sdk): Zeus regen с ExpenseProposal*GenerateDocumentInput — sdk tsc падал на ручных обёртках без типов
Typecheck / desktop (pull_request) Failing after 8m19s
Typecheck / controller (pull_request) Failing after 8m14s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:22:27 +00:00
coopops edfde6fa1e [105-6][@ant] feat: magic-link восстановление доступа пайщика через email — первый шаг recovery-контура CoopID
Эндпоинт POST /coop/recovery/request: одноразовый токен в Redis (5 мин),
письмо через готовый Центр уведомлений (workflow reset-key), константный
202 (анти-enumeration), rate-limit 3/час по email и IP с отдельным кодом
TooManyRecoveryAttempts. Источник пайщика — user-домен (таблицы participants
из AC в brownfield нет). Открывает Подход 2 (Эпик 3, восстановление доступа).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:02:47 +00:00
coopops a288e8888b [105-12][@ant] docs: runbook восстановления кооператива из S3-бэкапа со smoke-тестом
Typecheck / desktop (pull_request) Failing after 8m17s
Typecheck / controller (pull_request) Successful in 13m3s
Story 9.9. docs/operations/disaster-recovery.md — пошаговое восстановление
после потери сервера из бэкапа Story 9.8 за RTO до 2 часов: новый сервер с
Docker, скачать архив из S3, распаковать физический basebackup в PGDATA нового
PG-тома (не pg_restore — бэкап физический), docker compose up, и сквозной
smoke-test контура CoopID (вход пайщика → выпуск participant_certificate →
проверка цепочки доверия coop_chain). Документированы RTO и шесть частых
граблей, главная — пароли ролей лежат в самом бэкапе, init-скрипт на непустом
томе не выполняется, нужен ALTER ROLE под текущие секреты. Завершает Подход 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:15:52 +00:00
coopops f4701d07c7 [105-12][@ant] feat: ежедневный pg_basebackup кооп-БД в S3 со связкой retention и аудитом
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Story 9.8. Скрипт infra/coopid/scripts/coopid-backup.sh делает pg_basebackup
всего PG-кластера (authentik_db + coop_domain_db одним gzip-tar) и заливает в
s3://{bucket}/{coopname}/{YYYY-MM-DD}.tar.gz с SSE-S3, чтобы восстановить
кооператив за любой день. Retention 30 дней — через S3 lifecycle rule (режим
--setup), не ручным удалением в cron. Каждый прогон пишет audit_events
coopid.backup.created (s3_key, size_bytes, duration_seconds) прямым INSERT —
бэкап отрабатывает даже при лежащем coopback. Режимы --setup и --dry-run.
README: раздел Бэкапы с env, crontab 03:00 UTC и требованиями к PG. Cron
ставит прод-плейбук. shellcheck зелёный; S3-путь и lifecycle 30d проверены
против MinIO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:09:31 +00:00
coopops d31aa50768 [105-12][@ant] feat: двухключевой rate-limiting контура входа CoopID — защита от брутфорса по аккаунту и по IP
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Story 9.1. RedisThrottlerStorage (атомарный Lua INCR/PEXPIRE/block) в
infrastructure (ioredis запрещён в application/domain — порт-токен в domain).
AuthRateLimitGuard проверяет два независимых счётчика per-IP (50/15мин) и
per-account (5/15мин), превышение любого → 429 too_many_attempts в OAuth2-формате
через контурный AuthV2ExceptionFilter. Навешано на session/bind, verify/timestamp
(ip), vault-retrieve (account по subject_id + ip — цель harvest-энумерации).
Изолировано от глобального ThrottlerModule (не задевает прочие роуты). Пресет
magic-link (3/час) готов под Story 3.1; escalating lockout — Story 3.12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:57:26 +00:00
coopops 9ece08eed9 [105-5][@ant] feat: SDK verifyDocumentOffline — backward-валидность подписей через окна активности ключей после ротации
Typecheck / desktop (pull_request) Failing after 8m17s
Typecheck / controller (pull_request) Successful in 13m7s
2026-06-10 16:18:13 +00:00
coopops 0a6ede48ef [105-5][@ant] feat: SDK signDocument — локальная подпись документов в compact JWS ES256K без обращения к серверу
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-10 16:08:54 +00:00
coopops 2f15111f99 [105-4][@ant] feat: единый AuthV2ExceptionFilter и SDK-каталог человеческих сообщений — понятные причины сбоя входа и доставка OAuth2-контракта по HTTP
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 15:58:21 +00:00
coopops c6ca8a2fa5 [105-4][@ant] feat: RP-initiated logout — отзыв refresh_token и затирание keystore при выходе пайщика
Typecheck / desktop (pull_request) Failing after 8m21s
Typecheck / controller (pull_request) Successful in 13m8s
Эндпоинт POST /coop/logout (контур auth-v2): отзывает refresh/access токены
через TokenApplicationService, гасит session_binding cookie и пишет audit_events.
Без guard и идемпотентен — выйти можно и с истёкшим access-токеном. SDK logout()
дёргает сервер best-effort и в любом случае затирает локальный keystore и PIN-ключ
(finally), чтобы расшифрованный ключ не остался в памяти браузера при сбое сети.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:22:21 +00:00
coopops 3ed1c5ee95 [105-4][@ant] feat: карточка удостоверения пайщика в ЛК — пайщик видит серийник, срок, статус, цепочку доверия и подтверждения своего CoopID
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
ProfilePage (пункт меню «Удостоверение») расширена карточкой: серийный номер (jti, copyable mono), срок действия, статус-чип (Активно/Истекает/Истекло по exp), визуальная цепочка подписей АНО→Восход→Восток→Вы, список verification_types человеческим языком и кнопка «Скачать как QR» (Vision-stub, мягкое уведомление). Данные — GET /coop/certificate через существующий sendGET (Bearer), payload декодируется локально для отображения (подпись — offline-verify Story 4.4). Best-effort: сбой/отсутствие удостоверения показывает EmptyState, профиль работает. Только канон-компоненты и токены, eslint чист, HMR без рестарта контейнера.
2026-06-10 15:07:55 +00:00
coopops d63ace86e4 [105-4][@ant] feat: SDK-чтение participant_certificate и серийник jti — фундамент личного кабинета удостоверения, чтобы пайщик видел claims без серверного раунда
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
CertificateService добавляет серийный номер удостоверения (jti=UUID), который требует страница ЛК (Story 1.9). SDK @coopenomics/auth: модуль certificate/ с decodeParticipantCertificate (claims без проверки подписи — для отображения), certificateStatus (active/expiring/expired по exp), VERIFICATION_TYPE_LABELS/verificationTypeLabel (человекочитаемые типы верификации, forward-совместимо); getParticipantCertificate(apiUrl, accessToken) тянет GET /coop/certificate с Bearer по образцу fetchVaultBlob. 13 SDK + 6 controller тестов зелёные, dist пересобран, tsc чист.
2026-06-10 15:00:22 +00:00
coopops e67001a82a [105-4][@ant] feat: выпуск participant_certificate — compact JWS ES256K с цепью доверия coop_chain даёт пайщику самопроверяемое удостоверение для внешних сервисов
Typecheck / desktop (pull_request) Failing after 8m20s
Typecheck / controller (pull_request) Has been cancelled
CertificateService подписывает claims (iss/sub=UUID/iat/exp/coopname/coop_chain[ano,voskhod,vostok]/verification_types/identification/claim_schema_version) ключом permission cert аккаунта vostok через jose+Node KeyObject из PEM (Docker Secret coop_cert_key); coop_chain читается из COOPOS через новый BlockchainPort.getCertPublicKey (single-key, нормализация PUB_K1), кэшируется; size-guard 5КБ под Vision/MIFARE. Эндпоинт GET /coop/certificate под HttpJwtAuthGuard; сертификат также встроен в ответ /coop/verify/timestamp best-effort (завершает контракт 1.7, не ломает логин при сбое). config: COOP_CERT_KEY[_FILE]+геттер. 16 unit зелёных, coopback поднялся чисто. verifyOffline/backward-валидность -> 4.4/2.5.
2026-06-10 14:44:12 +00:00
coopops c4febaa749 [105-4][@ant] feat: эндпоинт /coop/verify/timestamp — второй этап входа CoopID доказывает владение ключом, проверяя подпись метки против COOPOS и single-use jti, защищая от угона пароля
Typecheck / desktop (pull_request) Failing after 8m26s
Typecheck / controller (pull_request) Has been cancelled
Пайплайн VerifyTimestampService: верификация session_binding_token (HS256), атомарный GETDEL jti до криптопроверки (anti-replay), окно свежести 60s против head_block_time, recover pubkey по каноническому сообщению (зеркало SDK signTimestamp) и сверка с активным ключом аккаунта; успех -> выпуск access/refresh через TokenApplicationService, audit success/failure без секретов. Crypto recover вынесена за BlockchainPort.recoverPublicKey ради гексагонального инварианта auth-v2 (запрет wharfkit в application). id_token/certificate -> Story 1.8.
2026-06-10 14:22:32 +00:00
coopops f573d0e5a3 [105-5][@ant] feat: signTimestamp в SDK для второго этапа auth — локальная COOPOS-native recoverable подпись канонической метки {ts,jti,sub} ключом из keystore, без сети, с экспортом канонизатора чтобы verify-эндпоинт собрал те же байты для восстановления pubkey
Typecheck / desktop (pull_request) Failing after 8m56s
Typecheck / controller (pull_request) Successful in 13m46s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:16:18 +00:00
coopops fafb991561 [105-5][@ant] feat: клиентская расшифровка vault и локальный keystore в SDK — unlockWallet тянет blob через новый GET, расшифровывает паролём на клиенте и кладёт ключ только в RAM-keystore, getWallet отдаёт несериализуемую обёртку без приватного ключа, опциональный PIN-слой для быстрой разблокировки на устройстве
Typecheck / desktop (pull_request) Failing after 8m55s
Typecheck / controller (pull_request) Successful in 13m44s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:26:12 +00:00
coopops 49ecba1149 [105-4][@ant] docs: предупредить про обязательный coopid-gen-secrets перед docker compose up — иначе валидация падает на отсутствующих file-секретах и не поднимается весь стек на любом чекауте
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:11:14 +00:00
coopops 93fda756aa Revert "feat(desktop): сайт кооператива в scope_extensions динамического PWA-манифеста"
This reverts commit adea7038d3.
2026-06-10 12:09:51 +00:00
coopops adea7038d3 feat(desktop): сайт кооператива в scope_extensions динамического PWA-манифеста
Переходы из установленного PWA на сайт кооператива (vars.website из
публичного getSystemInfo, кэш 5 мин) остаются в окне приложения вместо
выброса в браузер. Заодно явные id и scope для стабильной identity.
Работает в Chrome/Edge 138+ на десктопе; сайт должен отдать
/.well-known/web-app-origin-association с {"https://<домен-лк>/": {"scope": "/"}}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:57:15 +00:00
coopops aa482316ee [105-4][@ant] refactor: свернуть CoopID-overlay'и в единый docker-compose и перенести authentik_db+coop_domain_db в существующий postgres — чтобы весь стек поднимался одной командой docker compose up -d, как того требует прод-деплой через плейбук, без отдельного контейнера postgres
Typecheck / desktop (pull_request) Failing after 9m11s
Typecheck / controller (pull_request) Successful in 14m5s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:36:49 +00:00
coopops 33548c2200 [105-5][@ant] feat: vault-шифрование участника — клиентский Argon2id+AES-256-GCM в SDK (AAD субъекта) + POST /coop/vault с type-driven запретом серверной расшифровки ключа, гарантируя что сервер хранит только зашифрованный блоб
Typecheck / desktop (pull_request) Failing after 8m54s
Typecheck / controller (pull_request) Successful in 13m43s
2026-06-10 08:13:28 +00:00
coopops 7b6fdd823e [105-4][@ant] feat: session_binding_token-мост (вариант B) — controller выпускает HS256-токен после authentik-password через защищённый bind-эндпоинт с jti в Redis, связывая два этапа входа и отсекая replay перед timestamp-проверкой
Typecheck / desktop (pull_request) Failing after 8m44s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 07:55:36 +00:00
coopops 0065314b95 [105-4][@ant] feat: парольная политика CoopID (zxcvbn≥3, ≥12 символов, RU-сообщение) blueprint'ом authentik + аудит WeakPasswordRejected в coop_domain_db через защищённый webhook — усиливаем вход и фиксируем отказы по слабым паролям в неизменяемом журнале
Typecheck / desktop (pull_request) Failing after 19m48s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 07:32:41 +00:00
coopops f8e835b4a8 [105-4][@ant] feat: схемы coop_domain_db миграцией V2.4.0 (vaults, append-only audit_events с партициями, chain cache, mapping, access_rules) — фундамент хранения для vault-шифрования, аудита и CASL ещё до их сервисов
Typecheck / desktop (pull_request) Failing after 9m14s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 07:10:52 +00:00
coopops 4ac9e17296 [105-4][@ant] feat: COOPOS permission cert (ES256K) на ano/voskhod/vostok миграцией 052 + BLOCKCHAIN_RPC_LIST и readCertPublicKey — закладываем цепочку доверия для подписи удостоверений пайщика ключом кооператива
Typecheck / desktop (pull_request) Failing after 8m48s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 06:59:56 +00:00
coopops 75f5a4bfd5 [105-4][@ant] feat: workspace-пакет @coopenomics/auth (скелет SDK) + ESLint-инвариант ports&adapters — фиксируем публичную поверхность auth flow для браузера/Node/desktop и запрещаем application/domain контроллера импортировать wharfkit/oidc-client-ts/ioredis напрямую
Typecheck / desktop (pull_request) Failing after 8m50s
Typecheck / controller (pull_request) Successful in 13m45s
2026-06-10 06:19:10 +00:00
coopops 86dede3d35 [105-4][@ant] feat: Docker Compose overlay-стек CoopID (authentik 2026.2 + postgres:16 + caddy + crowdsec) — инфраструктурный seed Эпика 1: per-coop OIDC Provider с двумя изолированными БД и секретами через Docker Secrets вместо открытых .env
Typecheck / desktop (pull_request) Failing after 8m58s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 06:02:23 +00:00
coopops 10cc7bdb9f fix(desktop): реестр платежей — кнопки действий не наезжают на статус
Глобальный канон .table{min-width:0!important} снимал локальный min-width
таблицы — колонки сжимались уже контента и кнопки «Подтвердить/Отклонить»
ложились поверх бейджа статуса. Возвращён min-width (860/980px) с
!important: при нехватке ширины таблица скроллится в .table-scroll, как
журнал уведомлений, а не схлопывает колонки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:26:46 +00:00
coopops 6c4b92805e fix(desktop): отступ между карточками пайщиков — margin-bottom на .participant-card
padding/margin на .q-table__grid-item virtual-scroll игнорирует, поэтому
зазор не появлялся. Отступ повешен на саму .participant-card (margin-bottom
внутри grid-item) — его virtual-scroll учитывает в измеряемой высоте.
Grid-item padding возвращён в 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:02:36 +00:00
coopops 7830713d7c chore(release): publish
Build bootstrap container / build (push) Successful in 46s
Release / publish-packages (push) Failing after 8m19s
Release / release (push) Successful in 24m57s
Release / trigger-coopenomics-docs (push) Successful in 1s
Release / trigger-mono-docs (push) Successful in 2s
2026-06-09 18:41:34 +00:00
ant 4b99780ab8 Merge pull request 'ci(release): FF-промоушн dev→testnet→main вместо merge -X theirs' (#119) from feat/ff-release-flow into dev
Reviewed-on: #119
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-09 18:41:01 +00:00
coopops 21d523d18b fix(desktop): карточка пайщика — отступ, наезд бейджа, иконки аккаунта/email
- Отступ между карточками: margin-bottom игнорился virtual-scroll, а
  padding:0 убрал дефолтный зазор. Зазор задан через padding grid-item
  (входит в измеряемую высоту элемента virtual-scroll).
- Наезд бейджа: длинный бейдж («Ожидает решения совета») переполнял свой
  контейнер и налезал на дату/удаление. Мета-строка переведена на flex-wrap
  — дата+удаление переносятся на след. строку, когда бейдж не помещается.
- Аккаунт: иконка badge с подсказкой «Имя аккаунта» в начале + кнопка
  копирования username. Email: иконка mail в начале. Текст с ellipsis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:36:37 +00:00
coopops eb025b6105 fix(desktop): реестр пайщиков — подложка, бейдж и отступы на мобиле
- ListOfParticipantsPage: на <768px (grid-режим карточек) убрана внешняя
  обрамлённая поверхность .participants-page__card — карточки уже сами в
  рамках, обёртка давала «подложку»/двойное обрамление. На десктопе рамка
  остаётся (там таблица).
- ParticipantCard: широкий бейдж статуса («Ожидает решения совета») сжимал
  имя/аккаунт/email до пары букв. Перестроено: идентификация на всю ширину
  в верхней строке, бейдж + дата + удаление — отдельной строкой снизу.
- ParticipantsTable: на мобиле карточки во всю ширину с понятным
  вертикальным отступом (убран дефолтный 4px-padding grid-item, задан gap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:30:49 +00:00
coopops 44e45dfb00 [@dark-sun] ci(release): FF-промоушн dev→testnet→main вместо merge -X theirs
Typecheck / desktop (pull_request) Failing after 8m19s
Typecheck / controller (pull_request) Successful in 12m30s
Релизный флоу переведён на линейную fast-forward модель — устраняет
регулярные конфликты на 20 package.json при релизе.

Корень проблемы: publish-alpha.sh/publish-prod.sh бампали версию НА КАЖДОЙ
ветке (alpha-N на testnet, чистую на main) через `git merge -X theirs` +
back-merge. Два независимых bump-коммита за цикл + merge'и плодили
расхождение веток → конфликты (особенно при гонке push/pull).

Новая модель:
- Версию бампает lerna ОДИН раз на dev (scripts/cut-release.sh).
- Тот же коммит едет вверх по FF: scripts/promote.sh testnet|main
  (server-side fast-forward push, рабочее дерево не трогается).
- testnet/main не несут своих коммитов → ветки не диверджатся → конфликты
  структурно невозможны.

release.yaml:
- Триггер: push в testnet/main с изменением lerna.json (вместо тега v*).
  Окружение определяет ВЕТКА (main→prod, testnet→staging), не суффикс -alpha.
- Версия читается из закоммиченного lerna.json (едет с коммитом по FF).
- Гейты npm-publish/доки: branch == main (вместо !contains '-alpha').
- Образы/webhook по-прежнему версия-тегированы → playbooks/приёмник деплоя
  не затрагиваются.

Удалены publish-alpha.sh/publish-prod.sh и мёртвые дубли тех же merge-X-theirs
скриптов (root sync:main, components/contracts production/testnet/docs-publish).
Документация — scripts/RELEASE.md + CLAUDE.md PR-flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:29:09 +00:00
coopops f64c9ae0f9 fix(desktop): контакты — заголовок организации внутрь карточки
Eyebrow + название кооператива висели отдельным блоком над карточкой и
выглядели оторванно. Перенесены в head карточки (отделены линией от сетки
реквизитов); размер h1→h2 под карточный контекст; field-значения
overflow-wrap anywhere→break-word (телефон не рвётся посреди цифр).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:22:15 +00:00
coopops e3f399c0e8 fix(desktop): минимальный остаток — через WalletCard, чтобы тоже бежал
Карточка «Минимальный неснижаемый остаток» рисовалась вручную сырыми
canon-классами .wallet в обход WalletCard, поэтому marquee на неё не
распространялся (бежали только программные кошельки). Переведена на
<WalletCard neutral> — DRY + бегущая строка заголовка/подписи бесплатно.
WalletCard: program стал опциональным, добавлен neutral-вариант подсветки
иконки (--p-canvas-2/--p-ink-2) вместо акцента программы.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:21:01 +00:00
coopops 8d5ff83c05 fix(desktop): мобайл-карточки журнала, бегущие строки кошелька, канон-удаление реквизитов
Build bootstrap container / build (push) Successful in 2m40s
- Журнал уведомлений: на телефоне вместо горизонтального скролла/посимвольного
  переноса — компактные карточки, поля по два в ряд (col-xs-6); таблица скрыта
  на ≤599px (скрытие через двойной селектор .nj-cards.nj-mobile).
- WalletCard: заголовок и подпись при переполнении становятся «бегущей строкой»
  (marquee) вместо обрезки «…» — JS-детект overflow + CSS-анимация, обе строки
  бегут одинаково (одна длительность, синхронные паузы). Уважает
  prefers-reduced-motion.
- Реквизиты (PaymentMethods): кнопка удаления переведена на канон — icon-only
  BaseButton (delete_outline, danger), без текста «удалить»; убраны лишние
  fallthrough-атрибуты flat/color.
- BaseCard head: align-items center → flex-start, чтобы угловое действие при
  2-строчном заголовке не проваливалось к центру.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:08:32 +00:00
coopops 1d56c8d9fd chore(release): publish
Build bootstrap container / build (push) Successful in 3m17s
Release / release (push) Successful in 25m58s
Release / publish-packages (push) Failing after 8m23s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 2s
2026-06-09 17:47:20 +00:00
coopops 3f8522507c chore(release): publish
Build bootstrap container / build (push) Successful in 3m12s
Release / release (push) Successful in 25m50s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-06-09 17:46:58 +00:00
coopops b9634c400d fix(desktop): перезагружать стол после входа в той же вкладке (белый экран)
Реальная причина белого экрана (предыдущий коммит чинил не то — гонка
чанков по логам не подтвердилась, router.onError не срабатывал):

Вход в той же вкладке (incognito-логин или выход→вход без F5) НЕ
переинициализирует приложение. LoginForm после login() звал только
selectDefaultWorkspace(true) + goToDefaultPage(), но НЕ loadDesktop().
Поэтому currentDesktop оставался АНОНИМНЫМ (загруженным до входа), целевой
стол (chairman/connect) резолвился, но рендерился пустым — данные/гранты
DesktopWorkspace были анонимные. Ручной reload делал init начисто как
авторизованный → стол подгружался → всё рисовалось.

Фикс: добавить `await desktops.loadDesktop()` в LoginForm после ожидания
loadComplete, перед навигацией — зеркало проверенного паттерна в
SignUp.vue / init-app / EnableButton (там стол перезагружают, вход забыли).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:42:47 +00:00
coopops 5b8d252d6d chore(release): publish
Release / release (push) Successful in 25m49s
Build bootstrap container / build (push) Successful in 40s
Release / publish-packages (push) Failing after 8m19s
Release / trigger-coopenomics-docs (push) Successful in 1s
Release / trigger-mono-docs (push) Successful in 2s
2026-06-09 16:55:44 +00:00
coopops 7bb4f18097 chore(release): publish
Build bootstrap container / build (push) Successful in 3m16s
Release / release (push) Failing after 16m17s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-06-09 16:55:28 +00:00
coopops cba0d41020 fix(desktop): устранить белый экран на первом холодном заходе (PWA boot race)
Гонка на первом заходе в проде: ленивый чанк маршрута падал на import()
из-за конкуренции за сеть с фоновым precache только что установленного SW,
а перехватить ошибку было некому → пустой router-view до ручной перезагрузки.

- FIX-1: router.onError ловит провал загрузки чанка и делает авто-reload на
  целевой путь (с защитой от reload-цикла через sessionStorage).
- FIX-2: регистрацию Service Worker откладываем до window 'load', чтобы
  precache-шторм не конкурировал с первой отрисовкой и догрузкой чанков.
- Диагностика: таймстемп-логи [BOOTRACE] по всей boot-цепочке (initApp,
  router beforeEach/afterEach/onError, App.onMounted/isLoaded, SW register,
  safety-timeout) для грепа порядка инициализации на проде.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:48:51 +00:00
coopops 7ca3c21079 chore(release): publish
Build bootstrap container / build (push) Successful in 3m6s
Release / release (push) Successful in 26m46s
Release / publish-packages (push) Failing after 8m13s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 2s
2026-06-09 15:37:07 +00:00
coopops 045204754b chore(release): publish
Build bootstrap container / build (push) Has been cancelled
Release / release (push) Has been cancelled
Release / publish-packages (push) Has been cancelled
Release / trigger-coopenomics-docs (push) Has been cancelled
Release / trigger-mono-docs (push) Has been cancelled
2026-06-09 15:36:50 +00:00
coopops b7c56bd948 fix(payment): добавить legacy-поле kpp в BankAccountDetails
Старые клиенты селектят kpp на BankAccountDetails → GraphQL-валидация
падала (Cannot query field "kpp"). Добавлено nullable-поле в DTO + во все
места Zeus-селектора SDK (иначе сборка десктопа падает на MakeAllFieldsRequired).
Помечено TODO «удалить после 1 августа».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:33:59 +00:00
coopops 3bfd5d219a fix(desktop): карточки платежей не лезут на десктоп + DataRow стек на мобиле
- Реестр платежей: мобильные карточки (.payments-cards.pmt-mobile) показывались
  и на десктопе поверх таблицы. Причина — .payments-cards{display:flex} имел ту
  же специфичность, что одиночный .pmt-mobile{display:none}, и перебивал его по
  порядку источника. Скрытие/показ переведены на двойной селектор
  .payments-cards.pmt-mobile (специфичнее) — на десктопе только таблица.
- Удостоверение/DataRow: горизонтальная пара label|value на телефоне (≤599px)
  стекается в одну колонку — длинный публичный ключ занимает всю ширину
  карточки вместо узких ~150px и больше не рвётся в столбик по буквам.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:27:01 +00:00
coopops 7c01011a51 [@ant] fix(push): обработчик push в генерируемом SW + видимые логи доставки
Корень «push отправлен, но не виден»: workboxMode GenerateSW игнорирует
sourceFiles.serviceWorker, поэтому push/notificationclick из
custom-service-worker.ts в прод не попадали — браузер push получал, но SW его
не показывал (бэкенд честно «sent to N subscription(s)»). Доказано: прод
/service-worker.js содержит 0 push-обработчиков.

- desktop: public/push-sw.js (push → showNotification, notificationclick →
  focus/openWindow) + importScripts('push-sw.js') в extendGenerateSWOptions
- controller: «Доставлено»/«Канал пропущен» debug→info + сводка
  «web-push → user: N подписок» — чтобы доставка была видна в логах без БД
2026-06-09 15:25:31 +00:00
coopops 31b6f5805a fix(desktop): убрать посимвольный перенос и наезд бейджей на мобиле
Корень: overflow-wrap:anywhere рвал имена/email по буквам в столбик, а
бейджи статуса воровали ширину у имени рядом.

- BaseBadge: white-space:nowrap + flex-shrink:0 — бейдж не сжимается/не
  переносится (фикс глобально для всех экранов).
- components.css мобильный .table: anywhere → break-word (перенос по словам).
- Реестр платежей (pay-card): имя обрезается «…», сумма flex-shrink:0,
  тип переносится по словам.
- Реестр пайщиков (ParticipantCard): имя/аккаунт/email обрезаются «…»;
  попутно FontAwesome-иконка аватара → Material person.
- Журнал уведомлений: восстановлен задуманный горизонтальный скролл
  таблицы на мобиле (перебит глобальный min-width:0!important), 7 колонок
  с кнопкой действия больше не схлопываются в буквы-в-столбик.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:16:32 +00:00
coopops 5cf6c20ff2 chore(release): publish
Release / publish-packages (push) Has been cancelled
Release / trigger-coopenomics-docs (push) Has been cancelled
Release / trigger-mono-docs (push) Has been cancelled
Release / release (push) Has been cancelled
Build bootstrap container / build (push) Has been cancelled
2026-06-09 14:28:38 +00:00
coopops ef3fffecda chore(release): publish
Build bootstrap container / build (push) Successful in 3m9s
Release / release (push) Successful in 24m4s
Release / trigger-coopenomics-docs (push) Has been cancelled
Release / trigger-mono-docs (push) Has been cancelled
Release / publish-packages (push) Has been cancelled
2026-06-09 14:28:02 +00:00
coopops fd9b14a98b chore(release): publish
Build bootstrap container / build (push) Successful in 3m26s
Release / release (push) Successful in 23m22s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-06-09 14:27:46 +00:00
coopops 4a39c976a1 fix(desktop): ужать зазор между «Выйти» и версией в LeftDrawerMenu
Нижний padding rail__signout 12→4px, version padding-top 4→0px,
шрифт версии 11→10px — версия идёт сразу под кнопкой, не висит крупной.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:11:01 +00:00
coopops 7df88f48b7 [@ant] fix(desktop/notifications): панель уведомлений во всю ширину на мобиле
Было: q-menu прижат справа фиксированной шириной 360px → правый край резался
(«Прочитать в…», тексты заявок), снизу горизонтальный скролл. На мобиле (≤600px)
раскрываем меню во всю ширину вьюпорта (left/right 8px), панель width:100%.
2026-06-09 13:42:21 +00:00
coopops 354e2d2be0 [@ant] fix(desktop/notifications): чистка HTML в теле, гейт push-кнопки по реальному SW, нерасползающаяся strip
- тело уведомления приходит с <br>/HTML (шарится с email/push-шаблонами), а
  in-app панель рендерит как текст с переносом \n — нормализую HTML→plain в toItem
- push-strip и кнопку «Включить на устройстве» показываем только когда SW реально
  активен (hasActiveServiceWorker), иначе клик умирал на «Service Worker не активен»;
  в dev/без-PWA strip не рисуем (не путать с «браузер не поддерживает»)
- strip не переносит текст/кнопку на узких экранах (nowrap + flex 0 0 auto)
2026-06-09 13:32:52 +00:00
coopops cb0d6365ba chore(release): publish
Release / release (push) Successful in 27m4s
Build bootstrap container / build (push) Successful in 39s
Release / publish-packages (push) Failing after 8m11s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 1s
2026-06-09 13:07:28 +00:00
coopops f608119278 Merge branch 'main' of ssh://git.coopenomics.world:222/C9S/mono
# Conflicts:
#	components/blago-cli/package.json
#	components/boot/package.json
#	components/cleos/package.json
#	components/contracts/package.json
#	components/controller/package-lock.json
#	components/controller/package.json
#	components/cooptypes/package.json
#	components/desktop/package.json
#	components/docs-harness/package-lock.json
#	components/docs-harness/package.json
#	components/docs/package.json
#	components/email-relay/package.json
#	components/factory/package.json
#	components/inter/package.json
#	components/migrator/package.json
#	components/notifications/package.json
#	components/parser/package-lock.json
#	components/parser/package.json
#	components/sdk/package.json
#	components/setup/package.json
#	lerna.json
2026-06-09 13:07:14 +00:00
coopops 3c3b6a76c4 chore(release): publish
Build bootstrap container / build (push) Successful in 3m9s
Release / release (push) Successful in 25m21s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-06-09 13:02:21 +00:00
coopops 8f3fdd49a1 Merge branch 'testnet' of ssh://git.coopenomics.world:222/C9S/mono into testnet
# Conflicts:
#	components/email-relay/package.json
2026-06-09 12:58:16 +00:00
coopops 9ffe184062 [@dark-sun] fix(desktop): реестр платежей — карточки на мобиле вместо ломаной таблицы
На телефоне (≤599px) таблица из 7–8 колонок схлопывалась в нечитаемые
буквы-в-столбик: глобальный канон components.css форсит
.table{min-width:0!important} + overflow-wrap:anywhere на ячейках, что
перебивало scoped min-width/горизонтальный скролл виджета. Канон запрещает
горизонтальный скролл таблиц.

Решение как в реестре документов: десктоп — канон-таблица, мобайл —
карточная раскладка (имя+статус, сумма+направление, тип·дата, действия,
раскрытие деталей). Переключение чисто на CSS @media (SSR-safe).
Заодно иконки направления fa-* → Material (канон запрещает FontAwesome).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:52:42 +00:00
coopops 9ae811dae3 [@ant] fix(desktop/push): device-aware подписка + кнопка переподписки в колокольчике
Корень бага «push приходит только на одно устройство»: autoSubscribe
гейтился store.isSubscribed (= у аккаунта есть подписка на ЛЮБОМ устройстве),
поэтому второе устройство никогда не подписывалось. Введён endpoint текущего
браузера + computed isThisDeviceSubscribed; autoSubscribe теперь идемпотентно
регистрирует каждое устройство. Добавлены resubscribe()/refreshDeviceState()
и строка статуса push с кнопкой «Включить/Переподписать» в виджете колокольчика.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:51:26 +00:00
coopops a77fa945a6 [C28-28][@ant] chore(email-relay): вынести релей из mono в C9S/email-relay
Релей переехал в отдельный репозиторий C9S/email-relay, собирается локально
плейбуком (playbooks/email-relay) — релиз mono его больше не пересобирает.
Прод-образ mono-base (prune --prod) не подходил для standalone-сервиса.

Удалено: components/email-relay, build_service в release.yaml, dev-сервис в
docker-compose. Контроллерный relay-режим (EMAIL_RELAY_URL/TOKEN в
email-channel.adapter + config) ОСТАЁТСЯ — он и активирует отправку через релей.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:47:59 +00:00
Alex Ant fd24efb63a chore(release): publish
Build bootstrap container / build (push) Successful in 3m19s
Release / release (push) Successful in 28m31s
Release / publish-packages (push) Has been cancelled
Release / trigger-coopenomics-docs (push) Has been cancelled
Release / trigger-mono-docs (push) Has been cancelled
2026-06-09 15:49:05 +05:00
Alex Ant 01fc0872ae chore(release): publish
Build bootstrap container / build (push) Has been cancelled
Release / publish-packages (push) Has been cancelled
Release / trigger-coopenomics-docs (push) Has been cancelled
Release / trigger-mono-docs (push) Has been cancelled
Release / release (push) Has been cancelled
2026-06-09 15:48:49 +05:00
Alex Ant 271de60831 Merge branch 'dev' of ssh://git.coopenomics.world:222/C9S/mono into dev 2026-06-09 15:48:32 +05:00
coopops be7e1585b0 [C28-28][@ant] fix(email-relay): ts-node+typescript в dependencies (переживают prune --prod)
Прод-образ mono-base делает `pnpm prune --prod` (Dockerfile:74) — devDeps
вырезаются. ts-node был в devDependencies → в проде `sh: ts-node: not found`,
контейнер в restart-loop, /health не отвечал. controller держит ts-node в
dependencies — повторяем паттерн. (Коммит из закрытого PR #115 не попал в dev,
вношу прямо.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:47:15 +00:00
Alex Ant addb527f3c chore(release): publish
Build bootstrap container / build (push) Successful in 3m16s
Release / release (push) Successful in 26m2s
Release / publish-packages (push) Failing after 8m18s
Release / trigger-coopenomics-docs (push) Successful in 3s
Release / trigger-mono-docs (push) Successful in 1s
2026-06-09 14:10:52 +05:00
Alex Ant 9e271a6ce9 chore(release): publish
Build bootstrap container / build (push) Has been cancelled
Release / release (push) Has been cancelled
Release / publish-packages (push) Has been cancelled
Release / trigger-coopenomics-docs (push) Has been cancelled
Release / trigger-mono-docs (push) Has been cancelled
2026-06-09 14:10:33 +05:00
Alex Ant f37605b343 migrations 2026-06-09 14:10:09 +05:00
ant f728563bdb Merge pull request '[C28-28] feat(email-relay): HTTP→SMTP релей для коопов с закрытым SMTP' (#115) from feature/C28-28-email-relay into dev
Reviewed-on: #115
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-09 07:54:56 +00:00
coopops 468c946610 [C28-28][@ant] fix(email-relay): убрать mailpit из compose, env_file как coopback
Typecheck / desktop (pull_request) Successful in 13m51s
Typecheck / controller (pull_request) Has been cancelled
mailpit был только локальным SMTP-перехватчиком для теста — в поставке не нужен.
email-relay сам ходит в реальный SMTP через nodemailer; конфиг (RELAY_TOKEN,
SMTP_*) выносим в components/email-relay/.env по образцу coopback, без отдельного
контейнера и inline-параметров.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:51:58 +00:00
coopops 7e2b26a02e [C28-28][@ant] feat(email-relay): HTTP→SMTP релей для коопов с закрытым SMTP
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Хостинги коопов режут исходящий SMTP → письма controller'а уходят в timeout
(раньше хабом был Novu, его убрали). Новый компонент @coopenomics/email-relay
принимает письма по HTTPS с Bearer-токеном и форвардит по SMTP. Ставится на
сервер с открытыми портами (плейбук в playbooks/email-relay).

- components/email-relay: express + nodemailer, POST /send + GET /health,
  Bearer-токен (constant-time), опц. IP-allowlist, конфиг из ENV
- controller: email-channel.adapter получил relay-режим — если задан
  EMAIL_RELAY_URL, письмо уходит POST'ом на релей; иначе прямой SMTP как
  раньше (opt-in, без регрессии)
- docker-compose: дев-сервисы email-relay + mailpit (SMTP-перехватчик для E2E)
- release.yaml: сборка образа dicoop/email-relay

E2E проверен локально: controller-формат письма → релей → SMTP → mailpit,
тело с <br> и заголовки целы; 401 на отсутствие/неверный токен, 400 на пустое тело.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:42:15 +00:00
coopops a76f875725 [C28-27][@ant] fix(desktop): in-app уведомления видны на тёмной теме
Build bootstrap container / build (push) Successful in 3m2s
В колоколе уведомлений на тёмной теме элементы сливались с фоном панели:
разделители на --p-line (0.06 white) почти невидимы, а непрочитанные
отличались лишь цветом текста + точкой — всё тонуло в --p-surface. На светлой
контраст тёмного текста на белом спасал.

Канон-токенами (обе темы, светлую не ухудшает): панели — рамка --p-line-1
для контура; разделители между уведомлениями — --p-line-1 (заметнее); у
непрочитанных подложка --p-primary-soft + левый акцент-бар --p-primary,
hover/focus — --p-primary-line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:22:39 +00:00
coopops 1d235e5caf chore(release): publish
Build bootstrap container / build (push) Successful in 3m5s
Release / release (push) Successful in 24m54s
Release / publish-packages (push) Failing after 8m23s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 2s
2026-06-09 07:18:39 +00:00
coopops 98b9c03977 chore(release): publish
Build bootstrap container / build (push) Successful in 3m5s
Release / release (push) Successful in 26m40s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-06-09 07:09:56 +00:00
ant c05daad8fd Merge pull request 'feat(pwa): надёжное оповещение об обновлении через version-watch' (#114) from feat/pwa-version-watch into dev
Reviewed-on: #114
2026-06-09 07:06:53 +00:00
ant c3ff628dac Merge pull request 'feat(desktop): статусы пайщиков бейджами + удаление непринятых аккаунтов из реестра' (#112) from fix/participants-registry-status-badges into dev
Build bootstrap container / build (push) Successful in 3m7s
Reviewed-on: #112
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-09 06:52:52 +00:00
coopops 3ba3a867ad feat(pwa): надёжное оповещение об обновлении через version-watch
Typecheck / desktop (pull_request) Successful in 15m5s
Typecheck / controller (pull_request) Successful in 13m12s
Заменяет ненадёжный триггер от lifecycle service worker'а (не срабатывал
на iOS standalone PWA, при первой установке и когда бинарь SW не менялся)
на сравнение версий: запечённая в бандл package-версия (process.env.APP_VERSION)
сверяется с self-report SSR-ноды (/version). При расхождении — существующий тост.

- quasar.config: APP_VERSION из package.json в build.env; middleware 'version'
- src-ssr/middlewares/version.ts: self-report /version (JSON, no-store)
- entities/AppVersion: стор useUpdateWatch (реестр компонентов, сейчас shell),
  опрос /version раз в 5 мин + при возврате на вкладку; SPA-safe (нет ноды -> no-op)
- init-app: старт version-watch рядом с systemMonitoring
- register-service-worker: убран SW-триггер; applyUpdate устойчив (waiting -> SKIP_WAITING, иначе reload)
- LeftDrawerMenu: версия приложения в футере рейла

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 06:46:04 +00:00
coopops 15813d91ef [@ant] fix(desktop): правка текста предупреждения при удалении registered-аккаунта
Typecheck / desktop (pull_request) Successful in 15m1s
Typecheck / controller (pull_request) Successful in 13m41s
«исчезнет» → «исчезает»; убрано предложение про отзыв заявки на повестке —
кнопка доступна только когда аккаунт уже в цепи (after оплаты), решение совета
тут неприменимо.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:44:31 +00:00
coopops 955142cf84 [@ant] feat: удаление любого непринятого аккаунта из реестра (гард только participant_account)
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Прежний allow-list статусов исключал Registered — но именно туда попадают
отклонённые советом и оплаченные брошенные регистрации (отказ/возврат
оставляют статус registered; failed/refunded users.status никем не
выставляются). То есть чистка не работала для своей же цели «убрать
незавершённые/отклонённые регистрации (тестеры)».

- backend deleteAccount: убран allow-list по статусу, остаётся единственный
  гард — НЕ принят в кооператив (нет participant_account).
- frontend isDeletable: !participant_account. Кнопка удаления теперь и у
  Registered (оплачен/на рассмотрении совета/отклонён/возврат).
- confirm-диалог: для blockchain-registered (Registered) предупреждение, что
  on-chain аккаунт неудаляем (имя в цепи занято навсегда) + про отзыв заявки
  на повестке, если ещё на рассмотрении совета.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:32:10 +00:00
coopops 7efbf90c9b [C28-27][@ant] fix(desktop): таблицы переносят содержимое, не скроллят по горизонтали
На мобиле (и в узком окне десктопа) canon-таблицы выходили за ширину: левая
колонка обрезалась, появлялся внутренний горизонтальный скролл — из-за
фиксированной min-width (780–1000px), которую виджеты ставят локально, а
скелетон inline-стилем.

Централизовал в каноне components.css: `.table { min-width: 0 !important }`
снимает форс-ширину на всех экранах (!important — иначе scoped/inline
перебивают канон), таблица всегда влезает (width:100%) и переносит содержимое
на следующую строку вместо скролла. На узких экранах (≤599px) дополнительно
table-layout:auto + перенос в ячейках + плотнее паддинги, чтобы многоколоночная
таблица читалась переносом, а не клиппингом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:29:07 +00:00
coopops 0e9542fff4 [@ant] feat(desktop): статусы пайщиков бейджами в реестре + enrich registration_payment
Typecheck / desktop (pull_request) Successful in 13m49s
Typecheck / controller (pull_request) Has been cancelled
- ParticipantsTable/ParticipantCard: галочка «Активен» → BaseBadge с
  человекочитаемым статусом; колонка «Активен» → «Статус».
- entities/Account/lib/accountStatus.ts: getAccountStatusBadge — сводит
  participant_account (accepted|blocked) + provider_account.status +
  отказ совета (Registered + возврат взноса) → «Отклонён советом».
- controller getAccounts: enrich registration_payment через this.getAccount,
  иначе на списке реестра отказ совета неотличим от «ожидает совет».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:14:46 +00:00
ant 8ffe4bea94 Merge pull request 'feat: Центр уведомлений DC v3 (замена Novu) [C28-22…27] — код-комплит, 6/6 эпиков' (#60) from feat/notification-center-dc-v3 into dev
Reviewed-on: #60
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-08 08:37:29 +00:00
coopops 70bd1323c7 chore(release): publish
Build bootstrap container / build (push) Successful in 3m8s
Release / release (push) Successful in 25m6s
Release / publish-packages (push) Failing after 8m25s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 1s
2026-06-08 07:03:58 +00:00
coopops 765af3cbef chore(release): publish
Build bootstrap container / build (push) Successful in 2m57s
Release / release (push) Successful in 25m7s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-06-08 07:03:41 +00:00
ant 9cdee035b9 Merge pull request 'fix(controller): приложения-оферты ЦПП к заявлению на повестке (wallet::signagree)' (#108) from fix/agenda-program-offer-links into dev
Reviewed-on: #108
2026-06-08 07:03:13 +00:00
ant 9553697f2b Merge pull request 'fix(desktop): чистить технические детали ассертов цепи в тостах ошибок' (#110) from fix/sanitize-blockchain-error-toast into dev
Reviewed-on: #110
2026-06-08 07:02:47 +00:00
ant 124782a8d9 Merge pull request 'fix(controller): не фиксировать возврат паевого взноса в БД при ошибке блокчейна' (#109) from fix/wallet-withdraw-no-phantom-payment into dev
Reviewed-on: #109
2026-06-08 07:02:39 +00:00
coopops f10b49ac8f [@ant] fix(desktop): чистить технические детали ассертов цепи в тостах ошибок
Typecheck / desktop (pull_request) Successful in 13m28s
Typecheck / controller (pull_request) Successful in 12m29s
Пользователю в тост летел сырой ассерт контракта, напр.
`walletop TRANSFER: недостаточно средств на кошельке w.wal.share`.
Ему не нужны ни scope-префикс операции, ни служебное имя кошелька — он
нажал конкретную кнопку и так понимает, о каком кошельке речь.

Добавлен универсальный санитайзер sanitizeBlockchainError, встроенный в
единую точку показа ошибок FailAlert:
- снимает ведущий технический scope-префикс (`walletop TRANSFER:`,
  `ledger2::apply:`, `o.mkt.supply:`) — распознаёт идентификатор с
  `.`/`::` либо слово+ALLCAPS-опкод, кириллицу не трогает;
- убирает служебные имена ledger2-кошельков (`w.wal.share` и т.п.);
- нормализует пробелы и ставит заглавную первую букву.

Результат для примера выше — «Недостаточно средств на кошельке».
Обычные (нетехнические) сообщения проходят без изменений.

Полный технический текст остаётся разработчикам: FailAlert дублирует
сырой message в console.error, бэкенд пишет его в логи контроллера.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:42:22 +00:00
coopops 75548036f4 chore(release): publish
Release / release (push) Successful in 24m41s
Build bootstrap container / build (push) Successful in 38s
Release / publish-packages (push) Failing after 8m22s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 1s
2026-06-08 06:27:19 +00:00
coopops ec0427c778 chore(release): publish
Build bootstrap container / build (push) Successful in 3m8s
Release / release (push) Failing after 14m44s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-06-08 06:27:02 +00:00
coopops 29417dd425 [@ant] fix(controller): не фиксировать возврат паевого взноса в БД при ошибке блокчейна
Typecheck / desktop (pull_request) Successful in 13m22s
Typecheck / controller (pull_request) Successful in 15m49s
Баг: при «Получить возврат» с суммой больше L3-баланса платёж писался в
gateway-БД (AWAITING_AUTHORIZATION) ДО on-chain транзакции. Контракт wallet
отклонял заявку (недостаточно L3-средств), а в разделе «Платежи» оставался
висеть фантомный исходящий платёж со статусом FAILED.

Фикс — порядок операций в WalletInteractor.createWithdraw:
1. prepareWithdraw — все валидации (символ, дубликат hash, платёжный метод)
   и сборка записи, БЕЗ записи в БД;
2. on-chain wallet::createwthd — при недостатке средств падает здесь,
   ни одной записи о платеже не создаётся;
3. persistWithdraw — фиксация платежа в БД только после успешной заявки.

GatewayInteractor.createWithdraw разбит на prepareWithdraw/persistWithdraw
(старый combined-метод сохранён как обёртка). Порт и оба адаптера обновлены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:00:02 +00:00
coopops ca1d1ec1a2 [@ant] fix(controller): приложения-оферты ЦПП к заявлению на повестке (wallet::signagree)
Typecheck / desktop (pull_request) Successful in 14m13s
Typecheck / controller (pull_request) Successful in 13m2s
Программные оферты (program_id > 0) с Эпика 2 уходят на цепь через
wallet::signagree (commit 21e94ce0ed), а не soviet::sndagreement —
soviet::newagreement по ним не создаётся. Агрегатор пакета документов
(document-package-v1) искал приложения к заявлению только по
soviet::newagreement, поэтому подписанная оферта молча выпадала из links
и не отображалась как приложение к заявлению на повестке совета (при этом
сам документ виден в реестре).

Резолв каждого linkHash вынесен в общий хелпер buildLinkedAggregate;
носитель подписи ищется сначала в soviet::newagreement, затем фолбэком в
wallet::signagree. Унифицированы три дублировавшихся цикла (statement /
decision / acts). Добавлен регрессионный unit-тест.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:49:07 +00:00
coopops c28bb81c25 [C28-27][@ant] fix: in-app уведомления — перенос строки вместо литерального <br>
In-app тело инбокса рендерится как текст ({{ n.description }}, без v-html —
payload несёт ФИО/заголовки, v-html = XSS-вектор), поэтому HTML-тег <br> из
шаблона показывался строкой «№C53E<br>От: …». Перевёл 2 in-app шаблона
(new-agenda-item, approval-request) с <br> на \n и добавил white-space:
pre-line на .notification-center__item-desc (line-clamp:2 сохранён).

Push не трогаю — его тела уже plain-text без тегов, отображается верно.
Email оставлен на HTML (<br>/<strong>) — рендерится почтовым клиентом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:28:04 +00:00
coopops 4df811d40f chore(desktop): пометить stripLegacyBankKpp на удаление после 01.09.2026
Build bootstrap container / build (push) Successful in 2m18s
Временный shim очистки persisted-localStorage от устаревшего «КПП банка».
К 01.09.2026 старые незавершённые черновики регистрации истекут — утилиту
и оба вызова (CreateUser/AddUser model) можно удалить.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:23:53 +00:00
coopops 54e27df7f8 chore(release): publish
Build bootstrap container / build (push) Successful in 3m27s
Release / release (push) Successful in 26m49s
Release / publish-packages (push) Failing after 28m24s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 1s
2026-06-08 05:13:30 +00:00
coopops b7da867198 chore(release): publish
Build bootstrap container / build (push) Successful in 3m19s
Release / publish-packages (push) Has been cancelled
Release / trigger-coopenomics-docs (push) Has been cancelled
Release / trigger-mono-docs (push) Has been cancelled
Release / release (push) Has been cancelled
2026-06-08 05:13:14 +00:00
coopops 6906d3fad0 fix(desktop): не отправлять устаревший «КПП банка» из persisted-localStorage
Build bootstrap container / build (push) Successful in 3m26s
Пайщики, начавшие регистрацию ИП/юрлица ДО удаления поля «КПП банка»
(commit 93b94326), имеют это значение в persisted-localStorage стора
регистратора (useRegistratorStore, persist:true). При построении мутации
RegisterAccount/AddParticipant org/entrepreneur объект уходил целиком,
включая bank_account.details.kpp → BankAccountDetailsInput его больше не
принимает → GRAPHQL_VALIDATION_FAILED, форму не отправить.

Защитный strip stripLegacyBankKpp() срезает kpp из bank_account.details
на входе в мутацию (createUser/signup и addUser/председатель). КПП самой
организации (details.kpp) не трогаем — легитимный реквизит юрлица.

Read-путь (старый фронт запрашивает details{kpp}) лечится перезагрузкой
страницы — селекторы SDK уже без kpp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:09:07 +00:00
coopops a4e5abe4fa chore(release): publish
Build bootstrap container / build (push) Successful in 3m36s
Release / release (push) Successful in 25m54s
Release / publish-packages (push) Failing after 8m23s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 2s
2026-06-07 15:29:25 +00:00
coopops ef393e9724 chore(release): publish
Build bootstrap container / build (push) Successful in 3m34s
Release / release (push) Successful in 25m2s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-06-07 15:28:58 +00:00
coopops f2e3165b54 Merge branch 'testnet' of ssh://git.coopenomics.world:222/C9S/mono into testnet
# Conflicts:
#	components/notifications/package.json
2026-06-07 15:27:59 +00:00
coopops 5b7e1015cd [C28-27][@ant] fix(desktop): 4 ошибки типов после де-Novu — тайпчек зелёный
Typecheck / desktop (pull_request) Successful in 13m26s
Typecheck / controller (pull_request) Successful in 13m11s
CI vue-tsc (PR #60) после фикса lockfile дошёл до тайпчека и поймал хвосты:
- src-ssr/middlewares/{injectEnv,generateConfig}.ts: NOVU_APP_ID/BACKEND_URL/
  SOCKET_URL слались в объект EnvVars, откуда поля удалены де-Novu → TS2353.
  Убрал мёртвые строки.
- NotificationJournalWidget.vue: getName() возвращает string|undefined
  (organization_data?.short_name) → .replace на possibly-undefined (TS2532).
  Обернул в (getName(account) ?? '').
- NotificationCenter/model/index.ts: n.createdAt — DateTime-скаляр SDK типа
  unknown, NotificationItem.date ждёт string|Date (TS2322). Каст as string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:58:24 +00:00
coopops 4ffc3b4616 [C28-27][@ant] fix(desktop): 4 ошибки типов после де-Novu — тайпчек зелёный
CI vue-tsc (PR #60) после фикса lockfile дошёл до тайпчека и поймал хвосты:
- src-ssr/middlewares/{injectEnv,generateConfig}.ts: NOVU_APP_ID/BACKEND_URL/
  SOCKET_URL слались в объект EnvVars, откуда поля удалены де-Novu → TS2353.
  Убрал мёртвые строки.
- NotificationJournalWidget.vue: getName() возвращает string|undefined
  (organization_data?.short_name) → .replace на possibly-undefined (TS2532).
  Обернул в (getName(account) ?? '').
- NotificationCenter/model/index.ts: n.createdAt — DateTime-скаляр SDK типа
  unknown, NotificationItem.date ждёт string|Date (TS2322). Каст as string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:58:13 +00:00
coopops e199eb4913 [C28-27][@ant] fix(lockfile): пересборка после удаления @novu/js — CI frozen-lockfile
Typecheck / desktop (pull_request) Failing after 8m22s
Typecheck / controller (pull_request) Has been cancelled
Удаление @novu/js из desktop/package.json (b500789dd6) не сопровождалось
пересборкой pnpm-lock.yaml → CI install --frozen-lockfile падал
ERR_PNPM_OUTDATED_LOCKFILE до шага typecheck. Пересобрал lockfile-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:45:05 +00:00
coopops e763adae48 Merge feat/notification-center-dc-v3 into dev — Центр уведомлений (C28-22…27)
DC v3: каталог @coopenomics/notifications, роутер outbox + worker доставки
(email/in-app/web-push), стол председателя (журнал + колокол), удаление Novu.

Чиню рассинхрон lockfile: feat-ветка убрала @novu/js из desktop/package.json,
но pnpm-lock.yaml не пересобрала → CI --frozen-lockfile падал
ERR_PNPM_OUTDATED_LOCKFILE. pnpm install --lockfile-only — @novu/js удалён,
lockfile согласован с package.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:40:52 +00:00
coopops 4f67231b3f [C28-27][@ant] fix: web-push без подписки — мягкий пропуск, не ошибка
Typecheck / desktop (pull_request) Failing after 4m49s
Typecheck / controller (pull_request) Failing after 4m47s
Получатель без активной push-подписки (не дал разрешения / нет PWA / 410)
давал {delivered:false,error} → worker ретраил 5× → терминальный FAILED:
ложные «Ошибка» в журнале и warn в логах для всех, у кого push не настроен
(в dev SW не регистрируется без ENABLE_PWA_DEV → у всех).

Ввёл исход skipped в ChannelDeliveryResult: канал неприменим к получателю.
Адаптер web-push возвращает skipped при 0 подписок / отсутствии username.
Worker гасит такую строку в canceled — без ретраев, без записи попытки в
журнал, без error-лога (debug); причину кладёт в lastError → тултип
«Отменено» на столе председателя. Гейт на слое отправки (состояние подписки
авторитетно в момент доставки), роутер outbox остаётся чистым.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:26:25 +00:00
coopops a2d5750449 [C28-27][@ant] feat: журнал — получатель ФИО + видимость ошибок доставки
Typecheck / desktop (pull_request) Failing after 4m49s
Typecheck / controller (pull_request) Failing after 4m46s
Получатель в журнале:
- две строки — ФИО (резолв username→account через getName на фронте, кэш
  на компонент) + AccountBadge с username и копированием. DTO журнала несёт
  только username/subscriber_id, поэтому ФИО тянем getAccount'ом. Нет ФИО
  (приватные данные недоступны) → одна строка с username.

Видимость ошибки доставки:
- worker раньше логировал только исключение тика; провалы отправки шли лишь
  в БД → при падающем канале в логах пусто и не видно, что worker жив.
  Добавлено: лог старта (onModuleInit), debug-heartbeat тика с числом строк,
  warn на ретрай и error на терминальный провал — с контекстом
  (workflow/канал/получатель/попытка) и текстом ошибки.
- в журнале на ячейке статуса «Ошибка» — q-tooltip с lastError (поле уже
  отдаётся DTO/селектором), курсор help.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:15:18 +00:00
coopops 423c1d0984 [C28-27][@ant] fix: убрать «Показать все» из колокола + бейдж не наезжает
Typecheck / desktop (pull_request) Failing after 4m48s
Typecheck / controller (pull_request) Has been cancelled
- Футер «Показать все» удалён из личного колокола: журнал — админский
  (стол председателя), пайщику туда не нужно; председатель открывает
  журнал через пункт меню. Колокол = свежие из инбокса + «Прочитать все».
- Бейдж счётчика вытолкнут за угол кнопки через transform translate(45%,-45%)
  — больше не «наезжает» на глиф колокола.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:08:43 +00:00
coopops f997c77315 [C28-27][@ant] fix: журнал в меню председателя + колокол не прыгает (скелетон/фикс-высота)
Typecheck / desktop (pull_request) Failing after 4m49s
Typecheck / controller (pull_request) Failing after 4m46s
- Журнал уведомлений зарегистрирован в реальном меню стола председателя
  (extensions/chairman/install routes[0].children) — был воткнут в мёртвый
  src/desktops/Chairman/model (не в реестре расширений), потому страница
  была недостижима. Дубль из мёртвого манифеста убран.
- Колокол: спиннер заменён канон-скелетоном (.skel), тело фиксированной
  высоты (~4 уведомления, max 60vh) — панель больше не «прыгает» при
  скелетон → меньше элементов → пусто.
- Футер: вместо неясной пагинации «Все загружены/Показать ещё» —
  навигация «Показать все →» в журнал (только для председателя; q-menu
  закрывается при переходе).
- Бейдж счётчика смещён в угол колокола (-4/-4) — убран «наезд» на иконку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:53:12 +00:00
coopops b653a3cbd5 Merge remote-tracking branch 'origin/dev' into feat/notification-center-dc-v3
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
# Conflicts:
#	components/notifications/package.json
2026-06-07 12:24:50 +00:00
Alex Ant aa9b677d9c chore(release): publish
Build bootstrap container / build (push) Successful in 3m25s
Release / release (push) Successful in 26m36s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-06-07 17:09:57 +05:00
ant d7fda5cca6 Merge pull request 'feat(account,desktop): удаление пайщика в незавершённом регистрационном статусе' (#106) from feat/delete-inactive-participant into dev
Reviewed-on: #106
2026-06-07 12:03:44 +00:00
ant dc564941d3 Merge pull request 'fix(chatcoop): новый формат имени LiveKit-комнаты Element Call — секретарь не входил в звонки' (#107) from fix/chatcoop-livekit-room-name into dev
Reviewed-on: #107
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-07 12:03:33 +00:00
coopops e98baeccc2 [chatcoop][@ant] fix(secretary): новый формат имени LiveKit-комнаты Element Call
Typecheck / desktop (pull_request) Successful in 13m37s
Typecheck / controller (pull_request) Successful in 13m8s
Публичный call.element.io перешёл на слот-протокол MatrixRTC: имя LiveKit-комнаты
теперь считает lk-jwt-service как unpaddedBase64(sha256(JSON.stringify([roomId,
"m.call#ROOM"]))), а не старое sha256(roomId+"|m.call#ROOM"). Старая формула
перестала матчить webhook'и → секретарь молча игнорировал все звонки
(«Комната ... не в реестре транскрипции»).

Матчинг теперь сверяет оба формата (текущий + legacy для старых клиентов Element).
Тест на живой паре с прода (комната «Ферма на Паях», созвон 07.06).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:54:31 +00:00
coopops fc9a05fe24 [del-participant][@ant] fix(account,desktop): гейт удаления по статусу, не по blockchain_account
Typecheck / desktop (pull_request) Successful in 13m26s
Typecheck / controller (pull_request) Successful in 13m6s
Кнопка удаления не показывалась НИ у кого, и сервер отверг бы все удаления.
Причина: гейт опирался на blockchain_account, но on-chain аккаунт заводится рано
и присутствует уже у статуса Created — то есть у всех. Плюс на фронте сравнение
шло со строками в нижнем регистре, а GraphQL отдаёт enum UserStatus с большой
буквы (Created/Joined/...).

- interactor.deleteAccount: убран blockchain_account из guard; различитель —
  participant_account (принят) + статус-allow-list (домен lowercase).
- ParticipantsTable.isDeletable: убран blockchain_account; allow-list через
  Zeus.UserStatus (Created/Joined/Payed/Failed/Refunded). Registered (после приёма
  взноса), Active, Blocked — не удаляются.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:06:56 +00:00
ant a57763a535 Merge pull request 'feat(ledger2,registrator): двухфазный учёт регистрационного взноса через счёт 76' (#105) from feat/registration-ledger2-suspense into dev
Reviewed-on: #105
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-07 08:33:11 +00:00
coopops 3faf9049f4 fix(registration): возврат заводится на каждом цикле отказа, а не только на первом
Typecheck / desktop (pull_request) Successful in 13m32s
Typecheck / controller (pull_request) Successful in 13m7s
Idempotency-guard listener'а пропускал создание возврата при наличии ЛЮБОГО
REGISTRATION_REFUND. После первого цикла отказа возврат cycle1 оставался в БД
(его hash = registration_hash = sha256(username) детерминирован и совпадает с
новым), поэтому на втором цикле возврат не создавался, latest refund оставался
старше нового платежа, и фронт зависал на «платёж принят».

Guard теперь пропускает только возврат ТЕКУЩЕГО цикла — свежее текущего
вступительного платежа. Детекция цикла по дате — как в account.interactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:30:16 +00:00
coopops a46d177242 fix(registration): повторная оплата генерит новый платёж + единое назначение с НДС-оговоркой
Typecheck / desktop (pull_request) Successful in 14m15s
Typecheck / controller (pull_request) Has been cancelled
Повторная подача после отказа совета:
- createRegistrationPayment больше не переиспользует старый COMPLETED рег-платёж,
  если возврат свежее его (цикл закрыт) — заводит новый ордер со свежим QR,
  иначе пайщик видел исполненный QR, а в реестре совета платёж не появлялся.
- ReadStatement сбрасывает согласие с Уставом при входе в раздел (как doc-галочки).
- GenerateAccount сбрасывает «Я сохранил ключ» при входе в шаг (q-step не размонтируется).

Назначение платежа (memo) — единый полный источник:
- оговорка «НДС не облагается.» вынесена в константу VAT_EXEMPT_NOTE и добавлена во
  все 4 назначения (вступительный, паевой, возврат паевого, возврат рег-взноса);
- qrpay/sberpoll больше НЕ дописывают «. Без НДС.» к Purpose — назначение целиком
  в memo, без задвоения.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:07:35 +00:00
coopops f666007647 fix(registration): кнопка «Подать заявку заново» только после завершения возврата
Typecheck / desktop (pull_request) Successful in 13m31s
Typecheck / controller (pull_request) Successful in 13m6s
Два явных шага возврата при отказе совета (по просьбе): пока касса не подтвердила
исходящий платёж — показываем «возврат выполняется» без кнопки; после подтверждения —
«взнос возвращён» + кнопка. Иначе ранний клик упирался в reguser (карточка ещё не
снята) и падал.

- getAccount: registration_payment.status = PROCESSING пока возврат идёт,
  REFUNDED по завершении (refundpay снял карточку — повторная подача возможна).
- WaitingRegistration: ветка isCouncilRefundPending (PROCESSING) — баннер + песочные
  часы, без кнопки; isCouncilDeclined (REFUNDED) — баннер + «Подать заявку заново».
- fixData: ошибка через extractGraphQLErrorMessages вместо e.message
  (раньше показывало «undefined» — у GraphQL-ошибки SDK message лежит глубже).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:33:18 +00:00
coopops 3af96ab8f8 feat(registrator): повторная подача заявления на том же аккаунте после отказа совета
Typecheck / desktop (pull_request) Successful in 13m36s
Typecheck / controller (pull_request) Has been cancelled
Вариант 1: после отказа совета и возврата взноса несостоявшийся пайщик может
подать заявку заново на том же аккаунте (а не упереться в тупик).

Контракт:
- reset_account_card: refundpay (и миграц.ветка declinereg) при закрытии кандидата
  снимают карточку участника (type="", storages) — иначе повторный reguser падает
  «повторное получение невозможно». Аккаунт и ключи сохраняются.

Бэкенд:
- registerBlockchainAccount: CreateAccount шлётся только если eosio-аккаунта ещё нет
  (при повторной подаче он уже существует — иначе двойное создание падало бы).
- resetRegistration: ветка «отказ совета» — пропускает pre-payment гарды (аккаунт в
  цепи и взнос принят здесь нормальны), требует возврат COMPLETED (карточку снял
  refundpay), сбрасывает кандидата + user.status=created. Старые платежи не удаляет.
- getAccount: сигнал REFUNDED только если возврат свежее последнего рег-платежа —
  после повторной подачи новый REGISTRATION перекрывает старый отказ.

Повторная подпись соглашений идемпотентна (signagree перезаписывает программу,
sndagreement падает лишь на confirmed — при отказе совета соглашение не confirmed).

Стандарт p.reg.refund: отмечено освобождение аккаунта для повторной подачи.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:16:32 +00:00
coopops 6755c04bc0 feat(registrator): возврат рег-взноса по подтверждению кассы (callback), а не на decline совета
Typecheck / desktop (pull_request) Successful in 13m25s
Typecheck / controller (pull_request) Successful in 13m2s
Деньги держатся на счёте 76 (w.reg.pend) до фактического исходящего возврата
кассой — симметрично возврату паевого в wallet (createoutpay → outcomplete).

Контракт:
- declinereg: вместо немедленной проводки 76/51 ставит кандидата в refunding и
  создаёт исходящий платёж gateway::createoutpay (callback refundpay/declinerfnd);
  миграц.ветка (нет баланса на 76) — старое поведение, erase сразу.
- refundpay (новый, callback gateway): обратная проводка Дт 76 / Кт 51 (o.reg.refund,
  BURN w.reg.pend) + закрытие кандидата — на подтверждении кассой.
- declinerfnd (новый): страж check(false) — отмена возврата невозможна.
- фикс инвертированного eosio::check в declinepay (всегда падал «Кандидат не найден»).

Стандарты: p.reg.refund — двухшаговый граф payed→refunding→refunded
(o.reg.refund triggered_by refundpay); p.reg.accept — ветка отказа без своей проводки.

Бэкенд:
- статус refunding в userStatus + MonoAccountStatusDomainInterface (без рассинхрона).
- RegistrationDeclineListener: hash платежа-возврата = registration_hash (мост к
  on-chain outcome).
- gateway.interactor.processOutgoingPayment: REGISTRATION_REFUND зовёт completeOutcome
  (→ outcomplete → refundpay) с миграц-fallback (нет on-chain объекта → off-chain).
- getAccount: при наличии возврата отдаёт registration_payment.status=REFUNDED.

Фронт: WaitingRegistration реагирует на отказ совета (REFUNDED) — баннер
«Совет отказал, взнос возвращается» + «Подать заявку заново». union статуса +refunding/refunded.

Миграц.код помечен «снять условие после 30.07.2026».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:26:09 +00:00
coopops 4c0938bdd8 feat(registrator): миграционный fallback для зависших кандидатов (снять после 30.07.2026)
Typecheck / desktop (pull_request) Successful in 13m34s
Typecheck / controller (pull_request) Has been cancelled
На момент релиза двухфазного учёта будут кандидаты в статусе payed, чей платёж
принят по СТАРОМУ пути — без o.reg.inpay, то есть без баланса на w.reg.pend (76).
Новые confirmreg/declinereg делали бы TRANSFER/BURN с пустого суспенса → падение
«недостаточно средств».

Fallback по наличию баланса кандидата на w.reg.pend (helper
get_registration_pending_balance читает ledger2::userwallets cross-contract):
  - есть баланс (новый путь): confirmreg → o.reg.setmin/setent (76→80/86),
    declinereg → o.reg.refund (BURN 76, Dr 76/Cr 51);
  - нет баланса (принят до релиза): confirmreg → o.reg.putmin/payent (прямой
    ISSUE Dr 51/Cr 80|86, как раньше), declinereg → без on-chain проводки
    (возврат банковским переводом из бэкенда, как было).

Весь блок помечен «снять условие после 30.07.2026»: к этому сроку все зависшие
на момент релиза кандидаты гарантированно получат решение совета.

registrator.wasm пересобран.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:33:47 +00:00
coopops 890c386118 [del-participant][@ant] feat(account,desktop): удаление пайщика в незавершённом регистрационном статусе
Typecheck / desktop (pull_request) Successful in 13m28s
Typecheck / controller (pull_request) Successful in 13m11s
Реестр пайщиков накапливает брошенные/отклонённые регистрации (тестеры,
повторные попытки) и держит занятым e-mail, мешая перерегистрации. Включаем
кнопку удаления, но строго ограничиваем безопасными случаями.

Backend:
- account.interactor.deleteAccount: guard — запрет, если есть blockchain_account
  или participant_account (любой on-chain след), и allow-list off-chain статусов
  {created, joined, payed, failed, refunded}. deleteUserByUsername удаляет только
  PG-строку users, не цепь, поэтому on-chain аккаунты осиротеют — их не трогаем.
  Граница совпадает с resetRegistration. fail-closed: blocked/active/registered и
  любой будущий не-регистрационный статус — удалить нельзя.
- account.resolver: активирована мутация deleteAccount (роль chairman).

SDK: активирован Mutations.Accounts.DeleteAccount (Boolean), регенерирован zeus.

Desktop (Реестр пайщиков):
- entities/Account: api.deleteAccount + store.deleteAccount (локальное удаление
  строки без перезапроса).
- ParticipantsTable/ParticipantCard: деструктивная кнопка рядом со статусом
  «Активен», видна только для удаляемых аккаунтов (isDeletable зеркалит серверный
  guard) + BaseDialog-подтверждение необратимого действия.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:26:42 +00:00
coopops 01ac09599e feat(ledger2,registrator): двухфазный учёт регистрационного взноса через счёт 76
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Раньше регистрационный взнос вставал на учёт одношагово только на одобрении
совета (confirmreg: Dr 51/Cr 80 + Dr 51/Cr 86), а приём денег кассой
(confirmpay) и отказ совета (declinereg) не делали проводок вообще.

Вводит суспенс-фазу через новый счёт 76 «Расчёты с пайщиками»:
  - confirmpay (касса приняла деньги) → o.reg.inpay: Dr 51 / Cr 76, ISSUE w.reg.pend
  - confirmreg (совет одобрил)        → o.reg.setmin (Dr 76/Cr 80) + o.reg.setent (Dr 76/Cr 86),
                                        TRANSFER w.reg.pend → w.reg.minshr / w.reg.entry
  - declinereg (совет отказал)        → o.reg.refund: Dr 76 / Cr 51, BURN w.reg.pend
                                        (отдельный процесс p.reg.refund) + удаление кандидата

Новые сущности ledger2:
  - счёт 76 PARTICIPANT_SETTLEMENTS (ACTIVE_PASSIVE)
  - кошелёк w.reg.pend REGISTRATION_PENDING (USER_SHARED, program_id=0)
  - операции o.reg.inpay/setmin/setent (p.reg.accept) + o.reg.refund (p.reg.refund)

confirmreg: проводки под гардами amount>0 (чинит латентный креш при initial==0).
declinereg: добавлен require_auth(_soviet) + возврат + чистка кандидата
(устраняет залипание записи кандидата, блокировавшее повторную подачу).

Синхронизированы оба зеркала (cpp registries + cooptypes TS), обновлён
snapshot реестра кошельков, добавлен p.reg.refund в process-hash-locator
(иначе integrity-check валит controller на старте). Стандарт p.reg.accept
обновлён под двухфазную схему, заведён p.reg.refund.standard.yaml.

Контракты registrator.wasm и ledger2.wasm собраны (static_assert'ы реестров прошли).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:21:24 +00:00
coopops 867a87f8db Merge remote-tracking branch 'origin/feat/registration-refund' into dev 2026-06-06 06:54:55 +00:00
ant 61570457db Merge pull request 'fix(gateway): не плодить повторный регистрационный платёж после приёма' (#98) from feat/registration-payment-dedup into feat/registration-refund
Reviewed-on: #98
2026-06-06 06:49:14 +00:00
ant 41c77b3b0f Merge pull request 'feat(gateway,account): возврат вступительного взноса при отказе совета' (#97) from feat/registration-refund into dev
Reviewed-on: #97
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-06 06:49:02 +00:00
coopops 9e64238682 fix(gateway,notifications): возврат взноса слал письмо «платёж принят»
Typecheck / desktop (pull_request) Successful in 13m24s
Typecheck / controller (pull_request) Successful in 13m15s
notifyPaymentStatus выбирал workflow только по статусу (PAID → PaymentPaid
«Платёж принят»), игнорируя direction. Исходящий PAID — это возврат взноса
пайщику, и он получал письмо как на приём. Баг общий для всех возвратов
(регистрационный + паевой — оба идут через gateway.setPaymentStatus).

- Новый workflow notifications/payment-refunded «Возврат взноса выполнен»
  (зеркало payment-paid, тот же payload).
- payment-notification.service: OUTGOING+PAID → PaymentRefunded, INCOMING+PAID →
  PaymentPaid, остальное → PaymentCancelled.

Workflow засинкан в Novu (pnpm sync, 24 workflow, 0 ошибок).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 06:38:51 +00:00
coopops 75a8068970 feat(account,desktop): назначение возврата взноса — зеркало входящего + показ на развороте
Typecheck / desktop (pull_request) Successful in 13m31s
Typecheck / controller (pull_request) Has been cancelled
Backend: memo возвратного платежа теперь зеркалит назначение входящего взноса —
«Возврат вступительного и минимального паевого взносов №XXXX. Без НДС.» с тем же
номером (original.hash.slice(0,8), как в QR при приёме) и НДС-оговоркой, что
qrpay/sberpoll добавляют к Purpose. Кассир копирует as-is в платёжку.

Desktop: PaymentDetails показывал «Назначение платежа» с кнопкой копирования
только в ветке OUTGOING+payment_details. Возврат идёт без payment_details и падал
в «нет дополнительной информации». Добавлена ветка: если есть payment.memo —
показываем его через CopyableInput (с копированием).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 06:22:38 +00:00
coopops e2cf85dd6b fix(account): declinereg не находил кандидата из-за регистра registration_hash
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
В БД registration_hash хранится lowercase (sha256().digest('hex')), а из
цепи checksum256 в declinereg.registration_hash приходит UPPERCASE — exact-match
findOneBy не находил кандидата, возврат взноса пропускался с warn.
Нормализуем входящий хэш .toLowerCase() в findByRegistrationHash.

Проверено на voskhod: candidate czeudubzzuql имеет registration_hash
f25ca396... (lowercase), warn пришёл с F25CA396... (uppercase) — тот же хэш.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 06:09:29 +00:00
coopops 2821cc729e fix(desktop): ожидание решения совета — статичная иконка + сброс согласий при возврате
1. WaitingRegistration: крутящийся спиннер заменён статичной иконкой
   hourglass_top с подписью «Ожидаем решение совета». Анимация загрузки
   на процессе до 30 дней сбивала с толку — казалось, страница не дозагрузилась.
2. fixData (возврат к редактированию после отклонённого платежа) теперь зовёт
   новый store.resetConsents(): сбрасывает agreements (устав/ПД/…) + подписи +
   документы. Раньше галочка «прочитал устав» оставалась проставленной на
   повторном проходе ReadStatement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 05:50:48 +00:00
coopops 6def1156ed fix(desktop): BaseRadioCard hover не белит карту на светлой теме
hover ставил background: var(--p-surface) (#fff на light) — карта на
--p-canvas (#f4f4f5) осветлялась до белого, выглядело как засвет выбора.
На dark было ок (canvas #0a0a0a → surface #141416 — чуть светлее).
Замена на --p-canvas-2: на light темнее канвы, на dark светлее — hover
идёт в одну сторону с темой в обеих.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 05:39:06 +00:00
coopops 03976c8be9 Merge remote-tracking branch 'origin/feat/registration-refund' into feat/registration-payment-dedup 2026-06-05 19:41:55 +00:00
coopops e3983c3d01 merge dev into feat/registration-refund
Typecheck / desktop (pull_request) Successful in 13m36s
Typecheck / controller (pull_request) Successful in 13m16s
Конфликт только в сгенерированном Zeus-клиенте (controller/zeus + sdk/zeus) — разрешён
перегенерацией codegen, не ручной разводкой:
- пересобран cooptypes dist (протух: DeclineRegistration был в src, не в dist → TS2339)
- generate-schema (56 резолверов) + generate-client
- zeus теперь надмножество: dev + fsm-recovery (resetRegistration) + refund (REGISTRATION_REFUND)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:36:42 +00:00
ant 0966a97dde Merge pull request 'perf(factory): параллелить независимые чтения сборки документа (resolveParallel)' (#103) from perf/factory-parallel-fetch into dev
Reviewed-on: #103
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-05 19:05:25 +00:00
claude 26d4774307 docs(factory): зафиксировать паттерн параллелизации resolveParallel в CLAUDE.md
Typecheck / desktop (pull_request) Successful in 13m29s
Typecheck / controller (pull_request) Successful in 13m14s
Конвенция для Actions/*: независимые от data чтения — в resolveParallel,
зависимые (getMeta/getDecision/getMeetQuestions) — после батча; ловушка TDZ;
системный шрифт вместо base64.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:01:46 +00:00
coopops 2496755c4e fix(factory): arial.ttf как binary — controller/.gitattributes нормализовал EOL
Typecheck / desktop (pull_request) Successful in 13m29s
Typecheck / controller (pull_request) Has been cancelled
В предыдущем коммите arial.ttf закоммитился битым: components/controller/.gitattributes
содержит `* text eol=lf`, что применилось и к бинарному шрифту → срезало 105 байт
\r (275572 → 275467), шрифт невалиден. Добавил `*.ttf/otf/woff binary` в
controller/.gitattributes (перекрывает правило выше) и перезаписал blob корректно.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:45:23 +00:00
coopops ddf050a02f perf(factory): системный Arial вместо base64 в html — рендер weasyprint ×10
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Главное горло генерации PDF: в каждый html инжектился @font-face с Arial через
data:base64 (~270 КБ TTF). weasyprint декодировал и парсил весь шрифт на КАЖДЫЙ
рендер — ~1.3 сек на документ в 754 байта (замер: тот же текст со встроенным
шрифтом 1282–1715 мс, без него 120–289 мс).

Ставим Arial СИСТЕМНО в образ controller (fontconfig) — парсится один раз и
кешируется. Шрифт тот же файл (декод прежнего ArialBase64 → arial.ttf), имя
семейства "Arial", глифы/вёрстка идентичны.

- controller/assets/fonts/arial.ttf — наш Arial (275 КБ)
- controller/Dockerfile — fontconfig + COPY шрифта + fc-cache
- factory Generator — убран @font-face data:base64, оставлен font-family: Arial
- удалён src/Fonts/arial.ts (367 КБ base64, больше не нужен; bundle 1.48 → 0.75 МБ)

Итог по всей серии (warm-pool + parallel-fetch + системный шрифт):
generateProjectOfFreeDecision ~5 сек → ~400 мс (×10).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:43:04 +00:00
coopops 0030f5a362 perf(factory): параллелить независимые чтения сборки документа через resolveParallel
Typecheck / desktop (pull_request) Successful in 12m54s
Typecheck / controller (pull_request) Successful in 12m31s
Сборка данных документа в каждом Action делала серию строго последовательных
await: getTemplate (2 сетевых запроса в explorer) + getMeta(getCurrentBlock) +
mongo-чтения (getUser/getCooperative/getProject/getVars) + udata/request/meet.
Сетевые запросы складывались в задержку ~1.5с поверх рендера PDF.

Добавлен хелпер DocFactory.resolveParallel({...}) — резолвит ВЗАИМНО
НЕЗАВИСИМЫЕ источники через Promise.all. В каждом из 46 Action-факторов
независимый префикс (template + mongo-чтения + независимые meet/request/udata)
сведён в один resolveParallel; зависимые вызовы оставлены последовательными
после батча, с сохранением порядка и аргументов:
  - getMeta({title: template.title}) — зависит от template (TDZ в Promise.all);
  - getFullName/getCommonUser — от user;
  - getDecision/getApprovedDecision — от coop (+meta.created_at);
  - getMeetQuestions/getGeneralMeetingDecision — от meet;
  - getProgram — от request.

Поведение не меняется — только параллелизуются независимые чтения. Условные
ветки (bank_account по типу юзера, branch по is_branched, signature) и гварды
сохранены на местах. Файлы, где параллелить нечего (только template+meta,
meta зависит от template) — не тронуты.

tsc + eslint + unbuild — зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:15:50 +00:00
ant 39ec558877 Merge pull request 'fix(decision): vue-tsc TS2719 в Decision/CreateProject — publish result ?? null' (#101) from fix/decision-createproject-tsc into dev
Reviewed-on: #101
2026-06-05 15:10:11 +00:00
ant 3a372fe801 Merge pull request 'perf(factory): тёплый пул процессов WeasyPrint вместо exec-per-doc' (#100) from perf/factory-weasyprint-warm-pool into dev
Reviewed-on: #100
2026-06-05 15:06:47 +00:00
coopops a0ce722952 fix(decision): publish result undefined → null для IAgenda | null
Typecheck / desktop (pull_request) Successful in 12m49s
Typecheck / controller (pull_request) Successful in 12m24s
vue-tsc TS2719 в Decision/CreateProject/model: publishProjectOfFreeDecision
объявлен Promise<IAgenda | null>, но поле мутации nullable в схеме —
result включает undefined и не присваивается IAgenda | null.

Fix: return result ?? null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:36:40 +00:00
coopops a2cf1d7bdf merge dev into perf/factory-weasyprint-warm-pool (PR #99 publish + др.)
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-05 14:29:48 +00:00
ant ca1dc4fa69 Merge pull request 'fix(soviet): возвращать созданный вопрос повестки из publish — мгновенный показ' (#99) from fix/agenda-pending-new-question into dev
Reviewed-on: #99
2026-06-05 14:22:34 +00:00
coopops 2244c4e95c perf(factory): тёплый пул процессов WeasyPrint вместо exec-per-doc
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Генерация PDF спавнила новый процесс `weasyprint` на каждый документ через
`exec`. Медленные 3-5 сек — это холодный старт Python: import всего стека
(cffi/pydyf/tinycss2/Pango/cairo/HarfBuzz) + скан fontconfig. Сам рендер
быстрый (<0.5 сек), но старт платился каждый раз, т.к. процесс умирал сразу.

Держим пул из N долгоживущих Python-процессов горячими (WeasyPool/WeasyWorker):
import платится один раз за жизнь бэкенда, далее каждый документ = чистый
рендер. Размер пула = env WEASY_POOL_SIZE (дефолт min(4, CPU)) — даёт реальный
параллелизм, одновременные генерации не сериализуются к одному процессу.
Воркер живёт внутри процесса controller'а: без докера, миграций и отдельного
сервиса. Питон берётся из /venv (controller/Dockerfile), фоллбэк python3.

Детерминизм сохранён: SOURCE_DATE_EPOCH=0 в env воркера. Заодно выровнял
weasyPrintVersion 62.3 → 67 (синхрон с pip install в controller/Dockerfile —
метадата producer'а документов больше не врёт).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:15:13 +00:00
coopops f7951a985e fix(soviet): publish матчил вопрос по doc_hash вместо hash — ускорение + рабочий показ
Typecheck / desktop (pull_request) Failing after 8m22s
Typecheck / controller (pull_request) Successful in 12m41s
getAgendaItemByHash сравнивал decision.hash с data.document.doc_hash (хэш только
содержимого), тогда как decision.hash в блокчейне == data.document.hash («общий
хэш» = doc_hash + meta_hash). Из-за этого матч НИКОГДА не срабатывал: publish
впустую опрашивал повестку весь таймаут (~4.5 c) и возвращал null — мгновенный
показ не работал ни разу, вопрос всегда дожидался поллинга страницы.

- Матч по data.document.hash (= decision.hash on-chain).
- Поллинг: проверка сразу (без слепой стартовой паузы), тик 400 мс — ловим
  индексацию парсером action'а (~2 c) минимальной задержкой; publish ~2.2 c.

По таймингам: decision на чейне мгновенно, aggregate ~0.4 c; доминирует лаг
индексации парсером (~2 c) — единственный способ убрать его — синтез action,
отвергнут (требует подделки ~12 explorer/SHiP-полей BlockchainActionDTO).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:48:15 +00:00
coopops cd45697623 fix(soviet): не блокировать кнопки у созданного вопроса — голос/утверждение сразу
Typecheck / desktop (pull_request) Failing after 8m56s
Typecheck / controller (pull_request) Successful in 13m10s
Бэкенд возвращает пункт повестки только когда он полностью собран (decision из
блокчейна + проиндексированные парсером действие и документ-заявление) — то есть
он уже идентичен тому, что отдаёт getAgenda. Значит блокировать голос/утверждение
до следующего поллинга незачем: пользователь может действовать немедленно.

Убрана блокировка: pendingConfirmationIds (store/process-decisions) и
disabledDecisions (страница). Буфер-мердж в agendaStore оставлен как страховка от
«мигания» (вставленный вопрос держится, пока поллинг не вернёт его authoritative).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:12:26 +00:00
coopops c2c5aa1d84 fix(soviet): возвращать созданный вопрос повестки из publish — мгновенный показ
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
После публикации свободного решения повестка показывала «Нет вопросов на
повестке» до следующего поллинга (10+ сек). getAgenda собирается join'ом таблицы
decisions из блокчейна с проиндексированными парсером действием newsubmitted и
документом-заявлением, поэтому только что созданный вопрос появляется не сразу.

Решение: мутация publishProjectOfFreeDecision теперь возвращает созданный пункт
повестки. Бэкенд после публикации делает settle-паузу (parser-индексация) и
извлекает готовый вопрос с повестки по хэшу документа; фронт вставляет его в
таблицу немедленно. Кнопки голосования/утверждения/правки на нём заблокированы,
пока ближайший getAgenda-поллинг не подтвердит вопрос authoritative-данными.

Backend:
- AgendaInteractor.getAgendaItemByHash / AgendaService.getAgendaItemByHash —
  сборка одного пункта повестки по хэшу заявления (null, пока не проиндексирован).
- FreeDecisionService.publishProjectOfFreeDecision — settle-пауза + страховочная
  петля, возвращает AgendaWithDocuments | null. Резолвер: тип возврата сменён с
  Boolean на AgendaWithDocuments (nullable).
- AgendaModule экспортирует AgendaService; DecisionModule импортирует AgendaModule.

SDK: publish-селектор → форма agenda-item (rawAgendaSelector); regen zeus-клиента.

Frontend:
- agendaStore: буфер оптимистично вставленных вопросов + computed-мердж с
  authoritative-списком (без «мигания»); pendingConfirmationIds для блокировки.
- CreateProjectButton: вставляет вернувшийся вопрос через insertCreated.
- process-decisions/страница: блокируют кнопки неподтверждённых вопросов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:07:45 +00:00
ant 5c13c4ac53 Merge pull request 'feat(soviet): отрицательный консенсус — voteagainst отклоняет решение до срока' (#94) from feat/soviet-negative-consensus into dev
Reviewed-on: #94
2026-06-05 12:21:51 +00:00
coopops 3e2bf3dbc6 docs(council): снятие вопроса с повестки — отказ председателем или по сроку
Typecheck / desktop (pull_request) Successful in 12m49s
Typecheck / controller (pull_request) Successful in 12m30s
Дополнена страница «Повестка совета» бизнес-языком: если решение не принято
или истёк его срок, вопрос снимается с повестки — председателем вручную
(отрицательный консенсус) или автоматически по истечении срока. Обновлён
mermaid-граф (ветки «большинство против» и «срок истёк»), добавлен раздел
«Снятие вопроса с повестки», в блок голосования добавлено описание индикатора
отклонения. Без скриншотов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:14:22 +00:00
coopops aea114b01a feat(soviet): развязка отказа решения — авто по сроку vs явный declinedec председателем
Typecheck / desktop (pull_request) Successful in 13m13s
Typecheck / controller (pull_request) Successful in 12m38s
Голос «против» больше не затирает решение автоматически. Развязаны два пути
отказа:
- по истечению срока — авто (cancelexprd кроном chairman, ключом кооператива);
- по отрицательному консенсусу (большинство «против») — явным действием
  председателя declinedec, до истечения срока.

Контракт (soviet):
- voteagainst: только фиксирует голос, без авто decline_and_erase_decision.
- Новый action declinedec (в cancelexprd.cpp): require_auth(coopname), проверяет
  votes_against*100 > members*50 (зеркало votefor), переиспользует общую ветку
  decline_and_erase_decision. Собран в CDT, declinedec в ABI.
- Стандарты sov.authpkg / sov.decision приведены к развязке.

cooptypes: action Declinedec + интерфейс IDeclinedec.

Контроллер:
- SovietBlockchainPort/adapter: declineDecision + getBoards.
- Мутация declineDecision (resolver/service/interactor + DeclineDecisionInput),
  роль chairman.
- Повестка обогащается council_members_count (состав совета `soviet`, дёргается
  один раз) — фронту для порога принятия/отклонения.
- chairman: убран флаг cancelApprovedDecisions и кривая подпись; крон по сроку
  гасит ВСЕ истёкшие решения.

SDK: селектор council_members_count, мутация DeclineDecision; zeus regen (обе копии).

Desktop:
- BaseButton: сплошной вариант negative.
- VotingButtons: красный крестик при отрицательном консенсусе.
- QuestionCard: у председателя красная «Отклонить» (declinedec) вместо «Утвердить»
  когда решение отклонено советом; фича Decision/DeclineDecision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:44:11 +00:00
coopops b010cbe248 fix(gateway): не плодить повторный регистрационный платёж после приёма
Баг: при перезаходе/перезагрузке страницы оплаты в момент приёма взноса
заводился второй регистрационный платёж. Дедуп findActivePendingPayment ловит
только status=PENDING — а когда первый платёж уже PAID/COMPLETED (принят,
аккаунт регистрируется), дедуп промахивался и createInitialPayment создавал
платёж-дубль. Его нельзя ни подтвердить (processIncomingPayment →
registerBlockchainAccount → «аккаунт уже зарегистрирован»), ни осмысленно
отклонить — мусорный сирота в реестре.

Фикс: регистрационный платёж одноразовый. Берём последний рег-платёж пайщика
и, если он в живом статусе (PENDING/PROCESSING/PAID/COMPLETED), возвращаем его
вместо создания нового. Новый ордер заводим только если прошлого нет либо он
FAILED/EXPIRED/CANCELLED (легитимная повторная попытка, в т.ч. после
resetRegistration). Авторитетная защита на бэке, не зависит от гонок на фронте.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:36:13 +00:00
coopops 89e042a8ef review(#97): подписка на declinereg через константы cooptypes
По ревью @ant: не хардкодить 'action::registrator::declinereg' строкой.
Добавил action-константу RegistratorContract.Actions.DeclineRegistration
(actionName='declinereg', тип IDeclinereg) в cooptypes — её не было.
Листенер подписывается через
action::${RegistratorContract.contractName.production}::${...DeclineRegistration.actionName}
по образцу signed-document-ingestion (SovietContract).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:35:47 +00:00
coopops 39d02f8592 feat(gateway,account): возврат вступительного взноса при отказе совета
При отказе совета в приёме пайщика (отрицательный консенсус >50% — PR #94 —
или истечение срока повестки) on-chain срабатывает registrator::declinereg.
Взнос по фондам на этот момент НЕ разносился (confirmreg не было), но реальные
деньги пайщика уже у кооператива на расчётном счёте — их нужно вернуть.

- Новый PaymentTypeEnum.REGISTRATION_REFUND (исходящий), отдельный от WITHDRAWAL
  (возврат паевого), чтобы не путать термины. Лейблы + OUTGOING_PAYMENT_TYPES.
- RegistrationDeclineListener на action::registrator::declinereg: резолвит
  кандидата по registration_hash, по исходному REGISTRATION-платежу берёт сумму
  и номер (hash8 = тот же № из QR), заводит исходящий платёж PENDING с
  назначением «Возврат вступительного и мин.паевого взноса №<hash8>».
  Идемпотентно (повторный приход события не плодит возвраты).
- processOutgoingPayment: для REGISTRATION_REFUND подтверждение кассиром —
  off-chain no-op (статус → COMPLETED, без completeOutcome). Взнос on-chain не
  двигался, откатывать нечего; обратная проводка по расчётному счёту появится
  здесь же, когда введём проводки на приёме платежа.
- Реестр платежей: для возврата спрятана кнопка «Отклонить» (отказать в
  возврате нельзя — совет уже отказал, деньги обязаны вернуть).
- Codegen: PaymentType.REGISTRATION_REFUND проброшен в Zeus (sdk + controller).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:15:55 +00:00
ant fc9df153ea Merge pull request 'feat(reg): обработка отклонения вступительного платежа (decline до создания аккаунта)' (#91) from feat/registration-payment-decline into dev
Reviewed-on: #91
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-05 11:05:08 +00:00
coopops 50a0550a6a feat(registration): поллинг приёма оплаты на шаге PayInitial
Провайдер QR (Bank) не эмитит success-колбэк, деньги подтверждаются вебхуком
на бэке асинхронно — без поллинга экран висел на форме оплаты до перезагрузки.
Каждые 10с подтягиваем аккаунт; как только вступительный платёж покинул PENDING
(принят/отклонён) — уходим на шаг ожидания решения совета, который сам
показывает «платёж принят» либо причину отказа.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:54:20 +00:00
ant eb9ff9d3b6 Merge pull request 'feat(pwa): надёжное обновление + пер-кооп имя/иконка установочника' (#93) from feat/pwa-reliable-update into dev
Reviewed-on: #93
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-05 09:27:06 +00:00
coopops de974d336d feat(pwa): стандартная иконка приложения (SVG + PNG-набор)
Typecheck / desktop (pull_request) Successful in 12m56s
Typecheck / controller (pull_request) Successful in 12m31s
PNG-иконки manifest'а физически отсутствовали (на проде /icons/*.png отдавали
HTML-фоллбэк) → на домашнем экране дефолтная иконка. Генерим полный набор из
logo.svg (квадрат, белый фон, лого по центру, maskable-safe) + в динамическом
manifest основная иконка — масштабируемый /logo.svg (purpose any maskable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:21:17 +00:00
coopops 14063859c6 feat(pwa): имя установочника = имя кооператива (динамический manifest)
Образ desktop generic, кооп задаётся env контейнера (COOP_SHORT_NAME), а
manifest пёкся на сборке и коопа не знал → на домашнем экране всегда дефолтное
'Цифровой Кооператив'. Отдаём manifest динамически SSR-middleware из env по
пути /manifest.webmanifest (статический /manifest.json перехватывается
serveStatic ДО user-middleware, поэтому новый путь + переписываем <link
rel=manifest> в HTML). Плюс рантайм apple-mobile-web-app-title для iOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:21:17 +00:00
coopops 7105c053fb feat(pwa): надёжное пользовательское обновление вместо авто-reload
Новый SW больше не активируется в фоне сам (skipWaiting/clientsClaim=false) —
ждёт в waiting, убирая гонку «подвисание между релизами» (старая страница +
новый SW). updated() шлёт window-событие 'sw:update-available'; boot 'pwa-update'
ловит его и показывает канон-тост UpdateAlert в левом нижнем углу (timeout 0,
кнопки «Обновить»/«Позже»). По «Обновить» → window.applyUpdate() → SKIP_WAITING
→ controllerchange → reload. Детерминированно, без фонового свопа версий.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:21:17 +00:00
coopops ffdf6fe8e3 [reg-fsm][@ant] feat(account,desktop): откат незавершённой регистрации + server-truth шаг
Plan B (recovery + источник истины о шаге на сервере), поверх гарда A.

Backend:
- resetRegistration (self-scoped по токену): откат незавершённой регистрации
  к редактированию до отправки в блокчейн. Снимает заморозку профиля/e-mail
  (users.status → created), чистит документы кандидата и отпечаток заявления,
  удаляет непринятую попытку вступительного платежа. Принятые средства
  (PAID/COMPLETED) или уже созданный on-chain аккаунт → отказ (нужна
  перерегистрация/возврат — отдельная ветка).
- PaymentRepository.delete.

Frontend:
- SignUp реконструирует шаг с сервера: Joined → шаг оплаты (переживает
  перезагрузку и работу в другой вкладке), приоритет у registration_payment.
- WaitingRegistration «Исправить данные» дёргает resetRegistration перед
  возвратом к данным (после подписи профиль заморожен, локального отката мало).
- Фича Account/ResetRegistration + Zeus-клиент (codegen).

Существующие статусы (joined/registered/active) не меняем, новых не вводим:
фронтовые active-гейты висят на on-chain user_account.status — другая ось,
не пересекается с mono users.status, который трогает recovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:18:58 +00:00
coopops f72cf529a9 feat(soviet): отрицательный консенсус — voteagainst отклоняет решение до срока
Typecheck / desktop (pull_request) Successful in 12m50s
Typecheck / controller (pull_request) Successful in 12m35s
На контракте совета теперь возможно не только заблокировать решение
голосованием, но и принять отрицательное решение: как только против
проголосовало больше половины состава совета, решение отклоняется
немедленно (не дожидаясь истечения срока) — той же веткой отказа, что и
просрочка: decline-callback в исходный процесс + удаление решения.

- Общая ветка отказа вынесена в decline_and_erase_decision (cancelexprd.cpp),
  переиспользована в cancelexprd (просрочка) и voteagainst (негативный консенсус).
- Порог зеркален votefor: votes_against*100 > total_members*50, строгое «больше».
- Стандарты sov.authpkg / sov.decision приведены к новому поведению; заодно
  убран cancelvote (в коде запрещён: eosio::check(false)).

Контракт soviet собран в CDT — зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:48:41 +00:00
coopops 2b44fe6022 [reg-decline][@ant] feat(account): гард консистентности профиля перед reguser
Typecheck / desktop (pull_request) Successful in 14m24s
Typecheck / controller (pull_request) Successful in 13m58s
Гард A: на цепь не уходят данные, отличные от подписанных в заявлении.
Каноничный отпечаток удостоверяющих личность полей фиксируется при
подписании заявления (registerParticipant → candidate.meta), сверяется
перед registerBlockchainAccount. Расхождение → BAD_REQUEST, платёж уходит
в FAILED с причиной (видна пайщику через registration_payment из #91).

Пропуск уже идущих регистраций: кандидаты без отпечатка в meta (заявление
подписано до внедрения гарда) не проверяются — grandfather.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:40:08 +00:00
coopops 5e333bdcc6 [reg-decline][@ant] feat(desktop): экран отклонения вступительного платежа + повтор
Typecheck / desktop (pull_request) Successful in 14m5s
Typecheck / controller (pull_request) Successful in 13m29s
Обработка отклонения платежа на этапе регистрации в SignUp:

- Session store: геттер registrationPayment из аккаунта.
- SignUp.vue onMounted: если есть незавершённый вступительный платёж —
  восстанавливаем шаг WaitingRegistration после перезагрузки/в любой вкладке
  (процесс может тянуться днями).
- WaitingRegistration.vue: ветка «Платёж не принят» с причиной из
  registration_payment.message и кнопками «Повторить оплату» / «Исправить
  данные» (откат назад по процессу — только по нажатию пайщика). Live-опрос
  аккаунта раз в 10с, чтобы экран сам переключился на отклонение/приём.
- Payment/SetStatus: setCancelledStatus(id, message); кнопка «Отклонить»
  теперь ставит CANCELLED (триггерит уведомление) с полем причины.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 06:53:55 +00:00
coopops c2f7eac7c2 [reg-decline][@ant] feat(gateway,account): причина отклонения платежа + сводка вступительного платежа в аккаунте
Backend под обработку отклонения регистрационного платежа (этап до создания
аккаунта в блокчейне):

- SetPaymentStatusInput: опциональное поле message — причина смены статуса;
  сохраняется в payment.message и показывается пайщику.
- gateway.interactor.setPaymentStatus: персистит message при смене статуса
  (уведомление о CANCELLED уже шлётся через PaymentNotificationService).
- PaymentRepository.findLatestByUsernameAndType — последний платёж типа.
- AccountDTO.registration_payment {status,message,hash,quantity,symbol}:
  сводка вступительного платежа в getAccount. Источник истины для фронта,
  чтобы восстановить шаг регистрации (ожидание/отклонение) после
  перезагрузки и в любой вкладке. Заполняется пока нет participant_account.
- Регенерация Zeus-клиента (schema.gql gitignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 06:53:43 +00:00
Alex Ant 8272fe6951 chore(release): publish
Release / release (push) Successful in 25m0s
Build bootstrap container / build (push) Successful in 39s
Release / publish-packages (push) Failing after 8m42s
Release / trigger-coopenomics-docs (push) Successful in 1s
Release / trigger-mono-docs (push) Successful in 2s
2026-06-05 11:42:28 +05:00
Alex Ant 793572ae4b Merge branch 'testnet' 2026-06-05 11:42:13 +05:00
Alex Ant 4cf2ab3c64 chore(release): publish
Release / release (push) Successful in 53m16s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
Build bootstrap container / build (push) Successful in 1m20s
2026-06-05 01:13:18 +05:00
ant fc3ae1b512 Merge pull request 'fix(install,contracts): чистка фантомных пайщиков fgrtejiwnynn + лок install от дублей' (#89) from fix/gorozhane-phantom-cleanup into dev
Reviewed-on: #89
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-04 20:06:42 +00:00
claude f09e267c28 fix(install): не включать maintenance во время нормальной установки
Typecheck / desktop (pull_request) Successful in 14m33s
Typecheck / controller (pull_request) Successful in 13m59s
'maintenance' на фронте включает полноэкранную заглушку «Техническое
обслуживание» (watch-desktop-health), которая накрывала бы саму страницу
установки. Поэтому статус остаётся 'initialized' на всё время install
(фронт показывает страницу установки, как и прежде), а в 'maintenance'
переводим ТОЛЬКО в catch при падении ПОСЛЕ записи в цепь — это блокирует
повторный запуск (guard требует 'initialized') и корректно сигналит, что
коопа в полу-установленном состоянии и требует ручного разбора.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:59:48 +00:00
claude cccec9f940 fix(registrator): пересчёт active_participants_count с явным исключением фантомов
Typecheck / desktop (pull_request) Successful in 14m25s
Typecheck / controller (pull_request) Has been cancelled
Счётчик сходится к верному значению (5) за один деплой независимо от
порядка выполнения migrate registrator vs soviet. Сверено с цепью:
11 participants = 6 фантомов + 5 реальных (3 founders + 2 новых пайщика
Белоус Нина/Сергей, вступивших после инцидента).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:41:37 +00:00
coopops 0354b391f5 fix(install,contracts): чистка фантомных пайщиков fgrtejiwnynn + лок install от дублей
Typecheck / desktop (pull_request) Successful in 14m51s
Typecheck / controller (pull_request) Has been cancelled
Причина (инцидент 2026-06-04, gorozhane): InstallInteractor.install прогнан 3×
(2 провала + успех). Каждый прогон генерил случайный username и необратимо
регистрировал совет на цепи (adduser), а откат в catch чистил только off-chain.
На цепи осели 6 дублей-аккаунтов 3 учредителей + 1800 RUB фантома в паевом фонде.

Багфикс (install.interactor.ts):
- лок: статус → 'maintenance' ДО любых on-chain операций; повторный install
  блокируется guard'ом (требует 'initialized'), что и плодило дубли;
- при ошибке ПОСЛЕ первого adduser off-chain rollback не делаем (сохраняем
  привязку личность↔username для разбора), статус остаётся 'maintenance';
- при ошибке ДО on-chain — чистый откат + возврат 'initialized'.

On-chain очистка (вызовется автоматически на деплое, идемпотентна, scope=coopname):
- registrator::migrate — erase 6 фантом-accounts + пересчёт active_participants_count
  из soviet::participants;
- soviet::migrate — erase 6 фантом-participants (гард: остаётся ≥1 реальный);
- ledger2::migrate — снять L3-доли фантомов на w.reg.minshr, на снятую сумму
  уменьшить L2-кошелёк и бухсчета 80 (Cr, паевой фонд) и 51 (Dr, расчётный).

Off-chain коррекции (mongo individuals и пр.) — вручную, см. ~/fixes.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:24:25 +00:00
ant 85e2f05bdb Merge pull request 'fix(payments): убрать обязательное поле «КПП банка» из реквизитов счёта' (#81) from fix/remove-bank-kpp into dev
Reviewed-on: #81
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-04 19:21:19 +00:00
coopops 96e16ee6ab Revert "Merge pull request '[apps-catalog umbrella] mono — Federation MWP (E9 demo pilot + E10 runtime federation + 9.3.b publish)' (#64) from apps-catalog into dev"
This reverts commit 9df3bfe4a9, reversing
changes made to e54115d28f.
2026-06-04 14:32:13 +00:00
coopops 93b94326ca fix(payments): убрать обязательное поле «КПП банка» из реквизитов счёта
Typecheck / desktop (pull_request) Successful in 13m21s
Typecheck / controller (pull_request) Successful in 13m24s
КПП — реквизит юрлица, у ИП и физлиц его нет. Общий тип реквизитов
банковского счёта (IBankAccount.details.kpp) требовал КПП у всех держателей
счёта, из-за чего индивидуальный предприниматель не мог пройти регистрацию
(и фронт, и бэкенд заворачивали пустой КПП).

Поле снято во всех слоях:
- cooptypes: тип IBankAccount.details + шаблон ParticipantApplication
  (render-аргументы и плейсхолдеры) + mock-данные registry
- controller: BankAccountDetails(Input) DTO, domain-интерфейсы, init-config
- factory: ajv-схема BankAccountSchema (properties + required)
- sdk: zeus-селектор bankAccountSelector + перегенерённый zeus-клиент
- desktop: формы ИП/юрлица/физлица, реквизиты, добавление способа оплаты,
  CSV-импорт участников

КПП организации/кооператива (details.kpp, отчётность, qrpay/sberpoll)
НЕ затронут — это легитимный реквизит юрлица.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:40:13 +00:00
claude 9df3bfe4a9 Merge pull request '[apps-catalog umbrella] mono — Federation MWP (E9 demo pilot + E10 runtime federation + 9.3.b publish)' (#64) from apps-catalog into dev 2026-06-04 11:26:47 +00:00
claude 83a3207805 Merge pull request '[E9-9] feat(controller): approveModeration + rejectModeration + appsCatalogPendingModerations' (#76) from feat/E9-9-approve-moderation into apps-catalog
Typecheck / desktop (pull_request) Successful in 14m35s
Typecheck / controller (pull_request) Successful in 13m24s
2026-06-04 11:23:10 +00:00
claude db2ff23756 Merge pull request '[E10-4] gateway-orchestrator: install-pipeline (OCI pull → compose up → health → registry)' (#73) from feat/E10-4-install-pipeline into apps-catalog
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-04 11:22:37 +00:00
coopops 66ad103e90 [E9-9] feat(controller): approveModeration + rejectModeration + appsCatalogPendingModerations
Story 9.9 «Стол восхода: approve pending packages» — backend-сторона.

GraphQL contract:
- Query.appsCatalogPendingModerations(status?: ModerationStatusEnum, limit?: Int)
  → [ModerationRequestDTO]. По умолчанию SUBMITTED. Chairman-only.
- Mutation.approveModeration(data: ApproveModerationInputDTO!)
  → ApproveModerationResultDTO. Discriminated outcome:
    applied / pendingChain (423) / conflict (409) / requiresOverride (403) / failed.
- Mutation.rejectModeration(data: RejectModerationInputDTO!)
  → RejectModerationResultDTO. Discriminated outcome: applied / conflict / failed.

HTTP-service: 3 новых метода (listSubmittedModerations / approveModeration /
rejectModeration) на ca-admin endpoints, добавленных параллельным
apps-catalog PR #7. Mapping snake_case → camelCase; degraded mode на
list — пустой массив, на mutations — failed с явной причиной (чтобы UI
не вводился в заблуждение «approved»).

ReleaseScope передаётся как InputType с union-style полями
(type + subnets?/coopnames?); резолвер валидирует соответствие type →
непустой массив перед отправкой на ca-admin.

Зависимость: apps-catalog PR #7 (GET /v1/admin/moderation) должен быть
смёрджен в feat/E9-demo-app-pilot до того, как этот endpoint можно
использовать в e2e.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 06:01:45 +00:00
ant e54115d28f Merge pull request 'feat: экспорт документов в .sig v2.0 + manifest.json (верификатор)' (#65) from feat/sig-v2-export into dev
Reviewed-on: #65
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-04 05:48:50 +00:00
coopops 3f1d816ac0 [E10-4] feat(gateway-orchestrator): install-pipeline через DockerRunner/HealthProbe/OciToken порты
Story 10.4 — pipeline установки subgraph-расширения:
1. (опц.) OCI token у CA-auth → docker pull образа;
2. (опц.) docker compose up сервиса;
3. healthcheck poll по url subgraph'а до 200 OK;
4. upsert в subgraph_registry (Apollo Gateway за ≤10с подхватит);
5. на failure — composeDown rollback + discriminated outcome {applied|failed}.

InstallController стал тонкой обёрткой над InstallOrchestratorService.
Внешние зависимости (docker shell / fetch / CA-auth) изолированы за
портами; тесты (7/7 green) гоняют сценарий через ин-мемори моки без
реального docker daemon'а и сети.

Что ОСТАЁТСЯ для отдельной ветки под этим зонтиком:
- wiring approve→install REST из mono controller (mutation approvePackage);
- desktop notification после успешного install'а;
- E2E на реальном compose + chatcoop subgraph (Story 10.8).
2026-06-04 05:08:39 +00:00
coopops 4e64637823 [9.3.b umbrella-merge] Mutation.publishPackage + publishRelease — proxy на ca-admin — closes #72
Typecheck / desktop (pull_request) Successful in 13m38s
Typecheck / controller (pull_request) Successful in 13m17s
2026-06-04 04:54:12 +00:00
coopops bf1f8ba345 [E10 umbrella-merge] Runtime Federation для backend-расширений (10.1+10.2+10.2b+10.3) — closes #71 2026-06-04 04:54:04 +00:00
coopops f698e39cb8 [9.3.b-rel] feat(controller): Mutation.publishRelease — on-chain setrelease через ca-admin
Story 9.3.b-rel — мутация выкладки нового релиза ранее зарегистрированного
пакета. Прокидывает manifest + версию + tarball_sha256 на ca-admin
POST /v1/admin/releases, который сам валидирует manifest Zod-схемой
(PackageManifestSchema) и подписывает on-chain apps::setrelease от
имени chairman'а кооператива-оператора каталога.

Backend (apps-catalog-proxy):
- PublishReleaseInputDTO / PublishReleaseResultDTO — GraphQL Input/Object;
  manifest как GraphQLJSON (произвольная структура валидируется на
  ca-admin'е).
- AppsCatalogHttpService.createRelease(): POST /v1/admin/releases;
  discriminated outcome — applied (с опциональным transaction_id) |
  invalidManifest (HTTP 422 INVALID_MANIFEST мапится явно, чтобы UI
  показывал валидационную ошибку отдельно от network/auth) | failed
  для прочих.
- AppsCatalogProxyResolver.publishRelease(): @AuthRoles(['chairman']).

Под архитектуру E10 manifest содержит ссылки на артефакты в Nexus:
- coopenomics.backend.image — docker-image (subgraph расширения);
- coopenomics.frontend.tarball — npm tarball (desktop-bundle).

Сам manifest НЕ валидируется на стороне controller'а — это делает
ca-admin (там Zod-схема и она же будет расширена для package.json
полей в story 10.7).

Frontend (форма Опубликовать релиз) — отдельной story 9.3.b-ui после
codegen'а Zeus.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 19:03:38 +00:00
coopops b3ed693db6 [794-4][@ant] feat(desktop): расширение файла подписи .sig → .coopsig — чтобы ОС не предлагала открыть в КриптоПро (несовместимый secp256k1-JSON vs ГОСТ-CMS)
Typecheck / desktop (pull_request) Successful in 13m34s
Typecheck / controller (pull_request) Successful in 13m11s
2026-06-03 19:01:19 +00:00
coopops a07b5f8d60 [9.3.b-pub] feat(controller): Mutation.publishPackage — on-chain регистрация пакета через ca-admin
Story 9.3.b-pub стола разработчика — мутация для воскходовского
chairman'а: координаты пакета (@scope/name + owner + compatible_subnets)
прокидываются на ca-admin POST /v1/admin/package, который сам
подписывает on-chain action apps::regpkg от имени chairman'а
кооператива-оператора каталога.

Backend (apps-catalog-proxy):
- PublishPackageInputDTO / PublishPackageResultDTO — GraphQL InputType +
  ObjectType (camelCase для Zeus, без snake-case утечек).
- AppsCatalogHttpService.registerPackage(): POST на /v1/admin/package;
  discriminated outcome (applied | conflict | failed) — мапит 409 в
  conflict, прочие axios-ошибки в failed; degraded-mode (без
  APPS_CATALOG_API_KEY) возвращает failed с явным сообщением, чтобы UI
  не вводился в заблуждение «опубликовано».
- AppsCatalogProxyResolver.publishPackage(): @UseGuards(GqlJwtAuthGuard,
  RolesGuard) + @AuthRoles(['chairman']) — мутация доступна только
  председателю кооператива.

Multipart upload install.js под архитектуру E10 НЕ делаем: расширения
теперь публикуются как npm-package + docker-image в Nexus отдельной
процедурой (`npm publish` / `docker push`) через scoped JWT от
CA-auth. Эта мутация — только on-chain маркер «такой пакет
существует»; релизы с manifest'ом — story 9.3.b-rel.

schema.gql / Zeus client — codegen за пределами этого PR (mono-каноном
pnpm generate-schema + generate-client + sdk build делается отдельным
коммитом, чтобы PR с backend changes не зависел от пересборки SDK
артефактов).

Frontend — отдельной story 9.3.b-ui: DeveloperPublishReleasePage.vue
сейчас заглушка, форму подключим после codegen'а Zeus.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 18:59:37 +00:00
coopops c1d1b08973 [794-4][@ant] feat(desktop): .sig пишет canonicalization по версии документа (1.1.0→jcs-1.0) + signed_at чистится до подписанного формата байт-в-байт
Typecheck / desktop (pull_request) Successful in 13m54s
Typecheck / controller (pull_request) Successful in 13m22s
2026-06-03 18:46:49 +00:00
coopops da55cbac08 [794-4][@ant] feat(cooptypes): canonicalizationForVersion(version) — единый источник соответствия версия подписи → режим канонизации (.sig для desktop и верификатора) 2026-06-03 18:46:49 +00:00
coopops 4a5f41d15e [794-4][@ant] feat(sdk): версия подписи 1.1.0 с детерминированной канонизацией meta (JCS RFC 8785) — meta_hash перестаёт зависеть от порядка ключей; 1.0.0 остаётся legacy; compareDocuments берёт версию из документа (бэкенд авто-корректен) 2026-06-03 18:46:49 +00:00
coopops 8ea8bceea9 [E10-3] feat(gateway-orchestrator): Apollo Gateway + subgraph registry skeleton
Typecheck / desktop (pull_request) Successful in 13m48s
Typecheck / controller (pull_request) Successful in 13m40s
Epic 10 Story 10.3 — отдельный per-tenant Nest-сервис, объединяющий:
1. Apollo Federation Gateway (ApolloGatewayDriver) на /v1/graphql.
2. REST orchestrator API на /v1/internal/{extensions,composition}/*.
3. Subgraph registry в Postgres (TypeORM entity SubgraphRegistryEntity).

Что работает:
- Bootstrap seed'ит запись core-subgraph'а (coopback) → IntrospectAndCompose
  стартует с одним subgraph'ом → gateway проксирует все queries в core.
- JWT forwarding: Authorization-header forward'ится в каждый subgraph
  через RemoteGraphQLDataSource.willSendRequest.
- POST /v1/internal/extensions/install { packageId, version, url } —
  upsert в registry.
- DELETE /v1/internal/extensions/uninstall/:packageId — deactivate.
- POST /v1/internal/composition/refresh — 202 Accepted (реальный recompose
  по polling'у IntrospectAndCompose, ≤10 сек).
- GET /v1/internal/composition/status — текущие subgraph'ы.

Trade-off MVP:
- subgraphs читаются ОДИН раз на bootstrap (Apollo 2.9 IntrospectAndCompose
  ожидает статичный массив). Добавление НОВОГО subgraph'а → требует
  рестарта контейнера gateway-orchestrator. Warmup ~5 сек, делается
  orchestrator-pipeline при install'е (Story 10.4).
- Реальный docker pull/up/healthcheck — Story 10.4 (Install pipeline).
- Динамическая supergraph composition без рестарта — Story 10.3b
  (custom SupergraphManager).
- On-chain auto-update watcher — Story 10.5.

ENV: GATEWAY_PORT, DATABASE_URL, CORE_SUBGRAPH_URL, COOPNAME, JWT_SECRET.
2026-06-03 18:44:08 +00:00
coopops 68b91566ad [E10-2b] feat(extension-hello-subgraph): PoC smoke-тест @coopenomics/extension-sdk
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Epic 10 Story 10.2b — минимальный subgraph на extension-sdk доказывает что
Federation v2 driver + JWT module + healthcheck реально работают вместе.

Smoke результат (SUBGRAPH_PORT=33001 JWT_SECRET=test-secret COOPNAME=voskhod):
- GET /_health → {"status":"ok","version":"unknown","uptimeSeconds":N}
- POST /v1/graphql { helloFromExtension(name:"мир") { greeting fromExtension } }
  → {"greeting":"Здравствуй, мир!","fromExtension":"hello-subgraph@0.1.0"}
- POST /v1/graphql { _service { sdl } } → returns federation v2 SDL с
  @key/@external/@requires/@shareable директивами

Также в этом коммите:
- extension-sdk переключён с unbuild на обычный tsc (unbuild esbuild не
  поддерживает декораторы для target ES2020). main: dist/index.js, CommonJS.
- @types/passport-jwt в devDeps.

Cross-extension entity references (SharedCooperator @key + ResolveField)
живут в chatcoop subgraph'е (story 10.8) — там есть локальная сущность
ChatThread которая ссылается на пайщика. Этот PoC только доказывает
базовую механику.
2026-06-03 18:38:37 +00:00
coopops 883e597d40 [E10-2] feat(extension-sdk): @coopenomics/extension-sdk — Nest-template subgraph'а
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Epic 10 Story 10.2 — SDK для разработки backend-расширений Apollo Federation v2
subgraph'ов экосистемы кооператива.

Что внутри:
- ExtensionFederationModule.forRoot() — готовый GraphQL модуль с
  ApolloFederationDriver + autoSchemaFile.federation: 2
- ExtensionAuthModule.forRoot({ secret }) — passport-jwt стратегия,
  валидирует токен который Apollo Gateway forward'ит в Authorization-header.
- ExtensionJwtAuthGuard — guard для GraphQL resolver'ов
- SharedCooperator/SharedCooperative/SharedAccount — federation v2 stub'ы
  на core-entity для cross-extension references (@key + @external)
- HealthController GET /_health — обязательный для orchestrator'а
- loadExtensionConfig() — fail-fast загрузчик env'ов

Templates:
- Dockerfile multi-stage (builder + runtime + wget healthcheck)
- docker-compose.snippet.yaml — шаблон service'а для orchestrator'а

Контракт фиксирован Epic 10 PRD (apps-catalog/docs/epics/E10-federation-runtime.md)
и Architecture v3 (blago req 8b-architecture-v3-runtime-federation).
2026-06-03 18:33:24 +00:00
coopops f6b5c01030 [E10-1] feat(controller): переключение GraphQL на ApolloFederationDriver (subgraph mode)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Epic 10 Story 10.1 — coopback становится первым federation v2 subgraph'ом.
Локальная core-схема компилируется с federation директивами (`autoSchemaFile.federation: 2`).
Endpoint остаётся на /v1/graphql; non-federated клиенты (desktop) не должны
заметить переключения — Apollo Federation добавляет лишь служебные
`_service { sdl }` и `_entities` query.

Gateway-режим (Apollo Gateway + IntrospectAndCompose) зайдёт отдельной
story 10.1b — поверх этого subgraph'а + registry в Postgres. На MVP пока
один subgraph, gateway не нужен.

Зависимости:
- + @apollo/subgraph ^2 (peer для federation 2 в @nestjs/apollo)

@key директивы на core entity (User/Cooperative/Account/Wallet) добавятся
по мере вытаскивания первых extension'ов — отдельными точечными PR.
2026-06-03 18:24:51 +00:00
coopops cb014d7b10 [C28-33][@ant] refactor(capital-desktop): asset symbol/precision из системного стора + строгий тип generate-input
R4: убрал хардкод "RUB" и "1000.0000 RUB" из feature ProgramExpense.
 - submitTopup принимает raw число "10000" → форматирует через formatToAsset
   с info.symbols.root_govern_symbol/precision.
 - submitProgramExpense форматирует total_amount + planned_amount каждого item
   через тот же helper (UX-долг "amount без IMask" закрыт по сумме).
 - Placeholder'ы Dialog'ов вычисляются computed'ом из system.info.symbols.

R1 (частично): тип `GenerateStatementInput = Mutations.Expense.GenerateExpense
ProposalStatementDocument.IInput['data']` + деструктуризация результата через
computed `[Mutations.X.Y.name]` — убраны 2 из 4 `as any` каста (input + index).
Оставшиеся (signed-as-any и create-input-as-any) — отдельный refactor PR через
signed-doc adapter в shared/lib/document.

Source-wallet вынесен в локальную map SOURCE_WALLET_BY_OPERATION с throw на
неизвестный код — backend-derivation (R3) отдельной задачей.
2026-06-03 16:17:01 +00:00
coopops 9c29cc3260 [C28-33][@ant] refactor(common): buildPaginationResult helper для inter-port consumers
Вынес общий PaginationInput → PaginationResult helper из capital-service в
~/application/common/dto/pagination.dto. Consumer'ы inter-port'ов (без своего
Repository.findAndCount) получают канон-сборку без дубля логики totalPages/
currentPage.

Также `paginationInputToOffset(options)` — стандартная конверсия для адаптеров,
работающих offset-form (большинство inter-port read API).

Capital ProgramExpensesManagementService переведён на оба helper'а — ужал
listProgramExpenses до 10 строк.
2026-06-03 16:16:45 +00:00
coopops 4f15fc7c7c [C28-33][@ant] fix(capital): item-enum + canon-резолвер программных расходов
ProgramExpenseItemOutputDTO теперь отдаёт ExpenseMechanics / ExpenseRecipientType /
ExpenseItemStatus вместо magic Int — это явное нарушение canon'а GraphQL "enum
вместо строковых литералов / number". Сервис маппит number→enum зеркально
expenses-extension. Резолвер возвращает PaginationResult<T> через canon-сигнатуру
вместо «as never». Десктоп подменил локальные string-union'ы на Zeus enum
(ExpenseRecipientType / ExpenseMechanics / ExpenseProposalStatus).

Schema regen + Zeus regen + SDK build.
2026-06-03 14:29:14 +00:00
coopops bec306f97a [C28-33][@ant] fix(desktop-capital): привести страницу/диалоги к канону /_dev/ui
Why: использовал несуществующие токены/классы — UI бы рендерился со сломанными цветами/иконками. Привёл к канону:
- BaseChip: `:variant` (pos/accent/warn/neg/info/neutral), не `:label/:tone` (нет такого API).
- BaseButton: иконка через `<template #icon-left>`, не `icon='X'` (нет такого пропа).
- CSS-токены: `--p-ink`/`--p-ink-2` (не `--p-text`/`--p-muted`), `--p-fs-meta` (не `--p-fs-body-xs`).
- Утилитарные классы: `t-h2`/`t-sm`/`t-eyebrow`/`t-muted` из components.css, не `t-section`/`t-body-sm`.
2026-06-03 13:23:12 +00:00
coopops eb39e1a456 [C28-33][@ant] fix(expense): callback включает coopname — иначе capital::onpgexpdone не десериализуется
Why: send_callback_if_any слал (proposal_hash, status, amount, data), а capital::onpgexpdone ждёт (coopname, expense_hash, status, total_actual, data). Скоуп таблицы progexpenses = coopname, без него инициатор не найдёт запись. Применимо ко всем будущим consumer'ам шасси (marketplace, EMP).
2026-06-03 13:17:59 +00:00
coopops 1296b85199 [C28-33][@ant] fix(capital): маппинг enum→uint8 для mechanics/recipient_type — было Number('ADVANCE')=NaN
Why: ExpenseMechanics и ExpenseRecipientType — string enums; Number(str) даёт NaN, wharfkit не сериализует. Зеркалит маппинг из expenses-mutations.service.ts:57-63.
2026-06-03 13:15:08 +00:00
coopops 8a80c15fdb [C28-33][@ant] feat(desktop-capital): страница «Управление расходами программы»
Тонкая consumer-страница Капитала над шасси расходов. Доступ chairman+member.

  entities/ProgramExpense — store + api (Queries.Capital.GetProgramExpense{s}).
  features/ProgramExpense/CreateProgramExpense — useCreateProgramExpense:
    generateUniqueHash → reuse `generateExpenseProposalStatementDocument` шасси
    → подпись через DigitalDocument (registry 2010, Эпик 4 pipeline)
    → Mutations.Capital.CreateProgramExpense.
  features/ProgramExpense/TopupPool — useTopupProgramExpensePool:
    Mutations.Capital.TopupProgramExpense.
  pages/ProgramExpensesPage — list по канону mono-platform (BaseCard/Chip/
    EmptyState/--p-* токены), два диалога в шапке.
  install.ts — route `/:coopname/capital/program-expenses` (name
    `capital-program-expenses`, иконка receipt, agreementsBase, roles=
    chairman+member).
2026-06-03 13:05:12 +00:00
coopops d30e2ab025 [C28-33][@ant] feat(sdk): Zeus regen + Mutations/Queries.Capital.{Create,Topup,GetProgram}Expense
Регенерация после backend-расширения. Добавлены manual wrappers (SDK Mutations/
Queries.Capital — не auto-generated, обёртки над Zeus):

  Mutations.Capital.CreateProgramExpense (capitalCreateProgramExpense)
  Mutations.Capital.TopupProgramExpense  (capitalTopupProgramExpensePool)
  Queries.Capital.GetProgramExpenses     (capitalProgramExpenses + pagination)
  Queries.Capital.GetProgramExpense      (capitalProgramExpense by hash)

Селекторы:
  programExpenseSelector / programExpensesPaginationSelector
  + items/callback вложенные селекторы.
2026-06-03 12:30:47 +00:00
coopops c2b0e12115 [C28-33][@ant] feat(capital-backend): расходы программы через шасси (resolver+service)
Тонкое расширение Capital для программных расходов: чисто consumer над
шасси `expense` (только Капитал-специфика, остальное обслуживает шасси).

Чейн:
  - CapitalBlockchainPort.createProgramExpense → capital::createpgexp.
  - CapitalBlockchainPort.topupProgramExpense  → capital::topupprogexp.

Backend:
  - ProgramExpensesManagementService — write через capital, read через
    INTER_EXPENSE_CHASSIS (list по owner='capital'+action='onpgexpdone' +
    карточка по hash). Никакого собственного TypeORM-mirror.
  - ProgramExpensesResolver — chairman/member guards, mutation+query.
  - DTO: CreateProgramExpenseInputDTO (items+statement registry 2010),
    TopupProgramExpenseInputDTO, ProgramExpenseOutputDTO.
  - Регистрация в capital-extension.module.

actual_amount/status в createProgramExpense инициализируются 0/0 серверно
(их C++ дефолтит, но cooptypes требует все поля IItem).

PR2 (Капитал на шасси).
2026-06-03 12:12:58 +00:00
coopops 7323572bfc [C28-33][@ant] feat(cooptypes): capital программные расходы через шасси expense
Адаптация blagorost-cooptypes под архитектуру «capital = инициатор + callback,
шасси expense обслуживает весь flow». Из blagorost-E2 берётся минимальный набор
интерфейсов под программный расход, без debt/role/duplicate-action типов.

Изменения:
- ICreatepgexp: amount/description упрощены, добавлены operation_code+items[]
  (передаются inline в шасси expense::createexp).
- ITopupprogexp — без изменений (пополнение пула).
- IOnpgexpdone — НОВЫЙ callback от шасси, payload (hash, status, total_actual, data).
- IApprvpgexp/IAuthpgexp/IPgexppay/IDeclpgexp — УДАЛЕНЫ (шасси expense замещает).
- IProgramExpense table-interface — сохранён.
- import IItem из expense interface для типизации items[].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:03:57 +00:00
coopops 1c82bdebe4 [C28-33][@ant] feat(capital+expense): соединение шасси expense с capital через inline-action+callback
capital теперь только инициатор+получатель результата для программного расхода —
весь flow обслуживает шасси expense. Раньше blagorost-ветка дублировала
approve/auth/pay/decline на стороне capital.

Изменения capital:
- удалены apprvpgexp/authpgexp/pgexppay/declpgexp (дубль шасси).
- createpgexp переписан: вместо самообслуживания резервирует пул и шлёт inline
  expense::createexp с callback={capital, onpgexpdone}.
- сигнатура createpgexp принимает operation_code+items[] (передаётся в шасси).
- добавлен onpgexpdone(coopname, hash, status, total_actual, data) — callback от
  шасси на CLOSED/DECLINED, обновляет program_expense_pool и удаляет progexpense.

Изменения expense (шасси):
- callback payload расширен полем status (ExpenseDomain::ProposalStatus) — инициатор
  отличает CLOSED от DECLINED.
- declexp теперь шлёт callback тоже (раньше только closeexp).

Изменения names.hpp:
- удалён CAPITAL_RESOLVE_PROGRAM_EXPENSE / AUTHORIZE_PROGRAM_EXPENSE /
  DECLINE_PROGRAM_EXPENSE / CONFIRM_PROGRAM_EXPENSE_PAYMENT.
- добавлен ON_PROGRAM_EXPENSE_DONE.
- добавлен External::CREATE_EXPENSE_PROPOSAL для inline в шасси.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:03:57 +00:00
coopops 5660b06199 feat(capital/expense): расходы программы — createpgexp/apprvpgexp/authpgexp/pgexppay/declpgexp + таблица progexpenses
Story 1.5 эпика «Благорост» (blago issue 51F-17).

Полный цикл целевого расхода программы «Благорост» (не привязанного к проекту):
от заявки председателя до подтверждения оплаты gateway. Источник средств —
program_expense_pool (Story 1.6), резерв снимается окончательно в pgexppay.

PRD-B1: «createprogexp/approveprogex/authprogexp/progexppay/declprogexp». Имена
действий сокращены под 12-символьный лимит EOSIO — семантика та же.

Изменения:
- expenses.hpp: struct program_expense + таблица progexpenses (scope coopname)
  + helper'ы create/get/set_program_approved/set_program_authorized/delete
- 5 новых cpp actions в expense_managment/program_expenses/:
  - createpgexp — резервирует в State::reserve_program_expense, создаёт запись
  - apprvpgexp — председатель → soviet::create_agenda с CAPITAL_RESOLVE_PROGRAM_EXPENSE
  - authpgexp — совет → ::Gateway::create_outpay (через Action::send), статус AUTHORIZED
  - pgexppay — gateway callback → consume_program_expense + Ledger2::apply PAY_EXPENSE + delete
  - declpgexp — отказ от coopname/_soviet/_gateway → release_program_expense + delete
- names.hpp: CREATE_PROGRAM_EXPENSE, AUTHORIZE_PROGRAM_EXPENSE, DECLINE_PROGRAM_EXPENSE,
  CONFIRM_PROGRAM_EXPENSE_PAYMENT, SovietActions::CAPITAL_RESOLVE_PROGRAM_EXPENSE

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:03:57 +00:00
coopops d02c0ce66f feat(capital/state): program_expense_pool + topupprogexp
Story 1.6 эпика «Благорост» (blago issue 51F-18).

Программные расходы (Story 1.5) списываются из дедикейтнутого пула, а не из
аллокаций проектов. До этого коммита поля для такого пула в global_state не было.

Изменения:
- global_state.program_expense_pool — доступная сумма программы под расходы
- global_state.program_expense_reserved — зарезервированная под approved/authorized
  программные расходы (Story 1.5: createprogexp резервирует, declprogexp возвращает,
  exppaycnfrm списывает окончательно)
- State::topup_program_expense_pool / reserve_program_expense /
  release_program_expense / consume_program_expense — utility-функции для Story 1.5
- topupprogexp(coopname, amount) action — председатель переводит amount из
  global_available_invest_pool в program_expense_pool; деньги остаются на
  ledger2-кошельке BLAGOROST_FUND, меняется только разбивка по назначению

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:03:57 +00:00
coopops 67c5b96a18 [C28-33][@ant] feat(inter+expenses): порт InterExpenseChassisPort + adapter
Typecheck / desktop (pull_request) Failing after 7m14s
Typecheck / controller (pull_request) Failing after 6m35s
Шасси расходов получает inter-порт для consumer-extension'ов (capital,
marketplace, EMP). Порт — read-only: list-view и карточка proposal'а
по hash. Mutations approve/authorize/pay/report/close/decline остаются
прямыми GraphQL-endpoint'ами шасси, consumer их НЕ оборачивает.

inter:
  - tokens.ts: INTER_EXPENSE_CHASSIS = Symbol.for(...)
  - expense-chassis.port.ts: InterExpenseChassisPort + DTO (Proposal/Item/
    Callback/Status). Enum-статусов = зеркало доменного ExpenseProposalStatus.
  - index.ts: реэкспорт.

extensions/expenses (шасси-backend):
  - infrastructure/inter/expenses-inter-expense-chassis.adapter.ts —
    тонкий read через ExpenseProposalTypeormEntity. Проекция доменных
    полей в нейтральный inter-DTO, без зависимости inter → controller.
  - Регистрация adapter'а в expenses-extension.module + экспорт.

inter-communication-bridge.module:
  - Импорт ExpensesExtensionModule + provider INTER_EXPENSE_CHASSIS =
    useExisting ExpensesInterExpenseChassisAdapter.

PR1 (шасси-собственное), будет использовано в PR2 (Капитал на шасси).
2026-06-03 12:03:20 +00:00
claude 593ce63489 Merge pull request 'ci(typecheck): concurrency cancel-in-progress по PR-ветке' (#66) from fix/ci-typecheck-concurrency into dev 2026-06-03 09:40:28 +00:00
coopops 10e1075d5d ci(typecheck): concurrency cancel-in-progress по PR-ветке
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Новый push в PR отменяет ещё бегущий typecheck той же ветки —
прежде 30-минутные прогоны копились очередью.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:39:39 +00:00
coopops 1bca8cf34d [C28-32][@ant] feat(desktop+backend): подпись Classes.Document для CreateDialog/AuthorizePage + generate-mutations 2010/2011
Typecheck / desktop (pull_request) Failing after 6m29s
Typecheck / controller (pull_request) Failing after 6m30s
- Backend: новые mutations generateExpenseProposalStatementDocument (2010) и generateExpenseProposalDecisionDocument (2011) на канонe wallet (Throttle + GqlJwtAuthGuard + RolesGuard, через GeneratorInfrastructureService).
- DTOs документов 2010/2011: расширены вложенными типами header/items/decision-body (registry-Action поля проброшены в GraphQL).
- expenses-extension.module: подключён GeneratorInfrastructureModule.
- expenses-mutations.service: добавлены generateExpenseProposalStatementDocument/generateExpenseProposalDecisionDocument; spec обновлён под новый конструктор.
- SDK: handwritten Mutations.Expense.GenerateExpenseProposalStatementDocument/GenerateExpenseProposalDecisionDocument + index re-export.
- Desktop API: generateExpenseProposalStatementDocument/generateExpenseProposalDecisionDocument + authorizeExpenseReport wrappers.
- Desktop model: useExpenseProposalActions composable (submitProposal: generate→sign→createExpenseProposal; authorizeProposal: generate→sign→authorizeExpenseReport).
- ExpenseProposalCreateDialog.vue: полный flow подписи через DigitalDocument.sign; убран sha256-плейсхолдер; убран баннер «ждёт расшивки».
- ExpenseProposalAuthorizeDialog.vue: новый диалог решения председателя с автоматической генерацией+подписью документа 2011.
- ExpensesAdminAuthorizePage: добавлена кнопка «Авторизовать» рядом с «Открыть»; открывает диалог авторизации.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:32:08 +00:00
coopops bf73659100 [C28-31][@ant] feat(backend): расшивка createExpenseProposal/authorizeExpenseReport + capital-trigger event
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- Расширил ExpensesBlockchainPort до 8 actions (+createExp/authExp с document2).
- Adapter: реализованы createExp/authExp через BlockchainService.transact на ключе кооператива (canon с capital).
- DTO: CreateExpenseProposalInputDTO теперь несёт username/operation_code/source_wallet/items[]/callback/statement (signed document2 registry 2010); AuthorizeExpenseReportInputDTO — decision (signed document2 registry 2011).
- Service: убраны два NotImplementedException — mutation полностью пробрасывает payload в port.
- ExpensesCapitalTriggerService: TODO log заменён на emit платформенного события `expense::capitalization::required` с реальным payload (coopname/proposal_hash/username/total_actual/block_num); capital extension Эпика 5 подпишется и сделает capital::createrid.
- cooptypes/expense.IDocument2 синхронизирован с C++ struct (version/hash/doc_hash/meta_hash/meta/signatures).
- Spec'и адаптера/сервиса: добавлены кейсы createExp/authExp; удалён ожидавший 501 кейс.
- README expenses-extension: scaffold-stub → implemented.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:17:51 +00:00
coopops 067718cf1f [794-4][@ant] feat(desktop): экспорт документов в .sig v2.0 + manifest.json вместо signature.txt — самодостаточный доказательный архив (canonical как есть, legacy-канонизация, встроенный сертификат) для верификатора
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
2026-06-03 08:02:59 +00:00
coopops 28ce608efe [794-4][@ant] feat(cooptypes): канонический тип SigFile v2.0 + PackageManifest для верификатора — единый источник формата .sig (целостность+подпись+OID), чтобы desktop-экспорт и верификатор ссылались на один контракт 2026-06-03 07:58:28 +00:00
coopops 8eb3996895 [C28-32][@ant] feat(desktop): ExpenseProposalCreateDialog — форма-конструктор СЗ с массивом items на canon
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
BaseDialog (size=lg) с BaseInput / q-select (recipient_type, mechanics) + динамическим массивом item-card'ов и валидацией canSubmit.

При submit:
- canonical JSON.stringify полей payload → SHA-256 через crypto.subtle (детерминированный proposal_hash);
- Mutations.Expense.CreateExpenseProposal({ coopname, proposal_hash }) — бэкенд резервирует placeholder, далее signature-pipeline document2.

Info-баннер информирует пайщика что подача откроется после Эпика 2.

7/7 страниц #104 расшито. Mutation-цепочка одобрить/авторизовать/оплатить — следующий шаг после расшивки document2 signature-pipeline.
2026-06-03 07:40:15 +00:00
coopops 530136510f [C28-32][@ant] feat(desktop): CashierPage — 4 таба кассы на canon (Queries.Expense.ExpenseProposalsByCooperative)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
PageTabs с 4 вкладками: К оплате (AUTHORIZED) · Ждут отчёта (PARTIALLY_PAID) · На авторизации (REPORT_SUBMITTED) · Закрытые (CLOSED). Счётчики по факту items в загруженной выборке.

Структура таблицы — тот же canon: TableSkeleton + .table-wrap + .table-foot + EmptyState (per-tab title/body). Действия (оплатить + чек → MinIO, авторизовать отчёт) появятся после расшивки Эпика 2 signature-pipeline.

6/7 страниц #104 расшито. Остался только ExpenseProposalCreateDialog (mutation create — заблокирован document2 stub).
2026-06-03 07:37:27 +00:00
coopops ecd0d7e66a [C28-32][@ant] feat(desktop): ExpenseDetailPage — экран доказательств на canon (proposal + items + docs + files)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Двухколоночный layout:
- Левая: BaseCard «Сводка» (DataRow per поле) + BaseCard «Строки расходов» (items table + статус-бейдж).
- Правая: BaseCard «Документы» (statement_doc 2010, decision_doc 2011) + BaseCard «Чеки» (ExpenseFiles c MinIO storage_key).

Источники:
- Queries.Expense.ExpenseProposal — по proposal_hash из route.
- Queries.Expense.ExpenseFilesByProposal — по coopname+proposal_hash.

Маппинг item-статусов и mechanics в model/expenseItemStatus.ts (label+variant).

5/7 страниц #104 расшито.
2026-06-03 07:35:05 +00:00
coopops 5c5657149e [C28-32][@ant] feat(desktop): председательские очереди Approve/Authorize на canon
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
ExpensesAdminApprovePage — таблица proposals в status=CREATED (in-memory filter после ExpenseProposalsByCooperative).
ExpensesAdminAuthorizePage — таблица proposals в status=REPORT_SUBMITTED (тот же подход).

Обе по канону: PageHead + TableSkeleton + .table-wrap + .table-foot + EmptyState + переход в /expenses/:hash.
Mutation-действия (одобрить/авторизовать) появятся после расшивки Эпика 2 signature-pipeline на document2.

4/7 страниц #104 расшито.
2026-06-03 07:31:02 +00:00
coopops 68122f5d32 [C28-32][@ant] feat(desktop): MyAdvancesPage через Queries.Expense.ExpenseProposalsByMember — реестр своих авансов на canon
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Table-based страница «Мои авансы» по тому же канону что ExpensesRegistryPage:
PageHead + TableSkeleton + .table-wrap + BaseBadge статуса + .table-foot + EmptyState.
Источник — useSessionStore().username + route.params.coopname.

Thin API: api/queries/getExpenseProposalsByMember.ts на Queries.Expense.ExpenseProposalsByMember.

2/7 страниц #104 расшито.
2026-06-03 07:28:33 +00:00
coopops e7b7770c4a [C28-32][@ant] feat(desktop): расшивка ExpensesRegistryPage через Queries.Expense.ExpenseProposalsByCooperative
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- api/queries/getExpenseProposalsByCooperative — thin wrapper над SDK,
  возвращает PaginatedExpenseProposals (типизировано через IInput/IOutput).
- api/index.ts — barrel-экспорт.
- model/expenseProposalStatus — label/variant maps для 7 значений
  Zeus.ExpenseProposalStatus (CREATED/AUTHORIZED/PARTIALLY_PAID/REPORT_SUBMITTED/
  CLOSED/DECLINED/UNDEFINED) + helpers с дефолтом neutral.
- ExpensesRegistryPage.vue: pug + .table-wrap canon, TableSkeleton(6 cols),
  EmptyState (receipt_long 48px), BaseBadge с variant из map, BaseButton ghost
  "Загрузить ещё", монохешчик в .t-mono-sm. coopname из route.params,
  sortBy=created_at DESC, page-by-page до hasMore.

Open: tooltip с FQH хеша, sort по колонкам, filter-bar по статусу — будут
вместе со страницами /expenses/admin/{approve,authorize} и /my/advances
(своя сигнатура запроса by-member).
2026-06-03 07:23:29 +00:00
coopops bfdabcfded [C28-31][@ant] feat(sdk): expense namespace — 8 mutations + 6 queries + selectors
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Mutations.Expense: CreateExpenseProposal / PayExpenseItem / ReportExpenseItem /
ReturnExpenseItem / OverspendExpenseItem / SubmitExpenseReport /
AuthorizeExpenseReport / DeclineExpenseReport.

Queries.Expense: ExpenseProposal / ExpenseProposalsByCooperative /
ExpenseProposalsByMember / ExpenseFile / ExpenseFilesByItem /
ExpenseFilesByProposal.

Selectors.expense*: ExpenseProposal (с items + statement/decision DocumentAggregate),
ExpenseItem, ExpenseFile, ExpenseProposalsPagination.

Zeus regen + re-exports в mutations/queries/selectors/index.ts.

Разблокирует #103 backend хвост и #104 UI расшивку 7 страниц end-to-end.
2026-06-03 07:18:45 +00:00
coopops a7bd5aae5c [C28-31][@ant] feat(controller): createExpenseProposal stub — ждёт document2 statement_doc
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
8-я mutation симметрично authorizeExpenseReport: NotImplementedException
со ссылкой на statement_doc (type=2010) от signature-pipeline UI Эпика 2.
Регистрирует GraphQL-сигнатуру end-to-end, чтобы SDK + UI видели mutation
до подключения document2.

jest 8/8 passed.
2026-06-03 06:17:07 +00:00
coopops 33d8163d08 [C28-31][@ant] test(controller): contract-spec adapter — formats transact для 6 actions
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
7 тестов проверяют что каждое из 6 действий expense-контракта уходит в
BlockchainService.transact с правильным форматом:
  account = ExpenseContract.contractName.production
  name    = ExpenseContract.Actions.<X>.actionName
  authorization = [{actor: coopname, permission: 'active'}]

Защита от дрейфа cooptypes actionName → adapter имя. Также verify
HttpApiError 502 при отсутствии WIF в vault'е (initialize/transact не зовутся).

jest 7/7 passed.
2026-06-03 05:51:24 +00:00
coopops 531b9574fe [C28-31][@ant] feat(controller): добавляю overspendExp — 6-я работающая mutation
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Доплата при перерасходе (expense::overspendexp, ADVANCE-механика):
- OverspendExpenseItemInputDTO (coopname/proposal_hash/item_hash/overspend_amount)
- ExpensesBlockchainPort.overspendExp + adapter (transact с coop-ключом)
- ExpensesMutationsService.overspendExpenseItem
- ExpenseMutationsResolver.overspendExpenseItem (@AuthRoles chairman)
- spec: 7-й тест action-mapping

Теперь 6 из 8 actions контракта expense работают через port:
payexp / reportexp / returnexp / overspendexp / closeexp / declexp.
createexp + authexp ждут document2 от signature-pipeline UI Эпика 2.

tsc EXIT=0, jest 7/7 passed.
2026-06-03 05:49:42 +00:00
coopops fbfd0a259e [C28-32][@ant] feat(desktop): регистрация expenses extension в registry
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Шасси расходов подключено к init-installed-extensions:
- expenses → expensesInstall в extensionsRegistry
- install.ts: убран TODO-комментарий о блокировке C28-31

Workspace 'expenses' (icon: receipt_long) с 6 routes (registry/admin/cashier/my)
становится доступен ролям chairman + user из конфигурации кооператива.
Stub-страницы внутри ждут расшивки через SDK Zeus после generate-client.
2026-06-03 05:46:00 +00:00
coopops b4dd4b335a [C28-31][@ant] feat(controller): расшивка 5 mutations через ExpensesBlockchainPort
Typecheck / desktop (pull_request) Successful in 13m18s
Typecheck / controller (pull_request) Successful in 13m12s
После Эпика 0 cooptypes/expense — подключаю реальный chain-submit через
port+adapter (канон CapitalBlockchainAdapter):

- ExpensesBlockchainPort: 5 actions без document2 (payexp/reportexp/returnexp/closeexp/declexp)
- ExpensesBlockchainAdapter: BlockchainService.transact + vault-ключ кооператива
- ExpensesMutationsService DI на port — 5 мутаций работают, authorize ждёт decision_doc Эпика 2
- spec обновлён: 6 тестов через mocked port, action-mapping verify

createexp + authexp ждут signature-pipeline UI Эпика 2 (document2 type=2010/2011).
overspendexp подключится с UI Phase 2.

tsc EXIT=0, jest 6/6 passed.
2026-06-03 05:29:41 +00:00
coopops da50e423ca [9.4.b] feat(controller+desktop): реальный remote-loader fetch+eval install.js
Typecheck / desktop (pull_request) Successful in 13m24s
Typecheck / controller (pull_request) Successful in 13m19s
mono controller:
- AppsCatalogHttpService.fetchInstallScript(scope, name) — fetch text
  install.js из ca-admin /v1/public/packages/:scope/:name/install.js;
  degraded mode (null) при отсутствии env-config'а apps-catalog'а.
- Новый REST controller AppsCatalogInstallScriptController
  `GET /v1/apps-catalog/install/:scope/:name` под HttpJwtAuthGuard:
  валидирует scope/name regex'ом, проксирует на ca-admin, отдаёт
  text/javascript + Cache-Control: no-store. Admin-API key остаётся
  на backend'е.
- AppsCatalogProxyModule: добавлен controller в module.controllers[].

mono desktop:
- remote-loader.ts: реальный fetch + CJS-eval. fetchInstalledRemotePackages
  читает Queries.Extensions.AppsCatalogRemotePackages, парсит packageId
  на (scope, name). loadRemoteExtension качает install.js по REST через
  axios responseType:'text' с JWT desktop'а, оборачивает в
  `new Function('module','exports','require', code)`, вызывает
  `module.exports.install()` → IWorkspaceConfig[].
- Try/catch per item (NFR15-стиль): падение одного пакета не валит
  остальные. require() сразу throw — пакеты должны быть CJS без
  внешних зависимостей (sandbox — Story 11.x).
- sha256 пока не проверяем (release-таблица будет в 9.4.c).

Demo path: voskhod publishes @voskhod/demo-app → ca-admin отдаёт
examples/demo-app/install.js → controller-proxy → desktop boot тянет +
eval'ит + получает workspace «Demo» с роутом demo-hello.
2026-06-03 05:26:05 +00:00
coopops 2fcbe3612f [C28-29][@ant] feat(cooptypes): Эпик 0 — типы контракта expense (8 actions + proposals table)
Typecheck / desktop (pull_request) Successful in 13m22s
Typecheck / controller (pull_request) Successful in 13m5s
- interfaces/expense.ts: 8 action input + IItem/IProposal/ICallbackHandler (hand-written из expense.hpp до ABI regen)
- contracts/expense/{actions,tables,index}: тонкие обёртки в стиле capital — authorizations + actionName + типовой re-export
- common/names: _expense ({production:'expense',testnet:'expense'})
- interfaces/index + contracts/index: re-export ExpenseContract
- ledger2/wallets.generated: +w.exp.adv (prebuild gen-from-cpp подцепил свежий wallet)

build EXIT=0, dist exports ExpenseContract; smoke node: contractName=expense, 8 actions, 1 table
2026-06-03 05:18:06 +00:00
coopops ece4d59c56 [C28-31][@ant] test(controller): smoke-контракт mutations — все 6 методов throw NotImplementedException до Эпика 0
Typecheck / desktop (pull_request) Successful in 13m29s
Typecheck / controller (pull_request) Successful in 13m30s
2026-06-03 05:02:44 +00:00
coopops 45def1bbec [9.5.b] feat(sdk+desktop): Zeus client + ExtensionsShowcase реальный fetch apps-catalog
Typecheck / desktop (pull_request) Failing after 7m27s
Typecheck / controller (pull_request) Failing after 7m36s
- sdk/zeus: regenerated через `controller generate-schema && generate-client`
  + `pnpm -F sdk build`; типы AppsCatalogRemotePackageDTO и
  Query.appsCatalogRemotePackages пробрасываются end-to-end.
- sdk/selectors/extensions: новый appsCatalogRemotePackageSelector с
  валидатором MakeAllFieldsRequired.
- sdk/queries/extensions: новый namespace Queries.Extensions.AppsCatalogRemotePackages
  (page/pageSize → AppsCatalogRemotePackageDTO[]).
- desktop/entities/Extension/api: api.loadAppsCatalogRemotePackages(page, pageSize)
  по канону client.Query + строгие типы из @coopenomics/sdk.
- desktop/pages/ExtensionStore/ExtensionsShowcase: убран inline-mock,
  каталог загружается через Zeus query. Добавлены loading/error/empty
  состояния по дизайн-канону (--p-*, base-card токены), плюсификация
  слова «пакет», version-чип и Intl-форматирование цены.

Это шаги 3–4 V2 9.5.b. Закрывает task #38 и #37.
2026-06-03 04:52:20 +00:00
coopops ce01d48df7 [C28-31][@ant] test(controller): unit-тест capital-trigger — фильтр CLOSED + total_actual
Typecheck / desktop (pull_request) Successful in 13m20s
Typecheck / controller (pull_request) Successful in 12m56s
Покрывает 5 кейсов: REPORT_SUBMITTED/AUTHORIZED/DECLINED → no-op;
CLOSED без total_actual → warn; CLOSED + total_actual → log с
TODO-ссылкой на Эпик 0. Защищает контракт триггера на будущее, когда
skeleton будет заменён на реальный capital::createrid через
WriteMutationPool.
2026-06-03 04:48:45 +00:00
coopops cb22349401 [C28-31][@ant] feat(controller): authorize/decline СЗ-отчёта — skeleton mutations
Typecheck / desktop (pull_request) Successful in 13m52s
Typecheck / controller (pull_request) Successful in 13m11s
GraphQL surface: authorizeExpenseReport (председатель утверждает,
триггерит capital-trigger → capitalization Благороста) +
declineExpenseReport (с reason, переход в DECLINED, capitalization
НЕ запускается). Skeleton — NotImplementedException, chain-submit
после Эпика 0 (cooptypes regen для expense::authexp / declineexp).
2026-06-03 04:34:22 +00:00
coopops a6d6b0af2f [9.5.b] fix(apps-catalog-proxy): явный type fn для nullable Field + .env.example
Typecheck / desktop (pull_request) Failing after 7m15s
Typecheck / controller (pull_request) Failing after 7m21s
При nullable=true декоратор @Field NestJS GraphQL не умеет вывести тип из
TS-reflection-метаданных (TS пишет в metadata-ключ Object). Без явного
type fn (() => String) — UndefinedTypeError на старте.

Фикс:
- @Field({ nullable: true }) → @Field(() => String, { nullable: true })
  для lastActiveVersion: string | null.
- .env.example добавлены APPS_CATALOG_URL и APPS_CATALOG_API_KEY с
  готовыми значениями для dev (URL = DNS-имя ca-admin в mono-shared
  сети).

Verify: coopback стартует чисто (Nest application successfully started),
DTO виден через introspection (__type AppsCatalogRemotePackageDTO →
поля packageId/publisher/title/description/rubPerMonth/lastActiveVersion/
compatibleSubnets), guard срабатывает (без JWT → 401 Unauthorized).
2026-06-02 22:00:59 +00:00
coopops 5a2fc336e9 [9.5.b] feat(controller): apps-catalog-proxy — резолвер appsCatalogRemotePackages
Typecheck / desktop (pull_request) Failing after 7m39s
Typecheck / controller (pull_request) Failing after 7m23s
Story 9.5.b mono часть: добавляет модуль расширения
`apps-catalog-proxy` с GraphQL Query.appsCatalogRemotePackages, который
проксирует запрос на apps-catalog/ca-admin /v1/public/packages.

Архитектурно — выбран альтернатива C (см. apps-catalog
docs/epics/E9-demo-app-pilot.md, V2 follow-ups): браузер ходит в каталог
через mono controller, а не напрямую в ca-admin. Поэтому:
- admin-secret apps-catalog'а живёт в env controller'а, не в SPA;
- нет CORS — desktop запрос на свой же origin;
- единый канон mono GraphQL (никаких raw HTTP в desktop).

Компоненты:
- AppsCatalogRemotePackageDTO (@ObjectType): packageId/publisher/
  compatibleSubnets/lastActiveVersion + UI-поля title/description/
  rubPerMonth (в V1 description и rubPerMonth — заглушки; в V2 будут из
  package manifest и apps-catalog pricing).
- AppsCatalogHttpService: axios-клиент к ca-admin с Authorization: Bearer
  admin-key из APPS_CATALOG_API_KEY. Degraded mode (пустой список без
  ошибки), если env пустой — чтобы dev/CI без apps-catalog не валили
  desktop boot.
- AppsCatalogProxyResolver: @Query, @UseGuards(GqlJwtAuthGuard) — каталог
  видят только авторизованные пайщики.
- Регистрация в ExtensionsModule.

Config:
- Новые env APPS_CATALOG_URL и APPS_CATALOG_API_KEY (оба optional).
- Зеркальные поля в config.apps_catalog.{url, api_key}.

TODO (требует ручного запуска на остановленном контейнере, см. CLAUDE.md:
'pnpm generate-schema / generate-client — не запускать локально'):
- pnpm -F controller generate-schema → пересоздать schema.gql.
- pnpm -F controller generate-client → graphql-zeus в SDK.
- pnpm -F sdk build → unbuild dist.
Без codegen фронт ещё не сможет дёрнуть резолвер через Zeus.
2026-06-02 21:55:19 +00:00
coopops f1dd62cfce [C28-31][@ant] feat(controller): capital-trigger skeleton — capitalization Благороста на CLOSED
Typecheck / desktop (pull_request) Successful in 13m27s
Typecheck / controller (pull_request) Successful in 13m2s
ExpenseProposalSyncService override handleSyncDelta → emit
`entitysynced::expense::proposals` после save (per-contract pubsub,
canon controller/CLAUDE.md). ExpensesCapitalTriggerService подписан,
проверяет переход в CLOSED + total_actual → log skeleton. Прямой
DI на CapitalExtensionModule ждёт Эпика 0 (cooptypes regen +
WriteMutationPool для capital::createrid-эквивалента).
2026-06-02 21:45:42 +00:00
coopops f24e3fffbf [C28-31][@ant] feat(controller): DocumentAggregate для statement_doc/decision_doc СЗ
Typecheck / desktop (pull_request) Successful in 13m46s
Typecheck / controller (pull_request) Successful in 13m10s
- `ExpensesManagementService.buildProposalDocumentAggregates(entity)` собирает
  оба подписанных дока (заявление + решение) через `DocumentAggregationService`
  (платформенный domain-сервис, `~/domain/document`); по EMPTY_HASH (`0x000…`)
  возвращает `null` — это валидное состояние chain-mirror'а, отличающееся от
  `undefined` («дока нет / зеркало ещё не пришло»).
- `ExpenseProposalOutputDTO.statement_doc` / `decision_doc` теперь типизированные
  `DocumentAggregateDTO | null` — UI сразу видит подпись/хэш/raw-документ
  единообразно с agreement/agenda/meet резолверами. Boolean-флаги `has_*_doc` сняты.
- `ExpenseProposalResolver` подключает агрегаты:
  - одиночный fetch — последовательно;
  - paginated — `Promise.all` по странице (10–20 смет на запрос — OK).
- `ExpensesExtensionModule` импортирует `DocumentDomainModule` для DI.

controller-tsc — 0 ошибок.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:15:01 +00:00
coopops 45a9381c4b [C28-31][@ant] feat(controller): submitExpenseReport + pagination для списков смет
Typecheck / desktop (pull_request) Successful in 13m19s
Typecheck / controller (pull_request) Successful in 13m4s
- `SubmitExpenseReportInputDTO` + `ExpenseMutationsResolver.submitExpenseReport`
  (chairman/member/user) — финализация СЗ-отчёта пайщиком когда все items закрыты;
  сервис — skeleton `NotImplementedException`, реальный chain-submit (`expense::closeexp`)
  подключим после Эпика 0 (cooptypes regen).
- `findByCoopnamePaginated` / `findByUsernamePaginated` в `ExpenseProposalRepository`
  + TypeORM-имплементация на `findAndCount` (sort по `_created_at DESC`, limit ≤200).
- `listProposalsByCooperativePaginated` / `ByMemberPaginated` в service'е.
- `ExpenseProposalResolver` — paginated по канону controller'а
  (`PaginationInputDTO` + `createPaginationResult('PaginatedExpenseProposals')`).

controller-tsc — 0 ошибок.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 20:45:36 +00:00
coopops 25e2d99046 [C28-31][@ant] feat(controller): expense files service + upload mutation
Typecheck / desktop (pull_request) Successful in 13m17s
Typecheck / controller (pull_request) Successful in 12m50s
- ExpenseFilesService с @UseBucket(EXPENSES_BUCKET):
  upload (base64 → SHA-256 verify → MinIO put → БД-запись),
  getReadUrl, listByProposal/Item, deleteFile;
  детерминированный ключ MinIO {coopname}/{proposal}/{item|_proposal}/{kind}/{checksum}.{ext};
  409 на повтор по checksum (per-coopname idempotence).
- UploadExpenseFileInputDTO: валидация mime allowlist + size_bytes + SHA-256 regex.
- ExpenseFileOutputDTO с короткоживущим read_url (HMAC-signed, TTL бакета).
- ExpenseFilesResolver: uploadExpenseFile + expenseFile/expenseFilesByProposal/ByItem.
- FileStorageInfrastructureModule.forFeature([ExpenseFilesService]) wire.

tsc --noEmit: 0 ошибок.
2026-06-02 20:16:16 +00:00
coopops 237cd19e62 [9.5.a] feat(ExtensionStore): inline-моки remote-витрины demo
Typecheck / desktop (pull_request) Failing after 7m18s
Typecheck / controller (pull_request) Failing after 7m14s
Секция «Удалённые пакеты» с карточкой @voskhod/demoapp (publisher,
price 1000 ₽/мес) + dialog «Подписаться» (V1 — заглушка).

Why: Анна видит реальный второй раздел, отделённый от bundle-расширений,
и проходит UX-сценарий клика «Подписаться» — без backend-резолверов.
Реальный список remoteExtensions через Query.appsCatalogRemotePackages
+ Mutation.appsCatalogSubscribe — отложено в 9.5.b (см. mono#64
follow-ups).

Стили — токены MONO Design System (--p-*), никаких magic hex.
2026-06-02 20:10:34 +00:00
coopops 9c8449408f [C28-31][@ant] feat(controller): expense mutation DTO + skeleton resolver
Typecheck / desktop (pull_request) Successful in 13m1s
Typecheck / controller (pull_request) Successful in 13m1s
- pay/report/return-expense-item.input.ts с asset-regex валидацией
- ExpensesMutationsService: skeleton с throw NotImplementedException
  (chain-submit через write-mutation pool подключится после Эпика 0
  cooptypes regen для контракта expense)
- ExpenseMutationsResolver: 3 Mutation (chairman / member+user)
  возвращают TransactionDTO — sigнатура зафиксирована в схеме сразу

GraphQL: payExpenseItem (chairman), reportExpenseItem (chairman/member/user),
returnExpenseItem (chairman/member/user).

tsc --noEmit: 0 ошибок.
2026-06-02 19:44:26 +00:00
coopops da0690bca1 [9.5] feat: Epic 9 hint-banner в pages/ExtensionStore/ExtensionsShowcase
Typecheck / desktop (pull_request) Failing after 7m22s
Typecheck / controller (pull_request) Failing after 7m15s
V1 минимум для UI ExtensionStore:
- Информационный banner сверху grid'а карточек, объясняющий что bundle-
  extensions ниже бесплатные, а remote-пакеты с подпиской появятся
  на следующем шаге пилота.
- Реальная карточка remote-пакета с кнопкой «Подписаться» / «Уже
  подписан до DD.MM» — story 9.5.b (требует расширения extStore
  на kind/subscribed/pricingRubPerMonth + backend-резолверы coopback'а).

Refs: docs/integration/native-to-catalog-research.md §9 story 9.5.
2026-06-02 19:21:56 +00:00
coopops 9f6d2dc699 [9.3][9.4] feat: стол разработчика + remote-loader каркас в desktop
Typecheck / desktop (pull_request) Failing after 7m22s
Typecheck / controller (pull_request) Failing after 7m21s
9.3 — components/desktop/extensions/developer/:
- install.ts: workspace `developer`, defaultRoute=developer-my-packages,
  4 страницы (packages, publish, subscribers, pricing), все under
  meta.roles=['chairman']. Регистрация в extensions-registry.ts.
- pages/: V1 Vue-заглушки с описанием TODO (без реальных мутаций).
  Mutation flow (POST /v1/admin/package, /releases, /pricing с подписью
  chairman'а) — story 9.3.b.

9.4 — processes/init-installed-extensions/remote-loader.ts:
- Контракт fetch/load для remote-пакетов с активной подпиской: тип
  RemoteExtensionDescriptor, функции fetchInstalledRemotePackages,
  loadRemoteExtension, installRemoteExtensions.
- V1 — заглушка возвращающая []. Не падает, логирует пропуск.
- index.ts: после bundle-pass — remote pass через installRemoteExtensions;
  try/catch чтобы падение remote-flow не валило bundle extensions.
- TODO 9.4.b: реальный fetch к coopback + tar-x + dynamic require
  + sha256-check + (V2 sandbox).

Видимость стола: пока через meta.roles в install.ts. Полноценная
авторизация через DesktopWorkspace.grants с флагом is_operator_coop —
9.3.b/9.5.

Refs: /home/admin/mono-ai-5/docs/integration/native-to-catalog-research.md §9.
2026-06-02 19:20:57 +00:00
coopops e248ddb2b0 [C28-31][@ant] feat(controller): expense read resolver + output DTO
Typecheck / desktop (pull_request) Successful in 13m9s
Typecheck / controller (pull_request) Successful in 12m48s
- ExpenseProposalOutputDTO + ExpenseItemOutputDTO + fromDomain mapper
- mechanics/recipient_type/item_status: uint8 → enum
- ExpenseProposalResolver — 3 query (byHash / byCooperative / byMember)
- Auth: chairman/member для списка по коопу, +user для своих
2026-06-02 19:15:14 +00:00
coopops 89f7ded6fc [C28-31][@ant] feat(controller): expenses syncer + management read-service
Typecheck / desktop (pull_request) Successful in 13m8s
Typecheck / controller (pull_request) Successful in 12m45s
- ExpenseProposalSyncService — extends AbstractEntitySyncService, OnEvent fork::*
- ExpensesManagementService — read-only (proposalByHash / byCoop / byMember)
- DI wire в ExpensesExtensionModule
2026-06-02 18:58:28 +00:00
coopops 9359e6b324 [C28-31][@ant] feat(controller): expenses repositories + database module + DI wire
Typecheck / desktop (pull_request) Successful in 13m18s
Typecheck / controller (pull_request) Successful in 12m50s
ExpenseProposalTypeormRepository extends BaseBlockchainRepository (sync_key
proposal_hash, findByCoopname/findByUsername/findByProposalHash).
ExpenseFileTypeormRepository — БД-only, поиск по checksum/proposal/item.
ExpensesDatabaseModule — TypeOrm.forFeature на 2 зеркала + EntityVersion.
ExpensesExtensionModule подаёт EXPENSE_PROPOSAL_REPOSITORY / EXPENSE_FILE_REPOSITORY
через useClass; EntityVersioningService приходит из @Global() TypeOrmModule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:54:12 +00:00
coopops e8f5d7604e [C28-31][@ant] feat(controller): expenses infra — typeorm-зеркала, mappers, delta-mapper
Typecheck / desktop (pull_request) Successful in 13m14s
Typecheck / controller (pull_request) Successful in 12m58s
ExpenseProposalTypeormEntity (proposal_hash sync_key, items/docs в jsonb),
ExpenseFileTypeormEntity (БД-only, unique по coopname+checksum).
ExpenseProposalMapper TypeORM↔Domain (toUpdateEntity = только block_num/present
по канону: блокчейн-поля обновляются через AbstractEntitySyncService).
ExpenseFileMapper — symmetric copy.
ExpenseContractInfoService — паттерны таблиц для parser2.
ExpenseProposalDeltaMapper extends AbstractBlockchainDeltaMapper, lowercase
proposal_hash, signed-docs через DomainToBlockchainUtils.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:44:24 +00:00
coopops f7a5defa95 [C28-31][@ant] feat(controller): expenses domain — interfaces, entity, repo tokens
Typecheck / desktop (pull_request) Successful in 13m15s
Typecheck / controller (pull_request) Successful in 12m47s
ExpenseProposalDomainEntity на BaseDomainEntity (зеркалит ExpenseDomain::proposal)
+ IExpenseProposalBlockchainData/DatabaseData, IExpenseItemBlockchainData/DatabaseData,
IExpenseFileDatabaseData (только БД, файл в MinIO).
Repository-интерфейсы + DI-токены EXPENSE_PROPOSAL_REPOSITORY, EXPENSE_FILE_REPOSITORY.
mapStatusToDomain зеркалит uint8_t status, неизвестное → UNDEFINED.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:34:28 +00:00
coopops c18828c534 docs(apps-catalog): Epic 9 MVP demo-app pilot — раскладка решений и stories
Typecheck / desktop (pull_request) Successful in 13m20s
Typecheck / controller (pull_request) Successful in 12m58s
Финальные решения по V1 пилоту (без миграции native):
- demo-app = пустой Hello-стол, @voskhod/demo-app
- Pricing 1000 RUB/мес (60 сек на dev), trial 15 мин
- partner1 как покупатель через pnpm boot:extra (на той же цепи)
- Self-subscription bypass: воскод как оператор подписан бесплатно
- Без кэша tarball'ов — каждый desktop boot качает заново
- Стол Разработчика = новый bundle-extension developer, виден chairman оператора
- partner1 desktop+coopback в отдельном docker overlay из apps-catalog

Epic 9 раскладка на 8 stories с зависимостями. Реализация — следующими
коммитами в зонтичной ветке apps-catalog mono + feat/E9-demo-app-pilot
в apps-catalog репо.
2026-06-02 18:30:18 +00:00
coopops 1c46fd0338 [C28-31][@ant] feat(controller): scaffold expenses extension — enum'ы + bucket + module wiring
Typecheck / desktop (pull_request) Successful in 13m4s
Typecheck / controller (pull_request) Successful in 13m4s
Шаг 1 из C28-31 (backend шасси расходов):
- 5 enum'ов (registerEnumType): ProposalStatus / ItemStatus / Mechanics / RecipientType / FileKind — зеркалят expense.hpp
- EXPENSES_BUCKET (constants/expenses-bucket.ts): MinIO-bucket spec для платёжек/чеков/возвратов
- ExpensesExtensionModule (минимальный) подключён в ExtensionsModule.register() (не desktop-расширение, без записи в AppRegistry)

Дальше: domain entities (composite db/bc/derived), TypeORM-зеркало, delta-mapper, sync-service, resolver. Реальная sync-инфра — после ABI regen контракта expense.
2026-06-02 18:24:07 +00:00
coopops 8897a93e83 [C28-29][@ant] feat(contracts): новый контракт expense — 8 actions, шасси расходов
Typecheck / desktop (pull_request) Successful in 13m4s
Typecheck / controller (pull_request) Successful in 12m44s
components/contracts/cpp/expense/:
- expense.hpp: 1 таблица proposals (scope=coopname; secondary by_hash/by_username/by_status),
  типы item, callback_handler, enum Mechanics/RecipientType/ProposalStatus/ItemStatus.
- expense.cpp: 8 actions (createexp/authexp/declexp/payexp/reportexp/closeexp/returnexp/overspendexp).
- CMakeLists.txt: стандартный add_contract(expense expense expense.cpp).

components/contracts/cpp/lib/consts.hpp:
- _expense = expense_n + добавлен в contracts_whitelist.

components/contracts/CMakeLists.txt:
- add_contract_build(expense).

Принцип v3:
- Один процесс p.exp.expns, 5 ledger2 operation_codes (o.exp.{blgadv,blgdir,advrpt,advret,over}).
- createexp принимает operation_code+source_wallet+callback в payload — контракт агностичен к источнику.
- payexp на DIRECT-механике сразу помечает item REPORTED (BURN blago = один полный шаг).
- payexp на ADVANCE — отдельный reportexp пайщиком после чека.
- closeexp шлёт inline action на сохранённый callback (contract,action,data) если он непустой.
- overspendexp — две последовательные Ledger2::apply (OVERSPEND + ADVANCE_REPORT) в одной транзакции.
2026-06-02 18:17:12 +00:00
coopops 87834c4e56 docs(apps-catalog): research-доклад по миграции native extensions в каталог
Typecheck / desktop (pull_request) Successful in 13m21s
Typecheck / controller (pull_request) Successful in 12m43s
2026-06-02 18:04:20 +00:00
coopops 42165ec3e8 [C28-28][@ant] feat(ledger2): шасси расходов — 1 кошелёк, 1 процесс, 5 операций
Typecheck / desktop (pull_request) Successful in 13m21s
Typecheck / controller (pull_request) Successful in 13m6s
wallets.hpp:
- w.exp.adv ADVANCE_HOLD (USER_SHARED) — кошелёк-резерв подотчёта пайщика
- mapping без program-gate: source-программа проверяется контрактом expense

processes.hpp:
- p.exp.expns PROPOSAL — единый процесс расхода по СЗ

operations.hpp (namespace operations::expense):
- o.exp.blgadv BLAGO_ADVANCE  — TRANSFER blago→adv, Дт 08 / Кт 51
- o.exp.blgdir BLAGO_DIRECT   — BURN blago,       Дт 08 / Кт 51
- o.exp.advrpt ADVANCE_REPORT — BURN adv,         без проводки (canal уже сделан)
- o.exp.advret ADVANCE_RETURN — TRANSFER adv→blago, Дт 51 / Кт 08 (зеркало blgadv)
- o.exp.over   OVERSPEND      — TRANSFER blago→adv, Дт 08 / Кт 51 (перерасход)

Принцип: расход не уменьшает паевой фонд (80) — меняется только форма актива
51 → 08. Канал 08/51 строится ровно один раз: на blgadv/blgdir. ADVANCE_REPORT —
только BURN кошелька-резерва. Все ack v3 (см. NAMING-C28-28.md commit 143d9a9d30).
2026-06-02 17:58:31 +00:00
claude c5496a7988 Merge pull request 'fix: перекрёст user↔privacy в coagreements + html2pdf SSR' (#63) from feat/decision-authorize-backend into dev 2026-06-02 17:57:27 +00:00
coopops 3482cfe094 fix(desktop): html2pdf — ленивый import, чинит SSR boot error «self is not defined»
Typecheck / desktop (pull_request) Successful in 13m17s
Typecheck / controller (pull_request) Successful in 12m53s
html2pdf.js на верхнем уровне модуля трогает `self` → при SSR-бандле Quasar
падает [Quasar] boot error: ReferenceError: self is not defined. Экспорт PDF
нужен только на клиенте по действию пользователя — переносим импорт в
dynamic import() внутри exportFormToPdf, сервер модуль не вычисляет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:57:04 +00:00
coopops 01c4815c65 fix(soviet): coagreements — user→draft_id 4, privacy→draft_id 3
Были перекрещены относительно реестра документов: draft 3=PrivacyPolicy,
4=UserAgreement (и тексты on-chain drafts, и cooptypes Registry, и protocol-vars
совета это подтверждают). Только make_base_coagreements крестил user↔privacy.
Затрагивает вновь создаваемые коопы; существующим нужна отдельная миграция.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:57:04 +00:00
coopops 143d9a9d30 [C28-28][@ant] docs: v3 — все ack получены, callback как переменная при createexp, проводки 08/51 для обеих механик Благороста
Typecheck / desktop (pull_request) Successful in 13m31s
Typecheck / controller (pull_request) Successful in 13m8s
2026-06-02 17:56:10 +00:00
coopops bf6a5fe8a3 [C28-34][@ant] docs: 2012/2013/2014 убраны — это файлы (MinIO), не document2; assertions на o.exp.* коды
Typecheck / desktop (pull_request) Successful in 13m36s
Typecheck / controller (pull_request) Successful in 13m4s
2026-06-02 17:31:06 +00:00
coopops c3cf1201c8 [C28-27][@ant] chore: убрать schema.gql из трекинга (gitignored-артефакт)
Typecheck / desktop (pull_request) Failing after 4m47s
Typecheck / controller (pull_request) Failing after 4m47s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:29:56 +00:00
coopops a22abfc45f [C28-27][@ant] chore: кодоген схемы и Zeus-клиента для операций Центра уведомлений
Typecheck / desktop (pull_request) Failing after 4m48s
Typecheck / controller (pull_request) Failing after 4m46s
generate-schema + generate-client: getNotifications/getInboxNotifications/
getUnreadNotificationsCount/markNotificationRead/markAllNotificationsRead/
resendNotification + enum NotificationChannel/NotificationOutboxStatus.
SDK-операции теперь компилируются (tsc зелёный).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:26:55 +00:00
coopops 88d47afb7a [C28-27][@ant] fix: guard журнала по coopname — доступ только к своему кооперативу
getNotifications/getNotification/resendNotification сверяют coopname
(фильтра и найденной строки) с config.coopname → ForbiddenException на чужой.
Канон-паттерн контроллера (free-decision/branch interactor). Federation-hardening.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:26:55 +00:00
claude dedcbfb5fe Merge pull request 'Кнопка подписи в прижатый футер + забытый WIP (authorize RAM-payer, boot pf, canon docs)' (#62) from feat/decision-authorize-backend into dev
Build bootstrap container / build (push) Successful in 3m28s
2026-06-02 17:17:09 +00:00
coopops 42722efb59 fix(desktop): кнопка «Подписать» в прижатый футер диалога — доступна без прокрутки длинного документа
Typecheck / desktop (pull_request) Successful in 13m31s
Typecheck / controller (pull_request) Successful in 13m10s
Документ остаётся в скроллящемся body BaseDialog, кнопка вынесена в слот
footer (.base-dialog__foot, вне overflow) — устраняет залипание, когда до
кнопки нельзя домотать (стек maximized-диалогов RequireAgreements).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:15:19 +00:00
coopops b058c7d9bd docs: канон вёрстки desktop → skill /mono-desktop-canon 2026-06-02 17:14:51 +00:00
coopops 472db16d4b [@ant] feat(boot): builtin protocol-feature ASSERT_RECOVER_KEY_ACCOUNT 2026-06-02 17:14:51 +00:00
coopops b60b281fc3 [@ant] fix(soviet): authorize — RAM-плательщик coopname, не председатель (tx подписывает кооператив) 2026-06-02 17:14:51 +00:00
coopops 3fd81215cf [C28-27][@ant] fix: тень панели колокола — канон-токен --p-shadow-pop вместо несуществующего --p-elev-2
Typecheck / desktop (pull_request) Failing after 4m50s
Typecheck / controller (pull_request) Failing after 4m48s
Code-review нашёл: --p-elev-2 нет в tokens.css → box-shadow не отрисовывался.
Канон-тень overlay (поповер) — --p-shadow-pop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:11:28 +00:00
coopops 043a52abf3 [C28-28][@ant] docs: универсальное шасси v2 — без 60/71, 5 op-кодов, payload-флаг capital trigger
Typecheck / desktop (pull_request) Successful in 13m31s
Typecheck / controller (pull_request) Successful in 13m32s
2026-06-02 17:08:01 +00:00
coopops b9c96bed6d [C28-28][@ant] docs: naming proposal — 6 op-кодов вместо 5 + 60/71 счета + capital trigger
Typecheck / desktop (pull_request) Successful in 13m34s
Typecheck / controller (pull_request) Successful in 13m23s
2026-06-02 16:09:49 +00:00
coopops 61f35b1ef9 [C28-34][@ant] docs: план e2e Journey A/B — scaffold-stub до выбора фреймворка и закрытия C28-28..33
Typecheck / desktop (pull_request) Successful in 13m31s
Typecheck / controller (pull_request) Successful in 13m28s
E2E.md в expenses/:
- Journey A (DIRECT-оплата Yandex Cloud из BLAGOROST_POOL): 10 шагов + matrix ledger2/document2 assertions
- Journey B (ADVANCE master+svetlana 2 items): 9 шагов + matrix
- Helpers-skeleton (loginAs, seedBlagorostPool, mockCouncilAuthorize, fixture*, assertLedgerOp/Document2)
- Performance gates ≤ 3 мин на сценарий
- Прежде чем писать — спросить про Cypress vs Playwright (канон-naming-soglasovyvat)
- Зависимости + дорожная карта расшивки

E2E framework в desktop отсутствует; реальные spec'и пишутся после C28-28..C28-33 и согласования инфраструктуры.
2026-06-02 15:37:22 +00:00
coopops 9757f5ec6b [C28-33][@ant] docs: план интеграции Благороста + cleanup-чеклист — doc-scaffold до C28-29/31/32
Typecheck / desktop (pull_request) Successful in 13m36s
Typecheck / controller (pull_request) Successful in 13m22s
INTEGRATIONS.md в expenses/:
- BLAGOROST_POOL flow через существующий capital::createprogexp (никаких новых contract-actions)
- Каталог 9 универсальных событий шасси для Notification Center
- Backward-compat редиректы /programs/blagorost/expenses → /expenses?source=BLAGOROST_POOL
- Cleanup-чеклист legacy program-expense: на ветке пусто (re-grep ProgramExpense → 0 файлов; PR #59 не залит)
- Шаблоны 1010/1011 — не существуют, удалять нечего; роль закрывают 2010/2011 из C28-30
- Расшивка после закрытия C28-29/C28-31/C28-32

Не трогает рабочие сервисы (только новый .md в extensions/expenses/, не зарегистрировано в extensions-registry).
2026-06-02 15:35:55 +00:00
coopops 6e0fb185b6 [C28-32][@ant] feat: UI scaffold-stub expenses extension — 7 page stubs + dialog + install.ts на canon до C28-31
Typecheck / desktop (pull_request) Successful in 13m33s
Typecheck / controller (pull_request) Successful in 13m37s
2026-06-02 15:08:37 +00:00
coopops 3537c7d89b [C28-31][@ant] docs: scaffold-stub нового expenses extension — план файловой структуры до C28-29
Typecheck / desktop (pull_request) Successful in 13m44s
Typecheck / controller (pull_request) Successful in 13m20s
C28-31 (backend extension шасси расходов) логически блокируется C28-29 (контракт expenses) и C28-28 (P0 коды ledger2). Чтобы зонтичный PR не простаивал и следующий цикл шёл механически — кладём README с планируемой структурой файлов, MinIO-спекой бакета `expenses:files` и черновой GraphQL-схемой.

Когда расшивается C28-28 → C28-29 → этот README раскрывается в код по списку.
2026-06-02 14:47:15 +00:00
coopops 1fa41b2b39 [C28-30][@ant] feat: добавить 2 шаблона document2 для шасси расходов (2010/2011) — единый источник СЗ-сметы и протокола-1 для MVP-SINGLE
Typecheck / desktop (pull_request) Successful in 13m28s
Typecheck / controller (pull_request) Successful in 13m4s
Реализация Эпика 2 шасси системы расходов (волна 6 проекта 14).

cooptypes/cooperative/registry/:
- 2010.ExpenseProposalStatement — СЗ-смета с массивом items (description, amount,
  recipient_type SELF/MEMBER/ORG, mechanics ADVANCE/DIRECT, recipient_name, requisites),
  заголовком proposal (description/total_amount/items_count/source_wallet). LiquidJS
  context с таблицей позиций, переводы ru.
- 2011.ExpenseProposalDecision — протокол-1 утверждения/отказа председателем.
  Поля decision {kind: approve|decline, reason?, protocol_number?, protocol_date?},
  переиспользование IExpenseItem/IExpenseProposalHeader из 2010 через cross-import.

factory/src/:
- Schema/ExpenseItemSchema — JSONSchemaType для ExpenseItem, ExpenseProposalHeader,
  ExpenseProposalDecisionBody.
- Templates/2010+2011 — обёртки по образцу 1010 ExpenseStatement / 1011 ExpenseDecision,
  но с массивом items в Schema.
- Actions/2010+2011 — генераторы pdf через DocFactory с маппингом meta+coop+user+vars
  +proposal+items[+decision].

factory/test/documents-1000-plus.test.ts:
- 3 golden-pdf теста (2010 СЗ, 2011 approve, 2011 decline + reason).

Legacy 1010/1011 НЕ удалены — будут выпилены в C28-33 после миграции backend/UI
на новые ID (чтобы не порвать PR #55 на марафоне рефакторинга).

Решение 2026-06-02: только 2 шаблона document2 в MVP (2010, 2011); остальные слоты
(payment_proof, report_files, return_proof, протокол-2) — файлы в MinIO/expense_files
без factory-цепочки. Тяжёлая document2-цепочка нужна только для юридически-весомых
актов СЗ-смета + протокол совета; первичные документы внешнего происхождения и
events приёмки идут отдельным механизмом.

Refs: C28-30, PRD f8 раздел 8.16 + override 2026-06-02, образцы 1010.ExpenseStatement
+ 1011.ExpenseDecision.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 14:39:37 +00:00
coopops 2420d3173f [C28-26][@ant] chore: убрать упоминание Novu из комментария init-wallet
Typecheck / desktop (pull_request) Failing after 4m47s
Typecheck / controller (pull_request) Failing after 4m46s
Бэкенд больше не использует Novu — комментарий устарел.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:17:15 +00:00
coopops 53376971b7 [C28-27][@ant] feat: стол председателя — журнал уведомлений + переотправка
Typecheck / desktop (pull_request) Failing after 4m47s
Typecheck / controller (pull_request) Failing after 4m49s
Эпик 6.5: страница «Журнал уведомлений» (Chairman desktop, /notifications-journal).
Канон-таблица .table-wrap (дата/получатель/тип/канал/статус/попытки), FilterBar по
каналу и статусу, BaseBadge-статусы, кнопка «Переотправить» на строке (resend →
новая строка в очереди, перечитываем). TableSkeleton/EmptyState/«Загрузить ещё».
Имя типа уведомления — из каталога Workflows. Только канон-компоненты + токены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:15:06 +00:00
coopops b500789dd6 [C28-27][@ant] feat: личный инбокс пайщика — колокол на нативном Центре
Typecheck / desktop (pull_request) Failing after 4m50s
Typecheck / controller (pull_request) Failing after 4m48s
Эпик 6.4: виджет NotificationCenter переписан с Novu UI на доменный канон-компонент
shared/ui/domain/NotificationCenter (колокол .icon-btn + BaseBadge-счётчик + q-menu).
Pinia-стор: лента инбокса/unreadCount/markRead/markAllRead через SDK, оптимистичные
отметки, поллинг непрочитанных 30с (live-подписки нет — опрос), тост на новое.
Категория группировки выводится из workflowId. Удалён @novu/js и NOVU_* из env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:09:32 +00:00
coopops 813cc1aeff [C28-27][@ant] feat: SDK-операции уведомлений — журнал, инбокс, переотправка
Typecheck / desktop (pull_request) Failing after 6m26s
Typecheck / controller (pull_request) Failing after 6m30s
Zeus-селекторы (Notification/NotificationDetail/NotificationAttempt/InboxNotification/
UnreadNotificationsCount + пагинации) + queries (getNotifications/getNotification/
getInboxNotifications/getUnreadNotificationsCount) + mutations (resendNotification/
markNotificationRead/markAllNotificationsRead). Enabling-слой для FE 6.4/6.5.
Компилируются после generate-schema/generate-client оператора (zeus regen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:02:50 +00:00
coopops f9543991e3 [C28-27][@ant] feat: read-side инбокса пайщика — лента/unreadCount/markRead
Typecheck / desktop (pull_request) Successful in 13m25s
Typecheck / controller (pull_request) Successful in 12m58s
Эпик 6.6 (backend-часть): личный инбокс канала In-app поверх notification_inbox.
getInboxNotifications (лента по сессии, пагинация канон), getUnreadNotificationsCount
(бейдж колокола), markNotificationRead/markAllNotificationsRead. Скоуп строго по
subscriber_id из JWT — пайщик видит только свой инбокс (ownership на markRead).
Live-подписка onNotification отложена: graphql-ws/PubSub в репо нет — фронт poll'ит.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:59:37 +00:00
coopops 96a8ecdf54 [C28-27][@ant] feat: бэкенд журнала уведомлений — getNotifications/getNotification/resend
Typecheck / desktop (pull_request) Successful in 13m25s
Typecheck / controller (pull_request) Successful in 13m0s
Read-side стола председателя (эпик 6, backend):
- getNotifications: журнал очереди notification_outbox с фильтрами
  (coopname/workflowId/channel/status/recipient) + пагинация (канон createPaginationResult).
- getNotification: детализация уведомления + история попыток из notification_deliveries.
- resendNotification: force-переотправка — новая строка очереди со свежим
  idempotencyKey (обходит дедуп), worker подхватывает.
- Статусы каналов/доставки — GraphQL enum через registerEnumType (канон).
- Гейтинг ролями: журнал/детализация — chairman+member, resend — chairman
  (RolesGuard+AuthRoles; платформенный CASL — Phase 2).

FE (инбокс/журнал) + codegen Zeus/SDK — за оператором (generate-schema/client
локально не запускать); live-подписка in-app — следующая история. tsc: 0 новых ошибок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:50:00 +00:00
coopops 4bb9a317ca [C28-26][@ant] refactor: полное удаление Novu из контроллера
Typecheck / desktop (pull_request) Successful in 13m42s
Typecheck / controller (pull_request) Successful in 13m4s
Контроллер больше не зависит от внешнего Novu (ADR-001, DEC-025):
- Удалены infrastructure/novu/* (адаптеры + модуль), порты NovuWorkflow/
  NovuCredentials + старый notification.port (NotificationPort→NovuAdapter),
  NotificationDomainService, DeviceTokenService, webhook-приёмник Novu, novu.utils.
- subscriber_hash переведён на HMAC по SERVER_SECRET (utils/subscriber-hash.util),
  identity своя; subscriber_id (адресация Центра) сохранён — backfill cron оставлен
  без Novu-синхронизации.
- Развязаны AccountDomainService/account+participant-interactor/subscription-service
  от Novu-вызовов; NovuModule/NotificationDomainModule убраны из 7+5 модулей.
- config.novu + NOVU_* env + @novu/api удалены; lockfile обновлён (desktop @novu/js
  снимется синхронно с эпиком 6).
- @coopenomics/notifications: удалён sync/ (внешняя синхронизация), bin/scripts/deps;
  остался каталог типов + шаблоны; пакет пересобран.
- grep -i novu по controller/src пуст; tsc = baseline (0 новых ошибок).

Boot/grep-ERROR верификация — за оператором (поднять контроллер нельзя из фоновой сессии).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:40:15 +00:00
coopops 0afb73a506 [C28-25][@ant] feat: миграция consumer-сервисов с Novu на единый порт уведомлений
Typecheck / desktop (pull_request) Successful in 13m32s
Typecheck / controller (pull_request) Successful in 13m3s
Drop-in переключение ~28 call-sites в 15 сервисах с
NOVU_WORKFLOW_PORT.triggerWorkflow на NOTIFICATION_PORT.notify (Центр
уведомлений DC v3). Listener'ы/события не тронуты — меняется только тело
вызова: name→workflowId, coopname обязателен (action.coopname/config.coopname),
recipient дополнен username (адресация in-app/web-push).

NotificationCenterModule помечен @Global — порт cross-cutting, консьюмеры
инжектят без импорта модуля (как NOTIFICATION_SUBSCRIPTION_PORT).
Cross-plugin InterCoopCalendarEventNotificationPort (chatcoop) сохранён,
мигрировано лишь тело. Из application NotificationSenderService убраны мёртвые
Novu-измы (broadcast/cancel/getStatus) — не использовались.

tsc: 0 новых ошибок (17 = baseline). NOVU_WORKFLOW_PORT-биндинг остаётся
мёртвым до удаления в эпике 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:08:06 +00:00
coopops 5b3a28d56c [C28-24][@ant] feat: outbox-worker — надёжная доставка с retry и статусами
Typecheck / desktop (pull_request) Successful in 13m42s
Typecheck / controller (pull_request) Successful in 13m14s
OutboxWorkerService @Interval(3000): поллит notification_outbox по индексу
(status, scheduledAt), шлёт через порт канала, исход пишет в журнал
notification_deliveries. at-least-once: backoff-ретрай (5s→30s→2m→10m→1h,
maxAttempts), после исчерпания — терминальный failed (переотправка на столе
председателя). Реклейм зависших SENDING после рестарта. Строка = один канал →
независимые попытки по каналам. Статусы — enum. ScheduleModule.forRoot()
подключён в app.module. Параметры worker'а из env с дефолтами.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:51:03 +00:00
coopops 0e98ae5538 [C28-23][@ant] feat: канал in-app — персист уведомлений в инбокс кооператива
Typecheck / desktop (pull_request) Successful in 13m24s
Typecheck / controller (pull_request) Successful in 13m18s
InAppChannelAdapter за InAppChannelPort: доставка in-app = запись
отрендеренного из каталога уведомления в notification_inbox. Новая
сущность notification_inbox (isRead/readAt, индексы под ленту + unreadCount).
ChannelMessage расширен outboxId + actorSubscriberId (worker даёт из outbox).
Live-подписка/unreadCount/mark-read — read-side инбокса, в эпик 6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:46:23 +00:00
coopops 8e6e45eba5 [C28-23][@ant] feat: каналы email + web-push + шаблонизатор
Typecheck / desktop (pull_request) Successful in 13m37s
Typecheck / controller (pull_request) Successful in 13m16s
EmailChannelAdapter (nodemailer, SMTP из config.email; disabled при пустом
SMTP_HOST, наружу не стучится — NFR11). WebPushChannelAdapter: контроллер
доставляет сам своим подписчикам напрямую через WebPushService на endpoint'ы
из web_push_subscriptions по username (фикс пушей — точки доставки, не ключи);
410 → деактивация подписки. Шаблоны рендерятся из каталога @coopenomics/
notifications (controlValues.subject/body), mustache-интерполяция {{payload.x}}
без liquidjs (лишняя зависимость для MVP). Порты EMAIL_CHANNEL_PORT/
WEB_PUSH_CHANNEL_PORT в NotificationCenterModule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:32:39 +00:00
coopops c783ce322b [C28-22][@ant] feat: порты каналов доставки — Email/InApp/WebPush
Typecheck / desktop (pull_request) Successful in 14m44s
Typecheck / controller (pull_request) Successful in 13m20s
Контракты EmailChannelPort/InAppChannelPort/WebPushChannelPort + ChannelMessage/
ChannelDeliveryResult + DI-токены. Только интерфейсы — реализации в эпике 2;
worker эпика 3 зависит от абстракций, не от провайдеров. Имена без I-префикса
(канон портов репо).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:23:40 +00:00
coopops 38187f3f26 [C28-22][@ant] feat: роутер NotificationService + идемпотентность — запись в outbox
NotificationService implements NotificationPort: резолвит каналы по типу из
каталога @coopenomics/notifications (steps[].type, только email/in_app/push),
для каждой пары получатель×канал пишет строку в notification_outbox идемпотентно
(ON CONFLICT DO NOTHING по unique idempotencyKey). Опциональный EntityManager —
запись в транзакции вызывающего (story 1.3). idempotencyKey = sha256(coopname|
workflowId|subscriberId|channel|stable(payload)) с детерминированной сериализацией
payload (story 1.5). email-канал — только при подтверждённом email (gate).
Модуль зарегистрирован в app.module, экспортирует NOTIFICATION_PORT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:22:53 +00:00
coopops 7d26237748 [C28-22][@ant] feat: схема БД Центра уведомлений — outbox + журнал доставок
Сущности notification_outbox (очередь recipient×channel, hot-path индекс
(status, scheduledAt), unique idempotency_key) и notification_deliveries
(append-журнал попыток, индексы по coopname/recipient для стола председателя
и личного инбокса). Статусы — enum, не строки. Фундамент для роутера (1.3),
worker'а (эпик 3) и стола председателя (эпик 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:14:42 +00:00
coopops 409a19f6ea [C28-22][@ant] feat: ввести доменный порт NotificationPort.notify — единый вход уведомлений без привязки к Novu
Порт notify(NotifyInput) + типы входа (coopname обязателен как federation-инвариант,
workflowId ссылается на каталог @coopenomics/notifications, NotificationChannel enum
email/in_app/push). Старый NovuWorkflowPort сохраняется до миграции call-sites (эпик 4),
удаляется в эпике 5. Часть истории 1.1 эпика 1 (C28-22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:48:22 +00:00
ant 6866717f98 Merge pull request 'feat(soviet): утверждение решений совета через бэкенд (coopname-auth)' (#51) from feat/decision-authorize-backend into dev
Reviewed-on: #51
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-02 07:59:09 +00:00
coopops 7a4d2e0cb3 [C28-21][@ant] fix: реестр совета показывает все документы (submitted+resolved)
Typecheck / desktop (pull_request) Successful in 13m36s
Typecheck / controller (pull_request) Successful in 13m33s
Дефолт реестра совета был newresolved → status=Resolved → виден только
утверждённый документооборот, submitted-документы (в т.ч. свежеподанные
заявления) пропадали. Переключатель вкладок на странице скрыт
(showFilter=false), сменить вид было нельзя. Выравниваем с реестром пайщика:
newsubmitted → status не ограничивается → совет видит ВСЕ документы кооператива.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:52:57 +00:00
coopops f7b12bda60 [C28-21][@ant] fix: пересборка агрегата не откатывает статус (resolved→submitted)
Typecheck / desktop (pull_request) Successful in 14m24s
Typecheck / controller (pull_request) Successful in 13m28s
freedecision эмитит newresolved и newdecision по одному package в одной
транзакции — оба @OnEvent-хендлера срабатывают одновременно. safeReassemble
(newdecision/newact/newlink) пере-upsert'ил все строки пакета ПОЛНЫМ набором
колонок со сталым row.status, прочитанным до коммита newresolved → затирал
resolved обратно в submitted. Решение совета застревало в submitted и пропадало
из реестра совета (дефолт «Только утверждённые» = status=Resolved).

Пересборка теперь обновляет ТОЛЬКО агрегат-производные колонки
(document_aggregate/full_title/content_text/signers_text) точечным UPDATE.
status/block_num/source_action_data принадлежат статус-событиям и пересборкой
не трогаются → конкурентный newresolved не откатывается. Новый метод
SignedDocumentRepository.updateAggregate + SignedDocumentAggregateUpdate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:32:42 +00:00
coopops e810644386 fix(desktop): открывать первую страницу стола при выборе из command palette
Предыдущий фикс снимал лоадер, но onSelectWorkspace пушил на mainRoute.name —
родительский layout-роут без компонента → серый экран. WorkspaceSwitcher
работает потому что зовёт goToDefaultPage(), который через getDefaultPageRoute()
резолвит реальную лист-страницу (authorized_default_route / defaultRoute /
первый child) И сбрасывает isWorkspaceChanging. Используем его же.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:29:15 +00:00
coopops fec4058ff6 feat(soviet): утверждение решений совета через бэкенд (coopname-auth) вместо client-side push
Typecheck / desktop (pull_request) Successful in 13m54s
Typecheck / controller (pull_request) Successful in 13m7s
Кнопка «Утвердить» совета слала authorize+exec напрямую из браузера ключом
председателя — при HTTP 504 от ноды ошибка терялась в браузере, бэкенд её не
видел и не логировал. Переводим действие на проведение через контроллер ключом
кооператива, как остальные soviet-мутации (sendAgreement) — теперь ошибки связи
с блокчейном попадают в логи бэкенда.

Контракт soviet: authorize/exec require_auth(chairman/executer) → require_auth(coopname).
Согласие председателя не теряется — закреплено его личной подписью на document2
(verify_document_or_fail) + проверкой is_valid_chairman.

- contracts: authorize.cpp/exec.cpp — require_auth(coopname)
- controller: новая мутация authorizeDecision (DecisionAuthorizeModule) + порт/адаптер,
  пушит authorize+exec одной транзакцией ключом кооператива из vault
- cooptypes: authorizations actor chairman/username → coopname
- sdk: Mutations.Decisions.AuthorizeDecision + регенерация schema.gql/zeus
- desktop: фича AuthorizeAndExecDecision и process-decisions зовут бэкенд-мутацию
  вместо useGlobalStore().transact; подпись документа остаётся клиентской

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 09:26:48 +00:00
coopops 264d592033 fix(desktop): снимать full-page лоадер при переключении стола из command palette
selectWorkspace() ставит isWorkspaceChanging=true (full-page WindowLoader
поверх router-view), а сбрасывал флаг только goToDefaultPage(). Обычное меню
(WorkspaceSwitcher) зовёт goToDefaultPage и лоадер снимается; command palette
(onSelectWorkspace/onSelectPage) делал прямой router.push без сброса флага —
лоадер висел вечно. Тот же leak в ConnectionAgreementStepper.goToQuickClient.

Снимаем флаг через setWorkspaceChanging(false) в .finally() после навигации.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:14:41 +00:00
coopops 7d43ef9668 fix(desktop): выделение текста — solid accent вместо болотного teal
Глобальный ::selection был --p-primary-soft (teal 10% alpha) — болотный,
почти невидим. Меняем на solid --p-accent + белый текст: видно на любом фоне.
Тост-специфичные ::selection-override (.q-notification/.toast) убраны —
accent читаем и на тёмной поверхности тоста, выделение единое везде.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 07:06:02 +00:00
ant bc989f47d1 Merge pull request '[C28-21] Реестр подписанных документов в Postgres: getDocuments+searchDocuments на PG, newdeclined, авто-backfill' (#47) from feat/C28-21-pg-signed-documents into dev
Reviewed-on: #47
2026-05-31 06:21:18 +00:00
coopops 6e388e3196 [C28-21][@ant] fix: icon в IWorkspaceRouteMeta опционален для hidden-роутов
Typecheck / desktop (pull_request) Successful in 13m52s
Typecheck / controller (pull_request) Successful in 13m12s
Страницы deep-link (карточка документа, name=document-details/
user-document-details) помечены meta.hidden=true и в навигацию не
попадают — иконка им не нужна. Drawer рендерит q-icon под v-if='item.icon',
undefined безопасен. Убирает TS2741 'Property icon is missing'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 05:37:11 +00:00
coopops 42d3fc46e0 [C28-21][@ant] fix: открытие документа из карточки по hash (подписанный), а не doc_hash
Typecheck / desktop (pull_request) Failing after 8m20s
Typecheck / controller (pull_request) Successful in 12m31s
Карточка реестра вела на страницу документа, передавая rawDocument.hash — а это
doc_hash (хеш самого документа, без подписи). Бэкенд же ищет документ по колонке
hash (подписанный документ = содержимое + подпись): UPPER(d.hash)=UPPER(:hash).
Значения разные → getDocuments возвращал 0 → «Документ не найден».

Из поиска работало, потому что searchDocuments отдаёт именно hash (колонку), а не
doc_hash. Карточка теперь берёт тот же document.hash (= колонка hash в реестре).

Проверено по БД: фильтр по doc_hash → 0 строк, по document.hash → 1; у всех
записей document.hash == колонка hash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:17:30 +00:00
coopops 716eb4fe5a [C28-21][@ant] feat: реестр документов на канон-карточки + липкая кнопка «назад»
Typecheck / desktop (pull_request) Failing after 8m31s
Typecheck / controller (pull_request) Successful in 12m49s
Реестр документов переведён с таблицы на канон плоские строки (DocumentRow):
строка кликабельна и ведёт на отдельную страницу документа. Таблица с
инлайн-раскрытием оставлена только для Union-страницы (expand=true).

- DocumentCardsList: список DocumentRow (заголовок, дата, ФИО подписантов,
  скачивание в actions), скелетоны при загрузке, «Загрузить ещё». Статус-чип
  не показываем — в реестре всё подписано (канон stop-signal).
- ListOfDocumentsWidget: проп expand — карточки по умолчанию (совет/пайщик),
  таблица для Union (передаёт expand=true) — поведение Union не меняется.
- useDocumentNavigation перенесён из фичи поиска в entity Document
  (переиспользуют и поиск, и карточный список — чистые слои).
- DocumentDetailsPage: кнопка «назад» теперь в липком баре (sticky,
  top=--p-topbar-h), видна всегда при прокрутке документа.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:04:31 +00:00
coopops 7f65478dc5 [C28-21][@ant] fix: раздел документа выводить из имени роута, не из workspace
Typecheck / desktop (pull_request) Failing after 8m32s
Typecheck / controller (pull_request) Successful in 12m33s
activeWorkspaceName инициализируется асинхронно — при холодном переходе по
прямой ссылке на документ совета скоуп мог упасть в скоуп пайщика и документ
«не находился». Имя роута (document-details / user-document-details) доступно
сразу и детерминированно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:41:23 +00:00
coopops 0cbc76b018 [C28-21][@ant] feat: отдельная canon-страница документа вместо фильтра реестра
Typecheck / desktop (pull_request) Failing after 8m24s
Typecheck / controller (pull_request) Successful in 12m42s
Прошлый подход (фильтр реестра по ?document=<hash> + баннер) отклонён —
нужна полноценная отдельная страница документа с переходом назад и deep-link.

- DocumentDetailsPage: canon-страница по эталону карточки собрания (back-link
  под шапкой, имя документа в заголовок через setPageTitleOverride, скелетон,
  EmptyState; контент — ComplexDocument). Грузит документ сама по hash из роута,
  поэтому открывается и прямым переходом по ссылке.
- Роуты documents/:hash в обоих расширениях: document-details (совет) и
  user-document-details (пайщик), hidden — по аналогии с meet-details.
- DocumentModel.loadDocument(username, hash): точечная загрузка одного документа
  (статус-агностичный фильтр newsubmitted), не трогает состояние списка.
- Клик в поиске ведёт на страницу документа (useDocumentNavigation выбирает роут
  по рабочему столу), а не фильтрует реестр.
- Откат фильтр-подхода: удалены SearchedDocumentBanner и useDocumentRouteFilter,
  страницы реестра/виджет/таблица возвращены к baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:39:48 +00:00
coopops f64a430bff [C28-21][@ant] feat: открытие документа из поиска через фильтр реестра
Typecheck / desktop (pull_request) Successful in 12m55s
Typecheck / controller (pull_request) Successful in 12m31s
Клик по результату поиска вёл в никуда (openDocument был пуст) — найденный
документ нельзя было посмотреть. Теперь клик кладёт hash в ?document=<hash>
текущего роута; страница реестра читает его и сводит список к одному документу,
авто-раскрывая его. Переиспользуем готовый backend-фильтр filter.document.hash
(тот же путь, что у Union-страницы) — без изменений API и codegen.

- useDocumentRouteFilter/useDocumentNavigation: чтение и установка ?document.
- SearchedDocumentBanner: баннер «показан один документ» с возвратом к списку.
- Обе страницы реестра (совет + пайщик) фильтруют виджет по выбранному документу.
- Виджет реагирует на смену filter и форсит статус-агностичный newsubmitted при
  hash-фильтре (иначе на вкладке «только утверждённые» неутверждённый документ
  из поиска не нашёлся бы).
- DocumentsTable авто-раскрывает строку по expandHash (регистронезависимо).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:06:38 +00:00
coopops 9d183f3b64 [C28-21][@ant] copy: срок рассмотрения заявления — до 30 дней вместо 24 часов
Typecheck / desktop (pull_request) Successful in 12m54s
Typecheck / controller (pull_request) Successful in 12m32s
В экране ожидания регистрации (WaitingRegistration) «до 24 часов» — неактуально:
реальный регламентный срок рассмотрения советом — до 30 дней (обычно решение
принимается за день-два). Поправили текст под фактический срок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:34:54 +00:00
coopops e877bd3eac [C28-21][@ant] style: убрать верхний отступ в выдаче поиска документов
Typecheck / desktop (pull_request) Successful in 13m3s
Typecheck / controller (pull_request) Successful in 12m36s
Большая пустая полоса между строкой поиска и первым результатом мешала —
снят верхний padding секции результатов (q-pt-none), список начинается ближе.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:52:52 +00:00
coopops 9427c204ab [C28-21][@ant] feat: выдача поиска документов — ФИО подписанта и чистый заголовок
Typecheck / desktop (pull_request) Successful in 12m57s
Typecheck / controller (pull_request) Successful in 12m36s
Поиск показывал username («ant») и сырой full_title с хвостом ` - - дата.pdf`.
Приводим к виду реестра на главной:
- SearchResult.signer — ФИО подписанта-субъекта («Иванов Иван Иванович»), берётся
  из signer_certificate готового агрегата (подпись с signer===username, иначе первая;
  организация → short_name). Тот же источник, что у чипов подписей в реестре.
- full_title в выдаче поиска теперь = meta.title (чистое наименование), как в реестре;
  fallback — прежний full_title.
- Фронт DocumentSearchDialog: заголовок + чип ФИО (BaseBadge) + дата вместо строки
  «username · дата»; убран пустой highlights-блок.

Схема + zeus-клиент перегенерированы (generate-schema/generate-client + sdk build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:30:54 +00:00
coopops cdb9cc8344 [C28-21][@ant] fix: парсинг meta.created_at формата DD.MM.YYYY HH:mm + устойчивый backfill
Typecheck / desktop (pull_request) Successful in 13m13s
Typecheck / controller (pull_request) Successful in 12m41s
Документы хранят дату в локализованном формате `DD.MM.YYYY HH:mm` + отдельное
meta.timezone (так пишет фабрика @coopenomics/factory через moment.tz). Прежний
`new Date(meta.created_at)` этот формат не парсил → Invalid Date → колонка
timestamptz падала с "invalid input syntax ... 0NaN-NaN-NaN", и throw в одной
записи ронял весь прогон backfill. Парсим тем же moment.tz, что и фабрика
(timezone из меты, иначе config.timezone), невалидное → null. Плюс per-record
try/catch в backfill: одна битая запись логируется и пропускается, а не валит прогон.

Проверено вживую на voskhod: backfill завершён scanned=8 created=4 updated=4
skipped=0, document_created_at = 2026-05-28 07:53:00+00 (валидные даты, не null).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:07:29 +00:00
coopops c5272c7088 [C28-21][@ant] fix: @Index по имени свойства packageHash, не db-колонки package
Typecheck / desktop (pull_request) Successful in 12m56s
Typecheck / controller (pull_request) Successful in 12m36s
TypeORM @Index(['...']) принимает имена СВОЙСТВ сущности, а не db-колонок.
Колонка объявлена @Column({ name: 'package' }) packageHash, поэтому индекс
['coopname','package'] падал на старте: "Index contains column that is missing
in the entity (SignedDocumentEntity): package". Индекс по db-колонке `package`
строится корректно и через имя свойства packageHash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:52:32 +00:00
coopops 6490aa81f4 chore(release): publish
Build bootstrap container / build (push) Successful in 3m10s
Release / release (push) Successful in 25m9s
Release / publish-packages (push) Successful in 14m40s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 1s
2026-05-30 07:56:36 +00:00
coopops 7b58187553 Merge branch 'testnet' 2026-05-30 07:56:22 +00:00
coopops b1c2f3d558 fix gitgnore
Build bootstrap container / build (push) Successful in 1m21s
2026-05-30 07:22:36 +00:00
coopops 6f53923303 chore(release): publish
Build bootstrap container / build (push) Successful in 3m7s
Release / release (push) Successful in 24m25s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-05-30 06:58:26 +00:00
coopops 4804909569 Merge branch 'testnet'
# Conflicts:
#	components/blago-cli/package.json
#	components/boot/package.json
#	components/cleos/package.json
#	components/contracts/package.json
#	components/controller/package-lock.json
#	components/controller/package.json
#	components/cooptypes/package.json
#	components/desktop/package.json
#	components/docs-harness/package-lock.json
#	components/docs-harness/package.json
#	components/docs/package.json
#	components/factory/package.json
#	components/inter/package.json
#	components/migrator/package.json
#	components/notifications/package.json
#	components/parser/package-lock.json
#	components/parser/package.json
#	components/sdk/package.json
#	components/setup/package.json
#	lerna.json
2026-05-30 06:55:07 +00:00
coopops 33b7923773 chore(release): publish
Build bootstrap container / build (push) Successful in 3m37s
Release / release (push) Successful in 25m15s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-05-30 06:48:30 +00:00
coopops 10d792613f Merge branch 'dev' into testnet 2026-05-30 06:48:13 +00:00
coopops 58bdb8a995 fix(desktop): форматировать суммы в тостах ошибок до 2 знаков
Ошибки цепи возвращают суммы с сырой precision=4 ("100.0000 RUB"). Добавлен formatAssetsInText() рядом с formatAsset2Digits и применён в FailAlert — единая точка для всех тостов ошибок. Суммы вида <целое>.<>=3 знаков> <ТИКЕР> приводятся к 2 знакам с группировкой; уже отформатированные не трогаются (идемпотентно).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:47:32 +00:00
coopops 067fd8d562 fix(desktop): не вешать watch темы на SSR-сервере (утечка подписчиков на Dark)
theme boot подписывал watch на глобальный Quasar-реактив Dark. В SSR boot-файлы
выполняются на каждый рендер-запрос, а watch без owning-scope не утилизируется —
подписчики копились на синглтоне Dark безгранично (та же природа, что у sentry.ts,
только дешевле за итерацию). Тема (data-theme на documentElement) нужна только на
клиенте; гейтим boot через process.env.SERVER.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:47:32 +00:00
coopops ca2b97f18f fix(desktop): не инициализировать Sentry на SSR-сервере (спам логов + утечка RAM)
Boot-файл sentry.ts использует браузерный @sentry/vue с browserTracingIntegration.
В SSR boot-файлы выполняются на каждый рендер-запрос, поэтому Sentry.init плодил
новый клиент/интеграции на каждом запросе (утечка памяти → «пила» RAM, заметная
на нодах с малым объёмом RAM) и спамил серверный лог строкой про инициализацию
(~80/мин). Гейтим boot через process.env.SERVER (как в chatwoot.ts) — Sentry
остаётся только на клиенте, где ему и место.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:47:32 +00:00
coopops 708607f83a fix(desktop): цвет выделения текста в тостах
Глобальный ::selection красит текст в var(--p-ink) (тёмный на свет-теме)
поверх полупрозрачного фона — на тёмной поверхности тоста выделенный текст
пропадал. Scoped-override для .q-notification/.toast: полупрозрачная светлая
подложка + светлый текст, читаемо на всех типах тостов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:47:32 +00:00
coopops 5a21d017e0 fix(desktop): убрать гонку Teleport-host шапки и обновлять гранты/столы после регистрации
Баг A: предупреждение "Failed to locate Teleport target '#header-actions-host'"
возникало, потому что host был под v-if='loggedIn'. Сразу после регистрации
навигация на стол происходит раньше, чем loggedIn (зависит от async
session.isAuth / isRegistrationComplete) станет true → цель Teleport
отсутствует в DOM → даже defer-Teleport источников страниц её не находит.
Рендерим #header-actions-host безусловно — видимость блока действий уже
регулируется CSS (.topbar__actions:has(> .header-actions-host:only-child:empty)).

Баг B: после регистрации grant-gated кнопки («Совершить взнос», «Получить
возврат») не появлялись до F5. В потоке завершения регистрации (watch на
participant_account) не вызывался desktops.loadDesktop() (свежие столы/гранты),
а кошелёк/agreements не грузились: на момент первого run() статус ещё не
'active' → isFullyActive=false. После того как статус обновился, до-вызываем
run(true) и loadDesktop() по образцу init-app / EnableButton. Условие
isFullyActive в init-wallet не ослаблялось.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:47:32 +00:00
coopops a2bfa69c2b feat(desktop): placeholder для наименования банка в форме ИП
Только неочевидное поле — наименование банка получило подсказку ПАО "Сбербанк".
Остальные поля ИП самоописательны (формат даты, кол-во цифр, «как в паспорте»).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:47:32 +00:00
coopops 804a178234 feat(desktop): добавить placeholder-примеры в форму организации при вступлении
Поля краткого/полного наименования, должности представителя и наименования
банка получили placeholder-подсказки (ПК "Ромашка", Председатель совета,
ПАО "Сбербанк") — показываются при фокусе пустого поля.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:47:32 +00:00
coopops 0eb1224390 fix(desktop): не терять выбор в BaseRadioCard при наведении
Hover-правило .base-radio-card:hover (специфичность 0,2,0) перекрывало
.base-radio-card--selected (0,1,0), поэтому при наведении на выбранную
карточку программы primary-фон/граница подменялись на surface/soft и
выбор визуально пропадал. Ограничил hover невыбранными карточками через
:not(.base-radio-card--selected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:47:32 +00:00
coopops b465529f8a [design-wave1][@dark] fix: палитра команд — рамка курсора на любом столе, не только активном
Заголовок workspace-row получал outline только когда стол активный
(.is-active .row.is-selected). На неактивных столах при навигации курсор
«терялся»: только лёгкая смена фона на surface-2, без рамки — в отличие
от page'ей, которые подсвечиваются outline'ом всегда. Выровнял правило:
outline на любом .workspace-row.is-selected.
2026-05-30 06:47:31 +00:00
coopops 6e4ee9303a [design-wave1][@dark] fix: палитра команд — clamp навигации стрелками вместо wrap-around
На верхней/нижней позиции стрелка дальше прыгала в противоположный
конец списка из-за (idx+delta+len)%len. Заменено на clamp в [0..len-1]:
курсор остаётся на месте, как в нативном listbox/меню. Пустое выделение
+ ↓ ставит на первый, ↑ — на последний.
2026-05-30 06:47:31 +00:00
coopops c3cc6bffb6 [design-wave1][@dark] fix: палитра команд — снят sticky-баннер активного стола
Активный workspace был position:sticky top:0 — при навигации стрелкой
вверх курсор-выделение «исчезал» за sticky-overlay'ем, потому что
scrollIntoView({block:'nearest'}) считал элемент видимым в normal flow,
не учитывая sticky-перекрытие. Активный стол и так идёт первым по
сортировке + есть badge «Активный», sticky не нужен.
2026-05-30 06:47:31 +00:00
coopops 39be17dfbc [@dark] fix(desktop): CommandPalette — primary вместо accent + meta.conditions/hidden + drawer close на мобильниках
- CommandPalette.vue: все --p-accent / --p-accent-soft / --p-ink-on-accent
  заменены на --p-primary (бирюзовая палитра канона). Откатывается
  регрессия после fix-коммитов design-wave1 (sticky-banner / clamp /
  рамка курсора), которые случайно вернули accent поверх primary
  (см. b82b778b81 — изначально palette был в primary).

- default.vue::paletteWorkspaces: возвращена фильтрация meta.conditions
  (participant/chairman/soviet install.ts) + meta.hidden (capital
  install.ts) — та же логика что в LeftDrawerMenu.filteredRoutes,
  чтобы CommandPalette не показывал страницы, которые не отображаются
  в rail-меню. onSelectWorkspace/onSelectPage закрывают левый дровер
  на мобильниках через desktop.closeLeftDrawerOnMobile().
2026-05-30 06:47:31 +00:00
coopops ab8f1186e8 [@dark] refactor(desktop): canon CommandPalette + снос осиротевшего меню — фикс утерянного ⌘K после design-wave1
- layouts/default.vue: вместо widgets/Desktop/CmdkMenu mount'им
  shared/ui/domain/CommandPalette (canon из design-wave1) с адаптером
  workspaceMenus → CommandPaletteWorkspace[] и глобальным ⌘K / Ctrl+K.
- entities/CommandPalette: минимальный store (isOpen / open / close /
  toggle) — заменяет тяжёлый useCmdkMenuStore.
- LeftDrawerMenu.onCmdk → palette.open() (триггер «Найти» в AppDrawer).
- Удалены целиком осиротевшие после design-wave1 директории:
  entities/CmdkMenu, widgets/Desktop/CmdkMenu, widgets/Desktop/CmdkTrigger,
  widgets/Desktop/SecondLevelMenuList (никем не использовался),
  widgets/Desktop/WorkspaceMenu (заменён на WorkspaceSwitcher),
  widgets/Wallet/MicroWallet (заменён на RailUserCard).
- Поправлены barrels widgets/Desktop и widgets/Wallet.
2026-05-30 06:47:31 +00:00
coopops 7b39ed34be fix(desktop): читаемость тостов на тёмной теме
Фон и текст тоста (.q-notification + .toast) теперь фиксированные тёмная
поверхность + светлый текст в обеих темах, а не инвертируемые
var(--p-ink)/var(--p-canvas). На dark-теме текст уходил в тёмный на тёмном
deep-tint фоне (#052e16 и т.п.) — нечитаемо. Цветные иконки по типу
остаются на токенах.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:47:31 +00:00
ant e68475e1d8 fix(desktop): отступ документа от рамки в диалоге подписи соглашения
q-pa-lg на flat-карточке SignAgreementDialog — документ больше не прижат
к границе на странице кошелька пайщика.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:47:31 +00:00
coopops a076962058 [blago-cli-add-filter][@ant] fix(blago-cli): фильтр non-entity .md в add + понятные ошибки push — чтобы blago add . не валил push «type: undefined»
— format/peekBlagoEntityType: не бросает; возвращает project|issue|story|undefined.
— sync/add: классификация unreadable/non-entity/clean/dirty; README/notes/CLAUDE/AGENTS не попадают в staging, отдельный счётчик skippedNonEntity.
— sync/push: на старте чистит staging от non-entity (warn); ошибки parseBlagoMarkdown / validateParsedForPush префиксуются путём файла.
— cmd/run-cli: вывод add показывает skippedNonEntity.
— bump 2026.5.28-1.
2026-05-30 06:47:31 +00:00
coopops 7e98a3adfb [mobile-drawer][@ant] fix: левый дровер — автозакрытие на мобиле при смене маршрута 2026-05-30 06:46:00 +00:00
coopops eb3e388b78 [fix-vue-tsc][@dark] fix: IPayment override → локальный IPaymentRow в виджете
Глобальный Omit<IPayment,'id'>&{id:string} ломал store.ts/mergePayments
и features/Payment/SetStatus/api (туда приходит сырой Zeus-вывод с
id:unknown). Откатываю override в entities/Payment/model/types.ts,
сужаю id до string локально в ListOfPaymentsWidget через IPaymentRow —
точка сужения только в UI, контракт стора/API не трогается.

vue-tsc desktop локально: PASS.
2026-05-30 06:46:00 +00:00
coopops d65643065e [fix-vue-tsc][@dark] fix: vue-tsc desktop — IAgenda в QuestionsTable + IPayment.id:string
QuestionsTable принимал Cooperative.Document.IComplexAgenda[] (cooptypes,
table:IDecision без certificates, id:IUint64=number|string), а сверху
ListOfAgendaQuestions передаёт IAgenda[] из desktop/entities/Agenda
(GraphQL-вывод getAgenda, table:BlockchainDecision с certificates,
id:number). Перевожу QuestionsTable на IAgenda — это реальная форма
данных из стора; уходит расхождение IComplexAgenda↔IAgenda для
:agenda='row' в QuestionCard (он тоже IAgenda).

IPayment.id был ModelTypes['ID']=unknown (Zeus не имеет scalar-resolver
для ID), из-за чего шаблон ListOfPaymentsWidget падал на expanded.get/.set
и :id-биндингах. Переопределяю id как string через Omit+&.
2026-05-30 06:46:00 +00:00
ant 0c343f0d50 Merge pull request 'fix(desktop): форматировать суммы в тостах ошибок до 2 знаков' (#48) from fix/desktop-format-asset-errors into dev
Reviewed-on: #48
2026-05-30 06:43:25 +00:00
coopops 91f74c76b4 fix(desktop): форматировать суммы в тостах ошибок до 2 знаков
Typecheck / desktop (pull_request) Successful in 14m11s
Typecheck / controller (pull_request) Successful in 13m38s
Ошибки цепи возвращают суммы с сырой precision=4 ("100.0000 RUB"). Добавлен formatAssetsInText() рядом с formatAsset2Digits и применён в FailAlert — единая точка для всех тостов ошибок. Суммы вида <целое>.<>=3 знаков> <ТИКЕР> приводятся к 2 знакам с группировкой; уже отформатированные не трогаются (идемпотентно).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:38:57 +00:00
coopops 3691d95777 fix(desktop): не вешать watch темы на SSR-сервере (утечка подписчиков на Dark)
theme boot подписывал watch на глобальный Quasar-реактив Dark. В SSR boot-файлы
выполняются на каждый рендер-запрос, а watch без owning-scope не утилизируется —
подписчики копились на синглтоне Dark безгранично (та же природа, что у sentry.ts,
только дешевле за итерацию). Тема (data-theme на documentElement) нужна только на
клиенте; гейтим boot через process.env.SERVER.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:25:10 +00:00
coopops f264aa85a3 fix(desktop): не инициализировать Sentry на SSR-сервере (спам логов + утечка RAM)
Boot-файл sentry.ts использует браузерный @sentry/vue с browserTracingIntegration.
В SSR boot-файлы выполняются на каждый рендер-запрос, поэтому Sentry.init плодил
новый клиент/интеграции на каждом запросе (утечка памяти → «пила» RAM, заметная
на нодах с малым объёмом RAM) и спамил серверный лог строкой про инициализацию
(~80/мин). Гейтим boot через process.env.SERVER (как в chatwoot.ts) — Sentry
остаётся только на клиенте, где ему и место.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:19:11 +00:00
coopops 3a59be5101 fix(desktop): цвет выделения текста в тостах
Глобальный ::selection красит текст в var(--p-ink) (тёмный на свет-теме)
поверх полупрозрачного фона — на тёмной поверхности тоста выделенный текст
пропадал. Scoped-override для .q-notification/.toast: полупрозрачная светлая
подложка + светлый текст, читаемо на всех типах тостов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 05:39:47 +00:00
coopops 43d9363800 [C28-21][@ant] docs: backfill идемпотентен по (coopname, doc_hash), не package
Typecheck / desktop (pull_request) Successful in 13m31s
Typecheck / controller (pull_request) Successful in 13m7s
Устаревшая формулировка в docstring backfill-сервиса: после перехода на ключ
doc_hash идемпотентность/уникальность реестра — по (coopname, doc_hash), а НЕ по
(coopname, package). package сознательно не уникален (один процесс = несколько
разных документов с разными подписантами), поэтому ключом записи он быть не может.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:15:16 +00:00
coopops 33437c6b35 [C28-21][@ant] fix: block_num реестра = блок on-chain действия, не генерации
Typecheck / desktop (pull_request) Successful in 13m36s
Typecheck / controller (pull_request) Successful in 13m5s
Ось упорядочивания версий подписи одного doc_hash должна мерить одну величину
на обеих сторонах guard'ов ingestAction. Раньше block_num хранился через
resolveBlockNum(meta.block_num, action.block_num) с приоритетом meta.block_num
(блок ГЕНЕРАЦИИ документа фабрикой), а incomingBlock в guard'ах — action.block_num
(блок ДЕЙСТВИЯ в цепи). Так как документ всегда генерируется до подачи в цепь
(meta.block_num <= action.block_num), защита от отката на старую версию подписи
(incomingBlock < existing.blockNum) и идемпотентность по блоку фактически не
срабатывали. Теперь приоритет — action.block_num, meta.block_num лишь fallback.

Заодно выправлены устаревшие комментарии интерфейса репозитория (ключ — doc_hash,
а не hash).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:07:47 +00:00
coopops 423648d342 [C28-21][@ant] fix(controller): ключ реестра — doc_hash (схлопывание версий подписи) + поиск пайщику по своим документам
Typecheck / desktop (pull_request) Successful in 13m57s
Typecheck / controller (pull_request) Successful in 13m34s
Два уточнения по замечаниям:

1. Ключ реестра переведён с hash на doc_hash. doc_hash = идентичность документа; hash включает
   наложенные подписи, поэтому у ОДНОГО документа бывает несколько hash (версии по мере накопления
   подписей — напр. акт приёма-передачи: 1-я подпись → один hash, 2-я → другой, doc_hash тот же).
   При ключе по hash документ задваивался в списке (одна подпись / две подписи). Теперь:
   - уникальный индекс (coopname, doc_hash); hash и package — неуникальные индексы;
   - ingestAction ключуется по data.document.doc_hash и держит КРАЙНЮЮ версию: guard'ы «не понижать
     статус» и «не откатывать на меньший block_num» (getState отдаёт status+blockNum); крайняя версия
     перетирает предыдущую (тот же документ, больше подписей);
   - hash хранится как версия последней подписи (для фильтра Union по document.hash и отображения).
   package по-прежнему группирует разные документы процесса (обмен = разные doc_hash = разные строки).

2. searchDocuments вернул доступ пайщику, но со скоупом: член совета (chairman/member) ищет по всему
   кооперативу, обычный пайщик — ТОЛЬКО по своим документам (search scope username=user.username).
   Снял role-гард, добавил @CurrentUser; SearchResolver решает скоуп по роли. Заодно закрывает прежнюю
   утечку (старый поиск был coop-wide для любого авторизованного).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:38:11 +00:00
coopops b9be36d203 [C28-21][@ant] fix(controller): ключ реестра — hash документа, а не package (package = процесс с N заявлений)
Typecheck / desktop (pull_request) Successful in 13m53s
Typecheck / controller (pull_request) Successful in 14m23s
Самоаудит по замечанию: package — это идентификатор ПРОЦЕССА, в который может входить несколько
заявлений с разными подписантами. Доказано в контракте: marketplace/change.cpp шлёт ДВА newsubmitted
с одним package, но разными document и username (обмен: contribute+return). Прежний уникальный индекс
(coopname, package) + upsert-по-package затирал второе заявление первым → ПОТЕРЯ ДОКУМЕНТА и склейка
username. Старый explorer-путь (список по newsubmitted) показывал оба.

- Сущность: уникальный индекс теперь (coopname, hash) — одна строка = один документ-заявление;
  (coopname, package) стал НЕуникальным группирующим индексом.
- ingestAction ключуется по data.document.hash; смена статуса (newsubmitted/newresolved/newdeclined,
  все несут то же заявление — подтверждено createagenda/declinedoc/make_complete_document) матчит
  строку по hash.
- newdecision/newact/newlink несут package (не заявление) → пересобирают агрегаты ВСЕХ заявлений
  пакета (repository.findByPackage), сохраняя статус каждого.
- Репозиторий: upsert/getStatus по hash; findByPackage вместо getSourceActionData; убраны
  неиспользуемые setStatus/exists.

Также: searchDocuments закрыт гардом RolesGuard + @AuthRoles(['chairman','member']) — поиск по
документообороту кооператива только для совета (SearchDocumentsInput не содержит username, поэтому
правило «свои ресурсы» не открывает доступ обычному пайщику). Поиск пайщиком по своим документам
пока не предусмотрен.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:20:38 +00:00
coopops 3943802931 [C28-21][@ant] fix(controller): парность getDocuments — «Все входящие» = все статусы + скоуп backfill по кооперативу
Typecheck / desktop (pull_request) Successful in 14m40s
Typecheck / controller (pull_request) Successful in 13m16s
Самоаудит видимости документов выявил две регрессии относительно прежнего explorer-пути:

1. Вкладка «Все входящие» (type=newsubmitted) теряла решённые документы. Прежний фильтр был по
   ИМЕНИ действия: у каждого пакета есть newsubmitted-действие → «Все входящие» = все документы
   (решённые показывались и тут, и во «Только утверждённые»). Партиция по статусу (newsubmitted→
   status=Submitted) скрывала решённые из дефолтной вкладки User/DocumentsPage. Фикс: newsubmitted →
   без фильтра статуса (все), newresolved → status=Resolved.

2. Backfill сканировал explorer с query={} — без скоупа. Explorer (cooparser) хранит трейсы по
   получателям; без receiver вернулись бы все трейсы каждого действия (soviet/пайщик) и, возможно,
   чужие кооперативы. Фикс: query={receiver: coopname} — ровно как прежний chairman-путь getDocuments
   (один трейс на документ, только наш кооператив).

Утечки не было и нет: RolesGuard разрешает обычному пайщику только data.username==его_имя, а фильтр
findAggregates ограничивает username; coopname-скоуп (весь кооператив) доступен лишь совету/председателю.
findAggregates всегда фильтрует по config.coopname.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:06:54 +00:00
coopops 33801798ed [C28-21][@ant] refactor(controller): полное удаление OpenSearch — поиск и список документов целиком на Postgres
Typecheck / desktop (pull_request) Successful in 14m26s
Typecheck / controller (pull_request) Successful in 13m48s
Завершение перехода одним PR: searchDocuments и getDocuments уже читают PG-реестр подписанных
документов, OpenSearch стал мёртвым кодом — убираем его полностью.

- Удалены infrastructure/search/* (opensearch.service, search-registry.service,
  search-infrastructure.module) и application/search/services/search-event.service
  (старый индексатор по newsubmitted — заменён SignedDocumentIngestionService + backfill).
- app.module: снят SearchInfrastructureModule. search.module: только SearchResolver (читает
  SIGNED_DOCUMENT_REPOSITORY из глобального TypeOrmModule).
- package.json: убрана зависимость @opensearch-project/opensearch; pnpm-lock.yaml пересчитан.
- docker-compose: удалён сервис opensearch + volume opensearch_data.
- .env-example: убраны OPENSEARCH_* (поиск больше не конфигурируется через env).
- system.interactor: features.search = true (поиск на PG доступен всегда, не зависит от OPENSEARCH_ENABLED).

Файлы удалены только из текущего состояния (git rm) — в истории остаются.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:36:55 +00:00
coopops aeeb9de380 [C28-21][@ant] fix(controller): getDocuments — точная эквивалентность read-path (receiver-скоуп + фильтр по hash)
Typecheck / desktop (pull_request) Successful in 13m35s
Typecheck / controller (pull_request) Successful in 13m6s
Старый getDocuments через DocumentService подмешивал в explorer-фильтр `receiver: username`
(on-chain require_recipient): receiver=coopname отдавал все документы кооператива, receiver=<пайщик>
— его документы; Union-страница добавляла filter.document.hash. PG read-path эти ключи игнорировал
(искал несуществующий data.username) — личная страница пайщика показала бы все документы кооператива.

- interactor: extractReceiverScope (receiver===coopname → весь кооператив; иначе фильтр username=receiver,
  т.к. колонка username = пайщик-субъект заявления) + extractHashFilter (filter.document.hash).
- repository: новый параметр hash в SignedDocumentListParams; фильтр UPPER(d.hash)=UPPER(:hash)
  (фронт шлёт хэш в upper-case).

Набор документов getDocuments теперь совпадает со старым explorer-путём для всех трёх потребителей
(Cooperative/ListOfDocuments, User/DocumentsPage, Union/ListOfCooperatives).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:30:02 +00:00
coopops 4d1b3b87c1 [C28-21][@ant] feat(controller): денормализация ФИО подписантов (signers_text) + поиск по подписанту
Typecheck / desktop (pull_request) Successful in 13m28s
Typecheck / controller (pull_request) Successful in 13m12s
Поиск по документам — в основном по фамилии подписанта, а не username. content_text (html заявления)
этого не покрывает: соподписанты (председатель/совет) подписывают решение/акты, а не тело заявления,
плюс зависимость от текста шаблона. ФИО уже присутствуют в агрегате — в signer_certificate каждой подписи
(DocumentAggregator резолвит их из учётных данных подписанта).

- Новая колонка signed_documents.signers_text — ФИО/наименования всех подписантов пакета
  (last/first/middle_name физлиц-ИП и short_name организаций из signer_certificate всех частей
  агрегата: заявление/решение/акты/связанные) + их username.
- Заполняется на ingestion и backfill из готового агрегата (collectSignersText).
- searchDocuments ILIKE теперь покрывает signers_text (приоритетно), full_title, content_text, username.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:24:14 +00:00
coopops d34303a5c4 [C28-21][@ant] feat: getDocuments+searchDocuments на Postgres, newdeclined в cooptypes, авто-backfill агрегатов
Typecheck / desktop (pull_request) Successful in 13m43s
Typecheck / controller (pull_request) Successful in 13m14s
Вторая итерация C28-21 — сервис функционирует по-новому из PG:

- cooptypes: заведено registry-действие soviet `newdeclined` (+ экспорт в Registry); отслеживается ingestion'ом.
- Реестр хранит ГОТОВЫЙ агрегат пакета (document_aggregate jsonb) + денормализованную проекцию
  (full_title/content_text/action/registry_id/username/block_num/document_created_at) для поиска и фильтра.
- Ingestion собирает агрегат через DocumentPackageAggregator на 6 событиях soviet:
  newsubmitted/newresolved/newdeclined (статус) + newdecision/newact/newlink (досборка по source_action_data).
- getDocuments — read-path из PG: отдаём готовые агрегаты, фильтр по статусу(type)/действию/пайщику/блокам,
  без обращений к explorer/Mongo на лету.
- searchDocuments — SQL ILIKE по PG-реестру (контракт SearchResultDTO сохранён).
- Backfill запускается автоматически РАЗОВО при пустом реестре (onApplicationBootstrap, детачем) или
  принудительно по SIGNED_DOCS_BACKFILL_ON_BOOT=true; идемпотентен по (coopname, package).

OpenSearch-инфра пока оставлена (снос — отдельный финальный шаг). cooptypes/dist gitignored — пересобирается в CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 08:52:48 +00:00
coopops f5ff2a7b10 [C28-21][@ant] feat(controller): реестр подписанных документов в Postgres — ingestion + backfill
Typecheck / desktop (pull_request) Successful in 14m18s
Typecheck / controller (pull_request) Successful in 13m30s
Фундамент C28-21: PG-проекция подписанных документов (таблица signed_documents),
наполняемая ловлей blockchain-событий soviet с внутренней шины и разовым backfill'ом.
Аддитивно — OpenSearch и резолверы (searchDocuments/getDocuments) пока не трогаются.

- SignedDocumentEntity (signed_documents): package/hash/coopname/username/status,
  full_title/content_text/html/pdf(bytea), document_aggregate(jsonb), meta, block_num.
- SignedDocumentRepository (+SIGNED_DOCUMENT_REPOSITORY) + TypeORM-реализация (upsert/setStatus/search ILIKE).
- SignedDocumentIngestionService: @OnEvent(action::soviet::{newsubmitted,newresolved,newdeclined})
  -> идемпотентный upsert с защитой от понижения статуса; контент из factory-Mongo.
- SignedDocumentBackfillService: разовый идемпотентный скан действий (env-gate SIGNED_DOCS_BACKFILL_ON_BOOT).
- Регистрация в TypeOrmModule + app.module.

newdeclined пока не экспортирован как registry-action в cooptypes — подписка по литералу на будущее.
Следующая итерация: перевод searchDocuments/getDocuments на PG + снос OpenSearch + тесты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 08:31:49 +00:00
coopops e96e157b91 fix(desktop): убрать гонку Teleport-host шапки и обновлять гранты/столы после регистрации
Баг A: предупреждение "Failed to locate Teleport target '#header-actions-host'"
возникало, потому что host был под v-if='loggedIn'. Сразу после регистрации
навигация на стол происходит раньше, чем loggedIn (зависит от async
session.isAuth / isRegistrationComplete) станет true → цель Teleport
отсутствует в DOM → даже defer-Teleport источников страниц её не находит.
Рендерим #header-actions-host безусловно — видимость блока действий уже
регулируется CSS (.topbar__actions:has(> .header-actions-host:only-child:empty)).

Баг B: после регистрации grant-gated кнопки («Совершить взнос», «Получить
возврат») не появлялись до F5. В потоке завершения регистрации (watch на
participant_account) не вызывался desktops.loadDesktop() (свежие столы/гранты),
а кошелёк/agreements не грузились: на момент первого run() статус ещё не
'active' → isFullyActive=false. После того как статус обновился, до-вызываем
run(true) и loadDesktop() по образцу init-app / EnableButton. Условие
isFullyActive в init-wallet не ослаблялось.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:39:59 +00:00
coopops bbb0ae5e41 feat(desktop): placeholder для наименования банка в форме ИП
Только неочевидное поле — наименование банка получило подсказку ПАО "Сбербанк".
Остальные поля ИП самоописательны (формат даты, кол-во цифр, «как в паспорте»).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:26:02 +00:00
coopops 2eeb64c188 feat(desktop): добавить placeholder-примеры в форму организации при вступлении
Поля краткого/полного наименования, должности представителя и наименования
банка получили placeholder-подсказки (ПК "Ромашка", Председатель совета,
ПАО "Сбербанк") — показываются при фокусе пустого поля.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:25:37 +00:00
coopops 33d4230873 fix(desktop): не терять выбор в BaseRadioCard при наведении
Hover-правило .base-radio-card:hover (специфичность 0,2,0) перекрывало
.base-radio-card--selected (0,1,0), поэтому при наведении на выбранную
карточку программы primary-фон/граница подменялись на surface/soft и
выбор визуально пропадал. Ограничил hover невыбранными карточками через
:not(.base-radio-card--selected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:07:29 +00:00
coopops 829f8f823b [design-wave1][@dark] fix: палитра команд — рамка курсора на любом столе, не только активном
Заголовок workspace-row получал outline только когда стол активный
(.is-active .row.is-selected). На неактивных столах при навигации курсор
«терялся»: только лёгкая смена фона на surface-2, без рамки — в отличие
от page'ей, которые подсвечиваются outline'ом всегда. Выровнял правило:
outline на любом .workspace-row.is-selected.
2026-05-28 19:17:02 +00:00
coopops 9f92e4bb52 [design-wave1][@dark] fix: палитра команд — clamp навигации стрелками вместо wrap-around
На верхней/нижней позиции стрелка дальше прыгала в противоположный
конец списка из-за (idx+delta+len)%len. Заменено на clamp в [0..len-1]:
курсор остаётся на месте, как в нативном listbox/меню. Пустое выделение
+ ↓ ставит на первый, ↑ — на последний.
2026-05-28 19:17:02 +00:00
coopops 8d895c5fa9 [design-wave1][@dark] fix: палитра команд — снят sticky-баннер активного стола
Активный workspace был position:sticky top:0 — при навигации стрелкой
вверх курсор-выделение «исчезал» за sticky-overlay'ем, потому что
scrollIntoView({block:'nearest'}) считал элемент видимым в normal flow,
не учитывая sticky-перекрытие. Активный стол и так идёт первым по
сортировке + есть badge «Активный», sticky не нужен.
2026-05-28 19:17:02 +00:00
coopops 8bfd4e916c [@dark] fix(desktop): CommandPalette — primary вместо accent + meta.conditions/hidden + drawer close на мобильниках
- CommandPalette.vue: все --p-accent / --p-accent-soft / --p-ink-on-accent
  заменены на --p-primary (бирюзовая палитра канона). Откатывается
  регрессия после fix-коммитов design-wave1 (sticky-banner / clamp /
  рамка курсора), которые случайно вернули accent поверх primary
  (см. b82b778b81 — изначально palette был в primary).

- default.vue::paletteWorkspaces: возвращена фильтрация meta.conditions
  (participant/chairman/soviet install.ts) + meta.hidden (capital
  install.ts) — та же логика что в LeftDrawerMenu.filteredRoutes,
  чтобы CommandPalette не показывал страницы, которые не отображаются
  в rail-меню. onSelectWorkspace/onSelectPage закрывают левый дровер
  на мобильниках через desktop.closeLeftDrawerOnMobile().
2026-05-28 19:15:47 +00:00
coopops df26d5ac9c [@dark] refactor(desktop): canon CommandPalette + снос осиротевшего меню — фикс утерянного ⌘K после design-wave1
- layouts/default.vue: вместо widgets/Desktop/CmdkMenu mount'им
  shared/ui/domain/CommandPalette (canon из design-wave1) с адаптером
  workspaceMenus → CommandPaletteWorkspace[] и глобальным ⌘K / Ctrl+K.
- entities/CommandPalette: минимальный store (isOpen / open / close /
  toggle) — заменяет тяжёлый useCmdkMenuStore.
- LeftDrawerMenu.onCmdk → palette.open() (триггер «Найти» в AppDrawer).
- Удалены целиком осиротевшие после design-wave1 директории:
  entities/CmdkMenu, widgets/Desktop/CmdkMenu, widgets/Desktop/CmdkTrigger,
  widgets/Desktop/SecondLevelMenuList (никем не использовался),
  widgets/Desktop/WorkspaceMenu (заменён на WorkspaceSwitcher),
  widgets/Wallet/MicroWallet (заменён на RailUserCard).
- Поправлены barrels widgets/Desktop и widgets/Wallet.
2026-05-28 19:02:24 +00:00
coopops 2136252933 fix(desktop): читаемость тостов на тёмной теме
Фон и текст тоста (.q-notification + .toast) теперь фиксированные тёмная
поверхность + светлый текст в обеих темах, а не инвертируемые
var(--p-ink)/var(--p-canvas). На dark-теме текст уходил в тёмный на тёмном
deep-tint фоне (#052e16 и т.п.) — нечитаемо. Цветные иконки по типу
остаются на токенах.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:18 +00:00
ant e5e97d9074 fix(desktop): отступ документа от рамки в диалоге подписи соглашения
q-pa-lg на flat-карточке SignAgreementDialog — документ больше не прижат
к границе на странице кошелька пайщика.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 17:51:58 +00:00
coopops 7d2f49468d [blago-cli-add-filter][@ant] fix(blago-cli): фильтр non-entity .md в add + понятные ошибки push — чтобы blago add . не валил push «type: undefined»
— format/peekBlagoEntityType: не бросает; возвращает project|issue|story|undefined.
— sync/add: классификация unreadable/non-entity/clean/dirty; README/notes/CLAUDE/AGENTS не попадают в staging, отдельный счётчик skippedNonEntity.
— sync/push: на старте чистит staging от non-entity (warn); ошибки parseBlagoMarkdown / validateParsedForPush префиксуются путём файла.
— cmd/run-cli: вывод add показывает skippedNonEntity.
— bump 2026.5.28-1.
2026-05-28 17:17:08 +00:00
coopops 9d3be7259f [mobile-drawer][@ant] fix: левый дровер — автозакрытие на мобиле при смене маршрута 2026-05-28 16:43:15 +00:00
ant 4ffb32ff9e Merge pull request 'fix(desktop): vue-tsc — IAgenda для QuestionsTable + IPayment.id:string' (#45) from fix/desktop-vue-tsc-errors into dev
Reviewed-on: #45
2026-05-28 16:42:01 +00:00
Alex Ant 9d5d3ae967 chore(release): publish
Build bootstrap container / build (push) Successful in 3m29s
Release / release (push) Successful in 26m5s
Release / publish-packages (push) Successful in 14m41s
Release / trigger-coopenomics-docs (push) Successful in 1s
Release / trigger-mono-docs (push) Successful in 2s
2026-05-28 21:31:36 +05:00
Alex Ant b8ff69f40b Merge branch 'main' of ssh://git.coopenomics.world:222/C9S/mono 2026-05-28 21:31:03 +05:00
Alex Ant 2747ac34a4 chore(release): publish
Build bootstrap container / build (push) Successful in 3m27s
Release / release (push) Successful in 26m48s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-05-28 21:17:47 +05:00
coopops 745df7f6cc [fix-vue-tsc][@dark] fix: IPayment override → локальный IPaymentRow в виджете
Typecheck / desktop (pull_request) Successful in 13m36s
Typecheck / controller (pull_request) Successful in 13m14s
Глобальный Omit<IPayment,'id'>&{id:string} ломал store.ts/mergePayments
и features/Payment/SetStatus/api (туда приходит сырой Zeus-вывод с
id:unknown). Откатываю override в entities/Payment/model/types.ts,
сужаю id до string локально в ListOfPaymentsWidget через IPaymentRow —
точка сужения только в UI, контракт стора/API не трогается.

vue-tsc desktop локально: PASS.
2026-05-28 15:32:24 +00:00
coopops 61283a7276 [fix-vue-tsc][@dark] fix: vue-tsc desktop — IAgenda в QuestionsTable + IPayment.id:string
Typecheck / desktop (pull_request) Failing after 8m13s
Typecheck / controller (pull_request) Successful in 13m20s
QuestionsTable принимал Cooperative.Document.IComplexAgenda[] (cooptypes,
table:IDecision без certificates, id:IUint64=number|string), а сверху
ListOfAgendaQuestions передаёт IAgenda[] из desktop/entities/Agenda
(GraphQL-вывод getAgenda, table:BlockchainDecision с certificates,
id:number). Перевожу QuestionsTable на IAgenda — это реальная форма
данных из стора; уходит расхождение IComplexAgenda↔IAgenda для
:agenda='row' в QuestionCard (он тоже IAgenda).

IPayment.id был ModelTypes['ID']=unknown (Zeus не имеет scalar-resolver
для ID), из-за чего шаблон ListOfPaymentsWidget падал на expanded.get/.set
и :id-биндингах. Переопределяю id как string через Omit+&.
2026-05-28 15:08:37 +00:00
ant 14bee19c87 Merge pull request 'Минимум членов совета на этапе Install Coop: 5 → 3' (#44) from fix/min-soviet-members-3 into dev
Reviewed-on: #44
2026-05-28 15:01:04 +00:00
coopops 5ee3a60b72 [soviet][@dark] fix: MIN_SOVIET_MEMBERS_COUNT 5 → 3 для прод
Typecheck / desktop (pull_request) Failing after 8m42s
Typecheck / controller (pull_request) Has been cancelled
Снижает минимальный порог состава совета кооператива на этапе
Install Coop с 5 до 3 человек. Единственный ограничитель —
контракт soviet::createboard (см. consts.hpp + createboard.cpp:58).
Backend (install.interactor) и desktop (SetSovietForm) собственных
числовых проверок не имеют — фронт требует только «хотя бы одного»,
бэкэнд передаёт массив как есть в blockchainPort.createBoard.
2026-05-28 14:55:00 +00:00
ant 0ff8bb5775 Merge pull request 'design-wave2: legacy-stylers.scss → canon-токены + снос мёртвых классов' (#43) from feat/design-wave2-legacy-stylers-canon into main
Reviewed-on: #43
2026-05-28 14:50:20 +00:00
coopops 117d79ba1f [design-wave1][@dark] feat: снос legacy-stylers.scss целиком — canon-стили инлайн в потребителях
Что сделано:
- Перенёс canon-стили card-label/card-value/section-title локально в scoped
  блоки каждого реального потребителя (26 файлов).
- 7 файлов которые казались USE-ONLY оказались false-positive substring
  совпадений (.resource-info-card / .meet-info-card / .membership-info-card /
  .tariff-card-container / .cmdk-empty-state / .import-wizard__section-title /
  .result-section-title) — глобальные классы info-card/card-container/
  empty-state/section-title для них уже не нужны.
- Все 8 живых классов из legacy-stylers.scss (.card-label/.card-value/
  .section-title/.card-container/.info-card/.empty-state/.section-header/
  .title-container) теперь имеют ноль use-only потребителей: каждый файл
  имеет либо local scoped def, либо использует другой класс с похожим именем.
- Удалил `src/css/legacy-stylers.scss` целиком.
- Убрал `'legacy-stylers.scss'` из css array в `quasar.config.cjs`.

Метрики:
- 26 файлов получили scoped-блоки (~6-10 строк canon-токенов в каждом).
- 1 файл удалён (legacy-stylers.scss, 134 строки).
- 1 импорт убран (quasar.config.cjs).

Sanity:
- Все 26 scoped scss-блоков прокомпилированы через sass.compileString — OK.
- false-positives откатил через git checkout HEAD.

Файл legacy-stylers.scss больше не существует.
Долг wave1/2/3 закрыт.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:18:08 +00:00
ant 5041abe851 Merge pull request 'design-wave1: миграция desktop на canon — Form/ModalBase/Styles shims снос' (#42) from feat/design-wave1-cleanup-dialogs-forms into dev
Reviewed-on: #42
2026-05-28 14:10:13 +00:00
coopops 40c39b93fc [design-wave1][@dark] feat: legacy-stylers.scss → canon-токены — снос мёртвых классов
Что сделано:
- Снёс 8 классов с 0 потребителей: info-card-hover, page-main-card,
  card-title, card-action-btn, balance-card{,-primary,-warning},
  info-warning-card, section-card-warning (со всеми вложенными
  .balance-label/.balance-value/.warning-info/.section-content/...).
- Снёс глобальный .profile-section — единственный потребитель
  (MicroWallet) определяет его локально в <style scoped>.
- Перевёл живые классы на canon-токены var(--p-*):
  • title-container — layout-only, токенов не требует.
  • card-container — radius/border/bg через --p-r-lg/--p-line-1/--p-surface.
  • info-card — surface-2/line-1/r-md/p-4/p-3.
  • section-header + section-title — h2 (--p-fs-h2), color --p-ink,
    section-icon margin --p-3, header margin-bottom --p-6.
  • card-label/card-value — --p-fs-body/--p-ink-2 vs 16/500/--p-ink.
  • empty-state — p-8/p-5/p-4/p-2/h2/body/ink-1/ink-2.
- Убрал ручные dark-overrides (.body--dark &, .q-dark &) —
  canon-токены сами переключаются через [data-theme="dark"] в tokens.css.
- Media @max-width:768px ужал до одного правила (мобильный h3 для секций).

Метрика: 433 → 56 строк (-377/+56), CSS-выхлоп 2.1 КБ.

Не тронуто:
- Все 22-23 потребителя card-value/card-label — большинство определяет
  стили локально (LOCAL), 5 USE-ONLY компонентов наследуют глобальный.
- 13 потребителей section-title — 6 LOCAL, 7 USE-ONLY.

Долг: переписать USE-ONLY компоненты на canon-виджеты (ColorCard / BaseCard),
после чего глобальные card-* и section-* можно полностью снести
вместе с этим файлом.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:57:49 +00:00
coopops 04e7503e7d [design-wave1][@dark] feat: миграция extensions на канон — снос ModalBase/TitleStyles/CardStyles/AutoAvatar shim'ов
Typecheck / desktop (pull_request) Failing after 8m58s
Typecheck / controller (pull_request) Successful in 13m16s
ModalBase → BaseDialog (18 call-sites):
* extensions/capital — 14 features + 1 widget (ImportContributorsButton).
* extensions/chairman — 2 approval features + 1 page (AgendaPresetsPage).
* extensions/powerup — 1 widget (ResourceInfoWidget, через barrel).
Паттерн: q-dialog внутри q-btn → BaseDialog параллельно q-btn в общей div-обёртке.
persistent=true → :close-on-backdrop='false' :close-on-escape='false'.
@hide → @update:model-value='(v) => !v && callback()'. maximized → :maximized='true'.
ProjectInvestFabAction: два дублирующих q-dialog объединены в один BaseDialog.

TitleStyles + CardStyles → src/css/legacy-stylers.scss (глобально через quasar.config.cjs).
Все 8 side-effect import'ов в extensions/capital удалены.
Долг: переписать .title-container/.info-card/.card-* классы на canon-токены.

AutoAvatar: 1 импорт в CapitalProfilePage переключён на shared/ui/domain/AutoAvatar.
Shim shared/ui/AutoAvatar снесён.

Удалены: shared/ui/{ModalBase,TitleStyles,CardStyles,AutoAvatar} + reexport ModalBase
из shared/ui/index.ts. Design-wave1 закрыт полностью.
2026-05-28 13:42:39 +00:00
coopops 8d2962b786 [design-wave1][@dark] fix: вернуть re-export ModalBase в shared/ui/index.ts
ResourceInfoWidget в extensions/powerup импортирует ModalBase через
barrel: import { ModalBase } from 'src/shared/ui'. После сноса
re-export'а vite валился SyntaxError: does not provide an export.
Возвращаю строку обратно — папка ModalBase уже восстановлена
предыдущим коммитом.
2026-05-28 13:10:30 +00:00
coopops 15465e805e [design-wave1][@dark] fix: вернуть ModalBase/TitleStyles/CardStyles/AutoAvatar для extensions
В прошлой волне снёс shared/ui/{ModalBase,TitleStyles,CardStyles,AutoAvatar},
но смотрел только src/. В extensions/{capital,chairman}/ осталось:
* ModalBase — 17 файлов
* TitleStyles (side-effect SCSS) — 6 страниц capital
* CardStyles (side-effect SCSS) — 1 виджет capital
* AutoAvatar — 1 страница capital
Из-за этого vite валился на 404 TitleStyles → "Failed to fetch dynamically
imported module src/boot/init.ts" и фронт не грузился.

Восстановлены оригинальные файлы. AutoAvatar в shared/ui/AutoAvatar
оставлен как re-export нового канон-расположения shared/ui/domain/AutoAvatar.
Полная миграция extensions на BaseDialog и канон-токены — отдельная волна.
2026-05-28 13:08:03 +00:00
coopops b69920e263 [design-wave1][@dark] fix: cooperativeAgreements — не падать на пустом coopname
При bootstrap'е фронта RequireAgreements/SignUp успевают смонтироваться
ДО завершения system.loadSystemInfo(), и info.coopname=undefined.
Zeus сериализует variables в {} → сервер кидает
"Variable $coopname of required type String! was not provided".

Решение по слою:
- api/index.ts: гард — если coopname пустой, не отправляем запрос.
- RequireAgreements/SignUp: watch(() => info.coopname) — load
  отрабатывает, как только coopname прорастёт, без блокировки UI.
2026-05-28 13:03:44 +00:00
coopops 298e1919e8 [design-wave1][@dark] feat: BaseDialog :maximized — закрываем последние 5 maximized + сносим ModalBase
В BaseDialog добавлен prop :maximized (boolean) — q-dialog получает maximized, q-card растягивается на весь экран, body становится flex-scrollable. При :maximized размер size игнорируется.

Это покрывает 5 last maximized call-sites:
* Agreementer.SignAgreementDialog — fullscreen для подписи договора (hideCloseButton)
* Agreementer.ReadAgreementDialog — fullscreen для просмотра договора
* Agreementer.StaticPrivacyDialog — fullscreen для статичной privacy policy
* Branch.SelectBranch.SelectBranchOverlay — fullscreen onboarding flow с шагами 1/2
* Decision.CreateProject.CreateProjectButton — fullscreen для предложения повестки

Все 5 переведены на BaseDialog :maximized='true' :close-on-backdrop='false' :close-on-escape='false'.

После миграции ModalBase больше не используется — удалена папка shared/ui/ModalBase и реэкспорт из shared/ui/index.ts. Design-wave1 закрыт полностью.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:49:56 +00:00
coopops 7735c40d06 [design-wave1][@dark] refactor: ModalBase → BaseDialog в 14 call-sites — основная часть миграции
Простые модальные диалоги переведены с q-dialog+ModalBase на canon BaseDialog (внутри уже q-dialog), API v-model:modelValue:

* features: DeleteBranch / CreateBranch / DepositToWallet (двух-стейтовый dialog) / WithdrawFromWallet / AddPaymentMethod / DeletePaymentMethod / Decision.CreateProjectFreeDecision (lg) / FreeDecision.CreateProject (md) / Payment.SetStatus.SetOrderPaid/Completed/Refunded
* widgets: ConnectionDashboard.AxonWallet / Desktop.WorkspaceMenu (size=lg вместо 700px-фикса)
* shared: CreateDialog (общий wrapper) / CouncilOnboarding.CouncilOnboardingCard (кнопки в slot footer)

Persistent → :close-on-backdrop='false' + :close-on-escape='false'. @hide='clear' → @update:model-value с проверкой v===false. style='width: NNNpx' переведён на canon size sm/md/lg.

Что НЕ мигрировалось (5 файлов остались с ModalBase):
SelectBranchOverlay, SignAgreementDialog, ReadAgreementDialog, StaticPrivacyDialog, Decision.CreateProject.CreateProjectButton — все используют :maximized='true'. BaseDialog в canon не имеет fullscreen-режима, расширять canon без согласования — нарушение правила "Канон-нейминг согласовывать ДО реализации". Эти 5 кейсов оставлены legacy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:48:41 +00:00
coopops 41d0af6706 [design-wave1][@dark] refactor: Form → shim над BaseForm/BaseButton — 20 call-sites визуально canon
Form.vue переписан как тонкий wrapper над canon BaseForm + BaseButton с сохранением старого API (handlerSubmit/isSubmitting/showCancel/showSubmit/buttonSubmitTxt/buttonCancelTxt/disabled/size).

Что изменилось визуально:
* Кнопки cancel/submit вынесены в slot #footer (canon-layout с gap=14px между body и footer).
* Cancel — variant='ghost' (плоская), Submit — variant='primary' (canon-primary), оба BaseButton с no-caps/без ripple.
* Кнопки выровнены justify-content:flex-end (canon-pattern для форм), вместо старого .flex без выравнивания.

API call-sites не трогаем — 20 form-call-sites (Branch/Wallet/PaymentMethod/Agreementer/Decision/Union/FreeDecision/Payment/Request/AxonWallet) получают canon-визуал сразу.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:47:15 +00:00
coopops 8356b507f8 [design-wave1][@dark] chore: снос CardStyles — заинлайнил единственный live-класс info-card
* Глобальный CardStyles из 430 строк CSS реально использовался только через .info-card в RestartMeetForm.vue.
* Все остальные классы (card-container, section-header, card-label, card-value) определены локально в scoped CSS соответствующих компонентов — глобальный импорт лишний.
* Стили .info-card (+ dark-override) заинлайнены в scoped стиль RestartMeetForm под нативным именем .meet-form-agenda-item.
* Удалены: глобальный импорт в App.vue, @import в RestartMeetForm, папка shared/ui/CardStyles.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:47:15 +00:00
coopops c306789ed2 [design-wave1][@dark] chore: снос мёртвых InputStyles + TitleStyles — волна 5
* InputStyles — весь файл состоял из закомментированного кода.
* TitleStyles — .title-container не использовался ни в одном .vue.
* Удалены 3 импорта InputStyles в Editable*Card и реэкспорт TitleStyles из shared/ui/index.ts.

CardStyles оставлен: используется в 4 call-sites (RestartMeetForm, UnionMembershipStep, TariffCard, MeetInfoCard) — миграция отдельным шагом.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:47:15 +00:00
coopops 11b0d6f123 [design-wave1][@dark] refactor: ToogleDarkLight → ThemeToggle + AutoAvatar в domain — добиваем волну 5
* ThemeToggle получает asButton/showText/isMobile, поглощая роль ToogleDarkLight (исправлена опечатка в имени).
* AutoAvatar перенесён в shared/ui/domain/AutoAvatar — он DiceBear-процедурный, а не base-примитив (canon Avatar делает инициалы/image и не заменяет процедурную генерацию).
* Снесены legacy папки shared/ui/ToogleDarkLight и shared/ui/AutoAvatar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:47:15 +00:00
ant 8bdc3dd0cd Merge pull request 'Канон тостов и шапка-мобильник: bottom-right + accent-полоска + icon-only CTA' (#38) from feat/notif-mobile-canon into dev
Reviewed-on: #38
2026-05-28 11:57:00 +00:00
coopops 0229b639b2 [design-wave2][@ant] fix: навигатор — canon clean-up + сохраняем primary
Typecheck / desktop (pull_request) Failing after 8m23s
Typecheck / controller (pull_request) Failing after 1m48s
- default.vue: убрал bordered у левого q-drawer (двойная линия с .rail
  border-right из канона) и hardcoded #00800038 (legacy зелёное cooperative)
  → var(--p-line); удалил мёртвый .drawer-close-btn.
- components.css: комментарий .rail__usercard поправлен — там primary,
  а не accent (намеренно: оранжевый в шапочке навигатора отвлекал бы).
- _dev/ui: текст секции AppDrawer привёл к фактическому поведению
  (soft-primary заливка + 2-px deep-teal rail).

Цветовая схема навигатора:
- активный пункт — primary (deep teal #0f766e), 2px rail слева;
- brand-иконка, avatar, balance-блок — primary-soft (не accent);
- никакого accent (оранжевого) в навигаторе.
2026-05-28 09:44:55 +00:00
coopops 64fa0d2f97 [design-wave2][@ant] docs: строгое указание использовать дизайн-канон в desktop
Typecheck / desktop (pull_request) Failing after 8m16s
Typecheck / controller (pull_request) Successful in 13m28s
В CLAUDE.md новый раздел «ДИЗАЙН-КАНОН desktop — ОБЯЗАТЕЛЕН»:
1) HTML-канон ~/blago/production/shared/MONO Design System.html;
2) Живая реализация components/desktop/src/pages/_dev/ui/index.vue.

После компакта забыл, что shared/MONO Design System.html — основной SoT,
и нашёл «канон» в auth-prototype, который НЕ канон — пришлось переверстать
тосты. Фиксирую правило, чтобы не повторять.
2026-05-28 09:33:57 +00:00
coopops 82478155ce [design-wave2][@ant] fix: capital header-кнопки в canon micro-режим
Typecheck / desktop (pull_request) Failing after 8m19s
Typecheck / controller (pull_request) Successful in 13m6s
Завершаю аудит — добавил capital по запросу:
- RouteMenuButton получил опциональный icon-проп; на мобильном
  превращается в round-иконку + tooltip, на десктопе — flat + label.
- В ProjectPage и ComponentPage прописал material-иконки для каждого
  раздела (Описание/Артефакты/Компоненты/План/Участники/История/
  Задачи/Голосование/Результаты).
- ImportContributorButton, ImportContributorsButton, FilterDialogWithButton
  переведены в canon-micro (isMobile → flat+dense+sm+accent + tooltip).
  Заодно вынес q-dialog из q-btn-вложенности.

RouteMenuButton по сути — раздельные таб-кнопки навигации; полноценное
UX-решение для capital (вынести в SecondLevelTabs под шапкой) — отдельная
задача, тут только мобильная читаемость шапки.
2026-05-28 09:03:45 +00:00
coopops ecd895e745 [design-wave2][@ant] fix: остальные header-кнопки в canon micro-режим
Typecheck / desktop (pull_request) Failing after 8m21s
Typecheck / controller (pull_request) Successful in 13m9s
Аудит точек установки в шапку — два механизма:
1. registerAction(useHeaderActions) — Members/Branches/Documents/Agenda
   /Participants/CoopWallets/powerup.Settings;
2. Teleport to=#header-actions-host — PaymentMethods/Documents/Wallet/Meets.

Уже в каноне: SearchHeaderAction, CreateMeetButton, DepositButton,
WithdrawButton, AddPaymentButton, AddMemberButton, CreateBranchButton.

Доделал остальные:
- CreateProjectButton (Предложить повестку);
- AddUserButton (Добавить пайщика);
- ImportParticipantsButton (Импорт);
- TransferWalletsButton (Перевести между кошельками);
- powerup SettingsPage save-кнопка (через computed + watch на isMobile).

Капитал-расширение по правилу проекта не трогаю.
2026-05-28 08:44:25 +00:00
ant e2f467b78f Merge pull request 'fix(desktop): четыре vue-tsc ошибки — типизация props и string-fallback'и' (#39) from fix/vue-tsc-desktop into dev
Reviewed-on: #39
2026-05-28 08:43:33 +00:00
coopops c29c3720d2 [design-wave2][@ant] fix: AddMember/CreateBranch — canon micro в шапке на мобиле
Typecheck / desktop (pull_request) Failing after 8m14s
Typecheck / controller (pull_request) Successful in 12m59s
CSS-хак `.topbar__actions .base-btn__label { display: none }` не покрывал
эти две кнопки: они рисуют q-btn напрямую через registerAction (не через
BaseButton под .topbar__actions). В итоге на мобильном «Добавить члена»
и «добавить участок» оставались с лейблами и распирали шапку.

Перевёл обе в canon-паттерн как CreateMeetButton/DepositButton/WithdrawButton:
isMobile → flat+dense+sm+accent, иконка + q-tooltip; без обёртки
.header-action, чтобы кнопка не растягивалась на всю высоту шапки.
2026-05-28 08:33:56 +00:00
coopops 45ef337201 [design-wave2][@ant] fix: канон тостов под shared/MONO Design System.html + dev/ui демо
Typecheck / desktop (pull_request) Failing after 8m14s
Typecheck / controller (pull_request) Successful in 13m2s
- quasar-canon.css: переписан override .q-notification под реальный
  канон (shared/MONO Design System.html → .toast): тёмный фон
  (var(--p-ink) для нейтрального; #052e16/#4c0a0a/#3d2400/#0c1e3f
  для positive/negative/warning/info), светлый текст, ЦВЕТНАЯ иконка
  по типу (--p-pos/--p-neg/--p-warn/--p-info). Раньше ошибочно был
  сделан нейтральный фон + left-accent-полоска — «откуда попало»,
  не из канона.
- alerts.ts: убран color: 'grey-7'/'positive' у actions — они
  наследуют светлый тон от тёмного фона через override.
- _dev/ui секция 39 «Тосты»: пять кнопок для триггера всех типов
  тостов (успех, успех с CTA, ошибка, in-app push, push с avatar).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 07:48:40 +00:00
coopops e0e846c24b [design-wave2][@ant] fix: канон тостов и шапка-мобильник — bottom-right + accent-полоска, icon-only CTA
Typecheck / desktop (pull_request) Failing after 8m14s
Typecheck / controller (pull_request) Successful in 13m1s
- SuccessAlert/FailAlert/NotifyAlert (shared/api/alerts.ts): единый
  визуал через override .q-notification в quasar-canon.css. Нейтральная
  поверхность + 3px accent-полоска по типу (positive/negative/warning
  /info), без «цветной заливки карточки» (stop-signal §19.1).
- Позиция тостов bottom-right — было top-right, что противоречило
  канону mono-design-system v2 (toast-host: fixed; bottom: 24px;
  right: 24px). На мобильнике — full-width минус 16px полей.
- SuccessAlert: type:'positive' вместо color:'primary' — раньше успех
  красился брендовым teal, а не позитивным зелёным.
- NotifyAlert: дефолтная иконка notifications, если нет avatar — чтобы
  in-app push от Novu визуально совпадал с Success/Fail.
- Topbar @media (max-width:600px): .topbar__actions .base-btn__label
  скрыт, остаются только иконки CTA-кнопок страницы. Бренд-название
  с ellipsis (max-width: 50vw). Раньше кнопки переносились на вторую
  строку на узких экранах.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 06:59:37 +00:00
coopops 81db8355e0 [fix-vue-tsc-desktop][@ant] fix: четыре vue-tsc ошибки в desktop — типизация props и string-fallback'и
Typecheck / desktop (pull_request) Failing after 8m22s
Typecheck / controller (pull_request) Successful in 13m12s
CI vue-tsc --noEmit падал на четырёх файлах:

- QuestionsTable.vue: props.decisions типизирован через
  PropType<Cooperative.Document.IComplexAgenda[]>. Раньше тип был
  плоский Array → row в v-for становился unknown, и template
  ругался на row.table.id, isProcessing(row.table.id) и т.п.
- ListOfPaymentsWidget.vue: явный computed items: IPayment[] через
  cast (as unknown as IPayment[]) — zeus резолвил items слабее, чем
  нужно шаблону, и поля row.id / username / quantity / etc. оставались
  unknown.
- ExtensionPage.vue и ExtensionCard.vue: для AutoAvatar :username
  добавлен fallback || '' — extension.name / extension.title могут
  быть string | null | undefined, AutoAvatar требует string.

ESLint по четырём файлам — EXIT=0; tsc-прогон делегирован CI
(точечный vue-tsc по факту тащит весь проект и вешает машину, см.
memory feedback_no_full_vue_tsc_desktop).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 06:50:12 +00:00
Alex Ant c3c49cc297 Merge branch 'main' into dev
Build bootstrap container / build (push) Successful in 2m26s
2026-05-27 23:52:26 +05:00
Alex Ant f4a5a8ce2d chore(release): publish
Build bootstrap container / build (push) Successful in 4m7s
Release / release (push) Successful in 42m38s
Release / publish-packages (push) Successful in 14m54s
Release / trigger-coopenomics-docs (push) Successful in 1s
Release / trigger-mono-docs (push) Successful in 2s
2026-05-27 23:52:22 +05:00
Alex Ant 2bbeecfa4f Merge branch 'testnet' 2026-05-27 23:36:46 +05:00
Alex Ant a32ec9532b Merge branch 'dev' of ssh://git.coopenomics.world:222/C9S/mono into dev 2026-05-27 23:36:09 +05:00
ant 67b808db04 Merge pull request 'fix(security): не отдавать matrixRoomId на фронт — комнаты секретаря и транскрипции по opaque id' (#37) from feat/chatcoop-hide-matrix-room-id into dev
Reviewed-on: #37
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-05-27 09:22:52 +00:00
coopops 0bc05de392 chore(release): publish
Build bootstrap container / build (push) Successful in 3m4s
Release / release (push) Successful in 26m12s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-05-27 09:22:05 +00:00
coopops 9f1058c997 Merge branch 'dev' into testnet
Build bootstrap container / build (push) Successful in 3m22s
2026-05-27 09:19:48 +00:00
ant 3c2a03df6f [nodemon-restart-storm][@ant] fix: debounce nodemon + SIGTERM в controller/nodemon.json — устранить restart-storm, вешавший coopback на bind-mount
Без delay nodemon реагировал на каждое fs-событие пачки (правки/сборка/git на хосте) десятками рестартов в секунду; pstree.remy без `ps` в образе виснет на обходе /proc (накапливались зомби-сканеры), холодный старт ts-node прерывался, порт 2998 не занимался — бэкенд молча лежал. delay:2000 схлопывает пачку в один рестарт, signal:SIGTERM даёт чистое завершение дочернего процесса.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:30 +00:00
ant 8209482556 [fix-ext-config-lost-update][@ant] fix: убрать lost-update конфигов расширений в периодических задачах — cron-планировщики перезаписывали config устаревшим in-memory снимком с boot и стирали онбординг-флаги/хэши председателя
extensionRepository.update заменяет config JSONB целиком; chairman/powerup/meet-tracker писали захваченный при initialize снимок поверх свежих данных. Теперь read-modify-write по актуальному config из БД с наложением только владомых полей.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:30 +00:00
coopops cd323add27 [design-wave1][@dark] feat: тактильная отдача (вибрация) при перезагрузке страницы
Boot-файл haptics: при reload страницы на устройстве с Vibration API (мобильный
браузер / PWA на Android) коротко вибрирует (15 мс). Reload определяется через
Navigation Timing (с фолбэком на legacy API), чтобы не срабатывать на первой
загрузке/навигации. Полностью безопасно для десктопа и обычных сайтов: где
Vibration API нет — вызов не делается, любая ошибка проглатывается, на загрузку
приложения не влияет. Только клиент (гард для SSR).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:30 +00:00
coopops 3d4e7fae0f [design-wave1][@dark] fix: убрать паразитный горизонтальный скролл левого дровера
В левом дровере был маленький горизонтальный скроллбар, и трекпадом его можно
было «оттянуть», обнажая правую границу — дровер выглядел скроллируемым, хотя не
должен. Причина: у .rail собственный border-right (1px), который при content-box
даёт ~1px overflow внутри контента дровера. Фикс в .app-left-drawer: overflow-x
hidden + overscroll-behavior-x contain на .q-drawer__content, .rail приведён к
box-sizing border-box и width 100%.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:30 +00:00
coopops a829e9452b [design-wave1][@dark] fix: повестка — держать лоадер на «Утвердить» 3с после голоса
Если проголосовать и сразу нажать «Утвердить», утверждение падает с ошибкой —
бэкенд ещё не учёл голос из блокчейна. После успешного голоса держим состояние
загрузки пункта (а значит и кнопку «Утвердить» в :loading) ещё VOTE_SETTLE_MS
(3с), за это время голос успевает обработаться. На ошибке загрузка снимается
сразу. Заодно ручное мутирование processingDecisions + setTimeout-хак заменены
аккуратным реактивным хелпером setProcessing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:30 +00:00
coopops 64809ab37c [design-wave1][@dark] fix: повестка — скрывать пункт только при утверждении, не при голосовании
Повестка совета показывает все НЕутверждённые вопросы. Голос «за»/«против» не
должен убирать пункт — он остаётся неутверждённым, лишь помечается отметкой
голоса. Убрал ошибочное actedDecisionIds.add из onVoteFor/onVoteAgainst
(оставлен только тихий рефетч). Скрытие через actedDecisionIds оставлено только
в onAuthorizeDecision — там пункт исполняется и должен уйти из повестки без
возврата от отстающего поллинга.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:30 +00:00
coopops 8586aab85a [design-wave1][@dark] fix: повестка совета — без моргания и без «исчез/вернулся» при голосовании
Две проблемы при голосовании/утверждении в повестке совета:

1. Моргание всей страницы: QuestionsTable подменял список тремя
   скелетонами при любом loading=true, а пост-экшен loadDecisions вызывался
   без hidden → loading=true → список «моргал» в скелетоны и обратно.
   Скелетоны теперь показываются только на первой загрузке (loading &&
   !decisions.length), а пост-экшен рефетчи переведены в тихий режим (hidden).

2. «Исчез → вернулся → исчез»: повестка на бэкенде уже не отдаёт пункт, по
   которому проголосовал/утвердил, но данные из блокчейна доходят с задержкой,
   и поллинг успевал вернуть отработанный пункт. Добавлен локальный набор
   actedDecisionIds — проголосованный/утверждённый пункт прячется сразу и не
   возвращается, что бы ни подтянул отстающий рефетч. Просто, без оптимистичных
   merge-оверлеев.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:30 +00:00
coopops d7ddfeb0f2 [design-wave1][@dark] fix: push-подписка не должна вешать UI и шуметь красной ошибкой
Две независимые проблемы при выдаче разрешения на уведомления:

1. Фриз приложения: shouldShowDialog был computed с побочным эффектом —
   внутри геттера вызывался updateSupport(), присваивавший store.support
   новый объект на каждое чтение. После выдачи разрешения это давало каскад
   инвалидаций/ре-рендеров, забивавший главный поток (роутер менял URL, DOM
   «застывал»). Геттер сделан чистым, updateSupport() вынесен в showDialog().

2. Красная ошибка «Service Worker не готов в течение 5 секунд»: в dev без PWA
   SW намеренно не регистрируется, поэтому navigator.serviceWorker.ready
   никогда не резолвится. Теперь сначала проверяем getRegistrations() — при
   отсутствии SW выходим сразу и тихо (warn вместо error), а subscribe()
   возвращает false без FailAlert. Push при недоступности SW полностью
   изолирован и не влияет на работу приложения.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:29 +00:00
coopops e41fbf4570 [design-wave1][@dark] feat: скрывать «Совершить взнос»/«Получить возврат» до подписи соглашения кошелька
Кнопки взноса и возврата на столе пайщика недоступны, пока пайщик не
подписал главное соглашение цифрового кошелька (type='wallet') — как и
сама карточка кошелька, которая не появляется без подписанного соглашения.

Геттер isWalletAgreementSigned в сторе Wallet читает тот же список
соглашений пайщика, что и RequireAgreements.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:29 +00:00
coopops 0f648d0bd6 [design-wave1][@dark] copy: явная опциональность шага «Импорт пайщиков» (онбординг председателя)
«(опционально)» в заголовок + пояснение в описании: если есть пайщики —
импортируйте CSV, если нет — двигайтесь дальше. Чтобы шаг не воспринимался
обязательным.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:29 +00:00
coopops 7af7a64091 [design-wave1][@dark] fix: ключ в Invite — выделенная панель вместо readonly-инпута
readonly outlined q-input рендерится с dashed-рамкой (дефолт Quasar) и
обрезал значение (…UJR4) — выпадало из канона и выглядело плоско.
Сделал ключ surface-2 панелью: eyebrow-лейбл + copy-иконка в шапке,
mono-значение целиком (word-break) ниже. Даёт вес и убирает «ёлочку».

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:29 +00:00
coopops 81b066e697 [design-wave1][@dark] fix: канон-композиция карточки «Сохраните ключ» (Invite)
Прошлый вариант разъезжался: «Скопировать» висел сиротой справа, провалы
между блоками, узкая потерянная кнопка, карточка 560px — разрежено.
Переделал по канону:
- копирование ключа — иконкой content_copy в append самого поля (+tooltip)
- кнопка «Установить ключ» — full-width (block), как «Войти» в LoginForm
- ширина карточки 480 как у остальных auth-карточек
- чекбокс слева, плотный ритм без лишних margin (reserve-hint-space)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:29 +00:00
coopops eac08d3438 [design-wave1][@dark] copy: полные примеры ОПФ+ в placeholder (с «Социального Комплекса»)
Голый «Потребительский Кооператив» в примере провоцировал вводить просто
ОПФ без расширения. Дал полный пример «...Социального Комплекса» во всех
трёх падежах — чтобы вводили расширенную ОПФ+, а не базовую форму.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:29 +00:00
coopops 933b0698be [design-wave1][@dark] copy: шаг «Настройка фабрики документов» вместо «Переменные документов»
Это не переменные, а постоянные параметры, которыми настраивается фабрика
документов кооператива. Переименовал label и intro шага.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:29 +00:00
coopops 6811bfa6fa [design-wave1][@dark] revert: убрать глобальный margin-bottom у .q-field--with-bottom
Микроотступ стал лишним и снова раздвигал поля слишком широко — откатываю.
Разделение полей обеспечивает сам reserve-hint-space, дополнительный
глобальный отступ не нужен.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:29 +00:00
coopops c2bbe68e2c [design-wave1][@dark] fix: примеры-подсказки в placeholder вместо hint (SetVariablesForm)
Постоянный hint под полем занимал место и отвлекал. Перенёс примеры
наименований ОПФ+ в placeholder (видны только в пустом поле), убрал hint.
Добавил reserve-hint-space всем q-input формы — строка под ошибку
резервируется без текста, валидация не вызывает layout shift.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:29 +00:00
coopops 17bfd2b5cd [design-wave1][@dark] fix: системный микроотступ под полями с hint/error
Без зазора текст hint/error верхнего поля почти касался границы нижнего.
Вернул маленький отступ, но системно и один раз — глобально в quasar-canon:
margin-bottom: var(--p-1) только полям .q-field--with-bottom (те, что
резервируют нижнюю строку). Работает везде (BaseForm, формы установки,
регистрация), в стеках не складывается в дыру, инлайновые поля не трогает.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:19:29 +00:00
claude ebf4929a66 booter fix 2026-05-27 09:19:29 +00:00
coopops 1961345a54 [design-wave1][@dark] fix: убрать двойной зазор между полями форм установки
reserve-hint-space у q-input/BaseInput уже даёт ~24px снизу под error/hint;
дополнительный gap в стеках полей складывался с ним → избыточное расстояние.
Убрал gap (канон BaseForm__body):
- CreateOrganizationDataForm / IndividualDataForm (.user-data-stack)
- SetVariablesForm (.vars-section__fields)
- RequestKeyForm (.request-key)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:52 +00:00
coopops 23918d6aea [design-wave1][@dark] fix: dense-инпуты в формах установки кооператива
Канон-плотность полей (dense) была только в BaseInput; общие формы
CreateOrganizationDataForm и IndividualDataForm + поля шагов установки
рендерили крупные не-dense q-input. Добавил dense:
- CreateOrganizationDataForm (22 поля) — данные организации
- IndividualDataForm (6 полей) — члены совета
- email-инпуты SetInitForm/SetSovietForm и поля SetVariablesForm

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:52 +00:00
coopops 1e774573fa [design-wave1][@dark] feat: страница установки кооператива в канон
- InstallCooperativePage: VerticalStepper вместо q-stepper, canon-панель
  с accent-стрипом, экран завершения в каноне (без градиентов/shimmer)
- RequestKeyForm: BaseInput (mono) + BaseButton
- SetInitForm: canon info-нотки, кнопки Назад/Далее на BaseButton
- SetSovietForm: canon-карточки членов совета вместо q-card/q-badge
- SetVariablesForm: canon-секции, q-input outlined с правилами, BaseButton
- Invite: осмысленное тело AuthCard при отсутствии токена приглашения

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:52 +00:00
coopops 30d5cf6156 [design-wave1][@dark] fix: убрать двойной отступ сверху на странице расширения
.extension-page уже внутри .catalog-shell__content с padding var(--p-6);
собственный padding давал двойной зазор от шапки до кнопки «Назад».

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:52 +00:00
coopops 303c169fea [design-wave1][@dark] style: панель вокруг контента расширения + «Отменить» в настройках
- Весь контент страницы расширения обёрнут в канон-панель (surface+border),
  больше не лежит на голом фоне
- В режиме настроек добавлена кнопка «Отменить» (CancelButton) рядом с
  «Сохранить» — явный выход без сохранения; кнопка «Назад» страницы тоже работает

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:52 +00:00
coopops 30a9b5c33e [design-wave1][@dark] style: страница расширения — назад на уровень страницы, центровка лого/столов, вкл+удалить в строку
- Кнопка «Назад» убрана из шапки (useBackButton снят), добавлена канон-кнопка
  под шапкой на самой странице
- Логотип AutoAvatar центрирован в колонке
- DesktopsList: центрированный inset-блок (выделяется), а не плоский список
- ext-actions toggle: «включено» и «удалить» снова в одной строке

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:52 +00:00
coopops f03f8f3bed [design-wave1][@dark] feat: страница расширения и его настройки в канон
- ExtensionPage: двухколоночный канон-layout с padding; q-img заменён на
  генеративный логотип AutoAvatar (animated), консистентно с каталогом
- ExtensionInfo: канон-заголовок + статус-бейдж, теги убраны
- ExtensionActions: компоновка кнопок в столбец на токенах, teal→primary
- ExtensionInstall: BaseCard вместо .info-card/text-h5
- DesktopsList: text-grey/subtitle → канон-токены
- SaveButton/SettingsButton/InstallButton: teal→primary, no-caps

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:52 +00:00
coopops a41861f356 [design-wave1][@dark] style: анимация логотипа по наведению на всю карточку
Триггер ринг-анимации перенесён с hover самого SVG на hover карточки
(.app-card:hover :deep(.ring-seg)).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:52 +00:00
coopops 754dbe67a0 [design-wave1][@dark] feat: анимация логотипа приложения по наведению
AutoAvatar получил режим animated: инлайн-SVG вместо <img>, каждый
сегмент-дуга обёрнут в .ring-seg. По hover логотипа сегменты на ~1.3с
расходятся — прямые и зеркальные (matrix-flip) от одного поворота идут
в разные стороны, со стаггером по индексу. CoopCard не затронут (img/blob).
Уважает prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops f6503e291b [design-wave1][@dark] style: убрать фиксированный резерв высоты заголовка карточки
Статус идёт сразу под заголовком (динамично) — без дыры под однострочными
названиями. Обрезку в 2 строки оставили, чтобы длинный заголовок не ломал сетку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops 3461343710 [design-wave1][@dark] style: выровнять карточки приложений при переносе заголовка
Заголовок резервирует 2 строки (clamp+min-height), аватар по верху —
статус и описание на всех карточках начинаются на одной линии, не прыгают.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops 2d1f869774 [design-wave1][@dark] style: компоновка карточки приложения — заголовок и статус рядом с логотипом
Верхний ряд: логотип + (заголовок, под ним статус); описание во всю
ширину ниже; футер «Подробнее» прижат к низу для выравнивания карточек.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops a4498e1a45 [design-wave1][@dark] style: приглушить генеративный логотип каталога
Палитра колец → мягкие приглушённые тона + CSS saturate(.5)/opacity .85,
чтобы знак читался как тихая текстура, а не неоновое пятно.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops 49836cd139 [design-wave1][@dark] feat: генеративный логотип (DiceBear rings) в карточке приложения
- AutoAvatar параметризован: props size/radius/background/ringColor
  с обратно-совместимыми дефолтами (CoopCard не затронут)
- ExtensionCard: буква-монограмма заменена на уникальный rings-логотип,
  seed = имя расширения; цвет кольца детерминирован по seed из палитры

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops 226943a52a [design-wave1][@dark] feat: каталог приложений в канон — табы на 2-й уровень, карточки без изображений
- «Магазин приложений» → «Каталог приложений» (drawer + breadcrumb)
- Витрина/Установленные перенесены из топбара в SecondLevelTabs
- ExtensionCard переверстан: убраны изображение и теги, добавлены
  монограмма-плитка, статус-бейдж, обрезка описания, футер «Подробнее»
- Quasar .row/.col-md-3 → канон auto-fill грид

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops 55983ed3f3 [design-wave1][@dark] fix: вёрстка формы провайдера платежей + текст контактов
- PaymentProviderForm: убраны вложенные q-gutter (кривой левый отступ
  селекта), standout=bg-teal → outlined, text-grey-7 → токен; чистый
  канон-столбец (селект max-width 480, hint ink-2, действия в ряд, no-caps).
- ChangeContacts: текст уточнён — контакты видны всем (раздел «Контакты
  кооператива» пайщикам + подвал сайта незарегистрированным), не только
  незарегистрированным.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops 974fcb305a [design-wave1][@dark] feat: канон для остальных страниц стола председателя
Кооперативные участки, Регистрационные взносы, Ключ кооператива,
Провайдер платежей, Контакты кооператива:
- убран дублирующий заголовок страницы (есть крошка в шапке);
- описание перенесено с голого фона на канон-поверхность .banner;
- page-shell/hero/surface-card с хардкод-px → канон-отступы и токены;
- q-input/q-select standout=bg-teal → outlined color=primary.
Также удалён лишний хвост текста на Стартовых страницах.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops a9fa74fc8c [design-wave1][@dark] fix: убрать дубль заголовка на Стартовых страницах и Членах совета
Заголовок страницы уже показан крошкой в шапке — повтор h2 убран.
Пояснительный текст вынесен с голого фона на канон-поверхность (.banner
с инфо-иконкой). ApprovalsPage не трогаем — там заголовка страницы нет.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops a41550d14d [design-wave1][@dark] feat: канон для Запросов одобрений, Стартовых страниц, Членов совета
- ApprovalsPage: канон-отступы страницы, фильтр-селект teal standout → outlined.
- SystemSettingsPage: hero-card/surface-card с хардкод-px → канон page-head
  (h2 + sub на токенах) + q-card(flat); канон-отступы.
- MembersPage: тот же канон-паттерн вместо hero-card с хардкодами.
- DefaultPagesForm: 4× q-select standout=bg-teal → outlined color=primary.
Виджеты таблиц/состава совета без экзотики — канон-тема через quasar-canon.css.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops 6d50758cc8 [design-wave1][@dark] fix: убрать Vue-warn о class в WorkspaceSwitcher
Шаблон двухкорневой (кнопка + Teleport затемнения), class из родителя
(LeftDrawerMenu) не наследовался автоматически. inheritAttrs:false +
v-bind=$attrs на корневую кнопку — атрибуты направлены явно.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops 26b94c367c [design-wave1][@dark] style: согласовать типографику диалога повестки
Подписи секций «Вопрос на повестке»/«Проект решения» переведены в
надстрочные метки (uppercase, трекинг, ink-3) — отдельный ярус ниже
заголовка окна, чтобы не казаться конкурирующим заголовком рядом с
крупным заголовком самого документа. Текст вопроса — читаемый body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops 60d647c470 [design-wave1][@dark] fix: канон-отступы и чистка онбординга председателя
- ConnectPage: добавлены канон-отступы страницы (var(--p-6)/var(--p-4)) —
  класс .padding в проекте не определён, контент липнул к краям.
- OnboardingStepsCard: убран q-card (двойной паддинг), текст-интро
  растянут на полную ширину (снят max-width 70ch), вокруг кнопки шага
  убрана лишняя рамка (.step__content → .step__action, только отступ).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops d55b3e2fd9 [design-wave1][@dark] feat: онбординг председателя в канон (вертикальный степпер + текст + диалог повестки)
- OnboardingStepsCard: под заголовком «Адаптируйте кооператив…» добавлен
  поясняющий текст (что происходит дальше: решения совета в электронной
  форме, вступление в «Восход», импорт пайщиков, общее собрание) + плашка
  срока адаптации с отсчётом.
- Чек-лист шагов переведён на канон-вертикальный степпер (.stepper--v):
  пройденные — галочка/заливка primary, текущий — подсветка номера,
  действия — канон BaseButton; agenda/import/meet типы сохранены.
- Диалог «Предложение повестки» (объявить собрание совета) переведён
  с ModalBase на канон BaseDialog + BaseButton.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:51 +00:00
coopops e0a9d559a9 [design-wave1][@dark] feat: хлебные крошки в шапке + скрыта кнопка «Перевести»
- CommonHeader: крошка строится из route.matched (путь внутри стола),
  напр. «Отчётность › Календарь»; корень стола не выводится; плоские
  страницы дают одну крошку как прежде; pageTitleOverride сохранён.
- CoopWalletsPage: переводы временно скрыты под флагом transferEnabled=false
  (открывались с L3-кошельков пайщиков; вернём для кооп-кошельков позже).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:50 +00:00
coopops b5cfe40661 [design-wave1][@dark] style: канон-токены в редакторе отчёта (ReportEditorDialog)
- editor-container/action-panel/backdrop/validation-badge/mark-hint: hex и rgba → --p-* токены
- text-grey-8 → t-muted; box-shadow/overlay через токены с fallback

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:50 +00:00
coopops 180e22d07a [design-wave1][@dark] style: канон-токены на списочных страницах бухстола + cells
- Operations/Postings/Accounts: канон page-padding, убран двойной q-mt-md, text-grey-* → t-faint/t-muted, hex-границы → токены
- Settings: канон-padding, save-bar на токены (--p-surface/--p-line/--p-pos), text-grey-7 → t-muted
- AccountIdCell/WalletIdCell: text-grey-6 → t-faint; DirectionCell: move-иконка на --p-ink-3
- PROCESS_COLORS доменного цвет-кодирования операций сохранены (это фича)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:50 +00:00
coopops 34ac74f6a1 [design-wave1][@dark] style: канон-токены на столе бухгалтера — кошельки, календарь, отчётность
- CoopWalletsPage/ParticipantWalletsPage: rgba/var(--q-*) → канон-токены (--p-pos/--p-warn/--p-ink-*)
- ReportsCalendar/CalendarCell: статусы и грид на канон-палитру, убраны body--dark дубли
- Documents подстраницы: убран двойной padding, q-chip→BaseBadge, таблицы в канон-карточку

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:50 +00:00
coopops e23196d492 [design-wave1][@dark] feat: канон-меню второго уровня (SecondLevelTabs) на столе бухгалтера
- SecondLevelTabs на глобальных .tabbar/.tab — sub-навигация между топбаром и контентом
- WalletsPage/DocumentsPage shell-страницы: вкладки в .tabbar вместо RouteMenuButton в топбаре
- TransferWalletsButton: канон header-кнопка (q-btn primary)
- WalletTransferDialog: на BaseDialog + BaseButton, токены вместо rgba

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:33 +00:00
coopops 8206c9f358 [design-wave1][@dark] fix: канон-поля ZodForm и убран дубль заголовка «Настройки»
ZodForm: убран standout="bg-teal text-white" с динамического поля —
QInput/QSelect теперь канон (outlined+dense+color=primary+reserve-hint-space),
как BaseInput. SCSS на канон-токены (вместо неопределённого --q-primary-rgb).
ExtensionSettings: снят заголовок «Настройки» внутри карточки — он дублировал
«Настройки аренды» в шапке.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:33 +00:00
coopops 5e01c25ff8 [design-wave1][@dark] fix: фильтры лога на BaseInput вместо сырого q-input
Канон-инпут вместо q-input: BaseInput расширен поддержкой type="date"
и clearable (+ emit clear) — это нужно для фильтра по датам. Фильтры
в ExtensionLogsList переведены на BaseInput.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 957711e3bc [design-wave1][@dark] feat: настройки и лог расширения в канон
ExtensionSettings: EmptyState + BaseCard вокруг ZodForm вместо самописных
header/empty-state. ExtensionLogsList (используется только powerup):
q-table → список канон-карточек с фильтром по датам, скелетоном,
«Загрузить ещё» и EmptyState; слот #log-item и API сохранены.
Канон-падинги страниц настроек/лога аренды.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 2ee53bc7dd [design-wave1][@dark] feat: Стол вычислительных ресурсов в канон — раскладка, виджеты, лог
Монитор ресурсов: баннер-предупреждение на всю ширину сверху, ряд
«Как это работает» (2/3) + кошелёк AXON (1/3), ряд CPU/NET/RAM.
Убран 3D-флип карт (он же «прыгал») — детали раскрываются inline.
Все виджеты/страницы/лог сведены к канон-токенам surface/line/ink.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 2f0aad0015 [design-wave1][@dark] fix: отступы действий платежей и ширина даты в документах
- Реестр платежей: кнопки действий больше не block (не заполняют ячейку
  от левого края, не липнут к бейджу статуса). Стопка inline-flex,
  равная ширина кнопок (align-items:stretch), прижата к правому краю
  колонки (col-action 168px, text-align:right) — слева остаётся воздух.
- Реестр документов: col-date 116→156px — дата «26.05.2026 08:25» больше
  не налезает на заголовок документа; min-width 720→780.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops e986984dcc [design-wave1][@dark] fix: реестры платежей/документов и форма взноса — ширины и кнопки
- Bank.vue: панель платежа max-width 380px по центру — длинное «Назначение»
  больше не раздувает диалог «Совершите взнос» до max-content; значение
  переносится по строкам (overflow-wrap: anywhere).
- Кнопки статуса платежа SetOrderPaid/RefundedStatusButton → BaseButton
  (primary/danger, block), подписи «Подтвердить»/«Отклонить»; диалог
  вынесен из-под кнопки.
- Реестр платежей: кнопки действий друг под другом (.cell-actions),
  колонка действий 156px, дата без переноса (.col-date 132px), min-width
  таблицы уменьшен.
- Реестр документов: сужены колонки подписей (220→150), действий
  (110→56), id/дата; min-width 880→720 — освобождено место под
  наименование, таблица не вылазит за страницу.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 976b98dd3f [design-wave1][@dark] fix: реестры документов и платежей — канон-поля страницы
Таблицы документов и платежей уже на канон-токенах и самообрамлены
(.table-wrap). Не хватало полей страницы как в реестре пайщиков:
- PaymentsPage рендерил виджет голым, впритык к краям → q-page + 24/16px.
- ListOfDocumentsPage: q-page.padding (16px) → канон-24px.
- Убрана лишняя .row.justify-center обёртка в ListOfDocumentsWidget
  (.col-12 и так во всю ширину).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 7bd98a2a79 [design-wave1][@dark] fix: диалоги «Добавить» и «Импорт» пайщиков в канон
AddUserDialog перестроен в канон-визард по образцу CreateMeetForm:
шапка-bar (surface+line) вместо bg-gradient-dark, VerticalStepper из трёх
шагов — Электронная почта → Тип и данные → Вступительный взнос, BaseButton
в подвале, outlined-инпуты. UserDataForm переиспользован целиком (общий,
не тронут). Опция начисления взноса — канон-блок вместо q-item/q-card.

ParticipantsImportDialog: добавлено описание-интро (раньше отсутствовало),
ModalBase/bg-gradient-dark заменён на канон-bar, выбор типа аккаунтов через
BaseRadioCard, dropzone на токенах вместо хардкод-цветов, секции и действия
в канон-обёртках, BaseButton вместо цветных q-btn. Логика парсинга/импорта
и таблицы превью/результатов сохранены.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 52ca724508 [design-wave1][@dark] fix: реестр пайщиков — канон-поля и дыхание вместо встык-вёрстки
Страница получила канон-отступ (--p-6 / --p-4 на мобилке), таблица обёрнута
в обрамлённую surface-карточку (--p-line, --p-r-lg) вместо edge-to-edge.
Развёрнутые данные пайщика — спокойная вложенная панель на --p-surface-2
с отступами --p-5/--p-6, читаемой шириной формы (640px) и вертикальным
ритмом между полями. Снят full-height у таблицы (мешал внутри карточки).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 5c0a08bce8 [design-wave1][@dark] refactor: реестр пайщиков в канон + убран переключатель Данные/Документы
Развёрнутая строка пайщика теперь показывает только его данные;
документы пайщика живут в отдельном «Реестре документов» и здесь не дублируются.
ParticipantDetails упрощён (без q-tabs/ListOfDocumentsWidget), tab-логика
вычищена из таблицы и страницы. Мобильная ParticipantCard переведена на канон-токены
(убраны хардкод-цвета, CardStyles и q-badge статуса → disabled-checkbox).
Таблица сохранена, сортировка по дате вступления (DESC) без изменений.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 9d27acb45a [design-wave1][@dark] fix: повестка — номер вопроса на зелёной плашке вместо иконки, клик копирует
Убран EntityIdBadge. Зелёная плашка-аватар слева теперь несёт сам номер вопроса
(он же Decision ID): клик по плашке копирует id (тултип «Скопировать №»), hover —
инверсия в сплошной teal. Заголовок снова просто текст вопроса.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops e643020980 [design-wave1][@dark] fix: повестка — бейдж ID в строку заголовка перед текстом вопроса
EntityIdBadge перенесён из отдельной строки под ФИО в начало строки заголовка:
[#id] → сразу текст вопроса. Inline, vertical-align middle. Раньше висел снизу
и пусто растягивал верхнюю строку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 2221552c32 [design-wave1][@dark] feat: повестка — копируемый бейдж Decision ID под ФИО
EntityIdBadge с id вопроса под заголовком/ФИО: отображает #<id>, по клику копирует
чистый id (copy-on-click). Канон-компонент, клик не раскрывает карточку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 3ef7bd1f92 [design-wave1][@dark] fix: повестка — срок «Истекает …» в нижнюю полоску рядом с «Утвердить»
«Истекает через месяц» перенесён из строки под заголовком в постоянную нижнюю
полоску (footer): срок слева, «Утвердить» справа (только председателю). У обычного
пайщика — только срок, узкая панелька.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 8beab9ab0a [design-wave1][@dark] fix: повестка — «Истекает …» и убран избыточный бейдж статуса
Срок теперь подписан «Истекает через месяц» (было просто «через месяц» — неясно).
Удалён статус-чип «Вы за/против» — направление голоса уже видно по подсветке кнопок,
бейдж был избыточным. Убраны связанные computeds и стили.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops 16884c9b1c [design-wave1][@dark] fix: повестка — переосмысленная компоновка карточки вопроса
Кнопки голосования прижаты к правому краю строки. «Утвердить» вынесена вниз
отдельной строкой-footer (справа, hairline сверху) — больше не теснит данные.
Срок «через месяц» и статус-чип «Вы за» перенесены влево под заголовок/ФИО.
Клик по строке раскрывает документ, по кнопкам голосования и footer — нет.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:32 +00:00
coopops b0a200a191 [design-wave1][@dark] fix: повестка — органы управления в одну строку с инфо, не остров по центру
Карточка вопроса перестроена в единую горизонтальную строку: иконка + вопрос/ФИО
слева (flex), компактный блок органов управления (голосование + «Утвердить»),
срок + статус-чип и шеврон — справа, друг рядом с другом. Убрана центрированная
vote-зона с пустотой по бокам. Клик по строке раскрывает документ, по органам
управления (@click.stop) — нет. Подсказка «Утвердить» возвращена в tooltip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops e1f2a0cb7d [design-wave1][@dark] fix: повестка — раскрытие документа по клику на шапку, не на органы управления
Шапка карточки (вопрос/ФИО/срок/статус) кликабельна целиком и раскрывает документ,
справа шеврон-индикатор. Органы управления голосованием — отдельная зона ниже шапки
(сиблинг, не аккордеон): клик по ним голосует/утверждает и документ не раскрывает.
Убрана отдельная кнопка «Документ» — раскрытие теперь по клику на шапку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops 23811fa3e7 [design-wave1][@dark] fix: голосование повестки на верхнем уровне + header-CTA совета
QuestionCard: VotingButtons + «Утвердить» вынесены на верхний уровень карточки —
голосовать можно без раскрытия; раскрытие («Документ») открывает только содержимое
документа. Истечение срока остаётся в шапке.
VotingButtons: вернул чек-индикатор «принято советом» (закрашивается при принятии)
вместо иконки verified.
Header-CTA совета (Предложить/Добавить/Импорт): убран push (q-btn--push не попадал
под канон-правило заливки primary → белый шрифт на нетиловом фоне), подписи с заглавной.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops 702d395f6c [design-wave1][@dark] feat: повестка совета в канон — единый карточный список
QuestionsTable: q-table → карточный список (скелетоны + EmptyState).
QuestionCard: канон-поверхность, BaseButton «Утвердить», токен-чип статуса
вместо q-badge, локальное состояние раскрытия, без @import CardStyles и хардкод-hex.
VotingButtons: токены pos/neg вместо .text-red/.text-green/#666.
Страница: канон-padding вместо q-card flat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops 1f74367a5a [design-wave1][@dark] fix: единый регистр названий столов в переключателе
Названия столов в реестре workspace'ов заведены вразнобой («Стол
благороста», «Стол вычислительных ресурсов» vs «Стол Совета»).
Добавил text-transform: capitalize на подписи в WorkspaceSwitcher
(заголовок текущего стола + пункты выпадающего меню) и WorkspaceMenu
(карусель + диалог выбора) — первая буква каждого слова заглавная,
одинаково для всех и для будущих столов, без правки строк-источников.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops 70a6d9010d [design-wave1][@dark] fix: «Контакты» — единая сетка полей вместо сегментов
По фидбеку переработана композиция:
- убраны заголовки секций «Контакты»/«Руководство» и per-строчные
  иконки телефона/почты/адреса (из-за них значения «прыгали» вправо
  относительно ИНН/ОГРН);
- ИНН и ОГРН теперь в одну строку (адаптивная сетка полей);
- председатель поднят наверх к реквизитам, поле названо просто
  «Председатель совета»;
- контакты — те же поля, телефон/email как ссылки (tel/mailto), всё на
  единой левой кромке; одна тонкая линия делит реквизиты и контакты.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops fbaca1ba9f [design-wave1][@dark] fix: «Контакты» — единая раскладка строк во всех секциях
Реквизиты/Руководство больше не разносят подпись и значение к
противоположным краям (на полной ширине это давало пустой провал и
оторванные значения). Все секции теперь как ContactSheet: подпись
сверху, значение под ней, слева, hairline между строками — один ритм
по всей карточке.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops 5ddc78b535 [design-wave1][@dark] feat: страница «Контакты» в канон + удалён мёртвый MeetQuorumIndicator
- ContactsPage: три цветные ColorCard (orange/indigo/teal/blue) заменены
  на единую спокойную canon-поверхность с секциями через hairline
  (Реквизиты / Контакты / Руководство). Контакты — через canon
  ContactSheet (копирование, mailto/tel). Заголовок и строки на токенах
  (--p-ink/-2/-3, --p-surface/--p-line/--p-r-lg), полная ширина (padding
  24/16px). Убраны rgba-фоны и .q-dark-хаки.
- Удалён неиспользуемый виджет MeetQuorumIndicator (нет импортов;
  явка/кворум живут в MeetInfoCard).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops 837d529ddb [design-wave1][@dark] feat: тело деталей собрания в канон — повестка/голосование/результаты
Убрал токсичный teal из state-gated виджетов деталей собрания:
- MeetDetailsAgenda/Voting/Results переписаны на canon-поверхности
  (--p-surface/--p-line/--p-r-lg), section-heading с иконкой вместо
  центрированной градиентной линии; color-mix/--q-primary/.q-dark удалены.
- Голосование: q-btn color=primary → BaseButton variant=primary;
  опции голоса на токенах pos/neg/neutral-soft, баннер «уже голосовали»
  на --p-pos-soft.
- Результаты: итог решения — токен-чип (pos/neg/unknown), счётчики
  голосов на pos/neg/surface-2-soft.
- AgendaNumberAvatar: teal q-avatar → нейтральный квадрат surface-2/ink
  (общий для трёх виджетов — убирает teal сразу везде).
- MeetCardsList: отступ между карточками 16→20px.
- MeetDetailsPage: кастомный empty-state → canon EmptyState, снят
  @import CardStyles.

MeetQuorumIndicator оставлен как есть — мёртвый код (нет использований).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops ba6464c397 [E14][@dark-sun] feat(собрания): полная ширина страниц; тело деталей (MeetInfoCard) на canon
- ListOfMeetsPage / MeetDetailsPage: убран max-width:960px center — контент
  во всю ширину с canon-паддингом (24px/16px), как документы/платежи.
- MeetInfoCard: переписан с токсичного teal на canon — единая calm-поверхность
  с тремя секциями через hairline (Даты / Ведущие / Явка и кворум) вместо
  трёх вложенных цветных карточек; заголовки ink, проценты ink (не teal);
  дубль заголовка «Общее собрание № N» убран (он в шапке). Убраны
  var(--q-primary), color-mix, .body--dark, хардкод-rgba.
- MeetDetailsInfo: контейнер с .card-container → canon.
- MeetDetailsActions: q-btn color=primary → canon BaseButton.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops 225b4abc0e [E14][@dark-sun] feat(собрания): повестка на шаге проверки канон; карточка списка канон; шапка деталей — back-link вниз, имя в заголовок
- CreateMeetForm: повестка на шаге «Проверка» — аккуратные canon-карточки
  (Вопрос / Проект решения / Приложения) вместо скомканного списка.
- MeetCompactCard: переписана на canon — нейтральный hover (без
  токсичного teal-свечения), ink-заголовок, canon-плитки/иконка;
  убраны @extend .card-container, var(--q-primary), .body--dark, color-mix.
- MeetStatusBanner: нейтральный canon-контейнер (surface-2 + line),
  цвет статуса несёт иконка; убраны хардкод-rgba и .body--dark.
- MeetCardsList: empty-state на canon EmptyState, skeleton на .skel.
- Детали собрания: кнопка «Назад» убрана из топбара (снят useBackButton),
  добавлен canon back-link под шапкой слева; название собрания выводится
  в заголовок шапки через новый desktopStore.pageTitleOverride
  (приоритет над route.meta.title; транзиентно, чистится при уходе).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops ddc66d8529 [E14][@dark-sun] feat(собрания): форма созыва на canon-степпер; кнопка в шапке к канону; скрыта «Сверить»
- BaseDocument: кнопка «Сверить» временно скрыта через :hide-verify
  (обработчик @verify остаётся, вернуть = убрать флаг).
- CreateMeetButton: приведена к одобренному canon-паттерну (primary
  solid на десктопе, micro flat accent + tooltip на мобильном),
  убран legacy push/teal с белым текстом.
- ListOfMeetsPage: инжект кнопки переведён с useHeaderActions на
  Teleport #header-actions-host.
- CreateMeetForm: полностью переписана с перегруженной тёмной модалки
  на canon-визард — maximized-диалог + VerticalStepper (3 шага:
  Параметры → Повестка → Проверка). Canon outlined-поля, повестка
  аккуратными карточками, шаг проверки со сводкой. Убраны
  bg-gradient-dark / standout bg-teal / .body--dark / хардкод rgba.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops 5d007b987d [E14][@dark-sun] feat(стол пайщика): скелетоны вместо спиннера в таблицах документов и платежей
Добавлен canon-компонент TableSkeleton (shared/ui/base) — повторяет
структуру .table-wrap/.table с реальными заголовками и мерцающими
плейсхолдерами (.skel) в ячейках. Каркас не дёргается при подгрузке
данных (calm-data, UX-DR2/UX-DR23). Заменил перекрывающий q-spinner
в DocumentsTable и ListOfPaymentsWidget.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops c807eb0ef8 [E14][@dark-sun] feat(стол пайщика): документы — ID копируемым бейджем, отдельные колонки Дата/Документ, подписи бейджами; поиск минимальный на мобильном
- DocumentsTable: наименование берём из meta.title (чистое, без даты/.pdf
  суффикса full_title); отдельная колонка Дата (meta.created_at) с сортировкой
  по block_num, по умолчанию свежие сверху; колонка Документ — только заголовок.
- ID — копируемый EntityIdBadge (показывает короткий хеш, копирует полный
  doc_hash по клику + иконка-affordance).
- Подписи — отдельные BaseBadge на подписанта (новый helper
  getSignersListFromDocumentPackage возвращает массив).
- EntityIdBadge канонизирован: токены вместо rgba/--q-accent/.q-dark; добавлен
  copyValue (показать одно — скопировать другое).
- SearchHeaderAction: на мобильном — минимальная round-dense иконка без подписи
  «Поиск» (label только на десктопе).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops 37d6a761db [E14][@dark-sun] feat(стол пайщика): документы и платежи — канон-таблица .table-wrap вместо q-table
Переделка после ревью: q-table давал не-канон вид, дёрганье при раскрытии
(virtual-scroll пересчитывал размеры + colspan не совпадал с числом колонок)
и уродливые мобильные карточки в grid-режиме.

- Статическая канон-таблица .table-wrap/.table (бордер+радиус+surface как
  карточка), table-layout:fixed → колонки не разъезжаются при раскрытии.
- Раскрытие строки — CSS-only (expand-row td colspan по числу колонок),
  без virtual-scroll → нет дёрганья шапки/колонок.
- Пагинация load-more (.table-foot + BaseButton «Загрузить ещё» + «1–N из M»)
  вместо infinite virtual-scroll.
- Мобильный: горизонтальный скролл таблицы (.table-scroll) вместо grid-карточек
  PaymentCard/DocumentCard — карточки удалены.
- Статусы платежей — BaseBadge (pos/warn/neg/info/neutral); направление —
  иконка + цвет --p-pos/--p-neg; хеш документа — mono.
- Действия платежей (SetOrderPaid/Refunded) скрыты на столе пайщика
  (hideActions=true) — они в реестре платежей; download документов сохранён.
- EmptyState + спиннер первой загрузки.

Виджеты общие с админ-контуром: load-more и горизонтальный скролл там тоже
уместнее jittery virtual-scroll.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops e5bf464a4d [E14][@dark-sun] feat(стол пайщика): документы и платежи в канон — статус-бейджи, токены, header-action на Teleport
- ListOfPaymentsWidget + PaymentCard: статус платежа через BaseBadge
  (canon-варианты pos/warn/neg/info/neutral) вместо хардкод q-color
  teal/orange/red/blue; убраны битый inline-стиль $toolbar-min-height,
  inline max-width, глобальный .q-list style-leak; цвета/тени/радиусы —
  на токены (--p-line/--p-ink-3/--p-shadow-card/--p-surface-2).
- DocumentsTable + DocumentCard: убраны битый inline-height, inline
  max-width, .q-dark-селекторы, --q-gray, хардкод rgba; тени/линии — токены;
  глобальный q-table__top style → scoped :deep; CardStyles import убран.
- ListOfDocumentsWidget: brand-leak toggle-color teal → primary.
- DocumentsPage: SearchHeaderAction со старого useHeaderActions store на
  canon Teleport (#header-actions-host), кнопка сама гейтится по features.search.
- DocumentsPage/PaymentsPage: канон-паддинг страницы.

q-table-движок (virtual-scroll/infinite-load/раскрытие строк/mobile-grid)
сохранён — виджеты общие с админ/председательскими контурами, регресс недопустим.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:31 +00:00
coopops 94d09d482d [E14][@dark-sun] fix(шапка): правая группа прилипала влево на страницах без действий — вернул margin-left:auto
Когда страница не телепортирует действия, .topbar__actions скрывается
display:none через :has(пустой host), но adjacent-селектор
.topbar__actions + .topbar__right всё равно матчился и обнулял margin-left
правой группы — она уезжала влево к крошке. Возвращаем auto в :has-правиле
(его специфичность выше). Также убрал лид-надпись на странице реквизитов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
coopops e125fb3d56 [E14][@dark-sun] feat(стол пайщика): удостоверение и реквизиты в канон — IdentityPanel/DataRow/BaseCard, header-action на Teleport
- ProfilePage (Удостоверение): IdentityPanel-шапка + BaseCard-секции (Учётная
  запись/Личные данные/Документы и реквизиты) на DataRow; убран CardStyles,
  хардкод rgba и .q-dark.
- PaymentMethods widget (Реквизиты): BaseCard на метод + DataRow + EmptyState
  вместо .info-label/.info-value и хардкод-цветов.
- PaymentMethodsPage: кнопка добавления реквизитов переведена с useHeaderActions
  store на canon Teleport (#header-actions-host), micro на мобильном.
- AddPaymentButton: триггер под canon micro-паттерн (как Deposit/WithdrawButton).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
claude 0acd4aa872 fix: remove stray node_modules symlink committed into branch
Симлинк node_modules -> /home/admin/mono-ai-2/node_modules был случайно
закоммичен и ломал pnpm install (ENOTDIR) на любой машине без этого пути.
Правило .gitignore 'node_modules/' (со слешем) ловит только каталог, не
симлинк-файл — добавлено правило 'node_modules' без слеша.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
coopops 5d490913b2 [E14][@dark-sun] feat(шапка): крошка-название страницы слева + фикс переноса заголовка ModalBase
Крошка: в #crumb рядом с BackButton выводится route.meta.title текущей
страницы (тот же, что подсвечен в меню) — именно для неё действия и
сдвинуты вправо. Длинное название обрезается ellipsis в .topbar__crumb b.

ModalBase: q-bar получил авто-высоту, заголовок переносится (white-space
normal + overflow-wrap), крестик прижат к верху — на узком экране титул
больше не обрезается сверху.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
coopops a3942d696c [E14][@dark-sun] fix(WalletPage): micro-кнопки взноса/возврата в шапке на мобильном
Why: полноразмерные DepositButton/WithdrawButton в узкой мобильной шапке
раздувались — текст переносился в 2 строки, кнопки вылезали за высоту
topbar. micro-вариант (иконка + tooltip, flat/dense) и предназначен
для слота шапки.

What: Teleport-кнопки получают :micro='isMobile' (useWindowSize, <768px).
Мобильный — компактные иконки; десктоп — полные кнопки с подписью.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
coopops 5784c20aa7 [E14][@dark-sun] fix(WithdrawButton): «получить возврат» → «Получить возврат» — заглавная
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
coopops ceac5aee31 [E14][@dark-sun] feat(стол пайщика): кошельки списком во всю ширину + действия шапки через Teleport
Кошельки (по фидбэку — переносы выглядят плохо):
- .wallet-programs из grid (repeat auto-fill minmax) → flex-column: карточки
  идут списком во всю ширину страницы.
- canon .wallet__title/__sub: возвращён nowrap + ellipsis (откат wrap-фикса);
  на широкой строке текст почти всегда влезает, иначе — ellipsis.
- WalletCard + минимум-карточка: нативный tooltip `title` — при наведении
  виден полный текст. Убран .wallet--row reset (базовый снова nowrap).

Действия шапки (кнопки взноса/возврата пропали; нужна новая механика):
- Новый canon-механизм: страница телепортирует свои действия в шапку через
  <Teleport to="#header-actions-host">. Host — постоянный span с
  display:contents в #actions слоте CommonHeader (при loggedIn).
- :has()-правило прячет .topbar__actions, когда внутри только пустой host
  (нет ни store-кнопок, ни телепорта) — чтобы не было пустого разделителя.
- WalletPage переведён на Teleport (DepositButton/WithdrawButton),
  useHeaderActions store-механизм убран со страницы.
- Старый useHeaderActions оставлен для прочих 10 страниц — мигрируем
  и удалим отдельно. DepositButton сам скрыт, пока пайщик не принят
  (status !== 'active') — это защита, не баг.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
coopops 028eb31c03 [E11][@dark-sun] fix(CommonHeader): бренд-логотип inline-SVG в зелёном квадрате, как в личном кабинете
Why: на странице без логина логотип был <img :src='logo.svg'> — img рендерит
SVG в изоляции, не наследует currentColor, поэтому показывался чёрно-белым
и не реагировал на смену темы. В личном кабинете (WorkspaceSwitcher)
тот же logo.svg рендерится inline через v-html в зелёном квадрате
и наследует color (logo.svg на fill:currentColor) — «зелёненький
на зелёном фоне», одинаковый в обеих темах.

What: CommonHeader brand-slot переведён на тот же приём —
`logo.svg?raw` + v-html внутри .app-q-header__logo
(background var(--p-primary-soft), color var(--p-primary), 28px квадрат,
16px svg). Переключение темы больше не требуется — зелёный константен.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
coopops fc60c58f51 [E14][@dark-sun] fix(canon .wallet): просто wrap по пробелам, без overflow-wrap: anywhere
Why: предыдущий фикс `overflow-wrap: anywhere; word-break: break-word`
ломал слова в любом месте даже когда колонка достаточно широкая —
«Минимальный неснижаемый остаток» рендерился по одному слову на строку,
несмотря на ~480px доступной ширины. Реально нужен только wrap по пробелам;
agressive break-word оправдан только для URL-подобных нерасчленяемых строк.

What: убраны `overflow-wrap` и `word-break` из .wallet__title/__sub —
браузер делает естественный wrap по пробелам, длинные заголовки переносятся
только когда не помещаются. `.wallet--row` reset для compact-варианта
оставлен — там по-прежнему single-line+ellipsis.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
coopops 6a2a56dcfa [E14][@dark-sun] fix(canon .wallet): разрешить перенос длинных заголовков на full-варианте + порядок карточек в WalletProgramWidget
Why: canon-стиль .wallet__title и .wallet__sub был с
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;` — на full-
варианте это резало длинные подписи («Минимальны…», «Возвращается п…»)
даже на просторных экранах, потому что grid-колонка `.wallet__main` сжимается
ради `.wallet__amount` справа. Эта обрезка будет всплывать на любой
длинной строке (метки программ, статусы пайщика, длинные subtitle).
Compact-вариант `.wallet--row` (слот шапки) должен остаться одной строкой.

What:
- .wallet__title/__sub: убран nowrap/ellipsis; добавлено
  `overflow-wrap: anywhere; word-break: break-word; hyphens: auto` (для title)
  и `overflow-wrap: anywhere; word-break: break-word` (для sub) — длинные
  заголовки переносятся на 2+ строки.
- .wallet--row: явно возвращает nowrap+ellipsis (с reset word-break/
  overflow-wrap), чтобы compact-ряды в шапке оставались строго одной строкой.
- WalletProgramWidget: карточка минимального неснижаемого остатка
  перемещена в начало grid'а (была после программ) — это базовая защита
  средств пайщика, логичнее видеть её первой.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
coopops a8013c7414 [E14][@dark-sun] refactor(WalletCard,WalletProgramWidget): «Главный кошелёк» канон-default + минимальный остаток отдельной карточкой
Why:
- Заголовок «Кошелёк» для program='wallet' путал — в столе пайщика этот
  кошелёк семантически главный, и в любых других местах canon (MicroWallet,
  WalletCardMini, _dev/ui) он тоже должен называться полно — «Главный
  кошелёк». Лучше один canon-default, чем локальный override в каждом
  потребителе. Согласовано — переименование canon DEFAULT_TITLES.
- Минимальный неснижаемый остаток — НЕ баланс кошелька, а самостоятельная
  сущность пайщика (паевой взнос, возвращается при выходе). Пристегивать
  его DataRow-строкой под grid'ом — нелогично, как было и раньше в legacy.
  Правильнее — отдельной карточкой в той же сетке кошельков.

What:
- WalletCard.vue: DEFAULT_TITLES.wallet 'Кошелёк' → 'Главный кошелёк'.
  Глобально для всех потребителей canon-компонента.
- WalletProgramWidget.vue: убран TITLE_OVERRIDE и DataRow-блок минимального
  остатка. Карточка остатка теперь рендерится в общем .wallet-programs
  grid'е canon-разметкой `.wallet` с иконкой `savings`, заголовком
  «Минимальный неснижаемый остаток», подзаголовком «Возвращается при выходе
  из кооператива» и зарезервированной суммой. Нейтральная подсветка иконки
  через `.wallet--minimum { --prog-bg, --prog-fg }`, чтобы визуально
  отличалась от программ, но встала в общую сетку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
coopops 0f475e7dbd [E14][@dark-sun] feat(WalletProgramWidget): «Главный кошелёк» + минимальный неснижаемый остаток
Why: на старом WalletWidget пайщик видел свой минимальный неснижаемый остаток
(паевой взнос, возвращается при выходе из кооператива) — самостоятельная
сущность пайщика, не баланс кошелька. При переходе на canon я её упустил.
Канон-default WalletCard 'wallet' = «Кошелёк» — для стола пайщика этот
кошелёк семантически является главным (свободный остаток ЦК), поэтому
локально перекрываем заголовок на «Главный кошелёк».

What:
- TITLE_OVERRIDE['wallet'] = 'Главный кошелёк' — локальное перекрытие
  заголовка только в этом widget'е, canon DEFAULT_TITLES не трогаем
  (другие потребители WalletCard могут использовать общий «Кошелёк»).
- DataRow «Минимальный неснижаемый остаток» под grid'ом программ
  (только когда session.participantAccount.minimum_amount > 0)
  с hint «Возвращается пайщику при выходе из кооператива».
- Источник остатка — session.participantAccount?.minimum_amount,
  как и в legacy WalletWidget.

Note: locked-line уже рендерится самим canon WalletCard, когда
locked-balance > 0 (logic: hasBlocked ? blocked.amount : undefined
в WalletProgramWidget). Отдельная разметка под «Заблокировано» не нужна.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:30 +00:00
coopops c824b2147e [E14][@dark-sun] fix(WalletProgramWidget): vue-tsc — flatMap вместо filter+predicate
Why: type predicate `e is CanonProgramEntry` не сходился — optional `locked?: string`
в interface vs required `locked: string | undefined` в литерале map'а. TS считает
эти типы несовместимыми для predicate, хотя они эквивалентны при присваивании.

What: переход на `flatMap<CanonProgramEntry>` с возвратом `[]` для исключаемых
программ. Predicate не нужен, generic flatMap даёт точный тип результата.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:29 +00:00
coopops df13f30788 [E14][@dark-sun] refactor(participant/wallet): WalletPage + WalletProgramWidget в канон MONO Platform v2
Why: первая страница стола пайщика (default route 'wallet') использовала legacy
обвязку — CardStyles import, scoped SCSS с .body--dark и hardcoded rgba цветами,
ColorCard с произвольным цветом по индексу. Канон уже знает программы платформы
(blagorost/wallet/generator) через WalletCard + токены --prog-*; на нём и строим.

What:
- WalletPage.vue: убран import 'src/shared/ui/CardStyles', scoped SCSS на
  токенах --p-6/--p-4; вырезана легаси-карточка «Минимальный остаток»
  (она не отображалась — не было разметки в template). useHeaderActions
  для Deposit/Withdraw оставлен — канон поддерживает actions через #actions slot.
- WalletProgramWidget.vue: переписан на canon WalletCard + EmptyState.
  Фильтр на канон-набор программ через ZEUS_TO_CANON
  (MAIN→wallet, BLAGOROST→blagorost, GENERATOR→generator);
  MARKETPLACE и прочие исключены — это не из основной тройки платформы
  и должны рендериться отдельным виджетом.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:29 +00:00
coopops 077e716407 [E11][@dark-sun] feat(AppHeader): brand slot — лого + название кооператива на странице без логина
Why: на главную без логина нужно показывать бренд кооператива (логотип + имя)
рядом с глобальными действиями шапки; раньше выводилось только текстовое
название через title-проп без логотипа.

What:
- AppHeader.vue: опциональный slot #brand перед .topbar__crumb;
  hasBrand computed по slots.brand. Когда slot заполнен — крошка не рендерится.
- components.css: стили .topbar__brand (desktop + .topbar--mobile вариант)
  с canon-токенами (--p-fs-body, --p-ink, --p-fs-meta).
- CommonHeader.vue: на !loggedIn заполняет #brand src/assets/logo.svg
  + <b>{coopTitle}</b>; передача title-пропа убрана.
- default.vue: .fixed-top-right { top: 51px } → top: var(--p-topbar-h)
  (canon 56px) — выравнивание FAB под точную высоту шапки.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:08 +00:00
coopops 5b24a78237 [E11][@dark-sun] refactor(CommandPalette): иерархия рабочих столов и страниц вместо generic pages/actions/recent
Существующая модель cmdk в проекте (entities/CmdkMenu/model/store.ts) —
иерархия рабочих столов и их страниц. Активный стол sticky сверху с
бейджем «Активный», без запроса показывается иерархия (стол + indented
страницы), с запросом — плоский список со столом-префиксом у каждой
страницы; стол отдельной строкой появляется только если запрос явно
начинается с его имени или содержит «стол»/«workspace».

Переписал canon CommandPalette под эту модель:
- Props: `workspaces: CommandPaletteWorkspace[]` вместо
  `commands: CommandItem[]`. Каждый workspace = `{ name, title, icon,
  isActive?, pages: CommandPalettePage[] }`. Page = `{ name, title,
  icon?, shortcut? }`.
- Emits: `select-workspace(name)`, `select-page(workspaceName, pageName)`
  — props-only, навигация и filtering по ролям/conditions остаются
  заботой connected-обёртки (миграция legacy CmdkMenu — отдельная story).
- Sticky-баннер активного стола, plus accent-soft фон + outline-обводка
  на selected, ↑↓ работает плоско поверх иерархии (стол → страницы → стол
  → страницы).

Mock-data в /_dev/ui/index.vue обновлён: три стола (Председатель/Пайщик/
Отчётность) со своими страницами вместо плоского списка команд.

Старый widgets/Desktop/CmdkMenu пока живёт параллельно — переключим в
ходе миграции Wave 3.
2026-05-27 09:17:08 +00:00
coopops 459888fea7 [E11][@dark-sun] feat: NotificationCenter, CommandPalette, DetailsDrawer — эпик «Навигация и нотификации»
Реализованы три props-only доменных компонента из E11:
- NotificationCenter — panel-content для popover в шапке: группировка
  notifications по category (system/financial/voting/message), unread-bullet
  через BaseBadge, кнопка «Прочитать все», EmptyState и «Показать все».
  Relative-date форматирование с русским склонением.
- CommandPalette — ⌘K/Ctrl+K с fuzzy-поиском, секции recent/pages/actions,
  ↑↓ навигация, Enter/Esc обработка. localStorage недавних — в connected
  обёртке, компонент props-only.
- DetailsDrawer — side-sheet справа 480px (override через :width), slots
  default/actions/footer, Esc и backdrop close, на xs — fullscreen.

Все три зарегистрированы в boot/ui.ts и локально импортированы в /_dev/ui
с mock-data в секциях 36-38.

E11.4 RailUserCard отложен из-за конфликта имён — существующий компонент
имеет другую роль; нужно согласовать канон-нейминг.
2026-05-27 09:17:08 +00:00
coopops 4c6a21a965 [E10][@dark-sun] fix(BaseDocument): центрировать spinner-лоадер по обеим осям
Заменил `.full-width.text-center` + flex без выравнивания на flex-column
со `min-height: 360px` — лоадер больше не прижат к левому верху карточки.
Заодно поправил опечатку «подговка» → «Формируем документ».
2026-05-27 09:17:08 +00:00
coopops 039ad58055 [E10][@dark-sun] fix(AmountInput): нативный :suffix='symbol' вместо #append slot
Корень: я положил символ валюты в #append slot своим <span>, в обход
встроенного prop suffix. Quasar имеет правила позиционирования именно
для родного .q-field__suffix (см. revert d0a8dc0080 от 2026-05-19,
где было решено оставить Quasar дефолт — «нас устраивает»). Мой span
в append-slot не подчинялся этим правилам и сидел в произвольной
позиции относительно цифр.

Фикс:
- :suffix='symbol' — символ валюты идёт нативным механизмом Quasar.
- Удалил .amount-input__symbol класс и template #append вообще.
- Удалил override align-items: center на q-field__control/__append/
  __after (тоже мешал, как было показано в d0a8dc0080).
- font-weight 500 на цифрах оставил — нормальный вес поля.

Совет на будущее: использовать встроенные q-input props (suffix,
prefix), а не #append/#prepend slots, если можно — Quasar для них
держит готовое выравнивание, проверенное пользователем.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:08 +00:00
coopops dac113f9f0 [E10][@dark-sun] fix(AmountInput): выровнять цифры и RUB-суффикс по центру
Корень: font-weight: 600 + tabular-nums + Quasar dense нативный input
имеют чуть смещённую baseline относительно append-слота, где сидит
RUB. Визуально цифры лежали ниже суффикса.

Фикс:
- font-weight 600 → 500 (нормальный вес поля ввода, без bold-акцента
  на цифрах).
- Явный font-size + line-height на нативном input.
- align-items: center на q-field__control / __append / __after, чтобы
  суффикс и any after-слот (кнопка «макс») центрировались по высоте
  входной полосы.
- align-self: center на самом __symbol — на случай если q-field__append
  кто-то переопределит как stretch.

TODO на следующий подход: BaseDocument loader («Формируем документ…»)
выравнивать по центру рамки документа (сейчас стоит сверху).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:08 +00:00
coopops 7425c66e2a [E10][@dark-sun] fix(OtpInput): убрать TypeScript-касты as из pug-шаблона
[Quasar] boot error: SyntaxError: Unexpected identifier 'as' — runtime-парсер
обрабатывает выражения в pug-template как чистый JS, без TypeScript. Касты
вида `el as HTMLInputElement | null`, `e as InputEvent`, `e as KeyboardEvent`
прямо в атрибутах `:ref` / `@input` / `@keydown` — синтаксическая ошибка во
время бутстрапа Vue, из-за которой /_dev/ui целиком не грузился.

Фикс:
- :ref='(el) => setRef(idx, el)' + функция setRef(idx, el: Element |
  ComponentPublicInstance | null) с кастом в TS-скрипте.
- @input='(e) => onInput(idx, e)' + сигнатура onInput(idx, event: Event)
  с внутренним кастом target.
- @keydown оставил передавать event «как есть» — KeyboardEvent — никакой
  cast не нужен.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:08 +00:00
coopops 88a8f54806 [E10][@dark-sun] fix(_dev/ui): локальный импорт E10-компонентов — boot/ui.ts не HMR'ится
Quasar boot-файлы подхватываются только при рестарте dev-сервера. После
коммита 1af0f9c06f глобальные регистрации AmountInput/OtpInput/FilterBar/
FileUploader/VerticalStepper не подтянулись на лету — теги рендерились как
unknown components (пусто внутри секций 31–35 на /_dev/ui).

Фикс: добавил локальные импорты прямо в script setup _dev/ui/index.vue —
тот же паттерн, что у WalletCard, RailUserCard, AuthCard. Глобальная
регистрация в boot/ui.ts остаётся для боевого использования из других
страниц (после следующего рестарта dev'а).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:08 +00:00
coopops 378aa664a4 [E10][@dark-sun] feat: AmountInput, OtpInput, FilterBar, FileUploader, VerticalStepper — эпик «Формы и ввод»
Пять props-only доменных компонентов из shared/ui/domain/:
- AmountInput — денежный ввод с символом валюты, форматированием тысячных,
  precision из marketplace asset config, кнопкой «макс» по balance, опциональной
  подписью баланса. Tabular-nums, right-align, font-weight 600.
- OtpInput — 6 ячеек с автопереходом фокуса, Backspace откатывает на
  предыдущую, paste 6-значного кода распределяется по ячейкам.
  Регулярка /^\d$/, состояния error/disabled.
- FilterBar — search (debounce 300мс) + dropdown-фильтры + chip'ы активных
  значений с remove + «сбросить всё». v-model для values, v-model:search для
  поиска. Активные chip'ы рендерятся под рядом фильтров.
- FileUploader — drag&drop + клик по зоне; валидация accept/maxSize/maxFiles
  → emit error; список загруженных с иконкой типа, именем, размером,
  кнопкой ×; слот progress для connected-обёртки.
- VerticalStepper — состояния pending/current/completed/error; completed
  кликабельны для возврата назад (опционально); опциональные/disabled шаги;
  слот active под телом текущего шага.

Все компоненты — pug, canon-токены --p-*, без store/router/api. Демо-секции
31–35 на /_dev/ui (наш стенд-витрина).

Зарегистрированы в boot/ui.ts и shared/ui/domain/index.ts. ESLint точечно
прошёл, vue-tsc не запускался полностью на desktop (запрет).

Wave 2 / E10 закрыт.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:08 +00:00
coopops 211c17f47c fix(DocumentPreview): убрать txt/image — наши документы это HTML/PDF
Удалены типы 'txt' и 'image' из DocumentPreviewType + соответствующие
ветки рендера (pre.document-preview__txt и img.document-preview__image)
и стили. В реальных потоках платформы документ кооператива всегда
HTML (рендерится в ShadowHtml внутри BaseDocument) или PDF — plain-
text фрагмент в моноширинном pre не используется и выглядит как
техническая ерунда. Аналогично image.

_dev: удалён previewTxtDemo (мусорный EOSIO chain id из debugging
notes → перешитый в фрагмент протокола → теперь полностью убран
по требованию пользователя). Секция 30 показывает только HTML +
loading + error состояния.
2026-05-27 09:17:08 +00:00
coopops 8c5a7bd466 fix(BaseDocument,_dev): vue-tsc, мусорный txt-demo, ширина документа
vue-tsc:
- _dev: убрал txId/explorerUrl из signatureSignedDemo — оба поля
  удалены из Signature ранее (общего эксплорера нет).
- BaseDocument: canonSignatures.map — нормализую is_valid через
  '?? undefined' (бэкенд может вернуть null, canon-компонент ждёт
  boolean | undefined).

DocumentPreview txt demo: вместо мусорного 'EOSIO chain id …
dirty window' (мой случайный кусок из debugging notes) — фрагмент
протокола собрания пайщиков ПК «Восход».

ComplexDocument: .col-md-7 → .col-md-10. Это контейнер, в котором
лежит BaseDocument в реальных страницах документов. 7/12 = 58%
ширины было визуально 'приплюснуто'.
2026-05-27 09:17:08 +00:00
coopops db80cf0362 feat(ui): DocumentSignatures + перевод BaseDocument на canon
Реальный формат подписей кооперативного документа — это не «pending/
signed/rejected» из абстрактного SignatureCard, а IDocumentAggregate с
полями doc_hash + signatures[] (signer_certificate, public_key,
signature, is_valid). Каждая подпись разворачивается в детали.

Что сделано:
- Новый canon-компонент DocumentSignatures (story 9.5) в shared/ui/
  domain — props-only, принимает уже резолвнутые signerName и hash-
  совпадение, эмитит download/verify. Поверх Quasar — собственный
  expand на ref<Set<number>>, чтобы стиль был полностью canon.
- BaseDocument теперь рендерит DocumentSignatures вместо своего
  q-card.verify-card + q-list + q-expansion-item на teal/red badges.
  Адаптер canonSignatures маппит signer_certificate → ФИО через
  getNameFromCertificate, чтобы canon-компонент не знал про сертификаты.
- SignatureCard: удалены поля txId/explorerUrl и ссылка «Открыть в
  explorer» — общего эксплорера в платформе нет.

Demo: секция 29 → DocumentSignatures (валидный + с битой подписью),
секция 30 → DocumentPreview (HTML заголовок сокращён, чтобы не
обрывался при узкой колонке).
2026-05-27 09:17:08 +00:00
coopops 7524fd6941 feat(ui): волна 2 / E9 — документы и подписи
4 props-only canon-компонента в src/shared/ui/domain/, регистрация
в boot/ui.ts, demo-секции 26-29 в _dev/ui:

- DocumentRow (story 9.1) — строка документа в списке: иконка типа
  с tint'ом (pdf neg-soft, docx info-soft, html primary-soft), title,
  status через BaseBadge, дата/автор/описание, slot actions, emit open.
- SignatureCard (story 9.3) — подписавший (Avatar+ФИО+AccountBadge),
  статус (BaseChip pending/signed/rejected), для signed — хеш в моно-
  блоке + ссылка на explorer, для rejected — BaseBanner с причиной.
- ActivityTimeline (story 9.4) — вертикальный таймлайн событий с
  цветными иконками по типу (sign/reject/create/update/comment/
  transfer), groupByDate группирует по «Сегодня/Вчера/конкретная
  дата».
- DocumentPreview (story 9.2) — html (через DOMPurify), pdf через
  iframe, image через img, txt через pre. Стейты loading/error.

Все props-only, темо-зависимые значения только через --p-* токены.
2026-05-27 09:17:08 +00:00
coopops 29cd2cd5e5 fix(_dev/ui): «Член кооператива» → «Пайщик» в PersonCard demo
Канон-терминология blago/ЦК: пайщик, не член. Прецедент закреплён
в auto-memory feedback_kooperatsiya_pai.
2026-05-27 09:17:08 +00:00
coopops cfec8afe70 fix(IdentityPanel): compact тоже в карточке — padding+border+bg
Раньше compact-вариант рендерился без обрамления — avatar упирался
в левый край контейнера, визуально «проваливался» из общего стека
full-карточек (Screenshot_2026-05-21_21-51-09).

Compact теперь: padding 8/12px (тоньше чем full 16px), тот же border
+ surface + radius. В одну строку avatar + ФИО + AccountBadge + status,
но визуально это всё-таки карточка, не голая строка.
2026-05-27 09:17:08 +00:00
coopops 9f24394398 fix(ContactSheet): label и значение в отдельных строках
Body раньше был обычным block-контейнером, а .head и .value-row —
оба display: inline-flex. Без явного block-форматирования inline-flex
элементы становятся inline и рендерились в одну строку. Визуально:
Emailivanov@example.ru, Телефон+7 (903)..., Telegram@ivanov — слипшиеся.

Body теперь display: flex + flex-direction: column, gap canon — 8px
в comfortable, 4px в compact. Head/value-row переведены на обычный
display: flex (вместо inline-flex), gap внутри сохранён.

Прецедент 2026-05-21 — Screenshot_2026-05-21_21-42-16, 21-42-31.
2026-05-27 09:17:08 +00:00
coopops 824fd3341e chore: убрать закоммиченный symlink node_modules 2026-05-27 09:17:07 +00:00
coopops cc05cd4ba3 fix(domain): E8 — copy-кнопки, отступы и compact-layout
- AccountBadge: copy-кнопка не сжимается (flex-shrink: 0), размер 20×20 + icon 14px — раньше 18×18 + 12px тонула рядом с текстом badge.
- DataRow: column-gap var(--p-3, 12px) → var(--p-5, 20px) + min-label-width 140 → 160px + padding-right на label. Между label и value был визуально слипшийся стык.
- ContactSheet: comfortable margin-top между label и value 2px → 8px (compact 4px). Раньше label «налипал» на значение.
- IdentityPanel compact: переделан в flat flex-row (avatar + ФИО + AccountBadge + status badge) — раньше grid с .body загонял имя и AccountBadge в две строки и avatar «уезжал» от имени. Spec 8.1 требует «только avatar + ФИО + EntityIdBadge в одну строку».
2026-05-27 09:17:07 +00:00
coopops 61e8bdf083 feat(ui): волна 2 / E8 — идентификация и контакты
5 props-only canon-компонентов в src/shared/ui/domain/, регистрация
в boot/ui.ts, demo-секции 21-25 в _dev/ui:

- AccountBadge (story 8.5) — on-chain account name в mono-шрифте,
  copyable + опциональный explorer link. Назван AccountBadge вместо
  EntityIdBadge: имя EntityIdBadge занято под numeric ID пайщика
  в capital. Decision зафиксирован в epics.md и planning-artifacts.
- DataRow (story 8.3) — `label: value` пара для реестровых карточек,
  mono-режим, copyable, slot value-override, hint.
- ContactSheet (story 8.2) — email/phone/address/tg/web с автоиконками,
  кликабельными mailto:/tel:/t.me ссылками, copy и verified-чек.
- IdentityPanel (story 8.1) — Avatar + ФИО + AccountBadge + статус
  (active/blocked/pending) + role + actions-slot; compact/full.
- PersonCard (story 8.4) — Avatar + ФИО + role + AccountBadge +
  ContactSheet + slot meta; compact/comfortable.

Все props-only: useStore/useRouter/useApi внутри запрещены. Темо-зависимые
значения только через --p-* токены, никаких body--dark селекторов.
2026-05-27 09:17:07 +00:00
coopops 98e82eeced fix(BaseCheckbox): приглушить заливку галочки через --p-primary-press
q-checkbox по умолчанию красит .q-checkbox__bg в --q-primary, который в
dark теме равен #2dd4bf — на маленьком квадрате 18px этот яркий бирюзовый
смотрится ядовито. Переопределяем заливку filled-состояния на
--p-primary-press (#134e4a light / #14b8a6 dark) — глубже и спокойнее,
чек-символ остаётся белым.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:07 +00:00
coopops c247eb169f fix(tokens): приглушить --p-accent + унифицировать .agreement-link
Старое значение --p-accent (#D84315 light / #FF7043 dark) — слишком
насыщенное, выглядит ядовито рядом с тёплой палитрой ink/canvas. Меняем
на тёплую терракоту: #B85C38 (light) / #E89472 (dark) — тот же warm-tone,
но без перевозбуждения красного канала.

.agreement-link (псевдо-ссылки на просмотр документа в согласиях) была
hardcoded в #1c64f2 (синий) light / #ff9f43 (жёлто-оранжевый) dark и не
совпадала со ссылкой «Устав кооператива». Унифицируем: все ссылки внутри
согласий теперь var(--p-accent), визиt-цвет тот же — без фиолета.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:07 +00:00
coopops 991ea1d299 fix(ReadStatement): чекбоксы согласий — выравнивание + gap + accent ссылка
BaseCheckbox block-вариант: убран собственный display:flex + align-items:
flex-start, который перебивал внутренний flex Quasar и сдвигал label вверх
относительно inner. Теперь Quasar сам центрирует inner на первой строке.
Добавлен padding-left: 8px на .q-checkbox__label — больше воздуха между
галочкой и текстом согласия.

ReadStatement: ссылка «Устав кооператива» — --p-primary → --p-accent
(тёплый оранжевый #D84315 / #FF7043 dark) как акцентный цвет палитры.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:07 +00:00
coopops 3e01d8c915 feat(UserDataForm): canon-стиль полей + тип аккаунта на BaseRadioCard
UserDataForm/UserDataForm.vue:
- Выбор типа аккаунта: три q-btn(glossy, height:75px) → три BaseRadioCard
  с title/description; больше нет teal-«кнопок-плиток»
- Возврат к выбору типа: flat q-btn → BaseButton(ghost) с arrow_back

Sub-forms (Individual / Entrepreneur / Organization + Create*):
- q-input/q-select: standout="bg-teal text-white" → outlined color='primary'
  (canon-tone, единый primary-акцент вместо устаревшего teal)
- .q-gutter-sm.q-mt-md → .user-data-stack (flex column, gap токены)
- OrganizationDataForm: кнопка «совпадает» — color='teal' → flat primary

Валидация q-form.validate() и :rules сохранены — это всё ещё q-input
с rules-массивом, только tone и контейнер переехали на canon-токены.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:07 +00:00
coopops 4348cd0ae7 feat(ui): BaseCheckbox + BaseRadioCard и миграция SignUp-шагов
- BaseCheckbox: обёртка q-checkbox с canon-стилями, block-вариант для длинных
  согласий (чекбокс прижат к верху, label многострочный)
- BaseRadioCard: карточка-опция с радио-индикатором справа, props title /
  description / meta + slot fallback'и; используется для выбора программы

Миграции SignUp:
- ReadStatement: q-checkbox → BaseCheckbox(block); согласия в .agreements-стек
- SelectProgram: q-list+q-radio → список BaseRadioCard
- GenerateAccount: q-input → BaseInput(readonly, mono) с copy в #append;
  q-checkbox → BaseCheckbox; автоселект через querySelector('input').select()
  вместо ref.select() (BaseInput не пробрасывает внутренний API q-input)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:07 +00:00
coopops 58530d9db8 fix(desktop): .ws-switcher__title padding-top 2px
Воздух над заголовком стола внутри WorkspaceSwitcher — заголовок не упирается
в верхнюю границу caption «ПК «ВОСХОД»».

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:07 +00:00
coopops 65b55d7e4b fix(desktop): .rail__top padding 16px 8px + убрать disabled-стиль у WorkspaceSwitcher
- .rail__top: padding 18px 20px → 16px 8px (унификация боковых отступов)
- WorkspaceSwitcher: убраны :disabled-атрибут и hover:not(:disabled) — у пайщика, которому доступен только один стол, кнопка выглядит как обычно (без not-allowed-cursor, без приглушённого цвета). Меню q-menu просто не открывается через v-if='workspaces.length > 1'. Тихое игнорирование клика вместо явного запрета.
2026-05-27 09:17:07 +00:00
coopops 7578d1cfd3 fix(desktop): WorkspaceSwitcher — фильтр столов по ролям + 3-строчный title
WorkspaceSwitcher показывал ВСЕ workspaceMenus, включая столы выше роли пользователя (например, «Стол председателя» видел рядовой пайщик), что приводило к ошибке access-denied при попытке переключения.

- Добавлена фильтрация по иерархии: chairman ⊇ member ⊇ user. Председатель видит столы любых ролей; член совета — user+member; пайщик — только user. Workspace без meta.roles (или с пустым массивом) — публичный для всех (та же логика, что в legacy WorkspaceMenu, плюс корректное наследование прав).
- Title допускает перенос на 3 строки (line-clamp 3) — «Стол вычислительных ресурсов» теперь умещается полностью
- Расширена кнопка switcher'а (margin сторон 8→4 в LeftDrawerMenu), даёт больше места под текст
2026-05-27 09:17:07 +00:00
coopops 7f2d809821 fix(desktop,signup): WorkspaceSwitcher polish + Bank toggle QR↔реквизиты
WorkspaceSwitcher:
- Brand-строка теперь собирается из vars.short_abbr + «vars.name» (получается «ПК «ВОСХОД»»). Раньше показывал только short_abbr — название кооператива было «потеряно»
- Title допускает перенос на 2 строки (line-clamp 2) — длинные названия типа «Стол вычислительных ресурсов» больше не обрезаются ellipsis'ом с одного слова
- Chevron приклеен к верху строки (align-self start) — корректно при двухстрочном title
- Меню workspace: canon padding (8/12) вокруг q-item, отступ между avatar-section и текстом — иконка и название больше не упираются в края
- Затемнение фона при открытом меню (rgba(9,9,11,0.32) overlay через Teleport в body) — меню больше не сливается с контентом за drawer'ом

Bank.vue (карточка платежа):
- Убрали q-expansion-item (раскрытие вниз внутри карточки выглядело перегружено)
- Кнопка-toggle: «Показать реквизиты» рядом с «Скачать QR». Клик переключает зону под сводкой между QR и списком 9 BaseInput'ов с copy-кнопками
- В режиме реквизитов: кнопки меняются на «Скопировать всё» (primary) + «Показать QR» (secondary)
- QR-canvas использует v-show чтобы сохранять состояние при toggle назад; реквизиты — v-if чтобы не держать BaseInput'ы в DOM
2026-05-27 09:17:07 +00:00
coopops 144cc901ff feat(desktop): WorkspaceSwitcher + фикс ReadStatement стилей + плоский Bank.vue
ReadStatement: backend-HTML генерирует с inline-стилями и Quasar text-h* классами, которые перебивали :deep правила. Усилили все ключевые селекторы !important + добавили охват .text-h1/h2/h3/h4/h5/h6 и .text-right (под мета-блок «УТВЕРЖДЕНО…»).

Bank.vue (карточка платежа):
- убрали отдельные surface-карточки вокруг сводки и QR (получалось «карточка-в-карточке-в-карточке»)
- сводка → плоский <dl> со строкой-разделителем снизу (border-bottom var(--p-line))
- QR → просто центрированный canvas без обёртки/фона/рамки; белый цвет внутри canvas — функциональное требование контрастного сканирования, не дизайн-выбор
- detail-expansion → без своего фона, только border-top/bottom

WorkspaceSwitcher (новый widget src/widgets/Desktop/WorkspaceSwitcher/):
- Карточка-кнопка в шапке drawer: иконка кооп + caption «ПК «ВОСХОД»» + bold «Стол совета» (текущий) + chevron
- Клик → q-menu со списком всех workspaceMenus (Стол пайщика, Стол совета, Стол председателя и т.д.); выбор → selectWorkspace + goToDefaultPage
- Подключён через #brand slot AppDrawer'а в LeftDrawerMenu, заменяет дефолтный brand-row
2026-05-27 09:17:07 +00:00
coopops 40492a40cd feat(signup): canon-типографика заявления + рефакторинг карточки платежа
ReadStatement (этап 6.1):
- Backend генерирует HTML документа — локально нормализуем через :deep
- h1 уменьшен с монструозного дефолта до canon h3 (20px), центрирован
- h2/h3, p, strong, ul/ol, table, hr — типографика по var(--p-*)
- мета-блок .approved/.meta-right (УТВЕРЖДЕНО…) — ink-2, мельче, плотнее
- Чекбоксы соглашений снизу не трогали (как просил пользователь)

Bank (этап 6.2 — карточка платежа PayWithProvider):
- QR-код вынесен в фокус: сначала сводка (получатель/сумма/назначение) → QR → действия → реквизиты для ручного перевода скрыты под q-expansion-item
- Полные 9 полей (ИНН/БИК/КПП/корр-счёт/...) переведены на BaseInput(readonly, mono) с copy-кнопкой в #append; раскрываются только при необходимости
- Кнопки «Скачать QR» (primary) и «Скопировать реквизиты» (secondary) — BaseButton
- Удалены: q-btn(push), case-mixed «скопировать реквизиты», стиль #qr через global селектор
2026-05-27 09:17:07 +00:00
coopops fd0609de19 feat(invite): этап 5 — миграция Invite на canon AuthCard + BaseInput/BaseButton
- InvitePage: row/col-обёртка → .invite-page flex-center + canon padding (как у SignInPage)
- Invite widget: AuthCard со штатными title/subtitle (вместо CAPS-заголовка с background-clip градиент-текста)
- BaseInput(mono, readonly) для приватного ключа — canon-оформление key/hash полей
- BaseButton(ghost) для копирования + BaseButton(primary) для «Установить ключ»
- Удалены: градиент-текст, semi-transparent blue/green tint карточки вокруг инструкции и чекбокса, hover translateY + box-shadow glow, custom border-radius/box-shadow для q-input и кнопок, мобильные @media-блоки (canon-токены и AuthCard сами адаптивны)
2026-05-27 09:17:07 +00:00
coopops 791c7e1707 fix(signup): canon-рамка для контейнера подписи вместо неонового glow
- .signature-container: 3px primary border + 3-слойный box-shadow glow → 1px dashed по var(--p-line-2), surface-2 фон, без свечения; на hover лишь окрашивается рамка
- .signature-hint: переехала в центр контейнера, canon ink-2 цвет, убран frosted-chip с blur/shadow — теперь спокойная метка «Оставьте собственноручную подпись в рамке», pointer-events: none пропускает клики на canvas
- min-height снижен 300→220, padding на canon-токены
2026-05-27 09:17:07 +00:00
coopops 71331854f0 fix(signup): отступ сверху, canon фон q-stepper, ширина email-поля
- SignUp.vue: добавлена обёртка .signup-page (padding + center + min-height) — как у SignInPage; раньше страница была прижата к шапке
- AuthCard maxWidth: 1000 → 720 — историческое 1000 для трёх-колоночной UserDataForm; для остальных шагов выглядит абсурдно широко
- q-stepper: убраны собственный фон/тень/padding (не «карточка в карточке»), линии-разделители переведены на var(--p-line)
- EmailInput: max-width поля 360px — email не должен растягиваться во всю ширину карточки
2026-05-27 09:17:07 +00:00
coopops 4a6af072ac fix(authcard): убрать оборванный barrel-экспорт после удаления старого AuthCard
vue-tsc ругался: shared/ui/index.ts:21 — Cannot find module './AuthCard'.
- shared/ui/index.ts: убран `export * from './AuthCard'`
- LoginRedirectPage.vue: импорт переключён на canon `shared/ui/domain/AuthCard`, CAPS-заголовок заменён на canon `title` prop
- Invite.vue: импорт переключён на canon (полная миграция Invite — этап 5)
2026-05-27 09:17:06 +00:00
coopops c418a544ac feat(signup): этап 4 — миграция SignUp на canon AuthCard + BaseButton/BaseInput
- canon `shared/ui/domain/AuthCard` переписан на pug
- SignUp.vue переключён со старого «глянцевого» AuthCard на canon (title prop вместо CAPS-заголовка)
- старый `shared/ui/AuthCard` удалён (использовался только в SignUp)
- 7 подэкранов SignUp (EmailInput, SetUserData, SelectProgram, GenerateAccount, SelectBranch, ReadStatement, SignStatement): q-btn → BaseButton (ghost для «назад», primary для «Продолжить»)
- EmailInput: q-input → BaseInput, валидация rules переведена на computed :error
- q-input в GenerateAccount оставлен из-за зависимости от ref.select()
2026-05-27 09:17:06 +00:00
coopops 5480c1cd04 content(brand): og-image из poster-logo-horizontal + новый длинный дескриптор
- og-image.png — взят brand-постер ~/blago/production/shared/poster-logo-horizontal.png,
  отресайзен до 1000px по ширине и центрирован на полотне 1200×630
  с белыми краями по вертикали. Шрифт/композиция родные бренда.
- description / og:description / twitter:description: «Цифровой Кооператив —
  система управления хозяйством. Регистрация пайщиков, заказы на поставку
  и приобретение имущества, собрания совета, общие собрания пайщиков,
  взаимные расчёты на блокчейне, автоматический документооборот и бухбаланс
  на основе простой электронной подписи.»
2026-05-27 09:17:06 +00:00
coopops fea2e32b59 content(brand): обновить превью и мета — система управления хозяйством на платформе «Благо»
- og-image.png перерисован: «Цифровой Кооператив» / «Система управления
  хозяйством» / «на платформе БЛАГО — кооперативной экономики для жизни» /
  «Регистрация пайщиков · заказы на поставку и приобретение имущества ·
  расчёты на блокчейне».
- description / og:description / twitter:description выровнены под этот
  же копирайт. Слово «взаимоотношения с пайщиками» убрано как слишком
  узкое — управление хозяйством включает в себя ещё заказы, реестры
  и документы.
2026-05-27 09:17:06 +00:00
coopops 265e2783af feat(brand): системный логотип ЦК + чистка ассетов + OG-превью
- src/assets/logo.svg, public/favicon.svg, public/logo.svg — единая SVG
  логотипа кооператива (path с «ц» внутри). Скачано с
  https://цифровой-кооператив.рф из шапки сайта.
- src/assets/logo.svg: fill -> currentColor чтобы лого внутри
  canon .rail__brand наследовал --p-primary (для тёмной темы тоже).
- LeftDrawerMenu подключает лого через slot #brand-icon AppDrawer
  (импорт ?raw + v-html). Внутри зелёного квадрата шапки рейла теперь
  наш системный знак, а не дефолтный q-icon "dashboard".
- index.html: favicon → /favicon.svg, apple-touch-icon тоже на SVG.
  Удалены неработающие ссылки на /favicon.ico и /apple-touch-icon-*.png.
- index.html: добавлены description + расширенные og:* и twitter:*
  с конкретным описанием и og-image (1200×630), чтобы ссылки в чатах
  выглядели прилично.
- public/og-image.png — превью 1200×630, ImageMagick-сгенерированный
  композит из логотипа + текста.
- Удалены неиспользуемые ассеты:
  - public/icons/, public/pwa/ — старые favicon/PWA-PNG'и всех размеров;
  - src/assets/* — 40 легаси SVG/PNG (anime, blockchain, dacom*, flow*,
    header-logo, quasar-logo-vertical, system*, club*, welcome.jpeg и т.д.).
  Оставлены только src/assets/pin.svg (используется в Map.vue) и
  src/assets/logo.svg (новый).
2026-05-27 09:17:06 +00:00
coopops 41dcd40098 fix(layout): убрать TS-каст из pug-шаблона AppDrawer
Pug-template передаёт выражения в Vue compiler как строки; конструкции
вида (entry as RailItem).route не превращаются в JS и падают
SyntaxError в браузере: «Unexpected identifier 'as'».

Касты унесены в <script>: добавлены computed flatItems и
normalizedGroups, шаблон работает с уже типизированными массивами без
inline-TS.
2026-05-27 09:17:06 +00:00
coopops c3649ca972 fix(layout): этап 3.3 — все canon-компоненты на pug + фикс импорта cmdk
- LeftDrawerMenu: импорт useCmdkStore → useCmdkMenuStore (правильное имя).
  Без этого ломался ESM build всего drawer'а: «no exported member
  useCmdkStore» — RailUserCard не монтировался, поэтому и chevron свёртки
  «не работал», и Найти/Пополнить не реагировали.
- AppDrawer.vue, AppHeader.vue, RailUserCard.vue переведены на pug
  (lang="pug"). В проекте везде pug — никакого исторического HTML в
  canon-компонентах быть не должно.
2026-05-27 09:17:06 +00:00
coopops abd634beca fix(layout): этап 3.2 — pug + v-model:collapsed + рабочие триггеры
- q-drawer width 240 → 248px (совпадает с canon --p-rail-w, кошелёк больше
  не уплывает справа из-за обрезки на 8px)
- LeftDrawerMenu: убран весь :deep override на .rail__usercard/.rail__signout,
  возвращаем canon margin/padding как есть
- LeftDrawerMenu: добавлен v-model:collapsed на RailUserCard с сохранением
  в localStorage — chevron «свернуть/развернуть» снова работает
- LeftDrawerMenu: кнопка Найти теперь дёргает cmdkStore.openDialog() напрямую
  (фейковый KeyboardEvent не доходил до глобального обработчика)
- LeftDrawerMenu: «Пополнить» → useDepositDialog().open(); DepositButton +
  WithdrawButton рендерятся скрыто как держатели q-dialog (q-portal в body)
- Header.vue и LeftDrawerMenu.vue переписаны на pug (как остальной проект)
2026-05-27 09:17:06 +00:00
coopops 26a25e1131 fix(layout): этап 3.1 — RailUserCard в footer + canvas-фон + отступы рейла
- AppDrawer: плоский items оборачивается в .rail__nav, появляется gap 4-8px до cmdk
- cmdk margin 16→8px, чтоб выровнять с rail__nav (8px от стенки)
- LeftDrawerMenu footer: WalletCardMini+LogoutButton → один canon RailUserCard
  (avatar+balance в primary-soft + Пополнить + встроенный signout)
- :deep override на .rail__usercard margin 16→12/8 и .rail__signout padding 12/20→12/12
- quasar-canon: body--light/dark + q-layout/q-page-container/q-page красятся
  через --p-canvas, чтобы фон страницы соответствовал шапке и рейлу
2026-05-27 09:17:06 +00:00
coopops e7a352af22 feat(layout): этап 3 — WalletCardMini + canon LogoutButton в footer AppDrawer
LeftDrawerMenu:
- legacy `MicroWallet` (ColorCard teal с большой карточкой пайщика + ИП-badge
  + Deposit/Withdraw micro-кнопки) → canon `WalletCardMini` из
  widgets/wallet-card-mini (тонкая карточка из shared/ui/domain/WalletCard
  compact-variant, читает walletStore сама).
- Убран toggle «свернуть/развернуть нижнюю секцию» — нижняя секция теперь
  всегда видна (короткий компактный блок: WalletCardMini + Выйти).
- Удалены неиспользуемые `onMounted`/`ref` импорты, slide-анимация и CSS
  toggle-кнопки.

LogoutButton:
- pug + q-item с красной полупрозрачной плашкой → плоский ghost-button в
  стиле rail-пункта AppDrawer: прозрачный фон в покое, на hover —
  `--p-neg-soft` фон + `--p-neg` текст/иконка.
- Из uppercase «ВЫЙТИ» в title-case «Выйти» (canon — никаких uppercase).

Визуально левый drawer теперь полностью в canon-семье: единый rail с
пунктами + поиском + компактным wallet-балансом + ghost-кнопкой выхода.

Закрывает этап 3 из 5 в волне «приземление DS на реальный layout».
Дальше — SignUp (этап 4) и Invite (этап 5).
2026-05-27 09:17:06 +00:00
coopops b524374b23 feat(layout): этап 2 — AppHeader в реальном layout (заменяет MainHeader)
widgets/Header/CommonHeader/Header.vue теперь использует canon AppHeader
из shared/ui/layout. Высота шапки 56px (--p-topbar-h), border-bottom
hairline, без shadow и фоновых градиентов.

Слоты:
- #crumb: BackButton (для авторизованных), либо title с названием
  кооператива из system.info.vars для гостей/install.
- #actions: headerActions injection через useHeaderActionsReader (всё что
  страницы инжектят — Deposit/Withdraw, SettingsDropdown и пр. — рендерим
  как было).
- #notifications: NotificationCenter (только loggedIn + isClient).
- #theme: ToogleDarkLight — пока legacy, сохраняет storage + PWA-цвет;
  наш canon ThemeToggle их не делает (привяжу позже отдельным эпиком).
- #profile: BaseButton primary для гостей — Регистрация/Вход (в зависимости
  от текущего route).

Удалены:
- MainHeader.vue (вся логика scroll arrows + carousel actions group ушла —
  canon `.topbar__actions { gap: 8px }` достаточно; overflow-scroll
  будет вернут отдельно если потребуется на узких экранах).
- HeaderStyles.scss (стили scroll-arrow и q-toolbar overrides больше
  не нужны — canon-разметка через `<header class="topbar">`).

q-header Quasar остаётся wrapper'ом (sticky-poзиционирование в q-layout),
внутри — AppHeader даёт canon-разметку.
2026-05-27 09:17:06 +00:00
coopops 8280cb938e feat(layout): этап 1 — AppDrawer в реальном layout (заменяет LeftDrawerMenu)
Переписан widgets/Desktop/LeftDrawerMenu/LeftDrawerMenu.vue: внутри теперь
canon `AppDrawer` из shared/ui/layout с rail-видом MONO v2 (логотип ПК +
короткое имя кооператива в шапке рейла, ⌘K-поиск, пункты-router-link'и
с иконкой/бэйджем, sticky-footer).

Адаптер store→canon:
- items: desktop.activeSecondLevelRoutes → RailItem[], с прежней
  фильтрацией по roles + meta.conditions + meta.hidden (один-к-одному
  логика из SecondLevelMenuList).
- activeKey: вычисляется из router.currentRoute с поддержкой группового
  паттерна project-* → projects-list.
- @select: router.push({name, params:{coopname}}) или
  actionsStore.executeAction(meta.action), закрытие drawer на mobile.
- @cmdk: эмиттим keyboard event ⌘K → CmdkMenu (он смонтирован глобально
  в default.vue) подхватывает.

Footer-слот: пока сохранён legacy MicroWallet + LogoutButton + toggle
свернуть/развернуть (заменим на WalletCardMini в этапе 3).

default.vue: q-drawer width 200 → 240 (canon-ширина rail'а).

Что НЕ затронуто этим коммитом: верхняя шапка (legacy MainHeader),
правый drawer для actions, q-footer для гостей. Их меняем отдельными
этапами 2–3.
2026-05-27 09:17:06 +00:00
coopops b327f1b103 fix(ui): убрать конфликт с Quasar — .row → .u-row
Корневая причина «оторванной» галочки в ResetKeyForm: я ранее задал в
mono-platform/components.css глобальное `.row { display: flex;
align-items: center; gap: 12px; }`. Quasar внутри `q-checkbox` (а также
q-radio, q-toggle, q-btn-toggle и др.) сам добавляет class="row" на
корень компонента и использует его как inline-flex — мой `gap: 12px`
раздвигал `.q-checkbox__inner` и `.q-checkbox__label` на 12px по всему
приложению.

Решение:
- Переименовать утилиту `.row` → `.u-row` (та же семантика, тот же набор
  модификаторов --wrap, --gap-2/4/6). У Quasar теперь свободный .row.
- Обновить все 6 использований в /_dev/ui.
- Откатить предыдущий :deep(.q-checkbox__label) padding-left:4px workaround
  в ResetKeyForm — лечил симптом, причина устранена.

Затрагивает только нашу dev-витрину и canon-utility; legacy pug-виджеты
с `.row.justify-center` продолжают работать через стандартный Quasar
flex-grid класс.
2026-05-27 09:17:06 +00:00
coopops 69e71871e8 fix(ui): ResetKeyForm — сжать зазор между галочкой и текстом «Я сохранил ключ»
Quasar по умолчанию даёт ~12–16px padding на .q-checkbox__label, в
compact-форме сохранения ключа это смотрелось «оторвано». Уменьшаем до 4px
через :deep override на классе .rk-form__confirm.
2026-05-27 09:17:05 +00:00
coopops 05c9fdf072 feat(dev): секция 20 — ResetKey двухэтапный flow в /_dev/ui
Три кадра в одной секции: LostKey (ввод email), ResetKeyForm check-mail
(уведомление «письмо отправлено»), ResetKeyForm save-key (демонстрация
сохранения ключа с моковым сгенерированным аккаунтом). Submit на третьем
кадре регенерирует мок-ключ для повторной проверки.

Заменяет ошибочную секцию ChangeKey из предыдущей итерации.
2026-05-27 09:17:05 +00:00
coopops 1d8268b41e fix(ui): откатить ChangeKey + мигрировать настоящий ResetKey flow на base-компоненты
ChangeKey-фича (формы current/new/confirm WIF + успешный диалог) была
ошибочной интерпретацией E7. Реальный flow «сменить ключ» в продукте —
это **двухэтапный ResetKey**:

  1. LostKey: пайщик вводит email (он потерял ключ — текущего WIF нет),
     бэк присылает письмо со ссылкой `?token=...`.
  2. ResetKey: при заходе по ссылке клиент сам генерирует новый ключ
     прямо в браузере (через `useCreateUser().generateAccount()`),
     показывает приватный ключ readonly с «Скопировать», требует
     подтверждения «Я сохранил ключ» и вызывает on-chain
     `resetKey({token, public_key})`.

Текущий ключ ввести нельзя (его нет). Новый ключ ввести нельзя (только
генерируется). Никакого подтверждения нового ключа — он один и тот же.

Что сделано:
- Удалена ошибочная `features/User/ChangeKey/`.
- Старый `widgets/Registrator/ResetKey/ui/ResetKey.vue` (pug + сырые
  q-input/q-checkbox + кастомные градиентные стили) разбит на:
  * `ResetKeyForm.vue` — presentation (props-driven, режимы
    `check-mail` / `save-key`), без useRouter/useApi — пригоден для
    /_dev/ui витрины.
  * `ResetKey.vue` — connected обёртка, читает `route.query.token`,
    генерит account через `useCreateUser`, диспатчит `resetKey` через
    `useResetKey`, на успех → router.push на signin.
- Новая UI собрана на BaseForm + BaseInput readonly mono + BaseButton +
  BaseBanner + AuthCard.

/_dev/ui секция 20 теперь показывает три кадра подряд: LostKey (email
шаг), ResetKey check-mail, ResetKey save-key с моковым сгенерированным
ключом.

FR11, UX-DR9, UX-DR15.
2026-05-27 09:17:05 +00:00
coopops 2e7a6547fb fix(ui): BaseInput/BaseSelect — явный проброс слотов вместо v-for in $slots
Generic-проброс `<template v-for="(_, slotName) in $slots" #[slotName]>`
рушится в Quasar 2.19 рендере: при наличии scoped-слота #append вылетает
`Cannot read properties of null (reading 'key')` в renderSlot + Vue warns
по `_isVue`/`constructor` свойствам. Заменяем на явный список слотов
(prepend / append / before / after / hint у q-input; +option, selected-item
у q-select) — синтаксис, который Quasar держит корректно.

Кейс всплыл на ChangeKeyForm: #append с q-icon (toggle visibility) ронял
страницу /_dev/ui целиком.
2026-05-27 09:17:05 +00:00
coopops aa24f3d9b2 feat(dev): секция 20 — ChangeKey flow в /_dev/ui (визуальная проверка E7)
Подключение ChangeKeyForm + ChangeKeySuccessDialog к dev-витрине.
Submit запускает заглушку useChangeKey().changeKey() (≈600 мс), по успеху
открывает диалог с новым WIF. Generate генерирует валидный K1 ключ через
SDK и заполняет оба новых поля. Confirmed сбрасывает форму.
2026-05-27 09:17:05 +00:00
coopops a444648db1 feat(ui): E7.2 ChangeKeySuccessDialog — подтверждение через BaseDialog без backdrop-exit
Диалог успеха смены ключа: новый WIF в моноширинном блоке с copy-кнопкой,
BaseDialog с closeOnBackdrop=false, closeOnEscape=false, hideCloseButton=true.
Закрытие только через primary-кнопку «Я сохранил ключ»; до копирования
кнопка показывает guard-баннер вместо немедленного закрытия. По confirmed
эмитим событие — реальный flow подключит router.push на дашборд.

FR11, UX-DR11, UX-DR15. Story 7.2 из epics.md.
2026-05-27 09:17:05 +00:00
coopops 39a03c48eb feat(ui): E7.1 ChangeKeyForm — смена ключа на BaseForm + BaseInput + BaseBanner
Форма смены ключа пайщика на новых компонентах дизайн-системы.
BaseBanner severity=warn с предупреждением о потере, checklist последствий,
поля current/new/confirm WIF моноширинные с toggle visibility, ghost-кнопка
«Сгенерировать» через PrivateKey.generate из @wharfkit/session.

Бизнес-логика — заглушка с TODO на привязку on-chain change-key action
(pattern см. features/User/ResetKey/api) и TODO на OtpInput шаг подтверждения
(приходит в Эпике 10).

FR11, UX-DR9, UX-DR15. Story 7.1 из epics.md.
2026-05-27 09:17:05 +00:00
coopops d70ecb3878 fix(ui): Avatar tone — единая семантика neutral/primary/accent, убрать ink
Bug после rename --p-accent → --p-primary: класс .avatar--accent смотрел
на --p-primary-soft (canon teal), а должен был — на --p-accent-soft
(warm orange по новой семантике). И вариант ink (белый bg + dark text)
визуально выпадал из палитры — крайний xl-аватар на демо-витрине казался
«не пришей к звезде рукав».

Новая семантика AvatarTone:
- neutral (default) — серый surface-3 + ink-1 (нейтральный)
- primary — canon-teal soft + canon-teal text (основной выделение)
- accent — warm-orange soft + warm-orange text (редкое выделение,
  например подсветка пайщика по batch_id)

Удалён вариант ink — не использовался в реальном коде.
В dev-витрине xl-аватар: tone="ink" → tone="primary".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:05 +00:00
coopops ed93765fb0 revert(ui): убрать override центрирования suffix/prefix — оставить Quasar дефолт
Override (padding-top:0 + align-self:center на dense) не отрабатывал —
Quasar в dense держит prefix/suffix по baseline вводимого значения,
и пользователю это устраивает. Убираем неработающий override, чтобы
не мешался в дальнейших правках.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:05 +00:00
coopops a252fe00ec fix(ui): BaseDialog — отступ через margin-top второго ребёнка
Inline `style="margin:0"` на <p>-описании перебивал мой margin-bottom
через CSS (специфичность inline > selector). Переключаюсь на
`> :first-child + *` margin-top — отступ крепится ко второму ребёнку,
не зависит от inline-стилей первого.

Работает универсально: <p> + <BaseInput>, <p> + <BaseForm>, любая
другая пара «описание + контент».

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:05 +00:00
coopops 0fbf764437 feat(ui): --p-accent (warm orange) + suffix-центр + точная вёрстка gap
1. --p-accent (canon token): #D84315 (light) / #FF7043 (dark).
   Совпадает с legacy Quasar $accent (warm orange, Material Deep Orange).
   Проброшен в --q-accent через CSS var — legacy color="accent",
   var(--q-accent), text-accent продолжают работать без изменений,
   но теперь следуют за canon-темой (light/dark). Резерв под редкое
   выделение: ссылки batch_id и т.п.
2. quasar-canon.css: .q-field--dense .q-field__suffix/prefix —
   padding-top:0 + align-self:center. Quasar по умолчанию ставит
   padding-top: 24px под floating-label, отчего «RUB» визуально
   опускался к низу control'а. Теперь в одну линию со значением.
3. BaseForm: убран gap 12px из .base-form__body — reserve-hint-space
   у q-input даёт ~24px снизу под error/hint, дополнительный gap
   делал расстояние между инпутами неприятно большим.
4. BaseDialog: убран универсальный gap из body, оставлен margin-bottom
   только на первом <p>/.intro — между описанием и первым инпутом
   воздух нужен, между остальными детьми reserve-hint-space хватает.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:05 +00:00
coopops 5ed4b25634 fix: убрать случайный symlink node_modules из индекса
Промахнулся `git add -A` — захватил worktree-symlink node_modules
(он pnpm-shared с основным репо). .gitignore покрывает node_modules/
но симлинк-файл — отдельный путь.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:05 +00:00
coopops fa423c1caa refactor(ui): --p-accent → --p-primary — устранить конфликт с Quasar primary
Контекст: после проброса --q-primary = var(--p-primary) (4fddfe2343)
наш токен --p-accent и Quasar primary стали обозначать ОДИН и тот же
canon-teal — два имени для одного цвета это когнитивный шум, и при
росте платформы будет путать. Имя `accent` оставляем свободным под
будущий ВТОРОЙ цвет (warm-orange, для редкого выделения вроде ссылок
по batch_id) — это отдельная задача когда понадобится.

Переименовано везде (109 ссылок в 6 файлах):
- --p-accent          → --p-primary
- --p-accent-hover    → --p-primary-hover
- --p-accent-press    → --p-primary-press
- --p-accent-soft     → --p-primary-soft
- --p-accent-line     → --p-primary-line
- --p-accent-strong   → --p-primary-strong
- --p-ink-on-accent   → --p-ink-on-primary

Файлы: tokens.css, quasar-canon.css, components.css, RailUserCard,
AuthCard, _dev/ui (включая user-facing labels палитры «Accent → Primary»
и заголовок секции «Акцент → Основной цвет»).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:17:05 +00:00
coopops 23389a8a42 feat(ui): canon-цвет через --q-primary CSS var; вернуть dense + rounded
- tokens.css: проброс canon-палитры в Quasar brand-переменные
  (--q-primary/negative/positive/warning/info = соответствующие
  --p-* токены). Резолвится lazy → автоматически следует за темой.
  Теперь Quasar САМ красит focus q-field, q-btn color="primary",
  bg-primary utility-классы в canon-цвета без CSS-overrides;
- quasar-canon.css: убраны border-color overrides на ::before
  (default/focus/error) — Quasar управляет цветом через --q-primary
  и собственный механизм --focused/--highlighted. Оставлены только
  background, text-color и rounded углы (border-radius var(--p-r-sm));
- BaseInput/Select: вернули `dense` (компактный 40px) и явный
  `color="primary"` (canon teal через --q-primary).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:37 +00:00
coopops 07cfff6341 fix(ui): BaseDialog — уменьшить отступ между body и footer-кнопками
Между нижним инпутом и footer-кнопками был ~32px (q-card-section
padding-bottom 20 + q-card-actions padding-top 12) — выглядело
растянуто. Сводим к 16px (8+8) — кнопки сидят ближе к контенту.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:37 +00:00
coopops d58cecc55d fix(ui): q-field overrides — оставить только цвет, всё остальное дефолт Quasar
Регрессия мерцания границы и приплюснутости — следствие борьбы наших
overrides с встроенной Quasar анимацией. Возвращаем дефолт: Quasar
сам решает геометрию (height/padding/border-radius/font), анимации,
opacity ::before и focus-индикацию. Наша задача — только покрасить
в canon-цвета.

Удалено из .q-field--outlined:
- height/padding/border-radius/font-size/font-family overrides;
- transition border-color (Quasar сам делает свой transition);
- opacity:1 !important на ::before (был костыль от мерцания);
- box-shadow:none на --focused/--highlighted;
- display:none на ::after underline;
- override .q-field--dense;
- override .q-field--float .q-field__label (Quasar анимирует label сам);
- focus-shadow / outline reset на native input.

Оставлено (только цвет):
- background: var(--p-surface);
- border-color: line-1 / accent (focus) / neg (error);
- color текста, placeholder, label, prefix/suffix, hint/error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:37 +00:00
coopops d32e63643c fix(ui): мерцание границы q-input на focus + unused import в BaseTable
- Quasar при focus анимирует opacity ::before (прячет старую границу
  перед показом ::after underline). Так как ::after мы спрятали,
  получался видимый gap ~200-400 ms «граница исчезла → появилась
  с подсветкой». Фикс: opacity:1 !important на ::before,
  transition только по border-color.
- BaseTable.vue: убран неиспользуемый импорт BaseTableColumn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:37 +00:00
coopops a9e6487ee7 fix(ui): убрать dense из BaseInput/Select; gap в Dialog/Form body; wallet 17px
- BaseInput/BaseSelect: убран `dense` — возвращаемся к дефолтному
  outlined Quasar (control ~56px). Floating-label получает достаточно
  воздуха, текст значения визуально центрируется без сжатия;
- BaseDialog: `.base-dialog__body` теперь flex-column с gap 16px —
  описание/инпуты/таблицы внутри диалога получают единый воздух
  без правки потребителя (пример: «Паевой взнос: укажите сумму…» →
  отступ до инпута появляется автоматически);
- BaseForm: `.base-form__body` gap 4px → 12px — те же требования
  внутри формы;
- WalletCard: `.wallet__metric-val` 19→17px — итоговое значение
  для аккуратного баланс-блока FULL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:37 +00:00
coopops 5462e59b47 fix(ui): убрать stack-label, двойную focus-подсветку и mini-wallet из шапки
1. Stack-label: BaseInput/BaseSelect → только `dense`. Quasar
   стандартно сам корректно держит floating-label в dense+outlined,
   без CSS-override метка работает по документации Quasar.
2. Двойная focus-подсветка: убран наш box-shadow var(--p-focus-ring)
   поверх Quasar внутренней focus-индикации (виден был как «белый
   ринг» на тёмной + наш teal). Теперь только border-color меняется
   через ::before (canon-минимализм). Дополнительно `box-shadow: none
   !important` на --focused/--highlighted control и `display:none` на
   ::after underline (на случай dense Quasar variants).
3. Mini-wallet в правом верхнем углу AppHeader: слот #wallet удалён
   из API (баланс уже отображается в RailUserCard в drawer'е,
   дубль в шапке избыточен). Убран из dev-витрины каркаса.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:37 +00:00
coopops a566b96609 fix(ui): WalletCard и mini-wallet — снизить визуальный вес цифр
Регрессия после Quasar wrapper-pivot: метрики кошельков выглядели
крупно по сравнению с заголовком/sub-label, а padding'и были
избыточны для compact-варианта.

- .wallet (FULL): padding 18→14px по верт, иконка 44→40px,
  metric-val 22→19px, ccy 13→12px;
- .wallet--row (compact): padding 14→10px, иконка 36→32px,
  metric-val 18→16px — выглядит как row в списке, а не как карточка;
- .rail__balance-val (mini-wallet в drawer): 22→18px — теперь не
  доминирует над identity-блоком пайщика.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops 80d1fe9e8f fix(ui): canon q-field/q-btn пропорции — убрать раздавливание floating-label
Корень регрессии: жёсткий height: 40px в .q-field--outlined раздавил
вертикальную разметку q-input (Quasar держит padding-top:24px под
floating-label слот; при сжатии текст значения сдвигается). Те же
эффекты для select.

- BaseInput/BaseSelect → dense + stack-label по умолчанию;
  Quasar сам корректно держит 40px в dense, метка статически стоит
  над инпутом без floating-механики;
- .q-field--outlined: убран принудительный height; .q-field--dense
  получает только padding-x, высоту контролирует Quasar;
- .q-btn: font-weight 500→450, min-height 40→36px, padding 16→14px —
  кнопки больше не выглядят «жирно/тяжело»;
- .q-btn--outline: hairline-граница через ::before (Quasar pattern),
  без двойной рамки;
- размеры sm/lg/dense пропорционально уменьшены;
- .q-chip: высота 24→26px, padding 10→12px, font-weight 500→450;
  + .q-chip__icon размер 14px (для точечных чипов).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops 7118328ba9 refactor(desktop/base): остальные base/ и AuthCard → обёртки Quasar
BaseSelect → q-select (outlined, reserve-hint-space, map-options/emit-value
для совместимости с {value,label} options API).

BaseCard → q-card flat с q-card-section head/body. Variants flat/inset/quiet
override-ят через scoped style.

BaseChip → q-chip с color mapping (variant → primary/positive/negative/
warning/info). Square для canon-look.

BaseBadge → q-badge с тем же color mapping. Dot-вариант через scoped style
(8×8 круг).

BaseBanner → q-banner rounded dense с border-left по variant.

BaseDialog → q-dialog + q-card с size→maxWidth mapping. closeOnBackdrop/
closeOnEscape пробрасываются через :persistent / :no-*-dismiss.

BaseTable → q-table с трансформацией columns в QTableProps['columns'].
Hide-pagination, binary-state-sort. Cell-slots работают через body-cell-{key}.

AuthCard (domain) — был на native canon `.card` div. Переведён на q-card flat
для консистентности. Усиленный shadow + accent-stripe сверху сохранены.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops 65db1b6660 refactor(desktop/base): BaseInput/Button/Form → обёртки Quasar
BaseInput теперь рендерит <q-input outlined reserve-hint-space no-error-icon>
с проброшенными slots. reserve-hint-space от Quasar резервирует место
под error/hint встроенно — раньше делали вручную через NBSP. Все Quasar
features (rules через v-bind \$attrs, mask, ref API, native slots) работают
прозрачно. Props API совместим (modelValue, label, hint, error, prefix,
suffix, mono, readonly, disabled, autocomplete, name, id, type).

BaseButton — q-btn с variant→Quasar mapping:
  primary → :unelevated color="primary" (заливка accent)
  secondary → :outline (border + surface)
  ghost → :flat (transparent)
  danger → :flat color="negative" (transparent + neg-cвет)
:no-caps :ripple=false для canon-look. Props API совместим.

BaseForm — q-form-обёртка с сводным error-баннером (q-banner) и slot footer.
banner—neg styling прилетает из quasar-canon.css через .bg-negative-soft.

Глобальная стилизация Quasar в quasar-canon.css даёт canon-look всем
этим компонентам и существующим q-input/q-btn/q-card по всему проекту.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops aba3fc53f0 feat(desktop/css): canon-стилизация Quasar поверх (quasar-canon.css)
Архитектурный pivot: вместо native HTML5-компонентов под canon-классами
стилизуем Quasar поверх. Причины:
- Сохраняется весь Quasar API из коробки (rules/mask/dense/slots/refs)
- Существующий код (сотни q-input, q-btn, q-card, q-select) автоматом
  получает canon-look без правок
- Меньше регрессий — Quasar отвечает за accessibility/keyboard/focus

Файл src/css/mono-platform/quasar-canon.css содержит overrides под canon
для: q-field--outlined (q-input/q-select), q-btn (unelevated/outline/flat),
q-card, q-chip, q-badge, q-banner, q-dialog/q-menu, q-item, q-table,
q-checkbox/q-toggle.

Подключен в quasar.config.cjs css[] после components.css. tokens.css
без изменений — все цвета/радиусы/тени тянутся как переменные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops 096f4c4ecc fix(desktop): удалить legacy глобальный .card { transform: scale(1.03) }
Истинный источник «прыгалки» AuthCard на hover: глобальное правило
`.card:hover { transform: scale(1.03) }` в src/app/styles/style.css
(и его дубль в pages/Marketplace/MainPage/ui/MainPage.vue, тоже не scoped)
бьёт по любому элементу с классом card. AuthCard имеет корневой
<div class="card auth-card"> — каждый hover дёргает её на 3%.

Поиск показал: класс `card` без модификаторов в templates никто кроме
новых canon-компонентов (BaseCard, AuthCard) не использует. Этот глобал —
мёртвый legacy, удаляем безопасно.

NBSP-фикс layout-shift из предыдущего коммита (2a2d9a786a) остаётся
актуальным — он лечил другую (на уровне поля) проблему.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops 02fa115365 fix(desktop/ui): добить layout-shift, ужать gap полей, accent-стрип AuthCard, затемнить canvas
1) BaseInput — layout shift жив: text-interpolation `{{ error || hint || ' ' }}`
   рендерила обычный пробел, браузер схлопывает text-node с одним whitespace
   в 0-width content → div сжимается → форма прыгает. Заменили на
   `<span>&nbsp;</span>` (NBSP в DOM, не схлопывается).

2) BaseInput — canon `.field { margin-bottom: 16px }` создавал слишком
   большой разрыв между полями (16px margin + 10px form gap + 17px message =
   ~43px). Override `.field { margin-bottom: 0 }` в scoped — расстояние
   управляется gap'ом BaseForm.

3) AuthCard — статичный accent-стрип 3px сверху (`::before`). Визуальный
   якорь без анимации — даёт бренд-маркер «здесь главное».

4) tokens.css light — canvas #fafafa → #f4f4f5 (cool-gray), canvas-2
   #f4f4f5 → #ebebed. Surface остаётся #ffffff, теперь карточка чётче
   проступает на фоне.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops d8b5afe7e4 fix(desktop/ui): убрать layout shift в BaseInput и оживить AuthCard на светлой
BaseInput: контейнер field__message теперь рендерится всегда с min-height
calc(--p-fs-meta * 1.4) — раньше div появлялся/исчезал на каждом keystroke
при валидации (см. LostKey emailError), форма прыгала вверх-вниз.
Когда нет ни ошибки, ни хинта — рендерится NBSP, высота сохраняется.

AuthCard: добавлен box-shadow поверх canon-минималистского .card —
canvas #fafafa и surface #ffffff на светлой почти неразличимы, без тени
карточка выглядит плоской. На тёмной отдельный shadow усилен (поверх
canon `--p-shadow-card`, который оптимизирован под data-cards, не hero).
border-color подкручен с --p-line на --p-line-1 для большей чёткости.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops 324d4fdab5 feat(desktop/LostKey): E6 Story 6.3 — миграция «Перевыпуск ключа» на canon
Scope-adjustment: plan'овая Story 6.3 предполагала миграцию SignUp как
второго auth-flow, но Register — multi-step wizard (10+ файлов в
pages/Registrator/SignUp/), который требует VerticalStepper из Эпика 10
и не помещается в Волну 1. Вместо этого мигрируем LostKey — простую
одно-полевую auth-страницу, того же auth-домена.

LostKey (widget): pug → html, старый AuthCard (q-card с shimmer) →
canon AuthCard. q-input → BaseInput с inline email-валидацией;
q-btn → BaseButton; всё обёрнуто в BaseForm с loading/error.
Бизнес-логика (useLostKey.startResetKey + router redirect на resetkey)
сохранена 1:1.

LostKeyPage: footer-кнопка «Назад» через ghost BaseButton в footer-slot
AuthCard; центровка через flexbox.

Не мигрировано в Волне 1 (известный gap):
- SignUp wizard (требует VerticalStepper из E10)
- ResetKey / Invite (на старом AuthCard, мигрируются в Волне 2)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops d5dfc3d645 feat(desktop/SignIn): E6 Story 6.2 — миграция Login на canon-компоненты
LoginForm (feature): q-input → BaseInput, q-btn → BaseButton, обёрнут в
BaseForm с управлением loading/error. Бизнес-логика (useLoginUser, redirect,
OpenReplay tracking, NotificationPermissionDialog) сохранена 1:1.
Ошибки входа теперь показываются через сводный error BaseForm
(плюс legacy FailAlert остаётся как fallback).

SignIn (widget): импорт AuthCard переключён на новый canon-композит
(src/shared/ui/domain/AuthCard), убран pug-template и градиентные стили.
Добавлен footer-slot — пробрасывается в AuthCard.

SignInPage: переписан с pug → html, footer-кнопки «Потеряли ключ?» /
«Нет аккаунта?» вынесены в footer-slot AuthCard через BaseButton ghost-sm.
Центровка через flexbox вместо QRow grid.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops 8591edb942 feat(desktop/AuthCard): E6 Story 6.1 — AuthCard композит + AuthLayout
AuthCard (src/shared/ui/domain/AuthCard) — canon .card + center-aligned
head (title/subtitle/slot head) + body-slot + опциональный footer-slot.
Старый src/shared/ui/AuthCard (q-card с shimmer-градиентом) остаётся
для не-мигрированных страниц (SignUp wizard, ResetKey, Invite).

AuthLayout (src/app/layouts/AuthLayout.vue) — wrapper-обёртка для full-page
auth-страниц: фон var(--p-canvas), центрирование, тогл темы в правом верхнем.

Витрина: dev-секция 19 с примером login-формы (BaseForm + BaseInput ×2 +
BaseButton + footer-ссылки).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops 294480ee5a feat(desktop/widgets/wallet-card-mini): E5 Story 5.2 — connected-обёртка WalletCard
WalletCardMini подключает WalletCard к Pinia (useWalletStore.program_wallets) +
System store (info.symbols.root_govern_symbol) + useRouter (клик →
name: 'wallet'). Программный mapping: wallet→MAIN, blagorost→BLAGOROST,
generator→GENERATOR (Zeus.ProgramType). Loading определяется автоматически
по наличию данных в store (или пробрасывается через prop).

Подключено в слот wallet AppHeader на dev-витрине (секция 16 — каркас) +
выделенная секция 18 с тремя программами. На dev-странице покажет
skeleton-loading т.к. loadUserWallet не вызывался; на реальных страницах
после init процесса — настоящий баланс.

Со-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops e4af71873e fix(desktop/_dev/ui): корректные подписи программ Благорост/Генератор в витрине WalletCard
Благорост — капитализация РИД (не «накопления на жильё»), Генератор —
генерация РИД (не «доходы от программы»). Кошелёк остаётся «свободный
остаток». Черновые ярлыки риском попадают в production через копипасту.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops f76e2c72f5 feat(desktop/WalletCard): E5 Story 5.1 — props-only компонент с программными акцентами
Канонический .wallet с компактным (--row) и full-вариантами, программные
акценты через --prog-blagorost / --prog-wallet / --prog-generator (UX-DR20).
Витрина на /_dev/ui секция 17: три программы × оба варианта + loading +
empty + locked-line.

Compact (.wallet--row): 36px icon, 18px sum, 14px padding — для слота шапки.
Full (.wallet): 44px icon, 22px sum, 18px padding — для дашборда.

Connected-обёртка widgets/wallet-card-mini (Story 5.2) делается отдельным
коммитом, требует интеграции с реальным wallet-store и MARKETPLACE_ASSET_CONFIG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:36 +00:00
coopops 816212cee0 revert(desktop/RailUserCard): убрать transition свёртки — baseline-skipping
Анимация max-height + opacity при свёртке давала видимый прыжок крупной
суммы баланса (`font-size: 22px` + `display: flex; align-items: baseline`):
во время промежуточных кадров baseline переcчитывался относительно
схлопывающегося контейнера → текст «прыгал в середину» и обратно.

Возвращаемся к canon `display: none`. Resize моментальный, без skipping.
Поведение rail (top опускается, bottom-chevron остаётся через flex-spacer)
работает и без анимации — пользователь это и подтвердил в фидбеке.

Chevron rotation через :deep(.q-icon) и balance-route не трогаем —
они независимы.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:35 +00:00
coopops 44f121a043 feat(desktop/_dev/ui): TokenPalette + chevron full-width fix
1. **Палитра системы.** Новая секция «00» в шапке dev-страницы со всеми
   основными --p-* токенами:
   - Акцент (accent / hover / press)
   - Поверхности (canvas / canvas-2 / surface / 2 / 3)
   - Текст (ink / ink-on-accent)
   - Линии (line / 1 / 2) — текстовый input для rgba
   - Статусы (pos / neg / warn / info)
   - Программные тинты (blagorost / wallet / generator)

   Color-picker для hex-токенов, текстовый ввод для rgba. Live-override
   через `document.documentElement.style.setProperty()` — inline-style на
   :root перебивает CSS-правила, перекрашивание мгновенное. Подсветка
   переопределённых токенов + счётчик в шапке + кнопка «Сбросить» (через
   removeProperty). Watch на Quasar Dark — при смене темы пикеры
   обновляются на новые resolved-значения.

2. **Chevron в свёрнутом виде — full-width.** Из-за того что для плавной
   анимации primary остаётся в DOM (max-height:0 вместо display:none),
   у него сохранялся `flex: 1` и он съедал ширину карточки → chevron не
   мог растянуться через canon-правило `width: 100%`. В `.is-collapsed`
   добавили `flex: 0 0 0` + `width: 0` для primary — chevron теперь
   корректно занимает всю ширину.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:35 +00:00
coopops fb43d9c2d0 fix(desktop/RailUserCard): chevron rotation, плавная свёртка, balance-route
По фидбеку:

1. **Chevron не переворачивается.** Canon-правило
   `.rail__usercard__collapse svg { transform: rotate(180deg) }` рассчитано
   на `<svg>`, а Quasar `<q-icon>` рендерит `<i class="q-icon">`. Прокидываем
   transition + rotate через scoped `:deep(.q-icon)` — chevron теперь крутится
   при свёртке/развёртке.

2. **«Карточка улетает»** — резкий jump при `display:none` создаёт визуальный
   прыжок. Подменяем display:none на `max-height: 0` + `opacity: 0` с
   transition'ом. Логика canon-селектора `.is-collapsed` не нарушается,
   просто добавлена плавность. Если структурное поведение rail (top
   опускается, bottom-chevron остаётся на месте) всё равно неудобно —
   перевёрстаем во вторую итерацию.

3. **Клик по балансу → маршрут кошелька.** Новый prop `balanceRoute` —
   если задан, блок `.rail__balance` оборачивается в `<router-link :to>`.
   Параллельно эмитим `balance-click` — для аналитики или функционального
   handler'а. На витрине привязал к `/_dev/ui#wallet` чтобы проверить.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:35 +00:00
coopops 0e60b5444a feat(desktop/domain): RailUserCard + фикс RouteMeta для dev-роута
1. `RouteMeta.icon` объявлен обязательным в `src/env.d.ts` — добавили
   `icon: 'fa-solid fa-flask'` для dev-роута, иначе vue-tsc валится.

2. `RailUserCard` (`shared/ui/domain/RailUserCard`) — доменный
   общеплатформенный компонент мини-кошелька в нижней части drawer'а.
   Это будущий E11 (Волна 2), но логично вытащить раньше, чтобы основной
   layout можно было заменить целиком уже сейчас.

   Реализован по canon строго: rail__usercard с usertop+balance+actions,
   collapse-chevron справа с поворотом arrow при свёртке (через
   `.is-collapsed` модификатор + canon CSS), отдельный `rail__signout`
   блок (выходит вне `.rail__usercard` — рендерится сразу после, в том
   же `aside.rail` через footer-slot).

   Контракт строго dumb по архитектуре: только props/emits/slots.
   - props: name, role, avatarSrc, balance, symbol, balanceLabel,
     lockedBalance, lockedLabel, primaryActionLabel, collapsed,
     showSignout, signoutLabel
   - emits: primary-action, update:collapsed (v-model), signout
   - slots: usertop-extra, actions

   Coнnected-обёртки (с инжектом store/auth) — на уровне страницы,
   не здесь.

3. Витрина `/_dev/ui`:
   - Секция 12 (AppDrawer): заменили временный огрызок rail-demo__user
     на полноценный `<RailUserCard show-signout>` через footer-slot.
   - Секция 16 (Каркас приложения): аналогично — теперь у rail полноценный
     footer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:35 +00:00
coopops 9ebed71283 feat(desktop/layout): E4 — AppDrawer + AppHeader + PageHead + PageTabs
E4 «Layout-каркас» Волны 1: 4 структурные обёртки в `src/shared/ui/layout/`.
По архитектуре — только props/emits/slots, без store/router/api, без знания
о данных. Cтраницы передают menu-items, breadcrumbs, табы и активные ключи
сверху.

Состав:
- `AppDrawer` — canon `.rail`: solid surface, активный пункт soft-accent
  пилюлей + 2-px рейл слева. Поддерживает плоский список + секции
  (RailSection с eyebrow-меткой), opt-in ⌘K-кнопку, slot `footer` для
  user-card / logout.
- `AppHeader` — canon `.topbar`: бургер · crumb · `topbar__actions` ·
  `topbar__right`. Поддерживает простой title и multi-level breadcrumb.
  Глобальные действия через слоты `notifications`/`theme`/`wallet`/`profile`
  или общий `right`.
- `PageHead` — canon `.page-head`: eyebrow + title + subtitle + slot
  `actions`. Используется когда страница БЕЗ подстраниц.
- `PageTabs` — canon `.tabbar` + `.tab`: вкладки подстраниц с tab__count,
  опциональный hairline-разделитель и `tabbar__actions` справа.

Также в составе коммита:
- Убрали AuthCard из base — он композит (cap + body + head + footer),
  переедет в E6 Auth flow рядом с SignUp/ResetKey/Invite.
- BaseForm: gap полей 16→10px (тесные формы как в каноне).
- Витрина `/_dev/ui`: 4 новые секции (12-15) на каждый layout-компонент +
  секция 16 «Каркас приложения» — AppDrawer + AppHeader + PageTabs в живой
  связке без legacy QLayout/QDrawer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:35 +00:00
coopops 1148eac2ed fix(desktop/mono-platform): убрать HTML-теги <style> из canon CSS
Скрипт извлечения из bundled HTML оставил `  <style>...` в начале и
`</style>` в конце обоих файлов. CSS-парсер ловил `<` как ошибочный
селектор и пропускал блок до ближайшей `}` — съедая весь первый блок
правил.

В tokens.css первым блоком был `:root, [data-theme="light"]` со светлой
палитрой — она целиком игнорировалась. `[data-theme="dark"]` ниже
парсился нормально, поэтому тёмная тема работала, а светлая — нет:
кнопки primary/ghost/danger оставались с transparent background и
сливались с canvas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:35 +00:00
coopops e741b5646a feat(desktop): MONO Platform v2 — базовые компоненты дизайн-системы
Заносим эталонную дизайн-систему в проект — только базовые элементы для
дальнейшей компоновки. Source — MONO Design System.html.

Архитектура (по `_bmad-output/planning-artifacts/architecture.md`):
- `src/css/mono-platform/{tokens.css,components.css}` — canon токены и стили
  компонентов 1:1 из эталона; импортируются первыми в quasar.config css[].
- `src/shared/ui/base/<Имя>/{Vue,types,index}` — 14 базовых обёрток (Vue 3 +
  TS, native HTML над canon BEM, no Quasar-deps кроме Dark API в ThemeToggle).
  FSD-граница: только props/emits/slots, никаких store/router/api.
- `src/boot/ui.ts` — глобальная регистрация 14 базовых компонентов.
- `src/boot/theme.ts` — sync `html[data-theme]` ↔ Quasar Dark.
- Inter + JetBrains Mono в index.html.

14 базовых:
BaseButton, BaseInput, BaseSelect, BaseCard, BaseTable, BaseChip, BaseBadge,
BaseDialog, BaseBanner, BaseForm, EmptyState, Avatar, ThemeToggle, AuthCard.

Витрина `/_dev/ui` (dev-only) — каждая обёртка отдельной секцией над чистым
canon-канвасом, без layout-обёрток. Маршрут смонтирован на корневом уровне,
ВНЕ DynamicLayoutWrapper, чтобы legacy default.vue не перебивал стили.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:16:35 +00:00
ant 937d4e3fb6 Merge pull request 'Дизайн-волны 1–2: канон UI, формы, документы, навигация, шапка, кошелёк' (#25) from worktree-design-wave1 into dev
Build bootstrap container / build (push) Successful in 4m8s
Reviewed-on: #25
2026-05-27 09:15:38 +00:00
coopops bcb76b4cd9 merge: разрешены конфликты с dev — boot-скрипт под dev, кошельки под canon
Typecheck / desktop (pull_request) Failing after 8m35s
Typecheck / controller (pull_request) Successful in 13m22s
- clean_reboot.sh: версия origin/dev (унификация трёх reboot-скриптов)
- WalletProgramWidget.vue: canon-версия (WalletCard + EmptyState)
- ParticipantWalletsPage.vue: canon-токены --p-pos/--p-warn + value-blocked

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:14:37 +00:00
coopops a267a8a24b [design-wave1][@dark] feat: тактильная отдача (вибрация) при перезагрузке страницы
Boot-файл haptics: при reload страницы на устройстве с Vibration API (мобильный
браузер / PWA на Android) коротко вибрирует (15 мс). Reload определяется через
Navigation Timing (с фолбэком на legacy API), чтобы не срабатывать на первой
загрузке/навигации. Полностью безопасно для десктопа и обычных сайтов: где
Vibration API нет — вызов не делается, любая ошибка проглатывается, на загрузку
приложения не влияет. Только клиент (гард для SSR).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:05:34 +00:00
coopops 8135ea46a5 [design-wave1][@dark] fix: убрать паразитный горизонтальный скролл левого дровера
В левом дровере был маленький горизонтальный скроллбар, и трекпадом его можно
было «оттянуть», обнажая правую границу — дровер выглядел скроллируемым, хотя не
должен. Причина: у .rail собственный border-right (1px), который при content-box
даёт ~1px overflow внутри контента дровера. Фикс в .app-left-drawer: overflow-x
hidden + overscroll-behavior-x contain на .q-drawer__content, .rail приведён к
box-sizing border-box и width 100%.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:53:46 +00:00
coopops 50e60f5750 [design-wave1][@dark] fix: повестка — держать лоадер на «Утвердить» 3с после голоса
Если проголосовать и сразу нажать «Утвердить», утверждение падает с ошибкой —
бэкенд ещё не учёл голос из блокчейна. После успешного голоса держим состояние
загрузки пункта (а значит и кнопку «Утвердить» в :loading) ещё VOTE_SETTLE_MS
(3с), за это время голос успевает обработаться. На ошибке загрузка снимается
сразу. Заодно ручное мутирование processingDecisions + setTimeout-хак заменены
аккуратным реактивным хелпером setProcessing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:49:25 +00:00
coopops 92bd6919d8 [design-wave1][@dark] fix: повестка — скрывать пункт только при утверждении, не при голосовании
Повестка совета показывает все НЕутверждённые вопросы. Голос «за»/«против» не
должен убирать пункт — он остаётся неутверждённым, лишь помечается отметкой
голоса. Убрал ошибочное actedDecisionIds.add из onVoteFor/onVoteAgainst
(оставлен только тихий рефетч). Скрытие через actedDecisionIds оставлено только
в onAuthorizeDecision — там пункт исполняется и должен уйти из повестки без
возврата от отстающего поллинга.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:46:11 +00:00
coopops 63fdb74db5 [design-wave1][@dark] fix: повестка совета — без моргания и без «исчез/вернулся» при голосовании
Две проблемы при голосовании/утверждении в повестке совета:

1. Моргание всей страницы: QuestionsTable подменял список тремя
   скелетонами при любом loading=true, а пост-экшен loadDecisions вызывался
   без hidden → loading=true → список «моргал» в скелетоны и обратно.
   Скелетоны теперь показываются только на первой загрузке (loading &&
   !decisions.length), а пост-экшен рефетчи переведены в тихий режим (hidden).

2. «Исчез → вернулся → исчез»: повестка на бэкенде уже не отдаёт пункт, по
   которому проголосовал/утвердил, но данные из блокчейна доходят с задержкой,
   и поллинг успевал вернуть отработанный пункт. Добавлен локальный набор
   actedDecisionIds — проголосованный/утверждённый пункт прячется сразу и не
   возвращается, что бы ни подтянул отстающий рефетч. Просто, без оптимистичных
   merge-оверлеев.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:43:01 +00:00
coopops 5d837f2c20 [design-wave1][@dark] fix: push-подписка не должна вешать UI и шуметь красной ошибкой
Две независимые проблемы при выдаче разрешения на уведомления:

1. Фриз приложения: shouldShowDialog был computed с побочным эффектом —
   внутри геттера вызывался updateSupport(), присваивавший store.support
   новый объект на каждое чтение. После выдачи разрешения это давало каскад
   инвалидаций/ре-рендеров, забивавший главный поток (роутер менял URL, DOM
   «застывал»). Геттер сделан чистым, updateSupport() вынесен в showDialog().

2. Красная ошибка «Service Worker не готов в течение 5 секунд»: в dev без PWA
   SW намеренно не регистрируется, поэтому navigator.serviceWorker.ready
   никогда не резолвится. Теперь сначала проверяем getRegistrations() — при
   отсутствии SW выходим сразу и тихо (warn вместо error), а subscribe()
   возвращает false без FailAlert. Push при недоступности SW полностью
   изолирован и не влияет на работу приложения.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:34:59 +00:00
coopops 426a06a076 [design-wave1][@dark] feat: скрывать «Совершить взнос»/«Получить возврат» до подписи соглашения кошелька
Кнопки взноса и возврата на столе пайщика недоступны, пока пайщик не
подписал главное соглашение цифрового кошелька (type='wallet') — как и
сама карточка кошелька, которая не появляется без подписанного соглашения.

Геттер isWalletAgreementSigned в сторе Wallet читает тот же список
соглашений пайщика, что и RequireAgreements.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:22:42 +00:00
coopops c2bb718895 [design-wave1][@dark] copy: явная опциональность шага «Импорт пайщиков» (онбординг председателя)
«(опционально)» в заголовок + пояснение в описании: если есть пайщики —
импортируйте CSV, если нет — двигайтесь дальше. Чтобы шаг не воспринимался
обязательным.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:05:49 +00:00
coopops 04cf9dc3c4 [design-wave1][@dark] fix: ключ в Invite — выделенная панель вместо readonly-инпута
readonly outlined q-input рендерится с dashed-рамкой (дефолт Quasar) и
обрезал значение (…UJR4) — выпадало из канона и выглядело плоско.
Сделал ключ surface-2 панелью: eyebrow-лейбл + copy-иконка в шапке,
mono-значение целиком (word-break) ниже. Даёт вес и убирает «ёлочку».

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:01:41 +00:00
coopops d243bf5621 [chatcoop-hide-matrix-room-id][@ant] fix(security): не отдавать matrixRoomId на фронт — комнаты секретаря и транскрипции по opaque id
Typecheck / desktop (pull_request) Successful in 13m48s
Typecheck / controller (pull_request) Successful in 13m9s
matrixRoomId — это одновременно идентичность и capability: зная его, можно
попытаться войти в комнату. Раздавая его на фронт (страница «Комнаты секретаря»
и список транскрипций отдавали его в каждой строке), мы по сути публиковали
handle для входа в приватные комнаты любому, кто видит раздел. Приватность тогда
теряет смысл.

Комнаты секретаря:
- ChatcoopSecretaryRoom и RemoveSecretaryRoomInput переведены с matrixRoomId на
  внутренний opaque id реестра (DB-ключ, по нему войти в Matrix-комнату нельзя);
- удаление резолвится по id → matrixRoomId на бэкенде; мутация больше не
  возвращает matrixRoomId;
- фронт: row-key/удаление по id, убран показ matrixRoomId в таблице;
- попутно фикс латентного бага из PR #34: parseKind не знал про 'secretary' и
  мапил такие комнаты в capital_project — из-за чего editable=false и удаление
  падало с «удалять можно только комнаты секретаря».

Транскрипции:
- CallTranscription больше не отдаёт matrixRoomId (на фронте он не отображался,
  но уходил в payload по всем комнатам, включая приватные). Остаётся roomId
  (LiveKit room name — внутренний, не Matrix-handle);
- input-фильтр GetTranscriptionsInput.matrixRoomId сохранён: это вход, не утечка,
  и им пользуется blago-cli;
- blago-cli: рендер транскрипций больше не зависит от tr.matrixRoomId (строка
  «Matrix room» убрана, иначе pull и restore рассогласовались бы — restore не
  имеет контекста комнаты). Заодно поправлен fallback курсоров.

Вне scope намеренно: chatcoopListNonProjectCommunicationRooms и per-room запросы
оставлены с matrixRoomId — их зовёт только blago-cli (доверенный инструмент
председателя), на фронт они не уходят, и matrixRoomId там — операционный ключ
синхронизации.

logger.ts: при CONTROLLER_SCHEMA_GEN не подключаем файловые транспорты winston
(codegen рядом с работающим контейнером падал на EACCES в root-овый logs/).

schema.gql + zeus (sdk и controller) перегенерированы и закоммичены — иначе CI
typecheck собирает sdk против старого zeus и падает.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:59:34 +00:00
coopops 5dec964a83 [design-wave1][@dark] fix: канон-композиция карточки «Сохраните ключ» (Invite)
Прошлый вариант разъезжался: «Скопировать» висел сиротой справа, провалы
между блоками, узкая потерянная кнопка, карточка 560px — разрежено.
Переделал по канону:
- копирование ключа — иконкой content_copy в append самого поля (+tooltip)
- кнопка «Установить ключ» — full-width (block), как «Войти» в LoginForm
- ширина карточки 480 как у остальных auth-карточек
- чекбокс слева, плотный ритм без лишних margin (reserve-hint-space)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:57:58 +00:00
coopops dd2ebc2983 [design-wave1][@dark] copy: полные примеры ОПФ+ в placeholder (с «Социального Комплекса»)
Голый «Потребительский Кооператив» в примере провоцировал вводить просто
ОПФ без расширения. Дал полный пример «...Социального Комплекса» во всех
трёх падежах — чтобы вводили расширенную ОПФ+, а не базовую форму.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:51:44 +00:00
coopops 3f6c2538fc [design-wave1][@dark] copy: шаг «Настройка фабрики документов» вместо «Переменные документов»
Это не переменные, а постоянные параметры, которыми настраивается фабрика
документов кооператива. Переименовал label и intro шага.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:49:33 +00:00
coopops 81cb16eb61 [design-wave1][@dark] revert: убрать глобальный margin-bottom у .q-field--with-bottom
Микроотступ стал лишним и снова раздвигал поля слишком широко — откатываю.
Разделение полей обеспечивает сам reserve-hint-space, дополнительный
глобальный отступ не нужен.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:48:17 +00:00
coopops 5de5e07dae [design-wave1][@dark] fix: примеры-подсказки в placeholder вместо hint (SetVariablesForm)
Постоянный hint под полем занимал место и отвлекал. Перенёс примеры
наименований ОПФ+ в placeholder (видны только в пустом поле), убрал hint.
Добавил reserve-hint-space всем q-input формы — строка под ошибку
резервируется без текста, валидация не вызывает layout shift.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:42:45 +00:00
coopops 5470e9ad89 [design-wave1][@dark] fix: системный микроотступ под полями с hint/error
Без зазора текст hint/error верхнего поля почти касался границы нижнего.
Вернул маленький отступ, но системно и один раз — глобально в quasar-canon:
margin-bottom: var(--p-1) только полям .q-field--with-bottom (те, что
резервируют нижнюю строку). Работает везде (BaseForm, формы установки,
регистрация), в стеках не складывается в дыру, инлайновые поля не трогает.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:22:06 +00:00
claude 8ac3f06928 booter fix 2026-05-27 12:17:17 +05:00
coopops 6c1b6262b2 [design-wave1][@dark] fix: убрать двойной зазор между полями форм установки
reserve-hint-space у q-input/BaseInput уже даёт ~24px снизу под error/hint;
дополнительный gap в стеках полей складывался с ним → избыточное расстояние.
Убрал gap (канон BaseForm__body):
- CreateOrganizationDataForm / IndividualDataForm (.user-data-stack)
- SetVariablesForm (.vars-section__fields)
- RequestKeyForm (.request-key)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:13:35 +00:00
coopops 618349ce91 [design-wave1][@dark] fix: dense-инпуты в формах установки кооператива
Канон-плотность полей (dense) была только в BaseInput; общие формы
CreateOrganizationDataForm и IndividualDataForm + поля шагов установки
рендерили крупные не-dense q-input. Добавил dense:
- CreateOrganizationDataForm (22 поля) — данные организации
- IndividualDataForm (6 полей) — члены совета
- email-инпуты SetInitForm/SetSovietForm и поля SetVariablesForm

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:09:47 +00:00
coopops 63d95f429d [design-wave1][@dark] feat: страница установки кооператива в канон
- InstallCooperativePage: VerticalStepper вместо q-stepper, canon-панель
  с accent-стрипом, экран завершения в каноне (без градиентов/shimmer)
- RequestKeyForm: BaseInput (mono) + BaseButton
- SetInitForm: canon info-нотки, кнопки Назад/Далее на BaseButton
- SetSovietForm: canon-карточки членов совета вместо q-card/q-badge
- SetVariablesForm: canon-секции, q-input outlined с правилами, BaseButton
- Invite: осмысленное тело AuthCard при отсутствии токена приглашения

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 05:36:48 +00:00
coopops 39a9727a93 [design-wave1][@dark] fix: убрать двойной отступ сверху на странице расширения
.extension-page уже внутри .catalog-shell__content с padding var(--p-6);
собственный padding давал двойной зазор от шапки до кнопки «Назад».

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:23:54 +00:00
coopops 1841c3ebaa [design-wave1][@dark] style: панель вокруг контента расширения + «Отменить» в настройках
- Весь контент страницы расширения обёрнут в канон-панель (surface+border),
  больше не лежит на голом фоне
- В режиме настроек добавлена кнопка «Отменить» (CancelButton) рядом с
  «Сохранить» — явный выход без сохранения; кнопка «Назад» страницы тоже работает

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:13:32 +00:00
coopops 46617a1430 [design-wave1][@dark] style: страница расширения — назад на уровень страницы, центровка лого/столов, вкл+удалить в строку
- Кнопка «Назад» убрана из шапки (useBackButton снят), добавлена канон-кнопка
  под шапкой на самой странице
- Логотип AutoAvatar центрирован в колонке
- DesktopsList: центрированный inset-блок (выделяется), а не плоский список
- ext-actions toggle: «включено» и «удалить» снова в одной строке

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:07:41 +00:00
ant 4c4c611a3d [nodemon-restart-storm][@ant] fix: debounce nodemon + SIGTERM в controller/nodemon.json — устранить restart-storm, вешавший coopback на bind-mount
Без delay nodemon реагировал на каждое fs-событие пачки (правки/сборка/git на хосте) десятками рестартов в секунду; pstree.remy без `ps` в образе виснет на обходе /proc (накапливались зомби-сканеры), холодный старт ts-node прерывался, порт 2998 не занимался — бэкенд молча лежал. delay:2000 схлопывает пачку в один рестарт, signal:SIGTERM даёт чистое завершение дочернего процесса.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:04:05 +00:00
coopops a42210e5b8 [design-wave1][@dark] feat: страница расширения и его настройки в канон
- ExtensionPage: двухколоночный канон-layout с padding; q-img заменён на
  генеративный логотип AutoAvatar (animated), консистентно с каталогом
- ExtensionInfo: канон-заголовок + статус-бейдж, теги убраны
- ExtensionActions: компоновка кнопок в столбец на токенах, teal→primary
- ExtensionInstall: BaseCard вместо .info-card/text-h5
- DesktopsList: text-grey/subtitle → канон-токены
- SaveButton/SettingsButton/InstallButton: teal→primary, no-caps

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:00:52 +00:00
coopops 7e4cb15207 [design-wave1][@dark] style: анимация логотипа по наведению на всю карточку
Триггер ринг-анимации перенесён с hover самого SVG на hover карточки
(.app-card:hover :deep(.ring-seg)).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:54:11 +00:00
coopops 0759793fb0 [design-wave1][@dark] feat: анимация логотипа приложения по наведению
AutoAvatar получил режим animated: инлайн-SVG вместо <img>, каждый
сегмент-дуга обёрнут в .ring-seg. По hover логотипа сегменты на ~1.3с
расходятся — прямые и зеркальные (matrix-flip) от одного поворота идут
в разные стороны, со стаггером по индексу. CoopCard не затронут (img/blob).
Уважает prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:51:43 +00:00
coopops 1b9fbabac5 [design-wave1][@dark] style: убрать фиксированный резерв высоты заголовка карточки
Статус идёт сразу под заголовком (динамично) — без дыры под однострочными
названиями. Обрезку в 2 строки оставили, чтобы длинный заголовок не ломал сетку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:46:22 +00:00
coopops 717eae8540 [design-wave1][@dark] style: выровнять карточки приложений при переносе заголовка
Заголовок резервирует 2 строки (clamp+min-height), аватар по верху —
статус и описание на всех карточках начинаются на одной линии, не прыгают.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:43:45 +00:00
coopops c456602763 [design-wave1][@dark] style: компоновка карточки приложения — заголовок и статус рядом с логотипом
Верхний ряд: логотип + (заголовок, под ним статус); описание во всю
ширину ниже; футер «Подробнее» прижат к низу для выравнивания карточек.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:40:13 +00:00
coopops a36f5a63a9 [design-wave1][@dark] style: приглушить генеративный логотип каталога
Палитра колец → мягкие приглушённые тона + CSS saturate(.5)/opacity .85,
чтобы знак читался как тихая текстура, а не неоновое пятно.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:35:26 +00:00
coopops 6eced283ec [design-wave1][@dark] feat: генеративный логотип (DiceBear rings) в карточке приложения
- AutoAvatar параметризован: props size/radius/background/ringColor
  с обратно-совместимыми дефолтами (CoopCard не затронут)
- ExtensionCard: буква-монограмма заменена на уникальный rings-логотип,
  seed = имя расширения; цвет кольца детерминирован по seed из палитры

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:29:36 +00:00
coopops 6890b13387 [design-wave1][@dark] feat: каталог приложений в канон — табы на 2-й уровень, карточки без изображений
- «Магазин приложений» → «Каталог приложений» (drawer + breadcrumb)
- Витрина/Установленные перенесены из топбара в SecondLevelTabs
- ExtensionCard переверстан: убраны изображение и теги, добавлены
  монограмма-плитка, статус-бейдж, обрезка описания, футер «Подробнее»
- Quasar .row/.col-md-3 → канон auto-fill грид

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:19:38 +00:00
ant d8c24ada78 [fix-ext-config-lost-update][@ant] fix: убрать lost-update конфигов расширений в периодических задачах — cron-планировщики перезаписывали config устаревшим in-memory снимком с boot и стирали онбординг-флаги/хэши председателя
extensionRepository.update заменяет config JSONB целиком; chairman/powerup/meet-tracker писали захваченный при initialize снимок поверх свежих данных. Теперь read-modify-write по актуальному config из БД с наложением только владомых полей.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:09:00 +00:00
coopops 18f8e2a86c [design-wave1][@dark] fix: вёрстка формы провайдера платежей + текст контактов
- PaymentProviderForm: убраны вложенные q-gutter (кривой левый отступ
  селекта), standout=bg-teal → outlined, text-grey-7 → токен; чистый
  канон-столбец (селект max-width 480, hint ink-2, действия в ряд, no-caps).
- ChangeContacts: текст уточнён — контакты видны всем (раздел «Контакты
  кооператива» пайщикам + подвал сайта незарегистрированным), не только
  незарегистрированным.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:03:04 +00:00
coopops abaf0cbdab [design-wave1][@dark] feat: канон для остальных страниц стола председателя
Кооперативные участки, Регистрационные взносы, Ключ кооператива,
Провайдер платежей, Контакты кооператива:
- убран дублирующий заголовок страницы (есть крошка в шапке);
- описание перенесено с голого фона на канон-поверхность .banner;
- page-shell/hero/surface-card с хардкод-px → канон-отступы и токены;
- q-input/q-select standout=bg-teal → outlined color=primary.
Также удалён лишний хвост текста на Стартовых страницах.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:54:16 +00:00
coopops 3560839ef8 [design-wave1][@dark] fix: убрать дубль заголовка на Стартовых страницах и Членах совета
Заголовок страницы уже показан крошкой в шапке — повтор h2 убран.
Пояснительный текст вынесен с голого фона на канон-поверхность (.banner
с инфо-иконкой). ApprovalsPage не трогаем — там заголовка страницы нет.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:48:07 +00:00
coopops 4a4024f3f4 [design-wave1][@dark] feat: канон для Запросов одобрений, Стартовых страниц, Членов совета
- ApprovalsPage: канон-отступы страницы, фильтр-селект teal standout → outlined.
- SystemSettingsPage: hero-card/surface-card с хардкод-px → канон page-head
  (h2 + sub на токенах) + q-card(flat); канон-отступы.
- MembersPage: тот же канон-паттерн вместо hero-card с хардкодами.
- DefaultPagesForm: 4× q-select standout=bg-teal → outlined color=primary.
Виджеты таблиц/состава совета без экзотики — канон-тема через quasar-canon.css.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:39:24 +00:00
coopops fee0a27b3f [design-wave1][@dark] fix: убрать Vue-warn о class в WorkspaceSwitcher
Шаблон двухкорневой (кнопка + Teleport затемнения), class из родителя
(LeftDrawerMenu) не наследовался автоматически. inheritAttrs:false +
v-bind=$attrs на корневую кнопку — атрибуты направлены явно.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:36:25 +00:00
coopops ac20b270eb [design-wave1][@dark] style: согласовать типографику диалога повестки
Подписи секций «Вопрос на повестке»/«Проект решения» переведены в
надстрочные метки (uppercase, трекинг, ink-3) — отдельный ярус ниже
заголовка окна, чтобы не казаться конкурирующим заголовком рядом с
крупным заголовком самого документа. Текст вопроса — читаемый body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:28:55 +00:00
coopops 90c733120d [design-wave1][@dark] fix: канон-отступы и чистка онбординга председателя
- ConnectPage: добавлены канон-отступы страницы (var(--p-6)/var(--p-4)) —
  класс .padding в проекте не определён, контент липнул к краям.
- OnboardingStepsCard: убран q-card (двойной паддинг), текст-интро
  растянут на полную ширину (снят max-width 70ch), вокруг кнопки шага
  убрана лишняя рамка (.step__content → .step__action, только отступ).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:20:05 +00:00
coopops b6b1f06948 [design-wave1][@dark] feat: онбординг председателя в канон (вертикальный степпер + текст + диалог повестки)
- OnboardingStepsCard: под заголовком «Адаптируйте кооператив…» добавлен
  поясняющий текст (что происходит дальше: решения совета в электронной
  форме, вступление в «Восход», импорт пайщиков, общее собрание) + плашка
  срока адаптации с отсчётом.
- Чек-лист шагов переведён на канон-вертикальный степпер (.stepper--v):
  пройденные — галочка/заливка primary, текущий — подсветка номера,
  действия — канон BaseButton; agenda/import/meet типы сохранены.
- Диалог «Предложение повестки» (объявить собрание совета) переведён
  с ModalBase на канон BaseDialog + BaseButton.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:06:00 +00:00
coopops 32afc40147 [design-wave1][@dark] feat: хлебные крошки в шапке + скрыта кнопка «Перевести»
- CommonHeader: крошка строится из route.matched (путь внутри стола),
  напр. «Отчётность › Календарь»; корень стола не выводится; плоские
  страницы дают одну крошку как прежде; pageTitleOverride сохранён.
- CoopWalletsPage: переводы временно скрыты под флагом transferEnabled=false
  (открывались с L3-кошельков пайщиков; вернём для кооп-кошельков позже).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:59:08 +00:00
coopops 93d2c6ce77 [design-wave1][@dark] style: канон-токены в редакторе отчёта (ReportEditorDialog)
- editor-container/action-panel/backdrop/validation-badge/mark-hint: hex и rgba → --p-* токены
- text-grey-8 → t-muted; box-shadow/overlay через токены с fallback

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:30:47 +00:00
coopops 099f7b5aa2 [design-wave1][@dark] style: канон-токены на списочных страницах бухстола + cells
- Operations/Postings/Accounts: канон page-padding, убран двойной q-mt-md, text-grey-* → t-faint/t-muted, hex-границы → токены
- Settings: канон-padding, save-bar на токены (--p-surface/--p-line/--p-pos), text-grey-7 → t-muted
- AccountIdCell/WalletIdCell: text-grey-6 → t-faint; DirectionCell: move-иконка на --p-ink-3
- PROCESS_COLORS доменного цвет-кодирования операций сохранены (это фича)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:28:50 +00:00
coopops 6e9d466172 [design-wave1][@dark] style: канон-токены на столе бухгалтера — кошельки, календарь, отчётность
- CoopWalletsPage/ParticipantWalletsPage: rgba/var(--q-*) → канон-токены (--p-pos/--p-warn/--p-ink-*)
- ReportsCalendar/CalendarCell: статусы и грид на канон-палитру, убраны body--dark дубли
- Documents подстраницы: убран двойной padding, q-chip→BaseBadge, таблицы в канон-карточку

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:24:33 +00:00
coopops 511e9ab2ad [design-wave1][@dark] feat: канон-меню второго уровня (SecondLevelTabs) на столе бухгалтера
- SecondLevelTabs на глобальных .tabbar/.tab — sub-навигация между топбаром и контентом
- WalletsPage/DocumentsPage shell-страницы: вкладки в .tabbar вместо RouteMenuButton в топбаре
- TransferWalletsButton: канон header-кнопка (q-btn primary)
- WalletTransferDialog: на BaseDialog + BaseButton, токены вместо rgba

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:19:33 +00:00
coopops 9373dfe847 [design-wave1][@dark] fix: канон-поля ZodForm и убран дубль заголовка «Настройки»
ZodForm: убран standout="bg-teal text-white" с динамического поля —
QInput/QSelect теперь канон (outlined+dense+color=primary+reserve-hint-space),
как BaseInput. SCSS на канон-токены (вместо неопределённого --q-primary-rgb).
ExtensionSettings: снят заголовок «Настройки» внутри карточки — он дублировал
«Настройки аренды» в шапке.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:01:36 +00:00
coopops e163bda08f [design-wave1][@dark] fix: фильтры лога на BaseInput вместо сырого q-input
Канон-инпут вместо q-input: BaseInput расширен поддержкой type="date"
и clearable (+ emit clear) — это нужно для фильтра по датам. Фильтры
в ExtensionLogsList переведены на BaseInput.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:55:44 +00:00
coopops 5257d5c6e6 [design-wave1][@dark] feat: настройки и лог расширения в канон
ExtensionSettings: EmptyState + BaseCard вокруг ZodForm вместо самописных
header/empty-state. ExtensionLogsList (используется только powerup):
q-table → список канон-карточек с фильтром по датам, скелетоном,
«Загрузить ещё» и EmptyState; слот #log-item и API сохранены.
Канон-падинги страниц настроек/лога аренды.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:52:04 +00:00
coopops 6184102022 [design-wave1][@dark] feat: Стол вычислительных ресурсов в канон — раскладка, виджеты, лог
Монитор ресурсов: баннер-предупреждение на всю ширину сверху, ряд
«Как это работает» (2/3) + кошелёк AXON (1/3), ряд CPU/NET/RAM.
Убран 3D-флип карт (он же «прыгал») — детали раскрываются inline.
Все виджеты/страницы/лог сведены к канон-токенам surface/line/ink.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:44:33 +00:00
coopops ba1e17c991 [design-wave1][@dark] fix: отступы действий платежей и ширина даты в документах
- Реестр платежей: кнопки действий больше не block (не заполняют ячейку
  от левого края, не липнут к бейджу статуса). Стопка inline-flex,
  равная ширина кнопок (align-items:stretch), прижата к правому краю
  колонки (col-action 168px, text-align:right) — слева остаётся воздух.
- Реестр документов: col-date 116→156px — дата «26.05.2026 08:25» больше
  не налезает на заголовок документа; min-width 720→780.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:29:48 +00:00
coopops fdd38951b5 [design-wave1][@dark] fix: реестры платежей/документов и форма взноса — ширины и кнопки
- Bank.vue: панель платежа max-width 380px по центру — длинное «Назначение»
  больше не раздувает диалог «Совершите взнос» до max-content; значение
  переносится по строкам (overflow-wrap: anywhere).
- Кнопки статуса платежа SetOrderPaid/RefundedStatusButton → BaseButton
  (primary/danger, block), подписи «Подтвердить»/«Отклонить»; диалог
  вынесен из-под кнопки.
- Реестр платежей: кнопки действий друг под другом (.cell-actions),
  колонка действий 156px, дата без переноса (.col-date 132px), min-width
  таблицы уменьшен.
- Реестр документов: сужены колонки подписей (220→150), действий
  (110→56), id/дата; min-width 880→720 — освобождено место под
  наименование, таблица не вылазит за страницу.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:24:31 +00:00
coopops fca4e7fc9c [design-wave1][@dark] fix: реестры документов и платежей — канон-поля страницы
Таблицы документов и платежей уже на канон-токенах и самообрамлены
(.table-wrap). Не хватало полей страницы как в реестре пайщиков:
- PaymentsPage рендерил виджет голым, впритык к краям → q-page + 24/16px.
- ListOfDocumentsPage: q-page.padding (16px) → канон-24px.
- Убрана лишняя .row.justify-center обёртка в ListOfDocumentsWidget
  (.col-12 и так во всю ширину).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:04:26 +00:00
coopops fa21066161 [design-wave1][@dark] fix: диалоги «Добавить» и «Импорт» пайщиков в канон
AddUserDialog перестроен в канон-визард по образцу CreateMeetForm:
шапка-bar (surface+line) вместо bg-gradient-dark, VerticalStepper из трёх
шагов — Электронная почта → Тип и данные → Вступительный взнос, BaseButton
в подвале, outlined-инпуты. UserDataForm переиспользован целиком (общий,
не тронут). Опция начисления взноса — канон-блок вместо q-item/q-card.

ParticipantsImportDialog: добавлено описание-интро (раньше отсутствовало),
ModalBase/bg-gradient-dark заменён на канон-bar, выбор типа аккаунтов через
BaseRadioCard, dropzone на токенах вместо хардкод-цветов, секции и действия
в канон-обёртках, BaseButton вместо цветных q-btn. Логика парсинга/импорта
и таблицы превью/результатов сохранены.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:53:06 +00:00
coopops 4b1bedd2e8 [design-wave1][@dark] fix: реестр пайщиков — канон-поля и дыхание вместо встык-вёрстки
Страница получила канон-отступ (--p-6 / --p-4 на мобилке), таблица обёрнута
в обрамлённую surface-карточку (--p-line, --p-r-lg) вместо edge-to-edge.
Развёрнутые данные пайщика — спокойная вложенная панель на --p-surface-2
с отступами --p-5/--p-6, читаемой шириной формы (640px) и вертикальным
ритмом между полями. Снят full-height у таблицы (мешал внутри карточки).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:37:15 +00:00
coopops be7fc22928 [design-wave1][@dark] refactor: реестр пайщиков в канон + убран переключатель Данные/Документы
Развёрнутая строка пайщика теперь показывает только его данные;
документы пайщика живут в отдельном «Реестре документов» и здесь не дублируются.
ParticipantDetails упрощён (без q-tabs/ListOfDocumentsWidget), tab-логика
вычищена из таблицы и страницы. Мобильная ParticipantCard переведена на канон-токены
(убраны хардкод-цвета, CardStyles и q-badge статуса → disabled-checkbox).
Таблица сохранена, сортировка по дате вступления (DESC) без изменений.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 08:01:45 +00:00
coopops 0a229df206 [design-wave1][@dark] fix: повестка — номер вопроса на зелёной плашке вместо иконки, клик копирует
Убран EntityIdBadge. Зелёная плашка-аватар слева теперь несёт сам номер вопроса
(он же Decision ID): клик по плашке копирует id (тултип «Скопировать №»), hover —
инверсия в сплошной teal. Заголовок снова просто текст вопроса.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 07:14:43 +00:00
coopops c67fb4703d [design-wave1][@dark] fix: повестка — бейдж ID в строку заголовка перед текстом вопроса
EntityIdBadge перенесён из отдельной строки под ФИО в начало строки заголовка:
[#id] → сразу текст вопроса. Inline, vertical-align middle. Раньше висел снизу
и пусто растягивал верхнюю строку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 07:00:08 +00:00
coopops d213b1639d [design-wave1][@dark] feat: повестка — копируемый бейдж Decision ID под ФИО
EntityIdBadge с id вопроса под заголовком/ФИО: отображает #<id>, по клику копирует
чистый id (copy-on-click). Канон-компонент, клик не раскрывает карточку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 06:39:47 +00:00
coopops fc8a060d71 [design-wave1][@dark] fix: повестка — срок «Истекает …» в нижнюю полоску рядом с «Утвердить»
«Истекает через месяц» перенесён из строки под заголовком в постоянную нижнюю
полоску (footer): срок слева, «Утвердить» справа (только председателю). У обычного
пайщика — только срок, узкая панелька.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 06:35:02 +00:00
coopops e8466143a6 [design-wave1][@dark] fix: повестка — «Истекает …» и убран избыточный бейдж статуса
Срок теперь подписан «Истекает через месяц» (было просто «через месяц» — неясно).
Удалён статус-чип «Вы за/против» — направление голоса уже видно по подсветке кнопок,
бейдж был избыточным. Убраны связанные computeds и стили.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 06:31:13 +00:00
coopops 009cc42afd [design-wave1][@dark] fix: повестка — переосмысленная компоновка карточки вопроса
Кнопки голосования прижаты к правому краю строки. «Утвердить» вынесена вниз
отдельной строкой-footer (справа, hairline сверху) — больше не теснит данные.
Срок «через месяц» и статус-чип «Вы за» перенесены влево под заголовок/ФИО.
Клик по строке раскрывает документ, по кнопкам голосования и footer — нет.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 06:24:50 +00:00
coopops cfe0835eea [design-wave1][@dark] fix: повестка — органы управления в одну строку с инфо, не остров по центру
Карточка вопроса перестроена в единую горизонтальную строку: иконка + вопрос/ФИО
слева (flex), компактный блок органов управления (голосование + «Утвердить»),
срок + статус-чип и шеврон — справа, друг рядом с другом. Убрана центрированная
vote-зона с пустотой по бокам. Клик по строке раскрывает документ, по органам
управления (@click.stop) — нет. Подсказка «Утвердить» возвращена в tooltip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 06:18:00 +00:00
coopops a86711c111 [design-wave1][@dark] fix: повестка — раскрытие документа по клику на шапку, не на органы управления
Шапка карточки (вопрос/ФИО/срок/статус) кликабельна целиком и раскрывает документ,
справа шеврон-индикатор. Органы управления голосованием — отдельная зона ниже шапки
(сиблинг, не аккордеон): клик по ним голосует/утверждает и документ не раскрывает.
Убрана отдельная кнопка «Документ» — раскрытие теперь по клику на шапку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 05:34:55 +00:00
coopops 9332bc63c8 [design-wave1][@dark] fix: голосование повестки на верхнем уровне + header-CTA совета
QuestionCard: VotingButtons + «Утвердить» вынесены на верхний уровень карточки —
голосовать можно без раскрытия; раскрытие («Документ») открывает только содержимое
документа. Истечение срока остаётся в шапке.
VotingButtons: вернул чек-индикатор «принято советом» (закрашивается при принятии)
вместо иконки verified.
Header-CTA совета (Предложить/Добавить/Импорт): убран push (q-btn--push не попадал
под канон-правило заливки primary → белый шрифт на нетиловом фоне), подписи с заглавной.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 04:39:25 +00:00
coopops 8217cd5299 [design-wave1][@dark] feat: повестка совета в канон — единый карточный список
QuestionsTable: q-table → карточный список (скелетоны + EmptyState).
QuestionCard: канон-поверхность, BaseButton «Утвердить», токен-чип статуса
вместо q-badge, локальное состояние раскрытия, без @import CardStyles и хардкод-hex.
VotingButtons: токены pos/neg вместо .text-red/.text-green/#666.
Страница: канон-padding вместо q-card flat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 04:26:00 +00:00
coopops 44a0f7b132 [design-wave1][@dark] fix: единый регистр названий столов в переключателе
Названия столов в реестре workspace'ов заведены вразнобой («Стол
благороста», «Стол вычислительных ресурсов» vs «Стол Совета»).
Добавил text-transform: capitalize на подписи в WorkspaceSwitcher
(заголовок текущего стола + пункты выпадающего меню) и WorkspaceMenu
(карусель + диалог выбора) — первая буква каждого слова заглавная,
одинаково для всех и для будущих столов, без правки строк-источников.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:58:18 +00:00
coopops d120fff065 [design-wave1][@dark] fix: «Контакты» — единая сетка полей вместо сегментов
По фидбеку переработана композиция:
- убраны заголовки секций «Контакты»/«Руководство» и per-строчные
  иконки телефона/почты/адреса (из-за них значения «прыгали» вправо
  относительно ИНН/ОГРН);
- ИНН и ОГРН теперь в одну строку (адаптивная сетка полей);
- председатель поднят наверх к реквизитам, поле названо просто
  «Председатель совета»;
- контакты — те же поля, телефон/email как ссылки (tel/mailto), всё на
  единой левой кромке; одна тонкая линия делит реквизиты и контакты.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:45:45 +00:00
coopops 6c38298545 [design-wave1][@dark] fix: «Контакты» — единая раскладка строк во всех секциях
Реквизиты/Руководство больше не разносят подпись и значение к
противоположным краям (на полной ширине это давало пустой провал и
оторванные значения). Все секции теперь как ContactSheet: подпись
сверху, значение под ней, слева, hairline между строками — один ритм
по всей карточке.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:40:12 +00:00
coopops a8f9bf3c36 [design-wave1][@dark] feat: страница «Контакты» в канон + удалён мёртвый MeetQuorumIndicator
- ContactsPage: три цветные ColorCard (orange/indigo/teal/blue) заменены
  на единую спокойную canon-поверхность с секциями через hairline
  (Реквизиты / Контакты / Руководство). Контакты — через canon
  ContactSheet (копирование, mailto/tel). Заголовок и строки на токенах
  (--p-ink/-2/-3, --p-surface/--p-line/--p-r-lg), полная ширина (padding
  24/16px). Убраны rgba-фоны и .q-dark-хаки.
- Удалён неиспользуемый виджет MeetQuorumIndicator (нет импортов;
  явка/кворум живут в MeetInfoCard).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:36:09 +00:00
coopops c099373d00 [design-wave1][@dark] feat: тело деталей собрания в канон — повестка/голосование/результаты
Убрал токсичный teal из state-gated виджетов деталей собрания:
- MeetDetailsAgenda/Voting/Results переписаны на canon-поверхности
  (--p-surface/--p-line/--p-r-lg), section-heading с иконкой вместо
  центрированной градиентной линии; color-mix/--q-primary/.q-dark удалены.
- Голосование: q-btn color=primary → BaseButton variant=primary;
  опции голоса на токенах pos/neg/neutral-soft, баннер «уже голосовали»
  на --p-pos-soft.
- Результаты: итог решения — токен-чип (pos/neg/unknown), счётчики
  голосов на pos/neg/surface-2-soft.
- AgendaNumberAvatar: teal q-avatar → нейтральный квадрат surface-2/ink
  (общий для трёх виджетов — убирает teal сразу везде).
- MeetCardsList: отступ между карточками 16→20px.
- MeetDetailsPage: кастомный empty-state → canon EmptyState, снят
  @import CardStyles.

MeetQuorumIndicator оставлен как есть — мёртвый код (нет использований).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:28:50 +00:00
coopops 4d52302389 [E14][@dark-sun] feat(собрания): полная ширина страниц; тело деталей (MeetInfoCard) на canon
- ListOfMeetsPage / MeetDetailsPage: убран max-width:960px center — контент
  во всю ширину с canon-паддингом (24px/16px), как документы/платежи.
- MeetInfoCard: переписан с токсичного teal на canon — единая calm-поверхность
  с тремя секциями через hairline (Даты / Ведущие / Явка и кворум) вместо
  трёх вложенных цветных карточек; заголовки ink, проценты ink (не teal);
  дубль заголовка «Общее собрание № N» убран (он в шапке). Убраны
  var(--q-primary), color-mix, .body--dark, хардкод-rgba.
- MeetDetailsInfo: контейнер с .card-container → canon.
- MeetDetailsActions: q-btn color=primary → canon BaseButton.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:00:40 +00:00
coopops 093a31a9b5 [E14][@dark-sun] feat(собрания): повестка на шаге проверки канон; карточка списка канон; шапка деталей — back-link вниз, имя в заголовок
- CreateMeetForm: повестка на шаге «Проверка» — аккуратные canon-карточки
  (Вопрос / Проект решения / Приложения) вместо скомканного списка.
- MeetCompactCard: переписана на canon — нейтральный hover (без
  токсичного teal-свечения), ink-заголовок, canon-плитки/иконка;
  убраны @extend .card-container, var(--q-primary), .body--dark, color-mix.
- MeetStatusBanner: нейтральный canon-контейнер (surface-2 + line),
  цвет статуса несёт иконка; убраны хардкод-rgba и .body--dark.
- MeetCardsList: empty-state на canon EmptyState, skeleton на .skel.
- Детали собрания: кнопка «Назад» убрана из топбара (снят useBackButton),
  добавлен canon back-link под шапкой слева; название собрания выводится
  в заголовок шапки через новый desktopStore.pageTitleOverride
  (приоритет над route.meta.title; транзиентно, чистится при уходе).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:46:01 +00:00
coopops f560e7d2d0 [E14][@dark-sun] feat(собрания): форма созыва на canon-степпер; кнопка в шапке к канону; скрыта «Сверить»
- BaseDocument: кнопка «Сверить» временно скрыта через :hide-verify
  (обработчик @verify остаётся, вернуть = убрать флаг).
- CreateMeetButton: приведена к одобренному canon-паттерну (primary
  solid на десктопе, micro flat accent + tooltip на мобильном),
  убран legacy push/teal с белым текстом.
- ListOfMeetsPage: инжект кнопки переведён с useHeaderActions на
  Teleport #header-actions-host.
- CreateMeetForm: полностью переписана с перегруженной тёмной модалки
  на canon-визард — maximized-диалог + VerticalStepper (3 шага:
  Параметры → Повестка → Проверка). Canon outlined-поля, повестка
  аккуратными карточками, шаг проверки со сводкой. Убраны
  bg-gradient-dark / standout bg-teal / .body--dark / хардкод rgba.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:04:51 +00:00
coopops 32f1a89bc9 [E14][@dark-sun] feat(стол пайщика): скелетоны вместо спиннера в таблицах документов и платежей
Добавлен canon-компонент TableSkeleton (shared/ui/base) — повторяет
структуру .table-wrap/.table с реальными заголовками и мерцающими
плейсхолдерами (.skel) в ячейках. Каркас не дёргается при подгрузке
данных (calm-data, UX-DR2/UX-DR23). Заменил перекрывающий q-spinner
в DocumentsTable и ListOfPaymentsWidget.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:49:27 +00:00
coopops 24601722f7 [E14][@dark-sun] feat(стол пайщика): документы — ID копируемым бейджем, отдельные колонки Дата/Документ, подписи бейджами; поиск минимальный на мобильном
- DocumentsTable: наименование берём из meta.title (чистое, без даты/.pdf
  суффикса full_title); отдельная колонка Дата (meta.created_at) с сортировкой
  по block_num, по умолчанию свежие сверху; колонка Документ — только заголовок.
- ID — копируемый EntityIdBadge (показывает короткий хеш, копирует полный
  doc_hash по клику + иконка-affordance).
- Подписи — отдельные BaseBadge на подписанта (новый helper
  getSignersListFromDocumentPackage возвращает массив).
- EntityIdBadge канонизирован: токены вместо rgba/--q-accent/.q-dark; добавлен
  copyValue (показать одно — скопировать другое).
- SearchHeaderAction: на мобильном — минимальная round-dense иконка без подписи
  «Поиск» (label только на десктопе).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:18:33 +00:00
coopops fe0d23bd92 [E14][@dark-sun] feat(стол пайщика): документы и платежи — канон-таблица .table-wrap вместо q-table
Переделка после ревью: q-table давал не-канон вид, дёрганье при раскрытии
(virtual-scroll пересчитывал размеры + colspan не совпадал с числом колонок)
и уродливые мобильные карточки в grid-режиме.

- Статическая канон-таблица .table-wrap/.table (бордер+радиус+surface как
  карточка), table-layout:fixed → колонки не разъезжаются при раскрытии.
- Раскрытие строки — CSS-only (expand-row td colspan по числу колонок),
  без virtual-scroll → нет дёрганья шапки/колонок.
- Пагинация load-more (.table-foot + BaseButton «Загрузить ещё» + «1–N из M»)
  вместо infinite virtual-scroll.
- Мобильный: горизонтальный скролл таблицы (.table-scroll) вместо grid-карточек
  PaymentCard/DocumentCard — карточки удалены.
- Статусы платежей — BaseBadge (pos/warn/neg/info/neutral); направление —
  иконка + цвет --p-pos/--p-neg; хеш документа — mono.
- Действия платежей (SetOrderPaid/Refunded) скрыты на столе пайщика
  (hideActions=true) — они в реестре платежей; download документов сохранён.
- EmptyState + спиннер первой загрузки.

Виджеты общие с админ-контуром: load-more и горизонтальный скролл там тоже
уместнее jittery virtual-scroll.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:59:47 +00:00
coopops a23efb69a1 [E14][@dark-sun] feat(стол пайщика): документы и платежи в канон — статус-бейджи, токены, header-action на Teleport
- ListOfPaymentsWidget + PaymentCard: статус платежа через BaseBadge
  (canon-варианты pos/warn/neg/info/neutral) вместо хардкод q-color
  teal/orange/red/blue; убраны битый inline-стиль $toolbar-min-height,
  inline max-width, глобальный .q-list style-leak; цвета/тени/радиусы —
  на токены (--p-line/--p-ink-3/--p-shadow-card/--p-surface-2).
- DocumentsTable + DocumentCard: убраны битый inline-height, inline
  max-width, .q-dark-селекторы, --q-gray, хардкод rgba; тени/линии — токены;
  глобальный q-table__top style → scoped :deep; CardStyles import убран.
- ListOfDocumentsWidget: brand-leak toggle-color teal → primary.
- DocumentsPage: SearchHeaderAction со старого useHeaderActions store на
  canon Teleport (#header-actions-host), кнопка сама гейтится по features.search.
- DocumentsPage/PaymentsPage: канон-паддинг страницы.

q-table-движок (virtual-scroll/infinite-load/раскрытие строк/mobile-grid)
сохранён — виджеты общие с админ/председательскими контурами, регресс недопустим.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:31:56 +00:00
coopops 21be815e3b [E14][@dark-sun] fix(шапка): правая группа прилипала влево на страницах без действий — вернул margin-left:auto
Когда страница не телепортирует действия, .topbar__actions скрывается
display:none через :has(пустой host), но adjacent-селектор
.topbar__actions + .topbar__right всё равно матчился и обнулял margin-left
правой группы — она уезжала влево к крошке. Возвращаем auto в :has-правиле
(его специфичность выше). Также убрал лид-надпись на странице реквизитов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:24:00 +00:00
coopops 29dab94a9a [E14][@dark-sun] feat(стол пайщика): удостоверение и реквизиты в канон — IdentityPanel/DataRow/BaseCard, header-action на Teleport
- ProfilePage (Удостоверение): IdentityPanel-шапка + BaseCard-секции (Учётная
  запись/Личные данные/Документы и реквизиты) на DataRow; убран CardStyles,
  хардкод rgba и .q-dark.
- PaymentMethods widget (Реквизиты): BaseCard на метод + DataRow + EmptyState
  вместо .info-label/.info-value и хардкод-цветов.
- PaymentMethodsPage: кнопка добавления реквизитов переведена с useHeaderActions
  store на canon Teleport (#header-actions-host), micro на мобильном.
- AddPaymentButton: триггер под canon micro-паттерн (как Deposit/WithdrawButton).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:13:32 +00:00
Alex Ant f20989e319 chore(release): publish
Build bootstrap container / build (push) Successful in 3m10s
Release / release (push) Successful in 25m29s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 1s
Release / publish-packages (push) Successful in 14m27s
2026-05-25 16:55:38 +05:00
Alex Ant 2d3cd34e82 chore(release): publish
Build bootstrap container / build (push) Successful in 3m25s
Release / release (push) Successful in 25m24s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-05-25 16:55:18 +05:00
claude 46cb511e43 fix: remove stray node_modules symlink committed into branch
Симлинк node_modules -> /home/admin/mono-ai-2/node_modules был случайно
закоммичен и ломал pnpm install (ENOTDIR) на любой машине без этого пути.
Правило .gitignore 'node_modules/' (со слешем) ловит только каталог, не
симлинк-файл — добавлено правило 'node_modules' без слеша.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:22:31 +05:00
Alex Ant 1461ec466e Merge branch 'dev' of ssh://git.coopenomics.world:222/C9S/mono into dev 2026-05-25 15:52:33 +05:00
coopops 61d53e7ea0 Merge remote-tracking branch 'origin/main' into dev
Build bootstrap container / build (push) Failing after 2m13s
2026-05-25 10:46:47 +00:00
coopops 25a917866c ci(release): вернуть webhook второй документации + переименовать токен в DOCS_DISPATCH_TOKEN
При удалении publish-docs я снёс не только мёртвый gh-pages-push, но и реальную
публикацию ВТОРОЙ документации (доки mono) через DOCS_DEPLOY_WEBHOOK_URL —
она отдельная от coopenomics и должна уезжать синхронно. Возвращаю её отдельным
lean-джобом trigger-mono-docs (только webhook, без gh-pages и mkdocs-сборки —
приёмник деплоит сам).

Токен GITEA_DISPATCH_TOKEN → DOCS_DISPATCH_TOKEN: Gitea резервирует префикс
GITEA_ для имён секретов, такой секрет не создать.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:40:30 +00:00
coopops c73e9b9e80 ci(release): дёргать coopenomics на ветке master (дефолтная), не main
Дефолтная ветка C9S/coopenomics — master; workflow_dispatch надо слать на
ref=master, иначе Gitea вернёт 404 по ветке.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:28:24 +00:00
coopops d7ad0d7251 ci(release): убрать publish-docs (gh-pages), триггер coopenomics через Gitea workflow_dispatch
После переезда на Gitea два downstream-джоба были наследием GitHub:
- publish-docs пушил site/ в gh-pages github.com под secrets.GITHUB_TOKEN
  (на Gitea — гитейный токен, невалиден) → Authentication failed. Pages
  устарел, доки теперь деплоит C9S/coopenomics. Джоб удалён целиком.
- trigger-coopenomics-docs слал repository_dispatch на github.com через
  peter-evans (COOPENOMICS_PAT пуст). Репо переехало в C9S/coopenomics на
  Gitea, а Gitea не имеет API для repository_dispatch — только
  workflow_dispatch. Заменено на curl к Gitea API workflow_dispatch с
  secret GITEA_DISPATCH_TOKEN; целевой workflow получит входы mono_sha/mono_ref.

publish-packages не трогаю — там нужен лишь NPM_TOKEN в секретах (E404 на
scoped-пакетах = отсутствует npm-авторизация).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:25:13 +00:00
ant 3f5859eb3e Merge pull request 'feat(chatcoop): комнаты секретаря + синхронизация непроектных комнат в blago' (#34) from feat/chatcoop-secretary-rooms into dev
Reviewed-on: #34
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-05-25 09:53:32 +00:00
coopops 46e3686550 [E14][@dark-sun] feat(шапка): крошка-название страницы слева + фикс переноса заголовка ModalBase
Крошка: в #crumb рядом с BackButton выводится route.meta.title текущей
страницы (тот же, что подсвечен в меню) — именно для неё действия и
сдвинуты вправо. Длинное название обрезается ellipsis в .topbar__crumb b.

ModalBase: q-bar получил авто-высоту, заголовок переносится (white-space
normal + overflow-wrap), крестик прижат к верху — на узком экране титул
больше не обрезается сверху.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 09:49:19 +00:00
Alex Ant a625fc79e1 Merge branch 'dev' of ssh://git.coopenomics.world:222/C9S/mono into dev 2026-05-25 14:42:03 +05:00
coopops fb545b4122 [E14][@dark-sun] fix(WalletPage): micro-кнопки взноса/возврата в шапке на мобильном
Why: полноразмерные DepositButton/WithdrawButton в узкой мобильной шапке
раздувались — текст переносился в 2 строки, кнопки вылезали за высоту
topbar. micro-вариант (иконка + tooltip, flat/dense) и предназначен
для слота шапки.

What: Teleport-кнопки получают :micro='isMobile' (useWindowSize, <768px).
Мобильный — компактные иконки; десктоп — полные кнопки с подписью.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 09:41:18 +00:00
coopops e20ddb4a9c [E14][@dark-sun] fix(WithdrawButton): «получить возврат» → «Получить возврат» — заглавная
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 09:39:51 +00:00
claude 393ea148f0 chore(compose): run dev-сервисы от uid 1000, чтобы codegen не плодил root-файлы
coopback/cooparser/desktop монтируют ./:/app, а node:22-slim без USER пишет
от root — codegen (generate-client, schema.gql, logs, quasar-кэш) кладёт
root-owned файлы в дерево и ломает git checkout (git работает от ant без sudo).
Добавлен user: "1000:1000" + HOME=/tmp, чтобы вывод контейнеров принадлежал
хостовому ant. node_modules/.pnpm-store уже ant:ant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:54:31 +05:00
Alex Ant 9029fa1f9d chore(release): publish
Build bootstrap container / build (push) Successful in 3m17s
Release / release (push) Successful in 25m39s
Release / publish-docs (push) Failing after 9m10s
Release / trigger-coopenomics-docs (push) Failing after 5s
Release / publish-packages (push) Failing after 9m23s
2026-05-25 12:41:40 +05:00
coopops e640b6add6 chore(controller): синхронизировать controller/zeus с регенерацией
Typecheck / desktop (pull_request) Successful in 13m19s
Typecheck / controller (pull_request) Successful in 12m49s
generate-client пишет копию zeus и в components/controller/zeus — обновляю вместе
со schema.gql, чтобы не оставлять дрифт сгенерированных артефактов.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 06:29:06 +00:00
coopops b2c56651f9 chore(controller,sdk): регенерация schema.gql + zeus под комнаты секретаря
Typecheck / desktop (pull_request) Successful in 13m29s
Typecheck / controller (pull_request) Successful in 13m0s
generate-schema + generate-client после добавления резолверов SecretaryRooms и
chatcoopListNonProjectCommunicationRooms. Без этого @coopenomics/sdk:build падал
в CI на отсутствующих в zeus типах (ChatcoopSecretaryRoom / CreateSecretaryRoomInput
/ RemoveSecretaryRoomInput / ChatcoopNonProjectCommunicationRoom) — общий build-шаг
обеих джоб typecheck. Локально sdk build (prebuild tsc --noEmit) зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 06:28:39 +00:00
coopops 5c6062420f feat(desktop/chatcoop): страница «Комнаты секретаря» на столе связи
Typecheck / desktop (pull_request) Failing after 7m26s
Typecheck / controller (pull_request) Failing after 6m28s
- entity SecretaryRoom (api/model/store) поверх SDK chatcoopListSecretaryRooms /
  chatcoopCreateSecretaryRoom / chatcoopRemoveSecretaryRoom
- SecretaryRoomsPage: список комнат реестра (тип, наличие секретаря, шифрование),
  создание комнаты (публичная/приватная) и удаление комнат секретаря
- маршрут chatcoop-secretary-rooms, доступ роли chairman+member
- системные/проектные комнаты — read-only (удаление только у kind secretary)

Затипизируется после generate-schema/generate-client (новые SDK-операции).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 05:56:57 +00:00
coopops d1ce3924d5 feat(sdk,blago-cli): SDK-операции комнат секретаря + pull непроектных транскрипций
SDK (рукописные обёртки над Zeus — затипизируются после generate-schema/client):
- queries: ListNonProjectCommunicationRooms, ListSecretaryRooms
- mutations: CreateSecretaryRoom, RemoveSecretaryRoom
- селекторы ChatcoopNonProjectCommunicationRoom / ChatcoopSecretaryRoom

blago-cli:
- pullNonProjectCommunicationArtifacts: переписка + транскрипции + memo комнат
  пайщиков/совета/секретаря в отдельную верхнюю папку rooms/<folder>/{messages,meetings}/
- курсор transcriptionLastEndedExclusiveByRoom (по matrixRoomId)
- системные комнаты — стабильные папки komnata-paishchikov / komnata-soveta;
  комнаты секретаря — slug + хвост хеша matrixRoomId

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 05:49:06 +00:00
coopops d7c2c90290 feat(chatcoop): комнаты секретаря + синхронизация непроектных комнат в blago
Задача 1 (синхронизация в blago):
- roomKind 'secretary' в реестре управляемых Matrix-комнат
- репозиторий: findAll + findNonProjectCommunicationRooms (всё кроме capital_project)
- inter-порт listNonProjectCommunicationRooms + тип InterNonProjectCommunicationRoomRef (kind)
- query chatcoopListNonProjectCommunicationRooms (members/council/secretary) для blago-cli

Задача 2 (комнаты секретаря, backend):
- SecretaryRoomManagementService: создание комнаты (public/private, всегда plaintext,
  force-join только секретаря, создатель — модератор) и удаление (kick секретаря + дерегистрация)
- SecretaryRoomsResolver: chatcoopListSecretaryRooms / chatcoopCreateSecretaryRoom /
  chatcoopRemoveSecretaryRoom; доступ chairman+member
- matrix-api: inviteUser / kickUser
- конфиг SECRETARY_ROOM_MATRIX

Принцип: секретарь присутствует только в backend-созданных комнатах; force-join в
произвольную чужую комнату запрещён (общий Synapse на все кооперативы).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 05:46:00 +00:00
coopops 5eb03c74c4 ci(release): retry на docker push (3 попытки) — гасит транзиентный DNS
registry-1.docker.io на runner'е изредка не резолвится (DNS-таймаут к
127.0.0.53), из-за чего push валит весь релиз уже после успешной сборки
образов. Обёртка dpush() повторяет push до 3 раз с паузой 10с во всех трёх
push-шагах (контракты, base, сервисы).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:18:33 +00:00
Alex Ant 64bb8a0be8 chore(release): publish
Build bootstrap container / build (push) Successful in 3m20s
Release / release (push) Successful in 25m42s
Release / publish-packages (push) Has been skipped
Release / publish-docs (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
2026-05-25 08:55:05 +05:00
Alex Ant a7c8f8cd17 Merge branch 'dev' of ssh://git.coopenomics.world:222/C9S/mono into dev 2026-05-25 08:54:48 +05:00
coopops da00407a7c ci(release): доставить рантайм-либы CDT (libz3-4 и др.) при установке из .deb
clang-9 падал "error while loading shared libraries: libz3.so.4". Пакет cdt
объявляет только libcurl4-gnutls-dev, но бинари тулчейна (objdump -p NEEDED)
требуют libz3.so.4/libtinfo.so.6/libxml2.so.2/libz.so.1. В образе
dicoop/blockchain они были из сборки исходников, из .deb не тянутся —
ставим libz3-4 libtinfo6 libxml2 zlib1g явно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:54:39 +00:00
Alex Ant cfb9398ec6 chore(release): publish
Build bootstrap container / build (push) Successful in 3m25s
Release / release (push) Failing after 2m29s
Release / publish-packages (push) Has been skipped
Release / publish-docs (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
2026-05-25 08:44:36 +05:00
coopops 966dcc45f2 ci(release): собирать контракты из cdt .deb прямо в job, без вложенного docker
Шаг компиляции контрактов в release.yaml падал под Gitea act_runner:
build-all.sh монтирует $(pwd):/project в sibling-контейнер, но job сам
исполняется в контейнере → хостовый демон не видит путь, /project пуст,
cmake падает "no CMakeLists.txt". Релиз не собирался зелёным (runs #144, #158).

- build_contracts_cdt.sh: чистый cmake/make без docker (общий источник флагов).
- build-all.sh: тонкая docker-обёртка над ним для локальной сборки.
- release.yaml: вместо pull образа + build-all.sh — установка cdt v4.2.0 .deb
  в окружение job'а + симлинк /cdt/build → /usr/opt/cdt/4.2.0 (сводит хардкод
  toolchain-путь CMakeLists без его правки) + вызов build_contracts_cdt.sh.
  Компиляция в самом job-контейнере, без вложенного docker. sudo-агностично —
  одинаково на GitHub-VM и Gitea-контейнере.
- Убран избыточный typecheck-гейт из release; typecheck.yaml остаётся PR-гейтом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:42:34 +00:00
Alex Ant 25f10e6782 Merge branch 'dev' of ssh://git.coopenomics.world:222/C9S/mono into dev
Build bootstrap container / build (push) Successful in 3m10s
2026-05-24 23:29:58 +05:00
Alex Ant 3d52d816a4 chore(release): publish
Build bootstrap container / build (push) Successful in 3m22s
Release / typecheck (push) Successful in 23m58s
Release / release (push) Failing after 2m27s
Release / publish-packages (push) Has been skipped
Release / publish-docs (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
2026-05-24 23:29:42 +05:00
claude 665c22fc87 fix(boot): унифицировать extra_reboot.sh и clean_reboot.sh с reboot.sh
Build bootstrap container / build (push) Successful in 3m14s
Все три reboot-скрипта теперь идентичны, кроме строки pnpm run boot[:extra|:clean]:
- wipe blockchain-data контейнером (docker run alpine rm) вместо sudo rm —
  работает без sudo на любой ноде независимо от владельца данных (на Pi нет
  passwordless sudo; на проде nodeos пишет данные под root). Единый способ с reboot.sh.
- monoredis в down -v и up -d — без него coopback падает на старте
  (getaddrinfo EAI_AGAIN monoredis → nodemon crash), и провайдер не получает
  org-данные partner1.
- coopback через up -d --force-recreate (перечитывает env), как в reboot.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:26:40 +05:00
claude b897627d72 fix(boot): partner1 провижининг под EXTRA_RENT, enrollment в ЦПП Кошелька, идемпотентная activateFeature
Build bootstrap container / build (push) Successful in 3m10s
- booter.ts: installExtraData (регистрация partner1 с auto-approve → триггер аренды
  провайдером) выполняется только под EXTRA_RENT=1. boot:extra используется и для
  других задач (пересев совета/чейна), где аренда VM не нужна.
- infra.ts: partner1 подписывает wallet-соглашение (wallet::signagree, program_id=1)
  при регистрации. Без членства в ЦПП Кошелька provider.performInitialTransfers
  (150 AXON на partner1 перед ACTIVE) падает ассертом eosio.token::is_can_transfer
  «Получатель не является участником целевой потребительской программы кошелька»,
  и инстанс навсегда висит в INSTALL.
- blockchain/index.ts: activateFeature не роняет boot при protocol_feature_exception
  (фича уже активна на не-обнулённом чейне) — ловим и продолжаем.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:13:39 +05:00
ant 72cec26c14 Merge pull request 'Убрать механику blocked из L3-кошельков (резерв возврата → отдельный кошелёк w.wal.wpend)' (#31) from feat/remove-l3-blocked into dev
Build bootstrap container / build (push) Successful in 3m21s
Reviewed-on: #31
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-05-24 17:32:03 +00:00
coopops 7a163389c6 chore(controller,sdk): полная регенерация schema.gql и zeus (generate-schema + generate-client)
Typecheck / desktop (pull_request) Successful in 13m28s
Typecheck / controller (pull_request) Successful in 13m9s
Штатный синк вместо точечной правки: schema.gql приведён в соответствие с
актуальными DTO контроллера (был протухший — накопленный дрейф полей,
не только blocked). zeus перегенерирован из новой схемы; оба
controller/zeus и sdk/src/zeus идентичны.

Результирующие изменения относительно L3-blocked-ветки:
- удалён blocked из Ledger2Wallet/ProgramWallet (цель PR);
- синхронизированы ранее не закоммиченные дрейфы: is_server_init в SystemInit,
  enum-значение AWAITING_AUTHORIZATION, актуализация описаний/ролей @Field.
- blocked сохранён в ChartOfAccountsItem (legacy chart-of-accounts).

Проверено локально: sdk typecheck и controller typecheck зелёные.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:11:32 +00:00
coopops 710e0cda8c [E14][@dark-sun] feat(стол пайщика): кошельки списком во всю ширину + действия шапки через Teleport
Кошельки (по фидбэку — переносы выглядят плохо):
- .wallet-programs из grid (repeat auto-fill minmax) → flex-column: карточки
  идут списком во всю ширину страницы.
- canon .wallet__title/__sub: возвращён nowrap + ellipsis (откат wrap-фикса);
  на широкой строке текст почти всегда влезает, иначе — ellipsis.
- WalletCard + минимум-карточка: нативный tooltip `title` — при наведении
  виден полный текст. Убран .wallet--row reset (базовый снова nowrap).

Действия шапки (кнопки взноса/возврата пропали; нужна новая механика):
- Новый canon-механизм: страница телепортирует свои действия в шапку через
  <Teleport to="#header-actions-host">. Host — постоянный span с
  display:contents в #actions слоте CommonHeader (при loggedIn).
- :has()-правило прячет .topbar__actions, когда внутри только пустой host
  (нет ни store-кнопок, ни телепорта) — чтобы не было пустого разделителя.
- WalletPage переведён на Teleport (DepositButton/WithdrawButton),
  useHeaderActions store-механизм убран со страницы.
- Старый useHeaderActions оставлен для прочих 10 страниц — мигрируем
  и удалим отдельно. DepositButton сам скрыт, пока пайщик не принят
  (status !== 'active') — это защита, не баг.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:45:39 +00:00
coopops 5c494db262 fix(sdk): синхронизировать schema.gql и zeus с удалением blocked из L3-кошельков
Typecheck / desktop (pull_request) Successful in 13m29s
Typecheck / controller (pull_request) Successful in 13m8s
CI Typecheck падал на @coopenomics/sdk:build (tsc): селекторы
programWalletSelector/ledger2WalletSelector и зависимые capital-селекторы
больше не содержат blocked, но zeus-типы (MakeAllFieldsRequired) всё ещё
требовали его как обязательное поле.

- schema.gql: убран `blocked: String!` из типов Ledger2Wallet и ProgramWallet
  (точечно, без полной регенерации — она тянет несвязанный дрейф схемы).
- zeus (controller/zeus + sdk/src/zeus): убран blocked из Ledger2Wallet и
  ProgramWallet во всех 4 секциях (ValueTypes/ResolverInputTypes/ModelTypes/
  GraphQLTypes). blocked в ChartOfAccountsItem (legacy chart-of-accounts) и
  статус-комментарии (accepted|blocked) сохранены.
- CoopCard.vue: удалён мёртвый закомментированный блок с wallet.blocked.

Проверено локально: sdk typecheck и controller typecheck зелёные.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:43:07 +00:00
coopops 0969505a71 feat(ledger2): убрать механику blocked из L3-кошельков, перевести возврат на отдельный кошелёк-резерв
Typecheck / desktop (pull_request) Failing after 6m36s
Typecheck / controller (pull_request) Failing after 6m30s
Упраздняем субсчёт "заблокировано" на кошельках. Резерв средств под заявку
на возврат паевого теперь выражается переводом на отдельный COOPERATIVE-кошелёк
w.wal.wpend, а не блокировкой внутри w.wal.share. Все движения — только переводы
и сжигание, без BLOCK/UNBLOCK.

Контракты (ledger2/wallet/capital):
- wallets.hpp: новый кошелёк WITHDRAW_PENDING (w.wal.wpend, COOPERATIVE).
- operations.hpp: из WalletOp удалены BLOCK/UNBLOCK/BURN_BLOCKED (числовые
  значения ISSUE/TRANSFER/BURN/NONE сохранены для совместимости истории).
  Переключены 3 операции возврата: o.wal.wthreq -> TRANSFER share->wpend;
  o.wal.wthdec -> TRANSFER wpend->share; o.wal.wthcpl -> BURN wpend.
- walletop.cpp/revert.cpp: удалены кейсы и валидации блокировки.
- migrate(): свёртка blocked->available по ВСЕМ коопам (выполняется автоматически
  при деплое; сигнатура без аргументов). Поле blocked в структурах таблиц
  оставлено deprecated — физическое удаление = смена layout таблицы на живых
  коопах, отдельный cleanup-деплой.
- capital balances/importcontr: больше не читают blocked.
- p.wal.wthdrw.standard.yaml: стандарт приведён к модели "резерв на кошельке".

cooptypes/SDK:
- operations.ts: WalletOp без BLOCK/UNBLOCK/BURN_BLOCKED; 3 операции возврата
  переключены; wallets.generated.ts регенерён (добавлен w.wal.wpend).
- selectors: убран blocked из programWalletSelector/ledger2WalletSelector.

Backend (controller): убрано поле blocked из GraphQL DTO ProgramWallet и
Ledger2Wallet. TypeORM-колонка/внутренние интерфейсы оставлены deprecated
(без DB-миграции).

Frontend (desktop): убраны все поля "Заблокировано" — WalletProgramWidget,
WalletWidget, ParticipantWalletsPage, CoopWalletsPage, CapitalWalletsCardsWidget,
ContributorsListWidget, optimistic blocked_delta.

Границы (не в этом PR): legacy soviet::progwallets blockbal/unblockbal и
donor-marketplace; legacy ledger v1 chart-of-accounts "Заблокированные средства";
полное удаление поля blocked из C++-структур таблиц.

Требует CI-шага: regen GraphQL schema + zeus client (generate-schema/-client)
для согласования schema.gql и zeus-типов с удалённым полем blocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:07:11 +00:00
coopops 36694f648d [E11][@dark-sun] fix(CommonHeader): бренд-логотип inline-SVG в зелёном квадрате, как в личном кабинете
Why: на странице без логина логотип был <img :src='logo.svg'> — img рендерит
SVG в изоляции, не наследует currentColor, поэтому показывался чёрно-белым
и не реагировал на смену темы. В личном кабинете (WorkspaceSwitcher)
тот же logo.svg рендерится inline через v-html в зелёном квадрате
и наследует color (logo.svg на fill:currentColor) — «зелёненький
на зелёном фоне», одинаковый в обеих темах.

What: CommonHeader brand-slot переведён на тот же приём —
`logo.svg?raw` + v-html внутри .app-q-header__logo
(background var(--p-primary-soft), color var(--p-primary), 28px квадрат,
16px svg). Переключение темы больше не требуется — зелёный константен.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 06:39:59 +00:00
coopops f20764adbd Merge branch 'dev' into worktree-design-wave1
Подтянуть hotfix controller TypeORM, SWC dev-builder и mongo standalone.
2026-05-23 05:33:20 +00:00
coopops 712080ff8f Merge branch 'dev' into worktree-design-wave1
Подтянуть свежие изменения dev в дизайн-ветку перед продолжением миграции стола пайщика.
2026-05-23 05:09:17 +00:00
coopops 76513b8526 [E14][@dark-sun] fix(canon .wallet): просто wrap по пробелам, без overflow-wrap: anywhere
Why: предыдущий фикс `overflow-wrap: anywhere; word-break: break-word`
ломал слова в любом месте даже когда колонка достаточно широкая —
«Минимальный неснижаемый остаток» рендерился по одному слову на строку,
несмотря на ~480px доступной ширины. Реально нужен только wrap по пробелам;
agressive break-word оправдан только для URL-подобных нерасчленяемых строк.

What: убраны `overflow-wrap` и `word-break` из .wallet__title/__sub —
браузер делает естественный wrap по пробелам, длинные заголовки переносятся
только когда не помещаются. `.wallet--row` reset для compact-варианта
оставлен — там по-прежнему single-line+ellipsis.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 04:50:01 +00:00
coopops 0872c4c093 [E14][@dark-sun] fix(canon .wallet): разрешить перенос длинных заголовков на full-варианте + порядок карточек в WalletProgramWidget
Why: canon-стиль .wallet__title и .wallet__sub был с
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;` — на full-
варианте это резало длинные подписи («Минимальны…», «Возвращается п…»)
даже на просторных экранах, потому что grid-колонка `.wallet__main` сжимается
ради `.wallet__amount` справа. Эта обрезка будет всплывать на любой
длинной строке (метки программ, статусы пайщика, длинные subtitle).
Compact-вариант `.wallet--row` (слот шапки) должен остаться одной строкой.

What:
- .wallet__title/__sub: убран nowrap/ellipsis; добавлено
  `overflow-wrap: anywhere; word-break: break-word; hyphens: auto` (для title)
  и `overflow-wrap: anywhere; word-break: break-word` (для sub) — длинные
  заголовки переносятся на 2+ строки.
- .wallet--row: явно возвращает nowrap+ellipsis (с reset word-break/
  overflow-wrap), чтобы compact-ряды в шапке оставались строго одной строкой.
- WalletProgramWidget: карточка минимального неснижаемого остатка
  перемещена в начало grid'а (была после программ) — это базовая защита
  средств пайщика, логичнее видеть её первой.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 04:45:30 +00:00
coopops c94cb3759a [E14][@dark-sun] refactor(WalletCard,WalletProgramWidget): «Главный кошелёк» канон-default + минимальный остаток отдельной карточкой
Why:
- Заголовок «Кошелёк» для program='wallet' путал — в столе пайщика этот
  кошелёк семантически главный, и в любых других местах canon (MicroWallet,
  WalletCardMini, _dev/ui) он тоже должен называться полно — «Главный
  кошелёк». Лучше один canon-default, чем локальный override в каждом
  потребителе. Согласовано — переименование canon DEFAULT_TITLES.
- Минимальный неснижаемый остаток — НЕ баланс кошелька, а самостоятельная
  сущность пайщика (паевой взнос, возвращается при выходе). Пристегивать
  его DataRow-строкой под grid'ом — нелогично, как было и раньше в legacy.
  Правильнее — отдельной карточкой в той же сетке кошельков.

What:
- WalletCard.vue: DEFAULT_TITLES.wallet 'Кошелёк' → 'Главный кошелёк'.
  Глобально для всех потребителей canon-компонента.
- WalletProgramWidget.vue: убран TITLE_OVERRIDE и DataRow-блок минимального
  остатка. Карточка остатка теперь рендерится в общем .wallet-programs
  grid'е canon-разметкой `.wallet` с иконкой `savings`, заголовком
  «Минимальный неснижаемый остаток», подзаголовком «Возвращается при выходе
  из кооператива» и зарезервированной суммой. Нейтральная подсветка иконки
  через `.wallet--minimum { --prog-bg, --prog-fg }`, чтобы визуально
  отличалась от программ, но встала в общую сетку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 04:39:27 +00:00
coopops 40cbd3fef9 [E14][@dark-sun] feat(WalletProgramWidget): «Главный кошелёк» + минимальный неснижаемый остаток
Why: на старом WalletWidget пайщик видел свой минимальный неснижаемый остаток
(паевой взнос, возвращается при выходе из кооператива) — самостоятельная
сущность пайщика, не баланс кошелька. При переходе на canon я её упустил.
Канон-default WalletCard 'wallet' = «Кошелёк» — для стола пайщика этот
кошелёк семантически является главным (свободный остаток ЦК), поэтому
локально перекрываем заголовок на «Главный кошелёк».

What:
- TITLE_OVERRIDE['wallet'] = 'Главный кошелёк' — локальное перекрытие
  заголовка только в этом widget'е, canon DEFAULT_TITLES не трогаем
  (другие потребители WalletCard могут использовать общий «Кошелёк»).
- DataRow «Минимальный неснижаемый остаток» под grid'ом программ
  (только когда session.participantAccount.minimum_amount > 0)
  с hint «Возвращается пайщику при выходе из кооператива».
- Источник остатка — session.participantAccount?.minimum_amount,
  как и в legacy WalletWidget.

Note: locked-line уже рендерится самим canon WalletCard, когда
locked-balance > 0 (logic: hasBlocked ? blocked.amount : undefined
в WalletProgramWidget). Отдельная разметка под «Заблокировано» не нужна.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 04:30:00 +00:00
coopops cdb12efb62 [E14][@dark-sun] fix(WalletProgramWidget): vue-tsc — flatMap вместо filter+predicate
Why: type predicate `e is CanonProgramEntry` не сходился — optional `locked?: string`
в interface vs required `locked: string | undefined` в литерале map'а. TS считает
эти типы несовместимыми для predicate, хотя они эквивалентны при присваивании.

What: переход на `flatMap<CanonProgramEntry>` с возвратом `[]` для исключаемых
программ. Predicate не нужен, generic flatMap даёт точный тип результата.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 04:20:59 +00:00
coopops 7bad49522b [E14][@dark-sun] refactor(participant/wallet): WalletPage + WalletProgramWidget в канон MONO Platform v2
Why: первая страница стола пайщика (default route 'wallet') использовала legacy
обвязку — CardStyles import, scoped SCSS с .body--dark и hardcoded rgba цветами,
ColorCard с произвольным цветом по индексу. Канон уже знает программы платформы
(blagorost/wallet/generator) через WalletCard + токены --prog-*; на нём и строим.

What:
- WalletPage.vue: убран import 'src/shared/ui/CardStyles', scoped SCSS на
  токенах --p-6/--p-4; вырезана легаси-карточка «Минимальный остаток»
  (она не отображалась — не было разметки в template). useHeaderActions
  для Deposit/Withdraw оставлен — канон поддерживает actions через #actions slot.
- WalletProgramWidget.vue: переписан на canon WalletCard + EmptyState.
  Фильтр на канон-набор программ через ZEUS_TO_CANON
  (MAIN→wallet, BLAGOROST→blagorost, GENERATOR→generator);
  MARKETPLACE и прочие исключены — это не из основной тройки платформы
  и должны рендериться отдельным виджетом.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 04:14:41 +00:00
coopops 463801beed [E11][@dark-sun] feat(AppHeader): brand slot — лого + название кооператива на странице без логина
Why: на главную без логина нужно показывать бренд кооператива (логотип + имя)
рядом с глобальными действиями шапки; раньше выводилось только текстовое
название через title-проп без логотипа.

What:
- AppHeader.vue: опциональный slot #brand перед .topbar__crumb;
  hasBrand computed по slots.brand. Когда slot заполнен — крошка не рендерится.
- components.css: стили .topbar__brand (desktop + .topbar--mobile вариант)
  с canon-токенами (--p-fs-body, --p-ink, --p-fs-meta).
- CommonHeader.vue: на !loggedIn заполняет #brand src/assets/logo.svg
  + <b>{coopTitle}</b>; передача title-пропа убрана.
- default.vue: .fixed-top-right { top: 51px } → top: var(--p-topbar-h)
  (canon 56px) — выравнивание FAB под точную высоту шапки.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 04:14:22 +00:00
coopops f02df5a8d0 [E11][@dark-sun] refactor(CommandPalette): иерархия рабочих столов и страниц вместо generic pages/actions/recent
Существующая модель cmdk в проекте (entities/CmdkMenu/model/store.ts) —
иерархия рабочих столов и их страниц. Активный стол sticky сверху с
бейджем «Активный», без запроса показывается иерархия (стол + indented
страницы), с запросом — плоский список со столом-префиксом у каждой
страницы; стол отдельной строкой появляется только если запрос явно
начинается с его имени или содержит «стол»/«workspace».

Переписал canon CommandPalette под эту модель:
- Props: `workspaces: CommandPaletteWorkspace[]` вместо
  `commands: CommandItem[]`. Каждый workspace = `{ name, title, icon,
  isActive?, pages: CommandPalettePage[] }`. Page = `{ name, title,
  icon?, shortcut? }`.
- Emits: `select-workspace(name)`, `select-page(workspaceName, pageName)`
  — props-only, навигация и filtering по ролям/conditions остаются
  заботой connected-обёртки (миграция legacy CmdkMenu — отдельная story).
- Sticky-баннер активного стола, plus accent-soft фон + outline-обводка
  на selected, ↑↓ работает плоско поверх иерархии (стол → страницы → стол
  → страницы).

Mock-data в /_dev/ui/index.vue обновлён: три стола (Председатель/Пайщик/
Отчётность) со своими страницами вместо плоского списка команд.

Старый widgets/Desktop/CmdkMenu пока живёт параллельно — переключим в
ходе миграции Wave 3.
2026-05-22 18:41:46 +00:00
coopops e14f0b6a1d [E11][@dark-sun] feat: NotificationCenter, CommandPalette, DetailsDrawer — эпик «Навигация и нотификации»
Реализованы три props-only доменных компонента из E11:
- NotificationCenter — panel-content для popover в шапке: группировка
  notifications по category (system/financial/voting/message), unread-bullet
  через BaseBadge, кнопка «Прочитать все», EmptyState и «Показать все».
  Relative-date форматирование с русским склонением.
- CommandPalette — ⌘K/Ctrl+K с fuzzy-поиском, секции recent/pages/actions,
  ↑↓ навигация, Enter/Esc обработка. localStorage недавних — в connected
  обёртке, компонент props-only.
- DetailsDrawer — side-sheet справа 480px (override через :width), slots
  default/actions/footer, Esc и backdrop close, на xs — fullscreen.

Все три зарегистрированы в boot/ui.ts и локально импортированы в /_dev/ui
с mock-data в секциях 36-38.

E11.4 RailUserCard отложен из-за конфликта имён — существующий компонент
имеет другую роль; нужно согласовать канон-нейминг.
2026-05-22 18:24:55 +00:00
coopops bed7ba0a13 [E10][@dark-sun] fix(BaseDocument): центрировать spinner-лоадер по обеим осям
Заменил `.full-width.text-center` + flex без выравнивания на flex-column
со `min-height: 360px` — лоадер больше не прижат к левому верху карточки.
Заодно поправил опечатку «подговка» → «Формируем документ».
2026-05-22 18:13:07 +00:00
coopops 2de5f81997 [E10][@dark-sun] fix(AmountInput): нативный :suffix='symbol' вместо #append slot
Корень: я положил символ валюты в #append slot своим <span>, в обход
встроенного prop suffix. Quasar имеет правила позиционирования именно
для родного .q-field__suffix (см. revert d0a8dc0080 от 2026-05-19,
где было решено оставить Quasar дефолт — «нас устраивает»). Мой span
в append-slot не подчинялся этим правилам и сидел в произвольной
позиции относительно цифр.

Фикс:
- :suffix='symbol' — символ валюты идёт нативным механизмом Quasar.
- Удалил .amount-input__symbol класс и template #append вообще.
- Удалил override align-items: center на q-field__control/__append/
  __after (тоже мешал, как было показано в d0a8dc0080).
- font-weight 500 на цифрах оставил — нормальный вес поля.

Совет на будущее: использовать встроенные q-input props (suffix,
prefix), а не #append/#prepend slots, если можно — Quasar для них
держит готовое выравнивание, проверенное пользователем.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:04:21 +00:00
coopops 3c6fbba4ea [E10][@dark-sun] fix(AmountInput): выровнять цифры и RUB-суффикс по центру
Корень: font-weight: 600 + tabular-nums + Quasar dense нативный input
имеют чуть смещённую baseline относительно append-слота, где сидит
RUB. Визуально цифры лежали ниже суффикса.

Фикс:
- font-weight 600 → 500 (нормальный вес поля ввода, без bold-акцента
  на цифрах).
- Явный font-size + line-height на нативном input.
- align-items: center на q-field__control / __append / __after, чтобы
  суффикс и any after-слот (кнопка «макс») центрировались по высоте
  входной полосы.
- align-self: center на самом __symbol — на случай если q-field__append
  кто-то переопределит как stretch.

TODO на следующий подход: BaseDocument loader («Формируем документ…»)
выравнивать по центру рамки документа (сейчас стоит сверху).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:47:59 +00:00
coopops a67180a153 [E10][@dark-sun] fix(OtpInput): убрать TypeScript-касты as из pug-шаблона
[Quasar] boot error: SyntaxError: Unexpected identifier 'as' — runtime-парсер
обрабатывает выражения в pug-template как чистый JS, без TypeScript. Касты
вида `el as HTMLInputElement | null`, `e as InputEvent`, `e as KeyboardEvent`
прямо в атрибутах `:ref` / `@input` / `@keydown` — синтаксическая ошибка во
время бутстрапа Vue, из-за которой /_dev/ui целиком не грузился.

Фикс:
- :ref='(el) => setRef(idx, el)' + функция setRef(idx, el: Element |
  ComponentPublicInstance | null) с кастом в TS-скрипте.
- @input='(e) => onInput(idx, e)' + сигнатура onInput(idx, event: Event)
  с внутренним кастом target.
- @keydown оставил передавать event «как есть» — KeyboardEvent — никакой
  cast не нужен.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:38:34 +00:00
coopops 3ed84f5ffc [E10][@dark-sun] fix(_dev/ui): локальный импорт E10-компонентов — boot/ui.ts не HMR'ится
Quasar boot-файлы подхватываются только при рестарте dev-сервера. После
коммита 1af0f9c06f глобальные регистрации AmountInput/OtpInput/FilterBar/
FileUploader/VerticalStepper не подтянулись на лету — теги рендерились как
unknown components (пусто внутри секций 31–35 на /_dev/ui).

Фикс: добавил локальные импорты прямо в script setup _dev/ui/index.vue —
тот же паттерн, что у WalletCard, RailUserCard, AuthCard. Глобальная
регистрация в boot/ui.ts остаётся для боевого использования из других
страниц (после следующего рестарта dev'а).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:07:30 +00:00
coopops 1af0f9c06f [E10][@dark-sun] feat: AmountInput, OtpInput, FilterBar, FileUploader, VerticalStepper — эпик «Формы и ввод»
Пять props-only доменных компонентов из shared/ui/domain/:
- AmountInput — денежный ввод с символом валюты, форматированием тысячных,
  precision из marketplace asset config, кнопкой «макс» по balance, опциональной
  подписью баланса. Tabular-nums, right-align, font-weight 600.
- OtpInput — 6 ячеек с автопереходом фокуса, Backspace откатывает на
  предыдущую, paste 6-значного кода распределяется по ячейкам.
  Регулярка /^\d$/, состояния error/disabled.
- FilterBar — search (debounce 300мс) + dropdown-фильтры + chip'ы активных
  значений с remove + «сбросить всё». v-model для values, v-model:search для
  поиска. Активные chip'ы рендерятся под рядом фильтров.
- FileUploader — drag&drop + клик по зоне; валидация accept/maxSize/maxFiles
  → emit error; список загруженных с иконкой типа, именем, размером,
  кнопкой ×; слот progress для connected-обёртки.
- VerticalStepper — состояния pending/current/completed/error; completed
  кликабельны для возврата назад (опционально); опциональные/disabled шаги;
  слот active под телом текущего шага.

Все компоненты — pug, canon-токены --p-*, без store/router/api. Демо-секции
31–35 на /_dev/ui (наш стенд-витрина).

Зарегистрированы в boot/ui.ts и shared/ui/domain/index.ts. ESLint точечно
прошёл, vue-tsc не запускался полностью на desktop (запрет).

Wave 2 / E10 закрыт.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 08:29:49 +00:00
coopops daae6170c7 fix(DocumentPreview): убрать txt/image — наши документы это HTML/PDF
Удалены типы 'txt' и 'image' из DocumentPreviewType + соответствующие
ветки рендера (pre.document-preview__txt и img.document-preview__image)
и стили. В реальных потоках платформы документ кооператива всегда
HTML (рендерится в ShadowHtml внутри BaseDocument) или PDF — plain-
text фрагмент в моноширинном pre не используется и выглядит как
техническая ерунда. Аналогично image.

_dev: удалён previewTxtDemo (мусорный EOSIO chain id из debugging
notes → перешитый в фрагмент протокола → теперь полностью убран
по требованию пользователя). Секция 30 показывает только HTML +
loading + error состояния.
2026-05-22 06:22:56 +00:00
coopops 46bae0a5de fix(BaseDocument,_dev): vue-tsc, мусорный txt-demo, ширина документа
vue-tsc:
- _dev: убрал txId/explorerUrl из signatureSignedDemo — оба поля
  удалены из Signature ранее (общего эксплорера нет).
- BaseDocument: canonSignatures.map — нормализую is_valid через
  '?? undefined' (бэкенд может вернуть null, canon-компонент ждёт
  boolean | undefined).

DocumentPreview txt demo: вместо мусорного 'EOSIO chain id …
dirty window' (мой случайный кусок из debugging notes) — фрагмент
протокола собрания пайщиков ПК «Восход».

ComplexDocument: .col-md-7 → .col-md-10. Это контейнер, в котором
лежит BaseDocument в реальных страницах документов. 7/12 = 58%
ширины было визуально 'приплюснуто'.
2026-05-22 06:01:55 +00:00
coopops a6c315ba32 feat(ui): DocumentSignatures + перевод BaseDocument на canon
Реальный формат подписей кооперативного документа — это не «pending/
signed/rejected» из абстрактного SignatureCard, а IDocumentAggregate с
полями doc_hash + signatures[] (signer_certificate, public_key,
signature, is_valid). Каждая подпись разворачивается в детали.

Что сделано:
- Новый canon-компонент DocumentSignatures (story 9.5) в shared/ui/
  domain — props-only, принимает уже резолвнутые signerName и hash-
  совпадение, эмитит download/verify. Поверх Quasar — собственный
  expand на ref<Set<number>>, чтобы стиль был полностью canon.
- BaseDocument теперь рендерит DocumentSignatures вместо своего
  q-card.verify-card + q-list + q-expansion-item на teal/red badges.
  Адаптер canonSignatures маппит signer_certificate → ФИО через
  getNameFromCertificate, чтобы canon-компонент не знал про сертификаты.
- SignatureCard: удалены поля txId/explorerUrl и ссылка «Открыть в
  explorer» — общего эксплорера в платформе нет.

Demo: секция 29 → DocumentSignatures (валидный + с битой подписью),
секция 30 → DocumentPreview (HTML заголовок сокращён, чтобы не
обрывался при узкой колонке).
2026-05-22 05:52:19 +00:00
coopops ccfaf3592e feat(ui): волна 2 / E9 — документы и подписи
4 props-only canon-компонента в src/shared/ui/domain/, регистрация
в boot/ui.ts, demo-секции 26-29 в _dev/ui:

- DocumentRow (story 9.1) — строка документа в списке: иконка типа
  с tint'ом (pdf neg-soft, docx info-soft, html primary-soft), title,
  status через BaseBadge, дата/автор/описание, slot actions, emit open.
- SignatureCard (story 9.3) — подписавший (Avatar+ФИО+AccountBadge),
  статус (BaseChip pending/signed/rejected), для signed — хеш в моно-
  блоке + ссылка на explorer, для rejected — BaseBanner с причиной.
- ActivityTimeline (story 9.4) — вертикальный таймлайн событий с
  цветными иконками по типу (sign/reject/create/update/comment/
  transfer), groupByDate группирует по «Сегодня/Вчера/конкретная
  дата».
- DocumentPreview (story 9.2) — html (через DOMPurify), pdf через
  iframe, image через img, txt через pre. Стейты loading/error.

Все props-only, темо-зависимые значения только через --p-* токены.
2026-05-21 17:02:51 +00:00
coopops de245ee810 fix(_dev/ui): «Член кооператива» → «Пайщик» в PersonCard demo
Канон-терминология blago/ЦК: пайщик, не член. Прецедент закреплён
в auto-memory feedback_kooperatsiya_pai.
2026-05-21 16:57:50 +00:00
coopops e7dd0ce641 fix(IdentityPanel): compact тоже в карточке — padding+border+bg
Раньше compact-вариант рендерился без обрамления — avatar упирался
в левый край контейнера, визуально «проваливался» из общего стека
full-карточек (Screenshot_2026-05-21_21-51-09).

Compact теперь: padding 8/12px (тоньше чем full 16px), тот же border
+ surface + radius. В одну строку avatar + ФИО + AccountBadge + status,
но визуально это всё-таки карточка, не голая строка.
2026-05-21 16:52:41 +00:00
coopops e1113391cf fix(ContactSheet): label и значение в отдельных строках
Body раньше был обычным block-контейнером, а .head и .value-row —
оба display: inline-flex. Без явного block-форматирования inline-flex
элементы становятся inline и рендерились в одну строку. Визуально:
Emailivanov@example.ru, Телефон+7 (903)..., Telegram@ivanov — слипшиеся.

Body теперь display: flex + flex-direction: column, gap canon — 8px
в comfortable, 4px в compact. Head/value-row переведены на обычный
display: flex (вместо inline-flex), gap внутри сохранён.

Прецедент 2026-05-21 — Screenshot_2026-05-21_21-42-16, 21-42-31.
2026-05-21 16:44:40 +00:00
coopops 25b6d6c42b chore: убрать закоммиченный symlink node_modules 2026-05-21 16:25:22 +00:00
coopops 05a8ac0a51 fix(domain): E8 — copy-кнопки, отступы и compact-layout
- AccountBadge: copy-кнопка не сжимается (flex-shrink: 0), размер 20×20 + icon 14px — раньше 18×18 + 12px тонула рядом с текстом badge.
- DataRow: column-gap var(--p-3, 12px) → var(--p-5, 20px) + min-label-width 140 → 160px + padding-right на label. Между label и value был визуально слипшийся стык.
- ContactSheet: comfortable margin-top между label и value 2px → 8px (compact 4px). Раньше label «налипал» на значение.
- IdentityPanel compact: переделан в flat flex-row (avatar + ФИО + AccountBadge + status badge) — раньше grid с .body загонял имя и AccountBadge в две строки и avatar «уезжал» от имени. Spec 8.1 требует «только avatar + ФИО + EntityIdBadge в одну строку».
2026-05-21 16:24:45 +00:00
coopops 8344808676 feat(ui): волна 2 / E8 — идентификация и контакты
5 props-only canon-компонентов в src/shared/ui/domain/, регистрация
в boot/ui.ts, demo-секции 21-25 в _dev/ui:

- AccountBadge (story 8.5) — on-chain account name в mono-шрифте,
  copyable + опциональный explorer link. Назван AccountBadge вместо
  EntityIdBadge: имя EntityIdBadge занято под numeric ID пайщика
  в capital. Decision зафиксирован в epics.md и planning-artifacts.
- DataRow (story 8.3) — `label: value` пара для реестровых карточек,
  mono-режим, copyable, slot value-override, hint.
- ContactSheet (story 8.2) — email/phone/address/tg/web с автоиконками,
  кликабельными mailto:/tel:/t.me ссылками, copy и verified-чек.
- IdentityPanel (story 8.1) — Avatar + ФИО + AccountBadge + статус
  (active/blocked/pending) + role + actions-slot; compact/full.
- PersonCard (story 8.4) — Avatar + ФИО + role + AccountBadge +
  ContactSheet + slot meta; compact/comfortable.

Все props-only: useStore/useRouter/useApi внутри запрещены. Темо-зависимые
значения только через --p-* токены, никаких body--dark селекторов.
2026-05-21 15:57:21 +00:00
coopops a192e2c398 fix(BaseCheckbox): приглушить заливку галочки через --p-primary-press
q-checkbox по умолчанию красит .q-checkbox__bg в --q-primary, который в
dark теме равен #2dd4bf — на маленьком квадрате 18px этот яркий бирюзовый
смотрится ядовито. Переопределяем заливку filled-состояния на
--p-primary-press (#134e4a light / #14b8a6 dark) — глубже и спокойнее,
чек-символ остаётся белым.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:37:24 +00:00
coopops d99413f896 fix(tokens): приглушить --p-accent + унифицировать .agreement-link
Старое значение --p-accent (#D84315 light / #FF7043 dark) — слишком
насыщенное, выглядит ядовито рядом с тёплой палитрой ink/canvas. Меняем
на тёплую терракоту: #B85C38 (light) / #E89472 (dark) — тот же warm-tone,
но без перевозбуждения красного канала.

.agreement-link (псевдо-ссылки на просмотр документа в согласиях) была
hardcoded в #1c64f2 (синий) light / #ff9f43 (жёлто-оранжевый) dark и не
совпадала со ссылкой «Устав кооператива». Унифицируем: все ссылки внутри
согласий теперь var(--p-accent), визиt-цвет тот же — без фиолета.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:34:28 +00:00
coopops 77ae6bb47d fix(ReadStatement): чекбоксы согласий — выравнивание + gap + accent ссылка
BaseCheckbox block-вариант: убран собственный display:flex + align-items:
flex-start, который перебивал внутренний flex Quasar и сдвигал label вверх
относительно inner. Теперь Quasar сам центрирует inner на первой строке.
Добавлен padding-left: 8px на .q-checkbox__label — больше воздуха между
галочкой и текстом согласия.

ReadStatement: ссылка «Устав кооператива» — --p-primary → --p-accent
(тёплый оранжевый #D84315 / #FF7043 dark) как акцентный цвет палитры.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:26:19 +00:00
coopops 6016c0597b feat(UserDataForm): canon-стиль полей + тип аккаунта на BaseRadioCard
UserDataForm/UserDataForm.vue:
- Выбор типа аккаунта: три q-btn(glossy, height:75px) → три BaseRadioCard
  с title/description; больше нет teal-«кнопок-плиток»
- Возврат к выбору типа: flat q-btn → BaseButton(ghost) с arrow_back

Sub-forms (Individual / Entrepreneur / Organization + Create*):
- q-input/q-select: standout="bg-teal text-white" → outlined color='primary'
  (canon-tone, единый primary-акцент вместо устаревшего teal)
- .q-gutter-sm.q-mt-md → .user-data-stack (flex column, gap токены)
- OrganizationDataForm: кнопка «совпадает» — color='teal' → flat primary

Валидация q-form.validate() и :rules сохранены — это всё ещё q-input
с rules-массивом, только tone и контейнер переехали на canon-токены.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 19:16:31 +00:00
coopops eeecab96da feat(ui): BaseCheckbox + BaseRadioCard и миграция SignUp-шагов
- BaseCheckbox: обёртка q-checkbox с canon-стилями, block-вариант для длинных
  согласий (чекбокс прижат к верху, label многострочный)
- BaseRadioCard: карточка-опция с радио-индикатором справа, props title /
  description / meta + slot fallback'и; используется для выбора программы

Миграции SignUp:
- ReadStatement: q-checkbox → BaseCheckbox(block); согласия в .agreements-стек
- SelectProgram: q-list+q-radio → список BaseRadioCard
- GenerateAccount: q-input → BaseInput(readonly, mono) с copy в #append;
  q-checkbox → BaseCheckbox; автоселект через querySelector('input').select()
  вместо ref.select() (BaseInput не пробрасывает внутренний API q-input)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 19:11:56 +00:00
coopops 127cf36ca9 fix(desktop): .ws-switcher__title padding-top 2px
Воздух над заголовком стола внутри WorkspaceSwitcher — заголовок не упирается
в верхнюю границу caption «ПК «ВОСХОД»».

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 19:03:05 +00:00
coopops f158afe485 fix(desktop): .rail__top padding 16px 8px + убрать disabled-стиль у WorkspaceSwitcher
- .rail__top: padding 18px 20px → 16px 8px (унификация боковых отступов)
- WorkspaceSwitcher: убраны :disabled-атрибут и hover:not(:disabled) — у пайщика, которому доступен только один стол, кнопка выглядит как обычно (без not-allowed-cursor, без приглушённого цвета). Меню q-menu просто не открывается через v-if='workspaces.length > 1'. Тихое игнорирование клика вместо явного запрета.
2026-05-20 18:55:36 +00:00
coopops 521575ab6a fix(desktop): WorkspaceSwitcher — фильтр столов по ролям + 3-строчный title
WorkspaceSwitcher показывал ВСЕ workspaceMenus, включая столы выше роли пользователя (например, «Стол председателя» видел рядовой пайщик), что приводило к ошибке access-denied при попытке переключения.

- Добавлена фильтрация по иерархии: chairman ⊇ member ⊇ user. Председатель видит столы любых ролей; член совета — user+member; пайщик — только user. Workspace без meta.roles (или с пустым массивом) — публичный для всех (та же логика, что в legacy WorkspaceMenu, плюс корректное наследование прав).
- Title допускает перенос на 3 строки (line-clamp 3) — «Стол вычислительных ресурсов» теперь умещается полностью
- Расширена кнопка switcher'а (margin сторон 8→4 в LeftDrawerMenu), даёт больше места под текст
2026-05-20 18:41:42 +00:00
coopops c2eefbf244 fix(desktop,signup): WorkspaceSwitcher polish + Bank toggle QR↔реквизиты
WorkspaceSwitcher:
- Brand-строка теперь собирается из vars.short_abbr + «vars.name» (получается «ПК «ВОСХОД»»). Раньше показывал только short_abbr — название кооператива было «потеряно»
- Title допускает перенос на 2 строки (line-clamp 2) — длинные названия типа «Стол вычислительных ресурсов» больше не обрезаются ellipsis'ом с одного слова
- Chevron приклеен к верху строки (align-self start) — корректно при двухстрочном title
- Меню workspace: canon padding (8/12) вокруг q-item, отступ между avatar-section и текстом — иконка и название больше не упираются в края
- Затемнение фона при открытом меню (rgba(9,9,11,0.32) overlay через Teleport в body) — меню больше не сливается с контентом за drawer'ом

Bank.vue (карточка платежа):
- Убрали q-expansion-item (раскрытие вниз внутри карточки выглядело перегружено)
- Кнопка-toggle: «Показать реквизиты» рядом с «Скачать QR». Клик переключает зону под сводкой между QR и списком 9 BaseInput'ов с copy-кнопками
- В режиме реквизитов: кнопки меняются на «Скопировать всё» (primary) + «Показать QR» (secondary)
- QR-canvas использует v-show чтобы сохранять состояние при toggle назад; реквизиты — v-if чтобы не держать BaseInput'ы в DOM
2026-05-20 18:22:58 +00:00
coopops 3f43a77167 feat(desktop): WorkspaceSwitcher + фикс ReadStatement стилей + плоский Bank.vue
ReadStatement: backend-HTML генерирует с inline-стилями и Quasar text-h* классами, которые перебивали :deep правила. Усилили все ключевые селекторы !important + добавили охват .text-h1/h2/h3/h4/h5/h6 и .text-right (под мета-блок «УТВЕРЖДЕНО…»).

Bank.vue (карточка платежа):
- убрали отдельные surface-карточки вокруг сводки и QR (получалось «карточка-в-карточке-в-карточке»)
- сводка → плоский <dl> со строкой-разделителем снизу (border-bottom var(--p-line))
- QR → просто центрированный canvas без обёртки/фона/рамки; белый цвет внутри canvas — функциональное требование контрастного сканирования, не дизайн-выбор
- detail-expansion → без своего фона, только border-top/bottom

WorkspaceSwitcher (новый widget src/widgets/Desktop/WorkspaceSwitcher/):
- Карточка-кнопка в шапке drawer: иконка кооп + caption «ПК «ВОСХОД»» + bold «Стол совета» (текущий) + chevron
- Клик → q-menu со списком всех workspaceMenus (Стол пайщика, Стол совета, Стол председателя и т.д.); выбор → selectWorkspace + goToDefaultPage
- Подключён через #brand slot AppDrawer'а в LeftDrawerMenu, заменяет дефолтный brand-row
2026-05-20 17:48:25 +00:00
coopops 9ea9467c2c feat(signup): canon-типографика заявления + рефакторинг карточки платежа
ReadStatement (этап 6.1):
- Backend генерирует HTML документа — локально нормализуем через :deep
- h1 уменьшен с монструозного дефолта до canon h3 (20px), центрирован
- h2/h3, p, strong, ul/ol, table, hr — типографика по var(--p-*)
- мета-блок .approved/.meta-right (УТВЕРЖДЕНО…) — ink-2, мельче, плотнее
- Чекбоксы соглашений снизу не трогали (как просил пользователь)

Bank (этап 6.2 — карточка платежа PayWithProvider):
- QR-код вынесен в фокус: сначала сводка (получатель/сумма/назначение) → QR → действия → реквизиты для ручного перевода скрыты под q-expansion-item
- Полные 9 полей (ИНН/БИК/КПП/корр-счёт/...) переведены на BaseInput(readonly, mono) с copy-кнопкой в #append; раскрываются только при необходимости
- Кнопки «Скачать QR» (primary) и «Скопировать реквизиты» (secondary) — BaseButton
- Удалены: q-btn(push), case-mixed «скопировать реквизиты», стиль #qr через global селектор
2026-05-20 17:20:15 +00:00
coopops 9a3ee9afa4 feat(invite): этап 5 — миграция Invite на canon AuthCard + BaseInput/BaseButton
- InvitePage: row/col-обёртка → .invite-page flex-center + canon padding (как у SignInPage)
- Invite widget: AuthCard со штатными title/subtitle (вместо CAPS-заголовка с background-clip градиент-текста)
- BaseInput(mono, readonly) для приватного ключа — canon-оформление key/hash полей
- BaseButton(ghost) для копирования + BaseButton(primary) для «Установить ключ»
- Удалены: градиент-текст, semi-transparent blue/green tint карточки вокруг инструкции и чекбокса, hover translateY + box-shadow glow, custom border-radius/box-shadow для q-input и кнопок, мобильные @media-блоки (canon-токены и AuthCard сами адаптивны)
2026-05-20 16:54:02 +00:00
coopops fd4bb48870 fix(signup): canon-рамка для контейнера подписи вместо неонового glow
- .signature-container: 3px primary border + 3-слойный box-shadow glow → 1px dashed по var(--p-line-2), surface-2 фон, без свечения; на hover лишь окрашивается рамка
- .signature-hint: переехала в центр контейнера, canon ink-2 цвет, убран frosted-chip с blur/shadow — теперь спокойная метка «Оставьте собственноручную подпись в рамке», pointer-events: none пропускает клики на canvas
- min-height снижен 300→220, padding на canon-токены
2026-05-20 16:52:23 +00:00
coopops d41ce5712d fix(signup): отступ сверху, canon фон q-stepper, ширина email-поля
- SignUp.vue: добавлена обёртка .signup-page (padding + center + min-height) — как у SignInPage; раньше страница была прижата к шапке
- AuthCard maxWidth: 1000 → 720 — историческое 1000 для трёх-колоночной UserDataForm; для остальных шагов выглядит абсурдно широко
- q-stepper: убраны собственный фон/тень/padding (не «карточка в карточке»), линии-разделители переведены на var(--p-line)
- EmailInput: max-width поля 360px — email не должен растягиваться во всю ширину карточки
2026-05-20 16:39:11 +00:00
coopops ed9f483e7a fix(authcard): убрать оборванный barrel-экспорт после удаления старого AuthCard
vue-tsc ругался: shared/ui/index.ts:21 — Cannot find module './AuthCard'.
- shared/ui/index.ts: убран `export * from './AuthCard'`
- LoginRedirectPage.vue: импорт переключён на canon `shared/ui/domain/AuthCard`, CAPS-заголовок заменён на canon `title` prop
- Invite.vue: импорт переключён на canon (полная миграция Invite — этап 5)
2026-05-20 16:16:11 +00:00
coopops d974d7698b feat(signup): этап 4 — миграция SignUp на canon AuthCard + BaseButton/BaseInput
- canon `shared/ui/domain/AuthCard` переписан на pug
- SignUp.vue переключён со старого «глянцевого» AuthCard на canon (title prop вместо CAPS-заголовка)
- старый `shared/ui/AuthCard` удалён (использовался только в SignUp)
- 7 подэкранов SignUp (EmailInput, SetUserData, SelectProgram, GenerateAccount, SelectBranch, ReadStatement, SignStatement): q-btn → BaseButton (ghost для «назад», primary для «Продолжить»)
- EmailInput: q-input → BaseInput, валидация rules переведена на computed :error
- q-input в GenerateAccount оставлен из-за зависимости от ref.select()
2026-05-20 16:10:50 +00:00
coopops bc5d7deda5 content(brand): og-image из poster-logo-horizontal + новый длинный дескриптор
- og-image.png — взят brand-постер ~/blago/production/shared/poster-logo-horizontal.png,
  отресайзен до 1000px по ширине и центрирован на полотне 1200×630
  с белыми краями по вертикали. Шрифт/композиция родные бренда.
- description / og:description / twitter:description: «Цифровой Кооператив —
  система управления хозяйством. Регистрация пайщиков, заказы на поставку
  и приобретение имущества, собрания совета, общие собрания пайщиков,
  взаимные расчёты на блокчейне, автоматический документооборот и бухбаланс
  на основе простой электронной подписи.»
2026-05-20 15:56:13 +00:00
coopops 44628dfde4 content(brand): обновить превью и мета — система управления хозяйством на платформе «Благо»
- og-image.png перерисован: «Цифровой Кооператив» / «Система управления
  хозяйством» / «на платформе БЛАГО — кооперативной экономики для жизни» /
  «Регистрация пайщиков · заказы на поставку и приобретение имущества ·
  расчёты на блокчейне».
- description / og:description / twitter:description выровнены под этот
  же копирайт. Слово «взаимоотношения с пайщиками» убрано как слишком
  узкое — управление хозяйством включает в себя ещё заказы, реестры
  и документы.
2026-05-20 15:44:57 +00:00
coopops 4fd79ffda9 feat(brand): системный логотип ЦК + чистка ассетов + OG-превью
- src/assets/logo.svg, public/favicon.svg, public/logo.svg — единая SVG
  логотипа кооператива (path с «ц» внутри). Скачано с
  https://цифровой-кооператив.рф из шапки сайта.
- src/assets/logo.svg: fill -> currentColor чтобы лого внутри
  canon .rail__brand наследовал --p-primary (для тёмной темы тоже).
- LeftDrawerMenu подключает лого через slot #brand-icon AppDrawer
  (импорт ?raw + v-html). Внутри зелёного квадрата шапки рейла теперь
  наш системный знак, а не дефолтный q-icon "dashboard".
- index.html: favicon → /favicon.svg, apple-touch-icon тоже на SVG.
  Удалены неработающие ссылки на /favicon.ico и /apple-touch-icon-*.png.
- index.html: добавлены description + расширенные og:* и twitter:*
  с конкретным описанием и og-image (1200×630), чтобы ссылки в чатах
  выглядели прилично.
- public/og-image.png — превью 1200×630, ImageMagick-сгенерированный
  композит из логотипа + текста.
- Удалены неиспользуемые ассеты:
  - public/icons/, public/pwa/ — старые favicon/PWA-PNG'и всех размеров;
  - src/assets/* — 40 легаси SVG/PNG (anime, blockchain, dacom*, flow*,
    header-logo, quasar-logo-vertical, system*, club*, welcome.jpeg и т.д.).
  Оставлены только src/assets/pin.svg (используется в Map.vue) и
  src/assets/logo.svg (новый).
2026-05-20 15:35:23 +00:00
coopops fca07fc3c7 fix(layout): убрать TS-каст из pug-шаблона AppDrawer
Pug-template передаёт выражения в Vue compiler как строки; конструкции
вида (entry as RailItem).route не превращаются в JS и падают
SyntaxError в браузере: «Unexpected identifier 'as'».

Касты унесены в <script>: добавлены computed flatItems и
normalizedGroups, шаблон работает с уже типизированными массивами без
inline-TS.
2026-05-20 15:20:25 +00:00
coopops dfd1ebd2a9 fix(layout): этап 3.3 — все canon-компоненты на pug + фикс импорта cmdk
- LeftDrawerMenu: импорт useCmdkStore → useCmdkMenuStore (правильное имя).
  Без этого ломался ESM build всего drawer'а: «no exported member
  useCmdkStore» — RailUserCard не монтировался, поэтому и chevron свёртки
  «не работал», и Найти/Пополнить не реагировали.
- AppDrawer.vue, AppHeader.vue, RailUserCard.vue переведены на pug
  (lang="pug"). В проекте везде pug — никакого исторического HTML в
  canon-компонентах быть не должно.
2026-05-20 15:12:38 +00:00
coopops 7d6592d1b6 fix(layout): этап 3.2 — pug + v-model:collapsed + рабочие триггеры
- q-drawer width 240 → 248px (совпадает с canon --p-rail-w, кошелёк больше
  не уплывает справа из-за обрезки на 8px)
- LeftDrawerMenu: убран весь :deep override на .rail__usercard/.rail__signout,
  возвращаем canon margin/padding как есть
- LeftDrawerMenu: добавлен v-model:collapsed на RailUserCard с сохранением
  в localStorage — chevron «свернуть/развернуть» снова работает
- LeftDrawerMenu: кнопка Найти теперь дёргает cmdkStore.openDialog() напрямую
  (фейковый KeyboardEvent не доходил до глобального обработчика)
- LeftDrawerMenu: «Пополнить» → useDepositDialog().open(); DepositButton +
  WithdrawButton рендерятся скрыто как держатели q-dialog (q-portal в body)
- Header.vue и LeftDrawerMenu.vue переписаны на pug (как остальной проект)
2026-05-20 14:40:31 +00:00
coopops e5799ba702 fix(layout): этап 3.1 — RailUserCard в footer + canvas-фон + отступы рейла
- AppDrawer: плоский items оборачивается в .rail__nav, появляется gap 4-8px до cmdk
- cmdk margin 16→8px, чтоб выровнять с rail__nav (8px от стенки)
- LeftDrawerMenu footer: WalletCardMini+LogoutButton → один canon RailUserCard
  (avatar+balance в primary-soft + Пополнить + встроенный signout)
- :deep override на .rail__usercard margin 16→12/8 и .rail__signout padding 12/20→12/12
- quasar-canon: body--light/dark + q-layout/q-page-container/q-page красятся
  через --p-canvas, чтобы фон страницы соответствовал шапке и рейлу
2026-05-20 14:19:18 +00:00
coopops 1e1a70ded2 feat(layout): этап 3 — WalletCardMini + canon LogoutButton в footer AppDrawer
LeftDrawerMenu:
- legacy `MicroWallet` (ColorCard teal с большой карточкой пайщика + ИП-badge
  + Deposit/Withdraw micro-кнопки) → canon `WalletCardMini` из
  widgets/wallet-card-mini (тонкая карточка из shared/ui/domain/WalletCard
  compact-variant, читает walletStore сама).
- Убран toggle «свернуть/развернуть нижнюю секцию» — нижняя секция теперь
  всегда видна (короткий компактный блок: WalletCardMini + Выйти).
- Удалены неиспользуемые `onMounted`/`ref` импорты, slide-анимация и CSS
  toggle-кнопки.

LogoutButton:
- pug + q-item с красной полупрозрачной плашкой → плоский ghost-button в
  стиле rail-пункта AppDrawer: прозрачный фон в покое, на hover —
  `--p-neg-soft` фон + `--p-neg` текст/иконка.
- Из uppercase «ВЫЙТИ» в title-case «Выйти» (canon — никаких uppercase).

Визуально левый drawer теперь полностью в canon-семье: единый rail с
пунктами + поиском + компактным wallet-балансом + ghost-кнопкой выхода.

Закрывает этап 3 из 5 в волне «приземление DS на реальный layout».
Дальше — SignUp (этап 4) и Invite (этап 5).
2026-05-20 14:01:08 +00:00
coopops 61bd6f2000 feat(layout): этап 2 — AppHeader в реальном layout (заменяет MainHeader)
widgets/Header/CommonHeader/Header.vue теперь использует canon AppHeader
из shared/ui/layout. Высота шапки 56px (--p-topbar-h), border-bottom
hairline, без shadow и фоновых градиентов.

Слоты:
- #crumb: BackButton (для авторизованных), либо title с названием
  кооператива из system.info.vars для гостей/install.
- #actions: headerActions injection через useHeaderActionsReader (всё что
  страницы инжектят — Deposit/Withdraw, SettingsDropdown и пр. — рендерим
  как было).
- #notifications: NotificationCenter (только loggedIn + isClient).
- #theme: ToogleDarkLight — пока legacy, сохраняет storage + PWA-цвет;
  наш canon ThemeToggle их не делает (привяжу позже отдельным эпиком).
- #profile: BaseButton primary для гостей — Регистрация/Вход (в зависимости
  от текущего route).

Удалены:
- MainHeader.vue (вся логика scroll arrows + carousel actions group ушла —
  canon `.topbar__actions { gap: 8px }` достаточно; overflow-scroll
  будет вернут отдельно если потребуется на узких экранах).
- HeaderStyles.scss (стили scroll-arrow и q-toolbar overrides больше
  не нужны — canon-разметка через `<header class="topbar">`).

q-header Quasar остаётся wrapper'ом (sticky-poзиционирование в q-layout),
внутри — AppHeader даёт canon-разметку.
2026-05-20 11:54:27 +00:00
coopops f651f05a06 feat(layout): этап 1 — AppDrawer в реальном layout (заменяет LeftDrawerMenu)
Переписан widgets/Desktop/LeftDrawerMenu/LeftDrawerMenu.vue: внутри теперь
canon `AppDrawer` из shared/ui/layout с rail-видом MONO v2 (логотип ПК +
короткое имя кооператива в шапке рейла, ⌘K-поиск, пункты-router-link'и
с иконкой/бэйджем, sticky-footer).

Адаптер store→canon:
- items: desktop.activeSecondLevelRoutes → RailItem[], с прежней
  фильтрацией по roles + meta.conditions + meta.hidden (один-к-одному
  логика из SecondLevelMenuList).
- activeKey: вычисляется из router.currentRoute с поддержкой группового
  паттерна project-* → projects-list.
- @select: router.push({name, params:{coopname}}) или
  actionsStore.executeAction(meta.action), закрытие drawer на mobile.
- @cmdk: эмиттим keyboard event ⌘K → CmdkMenu (он смонтирован глобально
  в default.vue) подхватывает.

Footer-слот: пока сохранён legacy MicroWallet + LogoutButton + toggle
свернуть/развернуть (заменим на WalletCardMini в этапе 3).

default.vue: q-drawer width 200 → 240 (canon-ширина rail'а).

Что НЕ затронуто этим коммитом: верхняя шапка (legacy MainHeader),
правый drawer для actions, q-footer для гостей. Их меняем отдельными
этапами 2–3.
2026-05-20 11:38:56 +00:00
coopops f3d0192e6b fix(ui): убрать конфликт с Quasar — .row → .u-row
Корневая причина «оторванной» галочки в ResetKeyForm: я ранее задал в
mono-platform/components.css глобальное `.row { display: flex;
align-items: center; gap: 12px; }`. Quasar внутри `q-checkbox` (а также
q-radio, q-toggle, q-btn-toggle и др.) сам добавляет class="row" на
корень компонента и использует его как inline-flex — мой `gap: 12px`
раздвигал `.q-checkbox__inner` и `.q-checkbox__label` на 12px по всему
приложению.

Решение:
- Переименовать утилиту `.row` → `.u-row` (та же семантика, тот же набор
  модификаторов --wrap, --gap-2/4/6). У Quasar теперь свободный .row.
- Обновить все 6 использований в /_dev/ui.
- Откатить предыдущий :deep(.q-checkbox__label) padding-left:4px workaround
  в ResetKeyForm — лечил симптом, причина устранена.

Затрагивает только нашу dev-витрину и canon-utility; legacy pug-виджеты
с `.row.justify-center` продолжают работать через стандартный Quasar
flex-grid класс.
2026-05-20 11:18:39 +00:00
coopops 7e429c481b fix(ui): ResetKeyForm — сжать зазор между галочкой и текстом «Я сохранил ключ»
Quasar по умолчанию даёт ~12–16px padding на .q-checkbox__label, в
compact-форме сохранения ключа это смотрелось «оторвано». Уменьшаем до 4px
через :deep override на классе .rk-form__confirm.
2026-05-20 11:05:02 +00:00
coopops 7819d9c5bb feat(dev): секция 20 — ResetKey двухэтапный flow в /_dev/ui
Три кадра в одной секции: LostKey (ввод email), ResetKeyForm check-mail
(уведомление «письмо отправлено»), ResetKeyForm save-key (демонстрация
сохранения ключа с моковым сгенерированным аккаунтом). Submit на третьем
кадре регенерирует мок-ключ для повторной проверки.

Заменяет ошибочную секцию ChangeKey из предыдущей итерации.
2026-05-20 10:44:50 +00:00
coopops c0df025e1f fix(ui): откатить ChangeKey + мигрировать настоящий ResetKey flow на base-компоненты
ChangeKey-фича (формы current/new/confirm WIF + успешный диалог) была
ошибочной интерпретацией E7. Реальный flow «сменить ключ» в продукте —
это **двухэтапный ResetKey**:

  1. LostKey: пайщик вводит email (он потерял ключ — текущего WIF нет),
     бэк присылает письмо со ссылкой `?token=...`.
  2. ResetKey: при заходе по ссылке клиент сам генерирует новый ключ
     прямо в браузере (через `useCreateUser().generateAccount()`),
     показывает приватный ключ readonly с «Скопировать», требует
     подтверждения «Я сохранил ключ» и вызывает on-chain
     `resetKey({token, public_key})`.

Текущий ключ ввести нельзя (его нет). Новый ключ ввести нельзя (только
генерируется). Никакого подтверждения нового ключа — он один и тот же.

Что сделано:
- Удалена ошибочная `features/User/ChangeKey/`.
- Старый `widgets/Registrator/ResetKey/ui/ResetKey.vue` (pug + сырые
  q-input/q-checkbox + кастомные градиентные стили) разбит на:
  * `ResetKeyForm.vue` — presentation (props-driven, режимы
    `check-mail` / `save-key`), без useRouter/useApi — пригоден для
    /_dev/ui витрины.
  * `ResetKey.vue` — connected обёртка, читает `route.query.token`,
    генерит account через `useCreateUser`, диспатчит `resetKey` через
    `useResetKey`, на успех → router.push на signin.
- Новая UI собрана на BaseForm + BaseInput readonly mono + BaseButton +
  BaseBanner + AuthCard.

/_dev/ui секция 20 теперь показывает три кадра подряд: LostKey (email
шаг), ResetKey check-mail, ResetKey save-key с моковым сгенерированным
ключом.

FR11, UX-DR9, UX-DR15.
2026-05-20 10:44:32 +00:00
coopops 5dba22d457 fix(ui): BaseInput/BaseSelect — явный проброс слотов вместо v-for in $slots
Generic-проброс `<template v-for="(_, slotName) in $slots" #[slotName]>`
рушится в Quasar 2.19 рендере: при наличии scoped-слота #append вылетает
`Cannot read properties of null (reading 'key')` в renderSlot + Vue warns
по `_isVue`/`constructor` свойствам. Заменяем на явный список слотов
(prepend / append / before / after / hint у q-input; +option, selected-item
у q-select) — синтаксис, который Quasar держит корректно.

Кейс всплыл на ChangeKeyForm: #append с q-icon (toggle visibility) ронял
страницу /_dev/ui целиком.
2026-05-20 08:47:48 +00:00
coopops 698b1d6333 feat(dev): секция 20 — ChangeKey flow в /_dev/ui (визуальная проверка E7)
Подключение ChangeKeyForm + ChangeKeySuccessDialog к dev-витрине.
Submit запускает заглушку useChangeKey().changeKey() (≈600 мс), по успеху
открывает диалог с новым WIF. Generate генерирует валидный K1 ключ через
SDK и заполняет оба новых поля. Confirmed сбрасывает форму.
2026-05-19 20:03:38 +00:00
coopops 8b3263bf70 feat(ui): E7.2 ChangeKeySuccessDialog — подтверждение через BaseDialog без backdrop-exit
Диалог успеха смены ключа: новый WIF в моноширинном блоке с copy-кнопкой,
BaseDialog с closeOnBackdrop=false, closeOnEscape=false, hideCloseButton=true.
Закрытие только через primary-кнопку «Я сохранил ключ»; до копирования
кнопка показывает guard-баннер вместо немедленного закрытия. По confirmed
эмитим событие — реальный flow подключит router.push на дашборд.

FR11, UX-DR11, UX-DR15. Story 7.2 из epics.md.
2026-05-19 20:03:26 +00:00
coopops 9578dc1b86 feat(ui): E7.1 ChangeKeyForm — смена ключа на BaseForm + BaseInput + BaseBanner
Форма смены ключа пайщика на новых компонентах дизайн-системы.
BaseBanner severity=warn с предупреждением о потере, checklist последствий,
поля current/new/confirm WIF моноширинные с toggle visibility, ghost-кнопка
«Сгенерировать» через PrivateKey.generate из @wharfkit/session.

Бизнес-логика — заглушка с TODO на привязку on-chain change-key action
(pattern см. features/User/ResetKey/api) и TODO на OtpInput шаг подтверждения
(приходит в Эпике 10).

FR11, UX-DR9, UX-DR15. Story 7.1 из epics.md.
2026-05-19 20:03:12 +00:00
coopops 45cfa29940 fix(ui): Avatar tone — единая семантика neutral/primary/accent, убрать ink
Bug после rename --p-accent → --p-primary: класс .avatar--accent смотрел
на --p-primary-soft (canon teal), а должен был — на --p-accent-soft
(warm orange по новой семантике). И вариант ink (белый bg + dark text)
визуально выпадал из палитры — крайний xl-аватар на демо-витрине казался
«не пришей к звезде рукав».

Новая семантика AvatarTone:
- neutral (default) — серый surface-3 + ink-1 (нейтральный)
- primary — canon-teal soft + canon-teal text (основной выделение)
- accent — warm-orange soft + warm-orange text (редкое выделение,
  например подсветка пайщика по batch_id)

Удалён вариант ink — не использовался в реальном коде.
В dev-витрине xl-аватар: tone="ink" → tone="primary".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:46:27 +00:00
coopops d0a8dc0080 revert(ui): убрать override центрирования suffix/prefix — оставить Quasar дефолт
Override (padding-top:0 + align-self:center на dense) не отрабатывал —
Quasar в dense держит prefix/suffix по baseline вводимого значения,
и пользователю это устраивает. Убираем неработающий override, чтобы
не мешался в дальнейших правках.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:42:30 +00:00
coopops 8d3cabab9b fix(ui): BaseDialog — отступ через margin-top второго ребёнка
Inline `style="margin:0"` на <p>-описании перебивал мой margin-bottom
через CSS (специфичность inline > selector). Переключаюсь на
`> :first-child + *` margin-top — отступ крепится ко второму ребёнку,
не зависит от inline-стилей первого.

Работает универсально: <p> + <BaseInput>, <p> + <BaseForm>, любая
другая пара «описание + контент».

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:39:29 +00:00
coopops 5ba95a2e6d feat(ui): --p-accent (warm orange) + suffix-центр + точная вёрстка gap
1. --p-accent (canon token): #D84315 (light) / #FF7043 (dark).
   Совпадает с legacy Quasar $accent (warm orange, Material Deep Orange).
   Проброшен в --q-accent через CSS var — legacy color="accent",
   var(--q-accent), text-accent продолжают работать без изменений,
   но теперь следуют за canon-темой (light/dark). Резерв под редкое
   выделение: ссылки batch_id и т.п.
2. quasar-canon.css: .q-field--dense .q-field__suffix/prefix —
   padding-top:0 + align-self:center. Quasar по умолчанию ставит
   padding-top: 24px под floating-label, отчего «RUB» визуально
   опускался к низу control'а. Теперь в одну линию со значением.
3. BaseForm: убран gap 12px из .base-form__body — reserve-hint-space
   у q-input даёт ~24px снизу под error/hint, дополнительный gap
   делал расстояние между инпутами неприятно большим.
4. BaseDialog: убран универсальный gap из body, оставлен margin-bottom
   только на первом <p>/.intro — между описанием и первым инпутом
   воздух нужен, между остальными детьми reserve-hint-space хватает.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:33:11 +00:00
coopops 6d5d614a2c fix: убрать случайный symlink node_modules из индекса
Промахнулся `git add -A` — захватил worktree-symlink node_modules
(он pnpm-shared с основным репо). .gitignore покрывает node_modules/
но симлинк-файл — отдельный путь.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:24:25 +00:00
coopops 0bac215035 refactor(ui): --p-accent → --p-primary — устранить конфликт с Quasar primary
Контекст: после проброса --q-primary = var(--p-primary) (4fddfe2343)
наш токен --p-accent и Quasar primary стали обозначать ОДИН и тот же
canon-teal — два имени для одного цвета это когнитивный шум, и при
росте платформы будет путать. Имя `accent` оставляем свободным под
будущий ВТОРОЙ цвет (warm-orange, для редкого выделения вроде ссылок
по batch_id) — это отдельная задача когда понадобится.

Переименовано везде (109 ссылок в 6 файлах):
- --p-accent          → --p-primary
- --p-accent-hover    → --p-primary-hover
- --p-accent-press    → --p-primary-press
- --p-accent-soft     → --p-primary-soft
- --p-accent-line     → --p-primary-line
- --p-accent-strong   → --p-primary-strong
- --p-ink-on-accent   → --p-ink-on-primary

Файлы: tokens.css, quasar-canon.css, components.css, RailUserCard,
AuthCard, _dev/ui (включая user-facing labels палитры «Accent → Primary»
и заголовок секции «Акцент → Основной цвет»).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:23:58 +00:00
coopops 4fddfe2343 feat(ui): canon-цвет через --q-primary CSS var; вернуть dense + rounded
- tokens.css: проброс canon-палитры в Quasar brand-переменные
  (--q-primary/negative/positive/warning/info = соответствующие
  --p-* токены). Резолвится lazy → автоматически следует за темой.
  Теперь Quasar САМ красит focus q-field, q-btn color="primary",
  bg-primary utility-классы в canon-цвета без CSS-overrides;
- quasar-canon.css: убраны border-color overrides на ::before
  (default/focus/error) — Quasar управляет цветом через --q-primary
  и собственный механизм --focused/--highlighted. Оставлены только
  background, text-color и rounded углы (border-radius var(--p-r-sm));
- BaseInput/Select: вернули `dense` (компактный 40px) и явный
  `color="primary"` (canon teal через --q-primary).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:14:20 +00:00
coopops 9f38148dc4 fix(ui): BaseDialog — уменьшить отступ между body и footer-кнопками
Между нижним инпутом и footer-кнопками был ~32px (q-card-section
padding-bottom 20 + q-card-actions padding-top 12) — выглядело
растянуто. Сводим к 16px (8+8) — кнопки сидят ближе к контенту.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:06:27 +00:00
coopops 559493a4b2 fix(ui): q-field overrides — оставить только цвет, всё остальное дефолт Quasar
Регрессия мерцания границы и приплюснутости — следствие борьбы наших
overrides с встроенной Quasar анимацией. Возвращаем дефолт: Quasar
сам решает геометрию (height/padding/border-radius/font), анимации,
opacity ::before и focus-индикацию. Наша задача — только покрасить
в canon-цвета.

Удалено из .q-field--outlined:
- height/padding/border-radius/font-size/font-family overrides;
- transition border-color (Quasar сам делает свой transition);
- opacity:1 !important на ::before (был костыль от мерцания);
- box-shadow:none на --focused/--highlighted;
- display:none на ::after underline;
- override .q-field--dense;
- override .q-field--float .q-field__label (Quasar анимирует label сам);
- focus-shadow / outline reset на native input.

Оставлено (только цвет):
- background: var(--p-surface);
- border-color: line-1 / accent (focus) / neg (error);
- color текста, placeholder, label, prefix/suffix, hint/error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:05:57 +00:00
coopops ed61bb46b6 fix(ui): мерцание границы q-input на focus + unused import в BaseTable
- Quasar при focus анимирует opacity ::before (прячет старую границу
  перед показом ::after underline). Так как ::after мы спрятали,
  получался видимый gap ~200-400 ms «граница исчезла → появилась
  с подсветкой». Фикс: opacity:1 !important на ::before,
  transition только по border-color.
- BaseTable.vue: убран неиспользуемый импорт BaseTableColumn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:01:31 +00:00
coopops 113a7b8a1b fix(ui): убрать dense из BaseInput/Select; gap в Dialog/Form body; wallet 17px
- BaseInput/BaseSelect: убран `dense` — возвращаемся к дефолтному
  outlined Quasar (control ~56px). Floating-label получает достаточно
  воздуха, текст значения визуально центрируется без сжатия;
- BaseDialog: `.base-dialog__body` теперь flex-column с gap 16px —
  описание/инпуты/таблицы внутри диалога получают единый воздух
  без правки потребителя (пример: «Паевой взнос: укажите сумму…» →
  отступ до инпута появляется автоматически);
- BaseForm: `.base-form__body` gap 4px → 12px — те же требования
  внутри формы;
- WalletCard: `.wallet__metric-val` 19→17px — итоговое значение
  для аккуратного баланс-блока FULL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:00:14 +00:00
coopops 390c5e9a5c fix(ui): убрать stack-label, двойную focus-подсветку и mini-wallet из шапки
1. Stack-label: BaseInput/BaseSelect → только `dense`. Quasar
   стандартно сам корректно держит floating-label в dense+outlined,
   без CSS-override метка работает по документации Quasar.
2. Двойная focus-подсветка: убран наш box-shadow var(--p-focus-ring)
   поверх Quasar внутренней focus-индикации (виден был как «белый
   ринг» на тёмной + наш teal). Теперь только border-color меняется
   через ::before (canon-минимализм). Дополнительно `box-shadow: none
   !important` на --focused/--highlighted control и `display:none` на
   ::after underline (на случай dense Quasar variants).
3. Mini-wallet в правом верхнем углу AppHeader: слот #wallet удалён
   из API (баланс уже отображается в RailUserCard в drawer'е,
   дубль в шапке избыточен). Убран из dev-витрины каркаса.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:52:12 +00:00
coopops 0515994833 fix(ui): WalletCard и mini-wallet — снизить визуальный вес цифр
Регрессия после Quasar wrapper-pivot: метрики кошельков выглядели
крупно по сравнению с заголовком/sub-label, а padding'и были
избыточны для compact-варианта.

- .wallet (FULL): padding 18→14px по верт, иконка 44→40px,
  metric-val 22→19px, ccy 13→12px;
- .wallet--row (compact): padding 14→10px, иконка 36→32px,
  metric-val 18→16px — выглядит как row в списке, а не как карточка;
- .rail__balance-val (mini-wallet в drawer): 22→18px — теперь не
  доминирует над identity-блоком пайщика.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:38:31 +00:00
coopops b7717e0e1f fix(ui): canon q-field/q-btn пропорции — убрать раздавливание floating-label
Корень регрессии: жёсткий height: 40px в .q-field--outlined раздавил
вертикальную разметку q-input (Quasar держит padding-top:24px под
floating-label слот; при сжатии текст значения сдвигается). Те же
эффекты для select.

- BaseInput/BaseSelect → dense + stack-label по умолчанию;
  Quasar сам корректно держит 40px в dense, метка статически стоит
  над инпутом без floating-механики;
- .q-field--outlined: убран принудительный height; .q-field--dense
  получает только padding-x, высоту контролирует Quasar;
- .q-btn: font-weight 500→450, min-height 40→36px, padding 16→14px —
  кнопки больше не выглядят «жирно/тяжело»;
- .q-btn--outline: hairline-граница через ::before (Quasar pattern),
  без двойной рамки;
- размеры sm/lg/dense пропорционально уменьшены;
- .q-chip: высота 24→26px, padding 10→12px, font-weight 500→450;
  + .q-chip__icon размер 14px (для точечных чипов).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:36:29 +00:00
coopops 1ac70c0405 refactor(desktop/base): остальные base/ и AuthCard → обёртки Quasar
BaseSelect → q-select (outlined, reserve-hint-space, map-options/emit-value
для совместимости с {value,label} options API).

BaseCard → q-card flat с q-card-section head/body. Variants flat/inset/quiet
override-ят через scoped style.

BaseChip → q-chip с color mapping (variant → primary/positive/negative/
warning/info). Square для canon-look.

BaseBadge → q-badge с тем же color mapping. Dot-вариант через scoped style
(8×8 круг).

BaseBanner → q-banner rounded dense с border-left по variant.

BaseDialog → q-dialog + q-card с size→maxWidth mapping. closeOnBackdrop/
closeOnEscape пробрасываются через :persistent / :no-*-dismiss.

BaseTable → q-table с трансформацией columns в QTableProps['columns'].
Hide-pagination, binary-state-sort. Cell-slots работают через body-cell-{key}.

AuthCard (domain) — был на native canon `.card` div. Переведён на q-card flat
для консистентности. Усиленный shadow + accent-stripe сверху сохранены.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:12:12 +00:00
coopops 8ddcd30bc9 refactor(desktop/base): BaseInput/Button/Form → обёртки Quasar
BaseInput теперь рендерит <q-input outlined reserve-hint-space no-error-icon>
с проброшенными slots. reserve-hint-space от Quasar резервирует место
под error/hint встроенно — раньше делали вручную через NBSP. Все Quasar
features (rules через v-bind \$attrs, mask, ref API, native slots) работают
прозрачно. Props API совместим (modelValue, label, hint, error, prefix,
suffix, mono, readonly, disabled, autocomplete, name, id, type).

BaseButton — q-btn с variant→Quasar mapping:
  primary → :unelevated color="primary" (заливка accent)
  secondary → :outline (border + surface)
  ghost → :flat (transparent)
  danger → :flat color="negative" (transparent + neg-cвет)
:no-caps :ripple=false для canon-look. Props API совместим.

BaseForm — q-form-обёртка с сводным error-баннером (q-banner) и slot footer.
banner—neg styling прилетает из quasar-canon.css через .bg-negative-soft.

Глобальная стилизация Quasar в quasar-canon.css даёт canon-look всем
этим компонентам и существующим q-input/q-btn/q-card по всему проекту.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:09:34 +00:00
coopops 82f44284fa feat(desktop/css): canon-стилизация Quasar поверх (quasar-canon.css)
Архитектурный pivot: вместо native HTML5-компонентов под canon-классами
стилизуем Quasar поверх. Причины:
- Сохраняется весь Quasar API из коробки (rules/mask/dense/slots/refs)
- Существующий код (сотни q-input, q-btn, q-card, q-select) автоматом
  получает canon-look без правок
- Меньше регрессий — Quasar отвечает за accessibility/keyboard/focus

Файл src/css/mono-platform/quasar-canon.css содержит overrides под canon
для: q-field--outlined (q-input/q-select), q-btn (unelevated/outline/flat),
q-card, q-chip, q-badge, q-banner, q-dialog/q-menu, q-item, q-table,
q-checkbox/q-toggle.

Подключен в quasar.config.cjs css[] после components.css. tokens.css
без изменений — все цвета/радиусы/тени тянутся как переменные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:08:36 +00:00
coopops 6029342c7e fix(desktop): удалить legacy глобальный .card { transform: scale(1.03) }
Истинный источник «прыгалки» AuthCard на hover: глобальное правило
`.card:hover { transform: scale(1.03) }` в src/app/styles/style.css
(и его дубль в pages/Marketplace/MainPage/ui/MainPage.vue, тоже не scoped)
бьёт по любому элементу с классом card. AuthCard имеет корневой
<div class="card auth-card"> — каждый hover дёргает её на 3%.

Поиск показал: класс `card` без модификаторов в templates никто кроме
новых canon-компонентов (BaseCard, AuthCard) не использует. Этот глобал —
мёртвый legacy, удаляем безопасно.

NBSP-фикс layout-shift из предыдущего коммита (2a2d9a786a) остаётся
актуальным — он лечил другую (на уровне поля) проблему.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:06:59 +00:00
coopops 2a2d9a786a fix(desktop/ui): добить layout-shift, ужать gap полей, accent-стрип AuthCard, затемнить canvas
1) BaseInput — layout shift жив: text-interpolation `{{ error || hint || ' ' }}`
   рендерила обычный пробел, браузер схлопывает text-node с одним whitespace
   в 0-width content → div сжимается → форма прыгает. Заменили на
   `<span>&nbsp;</span>` (NBSP в DOM, не схлопывается).

2) BaseInput — canon `.field { margin-bottom: 16px }` создавал слишком
   большой разрыв между полями (16px margin + 10px form gap + 17px message =
   ~43px). Override `.field { margin-bottom: 0 }` в scoped — расстояние
   управляется gap'ом BaseForm.

3) AuthCard — статичный accent-стрип 3px сверху (`::before`). Визуальный
   якорь без анимации — даёт бренд-маркер «здесь главное».

4) tokens.css light — canvas #fafafa → #f4f4f5 (cool-gray), canvas-2
   #f4f4f5 → #ebebed. Surface остаётся #ffffff, теперь карточка чётче
   проступает на фоне.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 17:54:32 +00:00
coopops d6ab6e3cc6 fix(desktop/ui): убрать layout shift в BaseInput и оживить AuthCard на светлой
BaseInput: контейнер field__message теперь рендерится всегда с min-height
calc(--p-fs-meta * 1.4) — раньше div появлялся/исчезал на каждом keystroke
при валидации (см. LostKey emailError), форма прыгала вверх-вниз.
Когда нет ни ошибки, ни хинта — рендерится NBSP, высота сохраняется.

AuthCard: добавлен box-shadow поверх canon-минималистского .card —
canvas #fafafa и surface #ffffff на светлой почти неразличимы, без тени
карточка выглядит плоской. На тёмной отдельный shadow усилен (поверх
canon `--p-shadow-card`, который оптимизирован под data-cards, не hero).
border-color подкручен с --p-line на --p-line-1 для большей чёткости.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 17:48:01 +00:00
coopops 5d1d79d9ea feat(desktop/LostKey): E6 Story 6.3 — миграция «Перевыпуск ключа» на canon
Scope-adjustment: plan'овая Story 6.3 предполагала миграцию SignUp как
второго auth-flow, но Register — multi-step wizard (10+ файлов в
pages/Registrator/SignUp/), который требует VerticalStepper из Эпика 10
и не помещается в Волну 1. Вместо этого мигрируем LostKey — простую
одно-полевую auth-страницу, того же auth-домена.

LostKey (widget): pug → html, старый AuthCard (q-card с shimmer) →
canon AuthCard. q-input → BaseInput с inline email-валидацией;
q-btn → BaseButton; всё обёрнуто в BaseForm с loading/error.
Бизнес-логика (useLostKey.startResetKey + router redirect на resetkey)
сохранена 1:1.

LostKeyPage: footer-кнопка «Назад» через ghost BaseButton в footer-slot
AuthCard; центровка через flexbox.

Не мигрировано в Волне 1 (известный gap):
- SignUp wizard (требует VerticalStepper из E10)
- ResetKey / Invite (на старом AuthCard, мигрируются в Волне 2)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 17:29:04 +00:00
coopops 34e1ea8a37 feat(desktop/SignIn): E6 Story 6.2 — миграция Login на canon-компоненты
LoginForm (feature): q-input → BaseInput, q-btn → BaseButton, обёрнут в
BaseForm с управлением loading/error. Бизнес-логика (useLoginUser, redirect,
OpenReplay tracking, NotificationPermissionDialog) сохранена 1:1.
Ошибки входа теперь показываются через сводный error BaseForm
(плюс legacy FailAlert остаётся как fallback).

SignIn (widget): импорт AuthCard переключён на новый canon-композит
(src/shared/ui/domain/AuthCard), убран pug-template и градиентные стили.
Добавлен footer-slot — пробрасывается в AuthCard.

SignInPage: переписан с pug → html, footer-кнопки «Потеряли ключ?» /
«Нет аккаунта?» вынесены в footer-slot AuthCard через BaseButton ghost-sm.
Центровка через flexbox вместо QRow grid.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 17:28:31 +00:00
coopops ef80504a80 feat(desktop/AuthCard): E6 Story 6.1 — AuthCard композит + AuthLayout
AuthCard (src/shared/ui/domain/AuthCard) — canon .card + center-aligned
head (title/subtitle/slot head) + body-slot + опциональный footer-slot.
Старый src/shared/ui/AuthCard (q-card с shimmer-градиентом) остаётся
для не-мигрированных страниц (SignUp wizard, ResetKey, Invite).

AuthLayout (src/app/layouts/AuthLayout.vue) — wrapper-обёртка для full-page
auth-страниц: фон var(--p-canvas), центрирование, тогл темы в правом верхнем.

Витрина: dev-секция 19 с примером login-формы (BaseForm + BaseInput ×2 +
BaseButton + footer-ссылки).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 17:27:11 +00:00
coopops 51f89b3fe4 feat(desktop/widgets/wallet-card-mini): E5 Story 5.2 — connected-обёртка WalletCard
WalletCardMini подключает WalletCard к Pinia (useWalletStore.program_wallets) +
System store (info.symbols.root_govern_symbol) + useRouter (клик →
name: 'wallet'). Программный mapping: wallet→MAIN, blagorost→BLAGOROST,
generator→GENERATOR (Zeus.ProgramType). Loading определяется автоматически
по наличию данных в store (или пробрасывается через prop).

Подключено в слот wallet AppHeader на dev-витрине (секция 16 — каркас) +
выделенная секция 18 с тремя программами. На dev-странице покажет
skeleton-loading т.к. loadUserWallet не вызывался; на реальных страницах
после init процесса — настоящий баланс.

Со-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 17:00:57 +00:00
coopops 50b95de57b fix(desktop/_dev/ui): корректные подписи программ Благорост/Генератор в витрине WalletCard
Благорост — капитализация РИД (не «накопления на жильё»), Генератор —
генерация РИД (не «доходы от программы»). Кошелёк остаётся «свободный
остаток». Черновые ярлыки риском попадают в production через копипасту.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 16:48:01 +00:00
coopops 345b226f84 feat(desktop/WalletCard): E5 Story 5.1 — props-only компонент с программными акцентами
Канонический .wallet с компактным (--row) и full-вариантами, программные
акценты через --prog-blagorost / --prog-wallet / --prog-generator (UX-DR20).
Витрина на /_dev/ui секция 17: три программы × оба варианта + loading +
empty + locked-line.

Compact (.wallet--row): 36px icon, 18px sum, 14px padding — для слота шапки.
Full (.wallet): 44px icon, 22px sum, 18px padding — для дашборда.

Connected-обёртка widgets/wallet-card-mini (Story 5.2) делается отдельным
коммитом, требует интеграции с реальным wallet-store и MARKETPLACE_ASSET_CONFIG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 16:41:54 +00:00
coopops 8af8bf7512 revert(desktop/RailUserCard): убрать transition свёртки — baseline-skipping
Анимация max-height + opacity при свёртке давала видимый прыжок крупной
суммы баланса (`font-size: 22px` + `display: flex; align-items: baseline`):
во время промежуточных кадров baseline переcчитывался относительно
схлопывающегося контейнера → текст «прыгал в середину» и обратно.

Возвращаемся к canon `display: none`. Resize моментальный, без skipping.
Поведение rail (top опускается, bottom-chevron остаётся через flex-spacer)
работает и без анимации — пользователь это и подтвердил в фидбеке.

Chevron rotation через :deep(.q-icon) и balance-route не трогаем —
они независимы.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:33:04 +00:00
coopops 5ef499a75f feat(desktop/_dev/ui): TokenPalette + chevron full-width fix
1. **Палитра системы.** Новая секция «00» в шапке dev-страницы со всеми
   основными --p-* токенами:
   - Акцент (accent / hover / press)
   - Поверхности (canvas / canvas-2 / surface / 2 / 3)
   - Текст (ink / ink-on-accent)
   - Линии (line / 1 / 2) — текстовый input для rgba
   - Статусы (pos / neg / warn / info)
   - Программные тинты (blagorost / wallet / generator)

   Color-picker для hex-токенов, текстовый ввод для rgba. Live-override
   через `document.documentElement.style.setProperty()` — inline-style на
   :root перебивает CSS-правила, перекрашивание мгновенное. Подсветка
   переопределённых токенов + счётчик в шапке + кнопка «Сбросить» (через
   removeProperty). Watch на Quasar Dark — при смене темы пикеры
   обновляются на новые resolved-значения.

2. **Chevron в свёрнутом виде — full-width.** Из-за того что для плавной
   анимации primary остаётся в DOM (max-height:0 вместо display:none),
   у него сохранялся `flex: 1` и он съедал ширину карточки → chevron не
   мог растянуться через canon-правило `width: 100%`. В `.is-collapsed`
   добавили `flex: 0 0 0` + `width: 0` для primary — chevron теперь
   корректно занимает всю ширину.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:31:54 +00:00
coopops 79afee2b82 fix(desktop/RailUserCard): chevron rotation, плавная свёртка, balance-route
По фидбеку:

1. **Chevron не переворачивается.** Canon-правило
   `.rail__usercard__collapse svg { transform: rotate(180deg) }` рассчитано
   на `<svg>`, а Quasar `<q-icon>` рендерит `<i class="q-icon">`. Прокидываем
   transition + rotate через scoped `:deep(.q-icon)` — chevron теперь крутится
   при свёртке/развёртке.

2. **«Карточка улетает»** — резкий jump при `display:none` создаёт визуальный
   прыжок. Подменяем display:none на `max-height: 0` + `opacity: 0` с
   transition'ом. Логика canon-селектора `.is-collapsed` не нарушается,
   просто добавлена плавность. Если структурное поведение rail (top
   опускается, bottom-chevron остаётся на месте) всё равно неудобно —
   перевёрстаем во вторую итерацию.

3. **Клик по балансу → маршрут кошелька.** Новый prop `balanceRoute` —
   если задан, блок `.rail__balance` оборачивается в `<router-link :to>`.
   Параллельно эмитим `balance-click` — для аналитики или функционального
   handler'а. На витрине привязал к `/_dev/ui#wallet` чтобы проверить.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:28:31 +00:00
coopops 0a977dcd83 feat(desktop/domain): RailUserCard + фикс RouteMeta для dev-роута
1. `RouteMeta.icon` объявлен обязательным в `src/env.d.ts` — добавили
   `icon: 'fa-solid fa-flask'` для dev-роута, иначе vue-tsc валится.

2. `RailUserCard` (`shared/ui/domain/RailUserCard`) — доменный
   общеплатформенный компонент мини-кошелька в нижней части drawer'а.
   Это будущий E11 (Волна 2), но логично вытащить раньше, чтобы основной
   layout можно было заменить целиком уже сейчас.

   Реализован по canon строго: rail__usercard с usertop+balance+actions,
   collapse-chevron справа с поворотом arrow при свёртке (через
   `.is-collapsed` модификатор + canon CSS), отдельный `rail__signout`
   блок (выходит вне `.rail__usercard` — рендерится сразу после, в том
   же `aside.rail` через footer-slot).

   Контракт строго dumb по архитектуре: только props/emits/slots.
   - props: name, role, avatarSrc, balance, symbol, balanceLabel,
     lockedBalance, lockedLabel, primaryActionLabel, collapsed,
     showSignout, signoutLabel
   - emits: primary-action, update:collapsed (v-model), signout
   - slots: usertop-extra, actions

   Coнnected-обёртки (с инжектом store/auth) — на уровне страницы,
   не здесь.

3. Витрина `/_dev/ui`:
   - Секция 12 (AppDrawer): заменили временный огрызок rail-demo__user
     на полноценный `<RailUserCard show-signout>` через footer-slot.
   - Секция 16 (Каркас приложения): аналогично — теперь у rail полноценный
     footer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:19:09 +00:00
coopops 74539a6c46 feat(desktop/layout): E4 — AppDrawer + AppHeader + PageHead + PageTabs
E4 «Layout-каркас» Волны 1: 4 структурные обёртки в `src/shared/ui/layout/`.
По архитектуре — только props/emits/slots, без store/router/api, без знания
о данных. Cтраницы передают menu-items, breadcrumbs, табы и активные ключи
сверху.

Состав:
- `AppDrawer` — canon `.rail`: solid surface, активный пункт soft-accent
  пилюлей + 2-px рейл слева. Поддерживает плоский список + секции
  (RailSection с eyebrow-меткой), opt-in ⌘K-кнопку, slot `footer` для
  user-card / logout.
- `AppHeader` — canon `.topbar`: бургер · crumb · `topbar__actions` ·
  `topbar__right`. Поддерживает простой title и multi-level breadcrumb.
  Глобальные действия через слоты `notifications`/`theme`/`wallet`/`profile`
  или общий `right`.
- `PageHead` — canon `.page-head`: eyebrow + title + subtitle + slot
  `actions`. Используется когда страница БЕЗ подстраниц.
- `PageTabs` — canon `.tabbar` + `.tab`: вкладки подстраниц с tab__count,
  опциональный hairline-разделитель и `tabbar__actions` справа.

Также в составе коммита:
- Убрали AuthCard из base — он композит (cap + body + head + footer),
  переедет в E6 Auth flow рядом с SignUp/ResetKey/Invite.
- BaseForm: gap полей 16→10px (тесные формы как в каноне).
- Витрина `/_dev/ui`: 4 новые секции (12-15) на каждый layout-компонент +
  секция 16 «Каркас приложения» — AppDrawer + AppHeader + PageTabs в живой
  связке без legacy QLayout/QDrawer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 06:17:01 +00:00
coopops dc93704990 fix(desktop/mono-platform): убрать HTML-теги <style> из canon CSS
Скрипт извлечения из bundled HTML оставил `  <style>...` в начале и
`</style>` в конце обоих файлов. CSS-парсер ловил `<` как ошибочный
селектор и пропускал блок до ближайшей `}` — съедая весь первый блок
правил.

В tokens.css первым блоком был `:root, [data-theme="light"]` со светлой
палитрой — она целиком игнорировалась. `[data-theme="dark"]` ниже
парсился нормально, поэтому тёмная тема работала, а светлая — нет:
кнопки primary/ghost/danger оставались с transparent background и
сливались с canvas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 05:54:25 +00:00
coopops 02a4a2aa14 feat(desktop): MONO Platform v2 — базовые компоненты дизайн-системы
Заносим эталонную дизайн-систему в проект — только базовые элементы для
дальнейшей компоновки. Source — MONO Design System.html.

Архитектура (по `_bmad-output/planning-artifacts/architecture.md`):
- `src/css/mono-platform/{tokens.css,components.css}` — canon токены и стили
  компонентов 1:1 из эталона; импортируются первыми в quasar.config css[].
- `src/shared/ui/base/<Имя>/{Vue,types,index}` — 14 базовых обёрток (Vue 3 +
  TS, native HTML над canon BEM, no Quasar-deps кроме Dark API в ThemeToggle).
  FSD-граница: только props/emits/slots, никаких store/router/api.
- `src/boot/ui.ts` — глобальная регистрация 14 базовых компонентов.
- `src/boot/theme.ts` — sync `html[data-theme]` ↔ Quasar Dark.
- Inter + JetBrains Mono в index.html.

14 базовых:
BaseButton, BaseInput, BaseSelect, BaseCard, BaseTable, BaseChip, BaseBadge,
BaseDialog, BaseBanner, BaseForm, EmptyState, Avatar, ThemeToggle, AuthCard.

Витрина `/_dev/ui` (dev-only) — каждая обёртка отдельной секцией над чистым
canon-канвасом, без layout-обёрток. Маршрут смонтирован на корневом уровне,
ВНЕ DynamicLayoutWrapper, чтобы legacy default.vue не перебивал стили.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 05:30:28 +00:00
1595 changed files with 89072 additions and 32716 deletions
+11
View File
@@ -39,3 +39,14 @@ MINIO_ROOT_PASSWORD=minioadmin
CHAIN_URL=http://127.0.0.1:8888
API_URL=http://127.0.0.1:2998/v1/graphql
MONGODB_URL=mongodb://127.0.0.1:27017
# --- CoopID (overlays docker-compose.authentik.yml + docker-compose.edge.yml) ---
# Поднимаются только явной цепочкой -f (см. infra/coopid/README.md);
# перед первым запуском: bash scripts/coopid-gen-secrets.sh
# К портам применяется то же правило offset = (INSTANCE_INDEX - 1) * 10.
COOP_PG_HOST_PORT=5632
AUTHENTIK_HOST_PORT=9008
CADDY_HTTP_PORT=8088
CADDY_HTTPS_PORT=8443
# Домен кооператива для caddy: localhost = dev self-signed; реальный домен = Let's Encrypt.
COOPID_DOMAIN=localhost
+151 -199
View File
@@ -1,49 +1,45 @@
name: Release
# Единый релизный workflow по push'у тэга `v*`.
# Релизный workflow по push'у в testnet/main (FF-промоушн-модель, см.
# scripts/RELEASE.md).
#
# Порядок (через jobs.needs):
# 1) release — контракты + контейнеры + webhook деплоя (атомарно)
# 2) publish-packages — npm publish через lerna (если не -alpha)
# 3) publish-docs — mkdocs + standards-site → gh-pages (если не -alpha + ветка main)
# 4) trigger-coopenomics — repository_dispatch в coopenomics/coopenomics (тот же гейт, что docs)
# 2) publish-packages — npm publish через lerna (только ветка main)
# 3) trigger-coopenomics — workflow_dispatch сборки сайта C9S/coopenomics на
# Gitea (только ветка main); он сам тянет mono и
# пересобирает доки.
#
# publish-docs (gh-pages на github.com) удалён 2026-05-25 — после переезда на
# Gitea Pages-публикация невалидна; доки деплоятся через C9S/coopenomics.
#
# Зачем последовательно: пакеты/доки/внешний триггер не должны уезжать,
# если релиз контрактов или контейнеров провалился. Раньше четыре workflow'а
# стартовали параллельно по одному тэгу — отсюда гонка из инцидента 2026-05-13
# с ledger2 walletop sweep'ом (см. историю release.yaml до объединения).
# если релиз контрактов или контейнеров провалился.
#
# Гейт `-alpha` унифицирован: пакеты + доки + триггер docs пропускают только
# не-alpha тэги. -beta/-rc/-test в проекте не используются (см. CLAUDE.md),
# поэтому проще иметь один паттерн `!contains(github.ref, '-alpha')`.
# IS_PROD в release-job остаётся на `(alpha|beta|rc|test)` — он определяет
# деплой-webhook (testnet vs production) и тэг `:latest` у образов;
# намеренно строже, чтобы случайный -rc-тэг не уехал на прод.
# Триггер — push в testnet/main с изменением lerna.json. Версию бампает
# `lerna version` ОДИН раз на dev (scripts/cut-release.sh); тот же коммит едет
# вверх по fast-forward (scripts/promote.sh). lerna.json меняется только на
# релизном бампе, поэтому обычные feature-пуши деплой не триггерят. dev в
# триггере НЕТ: cut на dev только бампает версию, деплой начинается с testnet
# (staging) и завершается main (production).
#
# Резолв ветки. Тэг семантики окружения не несёт (например `v2026.5.13-alpha-2`
# может лежать на testnet или dev — определяет именно ветка, на которую сделан
# merge перед тэгом). Поэтому ищем первое совпадение SHA с remote-ветками
# main/testnet/dev. branch экспортируется как outputs.branch для downstream jobs.
# Окружение определяет ВЕТКА (main → production, testnet → staging), а не
# суффикс версии: единая версия едет во все среды, поэтому source of truth —
# ветка, на которую сделан промоушн. Версия читается из закоммиченного
# lerna.json. Гейты npm-publish/доки — `branch == main`.
on:
push:
tags:
- 'v*'
branches: [testnet, main]
paths:
- 'lerna.json'
workflow_dispatch:
permissions:
contents: write
jobs:
# Гейт TS-типов перед сборкой образов. Reusable workflow `typecheck.yaml`
# запускает vue-tsc на desktop и tsc --noEmit на controller. Нужно потому,
# что `lerna run build` в корневом Dockerfile НЕ ловит TS-ошибки:
# quasar build идёт через esbuild с выключенным vueTsc, а у controller'а
# build-скрипта вообще нет (ts-node в рантайме).
typecheck:
uses: ./.github/workflows/typecheck.yaml
release:
needs: typecheck
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.resolve.outputs.tag_name }}
@@ -55,22 +51,22 @@ jobs:
with:
fetch-depth: 0
- name: Resolve branch, build mode and tags
- name: Resolve branch, build mode and version
id: resolve
run: |
TAG_NAME="${{ github.ref_name }}"
# Ветка — это ветка push'а (или выбранная в workflow_dispatch).
BRANCH="${{ github.ref_name }}"
SHA="${{ github.sha }}"
BRANCH=""
for candidate in main testnet dev; do
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
BRANCH="$candidate"
break
fi
done
if [ -z "$BRANCH" ]; then
echo "::error::Тэг $TAG_NAME ($SHA) не лежит ни на одной из dev/testnet/main"
# Версия — из закоммиченного lerna.json. Её бампит lerna version ОДИН
# раз на dev (scripts/cut-release.sh), и тот же коммит едет по FF в
# testnet/main. Тэг как триггер больше не используется.
RAW_VERSION="$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' lerna.json | head -1)"
if [ -z "$RAW_VERSION" ]; then
echo "::error::Не удалось прочитать version из lerna.json"
exit 1
fi
TAG_NAME="v${RAW_VERSION}"
case "$BRANCH" in
main)
@@ -88,17 +84,22 @@ jobs:
CONTRACTS_TAG=dev
CONTRACTS_EXTRA=
;;
*)
echo "::error::Неподдерживаемая ветка $BRANCH (ожидались testnet/main/dev)"
exit 1
;;
esac
# IS_PROD определяет webhook деплоя (PRODUCTION vs TESTNET) и тэг :latest.
# Сознательно строже гейта publish-* — случайный -rc/-beta тэг не должен
# уехать на прод-вебхук, даже если в npm/docs его опубликовали.
if [[ "$TAG_NAME" =~ -(alpha|beta|rc|test) ]]; then
IS_PROD=false
WEBHOOK_URL='${{ vars.TESTNET_WEBHOOK_URL }}'
else
# Окружение деплоя определяет ВЕТКА: main → production, иначе → testnet.
# IS_PROD управляет деплой-webhook (PRODUCTION vs TESTNET) и тэгом :latest
# у образов. Раньше критерием был суффикс -alpha у тэга; теперь единая
# версия едет во все среды, поэтому источник истины — ветка промоушна.
if [ "$BRANCH" = "main" ]; then
IS_PROD=true
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
else
IS_PROD=false
WEBHOOK_URL='${{ vars.TESTNET_WEBHOOK_URL }}'
fi
{
@@ -127,14 +128,41 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
# === Этап 1: контракты ===
# CDT ставим из .deb (C9S/cdt v4.2.0) прямо в окружение job'а и
# компилируем напрямую — БЕЗ вложенного docker. Почему не build-all.sh:
# тот монтирует $(pwd):/project в sibling-контейнер, а под Gitea
# act_runner сам job исполняется в контейнере → хостовый демон не видит
# этот путь, /project пуст, cmake падает "no CMakeLists.txt".
# build-all.sh остаётся для локальной сборки (оборачивает тот же
# build_contracts_cdt.sh в docker).
# CMakeLists хардкодит toolchain /cdt/build/...; .deb кладёт CDT в
# /usr/opt/cdt/4.2.0, а сам CDTWasmToolchain.cmake указывает на /usr
# абсолютно — поэтому симлинк /cdt/build → /usr/opt/cdt/4.2.0 сводит пути
# без правки CMakeLists.
- name: Pull CDT toolchain image
run: docker pull dicoop/blockchain_v5.1.1:dev
- name: Install CDT 4.2.0 toolchain (.deb)
run: |
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update
# build-essential — для host-компилятора (project() в CMakeLists);
# libz3-4/libtinfo6/libxml2/zlib1g — рантайм бинарей CDT (clang-9,
# ld.lld и т.д.). Пакет cdt объявляет только libcurl4-gnutls-dev,
# поэтому остальные .so ставим явно — в образе dicoop/blockchain они
# были из сборки исходников, из .deb не тянутся.
$SUDO apt-get install -y --no-install-recommends \
curl ca-certificates cmake make build-essential \
libz3-4 libtinfo6 libxml2 zlib1g
curl -fsSL -o /tmp/cdt.deb \
https://git.coopenomics.world/C9S/cdt/releases/download/v4.2.0/cdt_4.2.0-1_amd64.deb
$SUDO apt-get install -y /tmp/cdt.deb
$SUDO mkdir -p /cdt
$SUDO ln -sfn /usr/opt/cdt/4.2.0 /cdt/build
cdt-cpp --version || true
- name: Compile contracts
working-directory: components/contracts
run: |
./build-all.sh "$BUILD_MODE"
./build_contracts_cdt.sh "$BUILD_MODE"
echo "--- build/contracts ---"
ls -la build/contracts/
@@ -215,6 +243,17 @@ jobs:
- name: Build and push contracts image
working-directory: components/contracts
run: |
# Ретрай push'а: резолвинг registry-1.docker.io на runner'е изредка
# моргает (DNS-таймаут к 127.0.0.53) — 3 попытки с паузой.
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
IMAGE="dicoop/contracts"
SHORT_SHA="${SHA::7}"
docker build \
@@ -223,14 +262,14 @@ jobs:
--label "build.mode=$BUILD_MODE" \
-t "$IMAGE:$CONTRACTS_TAG" \
./docker/.context
docker push "$IMAGE:$CONTRACTS_TAG"
dpush "$IMAGE:$CONTRACTS_TAG"
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
docker push "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
dpush "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
if [ -n "$CONTRACTS_EXTRA" ]; then
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_EXTRA"
docker push "$IMAGE:$CONTRACTS_EXTRA"
dpush "$IMAGE:$CONTRACTS_EXTRA"
fi
- name: Verify pushed contracts image
@@ -243,15 +282,31 @@ jobs:
- name: Build and push base image
run: |
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
docker push "dicoop/mono-base:$TAG_NAME"
dpush "dicoop/mono-base:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/mono-base:$TAG_NAME" dicoop/mono-base:latest
docker push dicoop/mono-base:latest
dpush dicoop/mono-base:latest
fi
- name: Build and push service images
run: |
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
build_service() {
local SVC="$1" PKG="$2" CMD="$3"
local DOCKERFILE="Dockerfile.$SVC"
@@ -260,10 +315,10 @@ jobs:
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
} > "$DOCKERFILE"
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
docker push "dicoop/$SVC:$TAG_NAME"
dpush "dicoop/$SVC:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
docker push "dicoop/$SVC:latest"
dpush "dicoop/$SVC:latest"
fi
}
@@ -285,14 +340,16 @@ jobs:
# ============================================================================
# publish-packages — npm publish (бывший publish-packages.yaml).
# Гейт: не-alpha. Не зависит от ветки.
# Гейт: ветка main. Версия для publish — из закоммиченного package.json
# (lerna publish from-package); её бампит cut-release.sh на dev, тот же коммит
# доезжает до main по FF.
# Жёстко прибиваем версию pnpm, которой сгенерён lockfile (lockfileVersion 9.0
# = pnpm 9/10). Иначе action-setup может подтянуть другую версию и переписать
# pnpm-lock.yaml → Lerna EUNCOMMIT.
# ============================================================================
publish-packages:
needs: release
if: ${{ !contains(github.ref, '-alpha') }}
if: ${{ needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -314,149 +371,44 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# ============================================================================
# publish-docs — mkdocs + standards-site → gh-pages (бывший publish-docs.yaml).
# Гейт: не-alpha + ветка main.
# ВНИМАНИЕ: gh-pages step пушит на github.com/coopenomics/mono — это GitHub
# Pages. При переезде на Gitea Actions нужно будет либо подложить PAT для
# внешнего push'а в GitHub, либо переключить публикацию на Gitea Pages.
# ============================================================================
publish-docs:
needs: release
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Версия pnpm берётся из `packageManager` корневого package.json,
# синхронно с publish-packages job'ом и Dockerfile'ами.
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Python requirements
run: |
python -m venv venv
source venv/bin/activate
pip install mkdocs-material mkdocs-macros-plugin mkdocs-section-index pymdown-extensions
working-directory: ./components/docs
- name: Install Node.js dependencies
run: pnpm install
working-directory: ./components/docs
- name: Patch spectaql-config.yml for CI
run: |
sed -i.bak "0,/url:.*/s|url:.*|url: 'https://testnet.coopenomics.world/backend/v1/graphql'|" spectaql-config.yml
working-directory: ./components/controller
- name: Show patched spectaql-config.yml
run: cat spectaql-config.yml
working-directory: ./components/controller
- name: Build cooptypes
run: pnpm run build
working-directory: ./components/cooptypes
- name: Generate controller docs
run: pnpm run docs
working-directory: ./components/controller
- name: Copy controller docs
run: |
mkdir -p ./components/docs/docs/graphql
cp -r ./components/controller/docs/* ./components/docs/docs/graphql/
- name: Generate sdk docs
run: pnpm run docs
working-directory: ./components/sdk
- name: Copy sdk docs
run: |
mkdir -p ./components/docs/docs/sdk
cp -r ./components/sdk/docs/* ./components/docs/docs/sdk/
- name: Generate cooptypes docs
run: pnpm run docs
working-directory: ./components/cooptypes
- name: Copy cooptypes docs
run: |
mkdir -p ./components/docs/docs/cooptypes
cp -r ./components/cooptypes/docs/* ./components/docs/docs/cooptypes/
# ── standards-site → /standards/ на docs.цифровой-кооператив.рф ──
# Отдельный Vue-сайт с BPMN-графом кооперативных стандартов,
# публикуется как поддиректория на том же домене. Vite собирает
# с base='/standards/' (см. vite.config.ts), относительные пути
# внутри dist/ и hash-router работают корректно.
# dist кладём в docs/standards/ ДО mkdocs build — так же, как
# graphql/sdk/cooptypes; mkdocs сам включит его в итоговый site/.
- name: Build standards-site
run: pnpm run build
working-directory: ./components/contracts/standards-site
- name: Copy standards-site into docs/standards
run: |
mkdir -p ./components/docs/docs/standards
cp -r ./components/contracts/standards-site/dist/* ./components/docs/docs/standards/
- name: Build docs (mkdocs)
run: |
source venv/bin/activate
mkdocs build
working-directory: ./components/docs
- name: Remove specific large file before publishing
run: |
rm -f ./components/docs/site/sdk/typedoc.json
if [ -f "./components/docs/site/sdk/typedoc.json" ]; then
echo "ERROR: typedoc.json still exists!"
exit 1
else
echo "SUCCESS: typedoc.json removed successfully"
fi
- name: Publish to GitHub Pages
run: npx gh-pages --nojekyll -d site --repo https://x-access-token:${GITHUB_TOKEN}@github.com/coopenomics/mono.git
working-directory: ./components/docs
env:
GIT_AUTHOR_NAME: github-actions
GIT_AUTHOR_EMAIL: github-actions@github.com
GIT_COMMITTER_NAME: github-actions
GIT_COMMITTER_EMAIL: github-actions@github.com
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger docs deployment webhook
if: ${{ success() }}
run: |
curl -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
-H 'Content-Type: application/json' \
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
# ============================================================================
# trigger-coopenomics-docs — repository_dispatch в coopenomics/coopenomics
# (бывший build-contracts-docs.yaml). Гейт идентичен publish-docs.
# trigger-coopenomics-docs — пересборка сайта C9S/coopenomics.
# Гейт: ветка main.
#
# coopenomics переехал на Gitea (C9S/coopenomics); его publish-docs.yaml
# клонирует mono и пересобирает сайт. Gitea НЕ имеет API для
# repository_dispatch — только workflow_dispatch, поэтому дёргаем целевой
# workflow через Gitea API с PAT (secret DOCS_DISPATCH_TOKEN — префикс GITEA_
# у секретов зарезервирован Gitea, нельзя; права write на C9S/coopenomics).
# Целевой workflow слушает workflow_dispatch с входами
# mono_sha/mono_ref.
# ============================================================================
trigger-coopenomics-docs:
needs: release
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
if: ${{ needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- name: Trigger Coopenomics deployment
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.COOPENOMICS_PAT }}
repository: coopenomics/coopenomics
event-type: deploy_from_mono
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
- name: Trigger coopenomics website rebuild (Gitea workflow_dispatch)
run: |
curl -fsSL -X POST \
-H "Authorization: token ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"ref":"master","inputs":{"mono_sha":"${{ github.sha }}","mono_ref":"${{ github.ref }}"}}' \
"${{ github.server_url }}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
# ============================================================================
# trigger-mono-docs — деплой ВТОРОЙ документации (сайт доков mono) через
# webhook DOCS_DEPLOY_WEBHOOK_URL. Отдельная от coopenomics публикация —
# обе доки уезжают синхронно по релизу. Извлечён из бывшего publish-docs:
# gh-pages на github.com выпилен как мёртвый, остался реальный deploy-webhook
# (приёмник деплоит доки на своей стороне). Гейт: ветка main.
# ============================================================================
trigger-mono-docs:
needs: release
if: ${{ needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- name: Trigger docs deployment webhook
run: |
curl -fsSL -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
-H 'Content-Type: application/json' \
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
+59
View File
@@ -0,0 +1,59 @@
name: SDK cross-runtime
# Story 9.13 (CoopID, Эпик 9): smoke-тесты @coopenomics/auth на трёх рантаймах —
# Node (vitest), браузер (vitest browser mode + chromium), desktop-runtime
# (electron, main-process против собранного dist). SDK уходит клиентам в три
# разных рантайма, и регрессия вида «crypto.subtle недоступен / bundle не
# грузится в браузере» не ловится Node-юнитами вовсе.
#
# Триггер — только изменения самого пакета: прогон тяжёлый (браузер + electron
# + xvfb), на каждый PR в dev его гонять незачем.
on:
pull_request:
branches: [dev]
paths:
- 'components/auth/**'
- '.github/workflows/sdk-cross-runtime.yaml'
workflow_call:
concurrency:
group: sdk-cross-runtime-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
cross-runtime:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install workspace deps
run: pnpm install --frozen-lockfile --filter @coopenomics/auth...
# xvfb — electron на Linux требует X-сервер; браузеры playwright ставим
# вместе с системными библиотеками chromium (--with-deps).
- name: Install runtime prerequisites
run: |
sudo apt-get update
sudo apt-get install -y xvfb
# test:electron поднимает реальный electron — ему нужны GTK/ATK/ALSA-библиотеки,
# которых нет в наборе `playwright --with-deps chromium` (chromium на другом тулките).
# Симптом без них: electron «error while loading shared libraries: libgtk-3.so.0».
# Ubuntu 24.04 (noble) — пакеты с суффиксом t64; ставим по одному, чтобы расхождение
# имени пакета не валило весь шаг.
for pkg in libgtk-3-0t64 libnotify4 libnss3 libxss1 libxtst6 libatspi2.0-0t64 libasound2t64; do
sudo apt-get install -y "$pkg" || true
done
cd components/auth && pnpm exec playwright install --with-deps chromium
- name: Run cross-runtime smoke
run: pnpm test:cross-runtime
+12 -4
View File
@@ -13,17 +13,25 @@ name: Typecheck
#
# Триггеры:
# - pull_request на dev — гейт перед мерджем в основную ветку;
# - workflow_call — переиспользуется из release.yaml как `needs:` у release-job,
# чтобы битый тэг не уехал в DockerHub.
# - workflow_call — оставлен для переиспользования, но release.yaml его
# больше НЕ вызывает (гейт из релиза убран как избыточный — типы
# проверяются на PR в dev до того, как код доедет до тэга).
#
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт достаточен,
# а тэги покрывает workflow_call из release.yaml).
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт на dev достаточен).
on:
pull_request:
branches: [dev]
workflow_call:
# Новый push в PR-ветку отменяет ещё бегущий typecheck по той же ветке —
# иначе 30-минутные прогоны копятся очередью. Группа по head_ref (исходная
# ветка PR), для workflow_call fallback на ref. Отмена только своих же
# прежних прогонов, разные PR друг друга не трогают.
concurrency:
group: typecheck-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
desktop:
runs-on: ubuntu-latest
+6
View File
@@ -1,4 +1,5 @@
node_modules/
node_modules
lerna-debug.log
components/controller/graph.png
blockchain-data/
@@ -22,3 +23,8 @@ components/contracts/cpp/ledger2/scripts/out/
.env.testnet
.pnpm-store
schema.gql
.claude
# CoopID: file-based Docker Secrets (генерируются scripts/coopid-gen-secrets.sh)
infra/coopid/secrets/
+31
View File
@@ -38,6 +38,8 @@ ln -s /home/admin/mono-ai-4/components/controller/.env $WT/components/controller
**Базовая ветка mono-ai-1 — `dev`.** Прямые коммиты в `dev` для мелких фиксов разрешены и не требуют feature-ветки/PR (отменено 2026-05-22 пользователем). Для крупных задач — feature-ветка (`feat/...` или `fix/...`) от dev и PR в dev; merge делает пользователь на GitHub. Push в `main` / `mvp` — по-прежнему только через PR.
**Релиз/деплой — FF-промоушн `dev → testnet → main`** (см. `scripts/RELEASE.md`). Версию бампает `lerna` ОДИН раз на dev (`scripts/cut-release.sh`), тот же коммит едет вверх по fast-forward (`scripts/promote.sh testnet|main`). `testnet`/`main` не несут своих коммитов — только FF-указатели, поэтому merge-конфликтов нет. **В `testnet`/`main` прямых коммитов и повторных бампов версии быть не должно** — это ломает FF. Деплой триггерит push в testnet/main с изменением `lerna.json`; окружение определяет ветка (main → prod). Старые `publish-alpha.sh`/`publish-prod.sh` (merge `-X theirs` + per-branch бамп + back-merge) удалены — они и плодили 20-package.json конфликты.
**Не stash'ить `-u` при unstaged WIP пользователя.** Это создаёт окно для потери при drop/конфликте. Если нужно временно убрать unstaged — `git stash push -- <конкретные-paths>` либо коммит-в-feature, и только свои файлы. Кейс 2026-05-18: после `git stash -u`+`drop` при конфликте потерял WIP пользователя (infra.ts/config.ts/quasar.config.cjs и др.).
### Story-by-story для проекта «Стол заказов» (mono-ai-4 на marketplace2)
@@ -184,6 +186,35 @@ await sendPOST('/v1/graphql', { query: QUERY, variables });
Канон — `features/Branch/CreateBranch/{api,model}/index.ts`: тип в model `export type IXxxInput = Mutations.X.Y.IInput['data']`; функция в api `function (data: IXxxInput) { ... variables: { data } }`. Не делать `as` cast'ов.
## ⚠️ ДИЗАЙН-КАНОН desktop — ОБЯЗАТЕЛЕН К ИСПОЛЬЗОВАНИЮ
**ПРИ ЛЮБОЙ ВЁРСТКЕ В `components/desktop/` НЕ ВЫДУМЫВАТЬ СТИЛИ.** Всегда сначала свериться с каноном. Не строй гипотез о цветах/радиусах/типографике/паттернах из памяти — иди и читай.
**Полная спецификация канона — в skill `/mono-desktop-canon`** (правила обёрток, цвета/токены, иконки, структура страницы, stop-signals). Здесь — короткая выжимка; при расхождении побеждает skill.
**Источник истины — в самом репозитории** (НЕ внешние HTML/прототипы):
| Что | Путь |
|---|---|
| Токены палитры/типографики/spacing/radii (`--p-*`) | `components/desktop/src/css/mono-platform/tokens.css` |
| Прототип-классы компонентов + typography-утилиты `.t-*` | `components/desktop/src/css/mono-platform/components.css` |
| Оверрайды Quasar под канон | `components/desktop/src/css/mono-platform/quasar-canon.css` |
| Минимальная SCSS-палитра Quasar (`$primary`/`$dark`) | `components/desktop/src/css/quasar.variables.scss` |
| Обёртки / доменные блоки / layout | `components/desktop/src/shared/ui/{base,domain,layout}/` |
| **Живой эталон** (`/_dev/ui` в dev-сборке) | `components/desktop/src/pages/_dev/ui/index.vue` |
**При сомнении — открыть `tokens.css` и `_dev/ui/index.vue`, смотреть как сделано там.** Внешний `shared/MONO Design System.html` и `auth-prototype/` каноном НЕ являются — это устаревшие прототипы.
**Запреты:**
- Экран собирается из готовых компонентов: `shared/ui/base` (вместо сырых `q-input`/`q-btn`/`q-card`/`q-table`/`q-chip`/`q-dialog`/`q-select`), `shared/ui/domain` (WalletCard, DataRow, DocumentRow, IdentityPanel…), `shared/ui/layout` (PageHead, PageTabs, AppHeader/AppDrawer). Голый Quasar — только где обёртки нет (`q-icon`, `q-toggle`, `q-list`, `q-menu`, `q-tooltip`, `q-tabs`, `q-separator`, `q-spinner`, `q-inner-loading`…). Props обёрток не угадывать — читать `*.types.ts` рядом.
- Цвет — только токены `var(--p-*)` (поверхности `--p-surface*`, текст `--p-ink*`, линии `--p-line*`, акцент `--p-primary`, статусы `--p-pos/neg/warn/info`) либо utility-классы/color-props. Никаких сырых hex/rgb. Темы light/dark переключаются через `[data-theme]` на `<html>` — токены следуют сами.
- Spacing/радиусы/типографика — токены `--p-1..--p-10` (4px…72px), `--p-r-sm/md/lg/xl`, `--p-fs-*`/`--p-lh-*` либо классы `.t-*`. Без хардкод-px.
- Иконки — `q-icon(name='…')` именами Material Icons. **FontAwesome (`fa-*`) запрещён** — заменять на Material-эквивалент попутно.
- Запрещённые/удаляемые токены: `--mp-*` (старый `marketplace-tokens.scss`), `--tr-*`, `--ds-*`. Легитимны только `--p-*`, `--q-*` (Quasar автоген), `--prog-*`.
- Никаких локальных переопределений `.q-btn`/`.q-card`/`.q-notification` в feature-файлах — Quasar-overrides централизованы в `quasar-canon.css`.
**Кейс 2026-05-28:** при сомнении в каноне пошёл искать его во внешнем HTML/auth-prototype вместо репозитория — перевёрстал не туда, переделывал. SoT — репо (`tokens.css` + `_dev/ui`) и skill `/mono-desktop-canon`, не внешние файлы.
## Frontend desktop — English имена
В `components/desktop/` и любом Vue/TS frontend коде **все имена идентификаторов — английские**:
+12
View File
@@ -0,0 +1,12 @@
import { defineBuildConfig } from 'unbuild'
// Один dual-выход (ESM .mjs + CJS .cjs + .d.ts) обслуживает все три target'а:
// браузер и desktop-runtime берут import→.mjs, Node — require→.cjs (exports-map).
export default defineBuildConfig({
entries: ['src/index'],
declaration: true,
clean: true,
rollup: {
emitCJS: true,
},
})
+13
View File
@@ -0,0 +1,13 @@
// @ts-check
import antfu from '@antfu/eslint-config'
export default antfu(
{
type: 'lib',
rules: {
'eslint-comments/no-unlimited-disable': 'off',
'no-console': 'off',
'ts/explicit-function-return-type': 'off',
},
},
)
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@coopenomics/auth",
"type": "module",
"version": "2026.6.9-18",
"private": false,
"packageManager": "pnpm@9.9.0",
"description": "SDK аутентификации CoopID: OIDC, vault, кошелёк и подпись для браузера, Node и desktop",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "MIT",
"keywords": [],
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"typesVersions": {
"*": {
"*": [
"./dist/*",
"./dist/index.d.ts"
]
}
},
"files": [
"dist"
],
"scripts": {
"prebuild": "pnpm run typecheck",
"build": "unbuild",
"dev": "unbuild --stub",
"lint": "eslint .",
"test": "vitest run",
"test:node": "vitest run test/cross-runtime",
"test:browser": "vitest run --config vitest.config.browser.ts",
"test:electron": "node test/cross-runtime/run-electron.mjs",
"test:cross-runtime": "pnpm run build && pnpm run test:node && pnpm run test:browser && pnpm run test:electron",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@noble/hashes": "^1.8.0",
"@wharfkit/antelope": "^1.1.1",
"@wharfkit/session": "^1.4.0",
"cooptypes": "workspace:*",
"jose": "^6.2.2",
"oidc-client-ts": "^3.1.0"
},
"devDependencies": {
"@antfu/eslint-config": "^3.3.2",
"@types/node": "^22.5.4",
"@vitest/browser": "2.1.9",
"electron": "36.9.5",
"eslint": "^9.9.1",
"playwright": "1.59.1",
"typescript": "^5.5.4",
"unbuild": "^2.0.0",
"vite": "^5.4.3",
"vitest": "^2.0.5"
}
}
+194
View File
@@ -0,0 +1,194 @@
/**
* participant_certificate: чтение claims на клиенте (ЛК, Story 1.9) и offline-
* верификация (Story 4.4). Здесь — только ДЕКОДИРОВАНИЕ payload и производные
* (статус, человекочитаемые типы верификации). Проверка подписи против trust
* anchor — отдельно (verifyOffline, chain/), для отображения в ЛК не требуется.
*/
import { decodeJwt } from 'jose'
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
export * from './schema-policy'
export interface CoopChainLink {
account: string
public_key: string
}
/**
* Подтверждённый тип верификации в удостоверении (Story 4.3). Структурная форма
* для RP: что подтверждено (`type`), когда (`verified_at`, ISO-8601 UTC) и на каком
* основании (`source`).
*/
export interface VerificationTypeClaim {
type: string
verified_at: string
source: string
}
/** Claims participant_certificate (зеркало payload контроллера, Story 1.8). */
export interface ParticipantCertificateClaims {
iss: string
/** UUID пайщика */
sub: string
/** серийный номер удостоверения */
jti: string
iat: number
exp: number
coopname: string
coop_chain: CoopChainLink[]
verification_types: VerificationTypeClaim[]
identification: Record<string, unknown> | null
claim_schema_version: string
/** 152-ФЗ-обязательство RP удалить данные пайщика (Story 4.8), напр. `erase_on_exclusion`. */
data_retention_contract: string
/** Дедлайн удаления данных RP, unix-секунды (`iat + 30 дней`, Story 4.8). */
retention_deadline_ts: number
}
/** Нормализовать сырой claim verification_types в структурную форму (Story 4.3). */
function normalizeVerificationTypes(raw: unknown): VerificationTypeClaim[] {
if (!Array.isArray(raw))
return []
return raw
.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null && typeof (e as Record<string, unknown>).type === 'string')
.map(e => ({
type: String(e.type),
verified_at: String(e.verified_at ?? ''),
source: String(e.source ?? ''),
}))
}
export type CertificateStatus = 'active' | 'expiring' | 'expired'
/** Окно «истекает» до exp (24ч по умолчанию у сертификата; здесь — последний час). */
export const CERTIFICATE_EXPIRING_WINDOW_MS = 60 * 60 * 1000
/** Человекочитаемые описания типов верификации (claim `verification_types`). */
export const VERIFICATION_TYPE_LABELS: Record<string, string> = {
coop_baseline: 'Базовое подтверждение кооперативом',
}
/** Описание типа верификации; неизвестный — отдаём как есть (forward-compat). */
export function verificationTypeLabel(type: string): string {
return VERIFICATION_TYPE_LABELS[type] ?? type
}
/**
* Декодировать compact JWS в claims БЕЗ проверки подписи (для отображения в ЛК).
* Бросает AuthV2Error при структурно некорректном сертификате.
*/
export function decodeParticipantCertificate(jws: string): ParticipantCertificateClaims {
let raw: Record<string, unknown>
try {
raw = decodeJwt(jws) as Record<string, unknown>
}
catch {
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Некорректный participant_certificate: не удалось прочитать claims')
}
if (typeof raw.jti !== 'string' || typeof raw.exp !== 'number' || typeof raw.sub !== 'string')
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'participant_certificate без обязательных claims (jti/exp/sub)')
return {
iss: String(raw.iss ?? ''),
sub: raw.sub,
jti: raw.jti,
iat: Number(raw.iat ?? 0),
exp: raw.exp,
coopname: String(raw.coopname ?? ''),
coop_chain: Array.isArray(raw.coop_chain) ? (raw.coop_chain as CoopChainLink[]) : [],
verification_types: normalizeVerificationTypes(raw.verification_types),
identification: (raw.identification as Record<string, unknown> | null) ?? null,
claim_schema_version: String(raw.claim_schema_version ?? ''),
data_retention_contract: String(raw.data_retention_contract ?? ''),
retention_deadline_ts: Number(raw.retention_deadline_ts ?? 0),
}
}
/** Статус по сроку действия: expired / expiring (близко к exp) / active. */
export function certificateStatus(claims: Pick<ParticipantCertificateClaims, 'exp'>, nowMs: number = Date.now()): CertificateStatus {
const expMs = claims.exp * 1000
if (nowMs >= expMs)
return 'expired'
if (expMs - nowMs <= CERTIFICATE_EXPIRING_WINDOW_MS)
return 'expiring'
return 'active'
}
/**
* Окно упреждающего перезапроса сертификата (Story 4.6): за 5 минут до `exp` SDK
* молча обновляет удостоверение, чтобы у короткоживущего cert (дефолт 1ч) не было
* разрыва доступа на стыке.
*/
export const CERTIFICATE_RENEWAL_LEAD_MS = 5 * 60 * 1000
/**
* Сколько миллисекунд ждать до момента перезапроса (`exp − lead`). Никогда не
* отрицательно: если до `exp` осталось ≤ lead (или cert уже истёк) — 0 (перезапрос сразу).
*/
export function computeRenewalDelayMs(claims: Pick<ParticipantCertificateClaims, 'exp'>, nowMs: number = Date.now()): number {
const renewAtMs = claims.exp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS
return Math.max(0, renewAtMs - nowMs)
}
/** Управление запланированным авто-обновлением сертификата. */
export interface CertificateRenewalHandle {
/** Отменить запланированный перезапрос (например, при logout). */
cancel: () => void
}
/** Параметры планировщика — для подмены времени/таймеров в тестах. */
export interface ScheduleCertificateRenewalOptions {
now?: () => number
setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>
clearTimer?: (handle: ReturnType<typeof setTimeout>) => void
}
/**
* Запланировать silent renewal сертификата за 5 минут до `exp` (Story 4.6). По
* срабатыванию таймера вызывает `renew()` — хост-колбэк, который обновляет access
* через `refresh_token` и тянет свежий cert (`getParticipantCertificate`), — затем
* перепланирует от `exp` нового удостоверения. Кросс-рантайм: SDK владеет ЛОГИКОЙ
* планирования, а транспорт/refresh — на стороне приложения (как и весь fetch в SDK).
* Бесконечный цикл сам себя продлевает, пока не вызван `cancel()`. Ошибка `renew`
* не роняет процесс (логируется), но и НЕ перепланирует — повторную попытку инициирует хост.
*/
export function scheduleCertificateRenewal(
initialJws: string,
renew: () => Promise<string>,
options: ScheduleCertificateRenewalOptions = {},
): CertificateRenewalHandle {
const now = options.now ?? (() => Date.now())
const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms))
const clearTimer = options.clearTimer ?? (h => clearTimeout(h))
let timer: ReturnType<typeof setTimeout> | null = null
let cancelled = false
const scheduleFrom = (jws: string): void => {
if (cancelled)
return
const claims = decodeParticipantCertificate(jws)
const delay = computeRenewalDelayMs(claims, now())
timer = setTimer(() => {
void renew()
.then((nextJws) => {
if (!cancelled)
scheduleFrom(nextJws)
})
.catch(() => {
// best-effort: сбой обновления (нет сети/refresh истёк) не роняет цикл;
// повторную попытку инициирует приложение (например, при следующем действии).
})
}, delay)
}
scheduleFrom(initialJws)
return {
cancel: () => {
cancelled = true
if (timer !== null)
clearTimer(timer)
},
}
}
@@ -0,0 +1,90 @@
/**
* Политика версий схемы claims удостоверения на стороне SDK (Story 4.10).
* Зеркало публичной `/.well-known/coopid-schema-policy.json` контроллера: типы,
* сравнение версий и кэш с TTL 24ч. `verifyOffline` сверяет `claim_schema_version`
* проверяемого сертификата с минимально поддерживаемой версией из этого кэша.
*
* Кросс-рантайм: SDK владеет ЛОГИКОЙ кеша/сравнения; сам HTTP-fetch политики
* инжектируется хостом (как и весь fetch в SDK — chain/ остаётся чисто офлайновым).
*/
/** Путь публичной политики версий схемы у контроллера кооператива. */
export const SCHEMA_POLICY_WELL_KNOWN_PATH = '/.well-known/coopid-schema-policy.json'
/** TTL кэша политики: офлайн принимает закэшированную min-версию до 24 часов (FR72). */
export const SCHEMA_POLICY_CACHE_TTL_MS = 24 * 60 * 60 * 1000
export interface CoopIdSchemaPolicy {
current_version: string
min_supported_version: string
deprecation: Record<string, string>
}
/**
* Сравнить две версии схемы. Версии — целочисленные строки (`'0'`, `'1'`, …):
* сравниваем численно, при нечисловой версии — лексикографический фолбэк.
* `< 0` если a старее b, `0` если равны, `> 0` если a новее.
*/
export function compareSchemaVersions(a: string, b: string): number {
const na = Number(a)
const nb = Number(b)
if (Number.isFinite(na) && Number.isFinite(nb))
return na - nb
if (a === b)
return 0
return a < b ? -1 : 1
}
/** Поддерживается ли версия схемы (не старее минимально поддерживаемой). */
export function isSchemaVersionSupported(version: string, minSupported: string): boolean {
return compareSchemaVersions(version, minSupported) >= 0
}
export interface SchemaPolicyCacheOptions {
/** Хост-колбэк, тянущий политику с `SCHEMA_POLICY_WELL_KNOWN_PATH` (fetch — у приложения). */
fetchPolicy: () => Promise<CoopIdSchemaPolicy>
/** «Сейчас» в мс (инъекция для детерминизма/тестов). */
now?: () => number
/** TTL кэша в мс (по умолчанию 24ч). */
ttlMs?: number
}
export interface SchemaPolicyCache {
/** Политика из кэша (свежий fetch при истёкшем TTL; stale-кэш при офлайне). */
getPolicy: () => Promise<CoopIdSchemaPolicy>
/** Минимально поддерживаемая версия — для передачи в `verifyOffline`. */
getMinSupportedVersion: () => Promise<string>
}
/**
* Кэш политики версий схемы с TTL 24ч (Story 4.10, FR72). В пределах TTL отдаёт
* закэшированную политику без сети. По истечении TTL пробует обновить; если fetch
* упал (офлайн/недоступность) — отдаёт последнюю валидную политику (недоступность
* сети НЕ роняет офлайн-проверку; короткий cert TTL ограничивает риск устаревания).
* Нет кэша и fetch упал → пробрасывает ошибку (политику взять неоткуда).
*/
export function createSchemaPolicyCache(options: SchemaPolicyCacheOptions): SchemaPolicyCache {
const now = options.now ?? (() => Date.now())
const ttlMs = options.ttlMs ?? SCHEMA_POLICY_CACHE_TTL_MS
let cached: { policy: CoopIdSchemaPolicy, fetchedAt: number } | null = null
const getPolicy = async (): Promise<CoopIdSchemaPolicy> => {
if (cached && now() - cached.fetchedAt < ttlMs)
return cached.policy
try {
const policy = await options.fetchPolicy()
cached = { policy, fetchedAt: now() }
return policy
}
catch (error) {
if (cached)
return cached.policy
throw error
}
}
return {
getPolicy,
getMinSupportedVersion: async () => (await getPolicy()).min_supported_version,
}
}
+315
View File
@@ -0,0 +1,315 @@
import type { CoopChainLink } from '../certificate'
/**
* Цепочка доверия: офлайн-проверка participant_certificate walk'ом
* `ano → voskhod → vostok → participant` от embedded trust anchor.
* Реализация verify — Story 4.4; чтение ключей `cert` из COOPOS — здесь.
*/
import { APIClient } from '@wharfkit/antelope'
import { base64url } from 'jose'
import { compareSchemaVersions } from '../certificate/schema-policy'
import { TRUST_ANCHOR_ANO_CERT_PUBKEY } from '../config/trust-anchor'
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
/**
* Публичный ключ permission `cert` аккаунта в COOPOS (Story 1.3).
* Возвращает строку в формате Antelope (`PUB_K1_...`).
*/
export async function readCertPublicKey(rpcUrl: string, account: string): Promise<string> {
const client = new APIClient({ url: rpcUrl })
const acc = await client.v1.chain.get_account(account).catch((e: unknown) => {
throw new AuthV2Error(
AuthV2ErrorCode.NetworkError,
`COOPOS недоступен или аккаунт ${account} не найден: ${e instanceof Error ? e.message : String(e)}`,
)
})
const cert = acc.permissions.find(p => String(p.perm_name) === 'cert')
const key = cert?.required_auth.keys[0]?.key
if (!cert || !key) {
throw new AuthV2Error(
AuthV2ErrorCode.ChainVerificationFailed,
`На аккаунте ${account} нет permission cert с ключом — цепочка доверия не настроена (Story 1.3).`,
)
}
// MVP-инвариант: cert — строго single-key (multi-sig для ano — Growth).
// Молча взять keys[0] у multi-sig значило бы проверять подпись против
// одного из N ключей — это дыра в цепочке доверия.
const auth = cert.required_auth
if (Number(auth.threshold) !== 1 || auth.keys.length !== 1 || auth.accounts.length > 0 || auth.waits.length > 0) {
throw new AuthV2Error(
AuthV2ErrorCode.ChainVerificationFailed,
`Permission cert на ${account} не является single-key (threshold=${auth.threshold}, keys=${auth.keys.length}) — не поддерживается MVP-верификацией.`,
)
}
return key.toString()
}
export type VerifyOfflineReason =
| 'malformed_certificate' // не compact JWS / нет обязательных claims (coop_chain, exp)
| 'unsupported_alg' // alg ≠ ES256K
| 'expired' // exp в прошлом относительно now
| 'untrusted_anchor' // coop_chain не укоренён в известном trust-anchor `ano`
| 'untrusted_issuer' // звено цепи (в т.ч. издатель) не совпало с доверенным кэшем ключей
| 'signature_mismatch' // подпись не сходится с ключом издателя
| 'unsupported_schema_version' // claim_schema_version старее min_supported_version политики
export interface VerifyOfflineResult {
valid: boolean
/** Причина отказа, если valid=false. Офлайн-отзыв (revoked) — вне MVP (Story 4.7). */
reason?: VerifyOfflineReason
/** Аккаунт-издатель (последнее звено coop_chain), под чьим ключом сошлась подпись. */
issuer?: string
}
export interface VerifyOfflineOptions {
/**
* Офлайн-снимок доверенных cert-ключей известных кооперативов
* (`chain_manifests_cache`): `account → Antelope public_key`. Источник доверия —
* каждое звено `coop_chain` сертификата сверяется с этим набором (закрывает
* подделку «свой leaf-ключ + настоящий ano в root»). Без него издатель не
* подтверждается → `untrusted_issuer` (fail-closed). Наполнение кэша
* (manifest-sync) — отдельная задача.
*/
trustedKeys?: Record<string, string>
/**
* Доверенный якорь `ano.cert` (Antelope `PUB_K1_…`). По умолчанию —
* `trustedKeys['ano']`, затем вшитый release-pinned `TRUST_ANCHOR_ANO_CERT_PUBKEY`.
*/
trustAnchor?: string
/** «Сейчас» в мс для проверки exp (инъекция для детерминизма/тестов). */
now?: number
/**
* Минимально поддерживаемая версия схемы claims (Story 4.10). Резолвится хостом
* из кэша политики (`createSchemaPolicyCache().getMinSupportedVersion`, TTL 24ч).
* Если задана и `cert.claim_schema_version` старее неё → `unsupported_schema_version`.
* Не задана → ось версии схемы не гейтит (крипто/exp/цепь остаются fail-closed).
*/
minSchemaVersion?: string
}
/** Порядок группы secp256k1 (n) и его половина — для low-S нормализации подписи. */
const SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n
const SECP256K1_HALF_N = SECP256K1_N >> 1n
/**
* Нормализовать S подписи R||S (64 байта) к нижней половине порядка кривой.
* cert подписывает controller через jose+Node KeyObject, а Node-ECDSA может выдать
* high-S; wharfkit/noble verify по умолчанию отвергает high-S (`lowS:true`).
* Верификация secp256k1 инвариантна к `S ↔ n−S`, поэтому приведение к low-S делает
* подпись принимаемой независимо от каноничности подписанта.
*/
function normalizeLowS(rs: Uint8Array): Uint8Array {
let s = 0n
for (const byte of rs.slice(32, 64))
s = (s << 8n) | BigInt(byte)
if (s <= SECP256K1_HALF_N)
return rs
let low = SECP256K1_N - s
const out = new Uint8Array(rs) // копия R||S
for (let i = 63; i >= 32; i--) {
out[i] = Number(low & 0xFFn)
low >>= 8n
}
return out
}
interface CertHead { alg?: string }
interface CertPayload { coop_chain?: CoopChainLink[], exp?: number, claim_schema_version?: string }
/**
* Офлайн-валидация удостоверения `participant_certificate` без обращения к сети
* (Story 4.4; Vision: NFC-карты, бумажный QR). Проверяет структуру/alg/exp,
* укоренение `coop_chain` в trust-anchor `ano`, принадлежность звеньев доверенному
* кэшу ключей и подпись против ключа издателя (последнее звено `coop_chain`).
*
* Полностью офлайн и fail-closed: без доверенного якоря/кэша вердикт всегда
* `valid:false`. Отзыв ключа офлайн не проверяется (вне MVP — Story 4.7, Growth
* FR65); компенсируется коротким TTL (Story 4.6).
*/
export async function verifyOffline(certificate: string, options: VerifyOfflineOptions = {}): Promise<VerifyOfflineResult> {
const parts = certificate.split('.')
if (parts.length !== 3)
return { valid: false, reason: 'malformed_certificate' }
const [h, p, s] = parts
let head: CertHead
let payload: CertPayload
try {
head = JSON.parse(new TextDecoder().decode(base64url.decode(h)))
payload = JSON.parse(new TextDecoder().decode(base64url.decode(p)))
}
catch {
return { valid: false, reason: 'malformed_certificate' }
}
if (head.alg !== 'ES256K')
return { valid: false, reason: 'unsupported_alg' }
const chain = payload.coop_chain
if (!Array.isArray(chain) || chain.length === 0 || typeof payload.exp !== 'number')
return { valid: false, reason: 'malformed_certificate' }
const now = options.now ?? Date.now()
if (now >= payload.exp * 1000)
return { valid: false, reason: 'expired' }
// Версия схемы (Story 4.10): если хост передал минимально поддерживаемую версию
// (из кэша политики, TTL 24ч), отвергаем сертификаты старее неё — устаревшая схема
// claims не должна проходить как валидная. Без minSchemaVersion ось не гейтит.
if (options.minSchemaVersion !== undefined
&& compareSchemaVersions(payload.claim_schema_version ?? '0', options.minSchemaVersion) < 0) {
return { valid: false, reason: 'unsupported_schema_version' }
}
// Якорь: цепь обязана начинаться с известного `ano`.
const root = chain[0]
const anchor = options.trustAnchor ?? options.trustedKeys?.ano ?? TRUST_ANCHOR_ANO_CERT_PUBKEY
if (!anchor || root.account !== 'ano' || root.public_key !== anchor)
return { valid: false, reason: 'untrusted_anchor' }
// Звенья: при наличии кэша каждое звено должно совпасть с доверенным ключом.
if (options.trustedKeys) {
for (const link of chain) {
if (options.trustedKeys[link.account] !== link.public_key)
return { valid: false, reason: 'untrusted_issuer' }
}
}
else {
// Без кэша подтверждён только якорь; издателя доверять нельзя — fail-closed.
return { valid: false, reason: 'untrusted_issuer' }
}
// Подпись: против ключа издателя (последнее звено coop_chain, оно же `kid`).
const issuer = chain[chain.length - 1]
let rs: Uint8Array
try {
rs = base64url.decode(s)
}
catch {
return { valid: false, reason: 'malformed_certificate' }
}
if (rs.length !== 64)
return { valid: false, reason: 'malformed_certificate' }
const { PublicKey, Signature } = await import('@wharfkit/antelope')
const normalized = normalizeLowS(rs)
const signingInput = new TextEncoder().encode(`${h}.${p}`)
let ok = false
try {
const sig = Signature.from({ type: 'K1', r: normalized.slice(0, 32), s: normalized.slice(32, 64), recid: 0 })
ok = sig.verifyMessage(signingInput, PublicKey.from(issuer.public_key))
}
catch {
ok = false // некорректный ключ/подпись в сертификате — не валим исключением
}
if (!ok)
return { valid: false, reason: 'signature_mismatch' }
return { valid: true, issuer: issuer.account }
}
/**
* Окно активности одного публичного ключа подписанта в истории его аккаунта.
* Блоки, не время: смена authority в COOPOS привязана к блоку.
*/
export interface KeyValidityWindow {
/** публичный ключ в формате Antelope (`PUB_K1_...`) */
public_key: string
/** блок, с которого ключ стал активным (включительно) */
valid_from_block: number
/** блок, по который ключ был активным (включительно); null = активен сейчас */
valid_to_block: number | null
}
export type VerifyDocumentReason =
| 'malformed_jws'
| 'unsupported_alg'
| 'signature_mismatch' // ни один исторический ключ не даёт валидную подпись (подделка/чужой)
| 'key_not_active_at_signing' // подпись валидна под ключом, но он НЕ был активен на signedAtBlock
export interface VerifyDocumentParams {
/** compact JWS из `signDocument` (Story 2.3) */
jws: string
/**
* Блок, на который проверяется активность ключа — `last_irreversible_block_num`
* на момент `iat` подписи. Известен держателю документа (метаданные подписи).
*/
signedAtBlock: number
/**
* Историческая хронология ключей подписанта (offline-снимок из
* `chain_manifests_cache`). Наполнение кэша (ротация ключей / manifest-sync) —
* отдельная задача; здесь — чистая проверка по уже полученной хронологии.
*/
authority: KeyValidityWindow[]
}
export interface VerifyDocumentResult {
valid: boolean
reason?: VerifyDocumentReason
/** ключ, под которым подпись подтвердилась (если valid) */
matched_key?: string
}
/**
* Backward-валидность подписи документа через chain-walk (Story 2.5). Подпись,
* сделанная ключом пайщика ДО ротации, остаётся валидной: проверяем, что ключ был
* активен в момент подписи (`signedAtBlock` ∈ окно ключа), а не «активен сейчас».
* Так ротация ключа не аннулирует юридически значимые документы.
*
* Полностью офлайн: работает по переданной хронологии `authority` (кэш манифеста),
* без сети. Верификация — `verifyMessage` против ключей-кандидатов (а НЕ recover:
* `signDocument` отбрасывает recovery-байт, оставляя JWS-формат R||S).
*/
export async function verifyDocumentOffline(params: VerifyDocumentParams): Promise<VerifyDocumentResult> {
const parts = params.jws.split('.')
if (parts.length !== 3)
return { valid: false, reason: 'malformed_jws' }
const [h, p, s] = parts
let header: { alg?: string }
try {
header = JSON.parse(new TextDecoder().decode(base64url.decode(h)))
}
catch {
return { valid: false, reason: 'malformed_jws' }
}
if (header.alg !== 'ES256K')
return { valid: false, reason: 'unsupported_alg' }
let rs: Uint8Array
try {
rs = base64url.decode(s)
}
catch {
return { valid: false, reason: 'malformed_jws' }
}
if (rs.length !== 64)
return { valid: false, reason: 'malformed_jws' }
const { PublicKey, Signature } = await import('@wharfkit/antelope')
const signingInput = new TextEncoder().encode(`${h}.${p}`)
const sig = Signature.from({ type: 'K1', r: rs.slice(0, 32), s: rs.slice(32, 64), recid: 0 })
let signedUnderRotatedKey = false
for (const w of params.authority) {
let ok = false
try {
ok = sig.verifyMessage(signingInput, PublicKey.from(w.public_key))
}
catch {
ok = false // некорректный ключ в хронологии — пропускаем, не валим всю проверку
}
if (!ok)
continue
const active = params.signedAtBlock >= w.valid_from_block
&& (w.valid_to_block === null || params.signedAtBlock <= w.valid_to_block)
if (active)
return { valid: true, matched_key: w.public_key }
// подпись валидна, но этот ключ не был активен в момент подписи — продолжаем
// искать (вдруг другой ключ окна подходит); запоминаем для точной причины.
signedUnderRotatedKey = true
}
return {
valid: false,
reason: signedUnderRotatedKey ? 'key_not_active_at_signing' : 'signature_mismatch',
}
}
@@ -0,0 +1,7 @@
/**
* Публичные RPC-ноды COOPOS — fallback для чтения ключей/manifest'ов,
* когда нода кооператива недоступна (используется начиная со Story 9.5).
*
* TODO(Story 9.5): наполнить актуальным списком публичных нод сети.
*/
export const COOPOS_PUBLIC_NODES: readonly string[] = []
@@ -0,0 +1,13 @@
/**
* Embedded trust anchor цепочки доверия: публичный ключ `ano.cert` в
* Antelope-формате (`PUB_K1_…`, кривая secp256k1). Якорь вшивается в пакет
* (release-pinned), чтобы `verifyOffline()` укоренял `coop_chain` без сети.
*
* Формат — Antelope-строка, а не JWK: вся цепь (`coop_chain`, `readCertPublicKey`,
* wharfkit-верификация) оперирует `PUB_K1_…`; JWK был несравним со звеньями
* (реконсиляция скелета 1.2 ↔ реальности, Story 4.4).
*
* TODO(release): подставить реальный ключ после создания permission `cert` на
* аккаунте `ano` в COOPOS (hash-pinned bundle в consuming-приложениях, NFR).
*/
export const TRUST_ANCHOR_ANO_CERT_PUBKEY: string | null = null
+223
View File
@@ -0,0 +1,223 @@
/**
* Зеркало enum'а ошибок auth-v2 контроллера (источник истины — controller,
* появится в Story 1.11). Расширяется синхронно с серверной стороной.
*/
export enum AuthV2ErrorCode {
NotImplemented = 'not_implemented',
InvalidCredentials = 'invalid_credentials',
WeakPassword = 'weak_password',
SessionBindingReused = 'session_binding_reused',
SessionBindingExpired = 'session_binding_expired',
TimestampTooOld = 'timestamp_too_old',
VaultDecryptionFailed = 'vault_decryption_failed',
CertificateExpired = 'certificate_expired',
CertificateRevoked = 'certificate_revoked',
ChainVerificationFailed = 'chain_verification_failed',
CooposDegraded = 'coopos_degraded',
TooManyAttempts = 'too_many_attempts',
TooManyRecoveryAttempts = 'too_many_recovery_attempts',
InvalidTwoFactorCode = 'invalid_2fa_code',
TwoFactorNotEnrolled = 'two_factor_not_enrolled',
InvalidRecoveryToken = 'invalid_recovery_token',
InvalidOfflineCode = 'invalid_offline_code',
InsufficientVerification = 'insufficient_verification',
NetworkError = 'network_error',
WalletLocked = 'wallet_locked',
ClientWalletMismatch = 'client_wallet_mismatch',
/** Клиентский код: high-stakes-действие (экспорт удостоверения с PII) не подтверждено. */
ConsentRequired = 'consent_required',
}
/**
* Ошибки SDK в формате OAuth 2.0 ({ error, error_description }): код — машинный,
* description — человеко-читаемое сообщение для UI.
*/
export class AuthV2Error extends Error {
readonly code: AuthV2ErrorCode
constructor(code: AuthV2ErrorCode, description: string) {
super(description)
this.name = 'AuthV2Error'
this.code = code
}
toJSON(): { error: AuthV2ErrorCode, error_description: string } {
return { error: this.code, error_description: this.message }
}
}
/** Внутренний помощник для каркаса: единообразный отказ нереализованных методов. */
export function notImplemented(method: string): never {
throw new AuthV2Error(
AuthV2ErrorCode.NotImplemented,
`Метод ${method}() ещё не реализован: скелет SDK (Story 1.2), реализация приходит историями Эпиков 1–2.`,
)
}
/**
* Рекомендованное действие для UI при ошибке — определяет, какую кнопку/подсказку
* показать пайщику (Story 1.11).
*/
export type AuthV2ErrorAction =
| 'retry' // повторить ввод/вход
| 'recover' // предложить восстановление доступа («Восстановить доступ»)
| 'check_connection' // проверить интернет
| 'contact_support' // обратиться в кооператив
| 'none'
/** Описание ошибки для отображения: сообщение + действие + судьба сессии. */
export interface AuthV2ErrorView {
/** машинный код (null — неожиданная, не-типизированная ошибка) */
code: AuthV2ErrorCode | null
/** человеко-читаемое сообщение для UI */
message: string
/** что предложить пайщику */
action: AuthV2ErrorAction
/**
* Сохранять ли текущую сессию. `true` — НЕ разлогинивать (сбой временный/внешний:
* нет связи, кооператив недоступен, кошелёк заперт) — NFR20/FR50: оффлайн не
* выкидывает пайщика. `false` — сбой требует повторного входа.
*/
keepSession: boolean
}
type AuthV2ErrorViewBody = Omit<AuthV2ErrorView, 'code'>
/**
* Каталог человеко-читаемых сообщений по кодам ошибок (Story 1.11). Источник
* UI-текстов — здесь; контроллер отдаёт только машинный `error`, клиент по нему
* выбирает сообщение и actionable-подсказку. Тексты — по-русски, без раскрытия
* технических деталей (например, какое именно поле логина неверно — security).
*/
export const AUTH_V2_ERROR_VIEWS: Record<AuthV2ErrorCode, AuthV2ErrorViewBody> = {
[AuthV2ErrorCode.NotImplemented]: {
message: 'Функция пока недоступна.',
action: 'none',
keepSession: true,
},
[AuthV2ErrorCode.InvalidCredentials]: {
message: 'Неверный email или пароль.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.WeakPassword]: {
message: 'Пароль слишком простой. Используйте не менее 12 символов и не повторяйте типичные сочетания.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.SessionBindingReused]: {
message: 'Сессия входа уже использована. Войдите заново.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.SessionBindingExpired]: {
message: 'Время на подтверждение входа истекло. Войдите заново.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.TimestampTooOld]: {
message: 'Истекло время на подтверждение входа. Повторите попытку.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.VaultDecryptionFailed]: {
message: 'Не удалось расшифровать кошелёк. Попробуйте восстановить доступ.',
action: 'recover',
keepSession: false,
},
[AuthV2ErrorCode.CertificateExpired]: {
message: 'Срок действия удостоверения истёк. Войдите заново, чтобы обновить его.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.CertificateRevoked]: {
message: 'Удостоверение отозвано. Обратитесь в кооператив.',
action: 'contact_support',
keepSession: false,
},
[AuthV2ErrorCode.ChainVerificationFailed]: {
message: 'Не удалось подтвердить подпись. Обратитесь в поддержку кооператива.',
action: 'contact_support',
keepSession: false,
},
[AuthV2ErrorCode.CooposDegraded]: {
message: 'Кооператив временно недоступен. Повторите попытку позже.',
action: 'retry',
keepSession: true,
},
[AuthV2ErrorCode.TooManyAttempts]: {
message: 'Слишком много попыток. Подождите немного и попробуйте снова.',
action: 'retry',
// временный троттлинг — не разлогиниваем пайщика, просто просим подождать.
keepSession: true,
},
[AuthV2ErrorCode.TooManyRecoveryAttempts]: {
message: 'Слишком много запросов на восстановление. Подождите и попробуйте позже.',
action: 'retry',
keepSession: true,
},
[AuthV2ErrorCode.InvalidTwoFactorCode]: {
message: 'Неверный код из приложения-аутентификатора. Проверьте код и попробуйте снова.',
action: 'retry',
keepSession: true,
},
[AuthV2ErrorCode.TwoFactorNotEnrolled]: {
message: 'Второй фактор не подключён.',
action: 'retry',
keepSession: true,
},
[AuthV2ErrorCode.InvalidRecoveryToken]: {
message: 'Ссылка восстановления недействительна или истекла. Запросите восстановление заново.',
action: 'recover',
keepSession: false,
},
[AuthV2ErrorCode.InvalidOfflineCode]: {
message: 'Код восстановления неверен или уже использован.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.InsufficientVerification]: {
message: 'Недостаточный уровень верификации для этого действия. Обратитесь в кооператив.',
action: 'contact_support',
// авторизационное ограничение по уровню доверия — сессия валидна, не разлогиниваем.
keepSession: true,
},
[AuthV2ErrorCode.NetworkError]: {
message: 'Нет связи с кооперативом. Проверьте интернет.',
action: 'check_connection',
keepSession: true,
},
[AuthV2ErrorCode.WalletLocked]: {
message: 'Кошелёк заблокирован. Введите пароль для доступа к ключу.',
action: 'retry',
keepSession: true,
},
[AuthV2ErrorCode.ClientWalletMismatch]: {
message: 'Ключ в этом браузере не соответствует аккаунту. Войдите заново.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.ConsentRequired]: {
message: 'Для экспорта удостоверения нужно подтверждение.',
action: 'retry',
keepSession: true,
},
}
/** Безопасный фолбэк для неожиданной (не-AuthV2) ошибки — без утечки технических деталей. */
const GENERIC_ERROR_VIEW: AuthV2ErrorViewBody = {
message: 'Не удалось выполнить операцию. Попробуйте ещё раз.',
action: 'retry',
keepSession: true,
}
/**
* Превратить любую брошенную SDK ошибку в готовое к показу описание (Story 1.11):
* человеко-читаемое сообщение, рекомендованное действие и флаг сохранения сессии.
* Не-типизированные ошибки сводятся к безопасному фолбэку (не показываем `e.message`).
*/
export function describeAuthV2Error(error: unknown): AuthV2ErrorView {
if (error instanceof AuthV2Error)
return { code: error.code, ...AUTH_V2_ERROR_VIEWS[error.code] }
return { code: null, ...GENERIC_ERROR_VIEW }
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Экспорт удостоверения наружу (QR для Vision/предъявления). В MVP существует
* только ПОЛНАЯ форма (`full_certificate`) — она несёт PII пайщика, поэтому
* огорожена двумя барьерами (разблокированный vault + явное согласие).
*
* ROADMAP (Growth): анонимная форма `proof_of_membership` — доказательство членства
* БЕЗ PII, с коротким сроком (`exp ≤ 24ч`). Она появится отдельным методом
* `exportProofQR()`. В MVP его НЕТ намеренно: пока anonymous-формат не существует,
* вызвать `exportProofQR()` нельзя (ошибка компиляции) — чтобы по ошибке не выпустить
* PII-полный сертификат под видом anonymous.
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { isWalletUnlocked } from '../wallet'
export interface ExportFullQROptions {
/**
* Колбэк high-stakes-consent диалога: хост показывает пайщику явное подтверждение
* («вы экспортируете удостоверение с персональными данными») и резолвит `true`,
* только если пайщик согласился. `false`/reject → экспорт отклоняется.
*/
confirm: () => Promise<boolean>
}
/**
* Экспорт ПОЛНОГО participant_certificate (с PII) для предъявления/QR. Доступен
* только когда (1) vault разблокирован — экспортирует владелец активной сессии, не
* случайный держатель запертого устройства; (2) пайщик явно подтвердил high-stakes
* consent. Возвращает payload-байты удостоверения; кодирование в визуальный QR —
* на стороне хоста/Vision (SDK кросс-рантайм, QR-рендерер не тянет).
*/
export async function exportFullQR(certificate: string, options: ExportFullQROptions): Promise<Uint8Array> {
if (!isWalletUnlocked())
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Экспорт удостоверения возможен только при разблокированном кошельке')
const consented = await options.confirm().catch(() => false)
if (!consented)
throw new AuthV2Error(AuthV2ErrorCode.ConsentRequired, 'Экспорт удостоверения с персональными данными требует явного подтверждения')
return new TextEncoder().encode(certificate)
}
+49
View File
@@ -0,0 +1,49 @@
export type { CertificateRenewalHandle, CertificateStatus, CoopChainLink, CoopIdSchemaPolicy, ParticipantCertificateClaims, ScheduleCertificateRenewalOptions, SchemaPolicyCache, SchemaPolicyCacheOptions, VerificationTypeClaim } from './certificate'
export {
CERTIFICATE_EXPIRING_WINDOW_MS,
CERTIFICATE_RENEWAL_LEAD_MS,
certificateStatus,
compareSchemaVersions,
computeRenewalDelayMs,
createSchemaPolicyCache,
decodeParticipantCertificate,
isSchemaVersionSupported,
scheduleCertificateRenewal,
SCHEMA_POLICY_CACHE_TTL_MS,
SCHEMA_POLICY_WELL_KNOWN_PATH,
VERIFICATION_TYPE_LABELS,
verificationTypeLabel,
} from './certificate'
export type { KeyValidityWindow, VerifyDocumentParams, VerifyDocumentReason, VerifyDocumentResult, VerifyOfflineOptions, VerifyOfflineReason, VerifyOfflineResult } from './chain'
export { readCertPublicKey, verifyDocumentOffline, verifyOffline } from './chain'
/**
* @coopenomics/auth — SDK аутентификации CoopID.
*
* Публичная поверхность зафиксирована скелетом (Story 1.2): все методы
* типизированы и бросают AuthV2Error(not_implemented) до своей реализации.
*/
export { COOPOS_PUBLIC_NODES } from './config/coopos-public-nodes'
export { TRUST_ANCHOR_ANO_CERT_PUBKEY } from './config/trust-anchor'
export { AUTH_V2_ERROR_VIEWS, AuthV2Error, AuthV2ErrorCode, describeAuthV2Error } from './errors'
export type { AuthV2ErrorAction, AuthV2ErrorView } from './errors'
export type { ExportFullQROptions } from './export'
export { exportFullQR } from './export'
export type { MigrateParams } from './migration'
export { canonicalMigrationMessage, migrate } from './migration'
export type { FlowExecutorParams, HandshakeResult, LoginParams, LoginResult, LogoutParams, SessionTokens } from './oidc'
export { authenticateWithFlowExecutor, configureCoopId, configureOidc, configureTokenStorage, currentTokens, DEFAULT_AUTHENTICATION_FLOW, getAccessToken, getParticipantCertificate, login, loginWithMagicLink, logout, performTimestampHandshake, recover, restoreSession } from './oidc'
export type { SignDocumentParams, TimestampSignature } from './signing'
export { canonicalTimestampMessage, signChainDigest, signDocument, signTimestamp } from './signing'
export type { EncryptedVaultBlob, VaultSubject } from './vault'
export { ARGON2ID_PARAMS, decryptPrivateKey, deriveKey, encryptPrivateKey } from './vault'
export type { StorageAdapter } from './wallet'
export { clearLocalVault, clearPinCache, DEFAULT_PIN, fetchVaultBlob, getWallet, hasPinCache, isWalletUnlocked, loadLocalVault, lockWallet, persistPinCache, rotateKey, saveLocalVault, saveToVault, storeVaultBlob, unlockWallet, unlockWithPin, Wallet } from './wallet'
+90
View File
@@ -0,0 +1,90 @@
/**
* Миграция действующего пайщика «ключ → пароль» (Story 11.4), клиентская сторона.
*
* Пайщик сейчас владеет только WIF (легаси, без пароля). `migrate` доказывает
* владение ключом подписью метки времени (как легаси `login`), сервер проверяет
* её против он-чейн active-permission и ставит пароль в authentik (Story 11.1).
* Затем клиент шифрует ТОТ ЖЕ WIF новым паролём в vault (Story 11.3) — приватный
* ключ на сервер не уходит. Идемпотентно (повтор с тем же ключом/паролём безопасен).
*
* Подпись биндится к новому паролю через `pw_hash` в каноническом сообщении:
* перехваченный proof нельзя переиспользовать с другим паролём.
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { coopIdApiUrl } from '../oidc/client'
import { saveToVault, type StorageAdapter } from '../wallet'
export interface MigrateParams {
email: string
/** WIF, которым пайщик владеет сейчас (легаси-ключ из поля входа). */
privateKey: string
newPassword: string
/** Если задано — сохранить локальную копию зашифрованного vault'а на устройстве. */
storage?: StorageAdapter
}
/**
* Каноническое сообщение proof'а миграции — **зеркало** серверного
* `canonicalMigrationMessage` (controller). Ключи в фиксированном алфавитном
* порядке: клиент и сервер собирают строку байт-в-байт, иначе `recoverPublicKey`
* на сервере восстановит чужой ключ. `pw_hash` — sha256-hex нового пароля.
*/
export function canonicalMigrationMessage(payload: { ts: string, pw_hash: string }): string {
return JSON.stringify({ pw_hash: payload.pw_hash, purpose: 'coopid-key-migration', ts: payload.ts })
}
/** sha256-hex (lowercase) — байт-в-байт совпадает с серверным createHash('sha256').digest('hex'). */
async function sha256Hex(input: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('')
}
/** Разобрать OAuth2-ошибку контроллера ({ error, error_description }) в AuthV2Error. */
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
return new AuthV2Error((body?.error as AuthV2ErrorCode) ?? fallback, body?.error_description ?? fallbackMsg)
}
/**
* Выполнить миграцию «ключ → пароль». Возвращает `{ username }` (резолвится
* сервером по email; нужен как subject vault'а). Бросает `AuthV2Error`:
* InvalidCredentials (неверный ключ/email/подпись), WeakPassword, TimestampTooOld,
* CooposDegraded, NetworkError.
*/
export async function migrate(params: MigrateParams): Promise<{ username: string }> {
const apiUrl = coopIdApiUrl()
const ts = new Date().toISOString()
const message = canonicalMigrationMessage({ ts, pw_hash: await sha256Hex(params.newPassword) })
// Подпись текущим ключом (recoverable SIG_K1_). Невалидный WIF → понятная ошибка.
const { PrivateKey } = await import('@wharfkit/antelope')
let signature: string
try {
signature = PrivateKey.from(params.privateKey).signMessage(new TextEncoder().encode(message)).toString()
}
catch {
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Некорректный приватный ключ')
}
let res: Response
try {
res = await fetch(`${apiUrl}/coop/migration`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: params.email, timestamp: ts, signature, new_password: params.newPassword }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при миграции: ${e instanceof Error ? e.message : String(e)}`)
}
if (!res.ok)
throw await authErrorFromResponse(res, AuthV2ErrorCode.InvalidCredentials, `Миграция отклонена (HTTP ${res.status})`)
const body = (await res.json()) as { username?: string }
if (!body?.username)
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Миграция не вернула username')
// Зашифровать текущий WIF новым паролём → server vault (обязательно) + локальная копия.
await saveToVault({ apiUrl, account: body.username, privateKey: params.privateKey, password: params.newPassword, storage: params.storage })
return { username: body.username }
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Первый этап входа CoopID (Story 11.2) — password через authentik:
* (1) встроенная форма гонит email+password в flow-executor authentik и
* устанавливает сессию (`flow-executor.ts`); (2) `oidc-client-ts` выполняет
* `authorization_code`+PKCE МОЛЧА (`signinSilent`, prompt=none) — сессия уже есть,
* второго ввода пароля нет. Так клиент видит пароль (нужно для vault, Story 11.3),
* не нарушая FR29: запрещённый `grant_type=password` не используется, грант —
* стандартный authorization_code+PKCE (Implicit и ROPC запрещены, RFC 9700).
*
* Конфигурация (client_id/redirect_uri/scope, база controller'а) задаётся
* приложением на старте через `configureOidc`/`configureCoopId` — это среда-зависимые
* параметры (frontend кооператива), не зашитые в SDK.
*/
import { type User, UserManager, type UserManagerSettings } from 'oidc-client-ts'
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { authenticateWithFlowExecutor } from './flow-executor'
interface OidcClientConfig {
clientId: string
redirectUri: string
scope: string
postLogoutRedirectUri?: string
/** redirect_uri скрытого silent-iframe для prompt=none authorize (Story 11.2). */
silentRedirectUri?: string
}
let oidcConfig: OidcClientConfig | null = null
let coopApiUrl: string | null = null
/** Конфигурация OIDC-клиента authentik (вызывается приложением на старте). */
export function configureOidc(config: { clientId: string, redirectUri: string, scope?: string, postLogoutRedirectUri?: string, silentRedirectUri?: string }): void {
oidcConfig = {
clientId: config.clientId,
redirectUri: config.redirectUri,
scope: config.scope ?? 'openid profile',
postLogoutRedirectUri: config.postLogoutRedirectUri,
silentRedirectUri: config.silentRedirectUri,
}
}
/** База URL controller'а кооператива (для второго этапа: bind/verify/refresh). */
export function configureCoopId(config: { apiUrl: string }): void {
coopApiUrl = config.apiUrl.replace(/\/$/, '')
}
/** База controller'а или явная ошибка конфигурации. */
export function coopIdApiUrl(): string {
if (!coopApiUrl)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, 'CoopID не сконфигурирован: вызовите configureCoopId({ apiUrl }) на старте приложения')
return coopApiUrl
}
function userManager(issuer: string): UserManager {
if (!oidcConfig)
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'OIDC не сконфигурирован: вызовите configureOidc({ clientId, redirectUri }) на старте приложения')
const settings: UserManagerSettings = {
authority: issuer.replace(/\/$/, ''),
client_id: oidcConfig.clientId,
redirect_uri: oidcConfig.redirectUri,
silent_redirect_uri: oidcConfig.silentRedirectUri ?? oidcConfig.redirectUri,
scope: oidcConfig.scope,
post_logout_redirect_uri: oidcConfig.postLogoutRedirectUri,
response_type: 'code', // Authorization Code + PKCE; oidc-client-ts включает PKCE по умолчанию.
}
return new UserManager(settings)
}
/**
* Проводит первый этап входа и возвращает OIDC-User (id_token/access_token +
* установленная сессионная cookie authentik, нужная для bind). Story 11.2:
* (1) встроенная форма → flow-executor authentik устанавливает сессию (фактор 1);
* (2) `signinSilent` выполняет authorization_code+PKCE молча (сессия уже есть,
* prompt=none через скрытый iframe `silent_redirect_uri`) — без попапа и без
* повторного ввода пароля.
*/
export async function authenticateWithAuthentik(params: { issuer: string, email: string, password: string, flowSlug?: string }): Promise<User> {
// Фактор-1: учётные данные уходят в authentik (а не в наш backend) — ROPC не используется.
await authenticateWithFlowExecutor({ issuer: params.issuer, email: params.email, password: params.password, flowSlug: params.flowSlug })
// Грант FR29: code+PKCE поверх уже установленной сессии, без интерактивного UI.
const um = userManager(params.issuer)
let user: User | null
try {
user = await um.signinSilent()
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, `Не удалось завершить вход через authentik: ${e instanceof Error ? e.message : String(e)}`)
}
if (!user)
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'authentik не вернул сессию после ввода пароля')
return user
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Story 11.2 — встроенный фактор-1 входа CoopID через flow-executor authentik.
*
* Зачем НЕ popup/redirect: desktop рисует СВОЮ форму email+password и гонит
* учётные данные в интерактивный flow-API authentik. Так клиент пайщика ВИДИТ
* пароль — это нужно, чтобы шифровать им password-vault (Story 11.3); требование
* вынуждено zero-knowledge-инвариантом vault'а (сервер хранит шифр и расшифровать
* не может → ключ шифрования обязан быть клиентским секретом, единственный такой
* секрет — пароль). FR29 при этом соблюдается: запрещённый `grant_type=password`
* НЕ используется — грант остаётся `authorization_code`+PKCE и выполняется в
* `client.ts` уже ПОСЛЕ установки сессии (см. `authenticateWithAuthentik`).
*
* Топология (Эпик 5, Caddyfile): flow-API доступен same-origin по
* `{issuer.origin}/api/v3/flows/executor/<slug>/` — Caddy catch-all проксирует всё
* на authentik под доменом кооператива. `credentials:'include'` доставляет и
* устанавливает сессионную cookie authentik; CSRF-cookie (`authentik_csrf`) эхо-
* отправляется в заголовке `X-authentik-CSRF` (authentik требует его на unsafe-
* методах API). Same-origin критичен: кросс-домен CSRF-cookie не прочитать, а
* сессионная cookie не доедет до последующего authorize.
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
/** Slug стандартного flow аутентификации authentik (identification → password). */
export const DEFAULT_AUTHENTICATION_FLOW = 'default-authentication-flow'
/** Предохранитель от зацикливания на неожиданной последовательности стадий. */
const MAX_FLOW_STEPS = 8
export interface FlowExecutorParams {
/** Issuer кооператива (`https://coop.example/application/o/coopid/`); origin — база flow-API. */
issuer: string
email: string
password: string
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
flowSlug?: string
}
/** Снимок challenge'а flow-executor (берём только нужные поля; authentik отдаёт больше). */
interface FlowChallenge {
/** `native` | `redirect` | `shell` — `redirect` означает завершение flow (сессия установлена). */
type?: string
/** Web-компонент текущей стадии: `ak-stage-identification`, `ak-stage-password`, … */
component?: string
/** Пер-полевые ошибки валидации (неверный пароль → перерисованная стадия с этим полем). */
response_errors?: Record<string, Array<{ string?: string, code?: string }>>
}
/** Текущий CSRF-токен authentik из cookie (same-origin). В Node/тестах — пусто. */
function csrfHeader(): Record<string, string> {
if (typeof document === 'undefined' || !document.cookie)
return {}
const match = document.cookie.match(/(?:^|;\s*)authentik_csrf=([^;]+)/)
return match ? { 'X-authentik-CSRF': decodeURIComponent(match[1]) } : {}
}
/** URL шага flow-executor. `query` пуст: flow запускается напрямую (authorize делает client.ts). */
function flowUrl(base: string, slug: string): string {
return `${base}/api/v3/flows/executor/${encodeURIComponent(slug)}/?query=${encodeURIComponent('')}`
}
/** Завершение flow: сессия authentik установлена (redirect-терминал или стадия user-login). */
function isSuccess(c: FlowChallenge): boolean {
return c.type === 'redirect' || c.component === 'xak-flow-redirect' || c.component === 'ak-stage-user-login'
}
/** Доступ отклонён политикой flow (например, аккаунт неактивен). */
function isAccessDenied(c: FlowChallenge): boolean {
return c.component === 'ak-stage-access-denied'
}
/** Первое человеко-читаемое сообщение об ошибке валидации из challenge'а (если есть). */
function firstResponseError(c: FlowChallenge): string | null {
const groups = c.response_errors
if (!groups)
return null
for (const key of Object.keys(groups)) {
const msg = groups[key]?.find(e => e.string)?.string
if (msg)
return msg
}
return Object.keys(groups).length > 0 ? '' : null
}
async function getChallenge(url: string): Promise<FlowChallenge> {
let res: Response
try {
res = await fetch(url, { method: 'GET', credentials: 'include', headers: { accept: 'application/json', ...csrfHeader() } })
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на старте входа: ${e instanceof Error ? e.message : String(e)}`)
}
if (!res.ok)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `flow-executor (старт) вернул HTTP ${res.status}`)
return (await res.json()) as FlowChallenge
}
async function postChallenge(url: string, body: Record<string, unknown>): Promise<FlowChallenge> {
let res: Response
try {
res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json', 'accept': 'application/json', ...csrfHeader() },
body: JSON.stringify(body),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при отправке формы входа: ${e instanceof Error ? e.message : String(e)}`)
}
// 400 — authentik перерисовывает ту же стадию с `response_errors` (неверный пароль);
// это валидное тело challenge'а, читаем его. Остальные не-ok — сетевая/конфиг ошибка.
if (!res.ok && res.status !== 400)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `flow-executor вернул HTTP ${res.status}`)
return (await res.json()) as FlowChallenge
}
/**
* Проводит фактор-1 (email+password) через flow-executor authentik и устанавливает
* сессионную cookie. Возвращает `void` при успехе (сессия — побочный эффект cookie),
* бросает `AuthV2Error(InvalidCredentials)` при неверных учётных данных / отказе и
* `AuthV2Error(NetworkError)` при сетевом сбое.
*
* Поддерживаются стандартные стадии `ak-stage-identification` и `ak-stage-password`
* (в т.ч. совмещённая identification с `password_fields`). Интерактивные стадии
* вне scope встроенной формы (MFA-валидация, consent) → InvalidCredentials с
* пояснением: их обрабатывает отдельный эпик динамического 2FA.
*/
export async function authenticateWithFlowExecutor(params: FlowExecutorParams): Promise<void> {
const base = new URL(params.issuer).origin
const url = flowUrl(base, params.flowSlug ?? DEFAULT_AUTHENTICATION_FLOW)
let challenge = await getChallenge(url)
for (let step = 0; step < MAX_FLOW_STEPS; step++) {
if (isSuccess(challenge))
return
if (isAccessDenied(challenge))
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Доступ запрещён: проверьте email и пароль')
const err = firstResponseError(challenge)
if (err !== null)
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, err || 'Неверный email или пароль')
switch (challenge.component) {
case 'ak-stage-identification':
// Совмещённая identification может ждать и пароль (`password_fields`) — отдаём оба.
challenge = await postChallenge(url, { uid_field: params.email, password: params.password })
break
case 'ak-stage-password':
challenge = await postChallenge(url, { password: params.password })
break
default:
throw new AuthV2Error(
AuthV2ErrorCode.InvalidCredentials,
`Стадия входа «${challenge.component ?? challenge.type ?? 'неизвестно'}» не поддерживается встроенной формой`,
)
}
}
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Вход не завершён: превышено число шагов flow authentik')
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Второй этап входа CoopID — timestamp-signature handshake (Story 1.7), браузер-
* агностичная часть фасада логина. Предполагает уже установленную сессию authentik
* (cookie доезжает на bind через `credentials: 'include'`); первый этап (password,
* Authorization Code + PKCE через oidc-client-ts, FR29) делает `client.ts`.
*
* Поток: `/coop/session/bind` → `signTimestamp` (ключ берётся из keystore, наружу
* не выходит) → `/coop/verify/timestamp`. Полученные токены кладутся в сессию
* (`tokens.ts`) и возвращаются вызывающему.
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { signTimestamp } from '../signing'
import { setSession } from './tokens'
export interface HandshakeResult {
accessToken: string
refreshToken: string
/** compact JWS ES256K, выпускается controller'ом (Story 1.8); может отсутствовать. */
participantCertificate?: string
/** true → вход выдан в degraded-режиме (узел недоступен / ключ не финализирован, Story 9.6). */
degraded: boolean
degradedReason?: string
}
interface BindResponse {
binding_token: string
expires_in: number
}
interface VerifyResponse {
access_token: string
refresh_token: string
participant_certificate?: string
degraded?: boolean
degraded_reason?: string
}
/** Извлечь AuthV2Error из тела ответа контроллера (OAuth2-формат { error, error_description }). */
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
const code = (body?.error as AuthV2ErrorCode) ?? fallback
return new AuthV2Error(code, body?.error_description ?? fallbackMsg)
}
/**
* Выполняет handshake второго этапа и сохраняет сессию. `apiUrl` — база controller'а
* кооператива (например `https://coop.example`).
*/
export async function performTimestampHandshake(apiUrl: string): Promise<HandshakeResult> {
const base = apiUrl.replace(/\/$/, '')
// 1. bind: session_binding_token из тела (Эпик 7, D2). credentials:'include' —
// чтобы сессионная cookie authentik доехала до controller'а для резолва username.
let bindRes: Response
try {
bindRes = await fetch(`${base}/coop/session/bind`, { method: 'POST', credentials: 'include' })
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на этапе bind: ${e instanceof Error ? e.message : String(e)}`)
}
if (bindRes.status === 401 || bindRes.status === 403)
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Сессия authentik не подтверждена: пройдите первый этап входа (password)')
if (!bindRes.ok)
throw await authErrorFromResponse(bindRes, AuthV2ErrorCode.NetworkError, `bind вернул HTTP ${bindRes.status}`)
const bind = (await bindRes.json()) as BindResponse
if (!bind?.binding_token)
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'bind не вернул session_binding_token')
// 2. подпись канонической метки (jti/sub — из binding_token; ключ — из keystore,
// бросит WalletLocked, если кошелёк заперт).
const sig = await signTimestamp({ sessionBindingToken: bind.binding_token })
// 3. verify: доказательство владения ключом → платформенные токены + сертификат.
let verifyRes: Response
try {
verifyRes = await fetch(`${base}/coop/verify/timestamp`, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ signature: sig.signature, timestamp: sig.ts, binding_token: bind.binding_token }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на этапе verify: ${e instanceof Error ? e.message : String(e)}`)
}
if (!verifyRes.ok)
throw await authErrorFromResponse(verifyRes, AuthV2ErrorCode.ChainVerificationFailed, `verify вернул HTTP ${verifyRes.status}`)
const v = (await verifyRes.json()) as VerifyResponse
setSession(base, { accessToken: v.access_token, refreshToken: v.refresh_token })
return {
accessToken: v.access_token,
refreshToken: v.refresh_token,
participantCertificate: v.participant_certificate,
degraded: Boolean(v.degraded),
degradedReason: v.degraded_reason,
}
}
+248
View File
@@ -0,0 +1,248 @@
import type { VaultSubject } from '../vault/types'
import type { StorageAdapter } from '../wallet'
/**
* OIDC-слой: вход через authentik (password + timestamp-signature),
* magic-link, recovery и работа с токенами (oidc-client-ts).
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { encryptPrivateKey } from '../vault/encrypt'
import { lockWallet, saveLocalVault, unlockWallet } from '../wallet'
import { authenticateWithAuthentik, coopIdApiUrl } from './client'
import { performTimestampHandshake } from './handshake'
import { clearSession, getAccessToken as getStoredAccessToken } from './tokens'
export { authenticateWithAuthentik, configureCoopId, configureOidc } from './client'
export { authenticateWithFlowExecutor, DEFAULT_AUTHENTICATION_FLOW } from './flow-executor'
export type { FlowExecutorParams } from './flow-executor'
export type { HandshakeResult } from './handshake'
export { performTimestampHandshake } from './handshake'
export type { SessionTokens } from './tokens'
export { configureTokenStorage, currentTokens, restoreSession } from './tokens'
export interface LoginParams {
/** Issuer кооператива, например `https://coop.example/application/o/coopid/` */
issuer: string
email: string
/**
* Пароль пайщика (Story 11.2). Уходит во встроенную форму → flow-executor
* authentik (фактор 1), а не в наш backend — запрещённый FR29 `grant_type=password`
* не используется. Тем же паролем клиент шифрует password-vault (Story 11.3).
*/
password: string
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
flowSlug?: string
}
export interface LoginResult {
accessToken: string
idToken: string
/** compact JWS ES256K, выпускается controller'ом */
participantCertificate: string
}
/**
* Двухэтапный вход (Story 1.7, обновлён Story 11.2): (1) password через authentik —
* встроенная форма гонит email+password в flow-executor (сессия), затем
* `authorization_code`+PKCE молча (`signinSilent`); (2) timestamp-signature handshake
* против controller'а (bind → подпись ключом из keystore → verify). Перед вызовом
* кошелёк должен быть разблокирован (`unlockWallet`), иначе handshake бросит WalletLocked.
*
* База controller'а берётся из `configureCoopId({ apiUrl })`, OIDC-клиент — из
* `configureOidc({ clientId, redirectUri })` (вызываются приложением на старте).
*/
export async function login(params: LoginParams): Promise<LoginResult> {
const apiUrl = coopIdApiUrl()
// 1. password-этап: устанавливает сессию authentik (cookie) + отдаёт id_token.
const user = await authenticateWithAuthentik({ issuer: params.issuer, email: params.email, password: params.password, flowSlug: params.flowSlug })
// 2. timestamp-signature handshake: платформенные токены + удостоверение.
const handshake = await performTimestampHandshake(apiUrl)
return {
accessToken: handshake.accessToken,
idToken: user.id_token ?? '',
participantCertificate: handshake.participantCertificate ?? '',
}
}
/** Разобрать OAuth2-ошибку контроллера ({ error, error_description }) в AuthV2Error. */
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
return new AuthV2Error((body?.error as AuthV2ErrorCode) ?? fallback, body?.error_description ?? fallbackMsg)
}
export interface LoginWithMagicLinkParams {
/** Issuer кооператива — для повторного входа через authentik уже новым паролём. */
issuer: string
/** Email пайщика — фактор-1 повторной аутентификации в authentik после смены ключа. */
email: string
/** Magic-link токен из ссылки восстановления (или `recovery_token` offline-канала, Story 3.4). */
token: string
/** TOTP-код из приложения-аутентификатора — второй фактор подтверждения (Story 3.2/3.6). */
totp: string
/** Новый пароль: им шифруется новый vault и он же ставится в authentik (Story 12.1). */
newPassword: string
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
flowSlug?: string
/** Если задано — сохранить локальную копию нового зашифрованного vault'а на устройстве. */
storage?: StorageAdapter
}
/**
* Вход по magic-link (восстановление доступа) — полный confirm-флоу + повторный вход
* (Эпик 12, Story 12.2). Старый ключ пайщиком утрачен (на то и восстановление),
* поэтому клиент генерит НОВУЮ пару: приватный шифруется новым паролём в vault и
* наружу/на сервер не уходит, on-chain едет только публичный.
*
* Account на старте неизвестен — magic-link несёт только непрозрачный `token`. Но он и
* не нужен заранее: AAD vault'а больше не зависит от account (см. `vault/encrypt.ts`),
* а username для повторного входа отдаёт сам `confirm`, резолвнув его из токена. Так
* обходимся без отдельного whoami-by-token эндпоинта.
*
* Шаги: (1) сгенерировать пару; (2) зашифровать новый ключ новым паролём (AAD=тип
* субъекта, без account); (3) `POST /coop/recovery/confirm` {token, TOTP, public_key,
* vault, password} — сервер (12.1) ставит пароль в authentik, сохраняет vault под нужным
* account, ротирует active-ключ (`registrator::changekey`), отзывает старые сессии и
* возвращает `{ username }`; (4) повторный вход новым контуром: authentik-сессия новым
* паролём → `unlockWallet` по этому username (скачать только что записанный блоб →
* расшифровать → keystore) → timestamp-handshake. Ключ к этому моменту уже ротирован
* on-chain, поэтому verify увидит новый pubkey (при лаге узла handshake вернёт degraded,
* Story 9.6).
*/
export async function loginWithMagicLink(params: LoginWithMagicLinkParams): Promise<LoginResult> {
const apiUrl = coopIdApiUrl()
// 1. Новая пара ключей (старый утрачен). WIF в формате `5J…`/`5K…` — как везде в системе.
const { PrivateKey } = await import('@wharfkit/antelope')
const newKey = PrivateKey.generate('K1')
const newPrivateKey = newKey.toWif()
const newPublicKey = newKey.toPublic().toString()
// 2. Зашифровать новый ключ новым паролём. AAD = тип субъекта (`participant`), а не
// account: id в AAD не участвует (см. vault/encrypt.ts), поэтому шифруем, не зная
// username. Сервер сохранит блоб под нужным account сам (по recovery-токену).
const subject: VaultSubject = { subject_type: 'participant', subject_id: '' }
const vaultBlob = await encryptPrivateKey(newPrivateKey, params.newPassword, subject)
// 3. confirm: токен magic-link + TOTP + новый материал. Тело — контракт RecoveryConfirmBody.
let res: Response
try {
res = await fetch(`${apiUrl}/coop/recovery/confirm`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: params.token, code: params.totp, public_key: newPublicKey, vault: vaultBlob, password: params.newPassword }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при подтверждении восстановления: ${e instanceof Error ? e.message : String(e)}`)
}
if (res.status === 429)
throw new AuthV2Error(AuthV2ErrorCode.TooManyRecoveryAttempts, 'Слишком много попыток подтверждения, попробуйте позже')
if (!res.ok)
throw await authErrorFromResponse(res, AuthV2ErrorCode.InvalidRecoveryToken, `Подтверждение восстановления отклонено (HTTP ${res.status})`)
// confirm вернул account пайщика (резолвнут из токена) — по нему скачаем и
// расшифруем только что сохранённый сервером блоб при повторном входе.
const confirmed = (await res.json().catch(() => null)) as { username?: string } | null
if (!confirmed?.username)
throw new AuthV2Error(AuthV2ErrorCode.InvalidRecoveryToken, 'Подтверждение восстановления не вернуло аккаунт')
const account = confirmed.username
// 4. Локальная копия нового блоба (best-effort на устройстве восстановления).
if (params.storage)
await saveLocalVault(params.storage, account, vaultBlob)
// 5. Повторный вход новым контуром. unlockWallet забирает только что сохранённый
// серверный блоб и расшифровывает новым паролём — заодно round-trip-проверка vault'а.
const user = await authenticateWithAuthentik({ issuer: params.issuer, email: params.email, password: params.newPassword, flowSlug: params.flowSlug })
await unlockWallet({ apiUrl, account, password: params.newPassword })
const handshake = await performTimestampHandshake(apiUrl)
return {
accessToken: handshake.accessToken,
idToken: user.id_token ?? '',
participantCertificate: handshake.participantCertificate ?? '',
}
}
/**
* Запросить восстановление доступа (magic-link на email; по стратегии кооператива
* — также offline-код). Эпик 3, `POST /coop/recovery/request`. Анти-enumeration:
* сервер всегда отвечает 202 вне зависимости от существования аккаунта.
*/
export async function recover(email: string): Promise<void> {
const base = coopIdApiUrl()
let res: Response
try {
res = await fetch(`${base}/coop/recovery/request`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе восстановления: ${e instanceof Error ? e.message : String(e)}`)
}
// 202 — нормальный путь; иные коды (кроме rate-limit) — ошибка конфигурации/сети.
if (res.status === 429)
throw new AuthV2Error(AuthV2ErrorCode.TooManyRecoveryAttempts, 'Слишком много попыток восстановления, попробуйте позже')
if (!res.ok && res.status !== 202)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Запрос восстановления отклонён (HTTP ${res.status})`)
}
/** Текущий access_token (с автообновлением через refresh). Story 1.7. */
export async function getAccessToken(): Promise<string> {
return getStoredAccessToken()
}
/**
* Актуальное participant_certificate текущей сессии — compact JWS из
* `GET /coop/certificate` (Story 1.8). `accessToken` — платформенный токен входа
* (Bearer). Декодирование claims — `decodeParticipantCertificate` (certificate/).
*/
export async function getParticipantCertificate(apiUrl: string, accessToken: string): Promise<string> {
let res: Response
try {
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/certificate`, {
headers: { authorization: `Bearer ${accessToken}` },
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе удостоверения: ${e instanceof Error ? e.message : String(e)}`)
}
if (!res.ok)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось получить удостоверение (HTTP ${res.status})`)
const body = (await res.json()) as { participant_certificate: string }
return body.participant_certificate
}
export interface LogoutParams {
/** Базовый URL controller'а кооператива (например `https://coop.example`). */
apiUrl: string
/** refresh_token текущей сессии — отзывается на сервере. */
refreshToken: string
/** access_token (опционально) — тоже отзывается. */
accessToken?: string
}
/**
* RP-initiated logout (Story 1.10): отзыв токенов на сервере + затирание локального
* keystore. Серверный вызов — best-effort; локальное затирание ключа и сессии
* выполняется ВСЕГДА (в `finally`), даже если сервер недоступен — безопасность важнее
* «чистого» logout: расшифрованный ключ не должен остаться в памяти браузера при
* сетевом сбое. Редирект на login — на стороне вызывающего. Стандартный OIDC
* end-session — Story 5.1.
*/
export async function logout(params: LogoutParams): Promise<void> {
try {
await fetch(`${params.apiUrl.replace(/\/$/, '')}/coop/logout`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ refresh_token: params.refreshToken, access_token: params.accessToken }),
})
}
catch {
// best-effort: недоступность сервера не должна блокировать локальное затирание ключа
}
finally {
lockWallet()
clearSession()
}
}
+147
View File
@@ -0,0 +1,147 @@
import type { StorageAdapter } from '../wallet/storage-adapter'
/**
* Lifecycle платформенных токенов сессии CoopID (Эпик 7). Источник истины токена —
* этот модуль `@coopenomics/auth`; `@coopenomics/sdk` копирует access в свои
* GraphQL-заголовки (D1: bearer не покидает слой SDK, приложение его не трогает).
*
* Хранение — in-memory (как keystore ключа): по умолчанию живёт только в RAM
* вкладки. Персистентность между перезагрузками подключается приложением через
* `StorageAdapter` (frontend — IndexedDB), чтобы крипто-/токен-логика не зависела
* от среды и тестировалась без браузера.
*/
import { decodeJwt } from 'jose'
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
export interface SessionTokens {
accessToken: string
refreshToken: string
}
/** Запас до фактического exp, при котором access считаем «пора обновлять». */
const REFRESH_SKEW_SEC = 30
let tokens: SessionTokens | null = null
let apiBase: string | null = null
/**
* Опциональная персистентность токенов между перезагрузками (паритет с легаси,
* у которого токены лежат в IndexedDB). По умолчанию контур RAM-only; приложение
* подключает `StorageAdapter` (frontend — IndexedDB) через `configureTokenStorage`.
* Без этого CoopID-сессия не переживала бы F5 (ключ поднимается из PIN-кэша, а
* токен терялся бы), т.е. была бы СЛАБЕЕ легаси.
*/
const TOKEN_STORAGE_KEY = 'coopid.session.tokens'
let storage: StorageAdapter | null = null
interface PersistedSession {
apiBase: string
tokens: SessionTokens
}
/** Подключает (или снимает — `null`) персистентность токенов сессии. */
export function configureTokenStorage(adapter: StorageAdapter | null): void {
storage = adapter
}
/** Кладёт токены текущей сессии (вызывается из handshake/login/refresh). `apiUrl` — база controller'а для refresh. */
export function setSession(apiUrl: string, next: SessionTokens): void {
apiBase = apiUrl.replace(/\/$/, '')
tokens = next
// best-effort персист: сбой записи не должен ронять вход (токены уже в RAM).
if (storage)
void storage.set(TOKEN_STORAGE_KEY, JSON.stringify({ apiBase, tokens: next } satisfies PersistedSession)).catch(() => undefined)
}
/**
* Восстанавливает токены сессии из персистентного хранилища на старте приложения
* (после reload). `true` — сессия поднята в RAM. Уже активная RAM-сессия не
* перетирается. Без подключённого storage или записи — `false`.
*/
export async function restoreSession(): Promise<boolean> {
if (tokens && apiBase)
return true
if (!storage)
return false
const raw = await storage.get(TOKEN_STORAGE_KEY)
if (!raw)
return false
try {
const parsed = JSON.parse(raw) as PersistedSession
if (!parsed?.apiBase || !parsed?.tokens?.accessToken || !parsed?.tokens?.refreshToken)
return false
apiBase = parsed.apiBase
tokens = parsed.tokens
return true
}
catch {
return false
}
}
/** Затирает токены сессии (logout). Идемпотентно. Стирает и персистентную копию. */
export function clearSession(): void {
tokens = null
apiBase = null
if (storage)
void storage.remove(TOKEN_STORAGE_KEY).catch(() => undefined)
}
/** Снимок текущих токенов (или null, если сессии нет). */
export function currentTokens(): SessionTokens | null {
return tokens ? { ...tokens } : null
}
/** Истёк ли (или вот-вот истечёт) access-токен. Невалидный/без exp → считаем истёкшим. */
function accessExpired(token: string): boolean {
try {
const { exp } = decodeJwt(token)
if (typeof exp !== 'number')
return true
return exp - REFRESH_SKEW_SEC <= Math.floor(Date.now() / 1000)
}
catch {
return true
}
}
/**
* Обновляет пару токенов через REST `/coop/refresh` (то же зеркало платформенной
* токен-машинерии, что и legacy GraphQL-refresh). 401 → сессия истекла/отозвана.
*/
export async function refreshSession(base: string, refreshToken: string): Promise<SessionTokens> {
let res: Response
try {
res = await fetch(`${base}/coop/refresh`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при обновлении токена: ${e instanceof Error ? e.message : String(e)}`)
}
if (res.status === 401 || res.status === 403)
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'Сессия истекла: требуется повторный вход')
if (!res.ok)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось обновить токен (HTTP ${res.status})`)
const body = (await res.json()) as { access_token: string, refresh_token: string }
return { accessToken: body.access_token, refreshToken: body.refresh_token }
}
/**
* Текущий access_token с авто-обновлением через refresh (Story 1.7). Бросает, если
* активной сессии нет (нужно войти). Обновлённую пару кладёт обратно в сессию.
*/
export async function getAccessToken(): Promise<string> {
if (!tokens || !apiBase)
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Нет активной сессии: сначала выполните вход')
if (!accessExpired(tokens.accessToken))
return tokens.accessToken
const refreshed = await refreshSession(apiBase, tokens.refreshToken)
// setSession обновляет RAM И персистентную копию — иначе на диске остался бы
// устаревший refresh-токен и следующий reload поднял бы протухшую сессию.
setSession(apiBase, refreshed)
return refreshed.accessToken
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Подпись: документы (Story 2.3) и timestamp-метка второго этапа аутентификации
* (Story 2.4). Полностью локальные операции (без сети); подпись timestamp НЕ
* создаёт audit_events.
*/
import { base64url, decodeJwt } from 'jose'
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { currentView, readUnlockedKey } from '../wallet/storage'
export interface SignDocumentParams {
/** Содержимое документа: байты или строка (кодируется UTF-8). */
payload: Uint8Array | string
/** Алгоритм подписи; поддерживается ES256K (COOPOS-кривая secp256k1). */
alg?: 'ES256K'
}
/**
* Подпись дайджеста транзакции COOPOS ключом пайщика из keystore (мост подписи
* CoopID, Эпик 7). Приватный ключ НЕ покидает keystore — наружу уходит только
* готовая подпись `SIG_K1_…`. Это та же операция, что внутри
* `@wharfkit/wallet-plugin-privatekey`, но ключ берётся из RAM-keystore (2.2), а
* не из аргумента: десктопный `WalletPluginCoopId` делегирует сюда `sign()`, и
* wharfkit `Session.transact()` подписывает чужими руками, не зная ключа.
*
* Вход — hex-строка signing-дайджеста (`transaction.signingDigest(chainId)`),
* чтобы не тащить wharfkit-типы через границу пакета (иначе ловушка двойного
* `Checksum256` из разных копий antelope). Бросает `WalletLocked`, если заперт.
*/
export async function signChainDigest(digestHex: string): Promise<string> {
currentView() // бросает WalletLocked, если keystore заперт
const { PrivateKey, Checksum256 } = await import('@wharfkit/antelope')
const signature = PrivateKey.from(readUnlockedKey()).signDigest(Checksum256.from(digestHex))
return signature.toString()
}
export interface TimestampSignature {
ts: string
binding_token_jti: string
sub: string
signature: string
public_key: string
}
/**
* Каноническое сообщение, которое подписывается и которое сервер обязан собрать
* байт-в-байт для `recoverMessage` (`/coop/verify/timestamp`, Story 1.7). Ключи в
* фиксированном алфавитном порядке — детерминизм между клиентом и сервером.
* Экспортируется, чтобы controller переиспользовал ту же канонизацию.
*/
export function canonicalTimestampMessage(payload: { ts: string, binding_token_jti: string, sub: string }): string {
return JSON.stringify({ binding_token_jti: payload.binding_token_jti, sub: payload.sub, ts: payload.ts })
}
/**
* Локальная подпись документа ключом пайщика (Story 2.3). Возвращает compact JWS
* `<header>.<payload>.<signature>` (alg=ES256K). Полностью офлайн: без сети и без
* `audit_events` — сервер о подписи не знает (AC: подпись неоспорима и не зависит
* от состояния сервера).
*
* Подпись делается тем же кросс-рантайм secp256k1-примитивом, что и `signTimestamp`
* (`@wharfkit/antelope`): jose в браузере ES256K не умеет (WebCrypto без secp256k1),
* поэтому compact JWS собирается вручную, а K1-подпись сериализуется в JWS-формат
* R||S (recovery-байт отбрасывается — верификация по pubkey, не recover). `kid` в
* заголовке = COOPOS-аккаунт подписанта (разрешение ключа при верификации, Story 2.5).
*/
export async function signDocument(params: SignDocumentParams): Promise<string> {
const alg = params.alg ?? 'ES256K'
if (alg !== 'ES256K')
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, `Неподдерживаемый алгоритм подписи: ${alg}`)
const { account } = currentView() // бросает WalletLocked, если заперт
const wif = readUnlockedKey()
const payloadBytes = typeof params.payload === 'string'
? new TextEncoder().encode(params.payload)
: params.payload
const header = base64url.encode(JSON.stringify({ alg, kid: account }))
const payloadB64 = base64url.encode(payloadBytes)
const signingInput = `${header}.${payloadB64}`
const { PrivateKey } = await import('@wharfkit/antelope')
const sig = PrivateKey.from(wif).signMessage(new TextEncoder().encode(signingInput))
// K1-подпись сериализуется как [recovery(1), r(32), s(32)] = 65б; JWS ES256K = R||S
// (64б) — recovery-байт не нужен (верификация по pubkey, не recover).
const rs = sig.data.array.slice(1)
return `${signingInput}.${base64url.encode(rs)}`
}
/**
* Подпись метки времени для второго этапа auth (Story 2.4). Берёт `sub`/`jti` из
* активного `session_binding_token` (Эпик 1: читаем claims без верификации —
* подпись делает локальный ключ, доверие даёт сервер при verify), подписывает
* каноническое `{ ts, binding_token_jti, sub }` COOPOS-native recoverable
* подписью (SIG_K1_..., секрет — из keystore 2.2). Схема самоописана префиксом
* SIG_K1_ — alg не хардкодим. Результат уходит в `/coop/verify/timestamp`, где
* сервер `recoverMessage` → сверяет pubkey с COOPOS `get_account`.
*/
export async function signTimestamp(params: { sessionBindingToken: string }): Promise<TimestampSignature> {
let claims: { sub?: string, jti?: string }
try {
claims = decodeJwt(params.sessionBindingToken)
}
catch {
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'Некорректный session_binding_token: не удалось прочитать claims')
}
const sub = claims.sub
const jti = claims.jti
if (!sub || !jti)
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'session_binding_token без обязательных claims sub/jti')
// Кошелёк должен принадлежать тому же субъекту, что и токен (иначе подпишем
// метку чужим ключом — сервер всё равно отвергнет, но ловим раньше и понятнее).
const wallet = currentView() // бросает WalletLocked, если заперт
if (wallet.account !== sub)
throw new AuthV2Error(AuthV2ErrorCode.ClientWalletMismatch, `Разблокированный кошелёк (${wallet.account}) не совпадает с субъектом токена (${sub})`)
const ts = new Date().toISOString()
const message = canonicalTimestampMessage({ ts, binding_token_jti: jti, sub })
const { PrivateKey } = await import('@wharfkit/antelope')
const pk = PrivateKey.from(readUnlockedKey())
const signature = pk.signMessage(new TextEncoder().encode(message))
return {
ts,
binding_token_jti: jti,
sub,
signature: signature.toString(),
public_key: pk.toPublic().toString(),
}
}
+124
View File
@@ -0,0 +1,124 @@
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { deriveKey } from './kdf'
import type { EncryptedVaultBlob, VaultSubject } from './types'
const CIPHER_VERSION = 'aes-256-gcm-v1'
const KDF_VERSION = 'argon2id-v1'
const SALT_LEN = 16
const NONCE_LEN = 12
function toB64Url(bytes: Uint8Array): string {
let bin = ''
for (const b of bytes) bin += String.fromCharCode(b)
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export function fromB64Url(s: string): Uint8Array {
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'))
const out = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
return out
}
/**
* AAD GCM привязывает шифртекст к ТИПУ субъекта (`participant`/`coop`/…), но НЕ к
* конкретному account-id. Раньше было `${type}|${id}` — это требовало знать
* username ещё до confirm при восстановлении (Эпик 12), хотя сервер и так
* резолвит account из recovery-токена; так родился лишний whoami-by-token.
*
* Account-id убран из AAD намеренно (решение владельца 2026-06-15): пер-юзер
* привязку даёт пароль + случайная соль, лежащая в самом блобе; контролем доступа
* AAD здесь не был (блоб и так публично читается по account через `GET /coop/vault`).
* Поэтому теперь клиент шифрует новый ключ просто паролём, а account для
* последующей выборки/расшифровки блоба берёт из ответа confirm.
*/
function aad(subject: VaultSubject): string {
return subject.subject_type
}
/** WebCrypto в strict-TS требует ArrayBuffer-backed view; нормализуем Uint8Array. */
function buf(u: Uint8Array): ArrayBuffer {
return u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) as ArrayBuffer
}
/**
* Subject-агностичное ядро (Story 2.2): Argon2id(пароль, salt) → AES-256-GCM с
* произвольной AAD-строкой. Поверх него работает vault приватного ключа
* (AAD=субъект). PIN-слой (AAD=`pin|<account>`) снят в 11.8 (модель «без PIN»).
*/
export async function encryptWithPassword(
plaintext: string,
password: string,
additionalData: string,
): Promise<EncryptedVaultBlob> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN))
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN))
const keyBytes = deriveKey(password, salt)
const key = await crypto.subtle.importKey('raw', buf(keyBytes), { name: 'AES-GCM' }, false, ['encrypt'])
const sealed = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: buf(nonce), additionalData: buf(new TextEncoder().encode(additionalData)) },
key,
buf(new TextEncoder().encode(plaintext)),
),
)
// WebCrypto склеивает ciphertext+tag; tag GCM — последние 16 байт.
const tagLen = 16
const ciphertext = sealed.slice(0, sealed.length - tagLen)
const authTag = sealed.slice(sealed.length - tagLen)
return {
cipher_version: CIPHER_VERSION,
kdf_version: KDF_VERSION,
salt: toB64Url(salt),
nonce: toB64Url(nonce),
ciphertext: toB64Url(ciphertext),
auth_tag: toB64Url(authTag),
}
}
/** Парная расшифровка ядра; неверный пароль/AAD → `VaultDecryptionFailed`. */
export async function decryptWithPassword(
blob: EncryptedVaultBlob,
password: string,
additionalData: string,
): Promise<string> {
const keyBytes = deriveKey(password, fromB64Url(blob.salt))
const key = await crypto.subtle.importKey('raw', buf(keyBytes), { name: 'AES-GCM' }, false, ['decrypt'])
const sealed = new Uint8Array([...fromB64Url(blob.ciphertext), ...fromB64Url(blob.auth_tag)])
try {
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: buf(fromB64Url(blob.nonce)), additionalData: buf(new TextEncoder().encode(additionalData)) },
key,
buf(sealed),
)
return new TextDecoder().decode(plain)
} catch {
throw new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'Не удалось расшифровать: неверный пароль или повреждённые данные')
}
}
/**
* Клиентское шифрование приватного ключа пайщика (Story 2.1). Сервер получает
* только результат — расшифровать может лишь владелец пароля (AAD=субъект).
*/
export async function encryptPrivateKey(
privateKey: string,
password: string,
subject: VaultSubject,
): Promise<EncryptedVaultBlob> {
return encryptWithPassword(privateKey, password, aad(subject))
}
/**
* Локальная расшифровка (round-trip тесты и keystore 2.2). Серверу недоступна —
* type-driven ban (см. controller VaultService).
*/
export async function decryptPrivateKey(
blob: EncryptedVaultBlob,
password: string,
subject: VaultSubject,
): Promise<string> {
return decryptWithPassword(blob, password, aad(subject))
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Vault-слой: client-side шифрование/расшифровка приватного ключа
* (Argon2id + AES-256-GCM). Сервер хранит только зашифрованный блоб и не
* может его расшифровать (type-driven ban в controller VaultService).
*/
export type { EncryptedVaultBlob, VaultSubject } from './types'
export { ARGON2ID_PARAMS, deriveKey } from './kdf'
export { decryptPrivateKey, encryptPrivateKey, fromB64Url } from './encrypt'
+17
View File
@@ -0,0 +1,17 @@
import { argon2id } from '@noble/hashes/argon2'
/** Параметры Argon2id KDF (CoopID): зафиксированы версией `argon2id-v1`. */
export const ARGON2ID_PARAMS = {
m: 65536, // 64 MiB
t: 3,
p: 4,
dkLen: 32, // ключ AES-256
} as const
/**
* Деривация 32-байтного ключа из пароля и соли (Argon2id).
* Изоморфно: @noble/hashes работает в браузере, Node и desktop-runtime.
*/
export function deriveKey(password: string, salt: Uint8Array): Uint8Array {
return argon2id(new TextEncoder().encode(password), salt, ARGON2ID_PARAMS)
}
+15
View File
@@ -0,0 +1,15 @@
/** Субъект, к которому привязан зашифрованный блоб (AAD). */
export interface VaultSubject {
subject_type: 'participant' | 'coop' | 'council_action'
subject_id: string
}
/** Зашифрованный блоб vault'а: всё, что хранит и отдаёт сервер. base64url-поля. */
export interface EncryptedVaultBlob {
cipher_version: string
kdf_version: string
salt: string
nonce: string
ciphertext: string
auth_tag: string
}
+197
View File
@@ -0,0 +1,197 @@
import type { EncryptedVaultBlob, VaultSubject } from '../vault/types'
import type { StorageAdapter } from './storage-adapter'
/**
* Кошелёк (Story 2.2): разблокировка после логина, доступ к публичному «виду»
* ключа в памяти, запирание на logout. Приватный ключ живёт ТОЛЬКО в keystore
* (storage.ts) и наружу не выходит — `Wallet` его не содержит и не сериализует.
* Desktop-кошелёк переезжает на этот модуль (миграция — Эпик 7).
*/
import { AuthV2Error, AuthV2ErrorCode, notImplemented } from '../errors'
import { decryptPrivateKey, encryptPrivateKey } from '../vault/encrypt'
import { saveLocalVault } from './local-vault'
import { clearPinProtected, DEFAULT_PIN, hasPinProtected, loadPinProtected, savePinProtected } from './pin'
import { currentView, isUnlocked, readUnlockedKey, storeUnlocked, wipeKeystore } from './storage'
/**
* Несериализуемая обёртка кошелька: отдаёт только аккаунт и публичный ключ.
* Приватного ключа в объекте НЕТ — `JSON.stringify(wallet)` физически не может
* его раскрыть. Подпись (Stories 2.3/2.4) берёт ключ из keystore, не из Wallet.
*/
export class Wallet {
readonly account: string
readonly publicKey: string
constructor(view: { account: string, publicKey: string }) {
this.account = view.account
this.publicKey = view.publicKey
}
toJSON(): { account: string, publicKey: string } {
return { account: this.account, publicKey: this.publicKey }
}
}
/** Деривует публичный ключ из приватного (WIF K1) через WharfKit antelope. */
async function derivePublicKey(privateKey: string): Promise<string> {
// Ленивый импорт: тяжёлый antelope не тянем в bundle, пока кошелёк не разблокируют.
const { PrivateKey } = await import('@wharfkit/antelope')
try {
return PrivateKey.from(privateKey).toPublic().toString()
}
catch {
throw new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'Расшифрованное значение не является валидным приватным ключом')
}
}
/** GET зашифрованного blob'а пайщика с контроллера (blob-only, см. 2.2). */
export async function fetchVaultBlob(apiUrl: string, subjectId: string): Promise<EncryptedVaultBlob> {
let res: Response
try {
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/vault/participant/${encodeURIComponent(subjectId)}`)
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе vault: ${e instanceof Error ? e.message : e}`)
}
if (!res.ok)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось получить vault (HTTP ${res.status})`)
return res.json() as Promise<EncryptedVaultBlob>
}
/**
* POST зашифрованного blob'а на контроллер (Story 11.3, `POST /coop/vault`, 201).
* Тело — blob + плоские `subject_type`/`subject_id` (контракт `StoreVaultDto`).
* Сервер только сохраняет шифр и расшифровать его не может (type-ban в 2.1).
*/
export async function storeVaultBlob(apiUrl: string, subject: VaultSubject, blob: EncryptedVaultBlob): Promise<void> {
let res: Response
try {
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/vault`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...blob, subject_type: subject.subject_type, subject_id: subject.subject_id }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при сохранении vault: ${e instanceof Error ? e.message : e}`)
}
if (!res.ok)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось сохранить vault (HTTP ${res.status})`)
}
interface SaveToVaultParams {
apiUrl: string
account: string
privateKey: string
password: string
/** Если задано — дополнительно сохранить локальную копию зашифрованного blob'а. */
storage?: StorageAdapter
}
/**
* Шифрует приватный ключ паролём (Argon2id+AES-256-GCM, AAD=субъект) и сохраняет
* vault: на сервере (обязательно) и локально (если передан `storage`). Возвращает
* зашифрованный blob. Приватный ключ наружу/на сервер не уходит — только шифр.
* Используется миграцией «ключ→пароль» (Story 11.4) и сменой пароля.
*/
export async function saveToVault(params: SaveToVaultParams): Promise<EncryptedVaultBlob> {
const subject: VaultSubject = { subject_type: 'participant', subject_id: params.account }
const blob = await encryptPrivateKey(params.privateKey, params.password, subject)
await storeVaultBlob(params.apiUrl, subject, blob)
if (params.storage)
await saveLocalVault(params.storage, params.account, blob)
return blob
}
interface UnlockParams {
apiUrl: string
account: string
password: string
}
/**
* Полная разблокировка: забрать blob → расшифровать паролём на клиенте →
* положить ключ в keystore. При неверном пароле keystore остаётся пуст.
*/
export async function unlockWallet(params: UnlockParams): Promise<Wallet> {
const blob = await fetchVaultBlob(params.apiUrl, params.account)
const privateKey = await decryptPrivateKey(blob, params.password, {
subject_type: 'participant',
subject_id: params.account,
})
const publicKey = await derivePublicKey(privateKey)
storeUnlocked({ account: params.account, publicKey, privateKey })
return new Wallet({ account: params.account, publicKey })
}
/** Текущий разблокированный кошелёк. Бросает `WalletLocked`, если заперт. */
export async function getWallet(): Promise<Wallet> {
return new Wallet(currentView())
}
/** Заперт ли кошелёк (нет ключа в памяти). */
export function isWalletUnlocked(): boolean {
return isUnlocked()
}
/** Затирает ключ из памяти (logout). Идемпотентно. */
export function lockWallet(): void {
wipeKeystore()
}
/** Ротация ключа пайщика (updateauth + перешифровка vault). Story 3.3. */
export async function rotateKey(): Promise<void> {
notImplemented('rotateKey')
}
interface PersistPinParams {
/** ПИН для шифрования локального кэша; по умолчанию — `DEFAULT_PIN` ('000000', прозрачный). */
pin?: string
storage: StorageAdapter
}
/**
* Перешифровывает текущий разблокированный ключ ПИНом и кладёт в локальный кэш
* (уточнённая at-rest модель, см. `pin.ts`). Вызывается сразу после успешного
* `unlockWallet`/`migrate`, чтобы последующие входы шли по ПИН, а не по паролю.
* Бросает `WalletLocked`, если кошелёк заперт (нечего кэшировать). Ключ читается
* пакет-внутренней `readUnlockedKey()` и наружу не выходит — в кэш ложится шифр.
*/
export async function persistPinCache(params: PersistPinParams): Promise<void> {
const { account } = currentView() // бросает WalletLocked, если заперт
await savePinProtected(readUnlockedKey(), params.pin ?? DEFAULT_PIN, account, params.storage)
}
interface UnlockWithPinParams {
/** ПИН; по умолчанию — `DEFAULT_PIN` ('000000', прозрачная авто-разблокировка). */
pin?: string
storage: StorageAdapter
}
/**
* Разблокировка из локального PIN-кэша без round-trip к серверу и без пароля
* (reload устройства, авто-лок по простою). `null` — кэша нет (нужен полный вход
* `unlockWallet` паролём). Неверный ПИН → `VaultDecryptionFailed`. При успехе
* кладёт ключ в keystore и возвращает `Wallet`.
*/
export async function unlockWithPin(params: UnlockWithPinParams): Promise<Wallet | null> {
const loaded = await loadPinProtected(params.pin ?? DEFAULT_PIN, params.storage)
if (!loaded)
return null
const publicKey = await derivePublicKey(loaded.privateKey)
storeUnlocked({ account: loaded.account, publicKey, privateKey: loaded.privateKey })
return new Wallet({ account: loaded.account, publicKey })
}
/** Есть ли локальный PIN-кэш (выбор сценария разблокировки на загрузке). */
export async function hasPinCache(storage: StorageAdapter): Promise<boolean> {
return hasPinProtected(storage)
}
/** Удаляет локальный PIN-кэш («забыть устройство» / смена аккаунта). */
export async function clearPinCache(storage: StorageAdapter): Promise<void> {
await clearPinProtected(storage)
}
export { clearLocalVault, loadLocalVault, saveLocalVault } from './local-vault'
export { DEFAULT_PIN } from './pin'
export type { StorageAdapter } from './storage-adapter'
+46
View File
@@ -0,0 +1,46 @@
/**
* Локальная копия зашифрованного vault-блоба (Story 11.3). Хранит то же, что и
* сервер — Argon2id+AES-256-GCM ciphertext, бесполезный без пароля; расшифровать
* нельзя без знания пароля пайщика (zero-knowledge инвариант сохраняется и локально).
*
* Зачем дублировать серверную копию на устройстве: вход и подпись возможны без
* round-trip к контроллеру (офлайн/деградация узла), а также чтобы сразу после
* миграции (Story 11.4) ключ был доступен локально. Сервер остаётся источником
* истины и обязателен для входа с НОВОГО устройства (там локальной копии нет).
*
* Это НЕ расшифрованный ключ: расшифрованный WIF живёт только в RAM-keystore
* (`storage.ts`) и стирается на логауте (Story 11.8). Локально лежит лишь шифр.
*/
import type { EncryptedVaultBlob } from '../vault/types'
import type { StorageAdapter } from './storage-adapter'
const LOCAL_VAULT_KEY = 'coopid.wallet.vault'
interface LocalVaultRecord {
account: string
blob: EncryptedVaultBlob
}
/** Сохраняет (перезаписывает) локальную копию зашифрованного blob'а пайщика. */
export async function saveLocalVault(storage: StorageAdapter, account: string, blob: EncryptedVaultBlob): Promise<void> {
const record: LocalVaultRecord = { account, blob }
await storage.set(LOCAL_VAULT_KEY, JSON.stringify(record))
}
/**
* Возвращает локальную копию blob'а для `account` (или `null`, если записи нет /
* она от другого аккаунта). Подмена account → `null`: чужой blob не отдаём, даже
* расшифровать его без чужого пароля всё равно нельзя.
*/
export async function loadLocalVault(storage: StorageAdapter, account: string): Promise<EncryptedVaultBlob | null> {
const raw = await storage.get(LOCAL_VAULT_KEY)
if (!raw)
return null
const record = JSON.parse(raw) as LocalVaultRecord
return record.account === account ? record.blob : null
}
/** Удаляет локальную копию vault'а (смена аккаунта / «забыть устройство»). */
export async function clearLocalVault(storage: StorageAdapter): Promise<void> {
await storage.remove(LOCAL_VAULT_KEY)
}
+77
View File
@@ -0,0 +1,77 @@
import type { EncryptedVaultBlob } from '../vault/types'
import type { StorageAdapter } from './storage-adapter'
/**
* Локальный PIN-кэш приватного ключа (уточнённая модель CoopID, 2026-06-15 —
* СУПЕРДИТ «без PIN» из Story 11.8). Двухуровневая защита ключа:
*
* 1) Серверный vault ← ПАРОЛЬ (сложный, записан отдельно). Расшифровывается
* только при входе; повторно пароль не спрашиваем. Защита от кражи блоба.
* 2) Локальный кэш ← ПИН (этот модуль). После входа расшифрованный WIF
* перешифровывается ПИНом тем же Argon2id+AES-256-GCM и кладётся в
* `StorageAdapter`. Дальнейшие разблокировки (reload, авто-лок по простою) —
* ПИНом, НЕ паролём.
*
* Модель угроз: ПИН защищает не от похищения блоба (от него защищает пароль), а
* «от дурака» — постороннего/ребёнка за разблокированным устройством. ПИН по
* умолчанию `DEFAULT_PIN` ('000000') делает разблокировку прозрачной (ключ де-факто
* лежит локально с тривиальной защитой — осознанная плата за «не вводить пароль
* повторно»); кастомный ПИН поднимает планку до анти-«дурак» (6 цифр). AAD
* `pin|<account>` привязывает обёртку к аккаунту; account лежит в самой записи,
* поэтому знать его заранее не нужно (в отличие от серверного blob'а).
*/
import { decryptWithPassword, encryptWithPassword } from '../vault/encrypt'
export type { StorageAdapter } from './storage-adapter'
const STORAGE_KEY = 'coopid.wallet.pin-vault'
/** Стандартный «прозрачный» ПИН: разблокировка происходит автоматически и незаметно. */
export const DEFAULT_PIN = '000000'
function aad(account: string): string {
return `pin|${account}`
}
interface PinRecord {
account: string
blob: EncryptedVaultBlob
}
/** Сохраняет ключ под ПИН в storage (перезаписывает предыдущую запись). */
export async function savePinProtected(
privateKey: string,
pin: string,
account: string,
storage: StorageAdapter,
): Promise<void> {
const blob = await encryptWithPassword(privateKey, pin, aad(account))
const record: PinRecord = { account, blob }
await storage.set(STORAGE_KEY, JSON.stringify(record))
}
/**
* Восстанавливает ключ из storage по ПИН. `null` — записи нет. Неверный ПИН →
* `VaultDecryptionFailed` (из ядра). Возвращает `{account, privateKey}` — account
* берётся из самой записи, знать его до разблокировки не требуется.
*/
export async function loadPinProtected(
pin: string,
storage: StorageAdapter,
): Promise<{ account: string, privateKey: string } | null> {
const raw = await storage.get(STORAGE_KEY)
if (!raw)
return null
const record = JSON.parse(raw) as PinRecord
const privateKey = await decryptWithPassword(record.blob, pin, aad(record.account))
return { account: record.account, privateKey }
}
/** Есть ли локальный PIN-кэш (без расшифровки — для выбора сценария разблокировки). */
export async function hasPinProtected(storage: StorageAdapter): Promise<boolean> {
return (await storage.get(STORAGE_KEY)) !== null
}
/** Удаляет PIN-запись (logout с «забыть устройство» / смена аккаунта). */
export async function clearPinProtected(storage: StorageAdapter): Promise<void> {
await storage.remove(STORAGE_KEY)
}
@@ -0,0 +1,13 @@
/**
* Подключаемый async key-value стор для персистентных артефактов кошелька
* (локальная копия vault'а — Story 11.3). PIN-слой снят в 11.8 (модель «без PIN»).
*
* Абстракция нужна для кросс-рантайма (NFR26): в браузере — обёртка над
* localStorage/IndexedDB, в desktop-runtime — своя реализация, в Node/тестах —
* память. Крипто и логика хранения от среды не зависят.
*/
export interface StorageAdapter {
get: (key: string) => Promise<string | null>
set: (key: string, value: string) => Promise<void>
remove: (key: string) => Promise<void>
}
+55
View File
@@ -0,0 +1,55 @@
/**
* In-memory keystore (Story 2.2). ЕДИНСТВЕННОЕ место, где живёт расшифрованный
* приватный ключ пайщика — module-private переменная, только в RAM процесса/вкладки.
*
* Публичная поверхность SDK (`getWallet()`) приватный ключ НЕ отдаёт: signing-модули
* (Stories 2.3/2.4) берут его через пакет-внутреннюю `readUnlockedKey()`. На logout —
* `wipeKeystore()` затирает ключ (перезапись + сброс ссылки).
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
interface UnlockedEntry {
account: string
publicKey: string
privateKey: string
}
/** Открытое состояние сессии. null = заперто. */
let unlocked: UnlockedEntry | null = null
/** Кладёт расшифрованный ключ в keystore (вызывается из unlockWallet). */
export function storeUnlocked(entry: UnlockedEntry): void {
unlocked = entry
}
/** Заперт ли кошелёк (нет расшифрованного ключа в памяти). */
export function isUnlocked(): boolean {
return unlocked !== null
}
/** Публичный «вид» кошелька без приватного ключа. Бросает, если заперто. */
export function currentView(): { account: string, publicKey: string } {
if (!unlocked)
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Кошелёк заперт: сначала вызовите unlockWallet()')
return { account: unlocked.account, publicKey: unlocked.publicKey }
}
/**
* Пакет-внутренний доступ к приватному ключу для signing-модулей (2.3/2.4).
* НЕ реэкспортируется из index.ts — наружу ключ не выходит.
*/
export function readUnlockedKey(): string {
if (!unlocked)
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Кошелёк заперт: подпись невозможна без unlockWallet()')
return unlocked.privateKey
}
/** Затирает ключ из памяти (logout / lockWallet). Идемпотентно. */
export function wipeKeystore(): void {
if (unlocked) {
// Перезаписываем строку-ключ перед сбросом ссылки, чтобы уменьшить окно жизни
// в куче (JS-строки иммутабельны — гарантий нет, но снимаем прямую ссылку).
unlocked.privateKey = '\0'.repeat(unlocked.privateKey.length)
unlocked = null
}
}
@@ -0,0 +1,89 @@
import { Buffer } from 'node:buffer'
import { describe, expect, it, vi } from 'vitest'
import {
CERTIFICATE_RENEWAL_LEAD_MS,
computeRenewalDelayMs,
scheduleCertificateRenewal,
} from '../src/certificate'
/** Собрать декодируемый compact-JWS с нужными claims (подпись не проверяется при decode). */
function fakeCert(claims: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'ES256K', typ: 'JWT' })).toString('base64url')
const payload = Buffer.from(JSON.stringify({ jti: 'serial-1', sub: 'uuid-1', ...claims })).toString('base64url')
return `${header}.${payload}.sig`
}
describe('computeRenewalDelayMs', () => {
it('обычный кейс: задержка до exp − lead', () => {
const nowMs = 1_000_000
const exp = Math.floor(nowMs / 1000) + 3600 // +1ч
const delay = computeRenewalDelayMs({ exp }, nowMs)
expect(delay).toBe(3600 * 1000 - CERTIFICATE_RENEWAL_LEAD_MS)
})
it('до exp осталось ≤ lead → 0 (перезапрос немедленно)', () => {
const nowMs = 1_000_000
const exp = Math.floor(nowMs / 1000) + 120 // 2 мин < lead (5 мин)
expect(computeRenewalDelayMs({ exp }, nowMs)).toBe(0)
})
it('cert уже истёк → 0', () => {
const nowMs = 1_000_000
const exp = Math.floor(nowMs / 1000) - 10
expect(computeRenewalDelayMs({ exp }, nowMs)).toBe(0)
})
})
describe('scheduleCertificateRenewal', () => {
it('срабатывает в exp − lead, зовёт renew и перепланирует от нового cert', async () => {
let currentNow = 0
const timers: Array<{ cb: () => void, delay: number }> = []
const setTimer = vi.fn((cb: () => void, delay: number) => {
timers.push({ cb, delay })
return timers.length as any
})
const clearTimer = vi.fn()
const firstExp = 3600 // сек
const secondExp = 7200
const first = fakeCert({ exp: firstExp })
const second = fakeCert({ exp: secondExp })
const renew = vi.fn().mockResolvedValue(second)
scheduleCertificateRenewal(first, renew, { now: () => currentNow, setTimer, clearTimer })
// первый таймер запланирован на exp − lead
expect(setTimer).toHaveBeenCalledTimes(1)
expect(timers[0].delay).toBe(firstExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS)
// время дошло до срабатывания → дёргаем callback
currentNow = firstExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS
timers[0].cb()
await Promise.resolve()
await Promise.resolve()
expect(renew).toHaveBeenCalledTimes(1)
// перепланирование от exp нового сертификата
expect(setTimer).toHaveBeenCalledTimes(2)
expect(timers[1].delay).toBe(secondExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS - currentNow)
})
it('cancel() отменяет запланированный таймер и не перепланирует', async () => {
const timers: Array<{ cb: () => void, delay: number }> = []
const setTimer = vi.fn((cb: () => void, delay: number) => {
timers.push({ cb, delay })
return timers.length as any
})
const clearTimer = vi.fn()
const renew = vi.fn().mockResolvedValue(fakeCert({ exp: 7200 }))
const handle = scheduleCertificateRenewal(fakeCert({ exp: 3600 }), renew, { now: () => 0, setTimer, clearTimer })
handle.cancel()
expect(clearTimer).toHaveBeenCalledTimes(1)
// даже если таймер «выстрелит» после cancel — renew не перепланирует
timers[0].cb()
await Promise.resolve()
expect(setTimer).toHaveBeenCalledTimes(1)
})
})
+127
View File
@@ -0,0 +1,127 @@
import { SignJWT } from 'jose'
import { describe, expect, it } from 'vitest'
import {
CERTIFICATE_EXPIRING_WINDOW_MS,
certificateStatus,
decodeParticipantCertificate,
verificationTypeLabel,
} from '../src/certificate'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
async function makeCert(overrides: Record<string, unknown> = {}, opts: { jti?: string, exp?: string } = {}): Promise<string> {
let b = new SignJWT({
coopname: 'voskhod',
coop_chain: [
{ account: 'ano', public_key: 'PUB_K1_ano' },
{ account: 'voskhod', public_key: 'PUB_K1_vos' },
{ account: 'vostok', public_key: 'PUB_K1_vostok' },
],
verification_types: [{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' }],
identification: { type: 'individual', username: 'ant', first_name: 'Иван' },
claim_schema_version: '1',
...overrides,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuer('https://voskhod.coop')
.setSubject('uuid-1')
.setIssuedAt()
.setExpirationTime(opts.exp ?? '24h')
if (opts.jti !== undefined || overrides.jti === undefined)
b = b.setJti(opts.jti ?? 'serial-123')
return b.sign(new TextEncoder().encode('test-secret-padding-000000000000000000'))
}
describe('decodeParticipantCertificate', () => {
it('декодирует все claims в типизированный объект', async () => {
const claims = decodeParticipantCertificate(await makeCert())
expect(claims.sub).toBe('uuid-1')
expect(claims.jti).toBe('serial-123')
expect(claims.coopname).toBe('voskhod')
expect(claims.claim_schema_version).toBe('1')
expect(claims.verification_types).toEqual([
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
])
expect(claims.coop_chain.map(l => l.account)).toEqual(['ano', 'voskhod', 'vostok'])
expect(claims.identification).toMatchObject({ type: 'individual', username: 'ant' })
expect(claims.exp).toBeGreaterThan(claims.iat)
})
it('identification может быть null', async () => {
const claims = decodeParticipantCertificate(await makeCert({ identification: null }))
expect(claims.identification).toBeNull()
})
it('retention-claims читаются (Story 4.8)', async () => {
const claims = decodeParticipantCertificate(await makeCert({
data_retention_contract: 'erase_on_exclusion',
retention_deadline_ts: 1800000000,
}))
expect(claims.data_retention_contract).toBe('erase_on_exclusion')
expect(claims.retention_deadline_ts).toBe(1800000000)
})
it('retention-claims отсутствуют → безопасные дефолты (Story 4.8)', async () => {
const claims = decodeParticipantCertificate(await makeCert())
expect(claims.data_retention_contract).toBe('')
expect(claims.retention_deadline_ts).toBe(0)
})
it('verification_types: структурные записи сохраняются, мусор отбрасывается (Story 4.3)', async () => {
const claims = decodeParticipantCertificate(await makeCert({
verification_types: [
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
'legacy_string',
{ source: 'no_type' },
],
}))
expect(claims.verification_types).toEqual([
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
])
})
it('не-JWT строка → AuthV2Error(chain_verification_failed)', () => {
let err: unknown
try {
decodeParticipantCertificate('garbage')
}
catch (e) {
err = e
}
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ChainVerificationFailed)
})
it('без обязательных claims (нет jti) → AuthV2Error', async () => {
// токен без jti
const noJti = await new SignJWT({ coopname: 'voskhod' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject('uuid-1')
.setExpirationTime('24h')
.sign(new TextEncoder().encode('test-secret-padding-000000000000000000'))
expect(() => decodeParticipantCertificate(noJti)).toThrow(AuthV2Error)
})
})
describe('certificateStatus', () => {
const now = 1_900_000_000_000 // фиксированный «сейчас» (мс)
it('exp в прошлом → expired', () => {
expect(certificateStatus({ exp: now / 1000 - 10 }, now)).toBe('expired')
})
it('exp в окне «истекает» → expiring', () => {
const exp = (now + CERTIFICATE_EXPIRING_WINDOW_MS - 1000) / 1000
expect(certificateStatus({ exp }, now)).toBe('expiring')
})
it('exp далеко → active', () => {
const exp = (now + CERTIFICATE_EXPIRING_WINDOW_MS + 10 * 60 * 1000) / 1000
expect(certificateStatus({ exp }, now)).toBe('active')
})
})
describe('verificationTypeLabel', () => {
it('известный тип → человекочитаемое описание', () => {
expect(verificationTypeLabel('coop_baseline')).toBe('Базовое подтверждение кооперативом')
})
it('неизвестный тип → возвращается как есть', () => {
expect(verificationTypeLabel('future_kyc_x')).toBe('future_kyc_x')
})
})
+29
View File
@@ -0,0 +1,29 @@
/**
* Интеграционный тест Story 1.3: SDK читает publickey permission `cert` из COOPOS.
* Требует живую дев-ноду: COOPID_TEST_RPC=http://127.0.0.1:8908 pnpm test
* Без env — скипается (юнит-прогоны CI не зависят от цепи).
*/
import { describe, expect, it } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import { readCertPublicKey } from '../src/index'
const RPC = process.env.COOPID_TEST_RPC
describe.skipIf(!RPC)('readCertPublicKey — против дев-сети COOPOS', () => {
it.each(['vostok', 'voskhod', 'ano'])('читает ключ cert у %s', async (account) => {
const key = await readCertPublicKey(RPC!, account)
expect(key).toMatch(/^(PUB_K1_|EOS)/)
})
it('аккаунт без cert → ChainVerificationFailed', async () => {
const err = await readCertPublicKey(RPC!, 'eosio').then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ChainVerificationFailed)
})
it('несуществующий аккаунт → NetworkError', async () => {
const err = await readCertPublicKey(RPC!, 'nonexistacct').then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.NetworkError)
})
})
@@ -0,0 +1,71 @@
/**
* Electron-таргет cross-runtime smoke (Story 9.13): те же проверки, что в
* smoke.test.ts, но против СОБРАННОГО артефакта (dist/index.cjs — то, что
* реально получают клиенты) в main-process electron. Запуск — через
* run-electron.mjs (`pnpm test:electron`), не напрямую.
*
* Без vitest: минимальный assert + exit-код, чтобы не тащить тест-раннер в
* electron. Сценарии держать синхронными со smoke.test.ts.
*/
const { app } = require('electron')
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PW = 'correct-horse-battery-staple-12'
const ACCOUNT = 'ant'
function assert(cond, name) {
if (!cond)
throw new Error(`smoke-проверка провалена: ${name}`)
console.log(`[electron-smoke] OK ${name}`)
}
async function run() {
const sdk = require('../../dist/index.cjs')
// vault round-trip + unlockWallet (WebCrypto AES-GCM + Argon2id в main-process)
const blob = await sdk.encryptPrivateKey(KEY, PW, { subject_type: 'participant', subject_id: ACCOUNT })
globalThis.fetch = async () =>
new Response(JSON.stringify(blob), { status: 200, headers: { 'content-type': 'application/json' } })
const wallet = await sdk.unlockWallet({ apiUrl: 'https://coop.stub', account: ACCOUNT, password: PW })
assert(wallet.account === ACCOUNT && wallet.publicKey.startsWith('PUB_K1_'), 'vault round-trip + unlockWallet')
assert(sdk.isWalletUnlocked() === true, 'isWalletUnlocked после unlock')
// signDocument → verifyDocumentOffline
const jws = await sdk.signDocument({ payload: 'кросс-рантайм smoke-документ' })
assert(jws.split('.').length === 3, 'signDocument: compact JWS')
const verdict = await sdk.verifyDocumentOffline({
jws,
signedAtBlock: 100,
authority: [{ public_key: wallet.publicKey, valid_from_block: 1, valid_to_block: null }],
})
assert(verdict.valid === true, 'verifyDocumentOffline подтверждает подпись')
// запертый кошелёк → типизированная WalletLocked
sdk.lockWallet()
const lockedErr = await sdk.signDocument({ payload: 'x' }).then(() => null, e => e)
assert(lockedErr instanceof sdk.AuthV2Error && lockedErr.code === sdk.AuthV2ErrorCode.WalletLocked, 'WalletLocked при запертом кошельке')
// login/getAccessToken реализованы (вход 1.7 + flow-executor Story 11.2): в stub-окружении
// без живого authentik/controller дают типизированную AuthV2Error (login→network_error,
// getAccessToken→wallet_locked). Конкретный код не пиннингуем — важно, что ошибка
// типизированная и код валиден для enum'а. Синхронно со smoke.test.ts.
const errorCodes = Object.values(sdk.AuthV2ErrorCode)
for (const [name, call] of [
['login', () => sdk.login({ issuer: 'https://coop.stub/application/o/coopid/', email: 'a@b.c', password: 'p' })],
['getAccessToken', () => sdk.getAccessToken()],
]) {
const err = await call().then(() => null, e => e)
assert(err instanceof sdk.AuthV2Error && errorCodes.includes(err.code), `${name}: типизированная AuthV2Error`)
}
}
app.whenReady().then(run).then(
() => {
console.log('[electron-smoke] все проверки пройдены')
app.exit(0)
},
(e) => {
console.error('[electron-smoke] FAIL:', e)
app.exit(1)
},
)
@@ -0,0 +1,35 @@
/**
* Лаунчер electron-таргета (Story 9.13): резолвит бинарь electron, проверяет
* наличие dist (smoke идёт против собранного артефакта), на безголовом хосте
* заворачивает запуск в `xvfb-run -a` (electron на Linux требует X-сервер).
* `--no-sandbox` — для контейнерных/CI-окружений без user namespaces.
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import electronPath from 'electron'
const here = dirname(fileURLToPath(import.meta.url))
const pkgRoot = join(here, '..', '..')
if (!existsSync(join(pkgRoot, 'dist', 'index.cjs'))) {
console.error('[electron-smoke] dist/index.cjs не найден — сначала `pnpm build` (test:cross-runtime делает это сам)')
process.exit(1)
}
const electronArgs = ['--no-sandbox', join(here, 'electron-main.cjs')]
const needXvfb = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY
const [cmd, args] = needXvfb
? ['xvfb-run', ['-a', electronPath, ...electronArgs]]
: [electronPath, electronArgs]
const res = spawnSync(cmd, args, { stdio: 'inherit', cwd: pkgRoot })
if (res.error) {
console.error(`[electron-smoke] не удалось запустить ${cmd}: ${res.error.message}`)
process.exit(1)
}
process.exit(res.status ?? 1)
@@ -0,0 +1,94 @@
/**
* Cross-runtime smoke (Story 9.13): один и тот же сценарий гоняется в Node
* (`pnpm test:node`) и в браузере (`pnpm test:browser`, chromium headless);
* electron-таргет (`pnpm test:electron`) выполняет те же проверки против
* собранного dist (см. electron-main.cjs).
*
* Правила файла:
* - только ПУБЛИЧНЫЙ API пакета (как у клиентов) и никаких Node-специфичных
* глобалов (Buffer, process, fs) — иначе браузерный таргет перестаёт быть
* честным; сеть стабится через globalThis.fetch;
* - дорогой Argon2id (KDF vault'а) выполняется ОДИН раз в beforeAll: в
* chromium pure-JS KDF в разы медленнее Node (минуты), per-test KDF
* упирается в таймауты. Тесты зависят от порядка: lockWallet — последний.
*/
import type { Wallet } from '../../src'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import {
AuthV2Error,
AuthV2ErrorCode,
encryptPrivateKey,
getAccessToken,
isWalletUnlocked,
lockWallet,
login,
signDocument,
unlockWallet,
verifyDocumentOffline,
} from '../../src'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PW = 'correct-horse-battery-staple-12'
const ACCOUNT = 'ant'
describe('cross-runtime smoke: vault → wallet → подпись → верификация', () => {
let wallet: Wallet
beforeAll(async () => {
// Стаб контроллера: GET /coop/vault/... отдаёт заранее зашифрованный blob.
const blob = await encryptPrivateKey(KEY, PW, { subject_type: 'participant', subject_id: ACCOUNT })
vi.stubGlobal('fetch', async () =>
new Response(JSON.stringify(blob), { status: 200, headers: { 'content-type': 'application/json' } }))
wallet = await unlockWallet({ apiUrl: 'https://coop.stub', account: ACCOUNT, password: PW })
// 10 мин: два прогона Argon2id (encrypt + unlock) — в браузере это минуты
}, 600_000)
afterAll(() => {
lockWallet()
vi.unstubAllGlobals()
})
it('vault round-trip + unlockWallet: WebCrypto AES-GCM и Argon2id работают в этом рантайме', () => {
expect(wallet.account).toBe(ACCOUNT)
expect(wallet.publicKey).toMatch(/^PUB_K1_/)
expect(isWalletUnlocked()).toBe(true)
})
it('signDocument → compact JWS, verifyDocumentOffline подтверждает подпись', async () => {
const jws = await signDocument({ payload: 'кросс-рантайм smoke-документ' })
expect(jws.split('.')).toHaveLength(3)
const verdict = await verifyDocumentOffline({
jws,
signedAtBlock: 100,
authority: [{ public_key: wallet.publicKey, valid_from_block: 1, valid_to_block: null }],
})
expect(verdict.valid).toBe(true)
})
// SDK-сторона login/getAccessToken реализована (вход 1.7 + flow-executor Story 11.2).
// Smoke фиксирует кросс-рантайм: модуль грузится, функции вызываемы, канал
// типизированных ошибок работает в каждом рантайме. В stub-окружении без живого
// authentik/controller: login → AuthV2Error(network_error) (flow-executor не достучался),
// getAccessToken → AuthV2Error(wallet_locked) (нет активной сессии). Конкретный код
// не пиннингуем жёстко — важно, что ошибка типизированная (AuthV2Error) в любом рантайме.
it('login()/getAccessToken(): вызываемы и дают типизированную AuthV2Error в каждом рантайме', async () => {
for (const call of [
() => login({ issuer: 'https://coop.stub/application/o/coopid/', email: 'a@b.c', password: 'p' }),
() => getAccessToken(),
]) {
const err = await call().then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
// код — валидное значение enum'а (канал типизированных ошибок целостен)
expect(Object.values(AuthV2ErrorCode)).toContain((err as AuthV2Error).code)
}
})
// Последний по порядку: запирает общий кошелёк из beforeAll
it('запертый кошелёк: signDocument даёт типизированную WalletLocked, не runtime-краш', async () => {
lockWallet()
const err = await signDocument({ payload: 'x' }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
})
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { AUTH_V2_ERROR_VIEWS, AuthV2Error, AuthV2ErrorCode, describeAuthV2Error } from '../src/index'
describe('describeAuthV2Error (Story 1.11)', () => {
it('каталог покрывает каждый код ошибки', () => {
for (const code of Object.values(AuthV2ErrorCode))
expect(AUTH_V2_ERROR_VIEWS[code], code).toBeDefined()
})
it('по AC: неверные учётные данные — общее сообщение без раскрытия поля', () => {
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'whatever'))
expect(view.code).toBe(AuthV2ErrorCode.InvalidCredentials)
expect(view.message).toBe('Неверный email или пароль.')
// перечисляет оба поля через «или» — не раскрывает, какое именно неверно (security)
expect(view.message).toMatch(/email или пароль/i)
expect(view.message).not.toMatch(/не найден|не существует|нет такого|такой email/i)
expect(view.keepSession).toBe(false)
})
it('по AC: vault не расшифровывается — предлагает восстановление доступа', () => {
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'bad key'))
expect(view.action).toBe('recover')
expect(view.message).toMatch(/восстановить доступ/i)
})
it('по AC: сетевая ошибка — «проверьте интернет» и НЕ разлогинивает (NFR20/FR50)', () => {
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.NetworkError, 'fetch failed'))
expect(view.action).toBe('check_connection')
expect(view.message).toMatch(/проверьте интернет/i)
expect(view.keepSession).toBe(true)
})
it('внешний сбой (кооператив недоступен) — сессия сохраняется', () => {
expect(describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.CooposDegraded, 'down')).keepSession).toBe(true)
})
it('неожиданная (не-AuthV2) ошибка — безопасный фолбэк без утечки technical message', () => {
const view = describeAuthV2Error(new Error('TypeError: cannot read property x of undefined'))
expect(view.code).toBeNull()
expect(view.message).not.toMatch(/TypeError|undefined/)
expect(view.keepSession).toBe(true)
})
it('сообщение берётся из каталога, а не из e.message сервера', () => {
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.TimestampTooOld, 'raw server text'))
expect(view.message).not.toBe('raw server text')
expect(view.message).toBe(AUTH_V2_ERROR_VIEWS[AuthV2ErrorCode.TimestampTooOld].message)
})
})
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import { exportFullQR } from '../src/export'
import { lockWallet } from '../src/wallet'
import { storeUnlocked } from '../src/wallet/storage'
const CERT = 'header.payload.sig'
afterEach(() => lockWallet())
function unlock(): void {
storeUnlocked({ account: 'ant', publicKey: 'PUB_K1_ant', privateKey: '5KdummyWIF' })
}
describe('exportFullQR (Story 4.9)', () => {
it('заперт vault → WalletLocked, consent даже не запрашивается', async () => {
lockWallet()
const confirm = vi.fn().mockResolvedValue(true)
await expect(exportFullQR(CERT, { confirm })).rejects.toMatchObject({ code: AuthV2ErrorCode.WalletLocked })
expect(confirm).not.toHaveBeenCalled()
})
it('consent отклонён → ConsentRequired', async () => {
unlock()
await expect(exportFullQR(CERT, { confirm: async () => false })).rejects.toMatchObject({ code: AuthV2ErrorCode.ConsentRequired })
})
it('reject в confirm трактуется как отказ → ConsentRequired', async () => {
unlock()
const confirm = async (): Promise<boolean> => {
throw new Error('dialog closed')
}
await expect(exportFullQR(CERT, { confirm })).rejects.toMatchObject({ code: AuthV2ErrorCode.ConsentRequired })
})
it('разблокирован + consent дан → payload-байты с данными сертификата', async () => {
unlock()
const bytes = await exportFullQR(CERT, { confirm: async () => true })
expect(bytes).toBeInstanceOf(Uint8Array)
expect(new TextDecoder().decode(bytes)).toBe(CERT)
})
it('ошибки — типизированная AuthV2Error', async () => {
lockWallet()
const err = await exportFullQR(CERT, { confirm: async () => true }).catch(e => e)
expect(err).toBeInstanceOf(AuthV2Error)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import * as api from '../src/index'
import { AuthV2Error, AuthV2ErrorCode } from '../src/index'
const PUBLIC_API = [
'login',
'loginWithMagicLink',
'recover',
'getAccessToken',
'getParticipantCertificate',
'logout',
'verifyOffline',
'signDocument',
'signTimestamp',
'getWallet',
'rotateKey',
'exportFullQR',
] as const
// Методы, ещё не реализованные (бросают not_implemented). По мере реализации
// историй метод уходит отсюда: getWallet — 2.2, signTimestamp — 2.4, verifyOffline — 4.4,
// exportFullQR — 4.9 (гейтится unlocked vault + consent, не not_implemented), login —
// 1.7/11.2 (flow-executor → network_error без живого authentik), recover — Эпик 3,
// getAccessToken — 1.7 (wallet_locked без сессии), loginWithMagicLink — 12.2 (confirm-флоу
// + повторный handshake). Реальный stub остался один: rotateKey (3.3).
const IMPLEMENTED = new Set(['getWallet', 'signTimestamp', 'getParticipantCertificate', 'logout', 'signDocument', 'verifyOffline', 'exportFullQR', 'login', 'recover', 'getAccessToken', 'loginWithMagicLink'])
const STILL_STUBBED = PUBLIC_API.filter(m => !IMPLEMENTED.has(m))
describe('@coopenomics/auth — скелет SDK', () => {
it('экспортирует всю публичную поверхность', () => {
for (const method of PUBLIC_API)
expect(api[method], method).toBeTypeOf('function')
})
it('stub\'ы отказывают типизированной AuthV2Error(not_implemented), а не молчат', async () => {
for (const method of STILL_STUBBED) {
const err = await (api[method] as () => Promise<unknown>)().then(
() => null,
e => e,
)
expect(err, method).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code, method).toBe(AuthV2ErrorCode.NotImplemented)
}
})
it('ошибка сериализуется в формат OAuth 2.0', () => {
const err = new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Неверный email или пароль')
expect(err.toJSON()).toEqual({
error: 'invalid_credentials',
error_description: 'Неверный email или пароль',
})
})
it('exportProofQR НЕ экспортируется в MVP (anonymous-форма — Growth, Story 4.9)', () => {
expect((api as Record<string, unknown>).exportProofQR).toBeUndefined()
})
it('trust anchor и список нод доступны (placeholder до release-pin/9.5)', () => {
expect(api.TRUST_ANCHOR_ANO_CERT_PUBKEY).toBeNull()
expect(api.COOPOS_PUBLIC_NODES).toEqual([])
})
})
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const lockWallet = vi.fn()
// keystore-затирание мокаем на уровне модуля wallet — logout обязан вызвать его
// независимо от исхода сетевого запроса.
vi.mock('../src/wallet', () => ({
lockWallet,
}))
const { logout } = await import('../src/oidc')
describe('logout (Story 1.10)', () => {
beforeEach(() => {
lockWallet.mockClear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('шлёт POST /coop/logout с refresh/access и затирает keystore', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
await logout({ apiUrl: 'https://coop.example/', refreshToken: 'r1', accessToken: 'a1' })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, opts] = fetchMock.mock.calls[0]
expect(url).toBe('https://coop.example/coop/logout')
expect(opts.method).toBe('POST')
expect(JSON.parse(opts.body)).toEqual({ refresh_token: 'r1', access_token: 'a1' })
expect(lockWallet).toHaveBeenCalledTimes(1)
})
it('затирает keystore ДАЖЕ при недоступности сервера (finally)', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'))
vi.stubGlobal('fetch', fetchMock)
await expect(logout({ apiUrl: 'https://coop.example', refreshToken: 'r1' })).resolves.toBeUndefined()
expect(lockWallet).toHaveBeenCalledTimes(1)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { canonicalMigrationMessage, migrate } from '../src/migration'
import { configureCoopId } from '../src/oidc/client'
const WIF = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
beforeEach(() => configureCoopId({ apiUrl: 'https://coop.example' }))
afterEach(() => vi.unstubAllGlobals())
describe('canonicalMigrationMessage (Story 11.4)', () => {
it('фиксированный алфавитный порядок ключей: pw_hash, purpose, ts', () => {
expect(canonicalMigrationMessage({ ts: 'T', pw_hash: 'H' }))
.toBe('{"pw_hash":"H","purpose":"coopid-key-migration","ts":"T"}')
})
})
describe('migrate (Story 11.4) — «ключ → пароль»', () => {
it('невалидный WIF → InvalidCredentials, без сетевого запроса', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(migrate({ email: 'a@e.com', privateKey: 'not-a-key', newPassword: 'Strong#Pass1' }))
.rejects
.toMatchObject({ code: 'invalid_credentials' })
expect(fetchMock).not.toHaveBeenCalled()
})
it('ошибка сервера (400 weak_password) пробрасывается; vault не сохраняется', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'weak_password', error_description: 'Пароль слишком короткий' }),
})
vi.stubGlobal('fetch', fetchMock)
await expect(migrate({ email: 'a@e.com', privateKey: WIF, newPassword: 'weakweak' }))
.rejects
.toMatchObject({ code: 'weak_password' })
// только POST /coop/migration, без POST /coop/vault
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/migration')
})
it('happy path: POST /coop/migration → username, затем saveToVault (POST /coop/vault)', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ username: 'ant' }) }) // migration
.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({}) }) // vault store
vi.stubGlobal('fetch', fetchMock)
const result = await migrate({ email: 'a@e.com', privateKey: WIF, newPassword: 'Strong#Pass1' })
expect(result).toEqual({ username: 'ant' })
// 1) запрос миграции
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/migration')
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(body.email).toBe('a@e.com')
expect(body.new_password).toBe('Strong#Pass1')
expect(typeof body.timestamp).toBe('string')
expect(body.signature).toMatch(/^SIG_/)
// 2) сохранение vault новым паролём (subject = возвращённый username)
expect(fetchMock.mock.calls[1][0]).toBe('https://coop.example/coop/vault')
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toMatchObject({ subject_type: 'participant', subject_id: 'ant' })
}, 60000)
})
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { authenticateWithFlowExecutor } from '../src/oidc/flow-executor'
const ISSUER = 'https://coop.example/application/o/coopid/'
const FLOW_URL = 'https://coop.example/api/v3/flows/executor/default-authentication-flow/?query='
afterEach(() => vi.unstubAllGlobals())
/** Ответ flow-executor: 200 + тело challenge'а. */
function challenge(body: unknown, status = 200) {
return { ok: status < 400, status, json: async () => body }
}
describe('authenticateWithFlowExecutor (Story 11.2) — встроенный фактор-1 authentik', () => {
it('раздельные стадии identification → password → redirect: успех, верные URL/тела/credentials', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-identification' })) // GET
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' })) // POST email
.mockResolvedValueOnce(challenge({ type: 'redirect' })) // POST password → завершение
vi.stubGlobal('fetch', fetchMock)
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'user@e.com', password: 'S3cret!' })
// GET старта flow
expect(fetchMock.mock.calls[0][0]).toBe(FLOW_URL)
expect(fetchMock.mock.calls[0][1].method).toBe('GET')
expect(fetchMock.mock.calls[0][1].credentials).toBe('include')
// POST identification — uid_field + password (совмещённая форма безопасна и для раздельной)
expect(fetchMock.mock.calls[1][1].method).toBe('POST')
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ uid_field: 'user@e.com', password: 'S3cret!' })
// POST password
expect(JSON.parse(fetchMock.mock.calls[2][1].body)).toEqual({ password: 'S3cret!' })
expect(fetchMock.mock.calls[2][1].credentials).toBe('include')
})
it('совмещённая identification сразу завершает flow (redirect)', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-identification' }))
.mockResolvedValueOnce(challenge({ type: 'redirect' }))
vi.stubGlobal('fetch', fetchMock)
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('завершение по стадии ak-stage-user-login (сессия установлена)', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
.mockResolvedValueOnce(challenge({ component: 'ak-stage-user-login' }))
vi.stubGlobal('fetch', fetchMock)
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })).resolves.toBeUndefined()
})
it('неверный пароль (response_errors, HTTP 400) → InvalidCredentials с сообщением authentik', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password', response_errors: { password: [{ string: 'Неверный пароль', code: 'invalid' }] } }, 400))
vi.stubGlobal('fetch', fetchMock)
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'bad' }))
.rejects
.toMatchObject({ code: 'invalid_credentials', message: 'Неверный пароль' })
})
it('ak-stage-access-denied → InvalidCredentials', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(challenge({ component: 'ak-stage-access-denied' })))
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
.rejects
.toMatchObject({ code: 'invalid_credentials' })
})
it('неподдерживаемая интерактивная стадия (MFA) → InvalidCredentials с её именем', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
.mockResolvedValueOnce(challenge({ component: 'ak-stage-authenticator-validate' }))
vi.stubGlobal('fetch', fetchMock)
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
.rejects
.toMatchObject({ code: 'invalid_credentials', message: expect.stringContaining('ak-stage-authenticator-validate') })
})
it('сеть упала на старте → NetworkError', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
.rejects
.toMatchObject({ code: 'network_error' })
})
it('эхо CSRF-cookie уходит в заголовке X-authentik-CSRF (same-origin)', async () => {
vi.stubGlobal('document', { cookie: 'foo=1; authentik_csrf=tok123; bar=2' })
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
.mockResolvedValueOnce(challenge({ type: 'redirect' }))
vi.stubGlobal('fetch', fetchMock)
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })
expect(fetchMock.mock.calls[0][1].headers['X-authentik-CSRF']).toBe('tok123')
expect(fetchMock.mock.calls[1][1].headers['X-authentik-CSRF']).toBe('tok123')
})
})
@@ -0,0 +1,94 @@
import { PrivateKey } from '@wharfkit/antelope'
import { SignJWT } from 'jose'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthV2Error } from '../src/errors'
import { performTimestampHandshake } from '../src/oidc/handshake'
import { clearSession, currentTokens } from '../src/oidc/tokens'
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PUB = PrivateKey.from(KEY).toPublic().toString()
const ACCOUNT = 'ant'
async function bindingTokenFor(sub: string, jti = 'jti-1'): Promise<string> {
return new SignJWT({ stage_completed: 'password' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(sub)
.setJti(jti)
.setIssuedAt()
.setExpirationTime('120s')
.sign(new TextEncoder().encode('test-binding-secret'))
}
function okJson(body: unknown) {
return { ok: true, status: 200, json: async () => body }
}
beforeEach(() => {
clearSession()
storeUnlocked({ account: ACCOUNT, publicKey: PUB, privateKey: KEY })
})
afterEach(() => {
wipeKeystore()
vi.unstubAllGlobals()
})
describe('performTimestampHandshake (Story 1.7) — handshake внутрь SDK', () => {
it('bind → sign → verify: токены/сертификат + сессия сохранена; credentials:include на обоих', async () => {
const binding = await bindingTokenFor(ACCOUNT)
const fetchMock = vi.fn()
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
.mockResolvedValueOnce(okJson({ access_token: 'acc', refresh_token: 'ref', participant_certificate: 'cert', degraded: false }))
vi.stubGlobal('fetch', fetchMock)
const r = await performTimestampHandshake('https://coop.example/')
expect(r).toMatchObject({ accessToken: 'acc', refreshToken: 'ref', participantCertificate: 'cert', degraded: false })
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/session/bind')
expect(fetchMock.mock.calls[0][1].credentials).toBe('include')
expect(fetchMock.mock.calls[1][0]).toBe('https://coop.example/coop/verify/timestamp')
expect(fetchMock.mock.calls[1][1].credentials).toBe('include')
const verifyBody = JSON.parse(fetchMock.mock.calls[1][1].body)
expect(verifyBody.binding_token).toBe(binding)
expect(typeof verifyBody.signature).toBe('string')
expect(typeof verifyBody.timestamp).toBe('string')
expect(currentTokens()).toEqual({ accessToken: 'acc', refreshToken: 'ref' })
})
it('degraded-вход (узел недоступен/ключ не финализирован) прокидывается', async () => {
const binding = await bindingTokenFor(ACCOUNT)
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
.mockResolvedValueOnce(okJson({ access_token: 'a', refresh_token: 'r', degraded: true, degraded_reason: 'rpc_unavailable' })))
const r = await performTimestampHandshake('https://coop.example')
expect(r.degraded).toBe(true)
expect(r.degradedReason).toBe('rpc_unavailable')
})
it('bind 401 → InvalidCredentials (сессия authentik не подтверждена), сессия не сохраняется', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}) }))
await expect(performTimestampHandshake('https://coop.example')).rejects.toBeInstanceOf(AuthV2Error)
expect(currentTokens()).toBeNull()
})
it('verify !ok → AuthV2Error с кодом из тела (OAuth2 error)', async () => {
const binding = await bindingTokenFor(ACCOUNT)
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ error: 'chain_verification_failed', error_description: 'Подпись не соответствует ключу' }) }))
await expect(performTimestampHandshake('https://coop.example')).rejects.toMatchObject({ code: 'chain_verification_failed' })
})
it('кошелёк заперт → WalletLocked (подпись невозможна, verify не вызывается)', async () => {
wipeKeystore()
const binding = await bindingTokenFor(ACCOUNT)
const fetchMock = vi.fn().mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
vi.stubGlobal('fetch', fetchMock)
await expect(performTimestampHandshake('https://coop.example')).rejects.toMatchObject({ code: 'wallet_locked' })
expect(fetchMock).toHaveBeenCalledTimes(1) // только bind, до verify не дошли
})
})
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Граница повторного входа (authentik-сессия + on-chain timestamp-handshake) — сетевые
// интеграции; мокаем их, чтобы юнит-тест проверял детерминированный confirm-флоу:
// генерацию пары, AAD vault'а, тело запроса confirm и маппинг ошибок. Крипто vault'а
// и antelope здесь настоящие — round-trip ключа проверяется по-настоящему.
vi.mock('../src/oidc/client', async importActual => ({
...(await importActual<typeof import('../src/oidc/client')>()),
authenticateWithAuthentik: vi.fn().mockResolvedValue({ id_token: 'ID_TOK' }),
}))
vi.mock('../src/oidc/handshake', async importActual => ({
...(await importActual<typeof import('../src/oidc/handshake')>()),
performTimestampHandshake: vi.fn().mockResolvedValue({ accessToken: 'AT', refreshToken: 'RT', participantCertificate: 'CERT', degraded: false }),
}))
vi.mock('../src/wallet', async importActual => ({
...(await importActual<typeof import('../src/wallet')>()),
unlockWallet: vi.fn().mockResolvedValue(undefined),
}))
const { PrivateKey } = await import('@wharfkit/antelope')
const { loginWithMagicLink } = await import('../src/oidc')
const { authenticateWithAuthentik, configureCoopId } = await import('../src/oidc/client')
const { performTimestampHandshake } = await import('../src/oidc/handshake')
const { decryptPrivateKey } = await import('../src/vault/encrypt')
const { unlockWallet } = await import('../src/wallet')
const BASE = 'https://coop.example'
// account больше НЕ параметр — сервер возвращает его в ответе confirm (резолвит из токена).
const ACCOUNT = 'ant'
const PARAMS = {
issuer: 'https://coop.example/application/o/coopid/',
email: 'ant@example.com',
token: 'magic-token-xyz',
totp: '123456',
newPassword: 'Strong#NewPass1',
}
beforeEach(() => configureCoopId({ apiUrl: BASE }))
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
describe('loginWithMagicLink (Story 12.2) — восстановление доступа', () => {
it('happy: новая пара → корректное тело confirm → vault round-trip → повторный вход', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ username: ACCOUNT }) })
vi.stubGlobal('fetch', fetchMock)
const result = await loginWithMagicLink(PARAMS)
// единственный сетевой вызов — confirm (unlock/handshake/authentik замоканы).
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/coop/recovery/confirm`)
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(body.token).toBe(PARAMS.token)
expect(body.code).toBe(PARAMS.totp)
expect(body.password).toBe(PARAMS.newPassword)
expect(body.public_key).toMatch(/^PUB_K1_/)
expect(body.vault).toMatchObject({ cipher_version: expect.any(String), salt: expect.any(String), ciphertext: expect.any(String) })
// AAD больше НЕ зависит от account: тот же блоб расшифровывается под ЛЮБЫМ
// subject_id (важен только тип субъекта) — регресс-гард решения 2026-06-15.
// И приватный ключ соответствует отправленному public_key — целостность пары.
const wif = await decryptPrivateKey(body.vault, PARAMS.newPassword, { subject_type: 'participant', subject_id: 'any-other-account' })
expect(PrivateKey.from(wif).toPublic().toString()).toBe(body.public_key)
// повторный вход новым контуром выполнен; account взят из ответа confirm.
expect(authenticateWithAuthentik).toHaveBeenCalledWith(expect.objectContaining({ email: PARAMS.email, password: PARAMS.newPassword, issuer: PARAMS.issuer }))
expect(unlockWallet).toHaveBeenCalledWith({ apiUrl: BASE, account: ACCOUNT, password: PARAMS.newPassword })
expect(performTimestampHandshake).toHaveBeenCalledWith(BASE)
expect(result).toEqual({ accessToken: 'AT', idToken: 'ID_TOK', participantCertificate: 'CERT' })
}, 60000)
it('confirm вернул 200 без username → InvalidRecoveryToken; повторный вход не запускается', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }))
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_recovery_token' })
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
expect(performTimestampHandshake).not.toHaveBeenCalled()
}, 60000)
it('неверный TOTP (400 invalid_2fa_code) → проброс; повторный вход не запускается', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'invalid_2fa_code', error_description: 'Неверный код' }) }))
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_2fa_code' })
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
expect(performTimestampHandshake).not.toHaveBeenCalled()
}, 60000)
it('недействительный токен (400 invalid_recovery_token) → проброс', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'invalid_recovery_token', error_description: 'Ссылка недействительна' }) }))
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_recovery_token' })
}, 60000)
it('429 → TooManyRecoveryAttempts', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }))
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'too_many_recovery_attempts' })
}, 60000)
it('сеть упала на confirm → NetworkError; повторный вход не запускается', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'network_error' })
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
}, 60000)
it('storage задан → локальная копия нового блоба сохранена под account', async () => {
const store = new Map<string, string>()
const storage = {
get: vi.fn(async (k: string) => store.get(k) ?? null),
set: vi.fn(async (k: string, v: string) => { store.set(k, v) }),
remove: vi.fn(async (k: string) => { store.delete(k) }),
}
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ username: ACCOUNT }) }))
await loginWithMagicLink({ ...PARAMS, storage })
expect(storage.set).toHaveBeenCalledTimes(1)
const saved = JSON.parse(storage.set.mock.calls[0][1] as string)
expect(saved.account).toBe(ACCOUNT)
expect(saved.blob).toMatchObject({ cipher_version: expect.any(String) })
}, 60000)
})
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { recover } from '../src/oidc'
import { configureCoopId } from '../src/oidc/client'
beforeEach(() => configureCoopId({ apiUrl: 'https://coop.example' }))
afterEach(() => vi.unstubAllGlobals())
describe('recover (Эпик 3) — запрос восстановления доступа', () => {
it('шлёт POST /coop/recovery/request с email (202 — нормальный путь, анти-enumeration)', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 202, json: async () => ({}) })
vi.stubGlobal('fetch', fetchMock)
await recover('user@example.com')
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/recovery/request')
expect(fetchMock.mock.calls[0][1].method).toBe('POST')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ email: 'user@example.com' })
})
it('429 → TooManyRecoveryAttempts', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }))
await expect(recover('u@e.com')).rejects.toMatchObject({ code: 'too_many_recovery_attempts' })
})
it('сеть упала → NetworkError', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
await expect(recover('u@e.com')).rejects.toMatchObject({ code: 'network_error' })
})
})
+134
View File
@@ -0,0 +1,134 @@
import type { StorageAdapter } from '../src/wallet'
import { SignJWT } from 'jose'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthV2Error } from '../src/errors'
import { clearSession, configureTokenStorage, currentTokens, getAccessToken, restoreSession, setSession } from '../src/oidc/tokens'
/** JWT с заданным exp (сек от now); содержимое не важно — getAccessToken читает только exp. */
async function jwtExpIn(seconds: number): Promise<string> {
return new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${seconds}s`)
.sign(new TextEncoder().encode('t'))
}
function okJson(body: unknown) {
return { ok: true, status: 200, json: async () => body }
}
/** In-memory StorageAdapter для тестов персистентности токенов. */
function memoryStorage(): StorageAdapter & { dump: () => Record<string, string> } {
const m = new Map<string, string>()
return {
get: async k => m.get(k) ?? null,
set: async (k, v) => void m.set(k, v),
remove: async k => void m.delete(k),
dump: () => Object.fromEntries(m),
}
}
beforeEach(() => clearSession())
afterEach(() => {
vi.unstubAllGlobals()
configureTokenStorage(null)
clearSession()
})
describe('getAccessToken lifecycle (Story 1.7) — токен внутри SDK, авто-refresh', () => {
it('нет активной сессии → бросает', async () => {
await expect(getAccessToken()).rejects.toBeInstanceOf(AuthV2Error)
})
it('свежий access → возвращается без обращения к сети', async () => {
const access = await jwtExpIn(3600)
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
expect(await getAccessToken()).toBe(access)
expect(fetchMock).not.toHaveBeenCalled()
})
it('истёкший access → refresh через /coop/refresh, новая пара сохраняется', async () => {
const expired = await jwtExpIn(-10)
const fresh = await jwtExpIn(3600)
setSession('https://coop.example/', { accessToken: expired, refreshToken: 'r-old' })
const fetchMock = vi.fn().mockResolvedValue(okJson({ access_token: fresh, refresh_token: 'r-new' }))
vi.stubGlobal('fetch', fetchMock)
expect(await getAccessToken()).toBe(fresh)
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/refresh')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ refresh_token: 'r-old' })
expect(currentTokens()).toEqual({ accessToken: fresh, refreshToken: 'r-new' })
})
it('refresh 401 → SessionBindingExpired (нужен повторный вход)', async () => {
const expired = await jwtExpIn(-10)
setSession('https://coop.example', { accessToken: expired, refreshToken: 'r' })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }))
await expect(getAccessToken()).rejects.toMatchObject({ code: 'session_binding_expired' })
})
})
describe('персистентность токенов (паритет с легаси — переживание reload)', () => {
it('setSession персистит пару токенов в storage', async () => {
const storage = memoryStorage()
const access = await jwtExpIn(3600)
configureTokenStorage(storage)
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
expect(JSON.stringify(storage.dump())).toContain(access)
})
it('restoreSession поднимает пару после «reload» (RAM пуста, storage цел) — сессия переживает F5', async () => {
const access = await jwtExpIn(3600)
// «До reload»: storage с записью, как её оставил setSession в прошлой сессии.
const storage = memoryStorage()
await storage.set('coopid.session.tokens', JSON.stringify({ apiBase: 'https://coop.example', tokens: { accessToken: access, refreshToken: 'r' } }))
// «После reload»: RAM пуста (beforeEach), подключаем storage и восстанавливаем.
configureTokenStorage(storage)
expect(await restoreSession()).toBe(true)
expect(currentTokens()).toEqual({ accessToken: access, refreshToken: 'r' })
expect(await getAccessToken()).toBe(access) // токен жив без сети
})
it('нет персистентной записи → restoreSession=false (нужен вход)', async () => {
configureTokenStorage(memoryStorage())
expect(await restoreSession()).toBe(false)
await expect(getAccessToken()).rejects.toBeInstanceOf(AuthV2Error)
})
it('активная RAM-сессия не перетирается restoreSession', async () => {
const access = await jwtExpIn(3600)
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
configureTokenStorage(memoryStorage()) // пустой storage
expect(await restoreSession()).toBe(true) // уже в RAM
expect(currentTokens()).toEqual({ accessToken: access, refreshToken: 'r' })
})
it('refresh обновляет персистентную копию (на диске свежий refresh-токен)', async () => {
const storage = memoryStorage()
const expired = await jwtExpIn(-10)
const fresh = await jwtExpIn(3600)
configureTokenStorage(storage)
setSession('https://coop.example', { accessToken: expired, refreshToken: 'r-old' })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okJson({ access_token: fresh, refresh_token: 'r-new' })))
expect(await getAccessToken()).toBe(fresh)
const dump = JSON.stringify(storage.dump())
expect(dump).toContain('r-new')
expect(dump).not.toContain('r-old')
})
it('clearSession стирает персистентную копию (logout)', async () => {
const storage = memoryStorage()
const access = await jwtExpIn(3600)
configureTokenStorage(storage)
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
expect(Object.keys(storage.dump())).toHaveLength(1)
clearSession()
expect(Object.keys(storage.dump())).toHaveLength(0)
})
})
+123
View File
@@ -0,0 +1,123 @@
import type { StorageAdapter } from '../src/wallet'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import {
clearPinCache,
DEFAULT_PIN,
getWallet,
hasPinCache,
isWalletUnlocked,
lockWallet,
persistPinCache,
unlockWithPin,
} from '../src/wallet'
import { storeUnlocked } from '../src/wallet/storage'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const ACCOUNT = 'ant'
const PUB = 'PUB_K1_'
/** In-memory StorageAdapter (в браузере — localStorage/IndexedDB, тут — Map). */
function memoryStorage(): StorageAdapter & { dump: () => Record<string, string> } {
const m = new Map<string, string>()
return {
get: async k => m.get(k) ?? null,
set: async (k, v) => void m.set(k, v),
remove: async k => void m.delete(k),
dump: () => Object.fromEntries(m),
}
}
/** Кладёт ключ в RAM-keystore, как это делает unlockWallet после входа паролём. */
function putUnlocked(): void {
// publicKey деривуется внутри unlockWallet/unlockWithPin; здесь для setup
// достаточно любого валидного вида — реальный pub проверяем после unlockWithPin.
storeUnlocked({ account: ACCOUNT, publicKey: 'PUB_K1_setup', privateKey: KEY })
}
beforeEach(() => {
lockWallet()
})
afterEach(() => {
lockWallet()
})
describe('pin-кэш: persist → unlock без пароля и round-trip к серверу', () => {
it('кастомный ПИН: persist под ПИН, lock, unlock тем же ПИН → тот же account и валидный publicKey', async () => {
const storage = memoryStorage()
putUnlocked()
await persistPinCache({ pin: '135790', storage })
lockWallet()
expect(isWalletUnlocked()).toBe(false)
const wallet = await unlockWithPin({ pin: '135790', storage })
expect(wallet).not.toBeNull()
expect(wallet!.account).toBe(ACCOUNT)
expect(wallet!.publicKey).toMatch(new RegExp(`^${PUB}`))
expect(isWalletUnlocked()).toBe(true)
// keystore реально наполнен правильным ключом — getWallet отдаёт тот же pub
expect((await getWallet()).publicKey).toBe(wallet!.publicKey)
}, 30000)
it('дефолтный ПИН (000000): persist и unlock без аргумента pin — прозрачно', async () => {
const storage = memoryStorage()
putUnlocked()
await persistPinCache({ storage }) // pin не задан → DEFAULT_PIN
lockWallet()
const wallet = await unlockWithPin({ storage }) // pin не задан → DEFAULT_PIN
expect(wallet).not.toBeNull()
expect(wallet!.account).toBe(ACCOUNT)
// тем же значением расшифровывается и явный DEFAULT_PIN
lockWallet()
expect(await unlockWithPin({ pin: DEFAULT_PIN, storage })).not.toBeNull()
}, 30000)
it('неверный ПИН → VaultDecryptionFailed, keystore остаётся пуст', async () => {
const storage = memoryStorage()
putUnlocked()
await persistPinCache({ pin: '111111', storage })
lockWallet()
const err = await unlockWithPin({ pin: '999999', storage }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
expect(isWalletUnlocked()).toBe(false)
}, 30000)
})
describe('pin-кэш: отсутствие кэша и жизненный цикл', () => {
it('нет кэша → unlockWithPin возвращает null (нужен полный вход паролём)', async () => {
const storage = memoryStorage()
expect(await unlockWithPin({ storage })).toBeNull()
expect(isWalletUnlocked()).toBe(false)
})
it('hasPinCache: false → persist → true → clear → false', async () => {
const storage = memoryStorage()
expect(await hasPinCache(storage)).toBe(false)
putUnlocked()
await persistPinCache({ storage })
expect(await hasPinCache(storage)).toBe(true)
await clearPinCache(storage)
expect(await hasPinCache(storage)).toBe(false)
}, 30000)
it('persistPinCache при запертом кошельке → WalletLocked (нечего кэшировать)', async () => {
const storage = memoryStorage()
lockWallet()
const err = await persistPinCache({ storage }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
})
describe('pin-кэш: at-rest — на диске только шифр', () => {
it('сохранённая запись не содержит WIF в открытом виде', async () => {
const storage = memoryStorage()
putUnlocked()
await persistPinCache({ pin: '424242', storage })
expect(JSON.stringify(storage.dump())).not.toContain(KEY)
}, 30000)
})
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import {
compareSchemaVersions,
type CoopIdSchemaPolicy,
createSchemaPolicyCache,
isSchemaVersionSupported,
SCHEMA_POLICY_CACHE_TTL_MS,
} from '../src/index'
const POLICY: CoopIdSchemaPolicy = { current_version: '1', min_supported_version: '1', deprecation: { 0: '2026-01-01' } }
describe('compareSchemaVersions (Story 4.10)', () => {
it('числовое сравнение версий', () => {
expect(compareSchemaVersions('0', '1')).toBeLessThan(0)
expect(compareSchemaVersions('2', '1')).toBeGreaterThan(0)
expect(compareSchemaVersions('1', '1')).toBe(0)
})
it('нечисловая версия → лексикографический фолбэк', () => {
expect(compareSchemaVersions('a', 'b')).toBeLessThan(0)
expect(compareSchemaVersions('v2', 'v2')).toBe(0)
})
it('isSchemaVersionSupported: не старее минимума', () => {
expect(isSchemaVersionSupported('1', '1')).toBe(true)
expect(isSchemaVersionSupported('2', '1')).toBe(true)
expect(isSchemaVersionSupported('0', '1')).toBe(false)
})
})
describe('createSchemaPolicyCache (Story 4.10, FR72)', () => {
it('в пределах TTL отдаёт кэш без повторного fetch', async () => {
let t = 1000
const fetchPolicy = vi.fn().mockResolvedValue(POLICY)
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
expect(await cache.getMinSupportedVersion()).toBe('1')
t += SCHEMA_POLICY_CACHE_TTL_MS - 1
await cache.getPolicy()
expect(fetchPolicy).toHaveBeenCalledTimes(1)
})
it('после истечения TTL рефетчит политику', async () => {
let t = 1000
const fetchPolicy = vi.fn().mockResolvedValue(POLICY)
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
await cache.getPolicy()
t += SCHEMA_POLICY_CACHE_TTL_MS + 1
await cache.getPolicy()
expect(fetchPolicy).toHaveBeenCalledTimes(2)
})
it('офлайн (fetch упал) после истечения TTL → отдаёт stale-кэш', async () => {
let t = 1000
const fetchPolicy = vi.fn()
.mockResolvedValueOnce(POLICY)
.mockRejectedValueOnce(new Error('offline'))
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
await cache.getPolicy()
t += SCHEMA_POLICY_CACHE_TTL_MS + 1
expect(await cache.getMinSupportedVersion()).toBe('1')
})
it('нет кэша и fetch упал → пробрасывает ошибку', async () => {
const fetchPolicy = vi.fn().mockRejectedValue(new Error('offline'))
const cache = createSchemaPolicyCache({ fetchPolicy })
await expect(cache.getPolicy()).rejects.toThrow('offline')
})
})
+178
View File
@@ -0,0 +1,178 @@
import { Checksum256, PrivateKey, PublicKey, Signature } from '@wharfkit/antelope'
import { base64url, SignJWT } from 'jose'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import { canonicalTimestampMessage, signChainDigest, signDocument, signTimestamp } from '../src/signing'
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const ACCOUNT = 'ant'
const PUB = PrivateKey.from(KEY).toPublic().toString()
/** Поддельный, но корректно сформированный session_binding_token (signTimestamp его только декодирует). */
async function makeToken(sub = ACCOUNT, jti = 'jti-abc-123'): Promise<string> {
return new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(sub)
.setJti(jti)
.setExpirationTime('2m')
.sign(new TextEncoder().encode('test-secret-padding-0000000000000000'))
}
beforeEach(() => {
storeUnlocked({ account: ACCOUNT, publicKey: PUB, privateKey: KEY })
})
afterEach(() => {
wipeKeystore()
})
describe('signTimestamp: COOPOS-native recoverable подпись', () => {
it('подписывает {ts,jti,sub}; recoverMessage канонического сообщения даёт тот же pubkey', async () => {
const token = await makeToken(ACCOUNT, 'jti-abc-123')
const res = await signTimestamp({ sessionBindingToken: token })
expect(res.sub).toBe(ACCOUNT)
expect(res.binding_token_jti).toBe('jti-abc-123')
expect(res.public_key).toBe(PUB)
expect(res.signature).toMatch(/^SIG_K1_/)
const msg = new TextEncoder().encode(canonicalTimestampMessage({
ts: res.ts,
binding_token_jti: res.binding_token_jti,
sub: res.sub,
}))
const recovered = Signature.from(res.signature).recoverMessage(msg).toString()
expect(recovered).toBe(PUB)
})
it('подмена сообщения ломает восстановление pubkey (integrity)', async () => {
const res = await signTimestamp({ sessionBindingToken: await makeToken() })
const tampered = new TextEncoder().encode(canonicalTimestampMessage({
ts: res.ts,
binding_token_jti: res.binding_token_jti,
sub: 'someone-else',
}))
const recovered = Signature.from(res.signature).recoverMessage(tampered).toString()
expect(recovered).not.toBe(PUB)
})
it('запертый кошелёк → WalletLocked', async () => {
wipeKeystore()
const err = await signTimestamp({ sessionBindingToken: await makeToken() }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
it('кошелёк не совпадает с субъектом токена → ClientWalletMismatch', async () => {
const err = await signTimestamp({ sessionBindingToken: await makeToken('petrov') }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ClientWalletMismatch)
})
it('битый токен → SessionBindingExpired', async () => {
const err = await signTimestamp({ sessionBindingToken: 'not-a-jwt' }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.SessionBindingExpired)
})
it('токен без sub/jti → SessionBindingExpired', async () => {
const noClaims = await new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.sign(new TextEncoder().encode('test-secret-padding-0000000000000000'))
const err = await signTimestamp({ sessionBindingToken: noClaims }).then(() => null, e => e)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.SessionBindingExpired)
})
})
describe('signChainDigest: подпись tx-дайджеста ключом из keystore (мост подписи CoopID)', () => {
it('подписывает дайджест; recoverDigest даёт pubkey подписанта (ключ наружу не выходит — только подпись)', async () => {
const digestHex = 'ab'.repeat(32) // 32 байта signing-дайджеста
const sig = await signChainDigest(digestHex)
expect(sig).toMatch(/^SIG_K1_/)
const recovered = Signature.from(sig).recoverDigest(Checksum256.from(digestHex)).toString()
expect(recovered).toBe(PUB)
})
it('подмена дайджеста ломает восстановление pubkey (integrity)', async () => {
const sig = await signChainDigest('ab'.repeat(32))
const recovered = Signature.from(sig).recoverDigest(Checksum256.from('cd'.repeat(32))).toString()
expect(recovered).not.toBe(PUB)
})
it('запертый кошелёк → WalletLocked (подпись транзакции невозможна без unlock)', async () => {
wipeKeystore()
const err = await signChainDigest('ab'.repeat(32)).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
})
describe('canonicalTimestampMessage: детерминизм', () => {
it('фиксированный алфавитный порядок ключей', () => {
expect(canonicalTimestampMessage({ ts: 't', binding_token_jti: 'j', sub: 's' }))
.toBe('{"binding_token_jti":"j","sub":"s","ts":"t"}')
})
})
/** Верифицирует compact JWS ES256K: разбирает подпись R||S и проверяет её ключом. */
function verifyJws(jws: string, pub: string): boolean {
const [h, p, s] = jws.split('.')
const rs = base64url.decode(s)
const sig = Signature.from({ type: 'K1', r: rs.slice(0, 32), s: rs.slice(32, 64), recid: 0 })
return sig.verifyMessage(new TextEncoder().encode(`${h}.${p}`), PublicKey.from(pub))
}
describe('signDocument: локальная подпись compact JWS (Story 2.3)', () => {
it('возвращает JWS <header>.<payload>.<signature>; header alg/kid, payload round-trip, подпись валидна', async () => {
const doc = new TextEncoder().encode('Протокол собрания №1')
const jws = await signDocument({ payload: doc, alg: 'ES256K' })
const parts = jws.split('.')
expect(parts).toHaveLength(3)
const header = JSON.parse(new TextDecoder().decode(base64url.decode(parts[0])))
expect(header).toEqual({ alg: 'ES256K', kid: ACCOUNT })
// payload декодируется обратно в исходные байты
expect(base64url.decode(parts[1])).toEqual(doc)
// подпись — ровно 64 байта (R||S, без recovery) и валидна для pubkey подписанта
expect(base64url.decode(parts[2]).length).toBe(64)
expect(verifyJws(jws, PUB)).toBe(true)
})
it('строковый payload эквивалентен его UTF-8 байтам', async () => {
const text = 'привет, кооператив'
const fromString = await signDocument({ payload: text })
const fromBytes = await signDocument({ payload: new TextEncoder().encode(text) })
// RFC6979-детерминизм K1: одинаковый вход → одинаковый JWS
expect(fromString).toBe(fromBytes)
expect(verifyJws(fromString, PUB)).toBe(true)
})
it('integrity: подделка payload в JWS ломает проверку подписи', async () => {
const jws = await signDocument({ payload: 'исходный документ' })
const [h, , s] = jws.split('.')
const tampered = `${h}.${base64url.encode(new TextEncoder().encode('подменённый документ'))}.${s}`
expect(verifyJws(tampered, PUB)).toBe(false)
})
it('alg по умолчанию — ES256K', async () => {
const jws = await signDocument({ payload: 'x' })
const header = JSON.parse(new TextDecoder().decode(base64url.decode(jws.split('.')[0])))
expect(header.alg).toBe('ES256K')
})
it('неподдерживаемый alg → AuthV2Error', async () => {
const err = await signDocument({ payload: 'x', alg: 'RS256' as 'ES256K' }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
})
it('запертый кошелёк → WalletLocked', async () => {
wipeKeystore()
const err = await signDocument({ payload: 'x' }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
})
+94
View File
@@ -0,0 +1,94 @@
import type { StorageAdapter } from '../src/wallet'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { decryptPrivateKey } from '../src/vault'
import { clearLocalVault, loadLocalVault, saveLocalVault, saveToVault, storeVaultBlob } from '../src/wallet'
const BLOB = {
cipher_version: 'aes-256-gcm-v1',
kdf_version: 'argon2id-v1',
salt: 'c2FsdA',
nonce: 'bm9uY2U',
ciphertext: 'Y2lwaGVy',
auth_tag: 'dGFn',
}
/** In-memory StorageAdapter для теста локальной копии. */
function memStorage(): StorageAdapter & { map: Map<string, string> } {
const map = new Map<string, string>()
return {
map,
get: async k => map.get(k) ?? null,
set: async (k, v) => void map.set(k, v),
remove: async k => void map.delete(k),
}
}
afterEach(() => vi.unstubAllGlobals())
describe('storeVaultBlob (Story 11.3) — POST /coop/vault', () => {
it('шлёт POST с плоскими subject_type/subject_id + полями blob; 201 ok', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({}) })
vi.stubGlobal('fetch', fetchMock)
await storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB)
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/vault')
expect(fetchMock.mock.calls[0][1].method).toBe('POST')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ ...BLOB, subject_type: 'participant', subject_id: 'ant' })
})
it('не-ok ответ → NetworkError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({}) }))
await expect(storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB))
.rejects
.toMatchObject({ code: 'network_error' })
})
it('сеть упала → NetworkError', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
await expect(storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB))
.rejects
.toMatchObject({ code: 'network_error' })
})
})
describe('локальная копия vault (Story 11.3)', () => {
it('save → load возвращает blob того же аккаунта', async () => {
const storage = memStorage()
await saveLocalVault(storage, 'ant', BLOB)
expect(await loadLocalVault(storage, 'ant')).toEqual(BLOB)
})
it('load для другого аккаунта → null (чужой blob не отдаём)', async () => {
const storage = memStorage()
await saveLocalVault(storage, 'ant', BLOB)
expect(await loadLocalVault(storage, 'someone-else')).toBeNull()
})
it('нет записи → null; clear удаляет', async () => {
const storage = memStorage()
expect(await loadLocalVault(storage, 'ant')).toBeNull()
await saveLocalVault(storage, 'ant', BLOB)
await clearLocalVault(storage)
expect(await loadLocalVault(storage, 'ant')).toBeNull()
})
})
describe('saveToVault (Story 11.3) — encrypt → POST → локальная копия', () => {
it('шифрует ключ паролём, шлёт на сервер и кладёт локально; blob расшифровывается обратно', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({}) })
vi.stubGlobal('fetch', fetchMock)
const storage = memStorage()
const wif = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const blob = await saveToVault({ apiUrl: 'https://coop.example', account: 'ant', privateKey: wif, password: 'P@ss', storage })
// сервер получил blob с субъектом ant
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/vault')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ subject_type: 'participant', subject_id: 'ant', cipher_version: 'aes-256-gcm-v1' })
// локальная копия сохранена
expect(await loadLocalVault(storage, 'ant')).toEqual(blob)
// round-trip: тем же паролём ключ восстанавливается (неверный пароль покрыт wallet.test.ts)
expect(await decryptPrivateKey(blob, 'P@ss', { subject_type: 'participant', subject_id: 'ant' })).toBe(wif)
}, 60000)
})
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import { decryptPrivateKey, encryptPrivateKey } from '../src/vault'
import type { VaultSubject } from '../src/vault'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PW = 'correct-horse-battery-staple-12'
const SUBJECT: VaultSubject = { subject_type: 'participant', subject_id: 'ant' }
describe('vault: Argon2id + AES-256-GCM', () => {
it('round-trip: encrypt → decrypt тем же паролем даёт исходный ключ', async () => {
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
expect(blob.cipher_version).toBe('aes-256-gcm-v1')
expect(blob.kdf_version).toBe('argon2id-v1')
const restored = await decryptPrivateKey(blob, PW, SUBJECT)
expect(restored).toBe(KEY)
})
it('каждое шифрование — новые salt и nonce', async () => {
const a = await encryptPrivateKey(KEY, PW, SUBJECT)
const b = await encryptPrivateKey(KEY, PW, SUBJECT)
expect(a.salt).not.toBe(b.salt)
expect(a.nonce).not.toBe(b.nonce)
expect(a.ciphertext).not.toBe(b.ciphertext)
})
it('неверный пароль → VaultDecryptionFailed', async () => {
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
const err = await decryptPrivateKey(blob, 'wrong-password-000000000000', SUBJECT).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
})
it('подмена subject_id НЕ ломает расшифровку: AAD не зависит от account (решение 2026-06-15)', async () => {
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
// другой subject_id, тот же тип и пароль → расшифровка проходит (id не в AAD).
const restored = await decryptPrivateKey(blob, PW, { subject_type: 'participant', subject_id: 'petrov' })
expect(restored).toBe(KEY)
})
it('подмена типа субъекта (AAD) ломает расшифровку', async () => {
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
const err = await decryptPrivateKey(blob, PW, { subject_type: 'coop', subject_id: 'ant' }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
})
}, 30000)
@@ -0,0 +1,82 @@
import { PrivateKey } from '@wharfkit/antelope'
import { base64url } from 'jose'
import { afterEach, describe, expect, it } from 'vitest'
import { type KeyValidityWindow, verifyDocumentOffline } from '../src/index'
import { signDocument } from '../src/signing'
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
const KEY1 = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PUB1 = PrivateKey.from(KEY1).toPublic().toString()
const ACCOUNT = 'ant'
// Второй ключ — результат ротации (Story 3.3). Генерим, чтобы не хардкодить.
const key2 = PrivateKey.generate('K1')
const KEY2 = key2.toString()
const PUB2 = key2.toPublic().toString()
// key1 активен в блоках [100,200], затем ротация на key2 [201, сейчас].
const authority: KeyValidityWindow[] = [
{ public_key: PUB1, valid_from_block: 100, valid_to_block: 200 },
{ public_key: PUB2, valid_from_block: 201, valid_to_block: null },
]
async function signWith(wif: string, payload: string): Promise<string> {
storeUnlocked({ account: ACCOUNT, publicKey: PrivateKey.from(wif).toPublic().toString(), privateKey: wif })
const jws = await signDocument({ payload })
wipeKeystore()
return jws
}
afterEach(() => wipeKeystore())
describe('verifyDocumentOffline: backward-валидность (Story 2.5)', () => {
it('подпись старым ключом валидна, если ключ был активен на момент подписи (хотя сейчас ротирован)', async () => {
const jws = await signWith(KEY1, 'протокол собрания №7')
const res = await verifyDocumentOffline({ jws, signedAtBlock: 150, authority })
expect(res.valid).toBe(true)
expect(res.matched_key).toBe(PUB1)
})
it('та же подпись на блоке ПОСЛЕ ротации → невалидна (key_not_active_at_signing)', async () => {
const jws = await signWith(KEY1, 'протокол собрания №7')
const res = await verifyDocumentOffline({ jws, signedAtBlock: 250, authority })
expect(res.valid).toBe(false)
expect(res.reason).toBe('key_not_active_at_signing')
})
it('подпись текущим ключом в его окне → валидна', async () => {
const jws = await signWith(KEY2, 'протокол собрания №8')
const res = await verifyDocumentOffline({ jws, signedAtBlock: 300, authority })
expect(res.valid).toBe(true)
expect(res.matched_key).toBe(PUB2)
})
it('подделка payload → signature_mismatch', async () => {
const jws = await signWith(KEY1, 'оригинальный документ')
const [h, , s] = jws.split('.')
const tampered = `${h}.${base64url.encode(new TextEncoder().encode('подменённый документ'))}.${s}`
const res = await verifyDocumentOffline({ jws: tampered, signedAtBlock: 150, authority })
expect(res.valid).toBe(false)
expect(res.reason).toBe('signature_mismatch')
})
it('чужой ключ (нет в хронологии подписанта) → signature_mismatch', async () => {
const jws = await signWith(KEY2, 'документ')
const onlyKey1: KeyValidityWindow[] = [{ public_key: PUB1, valid_from_block: 100, valid_to_block: 200 }]
const res = await verifyDocumentOffline({ jws, signedAtBlock: 150, authority: onlyKey1 })
expect(res.valid).toBe(false)
expect(res.reason).toBe('signature_mismatch')
})
it('некорректный JWS → malformed_jws', async () => {
expect((await verifyDocumentOffline({ jws: 'a.b', signedAtBlock: 1, authority })).reason).toBe('malformed_jws')
})
it('неподдерживаемый alg → unsupported_alg', async () => {
const h = base64url.encode(new TextEncoder().encode(JSON.stringify({ alg: 'RS256' })))
const p = base64url.encode(new TextEncoder().encode('x'))
const sig = base64url.encode(new Uint8Array(64))
const res = await verifyDocumentOffline({ jws: `${h}.${p}.${sig}`, signedAtBlock: 1, authority })
expect(res.reason).toBe('unsupported_alg')
})
})
+167
View File
@@ -0,0 +1,167 @@
import type { PrivateKey as PrivateKeyType } from '@wharfkit/antelope'
import { PrivateKey } from '@wharfkit/antelope'
import { base64url } from 'jose'
import { describe, expect, it } from 'vitest'
import { type CoopChainLink, verifyOffline } from '../src/index'
// Цепь доверия ano → voskhod → vostok(issuer). Ключи генерим, чтобы не хардкодить.
const anoKey = PrivateKey.generate('K1')
const voskhodKey = PrivateKey.generate('K1')
const issuerKey = PrivateKey.generate('K1') // vostok — издатель, его ключом подписан cert
const PUB_ANO = anoKey.toPublic().toString()
const PUB_VOSKHOD = voskhodKey.toPublic().toString()
const PUB_ISSUER = issuerKey.toPublic().toString()
const CHAIN: CoopChainLink[] = [
{ account: 'ano', public_key: PUB_ANO },
{ account: 'voskhod', public_key: PUB_VOSKHOD },
{ account: 'vostok', public_key: PUB_ISSUER },
]
// Полный доверенный кэш (chain_manifests_cache) — все звенья известны.
const TRUSTED: Record<string, string> = { ano: PUB_ANO, voskhod: PUB_VOSKHOD, vostok: PUB_ISSUER }
const NOW = 1_900_000_000_000 // фиксированный «сейчас» (мс)
const FUTURE_EXP = Math.floor(NOW / 1000) + 3600 // +1ч
const PAST_EXP = Math.floor(NOW / 1000) - 10
interface CertOpts {
chain?: CoopChainLink[]
exp?: number
alg?: string
signWith?: PrivateKeyType
schemaVersion?: string
}
/** Собрать compact JWS-сертификат (формат CertificateService, Story 1.8). */
function makeCert(opts: CertOpts = {}): string {
const signer = opts.signWith ?? issuerKey
const header = base64url.encode(JSON.stringify({ alg: opts.alg ?? 'ES256K', typ: 'JWT', kid: signer.toPublic().toString() }))
const payload = base64url.encode(JSON.stringify({
coopname: 'vostok',
coop_chain: opts.chain ?? CHAIN,
exp: opts.exp ?? FUTURE_EXP,
sub: 'uuid-1',
jti: 'serial-123',
claim_schema_version: opts.schemaVersion ?? '1',
}))
const signingInput = `${header}.${payload}`
const sig = signer.signMessage(new TextEncoder().encode(signingInput))
const rs = sig.data.array.slice(1) // [recovery(1), r(32), s(32)] → R||S
return `${signingInput}.${base64url.encode(rs)}`
}
describe('verifyOffline: офлайн-проверка удостоверения (Story 4.4)', () => {
it('валидный сертификат с доверенной цепью и якорем → valid, issuer', async () => {
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(true)
expect(res.issuer).toBe('vostok')
expect(res.reason).toBeUndefined()
})
it('exp в прошлом → expired', async () => {
const res = await verifyOffline(makeCert({ exp: PAST_EXP }), { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(false)
expect(res.reason).toBe('expired')
})
it('цепь не укоренена в известном ano (чужой якорь) → untrusted_anchor', async () => {
const other = PrivateKey.generate('K1').toPublic().toString()
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, trustAnchor: other, now: NOW })
expect(res.valid).toBe(false)
expect(res.reason).toBe('untrusted_anchor')
})
it('звено издателя не совпадает с доверенным кэшем → untrusted_issuer', async () => {
const foreign = PrivateKey.generate('K1').toPublic().toString()
const res = await verifyOffline(makeCert(), {
trustedKeys: { ano: PUB_ANO, voskhod: PUB_VOSKHOD, vostok: foreign },
now: NOW,
})
expect(res.valid).toBe(false)
expect(res.reason).toBe('untrusted_issuer')
})
it('без доверенного кэша ключей → fail-closed (untrusted_issuer)', async () => {
const res = await verifyOffline(makeCert(), { trustAnchor: PUB_ANO, now: NOW })
expect(res.valid).toBe(false)
expect(res.reason).toBe('untrusted_issuer')
})
it('подпись чужим ключом при валидной цепи → signature_mismatch', async () => {
// cert подписан foreignKey, но coop_chain заявляет издателя PUB_ISSUER;
// verifyOffline сверяет подпись с ключом издателя из цепи (не с kid) → провал.
const cert = makeCert({ signWith: PrivateKey.generate('K1') })
const res = await verifyOffline(cert, { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(false)
expect(res.reason).toBe('signature_mismatch')
})
it('high-S подпись (не каноническая, как у jose/Node) → принимается через low-S нормализацию', async () => {
const cert = makeCert()
const [h, p, s] = cert.split('.')
const rs = base64url.decode(s)
// S → n − S (переводим каноническую low-S подпись wharfkit в high-S вариант).
const n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n
let sVal = 0n
for (const b of rs.slice(32, 64))
sVal = (sVal << 8n) | BigInt(b)
let high = n - sVal
const highRs = new Uint8Array(rs)
for (let i = 63; i >= 32; i--) {
highRs[i] = Number(high & 0xFFn)
high >>= 8n
}
const highCert = `${h}.${p}.${base64url.encode(highRs)}`
const res = await verifyOffline(highCert, { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(true)
expect(res.issuer).toBe('vostok')
})
it('некорректный JWS (две части) → malformed_certificate', async () => {
const res = await verifyOffline('a.b', { trustedKeys: TRUSTED, now: NOW })
expect(res.reason).toBe('malformed_certificate')
})
it('неподдерживаемый alg → unsupported_alg', async () => {
const res = await verifyOffline(makeCert({ alg: 'RS256' }), { trustedKeys: TRUSTED, now: NOW })
expect(res.reason).toBe('unsupported_alg')
})
it('пустой coop_chain → malformed_certificate', async () => {
const res = await verifyOffline(makeCert({ chain: [] }), { trustedKeys: TRUSTED, now: NOW })
expect(res.reason).toBe('malformed_certificate')
})
it('версия схемы старее min_supported_version → unsupported_schema_version (Story 4.10)', async () => {
const res = await verifyOffline(makeCert({ schemaVersion: '0' }), { trustedKeys: TRUSTED, now: NOW, minSchemaVersion: '1' })
expect(res.valid).toBe(false)
expect(res.reason).toBe('unsupported_schema_version')
})
it('версия схемы = min_supported_version → ось схемы пройдена, cert валиден (Story 4.10)', async () => {
const res = await verifyOffline(makeCert({ schemaVersion: '1' }), { trustedKeys: TRUSTED, now: NOW, minSchemaVersion: '1' })
expect(res.valid).toBe(true)
expect(res.issuer).toBe('vostok')
})
it('без minSchemaVersion ось схемы не гейтит — даже старая версия проходит (Story 4.10)', async () => {
const res = await verifyOffline(makeCert({ schemaVersion: '0' }), { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(true)
})
it('не делает сетевых запросов (fetch недоступен) → всё равно verdict', async () => {
const orig = globalThis.fetch
globalThis.fetch = (() => {
throw new Error('сеть запрещена в офлайн-проверке')
}) as typeof fetch
try {
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(true)
}
finally {
globalThis.fetch = orig
}
})
})
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import { encryptPrivateKey } from '../src/vault'
import {
getWallet,
isWalletUnlocked,
lockWallet,
unlockWallet,
Wallet,
} from '../src/wallet'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PW = 'correct-horse-battery-staple-12'
const ACCOUNT = 'ant'
const API = 'http://stub'
/** Подменяет fetch так, чтобы GET vault отдавал заранее зашифрованный blob. */
async function stubVaultFetch(password = PW): Promise<void> {
const blob = await encryptPrivateKey(KEY, password, { subject_type: 'participant', subject_id: ACCOUNT })
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => blob }) as any))
}
beforeEach(() => {
lockWallet()
})
afterEach(() => {
vi.unstubAllGlobals()
lockWallet()
})
describe('wallet: unlock + in-memory keystore + getWallet', () => {
it('unlock → getWallet отдаёт account и деривованный publicKey', async () => {
await stubVaultFetch()
const wallet = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
expect(wallet.account).toBe(ACCOUNT)
expect(wallet.publicKey).toMatch(/^PUB_K1_/)
expect(isWalletUnlocked()).toBe(true)
const again = await getWallet()
expect(again.publicKey).toBe(wallet.publicKey)
}, 30000)
it('неверный пароль → VaultDecryptionFailed, keystore остаётся пуст', async () => {
await stubVaultFetch(PW)
const err = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: 'wrong-password-000000000000' })
.then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
expect(isWalletUnlocked()).toBe(false)
}, 30000)
it('getWallet при запертом кошельке → WalletLocked', async () => {
const err = await getWallet().then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
it('сеть недоступна → NetworkError', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) }) as any))
const err = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.NetworkError)
})
})
describe('wallet: несериализуемость приватного ключа', () => {
it('JSON.stringify(wallet) не раскрывает приватный ключ', async () => {
await stubVaultFetch()
const wallet = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
const serialized = JSON.stringify(wallet)
expect(serialized).not.toContain(KEY)
expect(JSON.parse(serialized)).toEqual({ account: ACCOUNT, publicKey: wallet.publicKey })
// приватного ключа нет ни в одном перечислимом свойстве обёртки
expect(Object.values(wallet as unknown as Record<string, unknown>)).not.toContain(KEY)
expect(wallet).toBeInstanceOf(Wallet)
}, 30000)
})
describe('wallet: lock/wipe', () => {
it('lockWallet затирает keystore → getWallet бросает; идемпотентно', async () => {
await stubVaultFetch()
await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
expect(isWalletUnlocked()).toBe(true)
lockWallet()
expect(isWalletUnlocked()).toBe(false)
lockWallet() // повторно — без ошибки
const err = await getWallet().then(() => null, e => e)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
}, 30000)
})
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"strict": true,
"strictNullChecks": true,
"noEmit": true,
"esModuleInterop": true,
"skipDefaultLibCheck": true,
"skipLibCheck": true
},
"include": ["src/**/*", "test/**/*"]
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Браузерный таргет cross-runtime smoke (Story 9.13): vitest browser mode,
* playwright + chromium headless. Гоняет ТОЛЬКО test/cross-runtime/** —
* остальные юнит-тесты пакета остаются Node-таргетом (`pnpm test`).
*/
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/cross-runtime/**/*.test.ts'],
browser: {
enabled: true,
provider: 'playwright',
name: 'chromium',
headless: true,
screenshotFailures: false,
},
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.5.23-4",
"version": "2026.6.9-18",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+2 -2
View File
@@ -362,9 +362,9 @@ export async function runCli(argv: string[]): Promise<void> {
)
.action(async (paths: string[]) => {
const root = requireRoot()
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts } = await runAdd(root, paths)
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity } = await runAdd(root, paths)
success(
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}.`,
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}; не blago-сущности (нет type=project/issue/story во frontmatter): ${skippedNonEntity}.`,
)
})
+16
View File
@@ -38,6 +38,22 @@ export function parseBlagoMarkdown(raw: string): ParsedBlagoFile {
}
}
/** Не бросает: возвращает тип сущности blago, если файл — корректный project/issue/story; иначе undefined. */
export function peekBlagoEntityType(raw: string): EntityFrontmatterType | undefined {
let file: ReturnType<typeof matter>
try {
file = matter(raw)
}
catch {
return undefined
}
const t = file.data?.type
if (t === 'project' || t === 'issue' || t === 'story') {
return t
}
return undefined
}
export function serializeBlagoMarkdown(data: Record<string, unknown>, body: string): string {
const trimmed = body.endsWith('\n') ? body : `${body}\n`
// matter.stringify(string, data) сначала парсит string как frontmatter — body, начинающийся
+40 -11
View File
@@ -3,6 +3,7 @@
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { peekBlagoEntityType } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { expandBlagoUserTargetsToRelativePaths } from './capital-target-expand.js'
import { isBlagoSyncExcludedDirName, isIgnoredRelativePath, loadBlagoIgnoreRules } from './ignore.js'
@@ -28,19 +29,39 @@ async function collectMarkdownFiles(absDir: string): Promise<string[]> {
return out
}
/** true — файл стоит поставить в staging: нет в индексе или SHA256 на диске ≠ content_etag_local. */
async function isDirtyVsIndex(root: string, rel: string, index: Awaited<ReturnType<typeof loadIndex>>): Promise<boolean> {
const entry = findByRelativePath(index, rel)
if (!entry) {
return true
}
type FileCheck =
| { kind: 'unreadable' }
| { kind: 'non-entity' }
| { kind: 'clean' }
| { kind: 'dirty' }
/**
* Решение по одному файлу для staging:
* — нечитаемый → dirty (push сам сообщит вменяемую ошибку),
* — без корректного frontmatter type → non-entity (README/notes/CLAUDE/AGENTS и пр. не отправляем),
* — есть в индексе и sha256 совпал → clean,
* — иначе dirty.
*/
async function classifyFileForAdd(
root: string,
rel: string,
index: Awaited<ReturnType<typeof loadIndex>>,
): Promise<FileCheck> {
let raw: string
try {
const raw = await fs.readFile(path.join(root, rel), 'utf8')
return sha256Hex(raw) !== entry.content_etag_local
raw = await fs.readFile(path.join(root, rel), 'utf8')
}
catch {
return true
return { kind: 'unreadable' }
}
if (peekBlagoEntityType(raw) === undefined) {
return { kind: 'non-entity' }
}
const entry = findByRelativePath(index, rel)
if (!entry) {
return { kind: 'dirty' }
}
return sha256Hex(raw) === entry.content_etag_local ? { kind: 'clean' } : { kind: 'dirty' }
}
export interface RunAddResult {
@@ -50,6 +71,8 @@ export interface RunAddResult {
skippedIgnored: number
/** messages/ и meetings/ — только синхронизация с сервера, в push не идут */
skippedPullOnlyArtifacts: number
/** Файлы без корректного blago-frontmatter (README, notes, CLAUDE/AGENTS и пр.) — в staging не попадают */
skippedNonEntity: number
}
export async function runAdd(root: string, targets: string[]): Promise<RunAddResult> {
@@ -65,6 +88,7 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
let skippedUnchanged = 0
let skippedIgnored = 0
let skippedPullOnlyArtifacts = 0
let skippedNonEntity = 0
for (const t of expanded) {
const abs = path.resolve(root, t)
@@ -86,7 +110,12 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
skippedPullOnlyArtifacts += 1
continue
}
if (!(await isDirtyVsIndex(root, rel, index))) {
const check = await classifyFileForAdd(root, rel, index)
if (check.kind === 'non-entity') {
skippedNonEntity += 1
continue
}
if (check.kind === 'clean') {
skippedUnchanged += 1
continue
}
@@ -96,5 +125,5 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
const stagedPaths = [...set].sort()
await saveStaging(root, { paths: stagedPaths })
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts }
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity }
}
@@ -9,12 +9,15 @@ export interface CommunicationCursorsFile {
messageLastTsByRoom: Record<string, number>
/** project_hash → ISO instant: транскрипции с endedAt ≤ этого момента уже выгружены */
transcriptionLastEndedExclusiveByProject: Record<string, string>
/** matrixRoomId → ISO instant: транскрипции непроектной комнаты с endedAt ≤ этого момента уже выгружены */
transcriptionLastEndedExclusiveByRoom: Record<string, string>
}
function empty(): CommunicationCursorsFile {
return {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
@@ -32,6 +35,11 @@ export async function loadCommunicationCursors(root: string): Promise<Communicat
&& typeof parsed.transcriptionLastEndedExclusiveByProject === 'object'
? { ...parsed.transcriptionLastEndedExclusiveByProject }
: {},
transcriptionLastEndedExclusiveByRoom:
parsed.transcriptionLastEndedExclusiveByRoom !== undefined
&& typeof parsed.transcriptionLastEndedExclusiveByRoom === 'object'
? { ...parsed.transcriptionLastEndedExclusiveByRoom }
: {},
}
}
catch {
@@ -43,7 +43,6 @@ export function projectCommunicationDayToMarkdown(
}
export interface TranscriptionHeaderModel {
matrixRoomId: string
roomId: string
startedAt: Date | string
endedAt: Date | string | null | undefined
@@ -71,7 +70,6 @@ export function renderCallTranscriptionMarkdown(
const header = [
`# Транскрипция звонка`,
``,
`- Matrix room: \`${transcription.matrixRoomId}\``,
`- LiveKit room: \`${transcription.roomId}\``,
`- Начало: ${start.toISOString()}`,
`- Окончание: ${end ? end.toISOString() : '—'}`,
@@ -25,7 +25,7 @@ import {
transcriptionMeetingFileStemUtc,
type CommunicationDayLine,
} from './communication-markdown.js'
import { workspaceBasePath, type ProjectPathModel } from './layout.js'
import { generateSlug, workspaceBasePath, type ProjectPathModel } from './layout.js'
import { syncEntityFile } from './sync-entity-file.js'
interface ProjectRowLite {
@@ -183,6 +183,19 @@ async function listRooms(ctx: AuthenticatedContext, projectHash: string) {
return q[Queries.ChatCoop.ListProjectCommunicationRooms.name] ?? []
}
/** Стабильная папка непроектной комнаты в `rooms/`. Системные — фиксированные, комнаты секретаря — slug + хвост id (уникальность). */
function nonProjectRoomFolder(kind: string, matrixRoomId: string, displayLabel: string): string {
if (kind === 'MEMBERS') {
return 'komnata-paishchikov'
}
if (kind === 'COUNCIL') {
return 'komnata-soveta'
}
const slug = generateSlug(displayLabel) || 'komnata'
const shortId = createHash('sha256').update(matrixRoomId, 'utf8').digest('hex').slice(0, 6)
return `${slug}-${shortId}`
}
export async function pullProjectCommunicationArtifacts(
ctx: AuthenticatedContext,
index: IndexFile,
@@ -198,6 +211,7 @@ export async function pullProjectCommunicationArtifacts(
cursors = {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
@@ -356,7 +370,6 @@ export async function pullProjectCommunicationArtifacts(
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
const md = renderCallTranscriptionMarkdown(
{
matrixRoomId: String(tr.matrixRoomId),
roomId: String(tr.roomId),
startedAt,
endedAt: endedAtTr,
@@ -422,3 +435,207 @@ export async function pullProjectCommunicationArtifacts(
warn(`Не удалось сохранить курсоры переписки: ${formatThrownValue(e)}`)
}
}
/**
* Pull переписки и транскрипций из комнат ВНЕ проектов Capital (пайщики, совет, комнаты секретаря).
* Раскладка — отдельная верхняя папка `rooms/<folder>/{messages,meetings}/`, чтобы не смешивать с
* проектными `meetings/`. Логика идентична проектной, но bucket = одна комната, курсор транскрипций — по matrixRoomId.
*/
export async function pullNonProjectCommunicationArtifacts(
ctx: AuthenticatedContext,
index: IndexFile,
): Promise<void> {
let rooms: { matrixRoomId: string, displayLabel: string, kind: string }[]
try {
const q = await ctx.client.Query(Queries.ChatCoop.ListNonProjectCommunicationRooms.query, {})
rooms = (q[Queries.ChatCoop.ListNonProjectCommunicationRooms.name] ?? []) as typeof rooms
}
catch (e) {
warn(`Список непроектных комнат (chatcoopListNonProjectCommunicationRooms): ${formatThrownValue(e)}`)
return
}
if (rooms.length === 0) {
return
}
let cursors: CommunicationCursorsFile
try {
cursors = await loadCommunicationCursors(ctx.root)
}
catch (e) {
warn(`Курсоры переписки (комнаты): не удалось прочитать, начинаем с пустых: ${formatThrownValue(e)}`)
cursors = {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
for (const room of rooms) {
const folder = nonProjectRoomFolder(room.kind, room.matrixRoomId, room.displayLabel)
const basePath = `rooms/${folder}`
const roomTitle = room.displayLabel || room.matrixRoomId
// Сообщения комнаты — по календарным суткам UTC новее курсора.
try {
const last = cursors.messageLastTsByRoom[room.matrixRoomId]
const afterTs = last ?? 0
const datesQ = await ctx.client.Query(Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, afterOriginServerTsExclusive: afterTs } },
})
const dates = (datesQ[Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.name] ?? []).sort()
for (const utcDate of dates) {
const mq = await ctx.client.Query(Queries.ChatCoop.GetRoomMessagesForUtcDate.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, utcDate } },
})
const linesRaw = mq[Queries.ChatCoop.GetRoomMessagesForUtcDate.name] ?? []
const lines: CommunicationDayLine[] = linesRaw.map(m => ({
originServerTs: m.originServerTs,
authorLabel: m.authorLabel,
coopUsername: m.coopUsername,
kind: String(m.kind),
bodyText: m.bodyText,
}))
if (lines.length === 0) {
continue
}
const content = projectCommunicationDayToMarkdown(roomTitle, room.matrixRoomId, utcDate, [
{ displayLabel: room.displayLabel, matrixRoomId: room.matrixRoomId, lines },
])
const rel = `${basePath}/messages/${utcDate}.md`
const entityHash = messageDayEntityHash(room.matrixRoomId, utcDate)
await syncEntityFile({
root: ctx.root,
index,
entityType: 'room_message_day',
entityHash,
relativePath: rel,
content,
remoteUpdatedAt: `${utcDate}T23:59:59.999Z`,
label: `переписка ${utcDate} (${room.matrixRoomId})`,
})
}
const maxQ = await ctx.client.Query(Queries.ChatCoop.GetMaxOriginServerTsForRoom.query, {
variables: { data: { matrixRoomId: room.matrixRoomId } },
})
const maxTs = maxQ[Queries.ChatCoop.GetMaxOriginServerTsForRoom.name] as number | null | undefined
if (maxTs !== undefined && maxTs !== null && Number.isFinite(maxTs)) {
cursors.messageLastTsByRoom[room.matrixRoomId] = maxTs
}
}
catch (e) {
warn(`Переписка Matrix, комната ${room.matrixRoomId} (${roomTitle}): ${formatThrownValue(e)}`)
}
// Транскрипции звонков комнаты + sibling memo.
try {
const tExIso = cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId]
const lowerBoundExclusive = tExIso === undefined ? new Date(0) : new Date(tExIso)
interface TranscriptionCandidate {
id: string
endedAt: Date
memo: string
updatedAt: Date | undefined
}
const byId = new Map<string, TranscriptionCandidate>()
const tq = await ctx.client.Query(Queries.ChatCoop.GetTranscriptions.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, limit: CHATCOOP_TRANSCRIPTIONS_QUERY_LIMIT, offset: 0 } },
})
const list = tq[Queries.ChatCoop.GetTranscriptions.name] ?? []
for (const t of list) {
const end = dateFromUnknown(t.endedAt)
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
continue
}
const prev = byId.get(t.id)
if (!prev || end > prev.endedAt) {
byId.set(t.id, {
id: t.id,
endedAt: end,
memo: typeof t.memo === 'string' ? t.memo : '',
updatedAt: dateFromUnknown(t.updatedAt),
})
}
}
const allCompleted = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
const newMeetings = allCompleted.filter(c => c.endedAt.getTime() > lowerBoundExclusive.getTime())
let maxEnded: Date | null = null
for (const c of newMeetings) {
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
variables: { data: { id: c.id } },
})
const pack = packQ[Queries.ChatCoop.GetTranscription.name]
if (!pack?.transcription || pack.transcription.status !== Zeus.TranscriptionStatus.COMPLETED) {
continue
}
const tr = pack.transcription
const startedAt: Date | string = dateFromUnknown(tr.startedAt) ?? (tr.startedAt as Date | string)
const endedAtTr: Date | string | null | undefined
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
const md = renderCallTranscriptionMarkdown(
{
roomId: String(tr.roomId),
startedAt,
endedAt: endedAtTr,
},
pack.segments.map(s => ({
speakerName: s.speakerName,
text: s.text,
startOffset: s.startOffset,
endOffset: s.endOffset,
})),
)
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
const rel = `${basePath}/meetings/${stem}.md`
const entityHash = c.id.toLowerCase()
await syncEntityFile({
root: ctx.root,
index,
entityType: 'call_transcription',
entityHash,
relativePath: rel,
content: md,
remoteUpdatedAt: toUpdatedIso(c.endedAt),
label: `транскрипция ${c.id}`,
})
if (!maxEnded || c.endedAt > maxEnded) {
maxEnded = c.endedAt
}
}
for (const c of allCompleted) {
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
const memoRel = `${basePath}/meetings/${stem}.memo.md`
const memoRemoteUpdatedAt = toUpdatedIso(c.updatedAt ?? c.endedAt)
await syncTranscriptionMemoFile({
root: ctx.root,
index,
transcriptionId: c.id,
relativePath: memoRel,
serverMemo: c.memo,
remoteUpdatedAtIso: memoRemoteUpdatedAt,
})
}
if (maxEnded) {
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = maxEnded.toISOString()
}
else if (tExIso === undefined) {
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = new Date().toISOString()
}
}
catch (e) {
warn(`Транскрипции звонков, комната ${room.matrixRoomId}: ${formatThrownValue(e)}`)
}
}
try {
await saveCommunicationCursors(ctx.root, cursors)
}
catch (e) {
warn(`Не удалось сохранить курсоры переписки (комнаты): ${formatThrownValue(e)}`)
}
}
+3 -1
View File
@@ -28,7 +28,7 @@ import {
storyFileRelativePath,
workspaceBasePath,
} from './layout.js'
import { pullProjectCommunicationArtifacts } from './pull-communication.js'
import { pullNonProjectCommunicationArtifacts, pullProjectCommunicationArtifacts } from './pull-communication.js'
import { scaffoldBmadWorkspacesAfterPull } from './scaffold-bmad-workspace.js'
import { syncEntityFile } from './sync-entity-file.js'
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
@@ -329,6 +329,8 @@ export async function runPull(ctx: AuthenticatedContext, options: RunPullOptions
await pullProjectCommunicationArtifacts(ctx, index, allProjects, projectByHash)
await pullNonProjectCommunicationArtifacts(ctx, index)
await scaffoldBmadWorkspacesAfterPull(ctx, allProjects, projectByHash)
await saveIndex(ctx.root, index)
+43 -3
View File
@@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { Mutations, Queries } from '@coopenomics/sdk'
import { parseBlagoMarkdown } from '../format/index.js'
import { parseBlagoMarkdown, peekBlagoEntityType } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { effectiveParentHash } from '../lib/parent-hash.js'
import { warn } from '../ui/output.js'
@@ -58,6 +58,30 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
}
staging = await loadStaging(ctx.root)
}
// Не-сущности (README/notes/CLAUDE/AGENTS и пр., в .md без type=project|issue|story) тоже не должны валить весь push.
// Чистим их автоматически — иначе одна забытая заметка блокирует отправку реальных правок.
const stagingAbs = (p: string): string => path.join(ctx.root, normalizeRelativePath(p))
const nonEntityInStaging: string[] = []
for (const p of staging.paths) {
try {
const raw = await fs.readFile(stagingAbs(p), 'utf8')
if (peekBlagoEntityType(raw) === undefined) {
nonEntityInStaging.push(normalizeRelativePath(p))
}
}
catch {
// нечитаемый файл — пусть основной цикл бросит понятную ошибку с путём
}
}
if (nonEntityInStaging.length > 0) {
const drop = new Set(nonEntityInStaging)
const kept = staging.paths.filter(p => !drop.has(normalizeRelativePath(p)))
await saveStaging(ctx.root, { paths: [...new Set(kept.map(p => normalizeRelativePath(p)))].sort() })
for (const p of nonEntityInStaging) {
warn(`Убрано из staging (нет blago-frontmatter type=project|issue|story): ${p}`)
}
staging = await loadStaging(ctx.root)
}
if (staging.paths.length === 0) {
throw new Error('Нечего отправлять. Добавьте файлы: blago add <путь | id проекта | projectId-issueId>')
}
@@ -75,8 +99,24 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
}
const abs = path.join(ctx.root, n)
const raw = await fs.readFile(abs, 'utf8')
const parsed = parseBlagoMarkdown(raw)
const { type, hash } = validateParsedForPush(parsed)
let parsed: ReturnType<typeof parseBlagoMarkdown>
try {
parsed = parseBlagoMarkdown(raw)
}
catch (err) {
// Без префикса путём из staging вылезает «type: undefined» без указания файла.
const msg = err instanceof Error ? err.message : String(err)
throw new Error(`Файл «${n}»: ${msg}`)
}
let type: ReturnType<typeof validateParsedForPush>['type']
let hash: string
try {
({ type, hash } = validateParsedForPush(parsed))
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throw new Error(`Файл «${n}»: ${msg}`)
}
const entry = findByHash(index, type, hash)
const pKind = pendingKindForEntityType(type)
const pending = pKind ? await findPendingForParsed(ctx.root, pKind, hash) : undefined
-2
View File
@@ -367,7 +367,6 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
if (entry.entity_type === 'call_transcription') {
interface TranscriptionRestorePack {
transcription: {
matrixRoomId: string
roomId: string
startedAt: Date | string
endedAt: Date | string | null | undefined
@@ -394,7 +393,6 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
}
const content = renderCallTranscriptionMarkdown(
{
matrixRoomId: tr.matrixRoomId,
roomId: tr.roomId,
startedAt: tr.startedAt,
endedAt: tr.endedAt,
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.5.23-4",
"version": "2026.6.9-18",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
@@ -26,6 +26,7 @@
"boot:remote": "esno src/index.ts boot:remote",
"reboot:clean": "cd components/boot/scripts && bash clean_reboot.sh",
"reboot:extra": "cd components/boot/scripts && bash extra_reboot.sh",
"reboot:blago": "cd components/boot/scripts && bash blago_reboot.sh",
"create-coop": "esno src/index.ts create-coop",
"clear": "./scripts/clear.sh",
"start": "./scripts/restart.sh",
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# Полный перезапуск dev-среды для теста Благороста одной командой:
# 1) extra_reboot.sh — чистая цепь + кооператив + совет 5 человек +
# dev-shortcut онбординга capital (extensions.capital: все *_done=true);
# 2) ожидание готовности controller (GraphQL отвечает);
# 3) seed-capital до фазы $SEED_UP_TO (default 04-contributor): программы
# УХД/Благорост + setconfig, проекты, регистрация ant как участника.
#
# Глубину сида можно менять: SEED_UP_TO=08-investments pnpm run reboot:blago
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/.env"
set +a
fi
bash "$SCRIPT_DIR/extra_reboot.sh"
# Per-instance адрес controller'а — из корневого .env (API_URL); coopback после
# force-recreate поднимается десятки секунд, seed-фазы без него падают.
GRAPHQL_URL="${API_URL:-http://127.0.0.1:2998/v1/graphql}"
echo "Ждём готовности controller ($GRAPHQL_URL)..."
until curl -s --max-time 3 -o /dev/null -X POST -H 'Content-Type: application/json' \
--data '{"query":"{__typename}"}' "$GRAPHQL_URL"; do
sleep 3
done
echo "Controller готов."
UP_TO="${SEED_UP_TO:-04-contributor}"
echo "Сидируем capital до фазы $UP_TO..."
cd "$SCRIPT_DIR/.."
CONTROLLER_GRAPHQL_URL="$GRAPHQL_URL" pnpm exec esno src/scripts/seed-capital/index.ts --up-to="$UP_TO"
echo "Готово: среда Благорост поднята (seed до $UP_TO)."
+22 -13
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# Загружаем per-instance конфиг из корня репо.
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
@@ -9,26 +10,34 @@ if [ -f "$ROOT_DIR/.env" ]; then
set +a
fi
# Останавливаем и удаляем контейнеры вместе с volumes
echo "Останавливаем и удаляем контейнеры с volumes..."
docker compose down -v mongo postgres cooparser || true
# Останавливаем и удаляем контейнеры (сеть НЕ трогаем — к ней может быть
# подключён внешний контейнер вроде provider-backend; down её снёс бы с ошибкой).
echo "Останавливаем и удаляем контейнеры..."
docker compose rm -fsv mongo postgres monoredis cooparser coopback || true
# Останавливаем blockchain контейнер перед удалением данных
echo "Останавливаем blockchain контейнер..."
docker compose stop node || true
# Удаляем blockchain data
# Удаляем тома баз данных для чистого старта (named volumes rm -v не чистит)
echo "Удаляем тома баз данных..."
PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT_DIR")}"
docker volume rm "${PROJECT}_postgres_data" "${PROJECT}_mongo_data" 2>/dev/null || true
# Удаляем blockchain data через контейнер (alpine под root стирает данные
# независимо от владельца — без sudo на любой ноде; на проде nodeos пишет под root).
echo "Удаляем blockchain data..."
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
sudo rm -rf ../blockchain-data/
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
# Пересоздаем и запускаем базы данных
# Пересоздаем и запускаем базы данных + Redis (monoredis).
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash.
echo "Пересоздаем и запускаем базы данных..."
docker compose up -d mongo postgres
docker compose up -d mongo postgres monoredis
# Ждем готовности MongoDB
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
echo "Ждем готовности MongoDB..."
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
echo "MongoDB еще не готов, ждем..."
sleep 2
done
@@ -42,7 +51,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
done
echo "PostgreSQL готов!"
# Запускаем boot процесс
# Запускаем boot процесс (clean: только программы Благорост/маркетплейс)
echo "Запускаем boot процесс..."
pnpm run boot:clean
@@ -51,6 +60,6 @@ echo "Запускаем parser..."
docker compose up -d cooparser
echo "Запускаем контроллер..."
docker compose restart coopback || true
docker compose up -d --force-recreate coopback || true
echo "Перезапуск завершен!"
+17 -14
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# Загружаем per-instance конфиг из корня репо.
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
@@ -9,30 +10,32 @@ if [ -f "$ROOT_DIR/.env" ]; then
set +a
fi
# Останавливаем контроллер перед очисткой данных
echo "Останавливаем контроллер..."
docker compose down coopback || true
# Останавливаем и удаляем контейнеры вместе с volumes
echo "Останавливаем и удаляем контейнеры с volumes..."
docker compose down -v mongo postgres cooparser || true
docker compose down -v mongo postgres monoredis cooparser coopback || true
# Останавливаем blockchain контейнер перед удалением данных
echo "Останавливаем blockchain контейнер..."
docker compose stop node || true
# Удаляем blockchain data
# Удаляем blockchain data.
# Контейнерный wipe (alpine под root) стирает данные независимо от их владельца —
# без sudo на любой ноде (на Pi нет passwordless sudo; на проде nodeos пишет
# данные под root). Единый способ с reboot.sh / clean_reboot.sh.
echo "Удаляем blockchain data..."
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
sudo rm -rf ../blockchain-data/
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
# Пересоздаем и запускаем базы данных
# Пересоздаем и запускаем базы данных + Redis (monoredis).
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash,
# и провайдер не может взять org-данные partner1 (PROVIDER_URL=coopback:2998).
echo "Пересоздаем и запускаем базы данных..."
docker compose up -d mongo postgres
docker compose up -d mongo postgres monoredis
# Ждем готовности MongoDB
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
echo "Ждем готовности MongoDB..."
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
echo "MongoDB еще не готов, ждем..."
sleep 2
done
@@ -46,7 +49,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
done
echo "PostgreSQL готов!"
# Запускаем boot процесс
# Запускаем boot процесс (расширенный: совет + пайщики; partner1 — при EXTRA_RENT=1)
echo "Запускаем boot процесс..."
pnpm run boot:extra
@@ -55,6 +58,6 @@ echo "Запускаем parser..."
docker compose up -d cooparser
echo "Запускаем контроллер..."
docker compose up -d coopback
docker compose up -d --force-recreate coopback || true
echo "Перезапуск завершен!"
+37 -22
View File
@@ -291,31 +291,46 @@ export default class Blockchain {
async activateFeature(feature: Feature) {
await this.update_pass_instance()
await this.api.transact(
{
actions: [
{
account: 'eosio',
name: 'activate',
authorization: [
{
actor: 'eosio',
permission: 'active',
try {
await this.api.transact(
{
actions: [
{
account: 'eosio',
name: 'activate',
authorization: [
{
actor: 'eosio',
permission: 'active',
},
],
data: {
feature_digest: feature.hash,
},
],
data: {
feature_digest: feature.hash,
},
},
],
},
{
blocksBehind: 3,
expireSeconds: 30,
},
)
],
},
{
blocksBehind: 3,
expireSeconds: 30,
},
)
console.log('Фича активирована: ', feature.name)
console.log('Фича активирована: ', feature.name)
}
catch (e: any) {
// На уже забутстрапленном (не обнулённом) чейне протокол-фича уже активна —
// nodeos отдаёт protocol_feature_exception (code 3250000). Это не повод
// ронять весь boot: фича на месте, нужный результат достигнут, продолжаем.
const errName = e?.json?.error?.name
const errCode = e?.json?.error?.code
const msg = e instanceof Error ? e.message : String(e)
if (errName === 'protocol_feature_exception' || errCode === 3250000 || /already activated/i.test(msg)) {
console.warn(`Фича уже активирована, пропускаю: ${feature.name}`)
return
}
throw e
}
}
async createToken(params: TokenContract.Interfaces.ICreate) {
+5
View File
@@ -128,4 +128,9 @@ export default [
path: path.join(userBase, 'apps'),
target: 'apps',
},
{
name: 'expense',
path: path.join(userBase, 'expense'),
target: 'expense',
},
]
+4
View File
@@ -154,6 +154,10 @@ export default {
name: 'apps',
code_permissions_to: ['apps'],
},
{
name: 'expense',
code_permissions_to: ['expense'],
},
// {
// name: provider_chairman,
// },
@@ -0,0 +1,11 @@
{
"protocol_feature_type": "builtin",
"dependencies": [],
"description_digest": "2ad6b7aabba8edbc87b46f3340df4d0db0e4b65474db3e1cd5db33fb685a506b",
"subjective_restrictions": {
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
"preactivation_required": true,
"enabled": true
},
"builtin_feature_codename": "ASSERT_RECOVER_KEY_ACCOUNT"
}
@@ -0,0 +1,33 @@
{
"protocol_feature_type": "builtin",
"dependencies": [
"09e86cb0accf8d81c9e85d34bea4b925ae936626d00c984e4691186891f5bc16",
"1a99a59d87e06e09ec5b028a9cbb7749b4a5ad8819004365d02dc4379a8b7241",
"2652f5f96006294109b3dd0bbde63693f55324af452b799ee137a81a905eed25",
"299dcb6af692324b899b39f16d5a530a33062804e41f09dc97e9f156b4476707",
"35c2186cc36f7bb4aeaf4487b36e57039ccf45a9136aa856a5d569ecca55ef2b",
"4a90c00d55454dc5b059055ca213579c6ea856967712a56017487886a4d4cc0f",
"4e7bf348da00a945489b2a681749eb56f5de00b900014e137ddae39f48f69d67",
"4fca8bd82bbd181e714e283f83e1b45d95ca5af40fb89ad3977b653c448f78c2",
"5443fcf88330c586bc0e5f3dee10e7f63c76c00249c87fe4fbf7f38c082006b4",
"63320dd4a58212e4d32d1f58926b73ca33a247326c2a5e9fd39268d2384e011a",
"68dcaa34c0517d19666e6b33add67351d8c5f69e999ca1e37931bc410a297428",
"6bcb40a24e49c26d0a60513b6aeb8551d264e4717f306b81a37a5afb3b47cedc",
"8ba52fe7a3956c5cd3a656a3174b931d3bb2abb45578befc59f283ecd816a405",
"ad9e3d8f650687709fd68f4b90b41f7d825a365b02c23a636cef88ac2ac00c43",
"bcd2a26394b36614fd4894241d3c451ab0f6fd110958c3423073621a70826e99",
"c3a6138c5061cf291310887c0b5c71fcaffeab90d5deb50d3b9e687cead45071",
"d528b9f6e9693f45ed277af93474fd473ce7d831dae2180cca35d907bd10cb40",
"e0fb64b1085cc5538970158d05a009c24e276fb94e1a0bf6a528b48fbc4ff526",
"ef43112c6543b88db2283a2e077278c315ae2c84719a8b25f25cc88565fbea99",
"f0af56d2c5a48d60a4a5b5c903edfb7db3a736a94ed589d0b797df33ff9d3e1d",
"fce57d2331667353a0eac6b4209b67b843a7262a848af0a49a6e2fa9f6584eb4"
],
"description_digest": "01a8bebb4922c38293a8909668f7d00ba4a21a455c69719ad7dcc33ea1e631c0",
"subjective_restrictions": {
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
"preactivation_required": true,
"enabled": true
},
"builtin_feature_codename": "SAVANNA"
}
+19 -9
View File
@@ -1,7 +1,6 @@
import config from '../configs'
import { initExtensionsInPostgres, initSystemStatus } from '../postgres-init'
import { installExtraData, installInitialData, startInfra } from './infra'
import { CooperativeClass, startCoop } from './cooperative'
import { startCoop } from './cooperative'
export async function boot() {
const blockchain = await startInfra()
@@ -14,18 +13,29 @@ export async function boot() {
}
export async function bootClean() {
const blockchain = await startInfra()
console.log('Создаём программы (Благорост и маркетплейс)')
const cooperative = new CooperativeClass(blockchain)
await cooperative.createPrograms(config.provider)
// Только инфраструктура: контракты, фичи, токен, системные параметры.
// Ни совета, ни программ — программы (createPrograms) требуют существующий
// совет и создаются в boot/bootExtra внутри installInitialData. В clean их
// не делаем (иначе soviet::createprog падает с «Совет не найден»).
await startInfra()
}
export async function bootExtra() {
const blockchain = await startInfra()
await installInitialData(blockchain, true) // Создать расширенный совет (устанавливает статус 'active' в MongoDB)
await installExtraData(blockchain) // Добавить дополнительных пайщиков
// installExtraData регистрирует partner1 как coop с auto-approve от провайдера —
// это и есть программная on-chain активация, которая триггерит аренду VM в
// провайдере (PENDING→RENT). boot:extra используется НЕ только для аренды
// сервера (например, просто пересев совета/чейна для других задач), поэтому
// провижининг partner1 включается ТОЛЬКО под флагом EXTRA_RENT=1.
if (process.env.EXTRA_RENT === '1') {
console.log('EXTRA_RENT=1 → installExtraData: провижининг partner1 (триггер аренды)')
await installExtraData(blockchain) // Регистрируем partner1 как coop (active)
}
else {
console.log('EXTRA_RENT не задан → пропускаем провижининг partner1 (аренда не запускается)')
}
console.log('Инициализируем статус системы в PostgreSQL')
await initSystemStatus() // Устанавливает статус 'active' в PostgreSQL
+14 -4
View File
@@ -15,6 +15,9 @@ import { sleep } from '../utils'
import { generateRandomSHA256 } from '../utils/randomHash'
import { initUsersInPostgres, initVaultInPostgres } from '../postgres-init'
import { CooperativeClass } from './cooperative'
import { signProgramAgreement } from './sign-program-agreement'
import { fakeDocument } from '../tests/shared/fakeDocument'
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
@@ -674,10 +677,17 @@ export async function installExtraData(blockchain: Blockchain) {
status: 'active',
})
// Ресурсы для партнёра: powerup CPU/NET. transfer токенов пропускаем —
// eosio.token::transfer падает на проверке membership в wallet program,
// которая для partner1 не настроена. Для provider sync + Hostkey-flow
// токены не нужны: tx от имени partner1 пойдут от soviet/admin.
// Записываем partner1 в ЦПП Кошелька оператора (voskhod, program_id=1).
// БЕЗ этого провайдерский performInitialTransfers (150 AXON на partner1 ДО
// перехода в ACTIVE, см. provider CLAUDE.md §13) падает ассертом
// eosio.token::is_can_transfer «Получатель не является участником целевой
// потребительской программы кошелька» — и инстанс навсегда застревает в
// INSTALL. wallet::signagree (auth voskhod@active) делает partner1 членом
// ЦПП Кошелька. Та же механика, что для обычных пайщиков в participant.ts.
console.log('Подписываем wallet-соглашение за partner1 (членство в ЦПП Кошелька)')
await signProgramAgreement(blockchain, config.provider, account.username, walletProgramId, walletDraftId, fakeDocument)
// Ресурсы для партнёра: powerup CPU/NET.
await blockchain.powerup({
payer: 'eosio',
receiver: account.username,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.5.23-4",
"version": "2026.6.9-18",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
+1
View File
@@ -58,6 +58,7 @@ add_contract_build(registrator)
add_contract_build(gateway)
add_contract_build(marketplace)
add_contract_build(apps)
add_contract_build(expense)
add_contract_build(system)
# Опции для тестов
+5 -7
View File
@@ -1,15 +1,13 @@
#!/usr/bin/env bash
# Локальная сборка всех контрактов: оборачивает build_contracts_cdt.sh в
# CDT-образ. Сам cmake/make живёт в build_contracts_cdt.sh — он же
# используется в CI (там CDT ставится из .deb, без docker), поэтому
# флаги сборки в одном месте и не разъезжаются.
mode="${1:-prod}"
if [ "$mode" = "test" ]; then
is_testnet="ON"
else
is_testnet="OFF"
fi
docker run --rm --name cdt \
--volume "$(pwd):/project" \
-w /project \
dicoop/blockchain:latest \
/bin/bash -c "mkdir -p build && cd build && cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET=$is_testnet .. && make"
/bin/bash -c "./build_contracts_cdt.sh $mode"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Компиляция всех контрактов кооперативной экономики через CDT.
#
# ВАЖНО: скрипт запускается УЖЕ внутри окружения с установленным CDT —
# тулчейн ожидается по пути /cdt/build/lib/cmake/cdt/CDTWasmToolchain.cmake
# (он захардкожен в CMakeLists.txt). Здесь НЕТ docker — только cmake/make.
#
# Два способа подготовить это окружение:
# - локально: build-all.sh оборачивает скрипт в `docker run dicoop/blockchain:latest`
# (в образе CDT уже лежит в /cdt/build);
# - в CI: CDT ставится из .deb (C9S/cdt), а /cdt/build симлинкуется на
# /usr/opt/cdt/<ver> (см. .github/workflows/release.yaml).
#
# Один источник cmake-флагов для обоих путей — чтобы локальная и CI-сборка
# не разъезжались.
set -euo pipefail
mode="${1:-prod}"
if [ "$mode" = "test" ]; then
is_testnet="ON"
else
is_testnet="OFF"
fi
mkdir -p build
cd build
cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET="$is_testnet" ..
make
@@ -0,0 +1,65 @@
/**
* @brief Инициировать программный расход через шасси expense.
*
* capital — только инициатор. Резервирует средства в @c program_expense_pool,
* создаёт скелет записи progexpense (status=CREATED, как маркер pending), затем
* шлёт inline action `expense::createexp(...)` с callback handler `{capital, onpgexpdone}`.
* Шасси expense обслуживает весь дальнейший flow (авторизация совета, выдача аванса,
* отчёт, закрытие либо отклонение) и на терминальном переходе шлёт inline `onpgexpdone`.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода (= proposal_hash в шасси)
* @param creator Инициатор расхода
* @param items Позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
* @param description Описание для отчётности
* @param statement Документ-заявление (ProgramExpenseStatement)
*
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname.
*/
void capital::createpgexp(name coopname, checksum256 expense_hash, name creator,
std::vector<ExpenseDomain::item> items,
std::string description, document2 statement) {
require_auth(coopname);
verify_document_or_fail(statement, {creator});
eosio::check(!items.empty(), "Программный расход должен содержать хотя бы один item");
// Сумма расхода — сумма planned_amount всех items.
asset amount = items.front().planned_amount;
amount.amount = 0;
for (const auto &it : items) {
amount += it.planned_amount;
}
Wallet::validate_asset(amount);
eosio::check(amount.amount > 0, "Сумма программного расхода должна быть положительной");
auto exist = Capital::get_program_expense(coopname, expense_hash);
eosio::check(!exist.has_value(), "Программный расход с указанным хэшем уже существует");
// Резерв в пуле до отправки в шасси — две параллельные заявки не превысят пул.
Capital::State::reserve_program_expense(coopname, amount);
Capital::Expenses::create_program_expense(coopname, expense_hash, creator,
amount, description, statement);
// Inline action в шасси expense с callback handler на capital::onpgexpdone.
// data пустой — capital резолвит запись по expense_hash, дополнительный payload не нужен.
ExpenseDomain::callback_handler callback{
.contract = _capital,
.action = Names::Capital::ON_PROGRAM_EXPENSE_DONE,
.data = std::vector<char>{}
};
// Authority — _capital: у capital@eosio.code нет coopname@active, а
// expense::createexp принимает контракты-инициаторы по contracts_whitelist.
eosio::action(
eosio::permission_level{_capital, "active"_n},
_expense,
Names::External::CREATE_EXPENSE_PROPOSAL,
std::make_tuple(coopname, creator, expense_hash,
ledger2_wallets::PROGRAM_EXPENSE_POOL, items, callback, statement)
).send();
}
@@ -0,0 +1,65 @@
/**
* @brief Callback от шасси expense на финализацию program-расхода.
*
* Шасси expense шлёт его inline (authority — _expense@active) когда закончило
* flow (closeexp или declexp). capital не делает ledger2-проводок —
* они уже сделаны шасси на payexp/reportexp/returnexp/overspendexp. capital
* только обновляет @c program_expense_pool и удаляет запись из @c progexpenses.
*
* status == ExpenseDomain::ProposalStatus::CLOSED (4):
* total_actual <= reserved → consume(total_actual) + release(остаток);
* total_actual > reserved (был overspendexp) → consume(reserved) +
* прямое списание превышения из program_expense_pool.
* status == ExpenseDomain::ProposalStatus::DECLINED (5):
* release(reserved); decline возможен только до оплат (total_actual == 0).
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода (ключ в progexpenses)
* @param status ExpenseDomain::ProposalStatus
* @param total_actual Фактически потраченная сумма
* @param data Произвольный payload от инициатора (не используется на MVP)
*
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p _expense — inline-callback шасси.
*/
void capital::onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
eosio::asset total_actual, std::vector<char> data) {
require_auth(_expense);
auto pgexp = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
const auto reserved = pgexp.amount;
if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::CLOSED)) {
eosio::check(total_actual.symbol == reserved.symbol,
"Символ total_actual не совпадает с зарезервированной суммой");
eosio::check(total_actual.amount >= 0,
"total_actual не может быть отрицательным");
if (total_actual.amount <= reserved.amount) {
if (total_actual.amount > 0) {
Capital::State::consume_program_expense(coopname, total_actual);
}
const asset leftover = reserved - total_actual;
if (leftover.amount > 0) {
Capital::State::release_program_expense(coopname, leftover);
}
} else {
// Перерасход (overspendexp в шасси): резерв списываем полностью,
// превышение — напрямую из свободного пула программных расходов.
Capital::State::consume_program_expense(coopname, reserved);
const asset excess = total_actual - reserved;
Capital::State::spend_program_expense_pool(coopname, excess);
}
} else if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::DECLINED)) {
// Шасси отклоняет СЗ только до первой оплаты — резерв возвращается целиком.
eosio::check(total_actual.amount == 0,
"DECLINED с ненулевым total_actual — нарушение инварианта шасси");
Capital::State::release_program_expense(coopname, reserved);
} else {
eosio::check(false, "onpgexpdone принимает только статусы CLOSED либо DECLINED");
}
Capital::Expenses::delete_program_expense(coopname, pgexp.id);
}
@@ -0,0 +1,33 @@
/**
* @brief Пополняет пул программных расходов из общего инвестиционного пула программы.
*
* Программные расходы — это целевые списания «Благорост», не аллоцированные ни в один проект
* (хозяйственные расходы программы, оплата инфраструктуры программы и т.п.). Прежде чем
* создавать запрос на программный расход, председатель пополняет дедикейтнутый пул
* @c program_expense_pool из общего @c global_available_invest_pool. Помимо счётчиков
* state наполняется КООПЕРАТИВНЫЙ ledger2-кошелёк @c PROGRAM_EXPENSE_POOL (w.cap.pgexp) —
* именно из него шасси expense оплачивает СЗ; личные L3-кошельки пайщиков (w.cap.blago)
* при расходах не изменяются.
*
* @param coopname Кооператив (scope state)
* @param amount Сумма пополнения (символ строго совпадает с программой)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от аккаунта: @p coopname (председатель).
*/
void capital::topupprogexp(name coopname, eosio::asset amount) {
require_auth(coopname);
Capital::State::topup_program_expense_pool(coopname, amount);
// Кошелёк-резерв пула: ISSUE w.cap.pgexp без бухпроводки (деньги физически
// на 51 с момента взносов — выделяется только назначение под расходы).
// Синтетический process_hash — у пополнения нет естественного анкера.
const uint64_t now_us = eosio::current_time_point().time_since_epoch().count();
const std::string anchor = coopname.to_string() + ":pgtopup:" + std::to_string(now_us);
const checksum256 process_hash = eosio::sha256(anchor.c_str(), anchor.size());
Ledger2::apply(_capital, coopname, operations::capital::PROGRAM_EXPENSE_TOPUP, amount,
name{}, process_hash, "capital:topupprogexp");
}
@@ -72,8 +72,8 @@ void capital::importcontrib(eosio::name coopname, eosio::name username, checksum
auto idx = user_wallets.get_index<"byuserwallet"_n>();
auto preimp_it = idx.find(combine_ids(ledger2_wallets::PREIMP_FUND.value, username.value));
if (preimp_it != idx.end() && preimp_it->available.amount > 0) {
eosio::check(preimp_it->blocked.amount == 0,
"preimp.blocked > 0 не поддерживается при импорте — обратиться в поддержку");
// Поле blocked упразднено (2026-05-24): резерв/блокировка на кошельках
// больше не используется, проверка preimp.blocked == 0 излишня.
Ledger2::apply(
_capital, coopname, operations::capital::DROP_PREIMP,
preimp_it->available, username, contributor_hash,
@@ -95,6 +95,12 @@
#include "app/debt_managment/create_debt/declinedebt.cpp"
#include "app/debt_managment/settle_debt/settledebt.cpp"
// Управление программными расходами через шасси expense.
// capital — инициатор + получатель callback, шасси обслуживает весь flow.
#include "app/expense_managment/program_expenses/topupprogexp.cpp"
#include "app/expense_managment/program_expenses/createpgexp.cpp"
#include "app/expense_managment/program_expenses/onpgexpdone.cpp"
// Управление расходами
#include "app/expense_managment/create_expense/approveexpns.cpp"
#include "app/expense_managment/create_expense/capauthexpns.cpp"
@@ -48,6 +48,7 @@ constexpr uint128_t CRPS_PRECISION_FACTOR = 100000000000000ULL; ///< Масшт
#include <eosio/asset.hpp>
#include "../lib/index.hpp"
#include "domain/index.hpp"
#include "../expense/expense.hpp" // ExpenseDomain::item / callback_handler — для inline-action в шасси
using namespace eosio;
@@ -322,4 +323,38 @@ public:
[[eosio::action]]
void settledebt(name coopname, name username, eosio::asset amount, document2 statement);
// Программные расходы через шасси expense.
// capital — инициатор + получатель callback, шасси держит весь flow расхода.
[[eosio::action]]
void topupprogexp(name coopname, eosio::asset amount);
/**
* @brief Инициировать программный расход через шасси expense.
* Резервирует amount в program_expense_pool, создаёт запись progexpense,
* шлёт inline action expense::createexp с callback={capital, onpgexpdone}.
* Шасси обслуживает весь flow (auth/pay/report/close|decline) и шлёт callback.
*
* items[] — позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
* (item.mechanics: ADVANCE — аванс, DIRECT — прямая оплата).
*/
[[eosio::action]]
void createpgexp(name coopname, checksum256 expense_hash, name creator,
std::vector<ExpenseDomain::item> items,
std::string description, document2 statement);
/**
* @brief Callback от шасси expense на финализацию program-расхода.
* Шасси шлёт его inline (authority — _expense@active, require_auth(_expense))
* после терминального перехода flow (CLOSED либо DECLINED).
*
* status == CLOSED → consume(total_actual) + release(остаток резерва);
* при total_actual > reserved (перерасход) превышение
* списывается напрямую из program_expense_pool.
* status == DECLINED → release(reserved); decline возможен только до оплат.
*/
[[eosio::action]]
void onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
eosio::asset total_actual, std::vector<char> data);
};
@@ -9,23 +9,26 @@ namespace Capital::Core {
* @brief Совокупный баланс программы Благорост на уровне кооператива.
*
* Источник — ledger2 L2 (`wallets[BLAGOROST_FUND]`), агрегированный по всем
* пайщикам. Σ L3.{available, blocked}[w.cap.blago] == L2.{available, blocked}
* (Эпик 3 invariant), поэтому L2 — авторитетная сумма для CRPS-знаменателя.
* Если кошелёк ещё не создан (никто не инвестировал) — нулевой баланс.
* пайщикам. Σ L3.available[w.cap.blago] == L2.available (Эпик 3 invariant),
* поэтому L2 — авторитетная сумма для CRPS-знаменателя. Если кошелёк ещё не
* создан (никто не инвестировал) — нулевой баланс.
*
* Поле blocked упразднено (2026-05-24): субсчёт «заблокировано» больше не
* используется, баланс кошелька = available.
*/
eosio::asset get_capital_program_share_balance(eosio::name coopname) {
wallets2_index wallets(_ledger2, coopname.value);
auto it = wallets.find(ledger2_wallets::BLAGOROST_FUND.value);
if (it == wallets.end()) return eosio::asset(0, _root_govern_symbol);
return it->available + it->blocked;
return it->available;
}
/**
* @brief Доля пайщика в Благоросте (available + blocked) из ledger2 L3.
* @brief Доля пайщика в Благоросте (available) из ledger2 L3.
*
* Источник — `userwallets[(w.cap.blago, username)]`. Запись существует
* только пока available + blocked > 0 (Эпик 3 §6: auto-delete на нуле),
* поэтому отсутствие = нулевой баланс.
* только пока available > 0 (Эпик 3 §6: auto-delete на нуле), поэтому
* отсутствие = нулевой баланс. Поле blocked упразднено (2026-05-24).
*/
eosio::asset get_capital_program_user_share_balance(eosio::name coopname, eosio::name username) {
userwallets_index user_wallets(_ledger2, coopname.value);
@@ -33,6 +36,6 @@ namespace Capital::Core {
auto key = combine_ids(ledger2_wallets::BLAGOROST_FUND.value, username.value);
auto it = idx.find(key);
if (it == idx.end()) return eosio::asset(0, _root_govern_symbol);
return it->available + it->blocked;
return it->available;
}
}
@@ -178,8 +178,96 @@ namespace Capital::Expenses {
Capital::expense_index expenses(_capital, coopname.value);
auto expense = expenses.find(expense_id);
eosio::check(expense != expenses.end(), "Расход не найден");
expenses.erase(expense);
}
} // namespace Capital::Expenses
namespace Capital {
/**
* @brief Таблица программных расходов — целевые списания «Благорост»,
* не привязанные к конкретному проекту.
* @par Область памяти (scope): coopname
* @par Имя таблицы (table): progexpenses
*/
struct [[eosio::table, eosio::contract(CAPITAL)]] program_expense {
uint64_t id; ///< Внутренний ключ
name coopname; ///< Имя кооператива
name username; ///< Инициатор расхода (председатель / казначей)
name status = Expenses::Status::CREATED; ///< Статус (CREATED/APPROVED/AUTHORIZED/PAID/DECLINED — общий enum)
checksum256 expense_hash; ///< Хеш расхода — анкер процесса p.cap.expns
uint64_t fund_id; ///< Идентификатор фонда списания
eosio::asset amount; ///< Сумма расхода
std::string description; ///< Описание (для отчётности)
document2 expense_statement; ///< Служебная записка (ProgramExpenseStatement)
document2 approved_statement; ///< Записка, принятая председателем
document2 authorization; ///< Решение совета
time_point_sec spended_at = current_time_point();
uint64_t primary_key() const { return id; }
uint64_t by_username() const { return username.value; }
checksum256 by_expense_hash() const { return expense_hash; }
};
typedef eosio::multi_index<
"progexpenses"_n, program_expense,
indexed_by<"byusername"_n, const_mem_fun<program_expense, uint64_t, &program_expense::by_username>>,
indexed_by<"byhash"_n, const_mem_fun<program_expense, checksum256, &program_expense::by_expense_hash>>
> program_expense_index;
inline std::optional<program_expense> get_program_expense(eosio::name coopname, const checksum256 &hash) {
program_expense_index t(_capital, coopname.value);
auto idx = t.get_index<"byhash"_n>();
auto itr = idx.find(hash);
if (itr == idx.end()) return std::nullopt;
return program_expense(*itr);
}
} // namespace Capital
namespace Capital::Expenses {
inline program_expense get_program_expense_or_fail(eosio::name coopname, const checksum256 &expense_hash) {
auto v = Capital::get_program_expense(coopname, expense_hash);
eosio::check(v.has_value(), "Программный расход с указанным хэшем не найден");
return v.value();
}
inline void create_program_expense(
eosio::name coopname,
const checksum256 &expense_hash,
eosio::name username,
const eosio::asset &amount,
const std::string &description,
const document2 &statement
) {
Capital::program_expense_index t(_capital, coopname.value);
uint64_t id = get_global_id_in_scope(_capital, coopname, "progexpenses"_n);
t.emplace(coopname, [&](auto &e) {
e.id = id;
e.coopname = coopname;
e.username = username;
e.expense_hash = expense_hash;
e.fund_id = Ledger::accounts::GENERAL_EXPENSES;
e.status = Status::CREATED;
e.spended_at = current_time_point();
e.expense_statement = statement;
e.amount = amount;
e.description = description;
});
}
inline void delete_program_expense(eosio::name coopname, uint64_t id) {
Capital::program_expense_index t(_capital, coopname.value);
auto itr = t.find(id);
eosio::check(itr != t.end(), "Программный расход не найден");
t.erase(itr);
}
} // namespace Capital::Expenses
@@ -39,8 +39,11 @@ namespace Capital {
asset program_membership_distributed = asset(0, _root_govern_symbol); ///< Распределенная сумма членских взносов по программе
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
asset program_expense_pool = asset(0, _root_govern_symbol); ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
asset program_expense_reserved = asset(0, _root_govern_symbol); ///< Сумма, зарезервированная под approved/authorized программные расходы
config config; ///< Управляемая конфигурация контракта
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
};
@@ -78,7 +81,7 @@ inline global_state get_global_state(name coopname) {
/**
* @brief Проверяет что контракт инициализирован.
*
*
* @param coopname Имя кооператива.
*/
inline void is_initialized(name coopname) {
@@ -86,6 +89,96 @@ inline void is_initialized(name coopname) {
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
}
/**
* @brief Пополняет пул программных расходов на @p amount из общего пула инвестиций программы.
* Вызывается из action topupprogexp (председатель).
*/
inline void topup_program_expense_pool(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма пополнения должна быть положительной");
eosio::check(itr->global_available_invest_pool >= amount,
"Недостаточно свободных инвестиций программы для пополнения пула расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.global_available_invest_pool -= amount;
s.program_expense_pool += amount;
});
}
/**
* @brief Резервирует средства под approved/authorized программный расход:
* program_expense_pool -= amount, program_expense_reserved += amount.
*/
inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма резерва должна быть положительной");
eosio::check(itr->program_expense_pool >= amount,
"Недостаточно средств в пуле программных расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_pool -= amount;
s.program_expense_reserved += amount;
});
}
/**
* @brief Освобождает зарезервированное при отклонении программного расхода:
* program_expense_reserved -= amount, program_expense_pool += amount.
*/
inline void release_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма освобождения должна быть положительной");
eosio::check(itr->program_expense_reserved >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved -= amount;
s.program_expense_pool += amount;
});
}
/**
* @brief Окончательное списание программного расхода (callback onpgexpdone, CLOSED):
* program_expense_reserved -= amount. Деньги уже ушли через шасси expense,
* ledger2-проводки сделаны на payexp/reportexp/returnexp.
*/
inline void consume_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
eosio::check(itr->program_expense_reserved >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved -= amount;
});
}
/**
* @brief Прямое списание из свободного пула программных расходов — превышение
* факта над резервом при закрытии расхода с перерасходом (overspendexp
* в шасси выдал доплату из BLAGOROST_FUND сверх зарезервированного).
*/
inline void spend_program_expense_pool(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
eosio::check(itr->program_expense_pool >= amount,
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_pool -= amount;
});
}
}// namespace State
}// namespace Capital
@@ -0,0 +1,10 @@
cmake_minimum_required(VERSION 3.5)
project(expense)
find_package(cdt)
add_contract(expense expense expense.cpp)
if(IS_TESTNET)
target_compile_definitions(expense PUBLIC IS_TESTNET=1)
endif()
@@ -0,0 +1,425 @@
// expense.cpp — шасси расходов (первый потребитель: Благорост).
//
// Принцип: контракт-владелец расходных операций, агностичен к программе-источнику.
// Кошелёк-пул приходит в createexp параметром source_wallet; ledger2-коды всех
// операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
// (фабричная настройка — подключение нового пула не меняет этот контракт).
// Механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
// callback-handler сохраняется как переменная.
#include "expense.hpp"
namespace D = ExpenseDomain;
// ── helpers ──────────────────────────────────────────────────────────────────
namespace {
asset zero_like(const asset& a) { return asset{0, a.symbol}; }
asset sum_planned(const std::vector<D::item>& items) {
if (items.empty()) return asset{0, eosio::symbol{"RUB", 4}};
asset total = zero_like(items.front().planned_amount);
for (const auto& it : items) total += it.planned_amount;
return total;
}
// Набор ledger2-кодов операций шасси по кошельку-источнику СЗ. Один источник =
// один набор (EXPENSE_OPERATION_SETS в ledger2) — в коде expense нет
// захардкоженных operation_code, контракт настраивается на любой пул.
const ExpenseOperationSet& expense_ops_for(eosio::name source_wallet) {
const auto* set = find_expense_operation_set(source_wallet);
eosio::check(set != nullptr,
"Для кошелька-источника не настроен набор операций шасси расходов (EXPENSE_OPERATION_SETS)");
return *set;
}
D::proposals_index get_proposals(eosio::name self, eosio::name coopname) {
return D::proposals_index(self, coopname.value);
}
auto find_proposal(D::proposals_index& tbl, const eosio::checksum256& proposal_hash) {
auto idx = tbl.get_index<"byhash"_n>();
return idx.find(proposal_hash);
}
void send_callback_if_any(const D::callback_handler& cb,
eosio::name coopname,
const eosio::checksum256& proposal_hash,
uint8_t status,
const eosio::asset& amount,
eosio::name self) {
if (cb.contract.value == 0) return;
// Payload: (coopname, proposal_hash, status, total_actual, data).
// status = ExpenseDomain::ProposalStatus — инициатор различает CLOSED vs DECLINED.
// coopname нужен инициатору для скоупа таблиц (capital::progexpenses scope=coopname).
eosio::action(
eosio::permission_level{self, "active"_n},
cb.contract,
cb.action,
std::make_tuple(coopname, proposal_hash, status, amount, cb.data)
).send();
}
} // namespace
// ── actions ──────────────────────────────────────────────────────────────────
void expense::createexp(name coopname, name username,
checksum256 proposal_hash,
name source_wallet,
std::vector<D::item> items,
D::callback_handler callback,
document2 statement) {
// Прямой вызов backend'а — авторизация кооператива; inline от контракта-инициатора
// (capital::createpgexp и т.п.) — авторизация по whitelist, как в ledger2::apply.
if (!has_auth(coopname)) {
check_auth_and_get_payer_or_fail(contracts_whitelist);
}
verify_document_or_fail(statement, {username});
eosio::check(!items.empty(), "СЗ должен содержать хотя бы один item");
eosio::check(ledger2_is_known_wallet(source_wallet),
"source_wallet не найден в LEDGER2_WALLET_REGISTRY");
// Источник определяет ledger2-коды всех операций жизненного цикла —
// отказываем сразу при создании, а не в момент оплаты.
expense_ops_for(source_wallet);
auto tbl = get_proposals(get_self(), coopname);
eosio::check(find_proposal(tbl, proposal_hash) == tbl.get_index<"byhash"_n>().end(),
"СЗ с таким proposal_hash уже существует");
// Механика оплаты задаётся на каждом item отдельно (ADVANCE | DIRECT) —
// в одном СЗ допустимо смешение: ledger2-проводка payexp идёт по-позиционно
// с кодом, выведенным из механики конкретного item'а.
for (auto& it : items) {
eosio::check(it.planned_amount.is_valid() && it.planned_amount.amount > 0,
"planned_amount каждого item должен быть положительным");
eosio::check(it.mechanics <= static_cast<uint8_t>(D::Mechanics::DIRECT),
"Неизвестная mechanics item (0 = ADVANCE, 1 = DIRECT)");
eosio::check(it.recipient_type <= static_cast<uint8_t>(D::RecipientType::ORG),
"Неизвестный recipient_type item");
// Инвариант: пайщик (SELF/MEMBER) получает только аванс под отчёт на личные
// реквизиты (расходом станет после его отчёта чеком); организация — только
// прямую оплату по выставленным реквизитам, аванс ей не выдаётся.
if (it.recipient_type == static_cast<uint8_t>(D::RecipientType::ORG)) {
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::DIRECT),
"Организации/ИП доступна только оплата по счёту (DIRECT)");
} else {
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::ADVANCE),
"Пайщику средства выдаются только авансом под отчёт (ADVANCE)");
eosio::check(it.recipient != name(),
"recipient обязателен для получателя-пайщика");
eosio::check(is_account(it.recipient),
"Аккаунт получателя-пайщика не существует");
}
it.status = static_cast<uint8_t>(D::ItemStatus::APPROVED);
it.actual_amount = zero_like(it.planned_amount);
}
const auto total = sum_planned(items);
tbl.emplace(get_self(), [&](auto& row) {
row.id = tbl.available_primary_key();
row.coopname = coopname;
row.proposal_hash = proposal_hash;
row.username = username;
row.source_wallet = source_wallet;
row.status = static_cast<uint8_t>(D::ProposalStatus::CREATED);
row.items = items;
row.total_planned = total;
row.total_actual = zero_like(total);
row.callback = callback;
row.statement_doc = statement;
row.decision_doc = document2{};
row.created_at = eosio::current_time_point();
row.updated_at = row.created_at;
});
// СЗ утверждает совет (как возвраты паевых взносов): ставим вопрос в повестку.
// Утверждение председателем после голосования → callback authexp от _soviet;
// отклонение (отрицательный консенсус / просрочка) → declexp с причиной.
::Soviet::create_agenda(
_expense,
coopname,
username,
get_valid_soviet_action("createexp"_n),
proposal_hash,
_expense,
"authexp"_n,
"declexp"_n,
statement,
std::string("")
);
}
void expense::authexp(name coopname, checksum256 proposal_hash, document2 decision) {
require_auth(_soviet);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED),
"Авторизовать можно только СЗ в статусе CREATED");
verify_document_or_fail(decision);
idx.modify(it, get_self(), [&](auto& row) {
row.decision_doc = decision;
row.status = static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED);
row.updated_at = eosio::current_time_point();
});
}
void expense::declexp(name coopname, checksum256 proposal_hash, std::string reason) {
require_auth(_soviet);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
// Отклонение возможно только пока ни один item не оплачен: после payexp деньги
// уже ушли через ledger2, и decline разъехался бы с учётом инициатора
// (callback DECLINED возвращает инициатору весь резерв). Частично исполненный
// СЗ завершается обычным путём: reportexp / returnexp → closeexp.
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED) ||
it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED),
"Отклонить можно только СЗ без оплат (статусы CREATED / AUTHORIZED)");
idx.modify(it, get_self(), [&](auto& row) {
row.status = static_cast<uint8_t>(D::ProposalStatus::DECLINED);
row.updated_at = eosio::current_time_point();
});
// Callback инициатору (capital::onpgexpdone и т.п.); total_actual здесь всегда 0 —
// оплат до decline не было (см. проверку статусов выше).
send_callback_if_any(it->callback, coopname, proposal_hash,
static_cast<uint8_t>(D::ProposalStatus::DECLINED),
it->total_actual, get_self());
}
void expense::payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset actual_amount) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED) ||
it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Оплата возможна только из статусов AUTHORIZED / PARTIALLY_PAID");
eosio::check(actual_amount.is_valid() && actual_amount.amount > 0,
"actual_amount должен быть положительным");
bool item_found = false;
uint8_t paid_mechanics = 0;
name paid_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
bool all_reported = true;
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::APPROVED),
"Item уже оплачен или закрыт");
eosio::check(actual_amount.symbol == i.planned_amount.symbol,
"Символ actual_amount не совпадает с планом item");
eosio::check(actual_amount.amount <= i.planned_amount.amount,
"Сумма оплаты не может превышать план item; доплата сверх плана — через overspendexp");
i.actual_amount = actual_amount;
// DIRECT не имеет фазы подотчёта (нет ADVANCE_HOLD): закрывающие документы
// прикладывает сам кассир, item считается отчитанным сразу после оплаты.
i.status = static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::DIRECT
? static_cast<uint8_t>(D::ItemStatus::REPORTED)
: static_cast<uint8_t>(D::ItemStatus::PAID);
paid_mechanics = i.mechanics;
paid_recipient = i.recipient;
item_found = true;
}
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
all_reported = false;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual += actual_amount;
// DIRECT-only СЗ становится готов к закрытию сразу после последней оплаты.
row.status = all_reported
? static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED)
: static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID);
row.updated_at = eosio::current_time_point();
});
// ledger2-проводка по механике оплаченного item'а — выдача аванса или прямая
// оплата; коды выводятся из кошелька-источника СЗ. СЗ может смешивать механики.
// username нужен ledger2 только для USER_SHARED-стороны (кошелёк-подотчёт) —
// это ПОЛУЧАТЕЛЬ аванса, не автор СЗ.
const auto& ops = expense_ops_for(it->source_wallet);
const auto code = static_cast<D::Mechanics>(paid_mechanics) == D::Mechanics::ADVANCE
? ops.advance
: ops.direct;
Ledger2::apply(get_self(), coopname, code, actual_amount,
paid_recipient, proposal_hash, "expense:payexp");
}
void expense::reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Отчёт возможен только по СЗ в статусе PARTIALLY_PAID");
asset item_amount = asset{0, it->total_actual.symbol};
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
bool all_reported = true;
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Отчёт возможен только для оплаченных items");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"reportexp применим только к ADVANCE-механике");
i.status = static_cast<uint8_t>(D::ItemStatus::REPORTED);
item_amount = i.actual_amount;
item_recipient = i.recipient;
item_found = true;
}
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
all_reported = false;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
if (all_reported) {
row.status = static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED);
}
row.updated_at = eosio::current_time_point();
});
// ledger2: BURN кошелька-подотчёта на остаточный actual item'а (выдано
// возвращено + доплачено), без бухпроводки — проводка уже сделана при выдаче.
// Подотчёт числится на получателе аванса — его username и закрывает burn.
// Полный возврат аванса (actual == 0) — burn не нужен, подотчёт уже пуст.
if (item_amount.amount > 0) {
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).report, item_amount,
item_recipient, proposal_hash, "expense:reportexp");
}
}
void expense::closeexp(name coopname, checksum256 proposal_hash) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED),
"Закрыть можно только СЗ в статусе REPORT_SUBMITTED");
idx.modify(it, get_self(), [&](auto& row) {
row.status = static_cast<uint8_t>(D::ProposalStatus::CLOSED);
row.updated_at = eosio::current_time_point();
});
// Callback на финализацию (capital::onpgexpdone и т.п.) — если был установлен при createexp.
send_callback_if_any(it->callback, coopname, proposal_hash,
static_cast<uint8_t>(D::ProposalStatus::CLOSED),
it->total_actual, get_self());
}
void expense::returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset return_amount) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Возврат возможен только по СЗ в статусе PARTIALLY_PAID");
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Возврат возможен только из статуса PAID");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"returnexp применим только к ADVANCE-механике");
eosio::check(return_amount.is_valid() &&
return_amount.symbol == i.actual_amount.symbol,
"Символ return_amount не совпадает с item");
eosio::check(return_amount.amount > 0 && return_amount.amount <= i.actual_amount.amount,
"return_amount должен быть положительным и не превышать actual_amount");
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — после
// возврата остатка получатель штатно отчитывается reportexp по
// фактически потраченной части (при полном возврате — actual == 0).
i.actual_amount -= return_amount;
item_recipient = i.recipient;
item_found = true;
break;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual -= return_amount;
row.updated_at = eosio::current_time_point();
});
// ledger2: TRANSFER подотчёт → пул-источник (зеркало выдачи аванса).
// Подотчёт снимается с получателя аванса.
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).refund, return_amount,
item_recipient, proposal_hash, "expense:returnexp");
}
void expense::overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset overspend_amount) {
require_auth(coopname);
eosio::check(overspend_amount.amount > 0, "overspend_amount должен быть положительным");
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Перерасход возможен только по СЗ в статусе PARTIALLY_PAID");
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Перерасход регистрируется только из статуса PAID");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"overspendexp применим только к ADVANCE-механике");
eosio::check(overspend_amount.is_valid() &&
overspend_amount.symbol == i.actual_amount.symbol,
"Символ overspend_amount не совпадает с item");
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — подотчёт
// (теперь на полную сумму выдано + доплата) закрывается штатным reportexp,
// который сделает burn ADVANCE_HOLD на итоговый actual.
i.actual_amount += overspend_amount;
item_recipient = i.recipient;
item_found = true;
break;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual += overspend_amount;
row.updated_at = eosio::current_time_point();
});
// ledger2: доплата перерасхода — TRANSFER пул-источник → подотчёт получателя
// (зеркало выдачи аванса на сумму доплаты). Закрытие на полную сумму — в reportexp.
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).overspend, overspend_amount,
item_recipient, proposal_hash, "expense:overspend");
}
@@ -0,0 +1,233 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/asset.hpp>
#include <eosio/crypto.hpp>
#include "../lib/index.hpp"
using namespace eosio;
using std::string;
/**
* @defgroup public_expense Контракт EXPENSE
* @brief Шасси расходов цифрового кооператива (MVP — Благорост).
*
* Универсальный контракт: principal storage = СЗ-расход с массивом items;
* кошелёк-источник (пул) приходит параметром source_wallet, ledger2-коды всех
* операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
* (ledger2/operations.hpp) — подключение нового пула не меняет контракт;
* механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
* callback на финализацию (контракт+action+data) сохраняется при createexp
* как переменная — `expense` агностичен к программе-получателю.
*
* See: components/desktop/extensions/expenses/NAMING-C28-28.md (v3).
*/
namespace ExpenseDomain {
/**
* @brief Способ оплаты item-а в СЗ.
* ADVANCE — пайщик-получатель оплачивает, затем приносит чек.
* DIRECT — кассир/председатель платит организации напрямую.
*/
enum class Mechanics : uint8_t {
ADVANCE = 0,
DIRECT = 1,
};
/**
* @brief Получатель платежа в item-е.
* SELF — сам создатель СЗ.
* MEMBER — другой пайщик.
* ORG — внешняя организация.
*/
enum class RecipientType : uint8_t {
SELF = 0,
MEMBER = 1,
ORG = 2,
};
enum class ProposalStatus : uint8_t {
CREATED = 0, ///< СЗ создан, ждёт подписи signact1 создателя.
AUTHORIZED = 1, ///< СЗ подписан signact2 (совет), готов к оплате.
PARTIALLY_PAID = 2, ///< Часть item-ов оплачена.
REPORT_SUBMITTED = 3, ///< Отчёт о расходе подан, ждёт авторизации совета.
CLOSED = 4, ///< СЗ-отчёт авторизован — расход закрыт.
DECLINED = 5, ///< СЗ отклонён до или после авторизации.
};
enum class ItemStatus : uint8_t {
APPROVED = 0, ///< Запланирован, ждёт оплаты.
PAID = 1, ///< Оплачен (ADVANCE — выдан пайщику; DIRECT — оплачен организации).
REPORTED = 2, ///< Закрыт отчётом (чек приложен). DIRECT-item получает его сразу при payexp.
RETURNED = 3, ///< Зарезервировано, не используется: returnexp уменьшает actual_amount, статус остаётся PAID до отчёта.
OVERSPENT = 4, ///< Зарезервировано, не используется: overspendexp увеличивает actual_amount, статус остаётся PAID до отчёта.
};
/**
* @brief Callback на финализацию — устанавливается при createexp как переменная.
*
* Контракт expense не знает про capital/blagorost — он просто вызывает inline
* action на (contract, action) и прокидывает data наружу. Пустой contract = нет callback.
*
* Пример: для расхода в РИД-проект Благороста UI/backend заполняет
* { "capital"_n, "onexpreport"_n, packed(wip_project_hash) }.
*/
struct callback_handler {
eosio::name contract;
eosio::name action;
std::vector<char> data;
EOSLIB_SERIALIZE(callback_handler, (contract)(action)(data))
};
/**
* @brief Item — одна строка СЗ-расхода.
*/
struct item {
eosio::checksum256 item_hash;
uint8_t mechanics; ///< Mechanics
uint8_t recipient_type; ///< RecipientType
eosio::name recipient; ///< username (для SELF/MEMBER), либо name-идентификатор (для ORG)
std::string description;
eosio::asset planned_amount;
eosio::asset actual_amount; ///< 0 пока не оплачено / не отчиталось
uint8_t status; ///< ItemStatus
EOSLIB_SERIALIZE(item, (item_hash)(mechanics)(recipient_type)(recipient)(description)(planned_amount)(actual_amount)(status))
};
/**
* @brief Таблица СЗ-расходов. Scope = coopname.
*/
struct [[eosio::table, eosio::contract("expense")]] proposal {
uint64_t id;
eosio::name coopname; ///< кооператив (= scope таблицы; дублируем полем по канону)
eosio::checksum256 proposal_hash;
eosio::name username; ///< создатель
eosio::name source_wallet; ///< семантически — из какого ЦПП/фонда (w.cap.blago, w.sov.expns, …)
uint8_t status; ///< ProposalStatus
std::vector<item> items;
eosio::asset total_planned;
eosio::asset total_actual;
callback_handler callback; ///< опционально; contract.value == 0 = нет callback
document2 statement_doc; ///< type=2010, signact1 создателя
document2 decision_doc; ///< type=2011, signact2 совета
eosio::time_point_sec created_at;
eosio::time_point_sec updated_at;
uint64_t primary_key() const { return id; }
eosio::checksum256 by_hash() const { return proposal_hash; }
uint64_t by_username() const { return username.value; }
uint64_t by_status() const { return static_cast<uint64_t>(status); }
EOSLIB_SERIALIZE(proposal,
(id)(coopname)(proposal_hash)(username)(source_wallet)(status)
(items)(total_planned)(total_actual)(callback)(statement_doc)(decision_doc)
(created_at)(updated_at))
};
using proposals_index = eosio::multi_index<"proposals"_n, proposal,
eosio::indexed_by<"byhash"_n, eosio::const_mem_fun<proposal, eosio::checksum256, &proposal::by_hash>>,
eosio::indexed_by<"byusername"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_username>>,
eosio::indexed_by<"bystatus"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_status>>
>;
} // namespace ExpenseDomain
/**
* @ingroup public_contracts
* @brief Шасси расходов — 8 actions, MVP только Благорост.
*/
class [[eosio::contract("expense")]] expense : public contract {
public:
using contract::contract;
/**
* @brief Создать и подать СЗ-расход.
*
* Создание и подача — одна транзакция (slug createexp). На входе: items + источник
* (source_wallet) + опц. callback на финализацию.
* Подписывает создатель (signact1 statement_doc, type=2010).
*
* Авторизация: coopname (прямой вызов backend'а) либо контракт-инициатор из
* contracts_whitelist (inline, например capital::createpgexp).
* Механика оплаты — per-item (item.mechanics: ADVANCE | DIRECT); ledger2-код
* операции выводится из механики позиции в момент оплаты payexp.
*
* Ставит вопрос в повестку совета (soviet::createagenda, тип createexp):
* утверждение совета → callback authexp, отклонение → declexp.
*/
[[eosio::action]]
void createexp(name coopname, name username,
checksum256 proposal_hash,
name source_wallet,
std::vector<ExpenseDomain::item> items,
ExpenseDomain::callback_handler callback,
document2 statement);
/**
* @brief Авторизовать СЗ советом (signact2 decision_doc, type=2011).
* Callback решения совета — вызывается только контрактом soviet после
* голосования и утверждения председателем. После этого расход доступен для оплаты.
*/
[[eosio::action]]
void authexp(name coopname, checksum256 proposal_hash, document2 decision);
/**
* @brief Отклонить СЗ. Callback решения совета (отрицательный консенсус
* голосов «против» либо просрочка повестки) — вызывается только контрактом
* soviet. Возможно только до первой оплаты (CREATED / AUTHORIZED):
* после payexp средства уже ушли через ledger2, и СЗ завершается обычным
* путём reportexp / returnexp → closeexp.
*/
[[eosio::action]]
void declexp(name coopname, checksum256 proposal_hash, std::string reason);
/**
* @brief Оплатить item — выдача аванса (ADVANCE) или прямая оплата организации (DIRECT).
* Контракт зовёт Ledger2::apply с кодом из набора операций кошелька-источника
* (EXPENSE_OPERATION_SETS: source_wallet + механика item'а → operation_code).
* actual_amount не может превышать план item (доплата — через overspendexp).
* DIRECT-item не имеет фазы подотчёта и помечается REPORTED сразу; когда все
* items REPORTED — proposal переходит в REPORT_SUBMITTED прямо из payexp.
*/
[[eosio::action]]
void payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset actual_amount);
/**
* @brief Отчёт о расходе (ADVANCE) — пайщик закрывает item чеком.
* Зовёт Ledger2::apply(o.exp.advrpt) на остаточный actual item'а (при полном
* возврате аванса burn пропускается). Когда все items reported — статус
* proposal становится REPORT_SUBMITTED.
*/
[[eosio::action]]
void reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash);
/**
* @brief Закрытие расхода советом — финальный signact2 СЗ-отчёта.
* Если у proposal заполнен callback — отправляет inline action на (callback.contract, callback.action, callback.data).
*/
[[eosio::action]]
void closeexp(name coopname, checksum256 proposal_hash);
/**
* @brief Возврат неиспользованного аванса (ADVANCE-остаток).
* Settlement-запись: уменьшает actual_amount item'а, статус остаётся PAID —
* фактически потраченная часть закрывается затем штатным reportexp.
* Зовёт Ledger2::apply(o.exp.advret).
*/
[[eosio::action]]
void returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset return_amount);
/**
* @brief Доплата при перерасходе (ADVANCE).
* Settlement-запись: увеличивает actual_amount item'а, статус остаётся PAID.
* Зовёт Ledger2::apply(o.exp.over) — выдача доплаты; закрытие подотчёта на
* полную сумму (выдано + доплата) делает последующий reportexp.
*/
[[eosio::action]]
void overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset overspend_amount);
};
@@ -67,12 +67,12 @@ void ledger2::revert(eosio::name coopname,
eosio::check(original_operation_id != 0, "revert: original_operation_id обязателен");
// -------- validate mirror_wallet_op --------
eosio::check(mirror_wallet_op <= 5, "revert: неизвестный mirror_wallet_op");
// Не позволяем откатывать через BLOCK/UNBLOCK — они асимметричны и нет
// адекватного зеркала в одной операции (BLOCK + UNBLOCK — обратные сами по себе).
eosio::check(mirror_wallet_op != static_cast<uint8_t>(WalletOp::BLOCK) &&
mirror_wallet_op != static_cast<uint8_t>(WalletOp::UNBLOCK),
"revert: BLOCK/UNBLOCK не подлежат откату через revert (они симметричны сами себе)");
// Зеркало revert — только TRANSFER (обмен wallet_from/wallet_to) либо BURN
// (зеркало ISSUE: изъятие с wallet_from). Бывшие BLOCK/UNBLOCK упразднены
// (2026-05-24); ISSUE/NONE как зеркало смысла не имеют.
eosio::check(mirror_wallet_op == static_cast<uint8_t>(WalletOp::TRANSFER) ||
mirror_wallet_op == static_cast<uint8_t>(WalletOp::BURN),
"revert: mirror_wallet_op должен быть TRANSFER или BURN");
// -------- validate mirror wallets/accounts --------
if (mirror_wallet_from.value != 0) {
@@ -1,5 +1,5 @@
/**
* @brief Атомарная операция по кошельку (issue/transfer/block/unblock/burn/burn_blocked).
* @brief Атомарная операция по кошельку (issue/transfer/burn).
*
* Внутренний action ledger2 — вызывается только через inline из apply().
* Auth: только сам ledger2 (require_auth(get_self())).
@@ -53,13 +53,15 @@ void ledger2::walletop(eosio::name coopname,
eosio::check(amount.symbol == _root_govern_symbol,
"walletop: некорректный символ валюты");
eosio::check(memo.size() < 256, "walletop: memo > 255");
// op_code = 5 (NONE) намеренно не допускается: NONE-операции — это только
// бухпроводка без кошелькового движения, apply.cpp не диспатчит для них walletop.
// Прямой вызов с op_code=5 был бы no-op и сбил бы инвариант parity.
// Допустимы 0..4 (ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN) и 6 (BURN_BLOCKED).
eosio::check(op_code <= static_cast<uint8_t>(WalletOp::BURN_BLOCKED) &&
op_code != static_cast<uint8_t>(WalletOp::NONE),
"walletop: неизвестный op_code");
// Допустимы только ISSUE(0), TRANSFER(1), BURN(4). NONE(5) — это только
// бухпроводка без кошелькового движения, apply.cpp для неё walletop не диспатчит;
// прямой вызов был бы no-op и сбил бы parity. Бывшие BLOCK(2)/UNBLOCK(3)/
// BURN_BLOCKED(6) упразднены (2026-05-24): резерв возврата теперь TRANSFER на
// w.wal.wpend, их op_code больше не валиден.
eosio::check(op_code == static_cast<uint8_t>(WalletOp::ISSUE) ||
op_code == static_cast<uint8_t>(WalletOp::TRANSFER) ||
op_code == static_cast<uint8_t>(WalletOp::BURN),
"walletop: недопустимый op_code");
wallets2_index wallets(get_self(), coopname.value);
userwallets_index user_wallets(get_self(), coopname.value);
@@ -172,7 +174,7 @@ void ledger2::walletop(eosio::name coopname,
// миграций 048/049.
//
// walletop по построению применяет одно и то же `amount` к L2 и L3 (см.
// ниже case'ы ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN/BURN_BLOCKED), а sender-guard на
// ниже case'ы ISSUE/TRANSFER/BURN), а sender-guard на
// строке 46-47 запрещает обход. Поэтому инвариант сохраняется по
// конструкции; полную сверку выполняет бэкенд («стол бухгалтера»),
// вне транзакционного hot path.
@@ -227,60 +229,6 @@ void ledger2::walletop(eosio::name coopname,
cleanup_l3_if_empty(wallet_from);
break;
}
case WalletOp::BLOCK: {
eosio::check(wallet_from.value != 0, "walletop BLOCK: требуется wallet_from");
eosio::check(wallet_to.value == 0, "walletop BLOCK: wallet_to должен быть пустым");
auto it = wallets.find(wallet_from.value);
eosio::check(it != wallets.end() && it->available >= amount,
std::string{"walletop BLOCK: недостаточно available на кошельке "} +
wallet_from.to_string());
wallets.modify(it, payer, [&](auto& w) {
w.available -= amount;
w.blocked += amount;
});
if (is_user_shared_l3(wallet_from)) {
auto uw = find_l3(wallet_from);
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
uw->available >= amount,
std::string{"walletop BLOCK: недостаточно L3-available у пайщика "} +
username.to_string() + " на " + wallet_from.to_string());
auto uw_pri = user_wallets.find(uw->id);
user_wallets.modify(uw_pri, payer, [&](auto& r) {
r.available -= amount;
r.blocked += amount;
});
}
break;
}
case WalletOp::UNBLOCK: {
eosio::check(wallet_from.value != 0, "walletop UNBLOCK: требуется wallet_from");
eosio::check(wallet_to.value == 0, "walletop UNBLOCK: wallet_to должен быть пустым");
auto it = wallets.find(wallet_from.value);
eosio::check(it != wallets.end() && it->blocked >= amount,
std::string{"walletop UNBLOCK: недостаточно blocked на кошельке "} +
wallet_from.to_string());
wallets.modify(it, payer, [&](auto& w) {
w.blocked -= amount;
w.available += amount;
});
if (is_user_shared_l3(wallet_from)) {
auto uw = find_l3(wallet_from);
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
uw->blocked >= amount,
std::string{"walletop UNBLOCK: недостаточно L3-blocked у пайщика "} +
username.to_string() + " на " + wallet_from.to_string());
auto uw_pri = user_wallets.find(uw->id);
user_wallets.modify(uw_pri, payer, [&](auto& r) {
r.blocked -= amount;
r.available += amount;
});
}
break;
}
case WalletOp::BURN: {
eosio::check(wallet_from.value != 0, "walletop BURN: требуется wallet_from");
eosio::check(wallet_to.value == 0, "walletop BURN: wallet_to должен быть пустым");
@@ -311,30 +259,6 @@ void ledger2::walletop(eosio::name coopname,
eosio::check(false, "walletop NONE: запрещённый op_code");
break;
}
case WalletOp::BURN_BLOCKED: {
eosio::check(wallet_from.value != 0, "walletop BURN_BLOCKED: требуется wallet_from");
eosio::check(wallet_to.value == 0, "walletop BURN_BLOCKED: wallet_to должен быть пустым");
auto it = wallets.find(wallet_from.value);
eosio::check(it != wallets.end() && it->blocked >= amount,
std::string{"walletop BURN_BLOCKED: недостаточно blocked на кошельке "} +
wallet_from.to_string());
wallets.modify(it, payer, [&](auto& w) { w.blocked -= amount; });
if (is_user_shared_l3(wallet_from)) {
auto uw = find_l3(wallet_from);
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
uw->blocked >= amount,
std::string{"walletop BURN_BLOCKED: недостаточно L3-blocked у пайщика "} +
username.to_string() + " на " + wallet_from.to_string());
auto uw_pri = user_wallets.find(uw->id);
user_wallets.modify(uw_pri, payer, [&](auto& r) { r.blocked -= amount; });
}
cleanup_l2_if_empty(wallet_from);
cleanup_l3_if_empty(wallet_from);
break;
}
}
// Post-mutation Σ L3 == L2: см. блок выше (строки 163-175). Снято
@@ -1,4 +1,4 @@
#include <vector>
#include <array>
/**
* @brief Универсальное миграционное действие контракта ledger2 — точка
@@ -9,23 +9,33 @@
* после её прогона на проде тело очищается до пустого `require_auth(get_self())`
* (как в `capital::migrate`).
*
* Текущая задача (2026-05-21): чистка осиротевших L3-записей `w.cap.gen`
* на voskhod после смены WalletKind GENERATOR_FUND с USER_SHARED на
* COOPERATIVE (см. `lib/core/ledger2/wallets.hpp`).
* Текущая задача (2026-05-24): свёртка `blocked → available` по ВСЕМ коопам.
*
* Контекст: на voskhod `convertsegm` падал «недостаточно L3-средств у
* пайщика» в проектах, где CRPS перераспределял доли между сегментами.
* w.cap.gen был USER_SHARED, а CRPS в approvecmmt не делал per-user
* компенсирующих TRANSFER между сегментами: `Σ COMMIT_RID == Σ ACCEPT_RID`
* соблюдался только на проекте, не на пайщике.
* Контекст: механика «заблокированного» баланса упразднена (см.
* `lib/core/ledger2/operations.hpp` — удалены WalletOp BLOCK/UNBLOCK/BURN_BLOCKED;
* резерв возврата паевого теперь выражается переводом на кошелёк-резерв
* `w.wal.wpend`). Поле `blocked` остаётся в таблицах `wallets2`/`userwallets`
* как deprecated (физическое удаление поля = небезопасная смена layout таблицы
* на живых коопах, выносится в отдельный cleanup-деплой). Перед тем как поле
* перестанет поддерживаться кодом, накопленные `blocked`-остатки нужно вернуть
* в `available`, чтобы средства не «зависли» на упразднённом субсчёте.
*
* Архитектурный фикс: w.cap.gen — COOPERATIVE-пул без L3. Чтобы UI/бэкенд
* не врали остатками по «личным» w.cap.gen, удаляем осиротевшие L3-записи
* прямым `userwallets.erase`. L2-баланс `wallets2[w.cap.gen]` уже верен
* (синхронен с Σ старых userwallets), его не трогаем.
* Действие: пройти всех кооперативов (cooperatives2 в scope registrator) и для
* каждого свернуть `blocked → available` на уровнях L2 (`wallets2`) и L3
* (`userwallets`): `available += blocked; blocked = 0`. Сумма средств на кошельке
* не меняется — только субсчёт.
*
* Идемпотентно: повторный вызов на чистой БД — no-op (lower_bound пуст).
* Только voskhod: на остальных кооперативах w.cap.gen не использовался.
* Идемпотентно: после свёртки `blocked == 0`, повторный прогон — no-op.
*
* Сигнатура без аргументов — действие вызывается автоматически при деплое
* контракта (как и прочие задачи migrate); проходит по всем кооперативам сам.
*
* ПРЕДУСЛОВИЕ (операционное): на момент прогона не должно быть заявок на возврат
* «в полёте» (статусы pending/authorized в `wallet::withdraws`) — их `blocked`
* относится к старой механике и при свёртке в `available` вернётся пайщику как
* свободные средства, а последующий `completewthd` (BURN с `w.wal.wpend`) не
* найдёт резерва. Незавершённые заявки нужно довести (complete/decline) ДО
* деплоя с этой миграцией.
*
* @ingroup public_ledger2_actions
*
@@ -34,24 +44,101 @@
void ledger2::migrate() {
require_auth(get_self());
const eosio::name target_coop = "voskhod"_n;
cooperatives2_index coops(_registrator, _registrator.value);
userwallets_index user_wallets(get_self(), target_coop.value);
auto idx = user_wallets.get_index<"bywallet"_n>();
for (auto c = coops.begin(); c != coops.end(); ++c) {
const eosio::name coopname = c->username;
// Собираем primary id записей до erase (модификация контейнера при
// итерации через secondary index — небезопасна).
std::vector<uint64_t> ids_to_erase;
for (auto it = idx.lower_bound(ledger2_wallets::GENERATOR_FUND.value);
it != idx.end() && it->wallet_name == ledger2_wallets::GENERATOR_FUND;
++it) {
ids_to_erase.push_back(it->id);
// --- L3: userwallets[coopname] ---
// Модифицируем только не-ключевые поля (available/blocked) — итерация по
// первичному индексу с modify безопасна (порядок строк не меняется).
userwallets_index user_wallets(get_self(), coopname.value);
for (auto it = user_wallets.begin(); it != user_wallets.end(); ++it) {
if (it->blocked.amount <= 0) continue;
user_wallets.modify(it, get_self(), [&](auto& r) {
r.available += r.blocked;
r.blocked = eosio::asset(0, r.blocked.symbol);
});
}
// --- L2: wallets2[coopname] ---
wallets2_index wallets(get_self(), coopname.value);
for (auto it = wallets.begin(); it != wallets.end(); ++it) {
if (it->blocked.amount <= 0) continue;
wallets.modify(it, get_self(), [&](auto& w) {
w.available += w.blocked;
w.blocked = eosio::asset(0, w.blocked.symbol);
});
}
}
for (uint64_t id : ids_to_erase) {
auto pri = user_wallets.find(id);
if (pri != user_wallets.end()) {
user_wallets.erase(pri);
// ──────────────────────────────────────────────────────────────────────────
// Разовая коррекция СЧЕТОВ под удаление фантомных пайщиков fgrtejiwnynn
// (инцидент 2026-06-04). Каждый из 6 дублей-аккаунтов при регистрации внёс
// минимальный паевой 300 RUB (o.reg.putmin: Dr 51 / Cr 80, ISSUE w.reg.minshr).
// Эти суммы — фантомные (нет реального пайщика), их нужно убрать из паевого
// фонда и с расчётного счёта. Снимаем L3-доли фантомов на w.reg.minshr,
// суммируем фактически снятое и ровно на эту сумму уменьшаем L2-кошелёк и
// бухсчета 80 (Cr) и 51 (Dr). Реальные пайщики (их доли на том же w.reg.minshr
// и счетах) не затрагиваются — трогаем только перечисленные username.
//
// Список синхронизирован с registrator::migrate и soviet::migrate.
// Идемпотентно: после первого прогона L3-строк фантомов нет → removed == 0 →
// L2/бухсчета не трогаются (вся коррекция в одной atomic-tx).
{
const eosio::name PHANTOM_COOP = "fgrtejiwnynn"_n;
const std::array<eosio::name, 6> PHANTOMS = {
"bbsezpgufvmm"_n, "errwcgjwverm"_n, "hcfsluqsfehw"_n,
"kgkzdadfpzki"_n, "nzuyijobapsv"_n, "tplwfwbujugq"_n,
};
const eosio::name MINSHR = ledger2_wallets::MIN_SHARE_FUND; // w.reg.minshr
eosio::asset removed(0, _root_govern_symbol);
// 1) Снимаем L3-доли фантомов на w.reg.minshr, суммируем снятое.
userwallets_index uw(get_self(), PHANTOM_COOP.value);
auto uw_byuser = uw.get_index<"byuser"_n>();
for (const auto& u : PHANTOMS) {
auto it = uw_byuser.lower_bound(u.value);
while (it != uw_byuser.end() && it->username == u) {
if (it->wallet_name == MINSHR) {
removed += it->available + it->blocked;
it = uw_byuser.erase(it);
} else {
++it;
}
}
}
if (removed.amount > 0) {
// 2) L2 w.reg.minshr: available -= removed; удалить кошелёк, если опустел.
wallets2_index w2(get_self(), PHANTOM_COOP.value);
auto wit = w2.find(MINSHR.value);
if (wit != w2.end()) {
w2.modify(wit, get_self(), [&](auto& w) { w.available -= removed; });
if (wit->is_empty()) w2.erase(wit);
}
// 3) Бухсчета (scope=coopname): Cr 80 (паевой фонд) -= removed,
// Dr 51 (расчётный счёт) -= removed; пересчёт сальдо по типу счёта.
// Инвариант Σ Dr == Σ Cr сохраняется (обе стороны уменьшены на removed).
accounts2_index acc(get_self(), PHANTOM_COOP.value);
auto a80 = acc.find(ledger2_accounts::SHARE_FUND);
if (a80 != acc.end()) {
acc.modify(a80, get_self(), [&](auto& a) {
a.credit_balance -= removed;
a.balance = account2::compute_balance(a.account_type, a.debit_balance, a.credit_balance);
});
}
auto a51 = acc.find(ledger2_accounts::BANK_ACCOUNT);
if (a51 != acc.end()) {
acc.modify(a51, get_self(), [&](auto& a) {
a.debit_balance -= removed;
a.balance = account2::compute_balance(a.account_type, a.debit_balance, a.credit_balance);
});
}
}
}
}
+8 -3
View File
@@ -102,7 +102,10 @@ static const std::set<eosio::name> soviet_actions = {
//WALLET
"createwthd"_n, //создать заявление на возврат паевого взноса
//EXPENSE
"createexp"_n, //служебная записка-смета о расходах (шасси расходов)
//LEDGER
"ledgerwthd"_n, //заявление на списание со счета через ledger
@@ -165,6 +168,7 @@ static constexpr uint64_t _capital_program_id = 4;
static constexpr eosio::name _ledger = "ledger"_n;
static constexpr eosio::name _ledger2 = "ledger2"_n;
static constexpr eosio::name _apps = "apps"_n;
static constexpr eosio::name _expense = "expense"_n;
static constexpr eosio::name _power_account = "eosio.power"_n;
static constexpr eosio::name _saving_account = "eosio.saving"_n;
@@ -193,7 +197,8 @@ static constexpr uint64_t _capital_program_id = 4;
"ledger"_n,
"ledger2"_n,
"capital"_n,
"apps"_n
"apps"_n,
"expense"_n
// Добавьте остальные стандартные или пользовательские контракты по необходимости
};
@@ -239,7 +244,7 @@ static constexpr uint64_t _capital_program_id = 4;
#ifdef IS_TESTNET
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 1; /*!< минимальное количество членов совета (тест) */
#else
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 5; /*!< минимальное количество членов совета (прод) */
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 3; /*!< минимальное количество членов совета (прод) */
#endif
/**

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