Compare commits

..

106 Commits

Author SHA1 Message Date
coopops 5e58c173ee chore(release): publish
Build bootstrap container / build (push) Successful in 3m45s
Release / publish-packages (push) Successful in 29m23s
Release / release (push) Successful in 29m7s
Release / trigger-mono-docs (push) Successful in 2s
Release / trigger-coopenomics-docs (push) Successful in 2s
2026-07-21 20:19:26 +00:00
coopops c1ce7a095d [989-10][@ant] fix: убрать поле СФР и сделать поля организации/подписанта в форме нулёвых отчётов read-only
Поле «Регистрационный номер СФР» в форме ЕФС-1 не использовалось генератором вовсе (в документ и в имя файла идёт только pfrRegNumber) — убрано вместе с ложной проверкой в fss4.generator.ts, требовавшей его заполнения.

Остальные поля секций «Организация»/«Подписант» в ZeroReportEditor.vue (ФИО, СНИЛС, ИНН/КПП/ОКВЭД/ОГРН/ОКТМО, рег. номер ПФР, должность, тип подписанта) всегда приходят из Реквизитов/БД кооператива и правка через этот черновик никуда не сохранялась — редактируемые поля создавали иллюзию, что правка что-то меняет. Переведены в read-only (readonly+disable, как уже сделано для орг-полей в SettingsPage) — правка доступна только на странице Реквизитов. Раздел «Отчётный год/период/корректировка» остался редактируемым — он не приходит из Реквизитов, это per-документ данные.
2026-07-21 20:17:24 +00:00
coopops 995f65f524 [989-10][@ant] fix: убрать лишний поясняющий хинт у поля «Рег. номер ПФР» — по замечанию пользователя ни у одного другого поля в реквизитах/форме ЕФС-1 такого хинта нет, заполняющий и так знает, что это за номер 2026-07-21 19:12:09 +00:00
coopops 5298e55a7b [989-10][@ant] fix: ЕФС-1 (4-ФСС) — использовать рег. номер ПФР, а не СФР, в имени файла и <ЕФС8:РегНомер>
Сторонние бухгалтерские системы (СБИС и др.) при приёме ЕФС-1 отклоняли выгруженный XML: "Рег. номер отправителя из наименования файла не соответствует ни рег. номеру ПФР организации, ни рег. номеру СФР". Причина в двух местах:
1) generateGenericFileName для FSS4 хардкодил сегмент отправителя как "0000000000" — реальный номер туда не попадал вообще (тот же класс бага, что чинили в idFile для нулёвок ранее).
2) fss4.generator.ts писал в <ЕФС8:РегНомер> sfrRegNumber (10-значный унифицированный номер СФР) — но внешние системы сверяют этот тег с рег. номером ПФР организации (формат XXX-XXX-XXXXXX), который у них уже настроен через канал ФСС ЭДО, а не с номером СФР.

Добавлено отдельное обязательное поле pfrRegNumber (report_requisites.pfr_reg_number, XXX-XXX-XXXXXX) — не заменяет sfrRegNumber (оставлен, вдруг понадобится), а используется отдельно для генерации ЕФС-1: и в сегменте имени файла, и в теге <ЕФС8:РегНомер>. Заодно сузили маску sfrRegNumber до строго 10 цифр (раньше маска дублировала формат ПФР и два поля выглядели взаимозаменяемыми — по замечанию в ревью). Цепочка regen пройдена (generate-schema → generate-client → sdk build), controller tsc и desktop vue-tsc чистые, добавлены/обновлены unit-тесты Fss4Generator.
2026-07-21 18:45:12 +00:00
coopops db28ba1137 [989-10][@ant] fix: чинить генерацию имени файла нулёвых отчётов (idFile/ИдФайл) — терялся код формы ФНС/СФР
generateGenericFileName брал префикс как первый underscore-сегмент имени XSD (xsd.split('_')[0]), а для всех форм с xsdFile вида 'NO_<Форма>_...' первый сегмент всегда равен 'NO' — код формы (NDFL6.2, RASCHSV, PERSSVFL, USN, UVISCHSUMNAL) терялся из имени файла. Внешние системы (в частности приёмка Сбера) отклоняли выгруженный XML как нераспознанный по имени, а после ручного добавления '_NDFL6.2_' в имя падала уже сверка тега <ИдФайл> с фактическим именем файла — оба генерировались одной и той же испорченной функцией. Исправлено: префикс строится из первых двух сегментов XSD-имени (slice(0, 2)), что совпадает с реальным форматом имени файла ФНС/СФР и с эталонными фикстурами tests/fixtures/reports-references/*.
2026-07-21 16:23:21 +00:00
coopops 6a05e88ba1 [989-10][@ant] fix: убрать stack-раскладку (иконка над текстом) у кнопок панели действий в ReportEditorDialog расширения reports — canon-паддинг q-btn (0 14px, без вертикального зазора) при вертикальной раскладке icon+label прижимал иконку к верхней границе кнопки, из-за чего панель «Действия»/«Скачать»/«Отметить сданным» выглядела недоделанной; иконка возвращена в строку рядом с текстом 2026-07-21 15:56:09 +00:00
coopops 09eaa6192c [reports][@ant] fix: показывать серверные ошибки валидации формы отчёта для полей шапки и строк баланса — счётчик «Ошибок: N» был непрозрачным без единой подсказки
BuhotchEditor/ZeroReportEditor подключали fieldErrors только для части полей
(организация, часть подписанта). Поля шапки (reportYear/period/docDate/
correctionNumber) и все строки баланса (BalanceRowEditor — otch/prev/prePrev)
не имели inline-подсветки вообще, хотя backend (validateReportEdits) исправно
возвращал path+message. В проде это привело к невидимой ошибке
organization.ogrn — 12 vs 13 цифр (опечатка в реквизитах, отдельно исправлена
data-фиксом в organizations, не кодом).

- BalanceRowEditor: добавлены basePath/fieldErrors пропсы, подсветка otch/prev/prePrev.
- BuhotchEditor: прокинул field-errors во все 6 BalanceRowEditor, подключил
  header.reportYear/docDate/correctionNumber.
- ZeroReportEditor: подключил header.reportYear/period.
- ReportEditorDialog: добавлен явный список ошибок (path → человекочитаемое
  сообщение) в панели действий — страховка на случай ещё не подключённых полей.
2026-07-21 15:45:20 +00:00
coopops 81df662cfa chore(release): publish
Build bootstrap container / build (push) Successful in 46s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / release (push) Successful in 34m17s
Release / trigger-mono-docs (push) Successful in 2s
Release / publish-packages (push) Failing after 13m36s
2026-07-21 08:50:38 +00:00
coopops 845c13605d fix(reports): разрешить 10-значный рег.номер СФР наравне с XXX-XXX-XXXXXX
XSD-схема ЕФС-1 (ТипРегНомерОбщ) допускает регистрационный номер
страхователя в двух форматах: действующий XXX-XXX-XXXXXX (12 цифр,
3-3-6) или прежний — 10 цифр без разделителей. Валидация на бэке и
фронте (форма реквизитов кооператива + редактор нулевого отчёта
ЕФС-1) принимала только 12-значный формат с тире, из-за чего
кооперативы со старым 10-значным номером ФСС не могли его сохранить.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 08:43:46 +00:00
Alex Ant 76b4988da8 migrate 2026-07-20 11:34:49 +03:00
coopops 3db80a30e9 chore(release): publish
Release / release (push) Failing after 2m16s
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 49s
2026-07-19 17:01:42 +00:00
coopops 1b457b8f87 fix(controller): stop migration failures being masked as success
Backfill-миграция V2.3.2 падала на testnet TS-ошибкой компиляции
(mongo.db возможно undefined, strictNullChecks), но deploy-скрипт
(blue-green-deploy.sh) рапортовал успех — 2.3.2 не появилась в
таблице migrations, extensions.capital_program_doc_data_hash осталась
пустой, а Semaphore-лог показывал "Database migrations completed
successfully".

Причина ложноположительного результата — не баг деплой-скрипта, а
Sentry: в production Sentry.init() (enabled: config.env==='production')
ставит process.on('unhandledRejection', ...) с дефолтным mode:'warn',
который перехватывает необработанный reject из bootstrap()'а --migrate
ветки и не роняет процесс — в отличие от штатного поведения голого
Node. В dev (Sentry выключен) тот же баг воспроизводимо валит процесс
exit 1; на testnet — exit 0. Подтверждено локальным воспроизведением
обеих версий на идентичном коде.

Фикс на двух уровнях:
1. index.ts: явный try/catch + process.exit(1) вокруг --migrate и
   --migrations-only веток. Не полагаемся на ambient unhandled-rejection
   поведение (Sentry/Node/версия) — exit code теперь детерминирован
   независимо от окружения.
2. V2.3.2: mongo.db действительно типизирован как возможно undefined —
   явная runtime-проверка с throw вместо каста.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 16:57:59 +00:00
coopops ba6d3d1310 chore(release): publish
Build bootstrap container / build (push) Successful in 1m10s
Release / publish-packages (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
Release / release (push) Successful in 28m13s
Release / trigger-coopenomics-docs (push) Has been skipped
2026-07-19 15:20:08 +00:00
ant 8e01373179 Merge pull request 'feat(capital): CPP document templates via PrivateData' (#154) from feature/capital-cpp-private-data-templates into dev
Build bootstrap container / build (push) Successful in 3m30s
Reviewed-on: #154
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-07-19 15:14:39 +00:00
coopops 31940461c6 fix(desktop): remove electron dependency, fix latent typecheck errors
Typecheck / desktop (pull_request) Successful in 15m11s
Typecheck / controller (pull_request) Successful in 13m55s
electron был не нужен вообще — тянулся транзитивно только через
неиспользуемую devDependency @vue/devtools (нет ни одного импорта в
коде). Убрана из desktop/package.json и из onlyBuiltDependencies
корня; lockfile пересобран. Это устраняет флап CI: pnpm install
изредка валился на сетевой ошибке при скачивании ~200MB бинарника
electron (run #745 и run #732 на этом PR), из-за чего typecheck
падал до первой TS-проверки.

Так как install перестал падать раньше срока, vue-tsc теперь
реально доходит до конца и вскрывает предсуществующие баги:

- useCapitalProgramDocParams.ts, CapitalProgramDocumentParametersWidget.vue:
  импорт `CapitalProgramPrivateData` напрямую из 'cooptypes' никогда не
  работал — тип лежит в Cooperative.Registry, не на верхнем уровне
  (остальной код capital-расширения уже импортирует так же). Каст
  результата Object.fromEntries (индексная сигнатура) в интерфейс с
  именованными полями TS отклоняет как insufficient overlap; вместо
  unknown-каста — каст в Record<EditableFieldKey, string> (тот же
  паттерн, что и в соседней createEmptyForm), который затем обычной
  структурной проверкой присваивается в CapitalProgramPrivateData,
  т.к. набор из 11 полей совпадает один в один.
- CapitalProgramDocumentParametersWidget.vue: legacy draft.activeTab
  стал optional при переходе на wizardStepKey и больше не
  записывается читалкой драфта — добавлен фолбэк на дефолт 994,
  как и в самой readCapitalProgramDocParamsDraft.
- CapitalProgramInlineDocumentPreview.vue: querySelectorAll с
  составным CSS-селектором типизируется как NodeListOf<Element>;
  cleanupEditors использовал узел как HTMLTextAreaElement без
  сужения (соседняя syncInlineEditors уже делает `as
  HTMLTextAreaElement` для той же ситуации).
- ExtensionInstall.vue: prop schema был типизирован как unknown,
  хотя передаётся напрямую в ZodForm, которому нужен
  IExtensionConfigSchema — типизирован точно под фактическое
  использование.
- sdk/queries/paymentMethods/getPaymentMethods.ts: `const name`
  был без export вопреки конвенции всех остальных query-модулей
  (Queries.X.Y.name используется для чтения ключа GraphQL-ответа).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 14:25:47 +00:00
coopops 37b0227735 fix(ci): skip electron binary download in typecheck workflow
Typecheck / desktop (pull_request) Failing after 9m12s
Typecheck / controller (pull_request) Has been cancelled
electron тянется транзитивно через @vue/devtools-electron (dev-only,
приложение devtools вне браузера) и попадает в onlyBuiltDependencies —
pnpm install качает ~200MB бинарник с github releases, который здесь
никогда не запускается. Изредка скачивание валит install сетевым
таймаутом/TLS-обрывом до того, как дело доходит до vue-tsc/tsc.

Run #745 (PR #154): desktop job упал именно на этом шаге, не на
типах — коммит 2cfb37c5d7 тут ни при чём, чистая инфраструктурная
нестабильность CI-раннера.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 14:07:54 +00:00
coopops 2cfb37c5d7 fix(agenda): resolve program-agreement links via soviet::newresolved, not wallet::signagree
Typecheck / desktop (pull_request) Failing after 6m47s
Typecheck / controller (pull_request) Has been cancelled
Прошлый фикс менял receiver=coopname на data.coopname прямо в explorer-запросе
к wallet::signagree — рабочий, но не объясняющий, почему стандартный
receiver-фильтр не подходил именно здесь: wallet::signagree не делает
require_recipient(coopname) сам по себе, поэтому explorer индексирует его
только под receiver='wallet'.

На самом деле уведомление уже существует и не требует правки контракта:
Soviet::make_complete_document (lib/core/soviet/soviet.hpp) на КАЖДЫЙ вызов —
из soviet::sndagreement И из wallet::signagree одинаково — шлёт inline
soviet::newresolved/newsubmitted, а require_recipient(coopname/username)
зашит централизованно внутри самих newresolved.cpp/newsubmitted.cpp
(soviet/src/doc/*.cpp). Проверено на живом хэше оферты Благорост: newresolved
уже приходит с receiver=voskhod, receiver=veszmgpjteqs, receiver=soviet — без
единой правки на контрактах.

Правильный fallback для findLinkedAgreementDocument — искать soviet::newresolved
(с обычным receiver=coopname, как везде), а не действие wallet::signagree
напрямую (у которого никакого receiver-уведомления и не предполагалось).
Расширять notification-поверхность wallet::signagree не нужно — единственное
место для этого уже newresolved.cpp/newsubmitted.cpp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:48:57 +00:00
coopops 1c605dd2a5 fix(agenda): resolve wallet::signagree linked documents by data.coopname, not receiver
Typecheck / desktop (pull_request) Failing after 9m39s
Typecheck / controller (pull_request) Has been cancelled
findLinkedAgreementDocument искал wallet::signagree по фильтру receiver=coopname,
но wallet::signagree не делает require_recipient(coopname) (в отличие от
Soviet::make_complete_document) — explorer индексирует это действие только с
receiver='wallet'. Фильтр receiver=coopname всегда давал 0 строк, поэтому
buildLinkedAggregate молча возвращал null, а программные оферты (capital:
Благорост/Генератор, program_id>0, подписываются через wallet::signagree, не
soviet::sndagreement) пропадали из links повестки совета — платформенные
соглашения (wallet/signature/privacy/user, идут через sndagreement) отображались,
а оферта ЦПП — нет, хотя её hash был на месте в meta.links и сам документ был
в реестре.

Воспроизведено и проверено на живом кандидате voskhod: 5 хэшей в meta.links,
но до фикса только 4 резолвились в документ-агрегаты. Скоуп по кооперативу
берём из payload действия (data.coopname), а не из индекса receiver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:36:58 +00:00
coopops b1b1775277 fix(contracts): migrate voskhod programs.program_type back to 'capital'
Typecheck / desktop (pull_request) Failing after 8m46s
Typecheck / controller (pull_request) Successful in 13m44s
Автоматическая CI/CD-миграция (soviet::migrate) раньше переименовывала
programs.program_type[id=4] voskhod в 'blagorost', но coagreements.type для
той же программы никогда не переименовывался и остаётся 'capital' — отсюда
расхождение двух таблиц на одном и том же боевом кооперативе, которое видно
любым прямым запросом к цепочке и вносит путаницу.

'capital' — каноничное значение по всей цепочке решений в этой сессии: это
то, что реально в coagreements.type, то, на что рассчитывает _capital_program
в lib/consts.hpp, и то, что теперь везде в controller/boot. Новый блок в
migrate() приводит programs.program_type к нему же — идемпотентно, сработает
автоматически на следующем деплое soviet, без ручных действий на проде.
soviet и capital пересобраны локально — компилируются чисто.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:35:37 +00:00
coopops 5f4281ac01 revert(contracts): _capital_program back to 'capital', drop stray blagorost migration
Typecheck / desktop (pull_request) Failing after 8m52s
Typecheck / controller (pull_request) Failing after 6m44s
Единственный источник правды — продовая цепочка voskhod: coagreements.type
для program_id=4 буквально 'capital' (проверено прямым запросом к
185.70.105.87), это боевое неизменяемое значение. Contract-константа
_capital_program в lib/consts.hpp была переименована в 'blagorost' коммитом
e1538bb5ab (2026-01-26) и никогда не доезжала до прода — из-за этого dev-цепочки
собирали программу с другим именем и падали на 'Недопустимый тип программы'
при попытке засеять её как 'capital'.

Заодно убран автоматический CI/CD-migrate в soviet::migrate(), который на
каждом деплое насильно переименовывал programs.program_type[id=4] voskhod в
'blagorost' — он и был причиной, что programs.program_type на проде уже
'blagorost', а coagreements.type там же остался 'capital' (миграция трогала
только одну из двух таблиц). Раз откатываем контракт на 'capital' насовсем —
эта миграция больше не нужна и мешает: на следующем деплое она снова
рассинхронила бы данные.

Boot-скрипты и BLAGOROST_AGREEMENT_TYPE в controller уже используют 'capital'
(предыдущий коммит) — теперь везде единое значение, dev-цепочка при
pnpm run reboot соберёт programs+coagreements так же, как боевой voskhod, без
какой-либо миграции на проде.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:58:50 +00:00
coopops d35e88d951 revert(capital): keep BLAGOROST_AGREEMENT_TYPE='capital', fix boot seed drift instead
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Прямой запрос к продовой цепочке voskhod (185.70.105.87) показал реальный
on-chain тип программы Благорост — 'capital' (program_id=4), не 'blagorost'.
Прод годами работает с этим значением, ошибок в логах нет. Предыдущий коммит
(5360daa471) поменял константу на 'blagorost' по данным ТОЛЬКО локальной
dev-цепочки mono-ai-2 — для прода это было бы регрессией. Откатываю.

Настоящий источник дрейфа — boot-сидинг: components/boot/src/init/cooperative.ts
createPrograms() и src/scripts/seed-capital/phases/01-programs.ts заводили
новый кооператив (createprog) с type='blagorost', расходясь с давно
задеплоенным продовым значением. Правлю сидинг под реальность, а не наоборот
(та же логика, что 'дрейф стандарт↔код — прав контракт': on-chain состояние
неизменяемо и авторитетно, код подстраивается). Заодно поправлены прочие
литералы 'blagorost' в тестах boot (capital.test.ts, capital-import.test.ts,
08-investments.ts, tests/capital/consts.ts), которые создавали/подписывали
программу под тем же неверным именем.

> monocoop@ reboot /home/admin/mono-ai-2
> cd components/boot/scripts && bash reboot.sh

Останавливаем и удаляем контейнеры с volumes...
Останавливаем blockchain контейнер...
Удаляем blockchain data...
Пересоздаем и запускаем базы данных...
Ждем готовности MongoDB...
MongoDB готов!
Ждем готовности PostgreSQL...
PostgreSQL готов!
Запускаем boot процесс...

> @coopenomics/boot@2026.6.24 boot /home/admin/mono-ai-2/components/boot
> esno src/index.ts boot

Node is healthy. Continue. {
  server_version: 'ee002f93',
  chain_id: 'db79c8409645082749ca50640d6f4ee511575acf26c4e2c8e4748e6bf6a01ed4',
  head_block_num: 10,
  last_irreversible_block_num: 9,
  last_irreversible_block_id: '000000093c561104c62b9058c82eab12559686eba92fdbe4bcf4df9bf2600131',
  head_block_id: '0000000ae4bffe027ec2a2c89ca92832422a84ec052649f1a80436fff5c61327',
  head_block_time: '2026-07-19T11:48:57.500',
  head_block_producer: 'eosio',
  virtual_block_cpu_limit: 1008036,
  virtual_block_net_limit: 1058054,
  block_cpu_limit: 1000000,
  block_net_limit: 1048576,
  server_version_string: 'v6.0.0',
  fork_db_head_block_num: 10,
  fork_db_head_block_id: '0000000ae4bffe027ec2a2c89ca92832422a84ec052649f1a80436fff5c61327',
  server_full_version_string: 'v6.0.0-ee002f9372237aa07b05879df772a95f9d54867f-dirty',
  total_cpu_weight: 0,
  total_net_weight: 0,
  earliest_available_block_num: 1,
  last_irreversible_block_time: '2026-07-19T11:48:57.000'
}
Created account: eosio.token
Created account: eosio.bpay
Created account: eosio.vpay
Created account: eosio.msig
Created account: eosio.wrap
Created account: eosio.power
Created account: eosio.saving
Created account: registrator
Created account: capital
Created account: loan
Created account: ledger
Created account: ledger2
Created account: test
Created account: meet
Created account: wallet
Created account: soviet
Created account: branch
Created account: marketplace
Created account: draft
Created account: gateway
Created account: fund
Created account: contributor
Created account: apps
Created account: expense
Created account: voskhod
ok -> init activation:  { result: 'ok' }
on set:  {
  name: 'eosio.boot',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot',
  target: 'eosio'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot/eosio.boot.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot/eosio.boot.abi
contract setted:  eosio
Фича активирована:  ACTION_RETURN_VALUE
Фича активирована:  CONFIGURABLE_WASM_LIMITS2
Фича активирована:  BLOCKCHAIN_PARAMETERS
Фича активирована:  GET_SENDER
Фича активирована:  FORWARD_SETCODE
Фича активирована:  ONLY_BILL_FIRST_AUTHORIZER
Фича активирована:  RESTRICT_ACTION_TO_SELF
Фича активирована:  DISALLOW_EMPTY_PRODUCER_SCHEDULE
Фича активирована:  FIX_LINKAUTH_RESTRICTION
Фича активирована:  REPLACE_DEFERRED
Фича активирована:  NO_DUPLICATE_DEFERRED_ID
Фича активирована:  ONLY_LINK_TO_EXISTING_PERMISSION
Фича активирована:  RAM_RESTRICTIONS
Фича активирована:  WEBAUTHN_KEY
Фича активирована:  WTMSIG_BLOCK_SIGNATURES
Фича активирована:  GET_CODE_HASH
Фича активирована:  GET_BLOCK_NUM
Фича активирована:  CRYPTO_PRIMITIVES
on set:  {
  name: 'eosio.system',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system',
  target: 'eosio'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system/eosio.system.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system/eosio.system.abi
contract setted:  eosio
on set:  {
  name: 'eosio.token',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token',
  target: 'eosio.token'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token/eosio.token.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token/eosio.token.abi
contract setted:  eosio.token
on set:  {
  name: 'eosio.msig',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.msig',
  target: 'eosio.msig'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.msig/eosio.msig.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.msig/eosio.msig.abi
contract setted:  eosio.msig
on set:  {
  name: 'eosio.wrap',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.wrap',
  target: 'eosio.wrap'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.wrap/eosio.wrap.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.wrap/eosio.wrap.abi
contract setted:  eosio.wrap
on set:  {
  name: 'registrator',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/registrator',
  target: 'registrator'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/registrator/registrator.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/registrator/registrator.abi
contract setted:  registrator
on set:  {
  name: 'soviet',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/soviet',
  target: 'soviet'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/soviet/soviet.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/soviet/soviet.abi
contract setted:  soviet
on set:  {
  name: 'marketplace',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/marketplace',
  target: 'marketplace'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/marketplace/marketplace.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/marketplace/marketplace.abi
contract setted:  marketplace
on set:  {
  name: 'draft',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/draft',
  target: 'draft'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/draft/draft.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/draft/draft.abi
contract setted:  draft
on set:  {
  name: 'branch',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/branch',
  target: 'branch'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/branch/branch.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/branch/branch.abi
contract setted:  branch
on set:  {
  name: 'gateway',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/gateway',
  target: 'gateway'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/gateway/gateway.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/gateway/gateway.abi
contract setted:  gateway
on set:  {
  name: 'fund',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/fund',
  target: 'fund'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/fund/fund.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/fund/fund.abi
contract setted:  fund
on set:  {
  name: 'ledger',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/ledger',
  target: 'ledger'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/ledger/ledger.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/ledger/ledger.abi
contract setted:  ledger
on set:  {
  name: 'ledger2',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/ledger2',
  target: 'ledger2'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/ledger2/ledger2.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/ledger2/ledger2.abi
contract setted:  ledger2
on set:  {
  name: 'test',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/test',
  target: 'test'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/test/test.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/test/test.abi
contract setted:  test
on set:  {
  name: 'contributor',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/contributor',
  target: 'contributor'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/contributor/contributor.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/contributor/contributor.abi
contract setted:  contributor
on set:  {
  name: 'capital',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/capital',
  target: 'capital'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/capital/capital.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/capital/capital.abi
contract setted:  capital
on set:  {
  name: 'wallet',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/wallet',
  target: 'wallet'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/wallet/wallet.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/wallet/wallet.abi
contract setted:  wallet
on set:  {
  name: 'loan',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/loan',
  target: 'loan'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/loan/loan.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/loan/loan.abi
contract setted:  loan
on set:  {
  name: 'meet',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/meet',
  target: 'meet'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/meet/meet.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/meet/meet.abi
contract setted:  meet
on set:  {
  name: 'apps',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/apps',
  target: 'apps'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/apps/apps.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/apps/apps.abi
contract setted:  apps
on set:  {
  name: 'expense',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/expense',
  target: 'expense'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/expense/expense.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/expense/expense.abi
contract setted:  expense
создаём токен
Токен создан:  { issuer: 'eosio', maximum_supply: '1000000000.0000 AXON' }
выпускаем токены
Токен выпущен:  { to: 'eosio', quantity: '100000.0000 AXON', memo: '' }
Updated account permissions: eosio.saving -> eosio.saving
Updated account permissions: registrator -> registrator
Updated account permissions: capital -> capital
Updated account permissions: loan -> loan
Updated account permissions: ledger -> ledger
Updated account permissions: ledger2 -> ledger2
Updated account permissions: test -> test
Updated account permissions: meet -> meet
Updated account permissions: wallet -> wallet
Updated account permissions: soviet -> soviet
Updated account permissions: branch -> branch
Updated account permissions: marketplace -> marketplace
Updated account permissions: draft -> draft
Updated account permissions: gateway -> gateway
Updated account permissions: fund -> fund
Updated account permissions: contributor -> contributor
Updated account permissions: apps -> apps
Updated account permissions: expense -> expense
Updated account permissions: voskhod -> registrator
Системный контракт инициализирован { version: 0, core: '4,AXON' }
Эмиссия инициализирована {
  init_supply: '1000.0000 AXON',
  tact_duration: 86400,
  emission_factor: 0.618
}
Система аренды инициализирована { args: { powerup_days: 1, min_powerup_fee: '0.0001 AXON' } }
Шаблон создан:  1
Шаблон создан:  2
Шаблон создан:  3
Шаблон создан:  4
Шаблон создан:  50
Шаблон создан:  51
Шаблон создан:  100
Шаблон создан:  101
Шаблон создан:  200
Шаблон создан:  201
Шаблон создан:  300
Шаблон создан:  301
Шаблон создан:  302
Шаблон создан:  303
Шаблон создан:  304
Шаблон создан:  501
Шаблон создан:  599
Шаблон создан:  600
Шаблон создан:  699
Шаблон создан:  700
Шаблон создан:  701
Шаблон создан:  702
Шаблон создан:  800
Шаблон создан:  801
Шаблон создан:  802
Шаблон создан:  900
Шаблон создан:  901
Шаблон создан:  994
Шаблон создан:  995
Шаблон создан:  996
Шаблон создан:  997
Шаблон создан:  998
Шаблон создан:  999
Шаблон создан:  1000
Шаблон создан:  1001
Шаблон создан:  1002
Шаблон создан:  1003
Шаблон создан:  1004
Шаблон создан:  1005
Шаблон создан:  1006
Шаблон создан:  1007
Шаблон создан:  1010
Шаблон создан:  1011
Шаблон создан:  1020
Шаблон создан:  1025
Шаблон создан:  1030
Шаблон создан:  1040
Шаблон создан:  1041
Шаблон создан:  1042
Шаблон создан:  1050
Шаблон создан:  1051
Шаблон создан:  1060
Шаблон создан:  1061
Шаблон создан:  1062
Шаблон создан:  1070
Шаблон создан:  1071
Шаблон создан:  1072
Шаблон создан:  1080
Шаблон создан:  1090
Шаблон создан:  2010
Шаблон создан:  2011
Арендуем ресурсы провайдеру
Аренда ресурсов:  {
  payer: 'eosio',
  receiver: 'voskhod',
  days: 1,
  payment: '10000.0000 AXON',
  transfer: true
}
{ from: 'eosio', to: 'voskhod', quantity: '1000.0000 AXON', memo: '' }
Перевод токенов:  { from: 'eosio', to: 'voskhod', quantity: '1000.0000 AXON', memo: '' }
Базовая инфраструктура установлена
Провайдер добавлен:  {
  username: 'voskhod',
  type: 'coop',
  short_name: 'ПК "Восход"',
  full_name: 'Потребительский Кооператив "ВОСХОД"',
  represented_by: {
    first_name: 'Иван',
    last_name: 'Иванов',
    middle_name: 'Иванович',
    position: 'Председатель',
    based_on: 'Решение общего собрания №1'
  },
  country: 'Российская Федерация',
  city: 'Москва',
  fact_address: '117593 г. Москва, муниципальный округ Ясенево, проезд Соловьиный, дом 1, помещение 1/1',
  full_address: '117593 г. Москва, муниципальный округ Ясенево, проезд Соловьиный, дом 1, помещение 1/1',
  email: 'copenomics@yandex.ru',
  phone: '+71234567890',
  details: { inn: '9728130611', ogrn: '1247700283346', kpp: '772801001' }
}
Vars сохранены с указанным _id и _created_at
Все документы удалены из коллекции sync
Все документы удалены из коллекции actions
Все документы удалены из коллекции deltas
Добавляем пайщика ant
{
  coopname: 'voskhod',
  referer: '',
  username: 'ant',
  type: 'individual',
  created_at: '2025-01-15T10:00:00',
  initial: '100.0000 RUB',
  minimum: '200.0000 RUB',
  spread_initial: true,
  meta: 'Основатель кооператива ВОСХОД',
  registration_hash: '1e87d2a7d35f1be95da5a676ae799262e970647d0ba69cd19c0eaff0afd5b488'
}
Добавлен пользователь:  ant
Устанавливаем дефолтный публичный ключ для ant
{
  coopname: 'voskhod',
  changer: 'voskhod',
  username: 'ant',
  public_key: 'EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV'
}
Изменён ключ для пользователя:  ant
Инициализируем пользователей в PostgreSQL
Подключение к PostgreSQL установлено для инициализации пользователей
Инициализировано 1 пользователей в PostgreSQL
Инициализируем vault в PostgreSQL
Подключение к PostgreSQL установлено для инициализации vault
Vault инициализирован в PostgreSQL
Создаём совет
{
  coopname: 'voskhod',
  username: 'ant',
  type: 'soviet',
  members: [
    {
      username: 'ant',
      is_voting: true,
      position_title: 'Председатель совета',
      position: 'chairman'
    }
  ],
  name: 'Совет',
  description: 'Совет кооператива ВОСХОД'
}
Создан совет:  {
  coopname: 'voskhod',
  username: 'ant',
  type: 'soviet',
  members: [
    {
      username: 'ant',
      is_voting: true,
      position_title: 'Председатель совета',
      position: 'chairman'
    }
  ],
  name: 'Совет',
  description: 'Совет кооператива ВОСХОД'
}
Создаём программы и соглашения
params {
  coopname: 'voskhod',
  username: 'voskhod',
  type: 'marketplace',
  title: 'КООПЛЕЙС',
  announce: '',
  description: '',
  preview: '',
  images: '',
  calculation_type: 'relative',
  fixed_membership_contribution: '0.0000 RUB',
  membership_percent_fee: 10000,
  meta: '',
  is_can_coop_spend_share_contributions: false
}
Программа установлена:  {
  coopname: 'voskhod',
  username: 'voskhod',
  type: 'marketplace',
  title: 'КООПЛЕЙС',
  announce: '',
  description: '',
  preview: '',
  images: '',
  calculation_type: 'relative',
  fixed_membership_contribution: '0.0000 RUB',
  membership_percent_fee: 10000,
  meta: '',
  is_can_coop_spend_share_contributions: false
}
params {
  coopname: 'voskhod',
  username: 'voskhod',
  type: 'generator',
  title: 'Целевая потребительская программа "Генератор"',
  announce: '',
  description: '',
  preview: '',
  images: '',
  calculation_type: 'free',
  fixed_membership_contribution: '0.0000 RUB',
  membership_percent_fee: 0,
  meta: '',
  is_can_coop_spend_share_contributions: true
}
Программа установлена:  {
  coopname: 'voskhod',
  username: 'voskhod',
  type: 'generator',
  title: 'Целевая потребительская программа "Генератор"',
  announce: '',
  description: '',
  preview: '',
  images: '',
  calculation_type: 'free',
  fixed_membership_contribution: '0.0000 RUB',
  membership_percent_fee: 0,
  meta: '',
  is_can_coop_spend_share_contributions: true
}
params {
  coopname: 'voskhod',
  username: 'voskhod',
  type: 'capital',
  title: 'Целевая потребительская программа "Благорост"',
  announce: '',
  description: '',
  preview: '',
  images: '',
  calculation_type: 'free',
  fixed_membership_contribution: '0.0000 RUB',
  membership_percent_fee: 0,
  meta: '',
  is_can_coop_spend_share_contributions: true
}
 ELIFECYCLE  Command failed with exit code 1.
Запускаем parser...
Запускаем контроллер...
Перезапуск завершен! пересоздаёт blockchain-data с нуля и гоняет
> monocoop@ boot /home/admin/mono-ai-2
> lerna run boot --scope @coopenomics/boot

> @coopenomics/boot:boot

> @coopenomics/boot@2026.6.24 boot /home/admin/mono-ai-2/components/boot
> esno src/index.ts boot
Node is healthy. Continue. {
  server_version: 'ee002f93',
  chain_id: 'db79c8409645082749ca50640d6f4ee511575acf26c4e2c8e4748e6bf6a01ed4',
  head_block_num: 123,
  last_irreversible_block_num: 122,
  last_irreversible_block_id: '0000007adc2e2215250e7ffbd9c7961faa675f57c3134be365b336292fda7e72',
  head_block_id: '0000007bf4fa5276948ff1ea2dce877a2032ac28be3a753271efd198ed5f79d5',
  head_block_time: '2026-07-19T11:49:54.000',
  head_block_producer: 'eosio',
  virtual_block_cpu_limit: 1128638,
  virtual_block_net_limit: 1184640,
  block_cpu_limit: 1000000,
  block_net_limit: 1048576,
  server_version_string: 'v6.0.0',
  fork_db_head_block_num: 123,
  fork_db_head_block_id: '0000007bf4fa5276948ff1ea2dce877a2032ac28be3a753271efd198ed5f79d5',
  server_full_version_string: 'v6.0.0-ee002f9372237aa07b05879df772a95f9d54867f-dirty',
  total_cpu_weight: 999999997,
  total_net_weight: 999999997,
  earliest_available_block_num: 1,
  last_irreversible_block_time: '2026-07-19T11:49:53.500'
}
error -> init activation:  AxiosError: Request failed with status code 500
    at settle (file:///home/admin/mono-ai-2/node_modules/.pnpm/axios@1.13.6/node_modules/axios/lib/core/settle.js:20:7)
    at IncomingMessage.handleStreamEnd (file:///home/admin/mono-ai-2/node_modules/.pnpm/axios@1.13.6/node_modules/axios/lib/adapters/http.js:844:13)
    at IncomingMessage.emit (node:events:531:35)
    at endReadableNT (node:internal/streams/readable:1698:12)
    at process.processTicksAndRejections (node:internal/process/task_queues:89:21)
    at Axios.request (file:///home/admin/mono-ai-2/node_modules/.pnpm/axios@1.13.6/node_modules/axios/lib/core/Axios.js:46:41)
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
    at async startInfra (/home/admin/mono-ai-2/components/boot/src/init/infra.ts:45:22)
    at async boot (/home/admin/mono-ai-2/components/boot/src/init/booter.ts:6:22)
    at async Command.<anonymous> (/home/admin/mono-ai-2/components/boot/src/index.ts:144:7) {
  isAxiosError: true,
  code: 'ERR_BAD_RESPONSE',
  config: {
    transitional: {
      silentJSONParsing: true,
      forcedJSONParsing: true,
      clarifyTimeoutError: false,
      legacyInterceptorReqResOrdering: true
    },
    adapter: [ 'xhr', 'http', 'fetch' ],
    transformRequest: [ [Function: transformRequest] ],
    transformResponse: [ [Function: transformResponse] ],
    timeout: 0,
    xsrfCookieName: 'XSRF-TOKEN',
    xsrfHeaderName: 'X-XSRF-TOKEN',
    maxContentLength: -1,
    maxBodyLength: -1,
    env: { FormData: [Function], Blob: [class Blob] },
    validateStatus: [Function: validateStatus],
    headers: Object [AxiosHeaders] {
      Accept: 'application/json, text/plain, */*',
      'Content-Type': 'application/json',
      'User-Agent': 'axios/1.13.6',
      'Content-Length': '102',
      'Accept-Encoding': 'gzip, compress, deflate, br'
    },
    method: 'post',
    url: 'http://127.0.0.1:8898/v1/producer/schedule_protocol_feature_activations',
    data: '{"protocol_features_to_activate":["0ec7e080177b2c02b278d5088611686b49d739925a92d9bfcacd7fc6b74053bd"]}',
    allowAbsoluteUrls: true
  },
  request: <ref *1> ClientRequest {
    _events: [Object: null prototype] {
      abort: [Function (anonymous)],
      aborted: [Function (anonymous)],
      connect: [Function (anonymous)],
      error: [Function (anonymous)],
      socket: [Function (anonymous)],
      timeout: [Function (anonymous)],
      finish: [Function: requestOnFinish]
    },
    _eventsCount: 7,
    _maxListeners: undefined,
    outputData: [],
    outputSize: 0,
    writable: true,
    destroyed: true,
    _last: false,
    chunkedEncoding: false,
    shouldKeepAlive: true,
    maxRequestsOnConnectionReached: false,
    _defaultKeepAlive: true,
    useChunkedEncodingByDefault: true,
    sendDate: false,
    _removedConnection: false,
    _removedContLen: false,
    _removedTE: false,
    strictContentLength: false,
    _contentLength: 102,
    _hasBody: true,
    _trailer: '',
    finished: true,
    _headerSent: true,
    _closed: true,
    _header: 'POST /v1/producer/schedule_protocol_feature_activations HTTP/1.1\r\n' +
      'Accept: application/json, text/plain, */*\r\n' +
      'Content-Type: application/json\r\n' +
      'User-Agent: axios/1.13.6\r\n' +
      'Content-Length: 102\r\n' +
      'Accept-Encoding: gzip, compress, deflate, br\r\n' +
      'Host: 127.0.0.1:8898\r\n' +
      'Connection: keep-alive\r\n' +
      '\r\n',
    _keepAliveTimeout: 0,
    _onPendingData: [Function: nop],
    agent: Agent {
      _events: [Object: null prototype],
      _eventsCount: 2,
      _maxListeners: undefined,
      options: [Object: null prototype],
      defaultPort: 80,
      protocol: 'http:',
      requests: [Object: null prototype] {},
      sockets: [Object: null prototype] {},
      freeSockets: [Object: null prototype],
      keepAliveMsecs: 1000,
      keepAlive: true,
      maxSockets: Infinity,
      maxFreeSockets: 256,
      scheduling: 'lifo',
      maxTotalSockets: Infinity,
      totalSocketCount: 1,
      agentKeepAliveTimeoutBuffer: 1000,
      [Symbol(shapeMode)]: false,
      [Symbol(kCapture)]: false
    },
    socketPath: undefined,
    method: 'POST',
    maxHeaderSize: undefined,
    insecureHTTPParser: undefined,
    joinDuplicateHeaders: undefined,
    path: '/v1/producer/schedule_protocol_feature_activations',
    _ended: true,
    res: IncomingMessage {
      _events: [Object],
      _readableState: [ReadableState],
      _maxListeners: undefined,
      socket: null,
      httpVersionMajor: 1,
      httpVersionMinor: 1,
      httpVersion: '1.1',
      complete: true,
      rawHeaders: [Array],
      rawTrailers: [],
      joinDuplicateHeaders: undefined,
      aborted: false,
      upgrade: false,
      url: '',
      method: null,
      statusCode: 500,
      statusMessage: 'Internal Server Error',
      client: [Socket],
      _consuming: false,
      _dumped: false,
      req: [Circular *1],
      _eventsCount: 4,
      responseUrl: 'http://127.0.0.1:8898/v1/producer/schedule_protocol_feature_activations',
      redirects: [],
      [Symbol(shapeMode)]: true,
      [Symbol(kCapture)]: false,
      [Symbol(kHeaders)]: [Object],
      [Symbol(kHeadersCount)]: 8,
      [Symbol(kTrailers)]: null,
      [Symbol(kTrailersCount)]: 0
    },
    aborted: false,
    timeoutCb: null,
    upgradeOrConnect: false,
    parser: null,
    maxHeadersCount: null,
    reusedSocket: true,
    host: '127.0.0.1',
    protocol: 'http:',
    _redirectable: Writable {
      _events: [Object],
      _writableState: [WritableState],
      _maxListeners: undefined,
      _options: [Object],
      _ended: true,
      _ending: true,
      _redirectCount: 0,
      _redirects: [],
      _requestBodyLength: 102,
      _requestBodyBuffers: [],
      _eventsCount: 3,
      _onNativeResponse: [Function (anonymous)],
      _currentRequest: [Circular *1],
      _currentUrl: 'http://127.0.0.1:8898/v1/producer/schedule_protocol_feature_activations',
      _timeout: null,
      [Symbol(shapeMode)]: true,
      [Symbol(kCapture)]: false
    },
    [Symbol(shapeMode)]: false,
    [Symbol(kCapture)]: false,
    [Symbol(kBytesWritten)]: 0,
    [Symbol(kNeedDrain)]: false,
    [Symbol(corked)]: 0,
    [Symbol(kChunkedBuffer)]: [],
    [Symbol(kChunkedLength)]: 0,
    [Symbol(kSocket)]: Socket {
      connecting: false,
      _hadError: false,
      _parent: null,
      _host: null,
      _closeAfterHandlingError: false,
      _events: [Object],
      _readableState: [ReadableState],
      _writableState: [WritableState],
      allowHalfOpen: false,
      _maxListeners: undefined,
      _eventsCount: 6,
      _sockname: null,
      _pendingData: null,
      _pendingEncoding: '',
      server: null,
      _server: null,
      timeout: 5000,
      parser: null,
      _httpMessage: null,
      [Symbol(async_id_symbol)]: -1,
      [Symbol(kHandle)]: [TCP],
      [Symbol(lastWriteQueueSize)]: 0,
      [Symbol(timeout)]: Timeout {
        _idleTimeout: 5000,
        _idlePrev: [TimersList],
        _idleNext: [TimersList],
        _idleStart: 11165,
        _onTimeout: [Function: bound ],
        _timerArgs: undefined,
        _repeat: null,
        _destroyed: false,
        [Symbol(refed)]: false,
        [Symbol(kHasPrimitive)]: false,
        [Symbol(asyncId)]: 3415,
        [Symbol(triggerId)]: 3413,
        [Symbol(kAsyncContextFrame)]: undefined
      },
      [Symbol(kBuffer)]: null,
      [Symbol(kBufferCb)]: null,
      [Symbol(kBufferGen)]: null,
      [Symbol(shapeMode)]: true,
      [Symbol(kCapture)]: false,
      [Symbol(kSetNoDelay)]: true,
      [Symbol(kSetKeepAlive)]: true,
      [Symbol(kSetKeepAliveInitialDelay)]: 1,
      [Symbol(kBytesRead)]: 0,
      [Symbol(kBytesWritten)]: 0
    },
    [Symbol(kOutHeaders)]: [Object: null prototype] {
      accept: [Array],
      'content-type': [Array],
      'user-agent': [Array],
      'content-length': [Array],
      'accept-encoding': [Array],
      host: [Array]
    },
    [Symbol(errored)]: null,
    [Symbol(kHighWaterMark)]: 65536,
    [Symbol(kRejectNonStandardBodyWrites)]: false,
    [Symbol(kUniqueHeaders)]: null
  },
  response: {
    status: 500,
    statusText: 'Internal Server Error',
    headers: Object [AxiosHeaders] {
      server: 'nodeos/v6.0.0',
      'access-control-allow-origin': '*',
      'content-type': 'application/json',
      'content-length': '371'
    },
    config: {
      transitional: [Object],
      adapter: [Array],
      transformRequest: [Array],
      transformResponse: [Array],
      timeout: 0,
      xsrfCookieName: 'XSRF-TOKEN',
      xsrfHeaderName: 'X-XSRF-TOKEN',
      maxContentLength: -1,
      maxBodyLength: -1,
      env: [Object],
      validateStatus: [Function: validateStatus],
      headers: [Object [AxiosHeaders]],
      method: 'post',
      url: 'http://127.0.0.1:8898/v1/producer/schedule_protocol_feature_activations',
      data: '{"protocol_features_to_activate":["0ec7e080177b2c02b278d5088611686b49d739925a92d9bfcacd7fc6b74053bd"]}',
      allowAbsoluteUrls: true
    },
    request: <ref *1> ClientRequest {
      _events: [Object: null prototype],
      _eventsCount: 7,
      _maxListeners: undefined,
      outputData: [],
      outputSize: 0,
      writable: true,
      destroyed: true,
      _last: false,
      chunkedEncoding: false,
      shouldKeepAlive: true,
      maxRequestsOnConnectionReached: false,
      _defaultKeepAlive: true,
      useChunkedEncodingByDefault: true,
      sendDate: false,
      _removedConnection: false,
      _removedContLen: false,
      _removedTE: false,
      strictContentLength: false,
      _contentLength: 102,
      _hasBody: true,
      _trailer: '',
      finished: true,
      _headerSent: true,
      _closed: true,
      _header: 'POST /v1/producer/schedule_protocol_feature_activations HTTP/1.1\r\n' +
        'Accept: application/json, text/plain, */*\r\n' +
        'Content-Type: application/json\r\n' +
        'User-Agent: axios/1.13.6\r\n' +
        'Content-Length: 102\r\n' +
        'Accept-Encoding: gzip, compress, deflate, br\r\n' +
        'Host: 127.0.0.1:8898\r\n' +
        'Connection: keep-alive\r\n' +
        '\r\n',
      _keepAliveTimeout: 0,
      _onPendingData: [Function: nop],
      agent: [Agent],
      socketPath: undefined,
      method: 'POST',
      maxHeaderSize: undefined,
      insecureHTTPParser: undefined,
      joinDuplicateHeaders: undefined,
      path: '/v1/producer/schedule_protocol_feature_activations',
      _ended: true,
      res: [IncomingMessage],
      aborted: false,
      timeoutCb: null,
      upgradeOrConnect: false,
      parser: null,
      maxHeadersCount: null,
      reusedSocket: true,
      host: '127.0.0.1',
      protocol: 'http:',
      _redirectable: [Writable],
      [Symbol(shapeMode)]: false,
      [Symbol(kCapture)]: false,
      [Symbol(kBytesWritten)]: 0,
      [Symbol(kNeedDrain)]: false,
      [Symbol(corked)]: 0,
      [Symbol(kChunkedBuffer)]: [],
      [Symbol(kChunkedLength)]: 0,
      [Symbol(kSocket)]: [Socket],
      [Symbol(kOutHeaders)]: [Object: null prototype],
      [Symbol(errored)]: null,
      [Symbol(kHighWaterMark)]: 65536,
      [Symbol(kRejectNonStandardBodyWrites)]: false,
      [Symbol(kUniqueHeaders)]: null
    },
    data: { code: 500, message: 'Internal Service Error', error: [Object] }
  },
  status: 500
}
on set:  {
  name: 'eosio.boot',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot',
  target: 'eosio'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot/eosio.boot.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot/eosio.boot.abi
contract setted:  eosio
on set:  {
  name: 'eosio.system',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system',
  target: 'eosio'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system/eosio.system.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system/eosio.system.abi
contract setted:  eosio
on set:  {
  name: 'eosio.token',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token',
  target: 'eosio.token'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token/eosio.token.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token/eosio.token.abi
 ELIFECYCLE  Command failed with exit code 1.

 Lerna (powered by Nx)   Running target boot for project @coopenomics/boot failed

Failed tasks:

- @coopenomics/boot:boot

Hint: run the command with --verbose for more details.

 ELIFECYCLE  Command failed with exit code 1.
(booter.ts → cooperative.ts createPrograms) — именно так локальная dev-цепочка
получила 'blagorost' вместо 'capital' и воспроизвела баг, которого на проде
никогда не было.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:50:04 +00:00
coopops 5360daa471 fix(capital): correct BLAGOROST_AGREEMENT_TYPE to match on-chain 'blagorost'
Typecheck / desktop (pull_request) Failing after 8m53s
Typecheck / controller (pull_request) Has been cancelled
Константа хранила 'capital', но boot/src/init/cooperative.ts createPrograms()
сеет программу с type='blagorost' на генезисе (soviet::createprog), на всех
кооперативах. account.adapter.ts искал coagreement по 'capital' — не находил,
programId откатывался на 0, и вместо wallet::signagree уходил
soviet::sndagreement, который на цепи падает: get_coagreement_or_fail не
находит type='capital' в coagreements — «Соглашение указанного типа не
найдено». Ловилось при приёме платежа регистрации (registerBlockchainAccount
из gateway.interactor при PaymentType.REGISTRATION), падало для КАЖДОГО
участника, зарегистрированного по офферте Благорост.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:41:03 +00:00
coopops 652f7c722e fix(cooptypes): add missing space after colon before eoap_definition placeholder
Typecheck / desktop (pull_request) Failing after 8m50s
Typecheck / controller (pull_request) Successful in 13m45s
п.1.1 «а именно:{{ doc_data.eoap_definition }}» без пробела — во всех шести
шаблонах ЦПП (994/995/996/998/999/1000), фраза идентична во всех программах.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:46:00 +00:00
coopops a2b132b457 fix(registration): resolve doc_data_hash for extension offers via registry spec
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Шаблоны оферт ЦПП (#996/#1000) с c7824545cf требуют PrivateData, но общий
путь регистрации (RegistrationDocumentsService) не передавал doc_data_hash —
регистрация пайщика по GENERATION/CAPITALIZATION падала на генерации превью.

Ядро остаётся слепым к расширениям: AgreementRegistrationSpec получает
опциональный resolve_doc_data_hash, который расширение-владелец прикрепляет
к своим офертам при регистрации в реестре. Capital резолвит hash свежим
чтением своего конфига на каждую генерацию (совет может пересохранить
параметры без перезапуска). Гранулярность — per-оферта: при разделении
параметров по программам ядро менять не потребуется.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:41:57 +00:00
coopops a6cc71d6a3 fix(extension): unwrap TypeORM [rows, count] tuple in patchConfig UPDATE...RETURNING
TypeORM возвращает для UPDATE/DELETE кортеж [rows, affectedCount] (в отличие
от INSERT с голым rows-массивом) — без распаковки row оказывался кортежем и
все поля config читались как undefined, роняя buildState онбординга.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:41:45 +00:00
coopops b67581ce80 fix(extension): atomic jsonb config patch to stop onboarding flag lost-updates
Typecheck / desktop (pull_request) Failing after 22m0s
Typecheck / controller (pull_request) Successful in 13m37s
extensions.config is a single jsonb blob shared by every flag/hash the L1
onboarding wizards (capital, chairman) write to. Every write site did the
classic read-modify-write: findByName() the whole row, spread+mutate one key
in app memory, then update() the whole config back. Two DecisionTrackedEvent
handlers firing concurrently (two council decisions tracked near-simultaneously)
each read the same stale snapshot and each write their own flag back — whichever
write lands last wins and silently reverts the other one's flag to its old value.
Symptom hit live: onboarding_generator_program_template_done reverted to false
(with its hash still present, proving the decision really was tracked) after
onboarding_generation_contract_template_done raced it to true.

Added ExtensionDomainRepository.patchConfig(name, patch), implemented as a single
`UPDATE extensions SET config = config || patch::jsonb ... RETURNING *` — no
app-side read before the write, Postgres serializes concurrent UPDATEs on the
row so two different-key patches can no longer clobber each other. Replaced
every config read-modify-write in capital's and chairman's onboarding services
(the events handler's flag write, loadPlugin's init/expire timestamps, the
hash writes in completeStep/completeGeneralMeet, saveProgramDocDataHash) with
this atomic patch. update() is untouched for other callers.

Live data recovered manually: onboarding_generator_program_template_done was
patched back to true for voskhod/capital (hash was already present, confirming
the decision had in fact been tracked before the race reverted the flag).
2026-07-19 07:56:00 +00:00
coopops 024d46ff64 fix update 2026-07-19 07:55:49 +00:00
coopops 9030d28c6b fix(cooptypes): remove п.8.1 «Под Сайтом» from BlagorostProgramTemplate, renumber
Typecheck / desktop (pull_request) Failing after 9m37s
Typecheck / controller (pull_request) Successful in 14m3s
По решению: универсал-документ (ЦПП БЛАГОРОСТ_финал_универсал.docx.rtf,
~/blago/production/shared) — новый правильный эталон. В нём раздел 8 «Прочие условия»
содержит только 2 пункта (8.1 «Общество и Участник...», 8.2 «Изменения и дополнения...»),
без пункта про «Под Сайтом... прочие программные продукты» — ни в Положении, ни в
Оферте БЛАГОРОСТ (universal) такого пункта нет.

Убрал п.8.1 из rawContext, сдвинул 8.2→8.1, 8.3→8.2. Ссылка "(п. 7.1.)" в новом 8.2
не требует правки — форс-мажор остаётся разделом 7 независимо от сдвига в разделе 8.
Теперь 998 совпадает с универсалом точь-в-точь (сверено скриптом по номерам пунктов
и по тексту под каждым номером).
2026-07-19 07:31:02 +00:00
coopops 6e0e68c1e7 fix(cooptypes): correct cross-reference in GeneratorProgramTemplate п.12.3
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Systematic clause-numbering comparison against Положение_ЦПП_ГЕНЕРАТОР_универсал.docx.rtf
(~/blago/production/shared) found п.12.3 pointing at "(п. 12.1.)" — that's the "Под Сайтом"
definition clause, unrelated. The universal document (and the sentence's own subject —
"изменения условий ЦПП... возможность внесения которых прямо предусмотрена настоящим
Положением") both point to п.11.1, the форс-мажор/legislative-changes clause that actually
grants this right. Fixed the reference in rawContext only; left the orphaned translations
copy (994's dead legacy i18n block, unused by rendering) untouched.
2026-07-19 07:26:44 +00:00
coopops c079785019 fix(cooptypes): remove orphaned п.5.2 in BlagorostProgramTemplate, renumber section 5
Typecheck / desktop (pull_request) Failing after 9m2s
Typecheck / controller (pull_request) Successful in 13m50s
Сверил с ЦПП БЛАГОРОСТ_финал_универсал.docx.rtf (~/blago/production/shared) — в
актуальной версии Положения самостоятельный п.5.2 «Основной экономический эффект
функционирования Платформы достигается за счет членских взносов Участников при
расширении круга пользователей Платформы» отсутствует: его смысл слит в п.5.1
("...экономический эффект от функционирования которого заключается в следующем: ...").
Оферта (999/1000.Blagorost*, уже сверено) была такой всегда — 4.1 (источник) сразу
за которым 4.2 (дополнительный источник), без промежуточного пункта.

Убрал п.5.2 из rawContext, сдвинул 5.3–5.10 на 5.2–5.9, поправил внутреннюю ссылку
"указанными в п. 5.9." → "указанными в п. 5.8." в новом 5.9 (бывший 5.10). Дублирующий
текст в дохлом export const translations (не используется рендером, см. context) не трогал.
2026-07-18 18:55:46 +00:00
coopops bbc6453b24 fix(capital): await onSaved before advancing wizard past БЛАГОРОСТ step
Typecheck / desktop (pull_request) Failing after 8m49s
Typecheck / controller (pull_request) Successful in 13m45s
goNext() checked getFirstAvailableCouncilGroup() (which reads isDocParamsReady,
derived from onboardingState.capital_program_doc_data_hash) synchronously right
after saveParams() resolved. But saveParams() fired options.onSaved(hash) without
awaiting it, and the card's onSaved handler did `void handleDocParamsSaved()` —
an async refetch of onboarding state — so isDocParamsReady was still stale at the
moment goNext() decided which council group to jump to. getFirstAvailableCouncilGroup()
returned null, setWizardStepKey never ran, and the wizard just re-rendered the same
БЛАГОРОСТ step, looking like the just-saved params were never registered.

Fix: onSaved is now awaited inside saveParams(), and the card passes the
handleDocParamsSaved() promise through instead of firing it and forgetting it.
2026-07-18 18:27:57 +00:00
coopops b5eb0ea51f fix(capital): address PR #154 review — proper interface instead of as-cast, IInput['data'] canon
Typecheck / desktop (pull_request) Failing after 23m23s
Typecheck / controller (pull_request) Failing after 21m57s
document-domain.service.ts: replaced the inline `as Cooperative.Document.IGenerate & {...}`
hack with a dedicated GenerateDocumentWithPrivateDataDomainInterface for the doc_data
pre-generation branch (Cooperative.Document.IGenerate already carries a `[key: string]: any`
index signature, so the narrow interface is assignable without any cast at all).

Onboarding api/index.ts: saveProgramDocDataHash now takes
Mutations.Capital.SaveCapitalProgramDocDataHash.IInput['data'] and forwards it as-is to
variables.data, matching the SDK IInput['data'] canon used by completeStep right above it
instead of taking a bare string and rebuilding the payload inline. Updated both call sites.
2026-07-18 15:39:57 +00:00
coopops a072364d00 fix(capital): auto-generate CPP doc preview on step entry, restore section header formatting
Typecheck / desktop (pull_request) Failing after 20m30s
Typecheck / controller (pull_request) Successful in 12m40s
Preview for ГЕНЕРАТОР/БЛАГОРОСТ params no longer requires a manual button click on
first visit — onMounted now calls ensurePreview for the active wizard step. Also
fixes wizardStepOrder typing (was narrower than the runtime string values it holds).

Restores <h3> centered section-title markup in registry templates 994/995/996/998/999/1000
that was lost during the "parameterize CPP templates via private data" refactor, when
translations+context i18n structure was flattened into raw HTML.
2026-07-17 15:21:01 +00:00
coopops ed9b0879d5 chore(release): publish
Build bootstrap container / build (push) Successful in 2m42s
Release / release (push) Successful in 22m23s
Release / publish-packages (push) Successful in 14m53s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 2s
2026-07-17 14:41:21 +00:00
coopops c94043e77f fix(docker): не скачивать бинарники electron/puppeteer в mono-base
Их postinstall тянет тяжёлые сетевые загрузки (Electron zip, Chromium),
не нужные ни для build (unbuild/tsc/vite), ни для runtime — периодически
валил release job TLS-таймаутом, из-за чего publish-packages (needs: release)
не запускался вообще.
2026-07-17 14:40:39 +00:00
coopops c1a8f3e637 fix(boot/Dockerfile): изолировать pnpm install от electron/puppeteer
Build bootstrap container / build (push) Successful in 2m37s
--filter не ограничивает lifecycle-скрипты общего lockfile store — тянулись
и падали по сети чужие тяжёлые постинсталлы (electron/puppeteer из
desktop/notifications), boot их не использует. --ignore-scripts + точечный
pnpm rebuild --filter решают.
2026-07-17 14:13:35 +00:00
coopops 15fdf35dd3 chore(release): publish
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
Build bootstrap container / build (push) Failing after 2m10s
Release / release (push) Failing after 15m13s
Release / publish-packages (push) Has been skipped
2026-07-17 14:02:54 +00:00
coopops 79b89695d1 chore(release): publish 2026-07-17 13:58:44 +00:00
coopops 7b27d0805c chore(release): publish
Release / release (push) Successful in 43m2s
Release / publish-packages (push) Has been cancelled
Release / trigger-coopenomics-docs (push) Has been cancelled
Release / trigger-mono-docs (push) Has been cancelled
Build bootstrap container / build (push) Successful in 46s
2026-07-17 12:54:54 +00:00
coopops e089dbd3fb ci(release): чинить publish-packages — генерировать controller/schema.gql перед publish
С 24 июня (минимум) publish-packages падает на каждом релизе main:
@coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
читает components/controller/schema.gql, которого нет в чекауте и никто не
генерирует — ENOENT. lerna publish атомарный, поэтому НИ ОДИН пакет не
публикуется. Итог: main давно на v2026.7.9, а npm видел последний раз
2026.5.30-3 — полтора месяца фиксов (включая убранный kpp из
BankAccountDetailsInput, PR #81) не долетали до потребителей SDK.

Фикс: добавлен шаг generate-schema (изолированный, GraphQLSchemaBuilderModule,
без БД) ПОСЛЕ lerna run build — порядок важен, иначе ts-node падает на
устаревших dist соседних workspace-пакетов (проверено локально: cooptypes →
factory → inter → notifications, каждый был stale и валил компиляцию
controller'а по цепочке).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:54:33 +00:00
coopops 5230fab7c6 fix(capital): add blagorost_goal_reason field, stop reusing blagorost_goal_expansion in clause 2.1.1
Typecheck / desktop (pull_request) Failing after 9m28s
Typecheck / controller (pull_request) Successful in 14m6s
Clause 2.1.1 of БЛАГОРОСТ Положение/Оферта needs different text than the
intro paragraph, but both were wired to the same doc_data.blagorost_goal_expansion
variable (confirmed against the original "универсал" RTF sources), so 2.1.1
rendered with the wrong sentence in all three registries (998, 999, 1000).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 07:41:24 +00:00
Alex Ant 345aadbd6a update
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Successful in 14m12s
2026-07-14 22:31:19 +03:00
Alex Ant 33f1ac1a15 fix(capital): restore CPP templates from RTF sources and improve parameter constructor
Typecheck / desktop (pull_request) Failing after 18m32s
Typecheck / controller (pull_request) Failing after 6m52s
Align cooptypes/factory templates 994–1000 with original document wording,
correct doc_data substitution points, update voskhod backfill values, and
add onboarding UI with descriptive field labels and doc data hash saving.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 14:27:27 +03:00
Alex Ant 5de7e7c65b update 2026-07-13 20:53:21 +03:00
coopops bfbcd2d57f fix(desktop): починить «Продолжить» в оверлее выбора КУ
Кнопка снова submit формы из footer, BranchSelector синхронизирует selectedBranch, ошибки генерации документа показываются пользователю.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 16:02:19 +00:00
coopops ff5df68bce chore(release): publish
Release / publish-packages (push) Failing after 21m11s
Release / release (push) Successful in 1h35m7s
Release / trigger-coopenomics-docs (push) Successful in 3s
Release / trigger-mono-docs (push) Successful in 3s
Build bootstrap container / build (push) Successful in 10m29s
2026-07-09 05:49:14 +00:00
coopops a948f07a1b fix(desktop): закрепить кнопки оверлея выбора КУ на мобильных
Кнопки «Продолжить» и «подписать» вынесены в footer BaseDialog, чтобы они не уезжали за пределы экрана при прокрутке контента.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 05:48:16 +00:00
coopops 85bd044cd6 fix(desktop): перезагрузка списка КУ при показе оверлея выбора
Оверлей выбора кооперативного участка теперь подгружает список при переходе
в мажоритарный режим, показывает лоадер и блокирует «Продолжить» без выбора.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 20:46:55 +00:00
Alex Ant 45feef2676 fix(capital): correct CPP template mapping and onboarding widget canon
Typecheck / desktop (pull_request) Failing after 13m22s
Typecheck / controller (pull_request) Failing after 13m26s
Fix swapped doc_data placeholders in generator/blagorost templates, align the install widget with Base* components, and use one shared PrivateData hash in factory tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 21:49:30 +03:00
Alex Ant 02ce9400a2 fix(capital): remove unsafe any from CPP onboarding changes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 11:05:20 +05:00
Alex Ant bfa2830650 feat(capital): collect CPP document parameters in onboarding
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 10:44:02 +05:00
Alex Ant c7824545cf feat(capital): parameterize CPP templates via private data 2026-06-28 09:50:03 +05:00
Alex Ant b9107e9ee9 chore(release): publish
Build bootstrap container / build (push) Successful in 1m45s
Release / release (push) Failing after 55m53s
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-24 22:04:51 +05:00
Alex Ant 4d151befe0 fix(install): блокировать «Продолжить» до минимума совета, удаление без ограничений.
На prod требуется 3 человека (на dev — 1): кнопка и подсказка, состав редактируется свободно до перехода на следующий шаг.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 22:03:12 +05:00
Alex Ant eb16d85abc fix(install): разблокировать мастер при прерванной установке и валидировать состав совета по окружению.
Не показываем заглушку техобслуживания на /install, разрешаем повтор install из maintenance без vars, проверяем минимум членов совета до записи в цепь (3 на prod, 1 на dev).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 11:35:01 +05:00
ant ee5f7e1850 Merge pull request 'feat: выход из кооператива (выход пайщика, возврат паевого взноса)' (#137) from feat/membership-exit into dev
Reviewed-on: #137
2026-06-22 19:56:24 +00:00
Alex Ant 10bacaad13 refactor(membership-exit): обновление интерфейса и логики выхода из кооператива
Typecheck / desktop (pull_request) Failing after 25m29s
Typecheck / controller (pull_request) Successful in 27m23s
Изменены компоненты, связанные с выходом из кооператива. В меню заменены иконки и пути для поддержки и выхода. Обновлены метаданные и названия для кнопок и страниц, чтобы улучшить пользовательский интерфейс. В ExitButton добавлены параметры для настройки иконки и метки. Упрощена структура MembershipExitPage с акцентом на ключевые шаги выхода. Исправлены комментарии и типы для лучшего понимания кода.
2026-06-23 00:26:38 +05:00
coopops 1f68169b94 feat(exit): возврат при выходе — сет из 3 паевых кошельков (minshr+share+blago)
Typecheck / desktop (pull_request) Failing after 9m37s
Typecheck / controller (pull_request) Successful in 12m46s
confirmexit теперь обходит сет LEDGER2_EXIT_REFUND_WALLETS (w.reg.minshr +
w.wal.share + w.cap.blago): собирает доступные L3-балансы каждого (>0),
консолидирует на главный паевой (w.reg.minshr→o.reg.mvmin, w.cap.blago→
o.cap.wthcap) и ставит полную сумму на возврат единым платежом. Раньше
хардкодил только minshr+share — паевой в Благоросте оставался висеть.

Сет задан один раз в wallets.hpp (источник истины), генерируется gen:from-cpp
в cooptypes (LEDGER2_EXIT_REFUND_WALLETS / EXIT_REFUND_WALLET_NAMES) и обходится
backend-preview (getReturnPreview) — расчёт на фронте всегда совпадает с тем,
что реально вернёт контракт. Подпись «планируемая сумма / итог фиксирует Совет»
убрана из ExitButton и ExitOverlay (сумма теперь авторитетна).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:36:52 +00:00
coopops 95891b2319 fix(desktop): развёл иконки меню «Подключение» и «Реквизиты»
Typecheck / desktop (pull_request) Failing after 8m41s
Typecheck / controller (pull_request) Failing after 6m39s
Обе были 'fas fa-link' (одинаковые + FontAwesome запрещён каноном). На Material:
- Подключение → 'link'
- Реквизиты → 'account_balance' (банковские реквизиты)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:38:23 +00:00
coopops f41f1444b1 fix(desktop): реестр пайщиков — статус «Вышел из кооператива», дата вступления, без удаления
Typecheck / desktop (pull_request) Failing after 8m55s
Typecheck / controller (pull_request) Successful in 13m48s
- статус: вышедший пайщик (participant_account стёрт delpartcpnt + user_account
  blocked) больше не показывается «Активный пайщик» — добавлен терминал
  «Вышел из кооператива» по user_account.status
- дата вступления: фолбэк participant_account.created_at → user_account.registered_at
  (у вышедших пайщик-запись стёрта, дата больше не «отсутствует»)
- убрана кнопка удаления пайщика (таблица + карточка + диалог + машинерия):
  пайщика из реестра не удаляют

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:48:14 +00:00
coopops da450ec605 fix(desktop): «платёжка» → «чек об оплате» в UI реестра платежей
Typecheck / desktop (pull_request) Failing after 6m42s
Typecheck / controller (pull_request) Has been cancelled
Подсказки/лейблы переименованы под каноничную терминологию (чек об оплате —
подтверждающий документ, не «платёжка»):
- тултипы «Чек об оплате приложен / не приложен» (desktop + mobile)
- fileKindLabel PAYMENT_PROOF → «Чек об оплате»
- расчётный ПЛАТЁЖ возврата/доплаты (вместо «платёжка») в подсказках/алертах
  ReportExpenseAdvance + согласование рода
- комментарии приведены к новой терминологии (чек / расчётный платёж)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:16:31 +00:00
coopops 7fdfd8385a feat(gateway): ядровый «чек об оплате» исходящих платежей по payment_hash
Typecheck / desktop (pull_request) Failing after 8m31s
Typecheck / controller (pull_request) Successful in 14m26s
Культура денег: входящие — подтверждением, исходящие — чеком. Единый механизм
на уровне ядра (gateway), привязка по payment_hash — один на все исходящие
(возврат паевого/withdrawal/registration-refund/аванс расхода), переиспользуется
расширениями. Контроль мягкий: статус не трогаем, но зеркалим proof_count →
реестр рисует «чек приложен / не приложен».

Backend (core gateway):
- таблица payment_files + бакет gateway:files (@UseBucket), PaymentFilesService
  (upload/read-url/list/delete + зеркалирование proof_count в платёж)
- enum PaymentFileKind (PAYMENT_PROOF), DTO, резолвер uploadPaymentProof /
  paymentProofs / paymentFile; провайдеры в typeorm.module + gateway.module

SDK: selectors/mutations/queries gateway + zeus regen

Desktop:
- features/Payment/AttachPaymentProof — панель чека по payment-hash
- реестр: панель + индикатор «чек приложен» для ЛЮБОГО исходящего (PAID/COMPLETED)
- конвергенция expense: чек ушёл в ядро, AttachExpenseProof оставляет только
  закрывающие документы (DIRECT)

Терминология: «чек об оплате» (не «платёжка»/«первичка»).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:17:09 +00:00
coopops 39433753c2 fix(membership-exit): терминальный экран после выхода + гард повторного выхода
Typecheck / desktop (pull_request) Failing after 8m47s
Typecheck / controller (pull_request) Has been cancelled
После completexit строка registrator::exits стирается (терминал=erase) →
getMembershipExit возвращал null → оверлей пропадал, кабинет разблокировался,
пайщик видел стол и мог подать выход повторно (гарда не было).

- enum MembershipExitStatus += COMPLETED
- getMembershipExit: терминальная фаза по blocked-аккаунту + персистентному
  MEMBERSHIP_EXIT-платежу (сумма возврата + статус 'Оплачено')
- createMembershipExit: гард — заблокированный аккаунт не может выйти повторно
  (контракт уже блокирует через get_participant_or_fail; отсекаем раньше)
- ExitOverlay: терминальная фаза 'Вы вышли из кооператива' (сумма + 'Оплачено'
  + ожидайте поступления), она же UX-гард — перекрывает кабинет
- SDK Zeus regen под новый enum

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:57:54 +00:00
coopops 560321853c Merge remote-tracking branch 'origin/dev' into feat/membership-exit
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
2026-06-16 08:42:23 +00:00
ant f42b703098 Merge pull request 'ci(release): независимый деплой окружений (testnet/main разным темпом)' (#150) from ci/independent-env-deploy into dev
Reviewed-on: #150
2026-06-16 08:17:55 +00:00
ant d7e08785eb Merge pull request 'fix(capital): регистрация долей Благороста при создании проекта по событию (не ждать крон)' (#151) from fix/capital-share-onproject-event into dev
Reviewed-on: #151
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-16 08:15:29 +00:00
coopops 7efd19f1bd chore(release): publish
Build bootstrap container / build (push) Has been cancelled
Release / publish-packages (push) Failing after 8m37s
Release / release (push) Successful in 27m21s
Release / trigger-coopenomics-docs (push) Successful in 1s
Release / trigger-mono-docs (push) Successful in 2s
2026-06-16 08:11:23 +00:00
coopops cce099748a fix(capital/state): program_expense_* в хвост через binary_extension — фикс нечитаемой таблицы state на проде
Инцидент 2026-06-16: коммит d02c0ce (18 мая) добавил program_expense_pool и
program_expense_reserved в global_state ПЕРЕД полем config напрямую, без
binary_extension. После деплоя на прод таблица state перестала читаться:
запись сериализована старым layout (config сразу после
program_membership_cumulative_reward_per_share), а новый ABI ждёт два asset
перед config → unpack натыкается на байты config (get table → "Invalid
symbol ...Y@" = double 100.0 = config.expense_pool_percent). Любой action,
читающий global_state, падал.

Фикс: оба поля перенесены в ХВОСТ struct (после config) и обёрнуты в
eosio::binary_extension<asset>. Старая запись прода читается без изменений
(хвостовые extension опциональны → пусто = 0), новая логика программных
расходов сохранена. Поля в таблицу EOSIO дописываются только в конец и только
binary_extension'ом.

utility-функции State:: (topup/reserve/release/consume/spend) переведены на
optional-семантику через ext_or_zero(); инвариант — оба поля материализуются
вместе, чтобы порядок хвостовых extension был консистентным. Прямого доступа к
полям вне State:: нет. Собрано в CDT-докере: capital.wasm слинкован, в ABI
program_expense_* → asset$ после config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:06:16 +00:00
coopops 3d04f76446 fix(capital): заводить доли только в active-проектах, не в pending
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
По решению пользователя заход долей Благороста — только в активные проекты.
В pending больше не заводим: при переходе pending→active придёт новая дельта
capital::projects со status=active, на ней листенер и зарегистрирует.

- Листенер ProgramShareRegistrationOnProjectDeltaListener: гейт pending|active → только active.
- ProgramShareRegistrationService.findActiveProjects (путь крона и wallet-листенера): фильтр pending|active → только active.
- Тест: pending теперь НЕ реагирует; active — позитивный кейс.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:04:08 +00:00
coopops 532e34c53b ci(release): promote по умолчанию шлёт текущий HEAD (не последний тег)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Дефолт promote.sh — текущий HEAD чекаута; второй аргумент по-прежнему задаёт явный
ref (ветка/тег/sha). Типичный путь: cut на dev → promote testnet → promote main,
все три от того же HEAD. testnet/main остаются независимыми FF-указателями.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:45:04 +00:00
coopops a0e0c9310c fix(desktop): confirm-выхода — auth-aware навигация вместо молчаливого бойнса на логин
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Ссылка из письма открывается часто без сессии кабинета (другой браузер/инкогнито/
после logout). Подтверждение работает по токену без входа, но wallet защищён —
гард молча редиректил на login-redirect, и пайщик не понимал, что произошло.

- есть сессия → 'Перейти в кабинет' → wallet (глобальный ExitOverlay сам покажет
  'на рассмотрении Совета')
- нет сессии → 'Войти в кабинет' → signin + пояснение, что войти нужно для слежения
  за статусом и суммой возврата

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:40:02 +00:00
coopops 0d51973a7b fix(capital): регистрировать доли Благороста при создании проекта по событию, без ожидания крона
Typecheck / desktop (pull_request) Successful in 13m55s
Typecheck / controller (pull_request) Has been cancelled
Доли пайщиков Благороста регистрировались в проект только периодическим
scheduler'ом (regshare) и точечным листенером на дельту кошелька. Новый
проект баланс не меняет → wallet-листенер молчит, и наполнение нового
проекта зависело ТОЛЬКО от крона. Если компонент быстро прогнали
pending→…→result до тика крона, пайщики в него не попадали, а откат
result→active контракт не допускает — доли в компоненте терялись
безвозвратно (voskhod, компонент 011bcd92…, 2026-06-16).

- Новый ProgramShareRegistrationOnProjectDeltaListener на
  delta::capital::projects: при появлении проекта в статусе pending|active
  сразу регистрирует доли всех активных пайщиков (неблокирующе, fire-and-forget,
  переиспользуя ядро syncContributor — тот же путь, что и крон).
- ProgramShareRegistrationService.syncProgramSharesForProject — обход пайщиков
  по одному проекту; syncContributor переведён с ProjectDomainEntity[] на
  projectHashes: string[].
- Крон ОСТАВЛЕН как reconciliation-бэкстоп: он ещё и ДОобновляет уже
  зарегистрированные доли при дрейфе баланса (контракт upsert_contributor_segment
  это допускает) и подбирает события, потерянные при downtime контроллера.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:30:10 +00:00
coopops 9c2729d243 ci(release): независимый деплой окружений — testnet/main как FF-указатели на теги dev
Typecheck / desktop (pull_request) Successful in 13m58s
Typecheck / controller (pull_request) Successful in 13m15s
Паровоз dev→testnet→main заменён независимыми указателями: каждое окружение —
самостоятельный fast-forward-указатель на выбранный релизный тег dev. Прод и тест
катятся разным темпом и на разные версии одновременно, без диверджа и конфликтов
(оба — FF-указатели на линейную историю dev).

- promote.sh <testnet|main> [ref]: FF выбранного тега (по умолчанию — последний на
  origin/dev) на ветку окружения; main больше не зависит от testnet; только вперёд.
- release.yaml: workflow_dispatch получил inputs environment+ref — откат на старую
  версию / редеплой без бампа / hotfix в один контур (то, что FF не умеет). Сборка
  идёт из вычекнутого ref, окружение — из inputs.environment. Авто-триггер по
  изменению lerna.json сохранён.
- RELEASE.md: модель и инварианты переписаны под независимые указатели.

Принципы деплоя сохранены: бамп версии один раз на dev, образы по версии,
BUILD_MODE по окружению, webhooks per env, npm publish/доки только на main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 06:49:12 +00:00
coopops 00e15be65e fix(desktop): убрал '*/' из SCSS-комментария — ломал sass-парсер
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Successful in 14m13s
'text-h*/inline' досрочно закрывал блочный комментарий → expected '{'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:31:10 +00:00
coopops a349cfcf6a fix(desktop): диалог выхода — придавил заголовок заявления к канону + soft-панель итога
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- заголовок документа из DocumentHtmlReader (3rem) прижат к h2 по канону,
  :deep + !important как в ReadStatement.vue
- 'Сумма к возврату' оформлена soft-панелью (label+hint слева, mono-значение
  справа) вместо висящего снизу текста

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:26:03 +00:00
coopops 07ec6d9a5b chore(release): publish
Build bootstrap container / build (push) Failing after 1m27s
Release / release (push) Failing after 16m43s
Release / trigger-mono-docs (push) Has been skipped
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
2026-06-16 06:02:31 +00:00
coopops 102eeb319e fix(desktop): суммы выхода через formatAsset2Digits + одна сумма без разбивки
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
- Суммы к возврату форматируются канонной formatAsset2Digits ("300.0000 RUB"
  → "300,00 RUB") в диалоге заявления и в оверлее — вместо сырого asset.
- Убрана разбивка "целевой + минимальный" (оба паевые, суммируются): показываем
  одну строку "Сумма к возврату".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:59:14 +00:00
coopops 2f1e078fe2 fix(desktop): один лоадер в диалоге заявления о выходе
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Было две крутилки рядом («Формирование заявления…» + «Расчёт суммы…»).
Теперь заявление и сумма грузятся параллельно (Promise.allSettled) под одним
центрированным лоадером и показываются вместе. Тот же стиль лоадера — на фазе
проверки реквизитов. Сумма некритична: документ подписывается и без предрасчёта.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:55:22 +00:00
coopops 554798b74b fix(desktop): пункт «Выход из кооператива» в конец меню + надёжный гейт реквизитов
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- Пункт меню «Выход из кооператива» перенесён в самый низ (после «Поддержки»),
  иконка group_remove вместо logout — чтобы не сливаться с кнопкой «Выйти»
  (выход из кабинета, дверь-logout) внизу панели.
- Гейт реквизитов сделан fail-open: блокируем выход ТОЛЬКО при достоверно
  пустом списке методов; null/непонятный ответ не блокирует. + реактивный
  фолбэк: если бэкенд отклонил подачу из-за отсутствия реквизитов — диалог
  переключается на баннер. Авторитетный источник — бэкенд (createMembershipExit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:44:34 +00:00
ant 0ae8702ade Merge pull request 'fix(registration): pending-счёт не показывать как «платёж принят»' (#149) from fix/registration-pending-display into dev
Build bootstrap container / build (push) Has been cancelled
Reviewed-on: #149
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-16 05:36:38 +00:00
coopops 2d71706840 fix(registration): pending-счёт не считать «оплачено» — вести на оплату, а не на ожидание
Typecheck / desktop (pull_request) Successful in 13m50s
Typecheck / controller (pull_request) Has been cancelled
Счёт регистрационного взноса создаётся со статусом pending сразу при заходе на
шаг оплаты («счёт выставлен», деньги ещё не получены). При возврате на страницу
(перезагрузка/повторный вход) роутинг считал сам факт наличия платежа за «этап
оплаты пройден» и вёл на экран ожидания, где дефолтная ветка показывала «Ваш
платеж принят» — хотя оплаты не было и статус не менялся.

- SignUp: на экран ожидания/отказа ведём только при PAID/COMPLETED либо
  терминальном статусе (отказ/отмена/возврат); pending → шаг оплаты (QR + поллинг).
- WaitingRegistration: для pending — отдельная ветка «Ожидаем поступление оплаты»
  + кнопка «Перейти к оплате»; «платёж принят» остаётся только для PAID/COMPLETED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 05:29:50 +00:00
coopops b8568eff4c feat(desktop): статус платежа в оверлее выхода + страница заявления по канону
Typecheck / desktop (pull_request) Failing after 8m24s
Typecheck / controller (pull_request) Has been cancelled
- ExitOverlay: чип статуса исходящего платежа у суммы (ожидает оплаты/оплачивается/
  оплачено/ошибка) — берётся из payment_status, меняется по мере обработки кассиром.
- MembershipExitPage переверстана на AuthCard (иконка-предупреждение + заголовок +
  ключевые шаги процедуры + запуск подачи) — единый канон с оверлеем.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:28:16 +00:00
coopops 448eb1de80 feat(membership-exit): статус исходящего платежа в getMembershipExit
getMembershipExit подтягивает платёж возврата по hash=exit_hash и отдаёт его
payment_status (PaymentStatus enum, nullable). Фронт показывает реальный статус
платежа кассира (ожидает оплаты → оплачено), а не только статус процесса выхода.
Сумма к возврату — зафиксированная советом on-chain (= сумма платежа), не preview.

+ codegen (controller/sdk zeus, селектор MembershipExit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:28:16 +00:00
coopops 8fae69d979 feat(desktop): экран выхода — собран в карточку AuthCard
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Has been cancelled
Контент больше не плавает по пустому экрану: статус выхода — в центрированной
карточке AuthCard (accent-стрип + мягкая тень, канонный hero-контейнер).
Иконка статуса в soft-плитке (primary/pos), заголовок, пояснение, сумма к
возврату на surface-2, действия в футере с разделителем. Всё на токенах --p-*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:12:55 +00:00
coopops 1b1b8b3246 feat(desktop): экран выхода по канону + кнопка выхода из личного кабинета
Typecheck / desktop (pull_request) Failing after 6m51s
Typecheck / controller (pull_request) Has been cancelled
- ExitOverlay переверстан на канонный EmptyState (иконка-плитка + заголовок +
  приглушённый текст + слот действий) вместо россыпи text-h5/64px-иконки.
- Добавлена кнопка «Выйти из личного кабинета» (logout) на оба экрана выхода:
  при активном выходе аккаунт заблокирован, и выйти из сессии было нечем.
  После logout gate обнуляется, редирект на signin; при повторном входе экран
  выхода снова показывается (статус живёт on-chain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:05:26 +00:00
coopops 72a8a3d0c3 feat(desktop): гейт реквизитов в выходе из кооператива
Typecheck / desktop (pull_request) Failing after 8m35s
Typecheck / controller (pull_request) Successful in 13m35s
Перед подписанием заявления о выходе проверяем наличие реквизитов пайщика
(getPaymentMethods). Если их нет — вместо формы показываем баннер «установите
реквизиты для получения возврата паевого взноса» + кнопку перехода на страницу
реквизитов (payment-methods). Бэкенд проверяет то же при подаче (страховка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:17:46 +00:00
coopops 56cd422440 feat(membership-exit): исходящий платёж возврата паевого + гейт реквизитов
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Закрываем пробел: при одобрении советом выхода (on-chain confirmexit) кассир
не видел платёж возврата — его никто не создавал в реестре gateway.

- Гейт реквизитов: createMembershipExit отклоняет подачу, если у пайщика нет
  ни одного платёжного метода («установите реквизиты для возврата паевого»).
- MembershipExitAuthorizationListener (@OnEvent action::registrator::confirmexit):
  по exit_hash берёт из таблицы exits username + сумму возврата, заводит
  исходящий платёж MEMBERSHIP_EXIT (PENDING — совет одобрил, сразу кассиру) по
  реквизитам пайщика (метод по умолчанию). hash платежа = exit_hash, поэтому
  подтверждение кассой через default-ветку processOutgoingPayment вызовет
  completeOutcome → registrator::completexit (списание + блокировка).
- getExitByHash в account-порт/адаптер: confirmexit отдаёт только coopname+exit_hash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:13:38 +00:00
coopops 949b5139af chore(codegen): regen Zeus после merge — membership-exit + expense в одной схеме
После merge dev zeus-клиенты были взяты из dev (без операций выхода).
Интроспекция живого coopback → schema.gql → generate-client вернула обе
группы операций (createMembershipExit/confirm/cancel + expense-шасси).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:13:38 +00:00
coopops 50d5d71c8d Merge branch 'dev' into feat/membership-exit
Втягиваем инфраструктуру исходящих платежей (шасси расходов C28) для
создания возврата паевого взноса в gateway при выходе пайщика.

Конфликты (объединены обе стороны):
- gateway payment-type.enum / payment-domain.entity: MEMBERSHIP_EXIT + EXPENSE*
- zeus-клиенты (controller, sdk): взята версия dev, регенерация следом

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:30:43 +00:00
coopops 8d9bc5f592 chore(codegen): controller Zeus-клиент — типы MembershipExit
Typecheck / desktop (pull_request) Successful in 13m43s
Typecheck / controller (pull_request) Successful in 13m28s
Регенерация Zeus-клиента контроллера под GraphQL-операции выхода
(CreateMembershipExitInput, MembershipExitStatus, signed-document inputs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:27:22 +00:00
coopops 82ea0a4c03 fix(desktop): страница подтверждения выхода — без requiresAuth
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Маршрут membership-exit/confirm авторизуется токеном из ссылки (мутация
confirmMembershipExit публичная), поэтому навигационный гард не должен
редиректить его на login-redirect. requiresAuth:false исключает страницу из
auth-гейта — ссылку из письма можно открыть без активной сессии.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:00:16 +00:00
coopops b728bc6295 fix(membership-exit): письмо подтверждения — неблокирующая отправка + лог ссылки
Typecheck / desktop (pull_request) Successful in 13m37s
Typecheck / controller (pull_request) Has been cancelled
Черновик заявления сохраняется до отправки письма; если провайдер писем
недоступен, приём заявления не падает (try/catch + error-лог), пайщик может
отменить. Ссылка подтверждения пишется в debug-лог для диагностики.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:20:39 +00:00
coopops 59585dc4ea feat(desktop): выход из кооператива с email-подтверждением — UI
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- ExitButton: диалог теперь показывает сгенерированное заявление (DocumentHtmlReader)
  с предупреждением «читайте внимательно, необратимо» + сумма к возврату, затем
  «Подписать и подать» (generateApplication → показ → submitSignedApplication).
- ExitOverlay: новое состояние AWAITING_CONFIRMATION — «пройдите по ссылке из письма»
  + кнопка «Отменить выход»; после перехода/отмены overlay переключается на
  ончейн-статус (рассмотрение Совета → одобрено → выплаты).
- useExitGate: isAwaitingConfirmation + cancelExit (Mutations.MembershipExit.CancelMembershipExit).
- model: generateApplication / submitSignedApplication / confirmExit вместо единого processMembershipExit.
- MembershipExitConfirmPage + маршрут membership-exit/confirm (цель ссылки из письма):
  onMounted confirmExit(token) → loadExitStatus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:19:30 +00:00
coopops 6bba3c7b76 feat(sdk): confirmMembershipExit/cancelMembershipExit + MembershipExitResult.status
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Регенерация Zeus-клиента (интроспекция живого coopback) + ручные обёртки:
- Mutations.MembershipExit.ConfirmMembershipExit (token → MembershipExitResult)
- Mutations.MembershipExit.CancelMembershipExit (coopname/username → Boolean)
- membershipExitResultSelector += status (enum += AWAITING_CONFIRMATION)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:12:59 +00:00
coopops 9ee57d1e05 feat(membership-exit): email-подтверждение выхода — backend + письмо
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Двойное подтверждение необратимого выхода (закрытия аккаунта): заявление
подписывается и принимается сразу, но в блокчейн уходит только после перехода
по ссылке из письма (по аналогии с verify-email/reset-key токенами).

- notifications: воркфлоу MembershipExitConfirmation (письмо со ссылкой confirmationUrl).
- token: тип CONFIRM_EXIT + generateConfirmExitToken (домен + application).
- entity membership_exit_requests (off-chain черновик подписанного заявления; uniq coopname+username; удаляется при confirm/cancel).
- MembershipExitStatus += AWAITING_CONFIRMATION; MembershipExitResult += status.
- service: createMembershipExit теперь сохраняет черновик + шлёт письмо (НЕ цепь);
  confirmMembershipExit(token) проверяет токен и шлёт exitcoop в цепь;
  cancelMembershipExit удаляет черновик+токен; getMembershipExit отдаёт off-chain фазу.
- resolver: + confirmMembershipExit (публичная, по токену) + cancelMembershipExit (auth+владелец).

Проверено вживую: мутации в схеме, enum/Result обновлены, таблица создана (synchronize).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:08:03 +00:00
coopops 0c995b1bca fix(membership-exit): заявление о выходе (200) — только цифровая подпись, без шапки утверждения
Typecheck / desktop (pull_request) Successful in 13m59s
Typecheck / controller (pull_request) Has been cancelled
Заявление о выходе из кооператива подписывается цифровой подписью (signatures[]),
поэтому из документа убрано поле собственноручной подписи (рукописная картинка):
- cooptypes 200: убраны Action.signature/Model.signature, <img src="{{ signature }}">,
  signature из exampleData;
- factory Actions/200: убрана логика поиска/сохранения подписи в mongo;
  Templates/200: убрано свойство signature из AJV-схемы;
- controller DTO: убрано поле signature (@IsString) из меты заявления.
  Это и вызывало "signature must be a string" — ValidationPipe (422, не логируется)
  рубил генерацию, т.к. фронт картинку не передаёт.

Также откат самодуманной шапки "ФОРМА УТВЕРЖДЕНА решением Собрания Совета (Протокол №)":
исходная форма заявления её не содержит. Убраны vars.participant_exit_application
(cooptypes IVars + factory VarsSchema + фикстура теста), проверка протокола в фабрике
и переводы APPROVED/approved_by_council/protocol.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:47:30 +00:00
coopops 5aea77bdcd feat(desktop): отдельная страница выхода + глобальная блокировка кабинета на время процедуры
Typecheck / desktop (pull_request) Successful in 13m42s
Typecheck / controller (pull_request) Successful in 13m1s
По уточнению UX:
- кнопка выхода убрана из шапки кошелька; заведена отдельная страница стола
  пайщика «Выход из кооператива» (route membership-exit) с описанием процесса
  (добровольно по заявлению; аккаунт закрывается и удаляется; паевой возвращается
  в срок по Уставу кооператива — без хардкода срока) + кнопка с переподтверждением;
- глобальный gate: пока активен процесс выхода (registrator::exits через query
  membershipExit), ExitOverlay (maximized, без закрытия) блокирует весь кабинет
  и показывает только статус заявления (ожидание Совета / одобрено) и планируемую
  сумму возврата — элегантно, по канону. Эталон — SelectBranchOverlay;
- useExitGate (статус+сумма, опрос) + watch-exit-overlay (поллинг 15с) +
  монтаж в App.vue + регистрация в init-app; после подачи статус подтягивается
  сразу;
- текст переподтверждения переформулирован (необратимо, запуск возврата +
  решение Совета).

ESLint чисто. Type-check — в CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:45:34 +00:00
coopops 3ac1b168d7 feat(controller,sdk): запрос статуса выхода пайщика (membershipExit) для блокировки кабинета
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- query membershipExit(coopname, username) → {exit_hash, status, quantity, created_at}
  или null; читает chain-таблицу registrator::exits; enum MembershipExitStatus
  (PENDING/AUTHORIZED); self-/operator-доступ;
- AccountBlockchainPort/adapter.getExit — чтение exits по username;
- SDK: Queries.MembershipExit.GetMembershipExit + селектор.

Нужен фронту для глобального gate: пока есть активный выход — кабинет блокируется,
показывается только статус заявления и сумма возврата. tsc контроллера + SDK чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:36:02 +00:00
coopops 2e42cac14c feat(desktop): UI выхода пайщика из кооператива
Typecheck / desktop (pull_request) Successful in 13m52s
Typecheck / controller (pull_request) Successful in 13m19s
- feature Membership/ExitFromCoop: кнопка «Выход из кооператива» (BaseButton
  danger) на WalletPage в шапке рядом с возвратом паевого; диалог-предупреждение
  (BaseDialog+Form+BaseBanner) с предрасчётом суммы возврата
  (MembershipExitReturnPreview) и текстом «паевой будет возвращён, аккаунт
  заблокирован, возврат невозможен»; submit генерит заявление (200), подписывает
  и подаёт createMembershipExit (push exitcoop). Канон-компоненты, Material-иконки;
- feature Membership/GenerateMembershipExitDecision: генерация решения совета (201);
- process-decisions: handler `leavecoop` в реестре повесток — Совет генерирует
  решение по выходу так же, как по вступлению/возврату.

ESLint чисто. Type-check — в CI (полный vue-tsc локально не гоняем).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:48:16 +00:00
coopops c1a940d1cd feat(sdk): codegen + операции выхода из кооператива (Zeus-клиент + Mutations/Queries)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- регенерирован Zeus-клиент (src/zeus) из обновлённой схемы контроллера —
  типы MembershipExit*Input / MembershipExitResult / MembershipExitReturnPreview;
- Mutations.MembershipExit: GenerateMembershipExitApplication /
  GenerateMembershipExitDecision / CreateMembershipExit;
- Queries.MembershipExit.MembershipExitReturnPreview;
- селекторы membershipExitResult / membershipExitReturnPreview.

tsc + сборка SDK — чисто. schema.gql не коммитим (gitignored-артефакт).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:37:59 +00:00
coopops 90f3a892a6 feat(controller): backend выхода пайщика из кооператива (генерация 200/201 + подача + предрасчёт)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- PaymentTypeEnum.MEMBERSHIP_EXIT (исходящий) + лейблы;
- document-DTO заявления (200) и решения совета (201) — зеркало join-документов;
- ParticipantInteractor.generateMembershipExit{Application,Decision} (registry 200/201);
- AccountBlockchainPort/adapter.exitCoop — push registrator::exitcoop с подписанным
  заявлением (прямой transact кооперативным ключом, как reguser);
- модуль membership-exit: resolver+service с мутациями
  generateMembershipExitApplication / generateMembershipExitDecision /
  createMembershipExit и query membershipExitReturnPreview (сумма возврата по
  L3-балансам w.wal.share + w.reg.minshr; итог фиксирует контракт на confirmexit);
  self-service для пайщика + операторские роли (chairman/member);
- cooptypes Ledger2.SHARE_WALLET_NAME / MIN_SHARE_WALLET_NAME — именованные
  константы кошельков вместо магических строк.

tsc --noEmit контроллера — чисто. Codegen Zeus-клиента — отдельным коммитом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:30:01 +00:00
coopops 941030f58f feat(cooptypes): TS-зеркала выхода из кооператива (registrator + soviet)
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
- interfaces/registrator: IExitcoop/IConfirmexit/ICompletexit/IDeclinexit + IExit
  (таблица exits) — синхронно с обновлённым ABI registrator;
- interfaces/soviet: IDelpartcpnt;
- action-зеркало registrator ExitCoop (член-инициируемое действие, как RegisterUser);
  внутренние коллбэки confirmexit/completexit/declinexit конструируются каскадом
  из soviet/gateway и в зеркалах не нуждаются (как confirmreg/addpartcpnt);
- table-зеркало registrator Exits (scope=coopname) для чтения статуса выхода и
  суммы возврата на бэкенде/фронте.

tsc --noEmit cooptypes — чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:08:07 +00:00
coopops dbda7ca5ba feat(contracts): оркестрация выхода пайщика из кооператива (registrator + soviet)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Жизненный цикл выхода — зеркало вступления (reguser→confirmreg) и возврата
паевого (wallet::createwthd→authwthd→completewthd):

- registrator::exitcoop    — пайщик подаёт заявление о выходе (registry 200),
  создаётся реестр exits (status=pending) и повестка совета `leavecoop`;
- registrator::confirmexit  — совет одобрил: контракт сам считает сумму возврата
  по L3-балансам ledger2 (мин. + целевой паевой), консолидирует минимальный на
  главный (o.reg.mvmin), резервирует сумму (o.wal.wthreq) и шлёт исходящий
  платёж в gateway; нулевой паевой → финализация без платежа;
- registrator::completexit  — кассир подтвердил выплату: проводка Дт80/Кт51
  (o.wal.wthcpl), пайщик удаляется (soviet::delpartcpnt), аккаунт блокируется;
- registrator::declinexit   — отказ совета или платежа: снятие резерва
  (o.wal.wthdec, если был), пайщик остаётся в кооперативе.

Новое: таблица registrator::exits, soviet_action `leavecoop`, soviet::delpartcpnt
(стирание пайщика, зеркало addpartcpnt; уменьшает счётчик активных). Новых
ledger2-кодов не вводилось — переиспользованы o.wal.wthreq/wthcpl/wthdec и ранее
добавленный o.reg.mvmin. registrator уже в contracts_whitelist → вправе применять
wallet-операции. Обе сборки (soviet/registrator) проходят в docker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:02:56 +00:00
coopops 93e13d6a14 feat(contracts): ledger2-операция o.reg.mvmin (мин. паевой → главный при выходе)
Typecheck / desktop (pull_request) Successful in 13m38s
Typecheck / controller (pull_request) Has been cancelled
Операция-фундамент для консолидации при выходе из кооператива: переносит
минимальный паевой (w.reg.minshr) на главный паевой кошелёк (w.wal.share)
через TRANSFER без проводки (оба кошелька на счёте 80), чтобы вернуть его
вместе с основным паевым через wallet-withdraw (o.wal.wthcpl, Дт80/Кт51).

- operations.hpp: объявление + запись OPERATION_REGISTRY (static_assert OK,
  registrator скомпилирован в docker dicoop/blockchain).
- operations.ts: ручное TS-зеркало (синхронизация обеих сторон).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:28:50 +00:00
coopops afeb728bec fix(factory): заявление о выходе (200) — паспорт по условию + ЭП-подпись
Typecheck / desktop (pull_request) Successful in 13m33s
Typecheck / controller (pull_request) Successful in 13m15s
- Паспортные данные физлица выводятся при vars.passport_request=='yes',
  как в заявлении на вступление (100).
- Внизу документа «Документ подписан электронной подписью.» вместо
  «личная подпись заявителя» (по правке пользователя).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:59:51 +00:00
coopops ee60ece571 feat(factory): документы выхода из кооператива (registry 200/201)
Typecheck / desktop (pull_request) Successful in 13m34s
Typecheck / controller (pull_request) Successful in 13m13s
Заявление о выходе из состава пайщиков (200) с условным рендером по трём
ролям (физлицо/ИП/юрлицо) — зеркало 100.ParticipantApplication, текст по
утверждённой форме. Решение совета о выходе (201) — зеркало 501.

- cooptypes: registry 200/201 (Model + context + переводы + exampleData),
  поле vars.participant_exit_application для шапки «ФОРМА УТВЕРЖДЕНА».
- factory: Templates/Actions 200/201, регистрация в индексах и factories-map.
- test: генерация 200 в трёх ролях + 201; сидинг participant_exit_application.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 10:24:19 +00:00
670 changed files with 10362 additions and 29709 deletions
-11
View File
@@ -39,14 +39,3 @@ 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
+39 -3
View File
@@ -33,7 +33,22 @@ on:
branches: [testnet, main]
paths:
- 'lerna.json'
# Ручной деплой: выбрать окружение и (опционально) версию/коммит. Нужен для
# отката на старую версию, редеплоя без бампа и точечного hotfix в один контур —
# всё, что FF-промоушн (scripts/promote.sh, только вперёд) сделать не может.
workflow_dispatch:
inputs:
environment:
description: 'Куда деплоить'
type: choice
options:
- testnet
- main
required: true
ref:
description: 'Релизный тег/коммит для сборки (пусто = верхушка выбранного окружения)'
required: false
default: ''
permissions:
contents: write
@@ -50,13 +65,22 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
# push → собираем пушнутый коммит; ручной запуск → выбранный ref
# (пусто = верхушка ветки окружения, на которой запущен workflow).
ref: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.ref) || github.sha }}
- name: Resolve branch, build mode and version
id: resolve
run: |
# Ветка — это ветка push'а (или выбранная в workflow_dispatch).
BRANCH="${{ github.ref_name }}"
SHA="${{ github.sha }}"
# Окружение: при ручном workflow_dispatch — из inputs.environment;
# при push — это ветка push'а (testnet/main). Версия/сборка берутся
# из реально вычекнутого дерева, не из ветки-триггера.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
BRANCH="${{ github.event.inputs.environment }}"
else
BRANCH="${{ github.ref_name }}"
fi
SHA="$(git rev-parse HEAD)"
# Версия — из закоммиченного lerna.json. Её бампит lerna version ОДИН
# раз на dev (scripts/cut-release.sh), и тот же коммит едет по FF в
@@ -366,6 +390,18 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm lerna run build
# schema.gql не коммитится и не создаётся ничем в этом job'е, а
# @coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
# читает components/controller/schema.gql и падает ENOENT без него — валя весь
# lerna publish (атомарный) молча с 30 мая. Скрипт изолированный (GraphQLSchemaBuilderModule,
# без TypeORM/Mongo/Redis) — не требует БД, но типизация controller'а резолвится через
# dist соседних workspace-пакетов (cooptypes/factory/notifications/inter) — ОБЯЗАТЕЛЬНО
# после `lerna run build`, иначе ts-node падает на их устаревших dist-типах.
- name: Generate controller schema.gql (required by sdk prepublishOnly)
working-directory: components/controller
run: pnpm run generate-schema
- run: pnpm lerna publish from-package --yes
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-59
View File
@@ -1,59 +0,0 @@
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
-3
View File
@@ -25,6 +25,3 @@ components/contracts/cpp/ledger2/scripts/out/
.pnpm-store
schema.gql
.claude
# CoopID: file-based Docker Secrets (генерируются scripts/coopid-gen-secrets.sh)
infra/coopid/secrets/
+8
View File
@@ -45,6 +45,14 @@ RUN corepack enable && npm install -g lerna --no-fund --no-audit
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
COPY . .
# electron (devDep desktop → @vue/devtools) и puppeteer нигде в build/runtime
# не запускаются (unbuild/tsc/vite их не трогают) — только их JS-обёртка нужна
# для типов. Их postinstall тянет тяжёлые бинарники (Electron zip с GitHub
# Releases, Chromium) по сети — периодически валит билд TLS-таймаутом.
# Отключаем сами загрузки, пакеты всё равно ставятся.
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
PUPPETEER_SKIP_DOWNLOAD=true
# Используем существующий lockfile. Если он не совпадает с workspace —
# падать сразу, не дрейфовать незаметно.
RUN pnpm install --frozen-lockfile
-12
View File
@@ -1,12 +0,0 @@
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
@@ -1,13 +0,0 @@
// @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
@@ -1,65 +0,0 @@
{
"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
@@ -1,194 +0,0 @@
/**
* 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)
},
}
}
@@ -1,90 +0,0 @@
/**
* Политика версий схемы 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
@@ -1,315 +0,0 @@
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',
}
}
@@ -1,7 +0,0 @@
/**
* Публичные RPC-ноды COOPOS — fallback для чтения ключей/manifest'ов,
* когда нода кооператива недоступна (используется начиная со Story 9.5).
*
* TODO(Story 9.5): наполнить актуальным списком публичных нод сети.
*/
export const COOPOS_PUBLIC_NODES: readonly string[] = []
@@ -1,13 +0,0 @@
/**
* 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
@@ -1,223 +0,0 @@
/**
* Зеркало 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
@@ -1,40 +0,0 @@
/**
* Экспорт удостоверения наружу (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
@@ -1,49 +0,0 @@
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
@@ -1,90 +0,0 @@
/**
* Миграция действующего пайщика «ключ → пароль» (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
@@ -1,91 +0,0 @@
/**
* Первый этап входа 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
@@ -1,160 +0,0 @@
/**
* 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
@@ -1,99 +0,0 @@
/**
* Второй этап входа 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
@@ -1,248 +0,0 @@
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
@@ -1,147 +0,0 @@
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
@@ -1,132 +0,0 @@
/**
* Подпись: документы (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
@@ -1,124 +0,0 @@
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
@@ -1,8 +0,0 @@
/**
* 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
@@ -1,17 +0,0 @@
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
@@ -1,15 +0,0 @@
/** Субъект, к которому привязан зашифрованный блоб (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
@@ -1,197 +0,0 @@
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
@@ -1,46 +0,0 @@
/**
* Локальная копия зашифрованного 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
@@ -1,77 +0,0 @@
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)
}
@@ -1,13 +0,0 @@
/**
* Подключаемый 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
@@ -1,55 +0,0 @@
/**
* 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
}
}
@@ -1,89 +0,0 @@
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
@@ -1,127 +0,0 @@
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
@@ -1,29 +0,0 @@
/**
* Интеграционный тест 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)
})
})
@@ -1,71 +0,0 @@
/**
* 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)
},
)
@@ -1,35 +0,0 @@
/**
* Лаунчер 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)
@@ -1,94 +0,0 @@
/**
* 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
@@ -1,49 +0,0 @@
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
@@ -1,48 +0,0 @@
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
@@ -1,62 +0,0 @@
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
@@ -1,43 +0,0 @@
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
@@ -1,62 +0,0 @@
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)
})
@@ -1,97 +0,0 @@
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')
})
})
@@ -1,94 +0,0 @@
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 не дошли
})
})
@@ -1,121 +0,0 @@
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
@@ -1,29 +0,0 @@
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
@@ -1,134 +0,0 @@
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
@@ -1,123 +0,0 @@
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)
})
@@ -1,68 +0,0 @@
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
@@ -1,178 +0,0 @@
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
@@ -1,94 +0,0 @@
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
@@ -1,47 +0,0 @@
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)
@@ -1,82 +0,0 @@
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
@@ -1,167 +0,0 @@
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
@@ -1,91 +0,0 @@
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
@@ -1,16 +0,0 @@
{
"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
@@ -1,19 +0,0 @@
/**
* Браузерный таргет 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.6.9-18",
"version": "2026.7.21-1",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+8 -1
View File
@@ -35,7 +35,14 @@ RUN npm install -g pnpm@10.33.0
COPY . .
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..."
# --ignore-scripts + точечный rebuild: --filter ограничивает только то, какие
# проекты получают node_modules, но НЕ то, для каких пакетов общего lockfile
# store выполняет lifecycle-скрипты при --frozen-lockfile. Без --ignore-scripts
# сюда тянутся electron/puppeteer (из @coopenomics/desktop/notifications,
# boot их не использует) — тяжёлые сетевые загрузки, изредка валящие билд по
# TLS-таймауту. rebuild --filter уже честно скопирован на подграф boot.
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..." --ignore-scripts \
&& pnpm rebuild --filter "@coopenomics/boot..."
# Билд графа: cooptypes/factory собирают свои dist/, boot собирает
# свой bundle с inlined workspace-deps.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.6.9-18",
"version": "2026.7.21-1",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
+1 -1
View File
@@ -76,7 +76,7 @@ export class CooperativeClass {
await this.blockchain.createProgram({
coopname,
username: coopname,
type: 'blagorost',
type: 'capital',
title: 'Целевая потребительская программа "Благорост"',
announce: '',
description: '',
@@ -1,6 +1,6 @@
/**
* Фаза 01 — программы УХД (id=3, type='generator') и Благорост
* (id=4, type='blagorost') + capital::setconfig.
* (id=4, type='capital') + capital::setconfig.
*
* Идемпотентно: программы создаются только если их ещё нет в `programs`-таблице,
* setconfig — только если `state` пуст.
@@ -88,7 +88,7 @@ export async function phase01(): Promise<void> {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'blagorost',
type: 'capital',
}
await blockchain.api.transact({
actions: [{
@@ -300,8 +300,8 @@ async function ensureAgreementsAndWallet(blockchain: Blockchain, fix: IFixture):
}
// Согласие с ЦПП «Благорост» — для инвестиций в программу/компонент.
// Блокировка идёт по своему пути; для безопасности дёргаем при первом запуске.
await signAgreement(blockchain, fix.username, 'blagorost').catch((e) => {
log(`signAgreement blagorost для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
await signAgreement(blockchain, fix.username, 'capital').catch((e) => {
log(`signAgreement capital для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
})
}
@@ -234,7 +234,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'blagorost',
type: 'capital',
}
const result = await blockchain.api.transact(
+1 -1
View File
@@ -213,7 +213,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'blagorost',
type: 'capital',
}
const result = await blockchain.api.transact(
+1 -1
View File
@@ -12,6 +12,6 @@ export const capitalDraftId = 1000
export const ratePerHour = '1000.0000 RUB'
export const sourceProgramName = 'generator'
export const capitalProgramName = 'blagorost'
export const capitalProgramName = 'capital'
// ledger2::accounts.id хранит счёт со смещением *1000 (80 → 80000).
export const circulationAccountId = 80_000
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.6.9-18",
"version": "2026.7.21-1",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
@@ -1,5 +1,7 @@
#pragma once
#include <eosio/binary_extension.hpp>
using namespace eosio;
using std::string;
@@ -39,11 +41,19 @@ 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; ///< Управляемая конфигурация контракта
// ВНИМАНИЕ: поля программных расходов добавлены ПОСЛЕ config и обёрнуты в
// binary_extension. Поля в таблицу EOSIO можно дописывать только в ХВОСТ
// struct и только через binary_extension — иначе ломается десериализация
// уже записанного global_state. Инцидент 2026-06-16: эти поля были вставлены
// перед config напрямую (без extension) и задеплоены на прод — таблица state
// перестала читаться (get table → "Invalid symbol", action'ы падали на unpack).
// Инвариант: оба поля материализуются вместе (всегда оба has_value или оба
// пустые), чтобы порядок хвостовых extension оставался консистентным.
eosio::binary_extension<asset> program_expense_pool; ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
eosio::binary_extension<asset> program_expense_reserved; ///< Сумма, зарезервированная под approved/authorized программные расходы
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
};
@@ -53,6 +63,16 @@ namespace Capital {
namespace State {
/**
* @brief Значение binary_extension<asset> или ноль с символом @p sym, если поле
* ещё не материализовано (старая запись, созданная до добавления
* program_expense_* через binary_extension).
*/
inline asset ext_or_zero(const eosio::binary_extension<asset>& v, const eosio::symbol& sym) {
return v.has_value() ? v.value() : asset(0, sym);
}
/**
* @brief Обновляет глобальное состояние новыми значениями.
*
@@ -103,8 +123,12 @@ inline void topup_program_expense_pool(eosio::name coopname, const asset &amount
"Недостаточно свободных инвестиций программы для пополнения пула расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
s.global_available_invest_pool -= amount;
s.program_expense_pool += amount;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
@@ -117,12 +141,16 @@ inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
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,
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
"Недостаточно средств в пуле программных расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_pool -= amount;
s.program_expense_reserved += amount;
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
pool -= amount;
reserved += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
@@ -135,12 +163,16 @@ inline void release_program_expense(eosio::name coopname, const asset &amount) {
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,
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved -= amount;
s.program_expense_pool += amount;
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
reserved -= amount;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
@@ -154,11 +186,15 @@ inline void consume_program_expense(eosio::name coopname, const asset &amount) {
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,
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved -= amount;
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
reserved -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
@@ -172,11 +208,15 @@ inline void spend_program_expense_pool(eosio::name coopname, const asset &amount
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,
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_pool -= amount;
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
pool -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
}// namespace State
+3 -2
View File
@@ -80,12 +80,13 @@ static constexpr eosio::name _wallet_program = "wallet"_n; ///< Главный
static constexpr eosio::name _marketplace_program = "marketplace"_n; ///< Кошелёк программы "Маркетплейс"
static constexpr eosio::name _source_program = "generator"_n; ///< Кошелёк для генерации по договору УХД
static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелёк программы "Благорост"
static constexpr eosio::name _capital_program = "capital"_n; ///< Кошелёк программы "Благорост"
static const std::set<eosio::name> soviet_actions = {
"joincoop"_n, //регистрация пайщика
"leavecoop"_n, //выход пайщика из кооператива (возврат паевого взноса)
//MEET
"creategm"_n,//предложение повестки планового общего собрание
"completegm"_n, //решение общего собрания пайщиков
@@ -61,6 +61,7 @@ namespace operations {
inline constexpr eosio::name SETTLE_MINSHARE = "o.reg.setmin"_n; ///< Зачисление минимального паевого по решению совета (Dr 76 / Cr 80, TRANSFER REGISTRATION_PENDING → MIN_SHARE_FUND).
inline constexpr eosio::name SETTLE_ENTRANCE = "o.reg.setent"_n; ///< Зачисление вступительного по решению совета (Dr 76 / Cr 86, TRANSFER REGISTRATION_PENDING → ENTRANCE_FEES).
inline constexpr eosio::name REFUND = "o.reg.refund"_n; ///< Возврат регистрационного взноса при отказе совета (Dr 76 / Cr 51, BURN REGISTRATION_PENDING — деньги уходят из системы банковским переводом кандидату).
inline constexpr eosio::name MOVE_MINSHARE = "o.reg.mvmin"_n; ///< Перенос минимального паевого на главный паевой при выходе из кооператива (TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY, без Dr/Cr — оба кошелька на счёте 80). Готовит полный паевой к возврату.
}
// wallet
@@ -242,6 +243,15 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::BANK_ACCOUNT,
"Возврат регистрационного взноса при отказе совета" },
// 2e. Перенос минимального паевого на главный при выходе из кооператива:
// TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY (без Dr/Cr — оба кошелька на счёте 80).
// Консолидирует минимальный паевой на главный, чтобы вернуть его вместе с
// основным паевым через wallet-withdraw (o.wal.wthcpl, Дт 80 / Кт 51).
{ operations::registrator::MOVE_MINSHARE, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
ledger2_wallets::MIN_SHARE_FUND, ledger2_wallets::SHARE_FUND_PAY,
0, 0,
"Перенос минимального паевого на главный при выходе из кооператива" },
// 3. Внесение паевого взноса: Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY
{ operations::wallet::COMPLETE_DEPOSIT, processes::wallet::DEPOSIT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::SHARE_FUND_PAY,
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
@@ -252,6 +252,37 @@ inline constexpr std::array<Ledger2WalletProgramMapping, 8> LEDGER2_USER_SHARED_
{ ledger2_wallets::REGISTRATION_PENDING, 0 /* w.reg.pend — кандидат ещё не член, соглашения нет, без проверки */ },
}};
/**
* @brief Сет «боевых» (паевых) кошельков пайщика, возвращаемых при выходе из
* кооператива (заявление registry 200 → одобрение совета `confirmexit`).
*
* Единый источник истины для суммы возврата. При одобрении выхода советом
* `registrator::confirmexit` обходит этот сет в цикле, аккумулирует доступный
* L3-баланс пайщика по каждому кошельку (>0), консолидирует на главный паевой
* (`w.wal.share`) и ставит всю сумму на возврат единым платежом. Тот же сет
* генерируется в cooptypes (`gen:from-cpp` → `wallets.generated.ts`) и
* используется backend-preview, поэтому расчёт на фронте всегда совпадает с
* тем, что реально вернёт контракт.
*
* Состав — только паевые/возвратные USER_SHARED-кошельки:
* w.reg.minshr — минимальный паевой взнос;
* w.wal.share — целевой паевой взнос (ЦК);
* w.cap.blago — паевой взнос в ЦПП «Благорост».
*
* НЕ входят: w.wal.member (членский — невозвратный), w.exp.adv (подотчёт под
* расход), w.cap.gen (Генератор — COOPERATIVE, без L3-разреза по пайщику),
* w.cap.preimp (пред-импорт-учёт РИД).
*
* Каждому не-главному кошельку сета должна соответствовать операция переноса
* на `w.wal.share` в `Registrator::consolidate_share_to_main` (exit_helpers.hpp)
* — иначе runtime упадёт с явным сообщением (защита от тихой потери средств).
*/
inline constexpr std::array<eosio::name, 3> LEDGER2_EXIT_REFUND_WALLETS = {{
ledger2_wallets::MIN_SHARE_FUND,
ledger2_wallets::SHARE_FUND_PAY,
ledger2_wallets::BLAGOROST_FUND,
}};
/**
* @brief Возвращает required program_id для USER_SHARED-кошелька.
*
@@ -17,6 +17,7 @@
#include "table_registrator_verification.hpp"
#include "table_registrator_candidates.hpp"
#include "table_registrator_candidates_legacy.hpp"
#include "table_registrator_exits.hpp"
// coops (soviet)
#include "coops_access_helpers.hpp"
@@ -0,0 +1,71 @@
#pragma once
#include <optional>
#include <eosio/asset.hpp>
#include <eosio/crypto.hpp>
#include <eosio/eosio.hpp>
#include "../consts.hpp"
#include "document_core.hpp"
namespace Registrator {
using namespace eosio;
/**
* @ingroup public_tables
* @ingroup public_registrator_tables
* @par table: exits (registrator)
*
* @brief Заявление пайщика на выход из кооператива и сопровождающий его
* процесс возврата паевого взноса.
*
* Жизненный цикл (зеркало вступления reguser→confirmreg и возврата
* wallet::createwthd→authwthd→completewthd):
* - exitcoop → запись со статусом `pending`, повестка совета `leavecoop`;
* - confirmexit → совет одобрил: статус `authorized`, консолидация
* минимального паевого на главный (o.reg.mvmin), резерв
* суммы возврата (o.wal.wthreq) и исходящий платёж в gateway;
* - completexit → кассир подтвердил выплату: проводка Дт80/Кт51
* (o.wal.wthcpl), пайщик удаляется, аккаунт блокируется,
* запись стирается;
* - declinexit → отказ совета или отклонение платежа: снятие резерва
* (o.wal.wthdec, если он был сделан) и стирание записи.
*
* `quantity` — итоговая сумма возврата, вычисляется контрактом в момент
* одобрения советом (минимальный + целевой паевой пайщика по L3-балансам
* ledger2), а не доверяется клиенту.
*/
struct [[eosio::table, eosio::contract(REGISTRATOR)]] exit {
name username;
name coopname;
name status; ///< pending | authorized
time_point_sec created_at;
document2 statement; ///< заявление о выходе (registry 200)
document2 approved_statement; ///< решение совета о выходе (авторизация)
checksum256 exit_hash;
asset quantity; ///< итоговая сумма возврата (заполняется на confirmexit)
uint64_t primary_key() const { return username.value; }
checksum256 by_hash() const { return exit_hash; }
};
typedef multi_index<
"exits"_n, exit,
indexed_by<"byhash"_n, const_mem_fun<exit, checksum256, &exit::by_hash>>>
exits_index;
inline std::optional<exit> get_exit_by_hash(name coopname, const checksum256 &hash) {
exits_index primary_index(_registrator, coopname.value);
auto secondary_index = primary_index.get_index<"byhash"_n>();
auto itr = secondary_index.find(hash);
if (itr == secondary_index.end()) {
return std::nullopt;
}
return *itr;
}
} // namespace Registrator
@@ -12,6 +12,12 @@
#include "src/user/declinerfnd.cpp"
#include "src/user/reguser.cpp"
#include "src/exit/exit_helpers.hpp"
#include "src/exit/exitcoop.cpp"
#include "src/exit/confirmexit.cpp"
#include "src/exit/completexit.cpp"
#include "src/exit/declinexit.cpp"
#include "src/account/createbranch.cpp"
#include "src/account/newaccount.cpp"
#include "src/account/updateaccnt.cpp"
@@ -85,6 +85,12 @@ public:
[[eosio::action]] void changekey(eosio::name coopname, eosio::name changer, eosio::name username, eosio::public_key public_key);
[[eosio::action]] void confirmreg(eosio::name coopname, checksum256 registration_hash, document2 authorization);
// Выход пайщика из кооператива (возврат паевого взноса) — src/exit/*.cpp
[[eosio::action]] void exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement);
[[eosio::action]] void confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization);
[[eosio::action]] void completexit(eosio::name coopname, checksum256 exit_hash);
[[eosio::action]] void declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason);
[[eosio::action]] void confirmpay(name coopname, checksum256 registration_hash);
[[eosio::action]] void declinepay(name coopname, checksum256 registration_hash, std::string reason);
[[eosio::action]] void declinereg(name coopname, checksum256 registration_hash, std::string reason);
@@ -0,0 +1,35 @@
/**
* @brief Завершение возврата паевого взноса при выходе из кооператива.
* Кассир подтвердил исходящий платёж — gateway вызывает этот коллбэк.
* Списываем зарезервированную сумму (o.wal.wthcpl: Дт80/Кт51, BURN с
* w.wal.wpend), удаляем пайщика из реестра совета и блокируем его аккаунт.
* Возврат назад невозможен — повторное участие только через новую регистрацию.
* @param coopname Наименование кооператива
* @param exit_hash Хэш процесса выхода
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p gateway
*/
void registrator::completexit(eosio::name coopname, checksum256 exit_hash) {
require_auth(_gateway);
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(exist.has_value(), "Объект выхода не найден");
Registrator::exits_index exits(_registrator, coopname.value);
auto e = exits.find(exist->username.value);
eosio::check(e->status == "authorized"_n, "Только одобренные заявления на выход могут быть завершены");
eosio::name username = e->username;
// Проводка возврата паевого: списываем резерв w.wal.wpend, Дт80/Кт51.
std::string memo = "Возврат паевого взноса при выходе из кооператива, username=" + username.to_string();
Ledger2::apply(_registrator, coopname, operations::wallet::COMPLETE_WITHDRAW,
e->quantity, username, exit_hash, memo);
// Финализируем выход: удаляем пайщика и блокируем аккаунт.
Registrator::finalize_member_exit(coopname, username);
exits.erase(e);
}
@@ -0,0 +1,76 @@
/**
* @brief Одобрение советом выхода пайщика из кооператива.
* Совет одобрил заявление о выходе. Контракт сам вычисляет сумму возврата:
* обходит сет паевых кошельков LEDGER2_EXIT_REFUND_WALLETS (w.reg.minshr +
* w.wal.share + w.cap.blago), собирает доступный L3-баланс каждого, консолидирует
* на главный паевой (w.wal.share), резервирует всю сумму (o.wal.wthreq) и создаёт
* исходящий платёж в gateway. Если возвращать нечего — выход завершается сразу
* без платежа.
* @param coopname Наименование кооператива
* @param exit_hash Хэш процесса выхода
* @param authorization Документ-решение совета о выходе
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p soviet
*/
void registrator::confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization) {
require_auth(_soviet);
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(exist.has_value(), "Объект выхода не найден");
Registrator::exits_index exits(_registrator, coopname.value);
auto e = exits.find(exist->username.value);
eosio::check(e->status == "pending"_n, "Только ожидающие заявления на выход могут быть одобрены");
eosio::name username = e->username;
// оповещаем пайщика
require_recipient(username);
// Контракт сам считает сумму возврата по L3-балансам ledger2 — не доверяем
// клиенту. Обходим сет паевых («боевых») кошельков LEDGER2_EXIT_REFUND_WALLETS
// (источник истины — wallets.hpp; он же генерируется в cooptypes для
// backend-preview, поэтому расчёт на фронте совпадает с этим): аккумулируем
// доступный баланс каждого (>0) и тут же консолидируем его на главный паевой
// (w.wal.share), чтобы единым платежом вернуть весь паевой через o.wal.*.
eosio::asset total_return = eosio::asset(0, _root_govern_symbol);
for (const auto &wallet_name : LEDGER2_EXIT_REFUND_WALLETS) {
eosio::asset balance = Registrator::get_user_wallet_available(coopname, wallet_name, username);
if (balance.amount <= 0) continue;
total_return += balance;
Registrator::consolidate_share_to_main(coopname, username, wallet_name, balance, exit_hash);
}
exits.modify(e, _soviet, [&](auto &row) {
row.status = "authorized"_n;
row.approved_statement = authorization;
row.quantity = total_return;
});
if (total_return.amount > 0) {
// Резервируем сумму возврата: w.wal.share → w.wal.wpend (o.wal.wthreq).
std::string memo_req = "Резерв паевого взноса под выход из кооператива, username=" + username.to_string();
Ledger2::apply(_registrator, coopname, operations::wallet::REQUEST_WITHDRAW,
total_return, username, exit_hash, memo_req);
// Создаём исходящий платёж в gateway с коллбэками completexit/declinexit.
Action::send<createoutpay_interface>(
_gateway,
"createoutpay"_n,
_registrator,
coopname,
username,
exit_hash,
total_return,
_registrator,
"completexit"_n,
"declinexit"_n
);
} else {
// Возвращать нечего — финализируем выход без платежа.
Registrator::finalize_member_exit(coopname, username);
exits.erase(e);
}
}
@@ -0,0 +1,41 @@
/**
* @brief Отказ в выходе из кооператива.
* Вызывается либо советом при отклонении заявления (до резервирования средств),
* либо gateway при отклонении исходящего платежа (после резервирования). Если
* сумма возврата уже была зарезервирована — снимаем резерв (o.wal.wthdec:
* w.wal.wpend → w.wal.share). Пайщик остаётся действующим членом кооператива.
* @param coopname Наименование кооператива
* @param exit_hash Хэш процесса выхода
* @param reason Причина отказа
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p soviet или @p gateway
*/
void registrator::declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason) {
check_auth_and_get_payer_or_fail({_soviet, _gateway});
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(exist.has_value(), "Объект выхода не найден");
Registrator::exits_index exits(_registrator, coopname.value);
auto e = exits.find(exist->username.value);
eosio::name username = e->username;
// Если резерв уже был сделан (отказ платежа после одобрения советом) —
// снимаем его, возвращая средства пайщику на главный паевой кошелёк.
// Минимальный паевой, ранее консолидированный на главный (o.reg.mvmin),
// остаётся на w.wal.share: пайщик сохраняет полную сумму паевого, аналитика
// минимального/целевого разреза при несостоявшемся выходе не восстанавливается.
if (e->status == "authorized"_n && e->quantity.amount > 0) {
std::string memo = "Снятие резерва паевого при отмене выхода из кооператива: " + reason;
Ledger2::apply(_registrator, coopname, operations::wallet::DECLINE_WITHDRAW,
e->quantity, username, exit_hash, memo);
}
// оповещаем пайщика
require_recipient(username);
exits.erase(e);
}
@@ -0,0 +1,95 @@
#pragma once
#include <eosio/asset.hpp>
#include <eosio/eosio.hpp>
#include "../../../lib/index.hpp"
/**
* @brief Вспомогательные функции процедуры выхода пайщика из кооператива.
*
* Вынесены в отдельный заголовок (а не в table_registrator_exits.hpp), т.к.
* опираются на ledger2-кошельки и userwallets, которые подключаются позже в
* domain/index.hpp. Подключается в registrator.cpp перед exit-экшенами, когда
* весь lib/index.hpp уже доступен.
*/
namespace Registrator {
using namespace eosio;
/**
* @brief Доступный L3-баланс пайщика на USER_SHARED-кошельке ledger2.
*
* Возвращает available (без blocked) пары `(wallet_name, username)` из таблицы
* userwallets контракта ledger2. Если записи нет — нулевая сумма в базовой
* валюте управления.
*/
inline asset get_user_wallet_available(name coopname, name wallet_name, name username) {
userwallets_index userwallets(_ledger2, coopname.value);
auto idx = userwallets.get_index<"byuserwallet"_n>();
auto it = idx.find(combine_ids(wallet_name.value, username.value));
if (it == idx.end()) {
return asset(0, _root_govern_symbol);
}
return it->available;
}
/**
* @brief Консолидация доступного паевого кошелька пайщика на главный
* (`w.wal.share`) перед резервом возврата при выходе.
*
* Главный кошелёк (`w.wal.share`) уже целевой — перенос не нужен. Для остальных
* кошельков сета `LEDGER2_EXIT_REFUND_WALLETS` применяется операция переноса на
* главный:
* w.reg.minshr → o.reg.mvmin (MOVE_MINSHARE);
* w.cap.blago → o.cap.wthcap (WITHDRAW_FROM_CAPITAL).
*
* Кошелёк сета без операции переноса (новый паевой кошелёк забыли смаппить)
* валит транзакцию с явным сообщением — защита от тихой потери средств.
*/
inline void consolidate_share_to_main(name coopname, name username, name wallet_name, asset amount, checksum256 exit_hash) {
if (wallet_name == ledger2_wallets::SHARE_FUND_PAY) return; // уже на главном паевом
eosio::name op;
if (wallet_name == ledger2_wallets::MIN_SHARE_FUND) {
op = operations::registrator::MOVE_MINSHARE; // w.reg.minshr → w.wal.share
} else if (wallet_name == ledger2_wallets::BLAGOROST_FUND) {
op = operations::capital::WITHDRAW_FROM_CAPITAL; // w.cap.blago → w.wal.share
} else {
eosio::check(false,
std::string{"Нет операции консолидации паевого кошелька "} + wallet_name.to_string() +
" на главный при выходе — добавьте маппинг в consolidate_share_to_main");
}
std::string memo = "Консолидация паевого взноса при выходе, кошелёк=" +
wallet_name.to_string() + ", username=" + username.to_string();
Ledger2::apply(_registrator, coopname, op, amount, username, exit_hash, memo);
}
/**
* @brief Финализация выхода: удаление пайщика из реестра совета и блокировка
* аккаунта в registrator.
*
* Вызывается по завершении возврата паевого взноса (completexit) либо сразу,
* если возвращать нечего (нулевой паевой). После этого `get_participant_or_fail`
* для пайщика начинает падать — он лишён права подавать заявления.
*/
inline void finalize_member_exit(name coopname, name username) {
// удаляем пайщика из реестра совета (уменьшит счётчик активных пайщиков)
action(
permission_level{_registrator, "active"_n},
_soviet,
"delpartcpnt"_n,
std::make_tuple(coopname, username)
).send();
// блокируем аккаунт в картотеке registrator
accounts_index accounts(_registrator, _registrator.value);
auto account = accounts.find(username.value);
eosio::check(account != accounts.end(), "Аккаунт не найден");
accounts.modify(account, _registrator, [&](auto &a) {
a.status = "blocked"_n;
});
}
} // namespace Registrator
@@ -0,0 +1,57 @@
/**
* @brief Подача заявления на выход из кооператива.
* Действующий пайщик подаёт подписанное заявление о выходе (registry 200).
* Создаётся объект выхода и повестка совета `leavecoop` на рассмотрение.
* Возврат паевого взноса произойдёт после одобрения советом (confirmexit).
* @param coopname Наименование кооператива
* @param username Имя пайщика, выходящего из кооператива
* @param exit_hash Хэш процесса выхода
* @param statement Подписанное заявление о выходе (registry 200)
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
void registrator::exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement) {
require_auth(coopname);
get_cooperative_or_fail(coopname);
// выйти может только действующий пайщик (не заблокированный)
get_participant_or_fail(coopname, username);
// повторная подача запрещена — у пайщика может быть только один процесс выхода
Registrator::exits_index exits(_registrator, coopname.value);
auto existing = exits.find(username.value);
eosio::check(existing == exits.end(), "Заявление на выход уже подано");
auto by_hash = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(!by_hash.has_value(), "Объект выхода уже существует с указанным хэшем");
// проверяем подпись заявления о выходе
verify_document_or_fail(statement);
exits.emplace(coopname, [&](auto &e) {
e.username = username;
e.coopname = coopname;
e.status = "pending"_n;
e.created_at = current_time_point();
e.statement = statement;
e.exit_hash = exit_hash;
e.quantity = asset(0, _root_govern_symbol);
});
// повестка совета на рассмотрение выхода с коллбэками confirmexit/declinexit
::Soviet::create_agenda(
_registrator,
coopname,
username,
get_valid_soviet_action("leavecoop"_n),
exit_hash,
_registrator, // callback_contract
"confirmexit"_n, // callback при одобрении
"declinexit"_n, // callback при отказе
statement,
std::string("")
);
}
@@ -49,6 +49,7 @@
#include "src/fund/fundwithdraw.cpp"
#include "src/participant/addparticipant.cpp"
#include "src/participant/delparticipant.cpp"
#include "src/participant/setminamt.cpp"
#include "src/participant/block.cpp"
#include "src/participant/cancelreg.cpp"
@@ -139,6 +139,9 @@ public:
//regaccount.cpp
[[eosio::action]] void addpartcpnt(eosio::name coopname, eosio::name username, eosio::name braname, eosio::name type, eosio::time_point_sec created_at, eosio::asset initial, eosio::asset minimum, bool spread_initial);
//delparticipant.cpp
[[eosio::action]] void delpartcpnt(eosio::name coopname, eosio::name username);
//setminamt.cpp
[[eosio::action]] void setminamt(eosio::name coopname, eosio::name username, eosio::asset minimum);
@@ -0,0 +1,38 @@
/**
* @brief Удаление пайщика из кооператива при выходе
* Стирает запись пайщика из реестра совета по завершении процедуры выхода
* (после возврата паевого взноса). Зеркало `addpartcpnt`. Если выбывающий
* пайщик был активным и имел право голоса — уменьшает счётчик активных
* пайщиков в registrator (как это делает `block`).
* @param coopname Наименование кооператива
* @param username Наименование выбывающего пайщика
* @ingroup public_actions
* @ingroup public_soviet_actions
* @note Авторизация требуется от аккаунта в белом списке контрактов
*/
void soviet::delpartcpnt(eosio::name coopname, eosio::name username) {
check_auth_and_get_payer_or_fail(contracts_whitelist);
get_cooperative_or_fail(coopname);
participants_index participants(_soviet, coopname.value);
auto participant = participants.find(username.value);
eosio::check(participant != participants.end(), "Пайщик не найден в кооперативе");
// Если пайщик был активным и имел право голоса — уменьшаем счётчик активных
// пайщиков (счётчик живёт в registrator, см. block.cpp / decparticpnt.cpp).
bool had_vote = participant->has_vote;
bool was_active = participant->status == "accepted"_n;
if (was_active && had_vote) {
action(
permission_level{_soviet, "active"_n},
_registrator,
"decparticpnt"_n,
std::make_tuple(coopname, username)
).send();
}
participants.erase(participant);
}
@@ -12,14 +12,22 @@
void soviet::migrate() {
require_auth(_soviet); // Проверяем авторизацию
// Миграция program_type для кооператива voskhod, программа id=4 -> blagorost
eosio::name coopname = "voskhod"_n;
programs_index programs(_soviet, coopname.value);
auto program_it = programs.find(4);
if (program_it != programs.end() && program_it->program_type != "blagorost"_n) {
programs.modify(program_it, _soviet, [&](auto &pr) {
pr.program_type = "blagorost"_n;
});
// Откат program_type для кооператива voskhod, программа id=4: blagorost -> capital.
// Предыдущая версия этой миграции переименовывала programs.program_type в
// 'blagorost', но coagreements.type для той же программы (program_id=4) всегда
// оставался 'capital' и никогда не переименовывался — таблицы разъехались.
// 'capital' — каноничное значение (используется coagreements.type, contract-
// константой _capital_program и всем controller-кодом); эта миграция приводит
// programs.program_type к нему же. Идемпотентно.
{
eosio::name coopname = "voskhod"_n;
programs_index programs(_soviet, coopname.value);
auto program_it = programs.find(4);
if (program_it != programs.end() && program_it->program_type != "capital"_n) {
programs.modify(program_it, _soviet, [&](auto &pr) {
pr.program_type = "capital"_n;
});
}
}
// ────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.6.9-18",
"version": "2026.7.21-1",
"private": true,
"type": "module",
"scripts": {
+3 -117
View File
@@ -5,13 +5,8 @@
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": [
"@typescript-eslint"
],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2018,
@@ -33,114 +28,5 @@
"consistent-return": "off",
"jest/expect-expect": "off",
"security/detect-object-injection": "off"
},
"overrides": [
{
"files": [
"src/application/**/*.ts",
"src/domain/**/*.ts"
],
"excludedFiles": [
"src/**/*.spec.ts",
"src/application/agreement/use-cases/agreement.interactor.ts",
"src/application/decision/use-cases/decision.interactor.ts",
"src/application/cooplace/interactors/cooplace.interactor.ts",
"src/application/participant/interactors/participant.interactor.ts",
"src/application/system/interactors/load-contacts.interactor.ts",
"src/application/system/dto/blockchain-info.dto.ts",
"src/application/system/interactors/wif.interactor.ts",
"src/domain/process-registry/services/process-registry.service.ts",
"src/domain/branch/interfaces/branch-blockchain.port.ts",
"src/domain/common/ports/soviet-blockchain.port.ts",
"src/domain/common/ports/blockchain.port.ts",
"src/domain/blockchain/types/transaction-result.type.ts",
"src/domain/cooplace/interfaces/cooplace-blockchain.port.ts",
"src/domain/auth/services/auth-domain.service.ts"
],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"@wharfkit/*",
"oidc-client-ts",
"ioredis"
],
"message": "Гексагональный инвариант auth-v2: application/ и domain/ не импортируют wharfkit/oidc-client-ts/ioredis напрямую — только через порты (infrastructure-адаптеры). excludedFiles — легаси-долг до Эпика 7."
}
]
}
]
}
},
{
"files": [
"src/application/auth-v2/**/*.ts"
],
"excludedFiles": [
"src/**/*.spec.ts"
],
"rules": {
"no-restricted-syntax": [
"error",
{
"selector": "ImportSpecifier[imported.name='AuthRoles']",
"message": "no-authroles-in-authv2: в auth-v2 запрещён роле-ориентированный @AuthRoles — используйте capability-ориентированный @CheckAbility + AuthorizationGuard (Эпик 6). Legacy auth/ сохраняет @AuthRoles до Phase-3 cleanup."
},
{
"selector": "Decorator[expression.callee.name='AuthRoles']",
"message": "no-authroles-in-authv2: в auth-v2 запрещён роле-ориентированный @AuthRoles — используйте capability-ориентированный @CheckAbility + AuthorizationGuard (Эпик 6). Legacy auth/ сохраняет @AuthRoles до Phase-3 cleanup."
},
{
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
},
{
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
},
{
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
},
{
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
}
]
}
},
{
"files": [
"src/**/*.ts"
],
"excludedFiles": [
"src/**/*.spec.ts",
"src/application/auth-v2/**/*.ts"
],
"rules": {
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
},
{
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
},
{
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
},
{
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
}
]
}
}
]
}
}
@@ -0,0 +1,126 @@
import { createHash } from 'node:crypto';
import type { DataSource } from 'typeorm';
import mongoose from 'mongoose';
import config from '~/config/config';
type MigrationLogger = {
info: (message: string) => void;
error: (message: string) => void;
warn: (message: string) => void;
};
const CAPITAL_DOC_REGISTRY_ID = 994;
const TARGET_COOPNAME = 'voskhod';
const COLLECTION_NAME = 'doc_private_data';
const capitalProgramPrivateData = {
generator_program_purpose:
"развитию информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации., на основе международных кооперативных принципов и законодательства Российской Федерации",
eoap_definition:
"информационная экосистема, интегрируемая в социально-экономическую среду Российской Федерации, состоящая из комплекса программных продуктов на базе технологии распределенного реестра, обеспечивающих широкое экономическое и социальное взаимодействие физических и юридических лиц, включая нерезидентов различных юрисдикций и организационно-правовых форм, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских кооперативов (обществ) под названием “Кооперативная Экономика”",
generator_task_goal:
"центра привлечения и интеграции передовых инновационных цифровых разработок, а также экономических и социальных методов и решений",
idea_unit_cost:
"50",
idea_unit_cost_words:
"пятьдесят",
blagorost_goal_expansion:
"развитии информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации",
blagorost_goal_reason:
"вследствие увеличения количества Участников информационной кооперативной экосистемы - ЕОАП - для расширения и повышения социальной эффективности их экономического взаимодействия в некоммерческом формате",
blagorost_task_expansion:
"расширение участников ЕОАП - информационной кооперативной экосистемы как центра экономического взаимодействия в некоммерческом формате",
blagorost_task_development:
"развитие ЕОАП как центра привлечения и интеграции инновационных цифровых разработок, а также экономических и социальных методов и решений",
return_source_description:
"аппаратно-программная сеть узлов распределенного реестра в формате «СМЭВ+SWIFT», построенная на принципах самоорганизации и самофинансирования деятельности технологической инфраструктуры ЕОАП, обеспечивающей консенсус ее распределенных узлов по формированию базового продукта ЕОАП - полного цикла документооборота по синхронному взаимодействию пайщиков и кооперативов - участников экосистемы ЕАОП - через использование цифровых контрактов, с одновременным выполнением функций нотариата и учета финансовых и юридических событий (подробнее на Сайте)",
return_additional_source:
"взносы пользователей ЕОАП, его отдельных программных продуктов и приложений, переданных Обществу или создаваемых в рамках Общества, которые интегрируются в ЕОАП"
};
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
const entries = Object.keys(value as Record<string, unknown>)
.sort()
.map((key) => {
const v = (value as Record<string, unknown>)[key];
return `${JSON.stringify(key)}:${stableStringify(v)}`;
});
return `{${entries.join(',')}}`;
}
function calculateSha256(value: unknown): string {
return createHash('sha256').update(Buffer.from(stableStringify(value), 'utf8')).digest('hex').toUpperCase();
}
export default {
name: 'Backfill capital_program_doc_data_hash for voskhod',
validUntil: new Date('2027-05-01T00:00:00Z'),
async up({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
if (config.coopname !== TARGET_COOPNAME) {
logger.info(`COOPNAME=${config.coopname}: миграция предназначена только для ${TARGET_COOPNAME}, пропускаю.`);
return true;
}
const hash = calculateSha256(capitalProgramPrivateData);
let mongo: mongoose.Connection | null = null;
try {
mongo = await mongoose.createConnection(config.mongoose.url).asPromise();
const db = mongo.db;
if (!db) {
throw new Error('Подключение к MongoDB установлено, но db не инициализирована');
}
const collection = db.collection(COLLECTION_NAME);
await collection.createIndex({ hash: 1 }, { unique: true });
await collection.updateOne(
{ hash },
{
$setOnInsert: {
hash,
registry_id: CAPITAL_DOC_REGISTRY_ID,
payload: capitalProgramPrivateData,
_created_at: new Date(),
},
},
{ upsert: true }
);
const result = await dataSource.query(
`UPDATE extensions
SET config = jsonb_set(COALESCE(config, '{}'::jsonb), '{capital_program_doc_data_hash}', to_jsonb($1::text), true),
updated_at = NOW()
WHERE name = 'capital'`,
[hash]
);
logger.info(`capital_program_doc_data_hash=${hash} сохранён для ${TARGET_COOPNAME}; extensions updated=${result?.[1] ?? 'unknown'}.`);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`Ошибка миграции capital_program_doc_data_hash для ${TARGET_COOPNAME}: ${message}`);
return false;
} finally {
await mongo?.close();
}
},
async down({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
if (config.coopname !== TARGET_COOPNAME) {
return true;
}
await dataSource.query(
`UPDATE extensions
SET config = COALESCE(config, '{}'::jsonb) - 'capital_program_doc_data_hash',
updated_at = NOW()
WHERE name = 'capital'`
);
logger.warn('capital_program_doc_data_hash удалён из config capital; запись doc_private_data оставлена как идемпотентный audit trail.');
return true;
},
};
@@ -1,166 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 1.4: начальные схемы coop_domain_db (отдельная БД в coop-postgres,
* Story 1.1). Миграция открывает СОБСТВЕННОЕ подключение из config.coopDomainDb —
* главный dataSource (voskhod) не используется и не передаётся дальше.
*
* Таблицы:
* - vaults — зашифрованные блобы (subject_type-discriminated);
* - audit_events — append-only журнал: месячные партиции + DEFAULT,
* триггер запрещает UPDATE/DELETE даже владельцу;
* - chain_manifests_cache — TTL-aware кеш authority из COOPOS;
* - legacy_user_mapping — UUID ↔ Mongo ObjectID на миграционный период;
* - access_rules — декларативная матрица CASL Layer 2 (семантика — Story 6.2).
*/
export default {
name: 'coopid: init coop_domain_db (vaults, audit_events, chain cache, mapping, access_rules)',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
await db.query(`
CREATE TABLE IF NOT EXISTS vaults (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
subject_type text NOT NULL CHECK (subject_type IN ('participant', 'coop', 'council_action')),
subject_id text NOT NULL,
cipher_version smallint NOT NULL DEFAULT 1,
kdf_version smallint NOT NULL DEFAULT 1,
salt bytea NOT NULL,
nonce bytea NOT NULL,
ciphertext bytea NOT NULL,
auth_tag bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT vaults_subject_uq UNIQUE (subject_type, subject_id)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS audit_events (
id bigint GENERATED ALWAYS AS IDENTITY,
event text NOT NULL,
subject_id text,
actor text,
result text NOT NULL CHECK (result IN ('success', 'failure', 'degraded')),
context jsonb NOT NULL DEFAULT '{}'::jsonb,
ip inet,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
`);
// Партиции: текущий месяц, следующий и DEFAULT-страховка (создание
// последующих помесячных — забота operational runbook, Stories 9.8/9.10).
await db.query(`
DO $$
DECLARE
m0 date := date_trunc('month', now())::date;
m1 date := (date_trunc('month', now()) + interval '1 month')::date;
m2 date := (date_trunc('month', now()) + interval '2 month')::date;
BEGIN
EXECUTE format(
'CREATE TABLE IF NOT EXISTS audit_events_%s PARTITION OF audit_events FOR VALUES FROM (%L) TO (%L)',
to_char(m0, 'YYYY_MM'), m0, m1);
EXECUTE format(
'CREATE TABLE IF NOT EXISTS audit_events_%s PARTITION OF audit_events FOR VALUES FROM (%L) TO (%L)',
to_char(m1, 'YYYY_MM'), m1, m2);
EXECUTE 'CREATE TABLE IF NOT EXISTS audit_events_default PARTITION OF audit_events DEFAULT';
END $$;
`);
// Append-only: триггер держит инвариант даже для владельца таблицы
// (GRANT/REVOKE ниже — декларация, заработает буквально при отдельной
// migration-роли из prod-плейбука).
await db.query(`
CREATE OR REPLACE FUNCTION audit_events_append_only() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_events is append-only: % is forbidden', TG_OP;
END;
$$ LANGUAGE plpgsql;
`);
await db.query(`
DROP TRIGGER IF EXISTS audit_events_no_update_delete ON audit_events;
CREATE TRIGGER audit_events_no_update_delete
BEFORE UPDATE OR DELETE ON audit_events
FOR EACH ROW EXECUTE FUNCTION audit_events_append_only();
`);
// ROW-триггер не ловит TRUNCATE — закрываем statement-триггером.
await db.query(`
DROP TRIGGER IF EXISTS audit_events_no_truncate ON audit_events;
CREATE TRIGGER audit_events_no_truncate
BEFORE TRUNCATE ON audit_events
FOR EACH STATEMENT EXECUTE FUNCTION audit_events_append_only();
`);
// username — идентификатор в DDL, биндинг невозможен: валидируем строго.
const role = config.coopDomainDb.username;
if (!/^[a-z_][a-z0-9_]*$/.test(role))
throw new Error(`Недопустимое имя роли COOP_DOMAIN_DB_USERNAME: ${role}`);
await db.query(`
REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM ${role};
GRANT INSERT, SELECT ON audit_events TO ${role};
`);
await db.query(`
CREATE TABLE IF NOT EXISTS chain_manifests_cache (
account text PRIMARY KEY,
manifest jsonb NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS legacy_user_mapping (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
mongo_object_id text NOT NULL UNIQUE,
username text NOT NULL UNIQUE,
auth_version text NOT NULL DEFAULT 'legacy' CHECK (auth_version IN ('legacy', 'oidc', 'dual')),
created_at timestamptz NOT NULL DEFAULT now()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS access_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
role text NOT NULL,
action text NOT NULL,
subject text NOT NULL,
conditions jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT access_rules_uq UNIQUE (role, action, subject)
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events (subject_id, created_at);`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_events_event ON audit_events (event, created_at);`);
logger.info('coop_domain_db: vaults, audit_events(+партиции, append-only), chain_manifests_cache, legacy_user_mapping, access_rules — готовы');
return true;
} catch (e) {
logger.error(`coop_domain_db init failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.0 не реализован намеренно: audit_events append-only, удаление схем — только вручную.');
return false;
},
};
@@ -1,53 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 4.7: manual revoke (compromised-key MVP). Таблица `revoked_keys` в
* coop_domain_db фиксирует ручной отзыв ключа пайщика председателем: durable
* pending-state (AC «или с переходом на pending state») — пока `recovered_at IS NULL`,
* ключ пайщика считается отозванным и пайщик обязан пройти recovery (Эпик 3) для
* получения нового. `reason`/`revoked_by` — для audit-трейла KeyRevokedManually.
*/
export default {
name: 'coopid: revoked_keys',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
await db.query(`
CREATE TABLE IF NOT EXISTS revoked_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
target_id text NOT NULL,
reason text NOT NULL,
revoked_by text NOT NULL,
revoked_at timestamptz NOT NULL DEFAULT now(),
recovered_at timestamptz
);
`);
// Активный отзыв (ожидает recovery) ищется по target_id + recovered_at IS NULL.
await db.query(`CREATE INDEX IF NOT EXISTS idx_revoked_keys_target_active ON revoked_keys (target_id, recovered_at);`);
logger.info('revoked_keys создана');
return true;
} catch (e) {
logger.error(`revoked_keys migration failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.10 не реализован.');
return false;
},
};
@@ -1,101 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 8.1: audit_events schema (append-only + помесячные партиции + триггеры +
* GRANT) уже создан в V2.4.0. Эта миграция закрывает остаток AC, отложенный тогда:
* - колонка `user_agent` (форензика, наполняет 8.2);
* - выделенная read-only роль `coop_audit_reader` (AC: INSERT приложению, SELECT читателю
* аудита) — в V2.4.0 разделение ролей было отложено в prod-плейбук;
* - форвард-роллинг партиций на текущий+2 месяца (идемпотентно);
* - re-assert append-only грантов приложению.
* Имена колонок event/context/created_at сохранены намеренно (боевая партиционированная
* append-only таблица, ~30 точек записи) — семантически = AC action/metadata/timestamp.
*/
export default {
name: 'coopid: audit_events user_agent + coop_audit_reader role',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
// 1. user_agent — AC явно перечисляет в структуре; nullable (backfill невозможен).
await db.query(`ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS user_agent text;`);
// 2. coop_audit_reader (NOLOGIN group-role) — SELECT-only для читателя аудита.
// Создание под insufficient_privilege-страховкой: в рестриктед prod без CREATEROLE
// роль заведёт плейбук, миграция не падает (как и декларация грантов в V2.4.0).
await db.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coop_audit_reader') THEN
BEGIN
CREATE ROLE coop_audit_reader NOLOGIN;
EXCEPTION WHEN insufficient_privilege THEN
RAISE NOTICE 'coop_audit_reader не создан: нет CREATEROLE; роль заведёт prod-playbook';
END;
END IF;
END $$;
`);
await db.query(`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coop_audit_reader') THEN
EXECUTE 'GRANT SELECT ON audit_events TO coop_audit_reader';
END IF;
END $$;
`);
// 3. Форвард-роллинг партиций: текущий + следующие 2 месяца (идемпотентно).
await db.query(`
DO $$
DECLARE
m date;
m_next date;
i int;
BEGIN
FOR i IN 0..2 LOOP
m := (date_trunc('month', now()) + (i || ' month')::interval)::date;
m_next := (date_trunc('month', now()) + ((i + 1) || ' month')::interval)::date;
EXECUTE format(
'CREATE TABLE IF NOT EXISTS audit_events_%s PARTITION OF audit_events FOR VALUES FROM (%L) TO (%L)',
to_char(m, 'YYYY_MM'), m, m_next);
END LOOP;
END $$;
`);
// 4. Re-assert append-only грантов приложению (идемпотентно; имя роли валидируем —
// идентификатор в DDL, биндинг невозможен).
const role = config.coopDomainDb.username;
if (!/^[a-z_][a-z0-9_]*$/.test(role))
throw new Error(`Недопустимое имя роли COOP_DOMAIN_DB_USERNAME: ${role}`);
await db.query(`
REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM ${role};
GRANT INSERT, SELECT ON audit_events TO ${role};
`);
logger.info('audit_events: user_agent добавлен, coop_audit_reader + партиции + гранты выровнены');
return true;
} catch (e) {
logger.error(`audit_events schema migration failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.11 не реализован: audit_events append-only, дроп колонки/роли — только вручную.');
return false;
},
};
@@ -1,100 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 6.11: назначаемые наборы возможностей (расширяемые роли). Председатель
* назначает пайщику именованный НАБОР («бухгалтер»/«кассир»); правила набора живут в
* `access_rules` (subject_type='capability_set', Story 6.2) — движок CASL переиспользуется.
*
* - `capability_sets` — реестр шаблонов (платформенные seed + кооп-кастом).
* - `participant_capability_sets` — назначение пайщик→набор (UNIQUE username+set_key,
* revoked_at для отзыва, expires_at для TTL).
*
* Seed: два платформенных набора (accountant/cashier) + их правила доступа к столам.
* Имена subject доступа (`AccountingDesk`/`PaymentRegistry`) — grant-строки для desktop
* meta.requires; уточняются при разводке desktop-gating.
*/
export default {
name: 'coopid: capability_sets',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
await db.query(`
CREATE TABLE IF NOT EXISTS capability_sets (
set_key text PRIMARY KEY,
title text NOT NULL,
description text NOT NULL DEFAULT '',
builtin boolean NOT NULL DEFAULT false,
coopname text,
created_at timestamptz NOT NULL DEFAULT now()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS participant_capability_sets (
id bigserial PRIMARY KEY,
username text NOT NULL,
set_key text NOT NULL,
granted_by text NOT NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz,
revoked_at timestamptz,
CONSTRAINT uq_participant_capability_set UNIQUE (username, set_key)
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_participant_capability_sets_username ON participant_capability_sets (username);`);
// --- Seed платформенных наборов (идемпотентно) ---
await db.query(`
INSERT INTO capability_sets (set_key, title, description, builtin, coopname) VALUES
('accountant', 'Бухгалтер', 'Доступ к столу бухгалтера (чтение реестра и связанных данных кооператива).', true, NULL),
('cashier', 'Кассир', 'Управление реестром платежей: подтверждение входящих и исходящих платежей.', true, NULL)
ON CONFLICT (set_key) DO NOTHING;
`);
// --- Seed правил наборов в access_rules (идемпотентно через NOT EXISTS) ---
const seedRules: Array<{ set: string; action: string; resource: string }> = [
{ set: 'accountant', action: 'read', resource: 'AccountingDesk' },
{ set: 'cashier', action: 'read', resource: 'PaymentRegistry' },
{ set: 'cashier', action: 'confirm', resource: 'PaymentRegistry' },
];
for (const r of seedRules) {
await db.query(
`INSERT INTO access_rules (subject_type, subject_id, effect, action, resource_type, conditions, expires_at, updated_at)
SELECT 'capability_set', $1, 'allow', $2, $3, NULL, NULL, now()
WHERE NOT EXISTS (
SELECT 1 FROM access_rules
WHERE subject_type = 'capability_set' AND subject_id = $1
AND action = $2 AND resource_type = $3
)`,
[r.set, r.action, r.resource],
);
}
logger.info('capability_sets + participant_capability_sets созданы, seed accountant/cashier применён');
return true;
} catch (e) {
logger.error(`capability_sets migration failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.12 не реализован.');
return false;
},
};
@@ -1,44 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 2.1 (фикс схемы 1.4): cipher_version/kdf_version в `vaults` —
* текстовые версии ('aes-256-gcm-v1' / 'argon2id-v1'), а не smallint.
* V2.4.0 объявила их smallint по недосмотру; правим отдельной миграцией,
* т.к. применённые миграции иммутабельны. Таблица на этом этапе пуста.
*/
export default {
name: 'coopid: vaults.cipher_version/kdf_version → text',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
await db.query(`ALTER TABLE vaults ALTER COLUMN cipher_version TYPE text USING cipher_version::text`);
await db.query(`ALTER TABLE vaults ALTER COLUMN kdf_version TYPE text USING kdf_version::text`);
await db.query(`ALTER TABLE vaults ALTER COLUMN cipher_version SET DEFAULT 'aes-256-gcm-v1'`);
await db.query(`ALTER TABLE vaults ALTER COLUMN kdf_version SET DEFAULT 'argon2id-v1'`);
logger.info('vaults: cipher_version/kdf_version → text');
return true;
} catch (e) {
logger.error(`vault version columns alter failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.1 не реализован.');
return false;
},
};
@@ -1,50 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 3.6: второй фактор (TOTP / Google Authenticator). Таблица
* `two_factor` в coop_domain_db — один секрет на пайщика (subject_id = user.id).
* `secret_enc` — Base32-секрет, зашифрованный server-key (aes.ts); сервер обязан
* его читать, чтобы проверять коды (это НЕ ключ пайщика — инвариант vault цел).
* `enabled=false` — секрет выпущен, но enrollment ещё не подтверждён первым кодом.
*/
export default {
name: 'coopid: two_factor (TOTP secrets)',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
await db.query(`
CREATE TABLE IF NOT EXISTS two_factor (
subject_id text PRIMARY KEY,
secret_enc text NOT NULL,
enabled boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
confirmed_at timestamptz
);
`);
logger.info('two_factor создана');
return true;
} catch (e) {
logger.error(`two_factor migration failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.2 не реализован.');
return false;
},
};
@@ -1,48 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 3.4: альтернативный recovery по offline-коду. Таблица
* `offline_recovery_code` в coop_domain_db — один код на пайщика (subject_id =
* user.id). `code_hash` — keyed HMAC-SHA256(server_secret, code), детерминирован
* для lookup по точному хешу; сырой код не хранится. Single-use: строка удаляется
* после выдачи recovery-токена (см. OfflineRecoveryService).
*/
export default {
name: 'coopid: offline_recovery_code',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
await db.query(`
CREATE TABLE IF NOT EXISTS offline_recovery_code (
subject_id text PRIMARY KEY,
code_hash text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
`);
logger.info('offline_recovery_code создана');
return true;
} catch (e) {
logger.error(`offline_recovery_code migration failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.3 не реализован.');
return false;
},
};
@@ -1,47 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 3.5: стратегия восстановления пайщика. Таблица `recovery_strategy`
* в coop_domain_db — одна запись на пайщика (subject_id = user.id). `strategy` —
* один из email_magic_link|offline_code|council; отсутствие записи = дефолт
* email_magic_link (обратная совместимость с 3.1).
*/
export default {
name: 'coopid: recovery_strategy',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
await db.query(`
CREATE TABLE IF NOT EXISTS recovery_strategy (
subject_id text PRIMARY KEY,
strategy text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
`);
logger.info('recovery_strategy создана');
return true;
} catch (e) {
logger.error(`recovery_strategy migration failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.4 не реализован.');
return false;
},
};
@@ -1,47 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 4.2: per-coop правила применения типов верификации. Таблица
* `verification_rules` в coop_domain_db — одна запись на действие (action_code =
* открытый идентификатор, PRIMARY KEY). `required_types` — обязательные типы
* верификации (`text[]`). Отсутствие записи = действие без ограничений по верификации.
*/
export default {
name: 'coopid: verification_rules',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
await db.query(`
CREATE TABLE IF NOT EXISTS verification_rules (
action_code text PRIMARY KEY,
required_types text[] NOT NULL DEFAULT '{}',
updated_at timestamptz NOT NULL DEFAULT now()
);
`);
logger.info('verification_rules создана');
return true;
} catch (e) {
logger.error(`verification_rules migration failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.5 не реализован.');
return false;
},
};
@@ -1,49 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 4.6: настройки кооператива в coop_domain_db (таблица `coop_settings`).
* Singleton-строка (`id = 1`) — coop_domain_db обслуживает один кооператив, поэтому
* глобальные настройки = одна запись; новые настройки добавляются колонками.
* `cert_ttl_seconds` — срок жизни participant_certificate (default 3600 = 1ч);
* короткий TTL ограничивает окно атаки при компрометации ключа (Story 4.6 AC).
*/
export default {
name: 'coopid: coop_settings',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
await db.query(`
CREATE TABLE IF NOT EXISTS coop_settings (
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
cert_ttl_seconds integer NOT NULL DEFAULT 3600,
updated_at timestamptz NOT NULL DEFAULT now()
);
`);
await db.query(`INSERT INTO coop_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;`);
logger.info('coop_settings создана');
return true;
} catch (e) {
logger.error(`coop_settings migration failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.6 не реализован.');
return false;
},
};
@@ -1,74 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 6.2: CASL Layer 2 — декларативные точечные права. Таблица
* `access_rules` в coop_domain_db: правило навешивается на роль (`subject_type='role'`,
* `subject_id` = имя core-роли) либо на конкретного пайщика (`subject_type='participant'`,
* `subject_id` = username). `effect` allow/deny, `action`/`resource_type` — CASL-словарь,
* `conditions` — CASL-условия (jsonb). `expires_at` — TTL точечных capabilities (Story 6.7).
*/
export default {
name: 'coopid: access_rules',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
// V2.4.0 init создал плейсхолдер access_rules со старой схемой (role/action/subject)
// без колонки subject_type. На такой БД `CREATE TABLE IF NOT EXISTS` ниже = no-op,
// а индекс по subject_type упал бы. Сносим пустой плейсхолдер (Layer 2 ещё ниоткуда
// не пишется → DROP безопасен). Идемпотентно: при правильной схеме DROP не выполняется.
await db.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'access_rules'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'access_rules' AND column_name = 'subject_type'
) THEN
DROP TABLE access_rules;
RAISE NOTICE 'access_rules: снесён плейсхолдер V2.4.0';
END IF;
END $$;
`);
await db.query(`
CREATE TABLE IF NOT EXISTS access_rules (
id bigserial PRIMARY KEY,
subject_type text NOT NULL,
subject_id text NOT NULL,
effect text NOT NULL DEFAULT 'allow',
action text NOT NULL,
resource_type text NOT NULL,
conditions jsonb,
expires_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_access_rules_principal ON access_rules (subject_type, subject_id);`);
logger.info('access_rules создана');
return true;
} catch (e) {
logger.error(`access_rules migration failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.7 не реализован.');
return false;
},
};
@@ -1,80 +0,0 @@
import { DataSource } from 'typeorm';
import config from '../src/config/config';
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
/**
* CoopID Story 6.3: реконсиляция таблицы `access_rules`. В init-миграции V2.4.0
* `access_rules` была создана плейсхолдером со старой схемой (`role/action/subject/
* conditions`), а Story 6.2 (V2.4.7) добавила правильную схему через
* `CREATE TABLE IF NOT EXISTS` — но на любой БД, где V2.4.0 отработала раньше, V2.4.7
* становилась no-op, и таблица оставалась со старой схемой. Из-за этого репозиторий
* Layer 2 (`PostgresAccessRulesRepository`, колонки `subject_type/subject_id/effect/
* resource_type/expires_at`) упал бы в рантайме на `column "subject_type" does not exist`.
*
* Фикс forward-миграцией (канон append-only, без правки применённых V2.4.0/V2.4.7):
* если таблица существует, но без колонки `subject_type` (= старый плейсхолдер) —
* пересоздаём с правильной схемой. Данных в плейсхолдере нет (CoopID не в проде,
* Layer 2 ни из какого UI ещё не пишет), поэтому DROP безопасен. Идемпотентно:
* на БД с уже правильной схемой DROP не выполняется, CREATE IF NOT EXISTS — no-op.
*/
export default {
name: 'coopid: reconcile access_rules schema (Layer 2)',
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
const db = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
});
try {
await db.initialize();
// Старый плейсхолдер из V2.4.0 (нет колонки subject_type) — снести.
await db.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'access_rules'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'access_rules' AND column_name = 'subject_type'
) THEN
DROP TABLE access_rules;
RAISE NOTICE 'access_rules: снесён плейсхолдер V2.4.0';
END IF;
END $$;
`);
// Правильная схема Layer 2 (идентична V2.4.7 — единый источник).
await db.query(`
CREATE TABLE IF NOT EXISTS access_rules (
id bigserial PRIMARY KEY,
subject_type text NOT NULL,
subject_id text NOT NULL,
effect text NOT NULL DEFAULT 'allow',
action text NOT NULL,
resource_type text NOT NULL,
conditions jsonb,
expires_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_access_rules_principal ON access_rules (subject_type, subject_id);`);
logger.info('access_rules: схема Layer 2 реконсилирована');
return true;
} catch (e) {
logger.error(`access_rules reconcile failed: ${e instanceof Error ? e.message : String(e)}`);
return false;
} finally {
if (db.isInitialized) await db.destroy();
}
},
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
logger.warn('Откат V2.4.8 не реализован: реконсиляция схемы необратима без потери данных.');
return false;
},
};

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