Compare commits

..

94 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:04:56 +00:00
coopops eaf4125f21 [105-14][@ant] feat: SDK migrate() и контракт username — подпись ключом + POST /coop/migration + saveToVault, чтобы пайщик задал пароль и зашифровал ключ за один шаг
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:36:06 +00:00
coopops 2e16e7d852 [105-14][@ant] feat: backend-эндпоинт миграции «ключ→пароль» — бессессионная проверка подписи против COOPOS + set_password authentik, чтобы действующие пайщики задали пароль без потери доступа
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:31:34 +00:00
coopops 0c68759455 [105-14][@ant] feat: SDK password-vault — POST /coop/vault, saveToVault и локальная копия шифроблоба — чтобы мигрировать ключ под пароль и входить офлайн без round-trip к узлу
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:02:31 +00:00
coopops 4fc6eaf986 [105-14][@ant] feat: встроенный фактор-1 входа через flow-executor authentik вместо signinPopup — чтобы клиент видел пароль для password-vault, не нарушая FR29 (грант остаётся code+PKCE)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:48:33 +00:00
coopops 6415db7085 [105-14][@ant] feat: добавить admin-API адаптер authentik (ensureUser/set_password) — разблокировать миграцию пайщиков «ключ→пароль» и установку пароля при восстановлении доступа
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:26:20 +00:00
coopops 1a97e1208c [105-10][@ant] feat: SDK CoopID-логин-фасад — handshake bind→sign→verify, lifecycle токенов и REST /coop/refresh — чтобы вход шёл по новому контуру при равноправии легаси-токенов (Эпик 7, фаза 3)
D1: фасад в @coopenomics/auth (handshake/токены/recover), @coopenomics/sdk оборачивает
через Client.setAccessTokenProvider (bearer в слое SDK, без импорта auth — защита Node-потребителей).
D2: /coop/session/bind отдаёт binding_token в теле (+ httpOnly-cookie как fallback).
Бэк: новый REST /coop/refresh (та же токен-машинерия, что и legacy GraphQL-refresh).
Инвариант равноправия токенов закреплён token-coexistence.spec (оба контура — один
generateAuthTokens/config.jwt.secret/guard, без маркера контура). Authorization Code + PKCE (FR29),
ROPC запрещён. Тесты: auth vitest 16/16, controller jest 6/6, tsc(auth+sdk) EXIT0, ESLint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:58:28 +00:00
coopops a7f1398529 [105-12][@ant] feat: устойчивость COOPOS RPC — пул с failover, finalized-only чтения ключей и M-of-N консенсус кэша — чтобы вход CoopID переживал падение/компрометацию узла без downtime (Stories 9.4/9.6/9.7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:33:18 +00:00
coopops f5bcaa3a69 [105-9][@ant] refactor: перевести account self-service auth-v2 с REST на GraphQL/SDK — единый типизированный фасад фронта
Корзина C Фазы 2: sessions/2fa/recovery-strategy/security-not-me →
AccountSecurityResolver (9 операций, GqlJwtAuthGuard), а
critical-actions/keys/force-recovery → CriticalActionsResolver (6 операций,
тот же CASL @CheckAbility+AuthorizationGuard). Движок Эпика 6 не тронут —
резолверы поверх тех же сервисов. 5 REST-контролёров сняты целиком, 2 урезаны
до magic-link :token (корзина D). SDK-домены AccountSecurity/CriticalActions +
codegen (SDL 62 резолвера) + Zeus закоммичен. Транспорт (IP/refresh-токен) —
request-meta декораторы, не GraphQL-переменные. Unit 24/24, ESLint 0.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:45:34 +00:00
coopops 1498be5213 [105-11][@ant] docs: перечислить реализованные OIDC-аудит-события в event-schema — синхронизировать схему событий с фактическим маппингом Story 8.3 для читателей кода
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:05:50 +00:00
coopops 6fbce27242 [105-11][@ant] feat: писать OIDC-операции и native-события authentik в audit_events через webhook — дать кооперативу единый аудит входов/выдач токенов для compliance и расследований (Story 8.3)
Все OIDC-операции выполняет authentik — контроллер узнаёт о них только через
native-события, доставляемые webhook'ом на /coop/internal/authentik-events
(механизм Story 1.5, shared-токен constant-time). Расширяем его на OIDC.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:05:09 +00:00
coopops 28c4164a61 [105-8][@ant] feat: поднять authentik OIDC-провайдер кооператива и корневой discovery — дать CoopID работать как стандартный OIDC-провайдер, чтобы любой внешний сервис подключался конфигом без правок кода (Story 5.1, 5.2, 5.4)
Сторона провайдера (IdP), потребители НЕ настраиваются (решение владельца:
«делаем только CoopID чтоб OIDC работал стандартно, потребителей накинем позже» —
это универсально).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 08:25:29 +00:00
coopops 865b5eb550 [105-12][@ant] test: добавить cross-runtime smoke-тесты SDK auth для Node, браузера и electron + CI-гейт — не дать релизу SDK сломать клиентов ни в одном рантайме (Story 9.13)
Smoke против публичного API @coopenomics/auth на трёх рантаймах: vault round-trip
(Argon2id+AES-GCM WebCrypto), signDocument→verifyDocumentOffline, WalletLocked,
типизированный not_implemented для login/getAccessToken (скелет Story 1.2). Node и
browser — vitest (общий smoke.test.ts), electron — main-process против собранного
dist (electron-main.cjs + run-electron.mjs c xvfb). Скрипты test:node/browser/
electron/cross-runtime + workflow sdk-cross-runtime.yaml. KDF один раз в beforeAll —
в chromium pure-JS Argon2id это минуты, per-test упирался в таймауты.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 05:43:22 +00:00
coopops 50ac2bffc3 [105-12][@ant] feat: структурировать логи в JSON для prod и распространить redaction 8.7 на путь в Sentry — дать агрегируемые stdout-логи без секретов/ПДн (NFR, 152-ФЗ, Story 9.12)
buildLogFormat(isProduction): в production winston.format.json() с полем service
и redaction после splat() (инвариант 8.8), в dev — прежний pretty-printf без
изменений. scrubSensitiveDataFromSentryEvent дополнен redactSensitive по
event.extra/contexts — секреты в Sentry маскируются той же утилитой 8.7. Тесты:
logger-format 7 (обе ветки + маскировка) + sentry-scrub 4. 28/28 зелёных с регрессом.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 06:03:17 +00:00
coopops d6f12ed7e9 [105-9][@ant] feat: CASL Layer 2 access_rules с merge поверх статики и Redis-инвалидацией — давать точечные права декларативно из БД без правки кода
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 05:33:03 +00:00
coopops 1a4876a0cd [105-9][@ant] feat: реальный @casl/ability-фундамент авторизации и Layer 1 static ability — переиспользовать зачаток marketplace2 и доделать капабилити-модель платформы
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 05:22:06 +00:00
coopops 5ed8a63839 [105-7][@ant] feat: политика версий схемы удостоверения через well-known + сверка в verifyOffline — отвергать офлайн устаревшие схемы claims, не принимая их как валидные
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 04:52:24 +00:00
coopops bfa5c7e00b [105-7][@ant] feat: SDK-гейт полного экспорта CoopID и запрет anonymous-формы в типах — не дать выпустить PII-полный сертификат под видом proof_of_membership
В MVP удостоверение только полное (с PII): exportToQR заменён на exportFullQR
с двумя барьерами — разблокированный vault и явный high-stakes consent. Метод
exportProofQR намеренно не объявлен, его вызов даёт ошибку компиляции; anonymous
proof_of_membership без PII (exp<=24ч) отложен в Growth. Добавлен клиентский
код ошибки ConsentRequired.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:36:15 +00:00
coopops edfde6fa1e [105-6][@ant] feat: magic-link восстановление доступа пайщика через email — первый шаг recovery-контура CoopID
Эндпоинт POST /coop/recovery/request: одноразовый токен в Redis (5 мин),
письмо через готовый Центр уведомлений (workflow reset-key), константный
202 (анти-enumeration), rate-limit 3/час по email и IP с отдельным кодом
TooManyRecoveryAttempts. Источник пайщика — user-домен (таблицы participants
из AC в brownfield нет). Открывает Подход 2 (Эпик 3, восстановление доступа).

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:22:21 +00:00
coopops 3ed1c5ee95 [105-4][@ant] feat: карточка удостоверения пайщика в ЛК — пайщик видит серийник, срок, статус, цепочку доверия и подтверждения своего CoopID
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
ProfilePage (пункт меню «Удостоверение») расширена карточкой: серийный номер (jti, copyable mono), срок действия, статус-чип (Активно/Истекает/Истекло по exp), визуальная цепочка подписей АНО→Восход→Восток→Вы, список verification_types человеческим языком и кнопка «Скачать как QR» (Vision-stub, мягкое уведомление). Данные — GET /coop/certificate через существующий sendGET (Bearer), payload декодируется локально для отображения (подпись — offline-verify Story 4.4). Best-effort: сбой/отсутствие удостоверения показывает EmptyState, профиль работает. Только канон-компоненты и токены, eslint чист, HMR без рестарта контейнера.
2026-06-10 15:07:55 +00:00
coopops d63ace86e4 [105-4][@ant] feat: SDK-чтение participant_certificate и серийник jti — фундамент личного кабинета удостоверения, чтобы пайщик видел claims без серверного раунда
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
CertificateService добавляет серийный номер удостоверения (jti=UUID), который требует страница ЛК (Story 1.9). SDK @coopenomics/auth: модуль certificate/ с decodeParticipantCertificate (claims без проверки подписи — для отображения), certificateStatus (active/expiring/expired по exp), VERIFICATION_TYPE_LABELS/verificationTypeLabel (человекочитаемые типы верификации, forward-совместимо); getParticipantCertificate(apiUrl, accessToken) тянет GET /coop/certificate с Bearer по образцу fetchVaultBlob. 13 SDK + 6 controller тестов зелёные, dist пересобран, tsc чист.
2026-06-10 15:00:22 +00:00
coopops e67001a82a [105-4][@ant] feat: выпуск participant_certificate — compact JWS ES256K с цепью доверия coop_chain даёт пайщику самопроверяемое удостоверение для внешних сервисов
Typecheck / desktop (pull_request) Failing after 8m20s
Typecheck / controller (pull_request) Has been cancelled
CertificateService подписывает claims (iss/sub=UUID/iat/exp/coopname/coop_chain[ano,voskhod,vostok]/verification_types/identification/claim_schema_version) ключом permission cert аккаунта vostok через jose+Node KeyObject из PEM (Docker Secret coop_cert_key); coop_chain читается из COOPOS через новый BlockchainPort.getCertPublicKey (single-key, нормализация PUB_K1), кэшируется; size-guard 5КБ под Vision/MIFARE. Эндпоинт GET /coop/certificate под HttpJwtAuthGuard; сертификат также встроен в ответ /coop/verify/timestamp best-effort (завершает контракт 1.7, не ломает логин при сбое). config: COOP_CERT_KEY[_FILE]+геттер. 16 unit зелёных, coopback поднялся чисто. verifyOffline/backward-валидность -> 4.4/2.5.
2026-06-10 14:44:12 +00:00
coopops c4febaa749 [105-4][@ant] feat: эндпоинт /coop/verify/timestamp — второй этап входа CoopID доказывает владение ключом, проверяя подпись метки против COOPOS и single-use jti, защищая от угона пароля
Typecheck / desktop (pull_request) Failing after 8m26s
Typecheck / controller (pull_request) Has been cancelled
Пайплайн VerifyTimestampService: верификация session_binding_token (HS256), атомарный GETDEL jti до криптопроверки (anti-replay), окно свежести 60s против head_block_time, recover pubkey по каноническому сообщению (зеркало SDK signTimestamp) и сверка с активным ключом аккаунта; успех -> выпуск access/refresh через TokenApplicationService, audit success/failure без секретов. Crypto recover вынесена за BlockchainPort.recoverPublicKey ради гексагонального инварианта auth-v2 (запрет wharfkit в application). id_token/certificate -> Story 1.8.
2026-06-10 14:22:32 +00:00
coopops f573d0e5a3 [105-5][@ant] feat: signTimestamp в SDK для второго этапа auth — локальная COOPOS-native recoverable подпись канонической метки {ts,jti,sub} ключом из keystore, без сети, с экспортом канонизатора чтобы verify-эндпоинт собрал те же байты для восстановления pubkey
Typecheck / desktop (pull_request) Failing after 8m56s
Typecheck / controller (pull_request) Successful in 13m46s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:16:18 +00:00
coopops fafb991561 [105-5][@ant] feat: клиентская расшифровка vault и локальный keystore в SDK — unlockWallet тянет blob через новый GET, расшифровывает паролём на клиенте и кладёт ключ только в RAM-keystore, getWallet отдаёт несериализуемую обёртку без приватного ключа, опциональный PIN-слой для быстрой разблокировки на устройстве
Typecheck / desktop (pull_request) Failing after 8m55s
Typecheck / controller (pull_request) Successful in 13m44s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:26:12 +00:00
coopops 49ecba1149 [105-4][@ant] docs: предупредить про обязательный coopid-gen-secrets перед docker compose up — иначе валидация падает на отсутствующих file-секретах и не поднимается весь стек на любом чекауте
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:11:14 +00:00
coopops aa482316ee [105-4][@ant] refactor: свернуть CoopID-overlay'и в единый docker-compose и перенести authentik_db+coop_domain_db в существующий postgres — чтобы весь стек поднимался одной командой docker compose up -d, как того требует прод-деплой через плейбук, без отдельного контейнера postgres
Typecheck / desktop (pull_request) Failing after 9m11s
Typecheck / controller (pull_request) Successful in 14m5s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:36:49 +00:00
coopops 33548c2200 [105-5][@ant] feat: vault-шифрование участника — клиентский Argon2id+AES-256-GCM в SDK (AAD субъекта) + POST /coop/vault с type-driven запретом серверной расшифровки ключа, гарантируя что сервер хранит только зашифрованный блоб
Typecheck / desktop (pull_request) Failing after 8m54s
Typecheck / controller (pull_request) Successful in 13m43s
2026-06-10 08:13:28 +00:00
coopops 7b6fdd823e [105-4][@ant] feat: session_binding_token-мост (вариант B) — controller выпускает HS256-токен после authentik-password через защищённый bind-эндпоинт с jti в Redis, связывая два этапа входа и отсекая replay перед timestamp-проверкой
Typecheck / desktop (pull_request) Failing after 8m44s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 07:55:36 +00:00
coopops 0065314b95 [105-4][@ant] feat: парольная политика CoopID (zxcvbn≥3, ≥12 символов, RU-сообщение) blueprint'ом authentik + аудит WeakPasswordRejected в coop_domain_db через защищённый webhook — усиливаем вход и фиксируем отказы по слабым паролям в неизменяемом журнале
Typecheck / desktop (pull_request) Failing after 19m48s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 07:32:41 +00:00
coopops f8e835b4a8 [105-4][@ant] feat: схемы coop_domain_db миграцией V2.4.0 (vaults, append-only audit_events с партициями, chain cache, mapping, access_rules) — фундамент хранения для vault-шифрования, аудита и CASL ещё до их сервисов
Typecheck / desktop (pull_request) Failing after 9m14s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 07:10:52 +00:00
coopops 4ac9e17296 [105-4][@ant] feat: COOPOS permission cert (ES256K) на ano/voskhod/vostok миграцией 052 + BLOCKCHAIN_RPC_LIST и readCertPublicKey — закладываем цепочку доверия для подписи удостоверений пайщика ключом кооператива
Typecheck / desktop (pull_request) Failing after 8m48s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 06:59:56 +00:00
coopops 75f5a4bfd5 [105-4][@ant] feat: workspace-пакет @coopenomics/auth (скелет SDK) + ESLint-инвариант ports&adapters — фиксируем публичную поверхность auth flow для браузера/Node/desktop и запрещаем application/domain контроллера импортировать wharfkit/oidc-client-ts/ioredis напрямую
Typecheck / desktop (pull_request) Failing after 8m50s
Typecheck / controller (pull_request) Successful in 13m45s
2026-06-10 06:19:10 +00:00
coopops 86dede3d35 [105-4][@ant] feat: Docker Compose overlay-стек CoopID (authentik 2026.2 + postgres:16 + caddy + crowdsec) — инфраструктурный seed Эпика 1: per-coop OIDC Provider с двумя изолированными БД и секретами через Docker Secrets вместо открытых .env
Typecheck / desktop (pull_request) Failing after 8m58s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 06:02:23 +00:00
670 changed files with 29707 additions and 10360 deletions
+11
View File
@@ -39,3 +39,14 @@ MINIO_ROOT_PASSWORD=minioadmin
CHAIN_URL=http://127.0.0.1:8888
API_URL=http://127.0.0.1:2998/v1/graphql
MONGODB_URL=mongodb://127.0.0.1:27017
# --- CoopID (overlays docker-compose.authentik.yml + docker-compose.edge.yml) ---
# Поднимаются только явной цепочкой -f (см. infra/coopid/README.md);
# перед первым запуском: bash scripts/coopid-gen-secrets.sh
# К портам применяется то же правило offset = (INSTANCE_INDEX - 1) * 10.
COOP_PG_HOST_PORT=5632
AUTHENTIK_HOST_PORT=9008
CADDY_HTTP_PORT=8088
CADDY_HTTPS_PORT=8443
# Домен кооператива для caddy: localhost = dev self-signed; реальный домен = Let's Encrypt.
COOPID_DOMAIN=localhost
+3 -39
View File
@@ -33,22 +33,7 @@ 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
@@ -65,22 +50,13 @@ 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: |
# Окружение: при ручном 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)"
# Ветка — это ветка push'а (или выбранная в workflow_dispatch).
BRANCH="${{ github.ref_name }}"
SHA="${{ github.sha }}"
# Версия — из закоммиченного lerna.json. Её бампит lerna version ОДИН
# раз на dev (scripts/cut-release.sh), и тот же коммит едет по FF в
@@ -390,18 +366,6 @@ 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
@@ -0,0 +1,59 @@
name: SDK cross-runtime
# Story 9.13 (CoopID, Эпик 9): smoke-тесты @coopenomics/auth на трёх рантаймах —
# Node (vitest), браузер (vitest browser mode + chromium), desktop-runtime
# (electron, main-process против собранного dist). SDK уходит клиентам в три
# разных рантайма, и регрессия вида «crypto.subtle недоступен / bundle не
# грузится в браузере» не ловится Node-юнитами вовсе.
#
# Триггер — только изменения самого пакета: прогон тяжёлый (браузер + electron
# + xvfb), на каждый PR в dev его гонять незачем.
on:
pull_request:
branches: [dev]
paths:
- 'components/auth/**'
- '.github/workflows/sdk-cross-runtime.yaml'
workflow_call:
concurrency:
group: sdk-cross-runtime-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
cross-runtime:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install workspace deps
run: pnpm install --frozen-lockfile --filter @coopenomics/auth...
# xvfb — electron на Linux требует X-сервер; браузеры playwright ставим
# вместе с системными библиотеками chromium (--with-deps).
- name: Install runtime prerequisites
run: |
sudo apt-get update
sudo apt-get install -y xvfb
# test:electron поднимает реальный electron — ему нужны GTK/ATK/ALSA-библиотеки,
# которых нет в наборе `playwright --with-deps chromium` (chromium на другом тулките).
# Симптом без них: electron «error while loading shared libraries: libgtk-3.so.0».
# Ubuntu 24.04 (noble) — пакеты с суффиксом t64; ставим по одному, чтобы расхождение
# имени пакета не валило весь шаг.
for pkg in libgtk-3-0t64 libnotify4 libnss3 libxss1 libxtst6 libatspi2.0-0t64 libasound2t64; do
sudo apt-get install -y "$pkg" || true
done
cd components/auth && pnpm exec playwright install --with-deps chromium
- name: Run cross-runtime smoke
run: pnpm test:cross-runtime
+3
View File
@@ -25,3 +25,6 @@ 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,14 +45,6 @@ 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
@@ -0,0 +1,12 @@
import { defineBuildConfig } from 'unbuild'
// Один dual-выход (ESM .mjs + CJS .cjs + .d.ts) обслуживает все три target'а:
// браузер и desktop-runtime берут import→.mjs, Node — require→.cjs (exports-map).
export default defineBuildConfig({
entries: ['src/index'],
declaration: true,
clean: true,
rollup: {
emitCJS: true,
},
})
+13
View File
@@ -0,0 +1,13 @@
// @ts-check
import antfu from '@antfu/eslint-config'
export default antfu(
{
type: 'lib',
rules: {
'eslint-comments/no-unlimited-disable': 'off',
'no-console': 'off',
'ts/explicit-function-return-type': 'off',
},
},
)
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@coopenomics/auth",
"type": "module",
"version": "2026.6.9-18",
"private": false,
"packageManager": "pnpm@9.9.0",
"description": "SDK аутентификации CoopID: OIDC, vault, кошелёк и подпись для браузера, Node и desktop",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "MIT",
"keywords": [],
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"typesVersions": {
"*": {
"*": [
"./dist/*",
"./dist/index.d.ts"
]
}
},
"files": [
"dist"
],
"scripts": {
"prebuild": "pnpm run typecheck",
"build": "unbuild",
"dev": "unbuild --stub",
"lint": "eslint .",
"test": "vitest run",
"test:node": "vitest run test/cross-runtime",
"test:browser": "vitest run --config vitest.config.browser.ts",
"test:electron": "node test/cross-runtime/run-electron.mjs",
"test:cross-runtime": "pnpm run build && pnpm run test:node && pnpm run test:browser && pnpm run test:electron",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@noble/hashes": "^1.8.0",
"@wharfkit/antelope": "^1.1.1",
"@wharfkit/session": "^1.4.0",
"cooptypes": "workspace:*",
"jose": "^6.2.2",
"oidc-client-ts": "^3.1.0"
},
"devDependencies": {
"@antfu/eslint-config": "^3.3.2",
"@types/node": "^22.5.4",
"@vitest/browser": "2.1.9",
"electron": "36.9.5",
"eslint": "^9.9.1",
"playwright": "1.59.1",
"typescript": "^5.5.4",
"unbuild": "^2.0.0",
"vite": "^5.4.3",
"vitest": "^2.0.5"
}
}
+194
View File
@@ -0,0 +1,194 @@
/**
* participant_certificate: чтение claims на клиенте (ЛК, Story 1.9) и offline-
* верификация (Story 4.4). Здесь — только ДЕКОДИРОВАНИЕ payload и производные
* (статус, человекочитаемые типы верификации). Проверка подписи против trust
* anchor — отдельно (verifyOffline, chain/), для отображения в ЛК не требуется.
*/
import { decodeJwt } from 'jose'
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
export * from './schema-policy'
export interface CoopChainLink {
account: string
public_key: string
}
/**
* Подтверждённый тип верификации в удостоверении (Story 4.3). Структурная форма
* для RP: что подтверждено (`type`), когда (`verified_at`, ISO-8601 UTC) и на каком
* основании (`source`).
*/
export interface VerificationTypeClaim {
type: string
verified_at: string
source: string
}
/** Claims participant_certificate (зеркало payload контроллера, Story 1.8). */
export interface ParticipantCertificateClaims {
iss: string
/** UUID пайщика */
sub: string
/** серийный номер удостоверения */
jti: string
iat: number
exp: number
coopname: string
coop_chain: CoopChainLink[]
verification_types: VerificationTypeClaim[]
identification: Record<string, unknown> | null
claim_schema_version: string
/** 152-ФЗ-обязательство RP удалить данные пайщика (Story 4.8), напр. `erase_on_exclusion`. */
data_retention_contract: string
/** Дедлайн удаления данных RP, unix-секунды (`iat + 30 дней`, Story 4.8). */
retention_deadline_ts: number
}
/** Нормализовать сырой claim verification_types в структурную форму (Story 4.3). */
function normalizeVerificationTypes(raw: unknown): VerificationTypeClaim[] {
if (!Array.isArray(raw))
return []
return raw
.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null && typeof (e as Record<string, unknown>).type === 'string')
.map(e => ({
type: String(e.type),
verified_at: String(e.verified_at ?? ''),
source: String(e.source ?? ''),
}))
}
export type CertificateStatus = 'active' | 'expiring' | 'expired'
/** Окно «истекает» до exp (24ч по умолчанию у сертификата; здесь — последний час). */
export const CERTIFICATE_EXPIRING_WINDOW_MS = 60 * 60 * 1000
/** Человекочитаемые описания типов верификации (claim `verification_types`). */
export const VERIFICATION_TYPE_LABELS: Record<string, string> = {
coop_baseline: 'Базовое подтверждение кооперативом',
}
/** Описание типа верификации; неизвестный — отдаём как есть (forward-compat). */
export function verificationTypeLabel(type: string): string {
return VERIFICATION_TYPE_LABELS[type] ?? type
}
/**
* Декодировать compact JWS в claims БЕЗ проверки подписи (для отображения в ЛК).
* Бросает AuthV2Error при структурно некорректном сертификате.
*/
export function decodeParticipantCertificate(jws: string): ParticipantCertificateClaims {
let raw: Record<string, unknown>
try {
raw = decodeJwt(jws) as Record<string, unknown>
}
catch {
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Некорректный participant_certificate: не удалось прочитать claims')
}
if (typeof raw.jti !== 'string' || typeof raw.exp !== 'number' || typeof raw.sub !== 'string')
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'participant_certificate без обязательных claims (jti/exp/sub)')
return {
iss: String(raw.iss ?? ''),
sub: raw.sub,
jti: raw.jti,
iat: Number(raw.iat ?? 0),
exp: raw.exp,
coopname: String(raw.coopname ?? ''),
coop_chain: Array.isArray(raw.coop_chain) ? (raw.coop_chain as CoopChainLink[]) : [],
verification_types: normalizeVerificationTypes(raw.verification_types),
identification: (raw.identification as Record<string, unknown> | null) ?? null,
claim_schema_version: String(raw.claim_schema_version ?? ''),
data_retention_contract: String(raw.data_retention_contract ?? ''),
retention_deadline_ts: Number(raw.retention_deadline_ts ?? 0),
}
}
/** Статус по сроку действия: expired / expiring (близко к exp) / active. */
export function certificateStatus(claims: Pick<ParticipantCertificateClaims, 'exp'>, nowMs: number = Date.now()): CertificateStatus {
const expMs = claims.exp * 1000
if (nowMs >= expMs)
return 'expired'
if (expMs - nowMs <= CERTIFICATE_EXPIRING_WINDOW_MS)
return 'expiring'
return 'active'
}
/**
* Окно упреждающего перезапроса сертификата (Story 4.6): за 5 минут до `exp` SDK
* молча обновляет удостоверение, чтобы у короткоживущего cert (дефолт 1ч) не было
* разрыва доступа на стыке.
*/
export const CERTIFICATE_RENEWAL_LEAD_MS = 5 * 60 * 1000
/**
* Сколько миллисекунд ждать до момента перезапроса (`exp − lead`). Никогда не
* отрицательно: если до `exp` осталось ≤ lead (или cert уже истёк) — 0 (перезапрос сразу).
*/
export function computeRenewalDelayMs(claims: Pick<ParticipantCertificateClaims, 'exp'>, nowMs: number = Date.now()): number {
const renewAtMs = claims.exp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS
return Math.max(0, renewAtMs - nowMs)
}
/** Управление запланированным авто-обновлением сертификата. */
export interface CertificateRenewalHandle {
/** Отменить запланированный перезапрос (например, при logout). */
cancel: () => void
}
/** Параметры планировщика — для подмены времени/таймеров в тестах. */
export interface ScheduleCertificateRenewalOptions {
now?: () => number
setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>
clearTimer?: (handle: ReturnType<typeof setTimeout>) => void
}
/**
* Запланировать silent renewal сертификата за 5 минут до `exp` (Story 4.6). По
* срабатыванию таймера вызывает `renew()` — хост-колбэк, который обновляет access
* через `refresh_token` и тянет свежий cert (`getParticipantCertificate`), — затем
* перепланирует от `exp` нового удостоверения. Кросс-рантайм: SDK владеет ЛОГИКОЙ
* планирования, а транспорт/refresh — на стороне приложения (как и весь fetch в SDK).
* Бесконечный цикл сам себя продлевает, пока не вызван `cancel()`. Ошибка `renew`
* не роняет процесс (логируется), но и НЕ перепланирует — повторную попытку инициирует хост.
*/
export function scheduleCertificateRenewal(
initialJws: string,
renew: () => Promise<string>,
options: ScheduleCertificateRenewalOptions = {},
): CertificateRenewalHandle {
const now = options.now ?? (() => Date.now())
const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms))
const clearTimer = options.clearTimer ?? (h => clearTimeout(h))
let timer: ReturnType<typeof setTimeout> | null = null
let cancelled = false
const scheduleFrom = (jws: string): void => {
if (cancelled)
return
const claims = decodeParticipantCertificate(jws)
const delay = computeRenewalDelayMs(claims, now())
timer = setTimer(() => {
void renew()
.then((nextJws) => {
if (!cancelled)
scheduleFrom(nextJws)
})
.catch(() => {
// best-effort: сбой обновления (нет сети/refresh истёк) не роняет цикл;
// повторную попытку инициирует приложение (например, при следующем действии).
})
}, delay)
}
scheduleFrom(initialJws)
return {
cancel: () => {
cancelled = true
if (timer !== null)
clearTimer(timer)
},
}
}
@@ -0,0 +1,90 @@
/**
* Политика версий схемы claims удостоверения на стороне SDK (Story 4.10).
* Зеркало публичной `/.well-known/coopid-schema-policy.json` контроллера: типы,
* сравнение версий и кэш с TTL 24ч. `verifyOffline` сверяет `claim_schema_version`
* проверяемого сертификата с минимально поддерживаемой версией из этого кэша.
*
* Кросс-рантайм: SDK владеет ЛОГИКОЙ кеша/сравнения; сам HTTP-fetch политики
* инжектируется хостом (как и весь fetch в SDK — chain/ остаётся чисто офлайновым).
*/
/** Путь публичной политики версий схемы у контроллера кооператива. */
export const SCHEMA_POLICY_WELL_KNOWN_PATH = '/.well-known/coopid-schema-policy.json'
/** TTL кэша политики: офлайн принимает закэшированную min-версию до 24 часов (FR72). */
export const SCHEMA_POLICY_CACHE_TTL_MS = 24 * 60 * 60 * 1000
export interface CoopIdSchemaPolicy {
current_version: string
min_supported_version: string
deprecation: Record<string, string>
}
/**
* Сравнить две версии схемы. Версии — целочисленные строки (`'0'`, `'1'`, …):
* сравниваем численно, при нечисловой версии — лексикографический фолбэк.
* `< 0` если a старее b, `0` если равны, `> 0` если a новее.
*/
export function compareSchemaVersions(a: string, b: string): number {
const na = Number(a)
const nb = Number(b)
if (Number.isFinite(na) && Number.isFinite(nb))
return na - nb
if (a === b)
return 0
return a < b ? -1 : 1
}
/** Поддерживается ли версия схемы (не старее минимально поддерживаемой). */
export function isSchemaVersionSupported(version: string, minSupported: string): boolean {
return compareSchemaVersions(version, minSupported) >= 0
}
export interface SchemaPolicyCacheOptions {
/** Хост-колбэк, тянущий политику с `SCHEMA_POLICY_WELL_KNOWN_PATH` (fetch — у приложения). */
fetchPolicy: () => Promise<CoopIdSchemaPolicy>
/** «Сейчас» в мс (инъекция для детерминизма/тестов). */
now?: () => number
/** TTL кэша в мс (по умолчанию 24ч). */
ttlMs?: number
}
export interface SchemaPolicyCache {
/** Политика из кэша (свежий fetch при истёкшем TTL; stale-кэш при офлайне). */
getPolicy: () => Promise<CoopIdSchemaPolicy>
/** Минимально поддерживаемая версия — для передачи в `verifyOffline`. */
getMinSupportedVersion: () => Promise<string>
}
/**
* Кэш политики версий схемы с TTL 24ч (Story 4.10, FR72). В пределах TTL отдаёт
* закэшированную политику без сети. По истечении TTL пробует обновить; если fetch
* упал (офлайн/недоступность) — отдаёт последнюю валидную политику (недоступность
* сети НЕ роняет офлайн-проверку; короткий cert TTL ограничивает риск устаревания).
* Нет кэша и fetch упал → пробрасывает ошибку (политику взять неоткуда).
*/
export function createSchemaPolicyCache(options: SchemaPolicyCacheOptions): SchemaPolicyCache {
const now = options.now ?? (() => Date.now())
const ttlMs = options.ttlMs ?? SCHEMA_POLICY_CACHE_TTL_MS
let cached: { policy: CoopIdSchemaPolicy, fetchedAt: number } | null = null
const getPolicy = async (): Promise<CoopIdSchemaPolicy> => {
if (cached && now() - cached.fetchedAt < ttlMs)
return cached.policy
try {
const policy = await options.fetchPolicy()
cached = { policy, fetchedAt: now() }
return policy
}
catch (error) {
if (cached)
return cached.policy
throw error
}
}
return {
getPolicy,
getMinSupportedVersion: async () => (await getPolicy()).min_supported_version,
}
}
+315
View File
@@ -0,0 +1,315 @@
import type { CoopChainLink } from '../certificate'
/**
* Цепочка доверия: офлайн-проверка participant_certificate walk'ом
* `ano → voskhod → vostok → participant` от embedded trust anchor.
* Реализация verify — Story 4.4; чтение ключей `cert` из COOPOS — здесь.
*/
import { APIClient } from '@wharfkit/antelope'
import { base64url } from 'jose'
import { compareSchemaVersions } from '../certificate/schema-policy'
import { TRUST_ANCHOR_ANO_CERT_PUBKEY } from '../config/trust-anchor'
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
/**
* Публичный ключ permission `cert` аккаунта в COOPOS (Story 1.3).
* Возвращает строку в формате Antelope (`PUB_K1_...`).
*/
export async function readCertPublicKey(rpcUrl: string, account: string): Promise<string> {
const client = new APIClient({ url: rpcUrl })
const acc = await client.v1.chain.get_account(account).catch((e: unknown) => {
throw new AuthV2Error(
AuthV2ErrorCode.NetworkError,
`COOPOS недоступен или аккаунт ${account} не найден: ${e instanceof Error ? e.message : String(e)}`,
)
})
const cert = acc.permissions.find(p => String(p.perm_name) === 'cert')
const key = cert?.required_auth.keys[0]?.key
if (!cert || !key) {
throw new AuthV2Error(
AuthV2ErrorCode.ChainVerificationFailed,
`На аккаунте ${account} нет permission cert с ключом — цепочка доверия не настроена (Story 1.3).`,
)
}
// MVP-инвариант: cert — строго single-key (multi-sig для ano — Growth).
// Молча взять keys[0] у multi-sig значило бы проверять подпись против
// одного из N ключей — это дыра в цепочке доверия.
const auth = cert.required_auth
if (Number(auth.threshold) !== 1 || auth.keys.length !== 1 || auth.accounts.length > 0 || auth.waits.length > 0) {
throw new AuthV2Error(
AuthV2ErrorCode.ChainVerificationFailed,
`Permission cert на ${account} не является single-key (threshold=${auth.threshold}, keys=${auth.keys.length}) — не поддерживается MVP-верификацией.`,
)
}
return key.toString()
}
export type VerifyOfflineReason =
| 'malformed_certificate' // не compact JWS / нет обязательных claims (coop_chain, exp)
| 'unsupported_alg' // alg ≠ ES256K
| 'expired' // exp в прошлом относительно now
| 'untrusted_anchor' // coop_chain не укоренён в известном trust-anchor `ano`
| 'untrusted_issuer' // звено цепи (в т.ч. издатель) не совпало с доверенным кэшем ключей
| 'signature_mismatch' // подпись не сходится с ключом издателя
| 'unsupported_schema_version' // claim_schema_version старее min_supported_version политики
export interface VerifyOfflineResult {
valid: boolean
/** Причина отказа, если valid=false. Офлайн-отзыв (revoked) — вне MVP (Story 4.7). */
reason?: VerifyOfflineReason
/** Аккаунт-издатель (последнее звено coop_chain), под чьим ключом сошлась подпись. */
issuer?: string
}
export interface VerifyOfflineOptions {
/**
* Офлайн-снимок доверенных cert-ключей известных кооперативов
* (`chain_manifests_cache`): `account → Antelope public_key`. Источник доверия —
* каждое звено `coop_chain` сертификата сверяется с этим набором (закрывает
* подделку «свой leaf-ключ + настоящий ano в root»). Без него издатель не
* подтверждается → `untrusted_issuer` (fail-closed). Наполнение кэша
* (manifest-sync) — отдельная задача.
*/
trustedKeys?: Record<string, string>
/**
* Доверенный якорь `ano.cert` (Antelope `PUB_K1_…`). По умолчанию —
* `trustedKeys['ano']`, затем вшитый release-pinned `TRUST_ANCHOR_ANO_CERT_PUBKEY`.
*/
trustAnchor?: string
/** «Сейчас» в мс для проверки exp (инъекция для детерминизма/тестов). */
now?: number
/**
* Минимально поддерживаемая версия схемы claims (Story 4.10). Резолвится хостом
* из кэша политики (`createSchemaPolicyCache().getMinSupportedVersion`, TTL 24ч).
* Если задана и `cert.claim_schema_version` старее неё → `unsupported_schema_version`.
* Не задана → ось версии схемы не гейтит (крипто/exp/цепь остаются fail-closed).
*/
minSchemaVersion?: string
}
/** Порядок группы secp256k1 (n) и его половина — для low-S нормализации подписи. */
const SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n
const SECP256K1_HALF_N = SECP256K1_N >> 1n
/**
* Нормализовать S подписи R||S (64 байта) к нижней половине порядка кривой.
* cert подписывает controller через jose+Node KeyObject, а Node-ECDSA может выдать
* high-S; wharfkit/noble verify по умолчанию отвергает high-S (`lowS:true`).
* Верификация secp256k1 инвариантна к `S ↔ n−S`, поэтому приведение к low-S делает
* подпись принимаемой независимо от каноничности подписанта.
*/
function normalizeLowS(rs: Uint8Array): Uint8Array {
let s = 0n
for (const byte of rs.slice(32, 64))
s = (s << 8n) | BigInt(byte)
if (s <= SECP256K1_HALF_N)
return rs
let low = SECP256K1_N - s
const out = new Uint8Array(rs) // копия R||S
for (let i = 63; i >= 32; i--) {
out[i] = Number(low & 0xFFn)
low >>= 8n
}
return out
}
interface CertHead { alg?: string }
interface CertPayload { coop_chain?: CoopChainLink[], exp?: number, claim_schema_version?: string }
/**
* Офлайн-валидация удостоверения `participant_certificate` без обращения к сети
* (Story 4.4; Vision: NFC-карты, бумажный QR). Проверяет структуру/alg/exp,
* укоренение `coop_chain` в trust-anchor `ano`, принадлежность звеньев доверенному
* кэшу ключей и подпись против ключа издателя (последнее звено `coop_chain`).
*
* Полностью офлайн и fail-closed: без доверенного якоря/кэша вердикт всегда
* `valid:false`. Отзыв ключа офлайн не проверяется (вне MVP — Story 4.7, Growth
* FR65); компенсируется коротким TTL (Story 4.6).
*/
export async function verifyOffline(certificate: string, options: VerifyOfflineOptions = {}): Promise<VerifyOfflineResult> {
const parts = certificate.split('.')
if (parts.length !== 3)
return { valid: false, reason: 'malformed_certificate' }
const [h, p, s] = parts
let head: CertHead
let payload: CertPayload
try {
head = JSON.parse(new TextDecoder().decode(base64url.decode(h)))
payload = JSON.parse(new TextDecoder().decode(base64url.decode(p)))
}
catch {
return { valid: false, reason: 'malformed_certificate' }
}
if (head.alg !== 'ES256K')
return { valid: false, reason: 'unsupported_alg' }
const chain = payload.coop_chain
if (!Array.isArray(chain) || chain.length === 0 || typeof payload.exp !== 'number')
return { valid: false, reason: 'malformed_certificate' }
const now = options.now ?? Date.now()
if (now >= payload.exp * 1000)
return { valid: false, reason: 'expired' }
// Версия схемы (Story 4.10): если хост передал минимально поддерживаемую версию
// (из кэша политики, TTL 24ч), отвергаем сертификаты старее неё — устаревшая схема
// claims не должна проходить как валидная. Без minSchemaVersion ось не гейтит.
if (options.minSchemaVersion !== undefined
&& compareSchemaVersions(payload.claim_schema_version ?? '0', options.minSchemaVersion) < 0) {
return { valid: false, reason: 'unsupported_schema_version' }
}
// Якорь: цепь обязана начинаться с известного `ano`.
const root = chain[0]
const anchor = options.trustAnchor ?? options.trustedKeys?.ano ?? TRUST_ANCHOR_ANO_CERT_PUBKEY
if (!anchor || root.account !== 'ano' || root.public_key !== anchor)
return { valid: false, reason: 'untrusted_anchor' }
// Звенья: при наличии кэша каждое звено должно совпасть с доверенным ключом.
if (options.trustedKeys) {
for (const link of chain) {
if (options.trustedKeys[link.account] !== link.public_key)
return { valid: false, reason: 'untrusted_issuer' }
}
}
else {
// Без кэша подтверждён только якорь; издателя доверять нельзя — fail-closed.
return { valid: false, reason: 'untrusted_issuer' }
}
// Подпись: против ключа издателя (последнее звено coop_chain, оно же `kid`).
const issuer = chain[chain.length - 1]
let rs: Uint8Array
try {
rs = base64url.decode(s)
}
catch {
return { valid: false, reason: 'malformed_certificate' }
}
if (rs.length !== 64)
return { valid: false, reason: 'malformed_certificate' }
const { PublicKey, Signature } = await import('@wharfkit/antelope')
const normalized = normalizeLowS(rs)
const signingInput = new TextEncoder().encode(`${h}.${p}`)
let ok = false
try {
const sig = Signature.from({ type: 'K1', r: normalized.slice(0, 32), s: normalized.slice(32, 64), recid: 0 })
ok = sig.verifyMessage(signingInput, PublicKey.from(issuer.public_key))
}
catch {
ok = false // некорректный ключ/подпись в сертификате — не валим исключением
}
if (!ok)
return { valid: false, reason: 'signature_mismatch' }
return { valid: true, issuer: issuer.account }
}
/**
* Окно активности одного публичного ключа подписанта в истории его аккаунта.
* Блоки, не время: смена authority в COOPOS привязана к блоку.
*/
export interface KeyValidityWindow {
/** публичный ключ в формате Antelope (`PUB_K1_...`) */
public_key: string
/** блок, с которого ключ стал активным (включительно) */
valid_from_block: number
/** блок, по который ключ был активным (включительно); null = активен сейчас */
valid_to_block: number | null
}
export type VerifyDocumentReason =
| 'malformed_jws'
| 'unsupported_alg'
| 'signature_mismatch' // ни один исторический ключ не даёт валидную подпись (подделка/чужой)
| 'key_not_active_at_signing' // подпись валидна под ключом, но он НЕ был активен на signedAtBlock
export interface VerifyDocumentParams {
/** compact JWS из `signDocument` (Story 2.3) */
jws: string
/**
* Блок, на который проверяется активность ключа — `last_irreversible_block_num`
* на момент `iat` подписи. Известен держателю документа (метаданные подписи).
*/
signedAtBlock: number
/**
* Историческая хронология ключей подписанта (offline-снимок из
* `chain_manifests_cache`). Наполнение кэша (ротация ключей / manifest-sync) —
* отдельная задача; здесь — чистая проверка по уже полученной хронологии.
*/
authority: KeyValidityWindow[]
}
export interface VerifyDocumentResult {
valid: boolean
reason?: VerifyDocumentReason
/** ключ, под которым подпись подтвердилась (если valid) */
matched_key?: string
}
/**
* Backward-валидность подписи документа через chain-walk (Story 2.5). Подпись,
* сделанная ключом пайщика ДО ротации, остаётся валидной: проверяем, что ключ был
* активен в момент подписи (`signedAtBlock` ∈ окно ключа), а не «активен сейчас».
* Так ротация ключа не аннулирует юридически значимые документы.
*
* Полностью офлайн: работает по переданной хронологии `authority` (кэш манифеста),
* без сети. Верификация — `verifyMessage` против ключей-кандидатов (а НЕ recover:
* `signDocument` отбрасывает recovery-байт, оставляя JWS-формат R||S).
*/
export async function verifyDocumentOffline(params: VerifyDocumentParams): Promise<VerifyDocumentResult> {
const parts = params.jws.split('.')
if (parts.length !== 3)
return { valid: false, reason: 'malformed_jws' }
const [h, p, s] = parts
let header: { alg?: string }
try {
header = JSON.parse(new TextDecoder().decode(base64url.decode(h)))
}
catch {
return { valid: false, reason: 'malformed_jws' }
}
if (header.alg !== 'ES256K')
return { valid: false, reason: 'unsupported_alg' }
let rs: Uint8Array
try {
rs = base64url.decode(s)
}
catch {
return { valid: false, reason: 'malformed_jws' }
}
if (rs.length !== 64)
return { valid: false, reason: 'malformed_jws' }
const { PublicKey, Signature } = await import('@wharfkit/antelope')
const signingInput = new TextEncoder().encode(`${h}.${p}`)
const sig = Signature.from({ type: 'K1', r: rs.slice(0, 32), s: rs.slice(32, 64), recid: 0 })
let signedUnderRotatedKey = false
for (const w of params.authority) {
let ok = false
try {
ok = sig.verifyMessage(signingInput, PublicKey.from(w.public_key))
}
catch {
ok = false // некорректный ключ в хронологии — пропускаем, не валим всю проверку
}
if (!ok)
continue
const active = params.signedAtBlock >= w.valid_from_block
&& (w.valid_to_block === null || params.signedAtBlock <= w.valid_to_block)
if (active)
return { valid: true, matched_key: w.public_key }
// подпись валидна, но этот ключ не был активен в момент подписи — продолжаем
// искать (вдруг другой ключ окна подходит); запоминаем для точной причины.
signedUnderRotatedKey = true
}
return {
valid: false,
reason: signedUnderRotatedKey ? 'key_not_active_at_signing' : 'signature_mismatch',
}
}
@@ -0,0 +1,7 @@
/**
* Публичные RPC-ноды COOPOS — fallback для чтения ключей/manifest'ов,
* когда нода кооператива недоступна (используется начиная со Story 9.5).
*
* TODO(Story 9.5): наполнить актуальным списком публичных нод сети.
*/
export const COOPOS_PUBLIC_NODES: readonly string[] = []
@@ -0,0 +1,13 @@
/**
* Embedded trust anchor цепочки доверия: публичный ключ `ano.cert` в
* Antelope-формате (`PUB_K1_…`, кривая secp256k1). Якорь вшивается в пакет
* (release-pinned), чтобы `verifyOffline()` укоренял `coop_chain` без сети.
*
* Формат — Antelope-строка, а не JWK: вся цепь (`coop_chain`, `readCertPublicKey`,
* wharfkit-верификация) оперирует `PUB_K1_…`; JWK был несравним со звеньями
* (реконсиляция скелета 1.2 ↔ реальности, Story 4.4).
*
* TODO(release): подставить реальный ключ после создания permission `cert` на
* аккаунте `ano` в COOPOS (hash-pinned bundle в consuming-приложениях, NFR).
*/
export const TRUST_ANCHOR_ANO_CERT_PUBKEY: string | null = null
+223
View File
@@ -0,0 +1,223 @@
/**
* Зеркало enum'а ошибок auth-v2 контроллера (источник истины — controller,
* появится в Story 1.11). Расширяется синхронно с серверной стороной.
*/
export enum AuthV2ErrorCode {
NotImplemented = 'not_implemented',
InvalidCredentials = 'invalid_credentials',
WeakPassword = 'weak_password',
SessionBindingReused = 'session_binding_reused',
SessionBindingExpired = 'session_binding_expired',
TimestampTooOld = 'timestamp_too_old',
VaultDecryptionFailed = 'vault_decryption_failed',
CertificateExpired = 'certificate_expired',
CertificateRevoked = 'certificate_revoked',
ChainVerificationFailed = 'chain_verification_failed',
CooposDegraded = 'coopos_degraded',
TooManyAttempts = 'too_many_attempts',
TooManyRecoveryAttempts = 'too_many_recovery_attempts',
InvalidTwoFactorCode = 'invalid_2fa_code',
TwoFactorNotEnrolled = 'two_factor_not_enrolled',
InvalidRecoveryToken = 'invalid_recovery_token',
InvalidOfflineCode = 'invalid_offline_code',
InsufficientVerification = 'insufficient_verification',
NetworkError = 'network_error',
WalletLocked = 'wallet_locked',
ClientWalletMismatch = 'client_wallet_mismatch',
/** Клиентский код: high-stakes-действие (экспорт удостоверения с PII) не подтверждено. */
ConsentRequired = 'consent_required',
}
/**
* Ошибки SDK в формате OAuth 2.0 ({ error, error_description }): код — машинный,
* description — человеко-читаемое сообщение для UI.
*/
export class AuthV2Error extends Error {
readonly code: AuthV2ErrorCode
constructor(code: AuthV2ErrorCode, description: string) {
super(description)
this.name = 'AuthV2Error'
this.code = code
}
toJSON(): { error: AuthV2ErrorCode, error_description: string } {
return { error: this.code, error_description: this.message }
}
}
/** Внутренний помощник для каркаса: единообразный отказ нереализованных методов. */
export function notImplemented(method: string): never {
throw new AuthV2Error(
AuthV2ErrorCode.NotImplemented,
`Метод ${method}() ещё не реализован: скелет SDK (Story 1.2), реализация приходит историями Эпиков 1–2.`,
)
}
/**
* Рекомендованное действие для UI при ошибке — определяет, какую кнопку/подсказку
* показать пайщику (Story 1.11).
*/
export type AuthV2ErrorAction =
| 'retry' // повторить ввод/вход
| 'recover' // предложить восстановление доступа («Восстановить доступ»)
| 'check_connection' // проверить интернет
| 'contact_support' // обратиться в кооператив
| 'none'
/** Описание ошибки для отображения: сообщение + действие + судьба сессии. */
export interface AuthV2ErrorView {
/** машинный код (null — неожиданная, не-типизированная ошибка) */
code: AuthV2ErrorCode | null
/** человеко-читаемое сообщение для UI */
message: string
/** что предложить пайщику */
action: AuthV2ErrorAction
/**
* Сохранять ли текущую сессию. `true` — НЕ разлогинивать (сбой временный/внешний:
* нет связи, кооператив недоступен, кошелёк заперт) — NFR20/FR50: оффлайн не
* выкидывает пайщика. `false` — сбой требует повторного входа.
*/
keepSession: boolean
}
type AuthV2ErrorViewBody = Omit<AuthV2ErrorView, 'code'>
/**
* Каталог человеко-читаемых сообщений по кодам ошибок (Story 1.11). Источник
* UI-текстов — здесь; контроллер отдаёт только машинный `error`, клиент по нему
* выбирает сообщение и actionable-подсказку. Тексты — по-русски, без раскрытия
* технических деталей (например, какое именно поле логина неверно — security).
*/
export const AUTH_V2_ERROR_VIEWS: Record<AuthV2ErrorCode, AuthV2ErrorViewBody> = {
[AuthV2ErrorCode.NotImplemented]: {
message: 'Функция пока недоступна.',
action: 'none',
keepSession: true,
},
[AuthV2ErrorCode.InvalidCredentials]: {
message: 'Неверный email или пароль.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.WeakPassword]: {
message: 'Пароль слишком простой. Используйте не менее 12 символов и не повторяйте типичные сочетания.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.SessionBindingReused]: {
message: 'Сессия входа уже использована. Войдите заново.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.SessionBindingExpired]: {
message: 'Время на подтверждение входа истекло. Войдите заново.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.TimestampTooOld]: {
message: 'Истекло время на подтверждение входа. Повторите попытку.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.VaultDecryptionFailed]: {
message: 'Не удалось расшифровать кошелёк. Попробуйте восстановить доступ.',
action: 'recover',
keepSession: false,
},
[AuthV2ErrorCode.CertificateExpired]: {
message: 'Срок действия удостоверения истёк. Войдите заново, чтобы обновить его.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.CertificateRevoked]: {
message: 'Удостоверение отозвано. Обратитесь в кооператив.',
action: 'contact_support',
keepSession: false,
},
[AuthV2ErrorCode.ChainVerificationFailed]: {
message: 'Не удалось подтвердить подпись. Обратитесь в поддержку кооператива.',
action: 'contact_support',
keepSession: false,
},
[AuthV2ErrorCode.CooposDegraded]: {
message: 'Кооператив временно недоступен. Повторите попытку позже.',
action: 'retry',
keepSession: true,
},
[AuthV2ErrorCode.TooManyAttempts]: {
message: 'Слишком много попыток. Подождите немного и попробуйте снова.',
action: 'retry',
// временный троттлинг — не разлогиниваем пайщика, просто просим подождать.
keepSession: true,
},
[AuthV2ErrorCode.TooManyRecoveryAttempts]: {
message: 'Слишком много запросов на восстановление. Подождите и попробуйте позже.',
action: 'retry',
keepSession: true,
},
[AuthV2ErrorCode.InvalidTwoFactorCode]: {
message: 'Неверный код из приложения-аутентификатора. Проверьте код и попробуйте снова.',
action: 'retry',
keepSession: true,
},
[AuthV2ErrorCode.TwoFactorNotEnrolled]: {
message: 'Второй фактор не подключён.',
action: 'retry',
keepSession: true,
},
[AuthV2ErrorCode.InvalidRecoveryToken]: {
message: 'Ссылка восстановления недействительна или истекла. Запросите восстановление заново.',
action: 'recover',
keepSession: false,
},
[AuthV2ErrorCode.InvalidOfflineCode]: {
message: 'Код восстановления неверен или уже использован.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.InsufficientVerification]: {
message: 'Недостаточный уровень верификации для этого действия. Обратитесь в кооператив.',
action: 'contact_support',
// авторизационное ограничение по уровню доверия — сессия валидна, не разлогиниваем.
keepSession: true,
},
[AuthV2ErrorCode.NetworkError]: {
message: 'Нет связи с кооперативом. Проверьте интернет.',
action: 'check_connection',
keepSession: true,
},
[AuthV2ErrorCode.WalletLocked]: {
message: 'Кошелёк заблокирован. Введите пароль для доступа к ключу.',
action: 'retry',
keepSession: true,
},
[AuthV2ErrorCode.ClientWalletMismatch]: {
message: 'Ключ в этом браузере не соответствует аккаунту. Войдите заново.',
action: 'retry',
keepSession: false,
},
[AuthV2ErrorCode.ConsentRequired]: {
message: 'Для экспорта удостоверения нужно подтверждение.',
action: 'retry',
keepSession: true,
},
}
/** Безопасный фолбэк для неожиданной (не-AuthV2) ошибки — без утечки технических деталей. */
const GENERIC_ERROR_VIEW: AuthV2ErrorViewBody = {
message: 'Не удалось выполнить операцию. Попробуйте ещё раз.',
action: 'retry',
keepSession: true,
}
/**
* Превратить любую брошенную SDK ошибку в готовое к показу описание (Story 1.11):
* человеко-читаемое сообщение, рекомендованное действие и флаг сохранения сессии.
* Не-типизированные ошибки сводятся к безопасному фолбэку (не показываем `e.message`).
*/
export function describeAuthV2Error(error: unknown): AuthV2ErrorView {
if (error instanceof AuthV2Error)
return { code: error.code, ...AUTH_V2_ERROR_VIEWS[error.code] }
return { code: null, ...GENERIC_ERROR_VIEW }
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Экспорт удостоверения наружу (QR для Vision/предъявления). В MVP существует
* только ПОЛНАЯ форма (`full_certificate`) — она несёт PII пайщика, поэтому
* огорожена двумя барьерами (разблокированный vault + явное согласие).
*
* ROADMAP (Growth): анонимная форма `proof_of_membership` — доказательство членства
* БЕЗ PII, с коротким сроком (`exp ≤ 24ч`). Она появится отдельным методом
* `exportProofQR()`. В MVP его НЕТ намеренно: пока anonymous-формат не существует,
* вызвать `exportProofQR()` нельзя (ошибка компиляции) — чтобы по ошибке не выпустить
* PII-полный сертификат под видом anonymous.
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { isWalletUnlocked } from '../wallet'
export interface ExportFullQROptions {
/**
* Колбэк high-stakes-consent диалога: хост показывает пайщику явное подтверждение
* («вы экспортируете удостоверение с персональными данными») и резолвит `true`,
* только если пайщик согласился. `false`/reject → экспорт отклоняется.
*/
confirm: () => Promise<boolean>
}
/**
* Экспорт ПОЛНОГО participant_certificate (с PII) для предъявления/QR. Доступен
* только когда (1) vault разблокирован — экспортирует владелец активной сессии, не
* случайный держатель запертого устройства; (2) пайщик явно подтвердил high-stakes
* consent. Возвращает payload-байты удостоверения; кодирование в визуальный QR —
* на стороне хоста/Vision (SDK кросс-рантайм, QR-рендерер не тянет).
*/
export async function exportFullQR(certificate: string, options: ExportFullQROptions): Promise<Uint8Array> {
if (!isWalletUnlocked())
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Экспорт удостоверения возможен только при разблокированном кошельке')
const consented = await options.confirm().catch(() => false)
if (!consented)
throw new AuthV2Error(AuthV2ErrorCode.ConsentRequired, 'Экспорт удостоверения с персональными данными требует явного подтверждения')
return new TextEncoder().encode(certificate)
}
+49
View File
@@ -0,0 +1,49 @@
export type { CertificateRenewalHandle, CertificateStatus, CoopChainLink, CoopIdSchemaPolicy, ParticipantCertificateClaims, ScheduleCertificateRenewalOptions, SchemaPolicyCache, SchemaPolicyCacheOptions, VerificationTypeClaim } from './certificate'
export {
CERTIFICATE_EXPIRING_WINDOW_MS,
CERTIFICATE_RENEWAL_LEAD_MS,
certificateStatus,
compareSchemaVersions,
computeRenewalDelayMs,
createSchemaPolicyCache,
decodeParticipantCertificate,
isSchemaVersionSupported,
scheduleCertificateRenewal,
SCHEMA_POLICY_CACHE_TTL_MS,
SCHEMA_POLICY_WELL_KNOWN_PATH,
VERIFICATION_TYPE_LABELS,
verificationTypeLabel,
} from './certificate'
export type { KeyValidityWindow, VerifyDocumentParams, VerifyDocumentReason, VerifyDocumentResult, VerifyOfflineOptions, VerifyOfflineReason, VerifyOfflineResult } from './chain'
export { readCertPublicKey, verifyDocumentOffline, verifyOffline } from './chain'
/**
* @coopenomics/auth — SDK аутентификации CoopID.
*
* Публичная поверхность зафиксирована скелетом (Story 1.2): все методы
* типизированы и бросают AuthV2Error(not_implemented) до своей реализации.
*/
export { COOPOS_PUBLIC_NODES } from './config/coopos-public-nodes'
export { TRUST_ANCHOR_ANO_CERT_PUBKEY } from './config/trust-anchor'
export { AUTH_V2_ERROR_VIEWS, AuthV2Error, AuthV2ErrorCode, describeAuthV2Error } from './errors'
export type { AuthV2ErrorAction, AuthV2ErrorView } from './errors'
export type { ExportFullQROptions } from './export'
export { exportFullQR } from './export'
export type { MigrateParams } from './migration'
export { canonicalMigrationMessage, migrate } from './migration'
export type { FlowExecutorParams, HandshakeResult, LoginParams, LoginResult, LogoutParams, SessionTokens } from './oidc'
export { authenticateWithFlowExecutor, configureCoopId, configureOidc, configureTokenStorage, currentTokens, DEFAULT_AUTHENTICATION_FLOW, getAccessToken, getParticipantCertificate, login, loginWithMagicLink, logout, performTimestampHandshake, recover, restoreSession } from './oidc'
export type { SignDocumentParams, TimestampSignature } from './signing'
export { canonicalTimestampMessage, signChainDigest, signDocument, signTimestamp } from './signing'
export type { EncryptedVaultBlob, VaultSubject } from './vault'
export { ARGON2ID_PARAMS, decryptPrivateKey, deriveKey, encryptPrivateKey } from './vault'
export type { StorageAdapter } from './wallet'
export { clearLocalVault, clearPinCache, DEFAULT_PIN, fetchVaultBlob, getWallet, hasPinCache, isWalletUnlocked, loadLocalVault, lockWallet, persistPinCache, rotateKey, saveLocalVault, saveToVault, storeVaultBlob, unlockWallet, unlockWithPin, Wallet } from './wallet'
+90
View File
@@ -0,0 +1,90 @@
/**
* Миграция действующего пайщика «ключ → пароль» (Story 11.4), клиентская сторона.
*
* Пайщик сейчас владеет только WIF (легаси, без пароля). `migrate` доказывает
* владение ключом подписью метки времени (как легаси `login`), сервер проверяет
* её против он-чейн active-permission и ставит пароль в authentik (Story 11.1).
* Затем клиент шифрует ТОТ ЖЕ WIF новым паролём в vault (Story 11.3) — приватный
* ключ на сервер не уходит. Идемпотентно (повтор с тем же ключом/паролём безопасен).
*
* Подпись биндится к новому паролю через `pw_hash` в каноническом сообщении:
* перехваченный proof нельзя переиспользовать с другим паролём.
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { coopIdApiUrl } from '../oidc/client'
import { saveToVault, type StorageAdapter } from '../wallet'
export interface MigrateParams {
email: string
/** WIF, которым пайщик владеет сейчас (легаси-ключ из поля входа). */
privateKey: string
newPassword: string
/** Если задано — сохранить локальную копию зашифрованного vault'а на устройстве. */
storage?: StorageAdapter
}
/**
* Каноническое сообщение proof'а миграции — **зеркало** серверного
* `canonicalMigrationMessage` (controller). Ключи в фиксированном алфавитном
* порядке: клиент и сервер собирают строку байт-в-байт, иначе `recoverPublicKey`
* на сервере восстановит чужой ключ. `pw_hash` — sha256-hex нового пароля.
*/
export function canonicalMigrationMessage(payload: { ts: string, pw_hash: string }): string {
return JSON.stringify({ pw_hash: payload.pw_hash, purpose: 'coopid-key-migration', ts: payload.ts })
}
/** sha256-hex (lowercase) — байт-в-байт совпадает с серверным createHash('sha256').digest('hex'). */
async function sha256Hex(input: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('')
}
/** Разобрать OAuth2-ошибку контроллера ({ error, error_description }) в AuthV2Error. */
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
return new AuthV2Error((body?.error as AuthV2ErrorCode) ?? fallback, body?.error_description ?? fallbackMsg)
}
/**
* Выполнить миграцию «ключ → пароль». Возвращает `{ username }` (резолвится
* сервером по email; нужен как subject vault'а). Бросает `AuthV2Error`:
* InvalidCredentials (неверный ключ/email/подпись), WeakPassword, TimestampTooOld,
* CooposDegraded, NetworkError.
*/
export async function migrate(params: MigrateParams): Promise<{ username: string }> {
const apiUrl = coopIdApiUrl()
const ts = new Date().toISOString()
const message = canonicalMigrationMessage({ ts, pw_hash: await sha256Hex(params.newPassword) })
// Подпись текущим ключом (recoverable SIG_K1_). Невалидный WIF → понятная ошибка.
const { PrivateKey } = await import('@wharfkit/antelope')
let signature: string
try {
signature = PrivateKey.from(params.privateKey).signMessage(new TextEncoder().encode(message)).toString()
}
catch {
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Некорректный приватный ключ')
}
let res: Response
try {
res = await fetch(`${apiUrl}/coop/migration`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: params.email, timestamp: ts, signature, new_password: params.newPassword }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при миграции: ${e instanceof Error ? e.message : String(e)}`)
}
if (!res.ok)
throw await authErrorFromResponse(res, AuthV2ErrorCode.InvalidCredentials, `Миграция отклонена (HTTP ${res.status})`)
const body = (await res.json()) as { username?: string }
if (!body?.username)
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Миграция не вернула username')
// Зашифровать текущий WIF новым паролём → server vault (обязательно) + локальная копия.
await saveToVault({ apiUrl, account: body.username, privateKey: params.privateKey, password: params.newPassword, storage: params.storage })
return { username: body.username }
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Первый этап входа CoopID (Story 11.2) — password через authentik:
* (1) встроенная форма гонит email+password в flow-executor authentik и
* устанавливает сессию (`flow-executor.ts`); (2) `oidc-client-ts` выполняет
* `authorization_code`+PKCE МОЛЧА (`signinSilent`, prompt=none) — сессия уже есть,
* второго ввода пароля нет. Так клиент видит пароль (нужно для vault, Story 11.3),
* не нарушая FR29: запрещённый `grant_type=password` не используется, грант —
* стандартный authorization_code+PKCE (Implicit и ROPC запрещены, RFC 9700).
*
* Конфигурация (client_id/redirect_uri/scope, база controller'а) задаётся
* приложением на старте через `configureOidc`/`configureCoopId` — это среда-зависимые
* параметры (frontend кооператива), не зашитые в SDK.
*/
import { type User, UserManager, type UserManagerSettings } from 'oidc-client-ts'
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { authenticateWithFlowExecutor } from './flow-executor'
interface OidcClientConfig {
clientId: string
redirectUri: string
scope: string
postLogoutRedirectUri?: string
/** redirect_uri скрытого silent-iframe для prompt=none authorize (Story 11.2). */
silentRedirectUri?: string
}
let oidcConfig: OidcClientConfig | null = null
let coopApiUrl: string | null = null
/** Конфигурация OIDC-клиента authentik (вызывается приложением на старте). */
export function configureOidc(config: { clientId: string, redirectUri: string, scope?: string, postLogoutRedirectUri?: string, silentRedirectUri?: string }): void {
oidcConfig = {
clientId: config.clientId,
redirectUri: config.redirectUri,
scope: config.scope ?? 'openid profile',
postLogoutRedirectUri: config.postLogoutRedirectUri,
silentRedirectUri: config.silentRedirectUri,
}
}
/** База URL controller'а кооператива (для второго этапа: bind/verify/refresh). */
export function configureCoopId(config: { apiUrl: string }): void {
coopApiUrl = config.apiUrl.replace(/\/$/, '')
}
/** База controller'а или явная ошибка конфигурации. */
export function coopIdApiUrl(): string {
if (!coopApiUrl)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, 'CoopID не сконфигурирован: вызовите configureCoopId({ apiUrl }) на старте приложения')
return coopApiUrl
}
function userManager(issuer: string): UserManager {
if (!oidcConfig)
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'OIDC не сконфигурирован: вызовите configureOidc({ clientId, redirectUri }) на старте приложения')
const settings: UserManagerSettings = {
authority: issuer.replace(/\/$/, ''),
client_id: oidcConfig.clientId,
redirect_uri: oidcConfig.redirectUri,
silent_redirect_uri: oidcConfig.silentRedirectUri ?? oidcConfig.redirectUri,
scope: oidcConfig.scope,
post_logout_redirect_uri: oidcConfig.postLogoutRedirectUri,
response_type: 'code', // Authorization Code + PKCE; oidc-client-ts включает PKCE по умолчанию.
}
return new UserManager(settings)
}
/**
* Проводит первый этап входа и возвращает OIDC-User (id_token/access_token +
* установленная сессионная cookie authentik, нужная для bind). Story 11.2:
* (1) встроенная форма → flow-executor authentik устанавливает сессию (фактор 1);
* (2) `signinSilent` выполняет authorization_code+PKCE молча (сессия уже есть,
* prompt=none через скрытый iframe `silent_redirect_uri`) — без попапа и без
* повторного ввода пароля.
*/
export async function authenticateWithAuthentik(params: { issuer: string, email: string, password: string, flowSlug?: string }): Promise<User> {
// Фактор-1: учётные данные уходят в authentik (а не в наш backend) — ROPC не используется.
await authenticateWithFlowExecutor({ issuer: params.issuer, email: params.email, password: params.password, flowSlug: params.flowSlug })
// Грант FR29: code+PKCE поверх уже установленной сессии, без интерактивного UI.
const um = userManager(params.issuer)
let user: User | null
try {
user = await um.signinSilent()
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, `Не удалось завершить вход через authentik: ${e instanceof Error ? e.message : String(e)}`)
}
if (!user)
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'authentik не вернул сессию после ввода пароля')
return user
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Story 11.2 — встроенный фактор-1 входа CoopID через flow-executor authentik.
*
* Зачем НЕ popup/redirect: desktop рисует СВОЮ форму email+password и гонит
* учётные данные в интерактивный flow-API authentik. Так клиент пайщика ВИДИТ
* пароль — это нужно, чтобы шифровать им password-vault (Story 11.3); требование
* вынуждено zero-knowledge-инвариантом vault'а (сервер хранит шифр и расшифровать
* не может → ключ шифрования обязан быть клиентским секретом, единственный такой
* секрет — пароль). FR29 при этом соблюдается: запрещённый `grant_type=password`
* НЕ используется — грант остаётся `authorization_code`+PKCE и выполняется в
* `client.ts` уже ПОСЛЕ установки сессии (см. `authenticateWithAuthentik`).
*
* Топология (Эпик 5, Caddyfile): flow-API доступен same-origin по
* `{issuer.origin}/api/v3/flows/executor/<slug>/` — Caddy catch-all проксирует всё
* на authentik под доменом кооператива. `credentials:'include'` доставляет и
* устанавливает сессионную cookie authentik; CSRF-cookie (`authentik_csrf`) эхо-
* отправляется в заголовке `X-authentik-CSRF` (authentik требует его на unsafe-
* методах API). Same-origin критичен: кросс-домен CSRF-cookie не прочитать, а
* сессионная cookie не доедет до последующего authorize.
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
/** Slug стандартного flow аутентификации authentik (identification → password). */
export const DEFAULT_AUTHENTICATION_FLOW = 'default-authentication-flow'
/** Предохранитель от зацикливания на неожиданной последовательности стадий. */
const MAX_FLOW_STEPS = 8
export interface FlowExecutorParams {
/** Issuer кооператива (`https://coop.example/application/o/coopid/`); origin — база flow-API. */
issuer: string
email: string
password: string
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
flowSlug?: string
}
/** Снимок challenge'а flow-executor (берём только нужные поля; authentik отдаёт больше). */
interface FlowChallenge {
/** `native` | `redirect` | `shell` — `redirect` означает завершение flow (сессия установлена). */
type?: string
/** Web-компонент текущей стадии: `ak-stage-identification`, `ak-stage-password`, … */
component?: string
/** Пер-полевые ошибки валидации (неверный пароль → перерисованная стадия с этим полем). */
response_errors?: Record<string, Array<{ string?: string, code?: string }>>
}
/** Текущий CSRF-токен authentik из cookie (same-origin). В Node/тестах — пусто. */
function csrfHeader(): Record<string, string> {
if (typeof document === 'undefined' || !document.cookie)
return {}
const match = document.cookie.match(/(?:^|;\s*)authentik_csrf=([^;]+)/)
return match ? { 'X-authentik-CSRF': decodeURIComponent(match[1]) } : {}
}
/** URL шага flow-executor. `query` пуст: flow запускается напрямую (authorize делает client.ts). */
function flowUrl(base: string, slug: string): string {
return `${base}/api/v3/flows/executor/${encodeURIComponent(slug)}/?query=${encodeURIComponent('')}`
}
/** Завершение flow: сессия authentik установлена (redirect-терминал или стадия user-login). */
function isSuccess(c: FlowChallenge): boolean {
return c.type === 'redirect' || c.component === 'xak-flow-redirect' || c.component === 'ak-stage-user-login'
}
/** Доступ отклонён политикой flow (например, аккаунт неактивен). */
function isAccessDenied(c: FlowChallenge): boolean {
return c.component === 'ak-stage-access-denied'
}
/** Первое человеко-читаемое сообщение об ошибке валидации из challenge'а (если есть). */
function firstResponseError(c: FlowChallenge): string | null {
const groups = c.response_errors
if (!groups)
return null
for (const key of Object.keys(groups)) {
const msg = groups[key]?.find(e => e.string)?.string
if (msg)
return msg
}
return Object.keys(groups).length > 0 ? '' : null
}
async function getChallenge(url: string): Promise<FlowChallenge> {
let res: Response
try {
res = await fetch(url, { method: 'GET', credentials: 'include', headers: { accept: 'application/json', ...csrfHeader() } })
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на старте входа: ${e instanceof Error ? e.message : String(e)}`)
}
if (!res.ok)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `flow-executor (старт) вернул HTTP ${res.status}`)
return (await res.json()) as FlowChallenge
}
async function postChallenge(url: string, body: Record<string, unknown>): Promise<FlowChallenge> {
let res: Response
try {
res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json', 'accept': 'application/json', ...csrfHeader() },
body: JSON.stringify(body),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при отправке формы входа: ${e instanceof Error ? e.message : String(e)}`)
}
// 400 — authentik перерисовывает ту же стадию с `response_errors` (неверный пароль);
// это валидное тело challenge'а, читаем его. Остальные не-ok — сетевая/конфиг ошибка.
if (!res.ok && res.status !== 400)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `flow-executor вернул HTTP ${res.status}`)
return (await res.json()) as FlowChallenge
}
/**
* Проводит фактор-1 (email+password) через flow-executor authentik и устанавливает
* сессионную cookie. Возвращает `void` при успехе (сессия — побочный эффект cookie),
* бросает `AuthV2Error(InvalidCredentials)` при неверных учётных данных / отказе и
* `AuthV2Error(NetworkError)` при сетевом сбое.
*
* Поддерживаются стандартные стадии `ak-stage-identification` и `ak-stage-password`
* (в т.ч. совмещённая identification с `password_fields`). Интерактивные стадии
* вне scope встроенной формы (MFA-валидация, consent) → InvalidCredentials с
* пояснением: их обрабатывает отдельный эпик динамического 2FA.
*/
export async function authenticateWithFlowExecutor(params: FlowExecutorParams): Promise<void> {
const base = new URL(params.issuer).origin
const url = flowUrl(base, params.flowSlug ?? DEFAULT_AUTHENTICATION_FLOW)
let challenge = await getChallenge(url)
for (let step = 0; step < MAX_FLOW_STEPS; step++) {
if (isSuccess(challenge))
return
if (isAccessDenied(challenge))
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Доступ запрещён: проверьте email и пароль')
const err = firstResponseError(challenge)
if (err !== null)
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, err || 'Неверный email или пароль')
switch (challenge.component) {
case 'ak-stage-identification':
// Совмещённая identification может ждать и пароль (`password_fields`) — отдаём оба.
challenge = await postChallenge(url, { uid_field: params.email, password: params.password })
break
case 'ak-stage-password':
challenge = await postChallenge(url, { password: params.password })
break
default:
throw new AuthV2Error(
AuthV2ErrorCode.InvalidCredentials,
`Стадия входа «${challenge.component ?? challenge.type ?? 'неизвестно'}» не поддерживается встроенной формой`,
)
}
}
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Вход не завершён: превышено число шагов flow authentik')
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Второй этап входа CoopID — timestamp-signature handshake (Story 1.7), браузер-
* агностичная часть фасада логина. Предполагает уже установленную сессию authentik
* (cookie доезжает на bind через `credentials: 'include'`); первый этап (password,
* Authorization Code + PKCE через oidc-client-ts, FR29) делает `client.ts`.
*
* Поток: `/coop/session/bind` → `signTimestamp` (ключ берётся из keystore, наружу
* не выходит) → `/coop/verify/timestamp`. Полученные токены кладутся в сессию
* (`tokens.ts`) и возвращаются вызывающему.
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { signTimestamp } from '../signing'
import { setSession } from './tokens'
export interface HandshakeResult {
accessToken: string
refreshToken: string
/** compact JWS ES256K, выпускается controller'ом (Story 1.8); может отсутствовать. */
participantCertificate?: string
/** true → вход выдан в degraded-режиме (узел недоступен / ключ не финализирован, Story 9.6). */
degraded: boolean
degradedReason?: string
}
interface BindResponse {
binding_token: string
expires_in: number
}
interface VerifyResponse {
access_token: string
refresh_token: string
participant_certificate?: string
degraded?: boolean
degraded_reason?: string
}
/** Извлечь AuthV2Error из тела ответа контроллера (OAuth2-формат { error, error_description }). */
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
const code = (body?.error as AuthV2ErrorCode) ?? fallback
return new AuthV2Error(code, body?.error_description ?? fallbackMsg)
}
/**
* Выполняет handshake второго этапа и сохраняет сессию. `apiUrl` — база controller'а
* кооператива (например `https://coop.example`).
*/
export async function performTimestampHandshake(apiUrl: string): Promise<HandshakeResult> {
const base = apiUrl.replace(/\/$/, '')
// 1. bind: session_binding_token из тела (Эпик 7, D2). credentials:'include' —
// чтобы сессионная cookie authentik доехала до controller'а для резолва username.
let bindRes: Response
try {
bindRes = await fetch(`${base}/coop/session/bind`, { method: 'POST', credentials: 'include' })
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на этапе bind: ${e instanceof Error ? e.message : String(e)}`)
}
if (bindRes.status === 401 || bindRes.status === 403)
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Сессия authentik не подтверждена: пройдите первый этап входа (password)')
if (!bindRes.ok)
throw await authErrorFromResponse(bindRes, AuthV2ErrorCode.NetworkError, `bind вернул HTTP ${bindRes.status}`)
const bind = (await bindRes.json()) as BindResponse
if (!bind?.binding_token)
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'bind не вернул session_binding_token')
// 2. подпись канонической метки (jti/sub — из binding_token; ключ — из keystore,
// бросит WalletLocked, если кошелёк заперт).
const sig = await signTimestamp({ sessionBindingToken: bind.binding_token })
// 3. verify: доказательство владения ключом → платформенные токены + сертификат.
let verifyRes: Response
try {
verifyRes = await fetch(`${base}/coop/verify/timestamp`, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ signature: sig.signature, timestamp: sig.ts, binding_token: bind.binding_token }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на этапе verify: ${e instanceof Error ? e.message : String(e)}`)
}
if (!verifyRes.ok)
throw await authErrorFromResponse(verifyRes, AuthV2ErrorCode.ChainVerificationFailed, `verify вернул HTTP ${verifyRes.status}`)
const v = (await verifyRes.json()) as VerifyResponse
setSession(base, { accessToken: v.access_token, refreshToken: v.refresh_token })
return {
accessToken: v.access_token,
refreshToken: v.refresh_token,
participantCertificate: v.participant_certificate,
degraded: Boolean(v.degraded),
degradedReason: v.degraded_reason,
}
}
+248
View File
@@ -0,0 +1,248 @@
import type { VaultSubject } from '../vault/types'
import type { StorageAdapter } from '../wallet'
/**
* OIDC-слой: вход через authentik (password + timestamp-signature),
* magic-link, recovery и работа с токенами (oidc-client-ts).
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { encryptPrivateKey } from '../vault/encrypt'
import { lockWallet, saveLocalVault, unlockWallet } from '../wallet'
import { authenticateWithAuthentik, coopIdApiUrl } from './client'
import { performTimestampHandshake } from './handshake'
import { clearSession, getAccessToken as getStoredAccessToken } from './tokens'
export { authenticateWithAuthentik, configureCoopId, configureOidc } from './client'
export { authenticateWithFlowExecutor, DEFAULT_AUTHENTICATION_FLOW } from './flow-executor'
export type { FlowExecutorParams } from './flow-executor'
export type { HandshakeResult } from './handshake'
export { performTimestampHandshake } from './handshake'
export type { SessionTokens } from './tokens'
export { configureTokenStorage, currentTokens, restoreSession } from './tokens'
export interface LoginParams {
/** Issuer кооператива, например `https://coop.example/application/o/coopid/` */
issuer: string
email: string
/**
* Пароль пайщика (Story 11.2). Уходит во встроенную форму → flow-executor
* authentik (фактор 1), а не в наш backend — запрещённый FR29 `grant_type=password`
* не используется. Тем же паролем клиент шифрует password-vault (Story 11.3).
*/
password: string
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
flowSlug?: string
}
export interface LoginResult {
accessToken: string
idToken: string
/** compact JWS ES256K, выпускается controller'ом */
participantCertificate: string
}
/**
* Двухэтапный вход (Story 1.7, обновлён Story 11.2): (1) password через authentik —
* встроенная форма гонит email+password в flow-executor (сессия), затем
* `authorization_code`+PKCE молча (`signinSilent`); (2) timestamp-signature handshake
* против controller'а (bind → подпись ключом из keystore → verify). Перед вызовом
* кошелёк должен быть разблокирован (`unlockWallet`), иначе handshake бросит WalletLocked.
*
* База controller'а берётся из `configureCoopId({ apiUrl })`, OIDC-клиент — из
* `configureOidc({ clientId, redirectUri })` (вызываются приложением на старте).
*/
export async function login(params: LoginParams): Promise<LoginResult> {
const apiUrl = coopIdApiUrl()
// 1. password-этап: устанавливает сессию authentik (cookie) + отдаёт id_token.
const user = await authenticateWithAuthentik({ issuer: params.issuer, email: params.email, password: params.password, flowSlug: params.flowSlug })
// 2. timestamp-signature handshake: платформенные токены + удостоверение.
const handshake = await performTimestampHandshake(apiUrl)
return {
accessToken: handshake.accessToken,
idToken: user.id_token ?? '',
participantCertificate: handshake.participantCertificate ?? '',
}
}
/** Разобрать OAuth2-ошибку контроллера ({ error, error_description }) в AuthV2Error. */
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
return new AuthV2Error((body?.error as AuthV2ErrorCode) ?? fallback, body?.error_description ?? fallbackMsg)
}
export interface LoginWithMagicLinkParams {
/** Issuer кооператива — для повторного входа через authentik уже новым паролём. */
issuer: string
/** Email пайщика — фактор-1 повторной аутентификации в authentik после смены ключа. */
email: string
/** Magic-link токен из ссылки восстановления (или `recovery_token` offline-канала, Story 3.4). */
token: string
/** TOTP-код из приложения-аутентификатора — второй фактор подтверждения (Story 3.2/3.6). */
totp: string
/** Новый пароль: им шифруется новый vault и он же ставится в authentik (Story 12.1). */
newPassword: string
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
flowSlug?: string
/** Если задано — сохранить локальную копию нового зашифрованного vault'а на устройстве. */
storage?: StorageAdapter
}
/**
* Вход по magic-link (восстановление доступа) — полный confirm-флоу + повторный вход
* (Эпик 12, Story 12.2). Старый ключ пайщиком утрачен (на то и восстановление),
* поэтому клиент генерит НОВУЮ пару: приватный шифруется новым паролём в vault и
* наружу/на сервер не уходит, on-chain едет только публичный.
*
* Account на старте неизвестен — magic-link несёт только непрозрачный `token`. Но он и
* не нужен заранее: AAD vault'а больше не зависит от account (см. `vault/encrypt.ts`),
* а username для повторного входа отдаёт сам `confirm`, резолвнув его из токена. Так
* обходимся без отдельного whoami-by-token эндпоинта.
*
* Шаги: (1) сгенерировать пару; (2) зашифровать новый ключ новым паролём (AAD=тип
* субъекта, без account); (3) `POST /coop/recovery/confirm` {token, TOTP, public_key,
* vault, password} — сервер (12.1) ставит пароль в authentik, сохраняет vault под нужным
* account, ротирует active-ключ (`registrator::changekey`), отзывает старые сессии и
* возвращает `{ username }`; (4) повторный вход новым контуром: authentik-сессия новым
* паролём → `unlockWallet` по этому username (скачать только что записанный блоб →
* расшифровать → keystore) → timestamp-handshake. Ключ к этому моменту уже ротирован
* on-chain, поэтому verify увидит новый pubkey (при лаге узла handshake вернёт degraded,
* Story 9.6).
*/
export async function loginWithMagicLink(params: LoginWithMagicLinkParams): Promise<LoginResult> {
const apiUrl = coopIdApiUrl()
// 1. Новая пара ключей (старый утрачен). WIF в формате `5J…`/`5K…` — как везде в системе.
const { PrivateKey } = await import('@wharfkit/antelope')
const newKey = PrivateKey.generate('K1')
const newPrivateKey = newKey.toWif()
const newPublicKey = newKey.toPublic().toString()
// 2. Зашифровать новый ключ новым паролём. AAD = тип субъекта (`participant`), а не
// account: id в AAD не участвует (см. vault/encrypt.ts), поэтому шифруем, не зная
// username. Сервер сохранит блоб под нужным account сам (по recovery-токену).
const subject: VaultSubject = { subject_type: 'participant', subject_id: '' }
const vaultBlob = await encryptPrivateKey(newPrivateKey, params.newPassword, subject)
// 3. confirm: токен magic-link + TOTP + новый материал. Тело — контракт RecoveryConfirmBody.
let res: Response
try {
res = await fetch(`${apiUrl}/coop/recovery/confirm`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: params.token, code: params.totp, public_key: newPublicKey, vault: vaultBlob, password: params.newPassword }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при подтверждении восстановления: ${e instanceof Error ? e.message : String(e)}`)
}
if (res.status === 429)
throw new AuthV2Error(AuthV2ErrorCode.TooManyRecoveryAttempts, 'Слишком много попыток подтверждения, попробуйте позже')
if (!res.ok)
throw await authErrorFromResponse(res, AuthV2ErrorCode.InvalidRecoveryToken, `Подтверждение восстановления отклонено (HTTP ${res.status})`)
// confirm вернул account пайщика (резолвнут из токена) — по нему скачаем и
// расшифруем только что сохранённый сервером блоб при повторном входе.
const confirmed = (await res.json().catch(() => null)) as { username?: string } | null
if (!confirmed?.username)
throw new AuthV2Error(AuthV2ErrorCode.InvalidRecoveryToken, 'Подтверждение восстановления не вернуло аккаунт')
const account = confirmed.username
// 4. Локальная копия нового блоба (best-effort на устройстве восстановления).
if (params.storage)
await saveLocalVault(params.storage, account, vaultBlob)
// 5. Повторный вход новым контуром. unlockWallet забирает только что сохранённый
// серверный блоб и расшифровывает новым паролём — заодно round-trip-проверка vault'а.
const user = await authenticateWithAuthentik({ issuer: params.issuer, email: params.email, password: params.newPassword, flowSlug: params.flowSlug })
await unlockWallet({ apiUrl, account, password: params.newPassword })
const handshake = await performTimestampHandshake(apiUrl)
return {
accessToken: handshake.accessToken,
idToken: user.id_token ?? '',
participantCertificate: handshake.participantCertificate ?? '',
}
}
/**
* Запросить восстановление доступа (magic-link на email; по стратегии кооператива
* — также offline-код). Эпик 3, `POST /coop/recovery/request`. Анти-enumeration:
* сервер всегда отвечает 202 вне зависимости от существования аккаунта.
*/
export async function recover(email: string): Promise<void> {
const base = coopIdApiUrl()
let res: Response
try {
res = await fetch(`${base}/coop/recovery/request`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе восстановления: ${e instanceof Error ? e.message : String(e)}`)
}
// 202 — нормальный путь; иные коды (кроме rate-limit) — ошибка конфигурации/сети.
if (res.status === 429)
throw new AuthV2Error(AuthV2ErrorCode.TooManyRecoveryAttempts, 'Слишком много попыток восстановления, попробуйте позже')
if (!res.ok && res.status !== 202)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Запрос восстановления отклонён (HTTP ${res.status})`)
}
/** Текущий access_token (с автообновлением через refresh). Story 1.7. */
export async function getAccessToken(): Promise<string> {
return getStoredAccessToken()
}
/**
* Актуальное participant_certificate текущей сессии — compact JWS из
* `GET /coop/certificate` (Story 1.8). `accessToken` — платформенный токен входа
* (Bearer). Декодирование claims — `decodeParticipantCertificate` (certificate/).
*/
export async function getParticipantCertificate(apiUrl: string, accessToken: string): Promise<string> {
let res: Response
try {
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/certificate`, {
headers: { authorization: `Bearer ${accessToken}` },
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе удостоверения: ${e instanceof Error ? e.message : String(e)}`)
}
if (!res.ok)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось получить удостоверение (HTTP ${res.status})`)
const body = (await res.json()) as { participant_certificate: string }
return body.participant_certificate
}
export interface LogoutParams {
/** Базовый URL controller'а кооператива (например `https://coop.example`). */
apiUrl: string
/** refresh_token текущей сессии — отзывается на сервере. */
refreshToken: string
/** access_token (опционально) — тоже отзывается. */
accessToken?: string
}
/**
* RP-initiated logout (Story 1.10): отзыв токенов на сервере + затирание локального
* keystore. Серверный вызов — best-effort; локальное затирание ключа и сессии
* выполняется ВСЕГДА (в `finally`), даже если сервер недоступен — безопасность важнее
* «чистого» logout: расшифрованный ключ не должен остаться в памяти браузера при
* сетевом сбое. Редирект на login — на стороне вызывающего. Стандартный OIDC
* end-session — Story 5.1.
*/
export async function logout(params: LogoutParams): Promise<void> {
try {
await fetch(`${params.apiUrl.replace(/\/$/, '')}/coop/logout`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ refresh_token: params.refreshToken, access_token: params.accessToken }),
})
}
catch {
// best-effort: недоступность сервера не должна блокировать локальное затирание ключа
}
finally {
lockWallet()
clearSession()
}
}
+147
View File
@@ -0,0 +1,147 @@
import type { StorageAdapter } from '../wallet/storage-adapter'
/**
* Lifecycle платформенных токенов сессии CoopID (Эпик 7). Источник истины токена —
* этот модуль `@coopenomics/auth`; `@coopenomics/sdk` копирует access в свои
* GraphQL-заголовки (D1: bearer не покидает слой SDK, приложение его не трогает).
*
* Хранение — in-memory (как keystore ключа): по умолчанию живёт только в RAM
* вкладки. Персистентность между перезагрузками подключается приложением через
* `StorageAdapter` (frontend — IndexedDB), чтобы крипто-/токен-логика не зависела
* от среды и тестировалась без браузера.
*/
import { decodeJwt } from 'jose'
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
export interface SessionTokens {
accessToken: string
refreshToken: string
}
/** Запас до фактического exp, при котором access считаем «пора обновлять». */
const REFRESH_SKEW_SEC = 30
let tokens: SessionTokens | null = null
let apiBase: string | null = null
/**
* Опциональная персистентность токенов между перезагрузками (паритет с легаси,
* у которого токены лежат в IndexedDB). По умолчанию контур RAM-only; приложение
* подключает `StorageAdapter` (frontend — IndexedDB) через `configureTokenStorage`.
* Без этого CoopID-сессия не переживала бы F5 (ключ поднимается из PIN-кэша, а
* токен терялся бы), т.е. была бы СЛАБЕЕ легаси.
*/
const TOKEN_STORAGE_KEY = 'coopid.session.tokens'
let storage: StorageAdapter | null = null
interface PersistedSession {
apiBase: string
tokens: SessionTokens
}
/** Подключает (или снимает — `null`) персистентность токенов сессии. */
export function configureTokenStorage(adapter: StorageAdapter | null): void {
storage = adapter
}
/** Кладёт токены текущей сессии (вызывается из handshake/login/refresh). `apiUrl` — база controller'а для refresh. */
export function setSession(apiUrl: string, next: SessionTokens): void {
apiBase = apiUrl.replace(/\/$/, '')
tokens = next
// best-effort персист: сбой записи не должен ронять вход (токены уже в RAM).
if (storage)
void storage.set(TOKEN_STORAGE_KEY, JSON.stringify({ apiBase, tokens: next } satisfies PersistedSession)).catch(() => undefined)
}
/**
* Восстанавливает токены сессии из персистентного хранилища на старте приложения
* (после reload). `true` — сессия поднята в RAM. Уже активная RAM-сессия не
* перетирается. Без подключённого storage или записи — `false`.
*/
export async function restoreSession(): Promise<boolean> {
if (tokens && apiBase)
return true
if (!storage)
return false
const raw = await storage.get(TOKEN_STORAGE_KEY)
if (!raw)
return false
try {
const parsed = JSON.parse(raw) as PersistedSession
if (!parsed?.apiBase || !parsed?.tokens?.accessToken || !parsed?.tokens?.refreshToken)
return false
apiBase = parsed.apiBase
tokens = parsed.tokens
return true
}
catch {
return false
}
}
/** Затирает токены сессии (logout). Идемпотентно. Стирает и персистентную копию. */
export function clearSession(): void {
tokens = null
apiBase = null
if (storage)
void storage.remove(TOKEN_STORAGE_KEY).catch(() => undefined)
}
/** Снимок текущих токенов (или null, если сессии нет). */
export function currentTokens(): SessionTokens | null {
return tokens ? { ...tokens } : null
}
/** Истёк ли (или вот-вот истечёт) access-токен. Невалидный/без exp → считаем истёкшим. */
function accessExpired(token: string): boolean {
try {
const { exp } = decodeJwt(token)
if (typeof exp !== 'number')
return true
return exp - REFRESH_SKEW_SEC <= Math.floor(Date.now() / 1000)
}
catch {
return true
}
}
/**
* Обновляет пару токенов через REST `/coop/refresh` (то же зеркало платформенной
* токен-машинерии, что и legacy GraphQL-refresh). 401 → сессия истекла/отозвана.
*/
export async function refreshSession(base: string, refreshToken: string): Promise<SessionTokens> {
let res: Response
try {
res = await fetch(`${base}/coop/refresh`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при обновлении токена: ${e instanceof Error ? e.message : String(e)}`)
}
if (res.status === 401 || res.status === 403)
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'Сессия истекла: требуется повторный вход')
if (!res.ok)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось обновить токен (HTTP ${res.status})`)
const body = (await res.json()) as { access_token: string, refresh_token: string }
return { accessToken: body.access_token, refreshToken: body.refresh_token }
}
/**
* Текущий access_token с авто-обновлением через refresh (Story 1.7). Бросает, если
* активной сессии нет (нужно войти). Обновлённую пару кладёт обратно в сессию.
*/
export async function getAccessToken(): Promise<string> {
if (!tokens || !apiBase)
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Нет активной сессии: сначала выполните вход')
if (!accessExpired(tokens.accessToken))
return tokens.accessToken
const refreshed = await refreshSession(apiBase, tokens.refreshToken)
// setSession обновляет RAM И персистентную копию — иначе на диске остался бы
// устаревший refresh-токен и следующий reload поднял бы протухшую сессию.
setSession(apiBase, refreshed)
return refreshed.accessToken
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Подпись: документы (Story 2.3) и timestamp-метка второго этапа аутентификации
* (Story 2.4). Полностью локальные операции (без сети); подпись timestamp НЕ
* создаёт audit_events.
*/
import { base64url, decodeJwt } from 'jose'
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { currentView, readUnlockedKey } from '../wallet/storage'
export interface SignDocumentParams {
/** Содержимое документа: байты или строка (кодируется UTF-8). */
payload: Uint8Array | string
/** Алгоритм подписи; поддерживается ES256K (COOPOS-кривая secp256k1). */
alg?: 'ES256K'
}
/**
* Подпись дайджеста транзакции COOPOS ключом пайщика из keystore (мост подписи
* CoopID, Эпик 7). Приватный ключ НЕ покидает keystore — наружу уходит только
* готовая подпись `SIG_K1_…`. Это та же операция, что внутри
* `@wharfkit/wallet-plugin-privatekey`, но ключ берётся из RAM-keystore (2.2), а
* не из аргумента: десктопный `WalletPluginCoopId` делегирует сюда `sign()`, и
* wharfkit `Session.transact()` подписывает чужими руками, не зная ключа.
*
* Вход — hex-строка signing-дайджеста (`transaction.signingDigest(chainId)`),
* чтобы не тащить wharfkit-типы через границу пакета (иначе ловушка двойного
* `Checksum256` из разных копий antelope). Бросает `WalletLocked`, если заперт.
*/
export async function signChainDigest(digestHex: string): Promise<string> {
currentView() // бросает WalletLocked, если keystore заперт
const { PrivateKey, Checksum256 } = await import('@wharfkit/antelope')
const signature = PrivateKey.from(readUnlockedKey()).signDigest(Checksum256.from(digestHex))
return signature.toString()
}
export interface TimestampSignature {
ts: string
binding_token_jti: string
sub: string
signature: string
public_key: string
}
/**
* Каноническое сообщение, которое подписывается и которое сервер обязан собрать
* байт-в-байт для `recoverMessage` (`/coop/verify/timestamp`, Story 1.7). Ключи в
* фиксированном алфавитном порядке — детерминизм между клиентом и сервером.
* Экспортируется, чтобы controller переиспользовал ту же канонизацию.
*/
export function canonicalTimestampMessage(payload: { ts: string, binding_token_jti: string, sub: string }): string {
return JSON.stringify({ binding_token_jti: payload.binding_token_jti, sub: payload.sub, ts: payload.ts })
}
/**
* Локальная подпись документа ключом пайщика (Story 2.3). Возвращает compact JWS
* `<header>.<payload>.<signature>` (alg=ES256K). Полностью офлайн: без сети и без
* `audit_events` — сервер о подписи не знает (AC: подпись неоспорима и не зависит
* от состояния сервера).
*
* Подпись делается тем же кросс-рантайм secp256k1-примитивом, что и `signTimestamp`
* (`@wharfkit/antelope`): jose в браузере ES256K не умеет (WebCrypto без secp256k1),
* поэтому compact JWS собирается вручную, а K1-подпись сериализуется в JWS-формат
* R||S (recovery-байт отбрасывается — верификация по pubkey, не recover). `kid` в
* заголовке = COOPOS-аккаунт подписанта (разрешение ключа при верификации, Story 2.5).
*/
export async function signDocument(params: SignDocumentParams): Promise<string> {
const alg = params.alg ?? 'ES256K'
if (alg !== 'ES256K')
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, `Неподдерживаемый алгоритм подписи: ${alg}`)
const { account } = currentView() // бросает WalletLocked, если заперт
const wif = readUnlockedKey()
const payloadBytes = typeof params.payload === 'string'
? new TextEncoder().encode(params.payload)
: params.payload
const header = base64url.encode(JSON.stringify({ alg, kid: account }))
const payloadB64 = base64url.encode(payloadBytes)
const signingInput = `${header}.${payloadB64}`
const { PrivateKey } = await import('@wharfkit/antelope')
const sig = PrivateKey.from(wif).signMessage(new TextEncoder().encode(signingInput))
// K1-подпись сериализуется как [recovery(1), r(32), s(32)] = 65б; JWS ES256K = R||S
// (64б) — recovery-байт не нужен (верификация по pubkey, не recover).
const rs = sig.data.array.slice(1)
return `${signingInput}.${base64url.encode(rs)}`
}
/**
* Подпись метки времени для второго этапа auth (Story 2.4). Берёт `sub`/`jti` из
* активного `session_binding_token` (Эпик 1: читаем claims без верификации —
* подпись делает локальный ключ, доверие даёт сервер при verify), подписывает
* каноническое `{ ts, binding_token_jti, sub }` COOPOS-native recoverable
* подписью (SIG_K1_..., секрет — из keystore 2.2). Схема самоописана префиксом
* SIG_K1_ — alg не хардкодим. Результат уходит в `/coop/verify/timestamp`, где
* сервер `recoverMessage` → сверяет pubkey с COOPOS `get_account`.
*/
export async function signTimestamp(params: { sessionBindingToken: string }): Promise<TimestampSignature> {
let claims: { sub?: string, jti?: string }
try {
claims = decodeJwt(params.sessionBindingToken)
}
catch {
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'Некорректный session_binding_token: не удалось прочитать claims')
}
const sub = claims.sub
const jti = claims.jti
if (!sub || !jti)
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'session_binding_token без обязательных claims sub/jti')
// Кошелёк должен принадлежать тому же субъекту, что и токен (иначе подпишем
// метку чужим ключом — сервер всё равно отвергнет, но ловим раньше и понятнее).
const wallet = currentView() // бросает WalletLocked, если заперт
if (wallet.account !== sub)
throw new AuthV2Error(AuthV2ErrorCode.ClientWalletMismatch, `Разблокированный кошелёк (${wallet.account}) не совпадает с субъектом токена (${sub})`)
const ts = new Date().toISOString()
const message = canonicalTimestampMessage({ ts, binding_token_jti: jti, sub })
const { PrivateKey } = await import('@wharfkit/antelope')
const pk = PrivateKey.from(readUnlockedKey())
const signature = pk.signMessage(new TextEncoder().encode(message))
return {
ts,
binding_token_jti: jti,
sub,
signature: signature.toString(),
public_key: pk.toPublic().toString(),
}
}
+124
View File
@@ -0,0 +1,124 @@
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
import { deriveKey } from './kdf'
import type { EncryptedVaultBlob, VaultSubject } from './types'
const CIPHER_VERSION = 'aes-256-gcm-v1'
const KDF_VERSION = 'argon2id-v1'
const SALT_LEN = 16
const NONCE_LEN = 12
function toB64Url(bytes: Uint8Array): string {
let bin = ''
for (const b of bytes) bin += String.fromCharCode(b)
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export function fromB64Url(s: string): Uint8Array {
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'))
const out = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
return out
}
/**
* AAD GCM привязывает шифртекст к ТИПУ субъекта (`participant`/`coop`/…), но НЕ к
* конкретному account-id. Раньше было `${type}|${id}` — это требовало знать
* username ещё до confirm при восстановлении (Эпик 12), хотя сервер и так
* резолвит account из recovery-токена; так родился лишний whoami-by-token.
*
* Account-id убран из AAD намеренно (решение владельца 2026-06-15): пер-юзер
* привязку даёт пароль + случайная соль, лежащая в самом блобе; контролем доступа
* AAD здесь не был (блоб и так публично читается по account через `GET /coop/vault`).
* Поэтому теперь клиент шифрует новый ключ просто паролём, а account для
* последующей выборки/расшифровки блоба берёт из ответа confirm.
*/
function aad(subject: VaultSubject): string {
return subject.subject_type
}
/** WebCrypto в strict-TS требует ArrayBuffer-backed view; нормализуем Uint8Array. */
function buf(u: Uint8Array): ArrayBuffer {
return u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) as ArrayBuffer
}
/**
* Subject-агностичное ядро (Story 2.2): Argon2id(пароль, salt) → AES-256-GCM с
* произвольной AAD-строкой. Поверх него работает vault приватного ключа
* (AAD=субъект). PIN-слой (AAD=`pin|<account>`) снят в 11.8 (модель «без PIN»).
*/
export async function encryptWithPassword(
plaintext: string,
password: string,
additionalData: string,
): Promise<EncryptedVaultBlob> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN))
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN))
const keyBytes = deriveKey(password, salt)
const key = await crypto.subtle.importKey('raw', buf(keyBytes), { name: 'AES-GCM' }, false, ['encrypt'])
const sealed = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: buf(nonce), additionalData: buf(new TextEncoder().encode(additionalData)) },
key,
buf(new TextEncoder().encode(plaintext)),
),
)
// WebCrypto склеивает ciphertext+tag; tag GCM — последние 16 байт.
const tagLen = 16
const ciphertext = sealed.slice(0, sealed.length - tagLen)
const authTag = sealed.slice(sealed.length - tagLen)
return {
cipher_version: CIPHER_VERSION,
kdf_version: KDF_VERSION,
salt: toB64Url(salt),
nonce: toB64Url(nonce),
ciphertext: toB64Url(ciphertext),
auth_tag: toB64Url(authTag),
}
}
/** Парная расшифровка ядра; неверный пароль/AAD → `VaultDecryptionFailed`. */
export async function decryptWithPassword(
blob: EncryptedVaultBlob,
password: string,
additionalData: string,
): Promise<string> {
const keyBytes = deriveKey(password, fromB64Url(blob.salt))
const key = await crypto.subtle.importKey('raw', buf(keyBytes), { name: 'AES-GCM' }, false, ['decrypt'])
const sealed = new Uint8Array([...fromB64Url(blob.ciphertext), ...fromB64Url(blob.auth_tag)])
try {
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: buf(fromB64Url(blob.nonce)), additionalData: buf(new TextEncoder().encode(additionalData)) },
key,
buf(sealed),
)
return new TextDecoder().decode(plain)
} catch {
throw new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'Не удалось расшифровать: неверный пароль или повреждённые данные')
}
}
/**
* Клиентское шифрование приватного ключа пайщика (Story 2.1). Сервер получает
* только результат — расшифровать может лишь владелец пароля (AAD=субъект).
*/
export async function encryptPrivateKey(
privateKey: string,
password: string,
subject: VaultSubject,
): Promise<EncryptedVaultBlob> {
return encryptWithPassword(privateKey, password, aad(subject))
}
/**
* Локальная расшифровка (round-trip тесты и keystore 2.2). Серверу недоступна —
* type-driven ban (см. controller VaultService).
*/
export async function decryptPrivateKey(
blob: EncryptedVaultBlob,
password: string,
subject: VaultSubject,
): Promise<string> {
return decryptWithPassword(blob, password, aad(subject))
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Vault-слой: client-side шифрование/расшифровка приватного ключа
* (Argon2id + AES-256-GCM). Сервер хранит только зашифрованный блоб и не
* может его расшифровать (type-driven ban в controller VaultService).
*/
export type { EncryptedVaultBlob, VaultSubject } from './types'
export { ARGON2ID_PARAMS, deriveKey } from './kdf'
export { decryptPrivateKey, encryptPrivateKey, fromB64Url } from './encrypt'
+17
View File
@@ -0,0 +1,17 @@
import { argon2id } from '@noble/hashes/argon2'
/** Параметры Argon2id KDF (CoopID): зафиксированы версией `argon2id-v1`. */
export const ARGON2ID_PARAMS = {
m: 65536, // 64 MiB
t: 3,
p: 4,
dkLen: 32, // ключ AES-256
} as const
/**
* Деривация 32-байтного ключа из пароля и соли (Argon2id).
* Изоморфно: @noble/hashes работает в браузере, Node и desktop-runtime.
*/
export function deriveKey(password: string, salt: Uint8Array): Uint8Array {
return argon2id(new TextEncoder().encode(password), salt, ARGON2ID_PARAMS)
}
+15
View File
@@ -0,0 +1,15 @@
/** Субъект, к которому привязан зашифрованный блоб (AAD). */
export interface VaultSubject {
subject_type: 'participant' | 'coop' | 'council_action'
subject_id: string
}
/** Зашифрованный блоб vault'а: всё, что хранит и отдаёт сервер. base64url-поля. */
export interface EncryptedVaultBlob {
cipher_version: string
kdf_version: string
salt: string
nonce: string
ciphertext: string
auth_tag: string
}
+197
View File
@@ -0,0 +1,197 @@
import type { EncryptedVaultBlob, VaultSubject } from '../vault/types'
import type { StorageAdapter } from './storage-adapter'
/**
* Кошелёк (Story 2.2): разблокировка после логина, доступ к публичному «виду»
* ключа в памяти, запирание на logout. Приватный ключ живёт ТОЛЬКО в keystore
* (storage.ts) и наружу не выходит — `Wallet` его не содержит и не сериализует.
* Desktop-кошелёк переезжает на этот модуль (миграция — Эпик 7).
*/
import { AuthV2Error, AuthV2ErrorCode, notImplemented } from '../errors'
import { decryptPrivateKey, encryptPrivateKey } from '../vault/encrypt'
import { saveLocalVault } from './local-vault'
import { clearPinProtected, DEFAULT_PIN, hasPinProtected, loadPinProtected, savePinProtected } from './pin'
import { currentView, isUnlocked, readUnlockedKey, storeUnlocked, wipeKeystore } from './storage'
/**
* Несериализуемая обёртка кошелька: отдаёт только аккаунт и публичный ключ.
* Приватного ключа в объекте НЕТ — `JSON.stringify(wallet)` физически не может
* его раскрыть. Подпись (Stories 2.3/2.4) берёт ключ из keystore, не из Wallet.
*/
export class Wallet {
readonly account: string
readonly publicKey: string
constructor(view: { account: string, publicKey: string }) {
this.account = view.account
this.publicKey = view.publicKey
}
toJSON(): { account: string, publicKey: string } {
return { account: this.account, publicKey: this.publicKey }
}
}
/** Деривует публичный ключ из приватного (WIF K1) через WharfKit antelope. */
async function derivePublicKey(privateKey: string): Promise<string> {
// Ленивый импорт: тяжёлый antelope не тянем в bundle, пока кошелёк не разблокируют.
const { PrivateKey } = await import('@wharfkit/antelope')
try {
return PrivateKey.from(privateKey).toPublic().toString()
}
catch {
throw new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'Расшифрованное значение не является валидным приватным ключом')
}
}
/** GET зашифрованного blob'а пайщика с контроллера (blob-only, см. 2.2). */
export async function fetchVaultBlob(apiUrl: string, subjectId: string): Promise<EncryptedVaultBlob> {
let res: Response
try {
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/vault/participant/${encodeURIComponent(subjectId)}`)
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе vault: ${e instanceof Error ? e.message : e}`)
}
if (!res.ok)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось получить vault (HTTP ${res.status})`)
return res.json() as Promise<EncryptedVaultBlob>
}
/**
* POST зашифрованного blob'а на контроллер (Story 11.3, `POST /coop/vault`, 201).
* Тело — blob + плоские `subject_type`/`subject_id` (контракт `StoreVaultDto`).
* Сервер только сохраняет шифр и расшифровать его не может (type-ban в 2.1).
*/
export async function storeVaultBlob(apiUrl: string, subject: VaultSubject, blob: EncryptedVaultBlob): Promise<void> {
let res: Response
try {
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/vault`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...blob, subject_type: subject.subject_type, subject_id: subject.subject_id }),
})
}
catch (e) {
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при сохранении vault: ${e instanceof Error ? e.message : e}`)
}
if (!res.ok)
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось сохранить vault (HTTP ${res.status})`)
}
interface SaveToVaultParams {
apiUrl: string
account: string
privateKey: string
password: string
/** Если задано — дополнительно сохранить локальную копию зашифрованного blob'а. */
storage?: StorageAdapter
}
/**
* Шифрует приватный ключ паролём (Argon2id+AES-256-GCM, AAD=субъект) и сохраняет
* vault: на сервере (обязательно) и локально (если передан `storage`). Возвращает
* зашифрованный blob. Приватный ключ наружу/на сервер не уходит — только шифр.
* Используется миграцией «ключ→пароль» (Story 11.4) и сменой пароля.
*/
export async function saveToVault(params: SaveToVaultParams): Promise<EncryptedVaultBlob> {
const subject: VaultSubject = { subject_type: 'participant', subject_id: params.account }
const blob = await encryptPrivateKey(params.privateKey, params.password, subject)
await storeVaultBlob(params.apiUrl, subject, blob)
if (params.storage)
await saveLocalVault(params.storage, params.account, blob)
return blob
}
interface UnlockParams {
apiUrl: string
account: string
password: string
}
/**
* Полная разблокировка: забрать blob → расшифровать паролём на клиенте →
* положить ключ в keystore. При неверном пароле keystore остаётся пуст.
*/
export async function unlockWallet(params: UnlockParams): Promise<Wallet> {
const blob = await fetchVaultBlob(params.apiUrl, params.account)
const privateKey = await decryptPrivateKey(blob, params.password, {
subject_type: 'participant',
subject_id: params.account,
})
const publicKey = await derivePublicKey(privateKey)
storeUnlocked({ account: params.account, publicKey, privateKey })
return new Wallet({ account: params.account, publicKey })
}
/** Текущий разблокированный кошелёк. Бросает `WalletLocked`, если заперт. */
export async function getWallet(): Promise<Wallet> {
return new Wallet(currentView())
}
/** Заперт ли кошелёк (нет ключа в памяти). */
export function isWalletUnlocked(): boolean {
return isUnlocked()
}
/** Затирает ключ из памяти (logout). Идемпотентно. */
export function lockWallet(): void {
wipeKeystore()
}
/** Ротация ключа пайщика (updateauth + перешифровка vault). Story 3.3. */
export async function rotateKey(): Promise<void> {
notImplemented('rotateKey')
}
interface PersistPinParams {
/** ПИН для шифрования локального кэша; по умолчанию — `DEFAULT_PIN` ('000000', прозрачный). */
pin?: string
storage: StorageAdapter
}
/**
* Перешифровывает текущий разблокированный ключ ПИНом и кладёт в локальный кэш
* (уточнённая at-rest модель, см. `pin.ts`). Вызывается сразу после успешного
* `unlockWallet`/`migrate`, чтобы последующие входы шли по ПИН, а не по паролю.
* Бросает `WalletLocked`, если кошелёк заперт (нечего кэшировать). Ключ читается
* пакет-внутренней `readUnlockedKey()` и наружу не выходит — в кэш ложится шифр.
*/
export async function persistPinCache(params: PersistPinParams): Promise<void> {
const { account } = currentView() // бросает WalletLocked, если заперт
await savePinProtected(readUnlockedKey(), params.pin ?? DEFAULT_PIN, account, params.storage)
}
interface UnlockWithPinParams {
/** ПИН; по умолчанию — `DEFAULT_PIN` ('000000', прозрачная авто-разблокировка). */
pin?: string
storage: StorageAdapter
}
/**
* Разблокировка из локального PIN-кэша без round-trip к серверу и без пароля
* (reload устройства, авто-лок по простою). `null` — кэша нет (нужен полный вход
* `unlockWallet` паролём). Неверный ПИН → `VaultDecryptionFailed`. При успехе
* кладёт ключ в keystore и возвращает `Wallet`.
*/
export async function unlockWithPin(params: UnlockWithPinParams): Promise<Wallet | null> {
const loaded = await loadPinProtected(params.pin ?? DEFAULT_PIN, params.storage)
if (!loaded)
return null
const publicKey = await derivePublicKey(loaded.privateKey)
storeUnlocked({ account: loaded.account, publicKey, privateKey: loaded.privateKey })
return new Wallet({ account: loaded.account, publicKey })
}
/** Есть ли локальный PIN-кэш (выбор сценария разблокировки на загрузке). */
export async function hasPinCache(storage: StorageAdapter): Promise<boolean> {
return hasPinProtected(storage)
}
/** Удаляет локальный PIN-кэш («забыть устройство» / смена аккаунта). */
export async function clearPinCache(storage: StorageAdapter): Promise<void> {
await clearPinProtected(storage)
}
export { clearLocalVault, loadLocalVault, saveLocalVault } from './local-vault'
export { DEFAULT_PIN } from './pin'
export type { StorageAdapter } from './storage-adapter'
+46
View File
@@ -0,0 +1,46 @@
/**
* Локальная копия зашифрованного vault-блоба (Story 11.3). Хранит то же, что и
* сервер — Argon2id+AES-256-GCM ciphertext, бесполезный без пароля; расшифровать
* нельзя без знания пароля пайщика (zero-knowledge инвариант сохраняется и локально).
*
* Зачем дублировать серверную копию на устройстве: вход и подпись возможны без
* round-trip к контроллеру (офлайн/деградация узла), а также чтобы сразу после
* миграции (Story 11.4) ключ был доступен локально. Сервер остаётся источником
* истины и обязателен для входа с НОВОГО устройства (там локальной копии нет).
*
* Это НЕ расшифрованный ключ: расшифрованный WIF живёт только в RAM-keystore
* (`storage.ts`) и стирается на логауте (Story 11.8). Локально лежит лишь шифр.
*/
import type { EncryptedVaultBlob } from '../vault/types'
import type { StorageAdapter } from './storage-adapter'
const LOCAL_VAULT_KEY = 'coopid.wallet.vault'
interface LocalVaultRecord {
account: string
blob: EncryptedVaultBlob
}
/** Сохраняет (перезаписывает) локальную копию зашифрованного blob'а пайщика. */
export async function saveLocalVault(storage: StorageAdapter, account: string, blob: EncryptedVaultBlob): Promise<void> {
const record: LocalVaultRecord = { account, blob }
await storage.set(LOCAL_VAULT_KEY, JSON.stringify(record))
}
/**
* Возвращает локальную копию blob'а для `account` (или `null`, если записи нет /
* она от другого аккаунта). Подмена account → `null`: чужой blob не отдаём, даже
* расшифровать его без чужого пароля всё равно нельзя.
*/
export async function loadLocalVault(storage: StorageAdapter, account: string): Promise<EncryptedVaultBlob | null> {
const raw = await storage.get(LOCAL_VAULT_KEY)
if (!raw)
return null
const record = JSON.parse(raw) as LocalVaultRecord
return record.account === account ? record.blob : null
}
/** Удаляет локальную копию vault'а (смена аккаунта / «забыть устройство»). */
export async function clearLocalVault(storage: StorageAdapter): Promise<void> {
await storage.remove(LOCAL_VAULT_KEY)
}
+77
View File
@@ -0,0 +1,77 @@
import type { EncryptedVaultBlob } from '../vault/types'
import type { StorageAdapter } from './storage-adapter'
/**
* Локальный PIN-кэш приватного ключа (уточнённая модель CoopID, 2026-06-15 —
* СУПЕРДИТ «без PIN» из Story 11.8). Двухуровневая защита ключа:
*
* 1) Серверный vault ← ПАРОЛЬ (сложный, записан отдельно). Расшифровывается
* только при входе; повторно пароль не спрашиваем. Защита от кражи блоба.
* 2) Локальный кэш ← ПИН (этот модуль). После входа расшифрованный WIF
* перешифровывается ПИНом тем же Argon2id+AES-256-GCM и кладётся в
* `StorageAdapter`. Дальнейшие разблокировки (reload, авто-лок по простою) —
* ПИНом, НЕ паролём.
*
* Модель угроз: ПИН защищает не от похищения блоба (от него защищает пароль), а
* «от дурака» — постороннего/ребёнка за разблокированным устройством. ПИН по
* умолчанию `DEFAULT_PIN` ('000000') делает разблокировку прозрачной (ключ де-факто
* лежит локально с тривиальной защитой — осознанная плата за «не вводить пароль
* повторно»); кастомный ПИН поднимает планку до анти-«дурак» (6 цифр). AAD
* `pin|<account>` привязывает обёртку к аккаунту; account лежит в самой записи,
* поэтому знать его заранее не нужно (в отличие от серверного blob'а).
*/
import { decryptWithPassword, encryptWithPassword } from '../vault/encrypt'
export type { StorageAdapter } from './storage-adapter'
const STORAGE_KEY = 'coopid.wallet.pin-vault'
/** Стандартный «прозрачный» ПИН: разблокировка происходит автоматически и незаметно. */
export const DEFAULT_PIN = '000000'
function aad(account: string): string {
return `pin|${account}`
}
interface PinRecord {
account: string
blob: EncryptedVaultBlob
}
/** Сохраняет ключ под ПИН в storage (перезаписывает предыдущую запись). */
export async function savePinProtected(
privateKey: string,
pin: string,
account: string,
storage: StorageAdapter,
): Promise<void> {
const blob = await encryptWithPassword(privateKey, pin, aad(account))
const record: PinRecord = { account, blob }
await storage.set(STORAGE_KEY, JSON.stringify(record))
}
/**
* Восстанавливает ключ из storage по ПИН. `null` — записи нет. Неверный ПИН →
* `VaultDecryptionFailed` (из ядра). Возвращает `{account, privateKey}` — account
* берётся из самой записи, знать его до разблокировки не требуется.
*/
export async function loadPinProtected(
pin: string,
storage: StorageAdapter,
): Promise<{ account: string, privateKey: string } | null> {
const raw = await storage.get(STORAGE_KEY)
if (!raw)
return null
const record = JSON.parse(raw) as PinRecord
const privateKey = await decryptWithPassword(record.blob, pin, aad(record.account))
return { account: record.account, privateKey }
}
/** Есть ли локальный PIN-кэш (без расшифровки — для выбора сценария разблокировки). */
export async function hasPinProtected(storage: StorageAdapter): Promise<boolean> {
return (await storage.get(STORAGE_KEY)) !== null
}
/** Удаляет PIN-запись (logout с «забыть устройство» / смена аккаунта). */
export async function clearPinProtected(storage: StorageAdapter): Promise<void> {
await storage.remove(STORAGE_KEY)
}
@@ -0,0 +1,13 @@
/**
* Подключаемый async key-value стор для персистентных артефактов кошелька
* (локальная копия vault'а — Story 11.3). PIN-слой снят в 11.8 (модель «без PIN»).
*
* Абстракция нужна для кросс-рантайма (NFR26): в браузере — обёртка над
* localStorage/IndexedDB, в desktop-runtime — своя реализация, в Node/тестах —
* память. Крипто и логика хранения от среды не зависят.
*/
export interface StorageAdapter {
get: (key: string) => Promise<string | null>
set: (key: string, value: string) => Promise<void>
remove: (key: string) => Promise<void>
}
+55
View File
@@ -0,0 +1,55 @@
/**
* In-memory keystore (Story 2.2). ЕДИНСТВЕННОЕ место, где живёт расшифрованный
* приватный ключ пайщика — module-private переменная, только в RAM процесса/вкладки.
*
* Публичная поверхность SDK (`getWallet()`) приватный ключ НЕ отдаёт: signing-модули
* (Stories 2.3/2.4) берут его через пакет-внутреннюю `readUnlockedKey()`. На logout —
* `wipeKeystore()` затирает ключ (перезапись + сброс ссылки).
*/
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
interface UnlockedEntry {
account: string
publicKey: string
privateKey: string
}
/** Открытое состояние сессии. null = заперто. */
let unlocked: UnlockedEntry | null = null
/** Кладёт расшифрованный ключ в keystore (вызывается из unlockWallet). */
export function storeUnlocked(entry: UnlockedEntry): void {
unlocked = entry
}
/** Заперт ли кошелёк (нет расшифрованного ключа в памяти). */
export function isUnlocked(): boolean {
return unlocked !== null
}
/** Публичный «вид» кошелька без приватного ключа. Бросает, если заперто. */
export function currentView(): { account: string, publicKey: string } {
if (!unlocked)
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Кошелёк заперт: сначала вызовите unlockWallet()')
return { account: unlocked.account, publicKey: unlocked.publicKey }
}
/**
* Пакет-внутренний доступ к приватному ключу для signing-модулей (2.3/2.4).
* НЕ реэкспортируется из index.ts — наружу ключ не выходит.
*/
export function readUnlockedKey(): string {
if (!unlocked)
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Кошелёк заперт: подпись невозможна без unlockWallet()')
return unlocked.privateKey
}
/** Затирает ключ из памяти (logout / lockWallet). Идемпотентно. */
export function wipeKeystore(): void {
if (unlocked) {
// Перезаписываем строку-ключ перед сбросом ссылки, чтобы уменьшить окно жизни
// в куче (JS-строки иммутабельны — гарантий нет, но снимаем прямую ссылку).
unlocked.privateKey = '\0'.repeat(unlocked.privateKey.length)
unlocked = null
}
}
@@ -0,0 +1,89 @@
import { Buffer } from 'node:buffer'
import { describe, expect, it, vi } from 'vitest'
import {
CERTIFICATE_RENEWAL_LEAD_MS,
computeRenewalDelayMs,
scheduleCertificateRenewal,
} from '../src/certificate'
/** Собрать декодируемый compact-JWS с нужными claims (подпись не проверяется при decode). */
function fakeCert(claims: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'ES256K', typ: 'JWT' })).toString('base64url')
const payload = Buffer.from(JSON.stringify({ jti: 'serial-1', sub: 'uuid-1', ...claims })).toString('base64url')
return `${header}.${payload}.sig`
}
describe('computeRenewalDelayMs', () => {
it('обычный кейс: задержка до exp − lead', () => {
const nowMs = 1_000_000
const exp = Math.floor(nowMs / 1000) + 3600 // +1ч
const delay = computeRenewalDelayMs({ exp }, nowMs)
expect(delay).toBe(3600 * 1000 - CERTIFICATE_RENEWAL_LEAD_MS)
})
it('до exp осталось ≤ lead → 0 (перезапрос немедленно)', () => {
const nowMs = 1_000_000
const exp = Math.floor(nowMs / 1000) + 120 // 2 мин < lead (5 мин)
expect(computeRenewalDelayMs({ exp }, nowMs)).toBe(0)
})
it('cert уже истёк → 0', () => {
const nowMs = 1_000_000
const exp = Math.floor(nowMs / 1000) - 10
expect(computeRenewalDelayMs({ exp }, nowMs)).toBe(0)
})
})
describe('scheduleCertificateRenewal', () => {
it('срабатывает в exp − lead, зовёт renew и перепланирует от нового cert', async () => {
let currentNow = 0
const timers: Array<{ cb: () => void, delay: number }> = []
const setTimer = vi.fn((cb: () => void, delay: number) => {
timers.push({ cb, delay })
return timers.length as any
})
const clearTimer = vi.fn()
const firstExp = 3600 // сек
const secondExp = 7200
const first = fakeCert({ exp: firstExp })
const second = fakeCert({ exp: secondExp })
const renew = vi.fn().mockResolvedValue(second)
scheduleCertificateRenewal(first, renew, { now: () => currentNow, setTimer, clearTimer })
// первый таймер запланирован на exp − lead
expect(setTimer).toHaveBeenCalledTimes(1)
expect(timers[0].delay).toBe(firstExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS)
// время дошло до срабатывания → дёргаем callback
currentNow = firstExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS
timers[0].cb()
await Promise.resolve()
await Promise.resolve()
expect(renew).toHaveBeenCalledTimes(1)
// перепланирование от exp нового сертификата
expect(setTimer).toHaveBeenCalledTimes(2)
expect(timers[1].delay).toBe(secondExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS - currentNow)
})
it('cancel() отменяет запланированный таймер и не перепланирует', async () => {
const timers: Array<{ cb: () => void, delay: number }> = []
const setTimer = vi.fn((cb: () => void, delay: number) => {
timers.push({ cb, delay })
return timers.length as any
})
const clearTimer = vi.fn()
const renew = vi.fn().mockResolvedValue(fakeCert({ exp: 7200 }))
const handle = scheduleCertificateRenewal(fakeCert({ exp: 3600 }), renew, { now: () => 0, setTimer, clearTimer })
handle.cancel()
expect(clearTimer).toHaveBeenCalledTimes(1)
// даже если таймер «выстрелит» после cancel — renew не перепланирует
timers[0].cb()
await Promise.resolve()
expect(setTimer).toHaveBeenCalledTimes(1)
})
})
+127
View File
@@ -0,0 +1,127 @@
import { SignJWT } from 'jose'
import { describe, expect, it } from 'vitest'
import {
CERTIFICATE_EXPIRING_WINDOW_MS,
certificateStatus,
decodeParticipantCertificate,
verificationTypeLabel,
} from '../src/certificate'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
async function makeCert(overrides: Record<string, unknown> = {}, opts: { jti?: string, exp?: string } = {}): Promise<string> {
let b = new SignJWT({
coopname: 'voskhod',
coop_chain: [
{ account: 'ano', public_key: 'PUB_K1_ano' },
{ account: 'voskhod', public_key: 'PUB_K1_vos' },
{ account: 'vostok', public_key: 'PUB_K1_vostok' },
],
verification_types: [{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' }],
identification: { type: 'individual', username: 'ant', first_name: 'Иван' },
claim_schema_version: '1',
...overrides,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuer('https://voskhod.coop')
.setSubject('uuid-1')
.setIssuedAt()
.setExpirationTime(opts.exp ?? '24h')
if (opts.jti !== undefined || overrides.jti === undefined)
b = b.setJti(opts.jti ?? 'serial-123')
return b.sign(new TextEncoder().encode('test-secret-padding-000000000000000000'))
}
describe('decodeParticipantCertificate', () => {
it('декодирует все claims в типизированный объект', async () => {
const claims = decodeParticipantCertificate(await makeCert())
expect(claims.sub).toBe('uuid-1')
expect(claims.jti).toBe('serial-123')
expect(claims.coopname).toBe('voskhod')
expect(claims.claim_schema_version).toBe('1')
expect(claims.verification_types).toEqual([
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
])
expect(claims.coop_chain.map(l => l.account)).toEqual(['ano', 'voskhod', 'vostok'])
expect(claims.identification).toMatchObject({ type: 'individual', username: 'ant' })
expect(claims.exp).toBeGreaterThan(claims.iat)
})
it('identification может быть null', async () => {
const claims = decodeParticipantCertificate(await makeCert({ identification: null }))
expect(claims.identification).toBeNull()
})
it('retention-claims читаются (Story 4.8)', async () => {
const claims = decodeParticipantCertificate(await makeCert({
data_retention_contract: 'erase_on_exclusion',
retention_deadline_ts: 1800000000,
}))
expect(claims.data_retention_contract).toBe('erase_on_exclusion')
expect(claims.retention_deadline_ts).toBe(1800000000)
})
it('retention-claims отсутствуют → безопасные дефолты (Story 4.8)', async () => {
const claims = decodeParticipantCertificate(await makeCert())
expect(claims.data_retention_contract).toBe('')
expect(claims.retention_deadline_ts).toBe(0)
})
it('verification_types: структурные записи сохраняются, мусор отбрасывается (Story 4.3)', async () => {
const claims = decodeParticipantCertificate(await makeCert({
verification_types: [
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
'legacy_string',
{ source: 'no_type' },
],
}))
expect(claims.verification_types).toEqual([
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
])
})
it('не-JWT строка → AuthV2Error(chain_verification_failed)', () => {
let err: unknown
try {
decodeParticipantCertificate('garbage')
}
catch (e) {
err = e
}
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ChainVerificationFailed)
})
it('без обязательных claims (нет jti) → AuthV2Error', async () => {
// токен без jti
const noJti = await new SignJWT({ coopname: 'voskhod' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject('uuid-1')
.setExpirationTime('24h')
.sign(new TextEncoder().encode('test-secret-padding-000000000000000000'))
expect(() => decodeParticipantCertificate(noJti)).toThrow(AuthV2Error)
})
})
describe('certificateStatus', () => {
const now = 1_900_000_000_000 // фиксированный «сейчас» (мс)
it('exp в прошлом → expired', () => {
expect(certificateStatus({ exp: now / 1000 - 10 }, now)).toBe('expired')
})
it('exp в окне «истекает» → expiring', () => {
const exp = (now + CERTIFICATE_EXPIRING_WINDOW_MS - 1000) / 1000
expect(certificateStatus({ exp }, now)).toBe('expiring')
})
it('exp далеко → active', () => {
const exp = (now + CERTIFICATE_EXPIRING_WINDOW_MS + 10 * 60 * 1000) / 1000
expect(certificateStatus({ exp }, now)).toBe('active')
})
})
describe('verificationTypeLabel', () => {
it('известный тип → человекочитаемое описание', () => {
expect(verificationTypeLabel('coop_baseline')).toBe('Базовое подтверждение кооперативом')
})
it('неизвестный тип → возвращается как есть', () => {
expect(verificationTypeLabel('future_kyc_x')).toBe('future_kyc_x')
})
})
+29
View File
@@ -0,0 +1,29 @@
/**
* Интеграционный тест Story 1.3: SDK читает publickey permission `cert` из COOPOS.
* Требует живую дев-ноду: COOPID_TEST_RPC=http://127.0.0.1:8908 pnpm test
* Без env — скипается (юнит-прогоны CI не зависят от цепи).
*/
import { describe, expect, it } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import { readCertPublicKey } from '../src/index'
const RPC = process.env.COOPID_TEST_RPC
describe.skipIf(!RPC)('readCertPublicKey — против дев-сети COOPOS', () => {
it.each(['vostok', 'voskhod', 'ano'])('читает ключ cert у %s', async (account) => {
const key = await readCertPublicKey(RPC!, account)
expect(key).toMatch(/^(PUB_K1_|EOS)/)
})
it('аккаунт без cert → ChainVerificationFailed', async () => {
const err = await readCertPublicKey(RPC!, 'eosio').then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ChainVerificationFailed)
})
it('несуществующий аккаунт → NetworkError', async () => {
const err = await readCertPublicKey(RPC!, 'nonexistacct').then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.NetworkError)
})
})
@@ -0,0 +1,71 @@
/**
* Electron-таргет cross-runtime smoke (Story 9.13): те же проверки, что в
* smoke.test.ts, но против СОБРАННОГО артефакта (dist/index.cjs — то, что
* реально получают клиенты) в main-process electron. Запуск — через
* run-electron.mjs (`pnpm test:electron`), не напрямую.
*
* Без vitest: минимальный assert + exit-код, чтобы не тащить тест-раннер в
* electron. Сценарии держать синхронными со smoke.test.ts.
*/
const { app } = require('electron')
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PW = 'correct-horse-battery-staple-12'
const ACCOUNT = 'ant'
function assert(cond, name) {
if (!cond)
throw new Error(`smoke-проверка провалена: ${name}`)
console.log(`[electron-smoke] OK ${name}`)
}
async function run() {
const sdk = require('../../dist/index.cjs')
// vault round-trip + unlockWallet (WebCrypto AES-GCM + Argon2id в main-process)
const blob = await sdk.encryptPrivateKey(KEY, PW, { subject_type: 'participant', subject_id: ACCOUNT })
globalThis.fetch = async () =>
new Response(JSON.stringify(blob), { status: 200, headers: { 'content-type': 'application/json' } })
const wallet = await sdk.unlockWallet({ apiUrl: 'https://coop.stub', account: ACCOUNT, password: PW })
assert(wallet.account === ACCOUNT && wallet.publicKey.startsWith('PUB_K1_'), 'vault round-trip + unlockWallet')
assert(sdk.isWalletUnlocked() === true, 'isWalletUnlocked после unlock')
// signDocument → verifyDocumentOffline
const jws = await sdk.signDocument({ payload: 'кросс-рантайм smoke-документ' })
assert(jws.split('.').length === 3, 'signDocument: compact JWS')
const verdict = await sdk.verifyDocumentOffline({
jws,
signedAtBlock: 100,
authority: [{ public_key: wallet.publicKey, valid_from_block: 1, valid_to_block: null }],
})
assert(verdict.valid === true, 'verifyDocumentOffline подтверждает подпись')
// запертый кошелёк → типизированная WalletLocked
sdk.lockWallet()
const lockedErr = await sdk.signDocument({ payload: 'x' }).then(() => null, e => e)
assert(lockedErr instanceof sdk.AuthV2Error && lockedErr.code === sdk.AuthV2ErrorCode.WalletLocked, 'WalletLocked при запертом кошельке')
// login/getAccessToken реализованы (вход 1.7 + flow-executor Story 11.2): в stub-окружении
// без живого authentik/controller дают типизированную AuthV2Error (login→network_error,
// getAccessToken→wallet_locked). Конкретный код не пиннингуем — важно, что ошибка
// типизированная и код валиден для enum'а. Синхронно со smoke.test.ts.
const errorCodes = Object.values(sdk.AuthV2ErrorCode)
for (const [name, call] of [
['login', () => sdk.login({ issuer: 'https://coop.stub/application/o/coopid/', email: 'a@b.c', password: 'p' })],
['getAccessToken', () => sdk.getAccessToken()],
]) {
const err = await call().then(() => null, e => e)
assert(err instanceof sdk.AuthV2Error && errorCodes.includes(err.code), `${name}: типизированная AuthV2Error`)
}
}
app.whenReady().then(run).then(
() => {
console.log('[electron-smoke] все проверки пройдены')
app.exit(0)
},
(e) => {
console.error('[electron-smoke] FAIL:', e)
app.exit(1)
},
)
@@ -0,0 +1,35 @@
/**
* Лаунчер electron-таргета (Story 9.13): резолвит бинарь electron, проверяет
* наличие dist (smoke идёт против собранного артефакта), на безголовом хосте
* заворачивает запуск в `xvfb-run -a` (electron на Linux требует X-сервер).
* `--no-sandbox` — для контейнерных/CI-окружений без user namespaces.
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import electronPath from 'electron'
const here = dirname(fileURLToPath(import.meta.url))
const pkgRoot = join(here, '..', '..')
if (!existsSync(join(pkgRoot, 'dist', 'index.cjs'))) {
console.error('[electron-smoke] dist/index.cjs не найден — сначала `pnpm build` (test:cross-runtime делает это сам)')
process.exit(1)
}
const electronArgs = ['--no-sandbox', join(here, 'electron-main.cjs')]
const needXvfb = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY
const [cmd, args] = needXvfb
? ['xvfb-run', ['-a', electronPath, ...electronArgs]]
: [electronPath, electronArgs]
const res = spawnSync(cmd, args, { stdio: 'inherit', cwd: pkgRoot })
if (res.error) {
console.error(`[electron-smoke] не удалось запустить ${cmd}: ${res.error.message}`)
process.exit(1)
}
process.exit(res.status ?? 1)
@@ -0,0 +1,94 @@
/**
* Cross-runtime smoke (Story 9.13): один и тот же сценарий гоняется в Node
* (`pnpm test:node`) и в браузере (`pnpm test:browser`, chromium headless);
* electron-таргет (`pnpm test:electron`) выполняет те же проверки против
* собранного dist (см. electron-main.cjs).
*
* Правила файла:
* - только ПУБЛИЧНЫЙ API пакета (как у клиентов) и никаких Node-специфичных
* глобалов (Buffer, process, fs) — иначе браузерный таргет перестаёт быть
* честным; сеть стабится через globalThis.fetch;
* - дорогой Argon2id (KDF vault'а) выполняется ОДИН раз в beforeAll: в
* chromium pure-JS KDF в разы медленнее Node (минуты), per-test KDF
* упирается в таймауты. Тесты зависят от порядка: lockWallet — последний.
*/
import type { Wallet } from '../../src'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import {
AuthV2Error,
AuthV2ErrorCode,
encryptPrivateKey,
getAccessToken,
isWalletUnlocked,
lockWallet,
login,
signDocument,
unlockWallet,
verifyDocumentOffline,
} from '../../src'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PW = 'correct-horse-battery-staple-12'
const ACCOUNT = 'ant'
describe('cross-runtime smoke: vault → wallet → подпись → верификация', () => {
let wallet: Wallet
beforeAll(async () => {
// Стаб контроллера: GET /coop/vault/... отдаёт заранее зашифрованный blob.
const blob = await encryptPrivateKey(KEY, PW, { subject_type: 'participant', subject_id: ACCOUNT })
vi.stubGlobal('fetch', async () =>
new Response(JSON.stringify(blob), { status: 200, headers: { 'content-type': 'application/json' } }))
wallet = await unlockWallet({ apiUrl: 'https://coop.stub', account: ACCOUNT, password: PW })
// 10 мин: два прогона Argon2id (encrypt + unlock) — в браузере это минуты
}, 600_000)
afterAll(() => {
lockWallet()
vi.unstubAllGlobals()
})
it('vault round-trip + unlockWallet: WebCrypto AES-GCM и Argon2id работают в этом рантайме', () => {
expect(wallet.account).toBe(ACCOUNT)
expect(wallet.publicKey).toMatch(/^PUB_K1_/)
expect(isWalletUnlocked()).toBe(true)
})
it('signDocument → compact JWS, verifyDocumentOffline подтверждает подпись', async () => {
const jws = await signDocument({ payload: 'кросс-рантайм smoke-документ' })
expect(jws.split('.')).toHaveLength(3)
const verdict = await verifyDocumentOffline({
jws,
signedAtBlock: 100,
authority: [{ public_key: wallet.publicKey, valid_from_block: 1, valid_to_block: null }],
})
expect(verdict.valid).toBe(true)
})
// SDK-сторона login/getAccessToken реализована (вход 1.7 + flow-executor Story 11.2).
// Smoke фиксирует кросс-рантайм: модуль грузится, функции вызываемы, канал
// типизированных ошибок работает в каждом рантайме. В stub-окружении без живого
// authentik/controller: login → AuthV2Error(network_error) (flow-executor не достучался),
// getAccessToken → AuthV2Error(wallet_locked) (нет активной сессии). Конкретный код
// не пиннингуем жёстко — важно, что ошибка типизированная (AuthV2Error) в любом рантайме.
it('login()/getAccessToken(): вызываемы и дают типизированную AuthV2Error в каждом рантайме', async () => {
for (const call of [
() => login({ issuer: 'https://coop.stub/application/o/coopid/', email: 'a@b.c', password: 'p' }),
() => getAccessToken(),
]) {
const err = await call().then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
// код — валидное значение enum'а (канал типизированных ошибок целостен)
expect(Object.values(AuthV2ErrorCode)).toContain((err as AuthV2Error).code)
}
})
// Последний по порядку: запирает общий кошелёк из beforeAll
it('запертый кошелёк: signDocument даёт типизированную WalletLocked, не runtime-краш', async () => {
lockWallet()
const err = await signDocument({ payload: 'x' }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
})
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { AUTH_V2_ERROR_VIEWS, AuthV2Error, AuthV2ErrorCode, describeAuthV2Error } from '../src/index'
describe('describeAuthV2Error (Story 1.11)', () => {
it('каталог покрывает каждый код ошибки', () => {
for (const code of Object.values(AuthV2ErrorCode))
expect(AUTH_V2_ERROR_VIEWS[code], code).toBeDefined()
})
it('по AC: неверные учётные данные — общее сообщение без раскрытия поля', () => {
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'whatever'))
expect(view.code).toBe(AuthV2ErrorCode.InvalidCredentials)
expect(view.message).toBe('Неверный email или пароль.')
// перечисляет оба поля через «или» — не раскрывает, какое именно неверно (security)
expect(view.message).toMatch(/email или пароль/i)
expect(view.message).not.toMatch(/не найден|не существует|нет такого|такой email/i)
expect(view.keepSession).toBe(false)
})
it('по AC: vault не расшифровывается — предлагает восстановление доступа', () => {
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'bad key'))
expect(view.action).toBe('recover')
expect(view.message).toMatch(/восстановить доступ/i)
})
it('по AC: сетевая ошибка — «проверьте интернет» и НЕ разлогинивает (NFR20/FR50)', () => {
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.NetworkError, 'fetch failed'))
expect(view.action).toBe('check_connection')
expect(view.message).toMatch(/проверьте интернет/i)
expect(view.keepSession).toBe(true)
})
it('внешний сбой (кооператив недоступен) — сессия сохраняется', () => {
expect(describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.CooposDegraded, 'down')).keepSession).toBe(true)
})
it('неожиданная (не-AuthV2) ошибка — безопасный фолбэк без утечки technical message', () => {
const view = describeAuthV2Error(new Error('TypeError: cannot read property x of undefined'))
expect(view.code).toBeNull()
expect(view.message).not.toMatch(/TypeError|undefined/)
expect(view.keepSession).toBe(true)
})
it('сообщение берётся из каталога, а не из e.message сервера', () => {
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.TimestampTooOld, 'raw server text'))
expect(view.message).not.toBe('raw server text')
expect(view.message).toBe(AUTH_V2_ERROR_VIEWS[AuthV2ErrorCode.TimestampTooOld].message)
})
})
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import { exportFullQR } from '../src/export'
import { lockWallet } from '../src/wallet'
import { storeUnlocked } from '../src/wallet/storage'
const CERT = 'header.payload.sig'
afterEach(() => lockWallet())
function unlock(): void {
storeUnlocked({ account: 'ant', publicKey: 'PUB_K1_ant', privateKey: '5KdummyWIF' })
}
describe('exportFullQR (Story 4.9)', () => {
it('заперт vault → WalletLocked, consent даже не запрашивается', async () => {
lockWallet()
const confirm = vi.fn().mockResolvedValue(true)
await expect(exportFullQR(CERT, { confirm })).rejects.toMatchObject({ code: AuthV2ErrorCode.WalletLocked })
expect(confirm).not.toHaveBeenCalled()
})
it('consent отклонён → ConsentRequired', async () => {
unlock()
await expect(exportFullQR(CERT, { confirm: async () => false })).rejects.toMatchObject({ code: AuthV2ErrorCode.ConsentRequired })
})
it('reject в confirm трактуется как отказ → ConsentRequired', async () => {
unlock()
const confirm = async (): Promise<boolean> => {
throw new Error('dialog closed')
}
await expect(exportFullQR(CERT, { confirm })).rejects.toMatchObject({ code: AuthV2ErrorCode.ConsentRequired })
})
it('разблокирован + consent дан → payload-байты с данными сертификата', async () => {
unlock()
const bytes = await exportFullQR(CERT, { confirm: async () => true })
expect(bytes).toBeInstanceOf(Uint8Array)
expect(new TextDecoder().decode(bytes)).toBe(CERT)
})
it('ошибки — типизированная AuthV2Error', async () => {
lockWallet()
const err = await exportFullQR(CERT, { confirm: async () => true }).catch(e => e)
expect(err).toBeInstanceOf(AuthV2Error)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import * as api from '../src/index'
import { AuthV2Error, AuthV2ErrorCode } from '../src/index'
const PUBLIC_API = [
'login',
'loginWithMagicLink',
'recover',
'getAccessToken',
'getParticipantCertificate',
'logout',
'verifyOffline',
'signDocument',
'signTimestamp',
'getWallet',
'rotateKey',
'exportFullQR',
] as const
// Методы, ещё не реализованные (бросают not_implemented). По мере реализации
// историй метод уходит отсюда: getWallet — 2.2, signTimestamp — 2.4, verifyOffline — 4.4,
// exportFullQR — 4.9 (гейтится unlocked vault + consent, не not_implemented), login —
// 1.7/11.2 (flow-executor → network_error без живого authentik), recover — Эпик 3,
// getAccessToken — 1.7 (wallet_locked без сессии), loginWithMagicLink — 12.2 (confirm-флоу
// + повторный handshake). Реальный stub остался один: rotateKey (3.3).
const IMPLEMENTED = new Set(['getWallet', 'signTimestamp', 'getParticipantCertificate', 'logout', 'signDocument', 'verifyOffline', 'exportFullQR', 'login', 'recover', 'getAccessToken', 'loginWithMagicLink'])
const STILL_STUBBED = PUBLIC_API.filter(m => !IMPLEMENTED.has(m))
describe('@coopenomics/auth — скелет SDK', () => {
it('экспортирует всю публичную поверхность', () => {
for (const method of PUBLIC_API)
expect(api[method], method).toBeTypeOf('function')
})
it('stub\'ы отказывают типизированной AuthV2Error(not_implemented), а не молчат', async () => {
for (const method of STILL_STUBBED) {
const err = await (api[method] as () => Promise<unknown>)().then(
() => null,
e => e,
)
expect(err, method).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code, method).toBe(AuthV2ErrorCode.NotImplemented)
}
})
it('ошибка сериализуется в формат OAuth 2.0', () => {
const err = new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Неверный email или пароль')
expect(err.toJSON()).toEqual({
error: 'invalid_credentials',
error_description: 'Неверный email или пароль',
})
})
it('exportProofQR НЕ экспортируется в MVP (anonymous-форма — Growth, Story 4.9)', () => {
expect((api as Record<string, unknown>).exportProofQR).toBeUndefined()
})
it('trust anchor и список нод доступны (placeholder до release-pin/9.5)', () => {
expect(api.TRUST_ANCHOR_ANO_CERT_PUBKEY).toBeNull()
expect(api.COOPOS_PUBLIC_NODES).toEqual([])
})
})
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const lockWallet = vi.fn()
// keystore-затирание мокаем на уровне модуля wallet — logout обязан вызвать его
// независимо от исхода сетевого запроса.
vi.mock('../src/wallet', () => ({
lockWallet,
}))
const { logout } = await import('../src/oidc')
describe('logout (Story 1.10)', () => {
beforeEach(() => {
lockWallet.mockClear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('шлёт POST /coop/logout с refresh/access и затирает keystore', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
await logout({ apiUrl: 'https://coop.example/', refreshToken: 'r1', accessToken: 'a1' })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, opts] = fetchMock.mock.calls[0]
expect(url).toBe('https://coop.example/coop/logout')
expect(opts.method).toBe('POST')
expect(JSON.parse(opts.body)).toEqual({ refresh_token: 'r1', access_token: 'a1' })
expect(lockWallet).toHaveBeenCalledTimes(1)
})
it('затирает keystore ДАЖЕ при недоступности сервера (finally)', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'))
vi.stubGlobal('fetch', fetchMock)
await expect(logout({ apiUrl: 'https://coop.example', refreshToken: 'r1' })).resolves.toBeUndefined()
expect(lockWallet).toHaveBeenCalledTimes(1)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { canonicalMigrationMessage, migrate } from '../src/migration'
import { configureCoopId } from '../src/oidc/client'
const WIF = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
beforeEach(() => configureCoopId({ apiUrl: 'https://coop.example' }))
afterEach(() => vi.unstubAllGlobals())
describe('canonicalMigrationMessage (Story 11.4)', () => {
it('фиксированный алфавитный порядок ключей: pw_hash, purpose, ts', () => {
expect(canonicalMigrationMessage({ ts: 'T', pw_hash: 'H' }))
.toBe('{"pw_hash":"H","purpose":"coopid-key-migration","ts":"T"}')
})
})
describe('migrate (Story 11.4) — «ключ → пароль»', () => {
it('невалидный WIF → InvalidCredentials, без сетевого запроса', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(migrate({ email: 'a@e.com', privateKey: 'not-a-key', newPassword: 'Strong#Pass1' }))
.rejects
.toMatchObject({ code: 'invalid_credentials' })
expect(fetchMock).not.toHaveBeenCalled()
})
it('ошибка сервера (400 weak_password) пробрасывается; vault не сохраняется', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'weak_password', error_description: 'Пароль слишком короткий' }),
})
vi.stubGlobal('fetch', fetchMock)
await expect(migrate({ email: 'a@e.com', privateKey: WIF, newPassword: 'weakweak' }))
.rejects
.toMatchObject({ code: 'weak_password' })
// только POST /coop/migration, без POST /coop/vault
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/migration')
})
it('happy path: POST /coop/migration → username, затем saveToVault (POST /coop/vault)', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ username: 'ant' }) }) // migration
.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({}) }) // vault store
vi.stubGlobal('fetch', fetchMock)
const result = await migrate({ email: 'a@e.com', privateKey: WIF, newPassword: 'Strong#Pass1' })
expect(result).toEqual({ username: 'ant' })
// 1) запрос миграции
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/migration')
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(body.email).toBe('a@e.com')
expect(body.new_password).toBe('Strong#Pass1')
expect(typeof body.timestamp).toBe('string')
expect(body.signature).toMatch(/^SIG_/)
// 2) сохранение vault новым паролём (subject = возвращённый username)
expect(fetchMock.mock.calls[1][0]).toBe('https://coop.example/coop/vault')
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toMatchObject({ subject_type: 'participant', subject_id: 'ant' })
}, 60000)
})
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { authenticateWithFlowExecutor } from '../src/oidc/flow-executor'
const ISSUER = 'https://coop.example/application/o/coopid/'
const FLOW_URL = 'https://coop.example/api/v3/flows/executor/default-authentication-flow/?query='
afterEach(() => vi.unstubAllGlobals())
/** Ответ flow-executor: 200 + тело challenge'а. */
function challenge(body: unknown, status = 200) {
return { ok: status < 400, status, json: async () => body }
}
describe('authenticateWithFlowExecutor (Story 11.2) — встроенный фактор-1 authentik', () => {
it('раздельные стадии identification → password → redirect: успех, верные URL/тела/credentials', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-identification' })) // GET
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' })) // POST email
.mockResolvedValueOnce(challenge({ type: 'redirect' })) // POST password → завершение
vi.stubGlobal('fetch', fetchMock)
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'user@e.com', password: 'S3cret!' })
// GET старта flow
expect(fetchMock.mock.calls[0][0]).toBe(FLOW_URL)
expect(fetchMock.mock.calls[0][1].method).toBe('GET')
expect(fetchMock.mock.calls[0][1].credentials).toBe('include')
// POST identification — uid_field + password (совмещённая форма безопасна и для раздельной)
expect(fetchMock.mock.calls[1][1].method).toBe('POST')
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ uid_field: 'user@e.com', password: 'S3cret!' })
// POST password
expect(JSON.parse(fetchMock.mock.calls[2][1].body)).toEqual({ password: 'S3cret!' })
expect(fetchMock.mock.calls[2][1].credentials).toBe('include')
})
it('совмещённая identification сразу завершает flow (redirect)', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-identification' }))
.mockResolvedValueOnce(challenge({ type: 'redirect' }))
vi.stubGlobal('fetch', fetchMock)
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('завершение по стадии ak-stage-user-login (сессия установлена)', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
.mockResolvedValueOnce(challenge({ component: 'ak-stage-user-login' }))
vi.stubGlobal('fetch', fetchMock)
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })).resolves.toBeUndefined()
})
it('неверный пароль (response_errors, HTTP 400) → InvalidCredentials с сообщением authentik', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password', response_errors: { password: [{ string: 'Неверный пароль', code: 'invalid' }] } }, 400))
vi.stubGlobal('fetch', fetchMock)
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'bad' }))
.rejects
.toMatchObject({ code: 'invalid_credentials', message: 'Неверный пароль' })
})
it('ak-stage-access-denied → InvalidCredentials', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(challenge({ component: 'ak-stage-access-denied' })))
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
.rejects
.toMatchObject({ code: 'invalid_credentials' })
})
it('неподдерживаемая интерактивная стадия (MFA) → InvalidCredentials с её именем', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
.mockResolvedValueOnce(challenge({ component: 'ak-stage-authenticator-validate' }))
vi.stubGlobal('fetch', fetchMock)
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
.rejects
.toMatchObject({ code: 'invalid_credentials', message: expect.stringContaining('ak-stage-authenticator-validate') })
})
it('сеть упала на старте → NetworkError', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
.rejects
.toMatchObject({ code: 'network_error' })
})
it('эхо CSRF-cookie уходит в заголовке X-authentik-CSRF (same-origin)', async () => {
vi.stubGlobal('document', { cookie: 'foo=1; authentik_csrf=tok123; bar=2' })
const fetchMock = vi.fn()
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
.mockResolvedValueOnce(challenge({ type: 'redirect' }))
vi.stubGlobal('fetch', fetchMock)
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })
expect(fetchMock.mock.calls[0][1].headers['X-authentik-CSRF']).toBe('tok123')
expect(fetchMock.mock.calls[1][1].headers['X-authentik-CSRF']).toBe('tok123')
})
})
@@ -0,0 +1,94 @@
import { PrivateKey } from '@wharfkit/antelope'
import { SignJWT } from 'jose'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthV2Error } from '../src/errors'
import { performTimestampHandshake } from '../src/oidc/handshake'
import { clearSession, currentTokens } from '../src/oidc/tokens'
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PUB = PrivateKey.from(KEY).toPublic().toString()
const ACCOUNT = 'ant'
async function bindingTokenFor(sub: string, jti = 'jti-1'): Promise<string> {
return new SignJWT({ stage_completed: 'password' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(sub)
.setJti(jti)
.setIssuedAt()
.setExpirationTime('120s')
.sign(new TextEncoder().encode('test-binding-secret'))
}
function okJson(body: unknown) {
return { ok: true, status: 200, json: async () => body }
}
beforeEach(() => {
clearSession()
storeUnlocked({ account: ACCOUNT, publicKey: PUB, privateKey: KEY })
})
afterEach(() => {
wipeKeystore()
vi.unstubAllGlobals()
})
describe('performTimestampHandshake (Story 1.7) — handshake внутрь SDK', () => {
it('bind → sign → verify: токены/сертификат + сессия сохранена; credentials:include на обоих', async () => {
const binding = await bindingTokenFor(ACCOUNT)
const fetchMock = vi.fn()
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
.mockResolvedValueOnce(okJson({ access_token: 'acc', refresh_token: 'ref', participant_certificate: 'cert', degraded: false }))
vi.stubGlobal('fetch', fetchMock)
const r = await performTimestampHandshake('https://coop.example/')
expect(r).toMatchObject({ accessToken: 'acc', refreshToken: 'ref', participantCertificate: 'cert', degraded: false })
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/session/bind')
expect(fetchMock.mock.calls[0][1].credentials).toBe('include')
expect(fetchMock.mock.calls[1][0]).toBe('https://coop.example/coop/verify/timestamp')
expect(fetchMock.mock.calls[1][1].credentials).toBe('include')
const verifyBody = JSON.parse(fetchMock.mock.calls[1][1].body)
expect(verifyBody.binding_token).toBe(binding)
expect(typeof verifyBody.signature).toBe('string')
expect(typeof verifyBody.timestamp).toBe('string')
expect(currentTokens()).toEqual({ accessToken: 'acc', refreshToken: 'ref' })
})
it('degraded-вход (узел недоступен/ключ не финализирован) прокидывается', async () => {
const binding = await bindingTokenFor(ACCOUNT)
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
.mockResolvedValueOnce(okJson({ access_token: 'a', refresh_token: 'r', degraded: true, degraded_reason: 'rpc_unavailable' })))
const r = await performTimestampHandshake('https://coop.example')
expect(r.degraded).toBe(true)
expect(r.degradedReason).toBe('rpc_unavailable')
})
it('bind 401 → InvalidCredentials (сессия authentik не подтверждена), сессия не сохраняется', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}) }))
await expect(performTimestampHandshake('https://coop.example')).rejects.toBeInstanceOf(AuthV2Error)
expect(currentTokens()).toBeNull()
})
it('verify !ok → AuthV2Error с кодом из тела (OAuth2 error)', async () => {
const binding = await bindingTokenFor(ACCOUNT)
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ error: 'chain_verification_failed', error_description: 'Подпись не соответствует ключу' }) }))
await expect(performTimestampHandshake('https://coop.example')).rejects.toMatchObject({ code: 'chain_verification_failed' })
})
it('кошелёк заперт → WalletLocked (подпись невозможна, verify не вызывается)', async () => {
wipeKeystore()
const binding = await bindingTokenFor(ACCOUNT)
const fetchMock = vi.fn().mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
vi.stubGlobal('fetch', fetchMock)
await expect(performTimestampHandshake('https://coop.example')).rejects.toMatchObject({ code: 'wallet_locked' })
expect(fetchMock).toHaveBeenCalledTimes(1) // только bind, до verify не дошли
})
})
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Граница повторного входа (authentik-сессия + on-chain timestamp-handshake) — сетевые
// интеграции; мокаем их, чтобы юнит-тест проверял детерминированный confirm-флоу:
// генерацию пары, AAD vault'а, тело запроса confirm и маппинг ошибок. Крипто vault'а
// и antelope здесь настоящие — round-trip ключа проверяется по-настоящему.
vi.mock('../src/oidc/client', async importActual => ({
...(await importActual<typeof import('../src/oidc/client')>()),
authenticateWithAuthentik: vi.fn().mockResolvedValue({ id_token: 'ID_TOK' }),
}))
vi.mock('../src/oidc/handshake', async importActual => ({
...(await importActual<typeof import('../src/oidc/handshake')>()),
performTimestampHandshake: vi.fn().mockResolvedValue({ accessToken: 'AT', refreshToken: 'RT', participantCertificate: 'CERT', degraded: false }),
}))
vi.mock('../src/wallet', async importActual => ({
...(await importActual<typeof import('../src/wallet')>()),
unlockWallet: vi.fn().mockResolvedValue(undefined),
}))
const { PrivateKey } = await import('@wharfkit/antelope')
const { loginWithMagicLink } = await import('../src/oidc')
const { authenticateWithAuthentik, configureCoopId } = await import('../src/oidc/client')
const { performTimestampHandshake } = await import('../src/oidc/handshake')
const { decryptPrivateKey } = await import('../src/vault/encrypt')
const { unlockWallet } = await import('../src/wallet')
const BASE = 'https://coop.example'
// account больше НЕ параметр — сервер возвращает его в ответе confirm (резолвит из токена).
const ACCOUNT = 'ant'
const PARAMS = {
issuer: 'https://coop.example/application/o/coopid/',
email: 'ant@example.com',
token: 'magic-token-xyz',
totp: '123456',
newPassword: 'Strong#NewPass1',
}
beforeEach(() => configureCoopId({ apiUrl: BASE }))
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
describe('loginWithMagicLink (Story 12.2) — восстановление доступа', () => {
it('happy: новая пара → корректное тело confirm → vault round-trip → повторный вход', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ username: ACCOUNT }) })
vi.stubGlobal('fetch', fetchMock)
const result = await loginWithMagicLink(PARAMS)
// единственный сетевой вызов — confirm (unlock/handshake/authentik замоканы).
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/coop/recovery/confirm`)
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(body.token).toBe(PARAMS.token)
expect(body.code).toBe(PARAMS.totp)
expect(body.password).toBe(PARAMS.newPassword)
expect(body.public_key).toMatch(/^PUB_K1_/)
expect(body.vault).toMatchObject({ cipher_version: expect.any(String), salt: expect.any(String), ciphertext: expect.any(String) })
// AAD больше НЕ зависит от account: тот же блоб расшифровывается под ЛЮБЫМ
// subject_id (важен только тип субъекта) — регресс-гард решения 2026-06-15.
// И приватный ключ соответствует отправленному public_key — целостность пары.
const wif = await decryptPrivateKey(body.vault, PARAMS.newPassword, { subject_type: 'participant', subject_id: 'any-other-account' })
expect(PrivateKey.from(wif).toPublic().toString()).toBe(body.public_key)
// повторный вход новым контуром выполнен; account взят из ответа confirm.
expect(authenticateWithAuthentik).toHaveBeenCalledWith(expect.objectContaining({ email: PARAMS.email, password: PARAMS.newPassword, issuer: PARAMS.issuer }))
expect(unlockWallet).toHaveBeenCalledWith({ apiUrl: BASE, account: ACCOUNT, password: PARAMS.newPassword })
expect(performTimestampHandshake).toHaveBeenCalledWith(BASE)
expect(result).toEqual({ accessToken: 'AT', idToken: 'ID_TOK', participantCertificate: 'CERT' })
}, 60000)
it('confirm вернул 200 без username → InvalidRecoveryToken; повторный вход не запускается', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }))
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_recovery_token' })
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
expect(performTimestampHandshake).not.toHaveBeenCalled()
}, 60000)
it('неверный TOTP (400 invalid_2fa_code) → проброс; повторный вход не запускается', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'invalid_2fa_code', error_description: 'Неверный код' }) }))
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_2fa_code' })
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
expect(performTimestampHandshake).not.toHaveBeenCalled()
}, 60000)
it('недействительный токен (400 invalid_recovery_token) → проброс', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'invalid_recovery_token', error_description: 'Ссылка недействительна' }) }))
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_recovery_token' })
}, 60000)
it('429 → TooManyRecoveryAttempts', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }))
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'too_many_recovery_attempts' })
}, 60000)
it('сеть упала на confirm → NetworkError; повторный вход не запускается', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'network_error' })
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
}, 60000)
it('storage задан → локальная копия нового блоба сохранена под account', async () => {
const store = new Map<string, string>()
const storage = {
get: vi.fn(async (k: string) => store.get(k) ?? null),
set: vi.fn(async (k: string, v: string) => { store.set(k, v) }),
remove: vi.fn(async (k: string) => { store.delete(k) }),
}
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ username: ACCOUNT }) }))
await loginWithMagicLink({ ...PARAMS, storage })
expect(storage.set).toHaveBeenCalledTimes(1)
const saved = JSON.parse(storage.set.mock.calls[0][1] as string)
expect(saved.account).toBe(ACCOUNT)
expect(saved.blob).toMatchObject({ cipher_version: expect.any(String) })
}, 60000)
})
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { recover } from '../src/oidc'
import { configureCoopId } from '../src/oidc/client'
beforeEach(() => configureCoopId({ apiUrl: 'https://coop.example' }))
afterEach(() => vi.unstubAllGlobals())
describe('recover (Эпик 3) — запрос восстановления доступа', () => {
it('шлёт POST /coop/recovery/request с email (202 — нормальный путь, анти-enumeration)', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 202, json: async () => ({}) })
vi.stubGlobal('fetch', fetchMock)
await recover('user@example.com')
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/recovery/request')
expect(fetchMock.mock.calls[0][1].method).toBe('POST')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ email: 'user@example.com' })
})
it('429 → TooManyRecoveryAttempts', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }))
await expect(recover('u@e.com')).rejects.toMatchObject({ code: 'too_many_recovery_attempts' })
})
it('сеть упала → NetworkError', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
await expect(recover('u@e.com')).rejects.toMatchObject({ code: 'network_error' })
})
})
+134
View File
@@ -0,0 +1,134 @@
import type { StorageAdapter } from '../src/wallet'
import { SignJWT } from 'jose'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthV2Error } from '../src/errors'
import { clearSession, configureTokenStorage, currentTokens, getAccessToken, restoreSession, setSession } from '../src/oidc/tokens'
/** JWT с заданным exp (сек от now); содержимое не важно — getAccessToken читает только exp. */
async function jwtExpIn(seconds: number): Promise<string> {
return new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${seconds}s`)
.sign(new TextEncoder().encode('t'))
}
function okJson(body: unknown) {
return { ok: true, status: 200, json: async () => body }
}
/** In-memory StorageAdapter для тестов персистентности токенов. */
function memoryStorage(): StorageAdapter & { dump: () => Record<string, string> } {
const m = new Map<string, string>()
return {
get: async k => m.get(k) ?? null,
set: async (k, v) => void m.set(k, v),
remove: async k => void m.delete(k),
dump: () => Object.fromEntries(m),
}
}
beforeEach(() => clearSession())
afterEach(() => {
vi.unstubAllGlobals()
configureTokenStorage(null)
clearSession()
})
describe('getAccessToken lifecycle (Story 1.7) — токен внутри SDK, авто-refresh', () => {
it('нет активной сессии → бросает', async () => {
await expect(getAccessToken()).rejects.toBeInstanceOf(AuthV2Error)
})
it('свежий access → возвращается без обращения к сети', async () => {
const access = await jwtExpIn(3600)
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
expect(await getAccessToken()).toBe(access)
expect(fetchMock).not.toHaveBeenCalled()
})
it('истёкший access → refresh через /coop/refresh, новая пара сохраняется', async () => {
const expired = await jwtExpIn(-10)
const fresh = await jwtExpIn(3600)
setSession('https://coop.example/', { accessToken: expired, refreshToken: 'r-old' })
const fetchMock = vi.fn().mockResolvedValue(okJson({ access_token: fresh, refresh_token: 'r-new' }))
vi.stubGlobal('fetch', fetchMock)
expect(await getAccessToken()).toBe(fresh)
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/refresh')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ refresh_token: 'r-old' })
expect(currentTokens()).toEqual({ accessToken: fresh, refreshToken: 'r-new' })
})
it('refresh 401 → SessionBindingExpired (нужен повторный вход)', async () => {
const expired = await jwtExpIn(-10)
setSession('https://coop.example', { accessToken: expired, refreshToken: 'r' })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }))
await expect(getAccessToken()).rejects.toMatchObject({ code: 'session_binding_expired' })
})
})
describe('персистентность токенов (паритет с легаси — переживание reload)', () => {
it('setSession персистит пару токенов в storage', async () => {
const storage = memoryStorage()
const access = await jwtExpIn(3600)
configureTokenStorage(storage)
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
expect(JSON.stringify(storage.dump())).toContain(access)
})
it('restoreSession поднимает пару после «reload» (RAM пуста, storage цел) — сессия переживает F5', async () => {
const access = await jwtExpIn(3600)
// «До reload»: storage с записью, как её оставил setSession в прошлой сессии.
const storage = memoryStorage()
await storage.set('coopid.session.tokens', JSON.stringify({ apiBase: 'https://coop.example', tokens: { accessToken: access, refreshToken: 'r' } }))
// «После reload»: RAM пуста (beforeEach), подключаем storage и восстанавливаем.
configureTokenStorage(storage)
expect(await restoreSession()).toBe(true)
expect(currentTokens()).toEqual({ accessToken: access, refreshToken: 'r' })
expect(await getAccessToken()).toBe(access) // токен жив без сети
})
it('нет персистентной записи → restoreSession=false (нужен вход)', async () => {
configureTokenStorage(memoryStorage())
expect(await restoreSession()).toBe(false)
await expect(getAccessToken()).rejects.toBeInstanceOf(AuthV2Error)
})
it('активная RAM-сессия не перетирается restoreSession', async () => {
const access = await jwtExpIn(3600)
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
configureTokenStorage(memoryStorage()) // пустой storage
expect(await restoreSession()).toBe(true) // уже в RAM
expect(currentTokens()).toEqual({ accessToken: access, refreshToken: 'r' })
})
it('refresh обновляет персистентную копию (на диске свежий refresh-токен)', async () => {
const storage = memoryStorage()
const expired = await jwtExpIn(-10)
const fresh = await jwtExpIn(3600)
configureTokenStorage(storage)
setSession('https://coop.example', { accessToken: expired, refreshToken: 'r-old' })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okJson({ access_token: fresh, refresh_token: 'r-new' })))
expect(await getAccessToken()).toBe(fresh)
const dump = JSON.stringify(storage.dump())
expect(dump).toContain('r-new')
expect(dump).not.toContain('r-old')
})
it('clearSession стирает персистентную копию (logout)', async () => {
const storage = memoryStorage()
const access = await jwtExpIn(3600)
configureTokenStorage(storage)
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
expect(Object.keys(storage.dump())).toHaveLength(1)
clearSession()
expect(Object.keys(storage.dump())).toHaveLength(0)
})
})
+123
View File
@@ -0,0 +1,123 @@
import type { StorageAdapter } from '../src/wallet'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import {
clearPinCache,
DEFAULT_PIN,
getWallet,
hasPinCache,
isWalletUnlocked,
lockWallet,
persistPinCache,
unlockWithPin,
} from '../src/wallet'
import { storeUnlocked } from '../src/wallet/storage'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const ACCOUNT = 'ant'
const PUB = 'PUB_K1_'
/** In-memory StorageAdapter (в браузере — localStorage/IndexedDB, тут — Map). */
function memoryStorage(): StorageAdapter & { dump: () => Record<string, string> } {
const m = new Map<string, string>()
return {
get: async k => m.get(k) ?? null,
set: async (k, v) => void m.set(k, v),
remove: async k => void m.delete(k),
dump: () => Object.fromEntries(m),
}
}
/** Кладёт ключ в RAM-keystore, как это делает unlockWallet после входа паролём. */
function putUnlocked(): void {
// publicKey деривуется внутри unlockWallet/unlockWithPin; здесь для setup
// достаточно любого валидного вида — реальный pub проверяем после unlockWithPin.
storeUnlocked({ account: ACCOUNT, publicKey: 'PUB_K1_setup', privateKey: KEY })
}
beforeEach(() => {
lockWallet()
})
afterEach(() => {
lockWallet()
})
describe('pin-кэш: persist → unlock без пароля и round-trip к серверу', () => {
it('кастомный ПИН: persist под ПИН, lock, unlock тем же ПИН → тот же account и валидный publicKey', async () => {
const storage = memoryStorage()
putUnlocked()
await persistPinCache({ pin: '135790', storage })
lockWallet()
expect(isWalletUnlocked()).toBe(false)
const wallet = await unlockWithPin({ pin: '135790', storage })
expect(wallet).not.toBeNull()
expect(wallet!.account).toBe(ACCOUNT)
expect(wallet!.publicKey).toMatch(new RegExp(`^${PUB}`))
expect(isWalletUnlocked()).toBe(true)
// keystore реально наполнен правильным ключом — getWallet отдаёт тот же pub
expect((await getWallet()).publicKey).toBe(wallet!.publicKey)
}, 30000)
it('дефолтный ПИН (000000): persist и unlock без аргумента pin — прозрачно', async () => {
const storage = memoryStorage()
putUnlocked()
await persistPinCache({ storage }) // pin не задан → DEFAULT_PIN
lockWallet()
const wallet = await unlockWithPin({ storage }) // pin не задан → DEFAULT_PIN
expect(wallet).not.toBeNull()
expect(wallet!.account).toBe(ACCOUNT)
// тем же значением расшифровывается и явный DEFAULT_PIN
lockWallet()
expect(await unlockWithPin({ pin: DEFAULT_PIN, storage })).not.toBeNull()
}, 30000)
it('неверный ПИН → VaultDecryptionFailed, keystore остаётся пуст', async () => {
const storage = memoryStorage()
putUnlocked()
await persistPinCache({ pin: '111111', storage })
lockWallet()
const err = await unlockWithPin({ pin: '999999', storage }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
expect(isWalletUnlocked()).toBe(false)
}, 30000)
})
describe('pin-кэш: отсутствие кэша и жизненный цикл', () => {
it('нет кэша → unlockWithPin возвращает null (нужен полный вход паролём)', async () => {
const storage = memoryStorage()
expect(await unlockWithPin({ storage })).toBeNull()
expect(isWalletUnlocked()).toBe(false)
})
it('hasPinCache: false → persist → true → clear → false', async () => {
const storage = memoryStorage()
expect(await hasPinCache(storage)).toBe(false)
putUnlocked()
await persistPinCache({ storage })
expect(await hasPinCache(storage)).toBe(true)
await clearPinCache(storage)
expect(await hasPinCache(storage)).toBe(false)
}, 30000)
it('persistPinCache при запертом кошельке → WalletLocked (нечего кэшировать)', async () => {
const storage = memoryStorage()
lockWallet()
const err = await persistPinCache({ storage }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
})
describe('pin-кэш: at-rest — на диске только шифр', () => {
it('сохранённая запись не содержит WIF в открытом виде', async () => {
const storage = memoryStorage()
putUnlocked()
await persistPinCache({ pin: '424242', storage })
expect(JSON.stringify(storage.dump())).not.toContain(KEY)
}, 30000)
})
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import {
compareSchemaVersions,
type CoopIdSchemaPolicy,
createSchemaPolicyCache,
isSchemaVersionSupported,
SCHEMA_POLICY_CACHE_TTL_MS,
} from '../src/index'
const POLICY: CoopIdSchemaPolicy = { current_version: '1', min_supported_version: '1', deprecation: { 0: '2026-01-01' } }
describe('compareSchemaVersions (Story 4.10)', () => {
it('числовое сравнение версий', () => {
expect(compareSchemaVersions('0', '1')).toBeLessThan(0)
expect(compareSchemaVersions('2', '1')).toBeGreaterThan(0)
expect(compareSchemaVersions('1', '1')).toBe(0)
})
it('нечисловая версия → лексикографический фолбэк', () => {
expect(compareSchemaVersions('a', 'b')).toBeLessThan(0)
expect(compareSchemaVersions('v2', 'v2')).toBe(0)
})
it('isSchemaVersionSupported: не старее минимума', () => {
expect(isSchemaVersionSupported('1', '1')).toBe(true)
expect(isSchemaVersionSupported('2', '1')).toBe(true)
expect(isSchemaVersionSupported('0', '1')).toBe(false)
})
})
describe('createSchemaPolicyCache (Story 4.10, FR72)', () => {
it('в пределах TTL отдаёт кэш без повторного fetch', async () => {
let t = 1000
const fetchPolicy = vi.fn().mockResolvedValue(POLICY)
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
expect(await cache.getMinSupportedVersion()).toBe('1')
t += SCHEMA_POLICY_CACHE_TTL_MS - 1
await cache.getPolicy()
expect(fetchPolicy).toHaveBeenCalledTimes(1)
})
it('после истечения TTL рефетчит политику', async () => {
let t = 1000
const fetchPolicy = vi.fn().mockResolvedValue(POLICY)
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
await cache.getPolicy()
t += SCHEMA_POLICY_CACHE_TTL_MS + 1
await cache.getPolicy()
expect(fetchPolicy).toHaveBeenCalledTimes(2)
})
it('офлайн (fetch упал) после истечения TTL → отдаёт stale-кэш', async () => {
let t = 1000
const fetchPolicy = vi.fn()
.mockResolvedValueOnce(POLICY)
.mockRejectedValueOnce(new Error('offline'))
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
await cache.getPolicy()
t += SCHEMA_POLICY_CACHE_TTL_MS + 1
expect(await cache.getMinSupportedVersion()).toBe('1')
})
it('нет кэша и fetch упал → пробрасывает ошибку', async () => {
const fetchPolicy = vi.fn().mockRejectedValue(new Error('offline'))
const cache = createSchemaPolicyCache({ fetchPolicy })
await expect(cache.getPolicy()).rejects.toThrow('offline')
})
})
+178
View File
@@ -0,0 +1,178 @@
import { Checksum256, PrivateKey, PublicKey, Signature } from '@wharfkit/antelope'
import { base64url, SignJWT } from 'jose'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import { canonicalTimestampMessage, signChainDigest, signDocument, signTimestamp } from '../src/signing'
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const ACCOUNT = 'ant'
const PUB = PrivateKey.from(KEY).toPublic().toString()
/** Поддельный, но корректно сформированный session_binding_token (signTimestamp его только декодирует). */
async function makeToken(sub = ACCOUNT, jti = 'jti-abc-123'): Promise<string> {
return new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(sub)
.setJti(jti)
.setExpirationTime('2m')
.sign(new TextEncoder().encode('test-secret-padding-0000000000000000'))
}
beforeEach(() => {
storeUnlocked({ account: ACCOUNT, publicKey: PUB, privateKey: KEY })
})
afterEach(() => {
wipeKeystore()
})
describe('signTimestamp: COOPOS-native recoverable подпись', () => {
it('подписывает {ts,jti,sub}; recoverMessage канонического сообщения даёт тот же pubkey', async () => {
const token = await makeToken(ACCOUNT, 'jti-abc-123')
const res = await signTimestamp({ sessionBindingToken: token })
expect(res.sub).toBe(ACCOUNT)
expect(res.binding_token_jti).toBe('jti-abc-123')
expect(res.public_key).toBe(PUB)
expect(res.signature).toMatch(/^SIG_K1_/)
const msg = new TextEncoder().encode(canonicalTimestampMessage({
ts: res.ts,
binding_token_jti: res.binding_token_jti,
sub: res.sub,
}))
const recovered = Signature.from(res.signature).recoverMessage(msg).toString()
expect(recovered).toBe(PUB)
})
it('подмена сообщения ломает восстановление pubkey (integrity)', async () => {
const res = await signTimestamp({ sessionBindingToken: await makeToken() })
const tampered = new TextEncoder().encode(canonicalTimestampMessage({
ts: res.ts,
binding_token_jti: res.binding_token_jti,
sub: 'someone-else',
}))
const recovered = Signature.from(res.signature).recoverMessage(tampered).toString()
expect(recovered).not.toBe(PUB)
})
it('запертый кошелёк → WalletLocked', async () => {
wipeKeystore()
const err = await signTimestamp({ sessionBindingToken: await makeToken() }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
it('кошелёк не совпадает с субъектом токена → ClientWalletMismatch', async () => {
const err = await signTimestamp({ sessionBindingToken: await makeToken('petrov') }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ClientWalletMismatch)
})
it('битый токен → SessionBindingExpired', async () => {
const err = await signTimestamp({ sessionBindingToken: 'not-a-jwt' }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.SessionBindingExpired)
})
it('токен без sub/jti → SessionBindingExpired', async () => {
const noClaims = await new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.sign(new TextEncoder().encode('test-secret-padding-0000000000000000'))
const err = await signTimestamp({ sessionBindingToken: noClaims }).then(() => null, e => e)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.SessionBindingExpired)
})
})
describe('signChainDigest: подпись tx-дайджеста ключом из keystore (мост подписи CoopID)', () => {
it('подписывает дайджест; recoverDigest даёт pubkey подписанта (ключ наружу не выходит — только подпись)', async () => {
const digestHex = 'ab'.repeat(32) // 32 байта signing-дайджеста
const sig = await signChainDigest(digestHex)
expect(sig).toMatch(/^SIG_K1_/)
const recovered = Signature.from(sig).recoverDigest(Checksum256.from(digestHex)).toString()
expect(recovered).toBe(PUB)
})
it('подмена дайджеста ломает восстановление pubkey (integrity)', async () => {
const sig = await signChainDigest('ab'.repeat(32))
const recovered = Signature.from(sig).recoverDigest(Checksum256.from('cd'.repeat(32))).toString()
expect(recovered).not.toBe(PUB)
})
it('запертый кошелёк → WalletLocked (подпись транзакции невозможна без unlock)', async () => {
wipeKeystore()
const err = await signChainDigest('ab'.repeat(32)).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
})
describe('canonicalTimestampMessage: детерминизм', () => {
it('фиксированный алфавитный порядок ключей', () => {
expect(canonicalTimestampMessage({ ts: 't', binding_token_jti: 'j', sub: 's' }))
.toBe('{"binding_token_jti":"j","sub":"s","ts":"t"}')
})
})
/** Верифицирует compact JWS ES256K: разбирает подпись R||S и проверяет её ключом. */
function verifyJws(jws: string, pub: string): boolean {
const [h, p, s] = jws.split('.')
const rs = base64url.decode(s)
const sig = Signature.from({ type: 'K1', r: rs.slice(0, 32), s: rs.slice(32, 64), recid: 0 })
return sig.verifyMessage(new TextEncoder().encode(`${h}.${p}`), PublicKey.from(pub))
}
describe('signDocument: локальная подпись compact JWS (Story 2.3)', () => {
it('возвращает JWS <header>.<payload>.<signature>; header alg/kid, payload round-trip, подпись валидна', async () => {
const doc = new TextEncoder().encode('Протокол собрания №1')
const jws = await signDocument({ payload: doc, alg: 'ES256K' })
const parts = jws.split('.')
expect(parts).toHaveLength(3)
const header = JSON.parse(new TextDecoder().decode(base64url.decode(parts[0])))
expect(header).toEqual({ alg: 'ES256K', kid: ACCOUNT })
// payload декодируется обратно в исходные байты
expect(base64url.decode(parts[1])).toEqual(doc)
// подпись — ровно 64 байта (R||S, без recovery) и валидна для pubkey подписанта
expect(base64url.decode(parts[2]).length).toBe(64)
expect(verifyJws(jws, PUB)).toBe(true)
})
it('строковый payload эквивалентен его UTF-8 байтам', async () => {
const text = 'привет, кооператив'
const fromString = await signDocument({ payload: text })
const fromBytes = await signDocument({ payload: new TextEncoder().encode(text) })
// RFC6979-детерминизм K1: одинаковый вход → одинаковый JWS
expect(fromString).toBe(fromBytes)
expect(verifyJws(fromString, PUB)).toBe(true)
})
it('integrity: подделка payload в JWS ломает проверку подписи', async () => {
const jws = await signDocument({ payload: 'исходный документ' })
const [h, , s] = jws.split('.')
const tampered = `${h}.${base64url.encode(new TextEncoder().encode('подменённый документ'))}.${s}`
expect(verifyJws(tampered, PUB)).toBe(false)
})
it('alg по умолчанию — ES256K', async () => {
const jws = await signDocument({ payload: 'x' })
const header = JSON.parse(new TextDecoder().decode(base64url.decode(jws.split('.')[0])))
expect(header.alg).toBe('ES256K')
})
it('неподдерживаемый alg → AuthV2Error', async () => {
const err = await signDocument({ payload: 'x', alg: 'RS256' as 'ES256K' }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
})
it('запертый кошелёк → WalletLocked', async () => {
wipeKeystore()
const err = await signDocument({ payload: 'x' }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
})
+94
View File
@@ -0,0 +1,94 @@
import type { StorageAdapter } from '../src/wallet'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { decryptPrivateKey } from '../src/vault'
import { clearLocalVault, loadLocalVault, saveLocalVault, saveToVault, storeVaultBlob } from '../src/wallet'
const BLOB = {
cipher_version: 'aes-256-gcm-v1',
kdf_version: 'argon2id-v1',
salt: 'c2FsdA',
nonce: 'bm9uY2U',
ciphertext: 'Y2lwaGVy',
auth_tag: 'dGFn',
}
/** In-memory StorageAdapter для теста локальной копии. */
function memStorage(): StorageAdapter & { map: Map<string, string> } {
const map = new Map<string, string>()
return {
map,
get: async k => map.get(k) ?? null,
set: async (k, v) => void map.set(k, v),
remove: async k => void map.delete(k),
}
}
afterEach(() => vi.unstubAllGlobals())
describe('storeVaultBlob (Story 11.3) — POST /coop/vault', () => {
it('шлёт POST с плоскими subject_type/subject_id + полями blob; 201 ok', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({}) })
vi.stubGlobal('fetch', fetchMock)
await storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB)
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/vault')
expect(fetchMock.mock.calls[0][1].method).toBe('POST')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ ...BLOB, subject_type: 'participant', subject_id: 'ant' })
})
it('не-ok ответ → NetworkError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({}) }))
await expect(storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB))
.rejects
.toMatchObject({ code: 'network_error' })
})
it('сеть упала → NetworkError', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
await expect(storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB))
.rejects
.toMatchObject({ code: 'network_error' })
})
})
describe('локальная копия vault (Story 11.3)', () => {
it('save → load возвращает blob того же аккаунта', async () => {
const storage = memStorage()
await saveLocalVault(storage, 'ant', BLOB)
expect(await loadLocalVault(storage, 'ant')).toEqual(BLOB)
})
it('load для другого аккаунта → null (чужой blob не отдаём)', async () => {
const storage = memStorage()
await saveLocalVault(storage, 'ant', BLOB)
expect(await loadLocalVault(storage, 'someone-else')).toBeNull()
})
it('нет записи → null; clear удаляет', async () => {
const storage = memStorage()
expect(await loadLocalVault(storage, 'ant')).toBeNull()
await saveLocalVault(storage, 'ant', BLOB)
await clearLocalVault(storage)
expect(await loadLocalVault(storage, 'ant')).toBeNull()
})
})
describe('saveToVault (Story 11.3) — encrypt → POST → локальная копия', () => {
it('шифрует ключ паролём, шлёт на сервер и кладёт локально; blob расшифровывается обратно', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({}) })
vi.stubGlobal('fetch', fetchMock)
const storage = memStorage()
const wif = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const blob = await saveToVault({ apiUrl: 'https://coop.example', account: 'ant', privateKey: wif, password: 'P@ss', storage })
// сервер получил blob с субъектом ant
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/vault')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ subject_type: 'participant', subject_id: 'ant', cipher_version: 'aes-256-gcm-v1' })
// локальная копия сохранена
expect(await loadLocalVault(storage, 'ant')).toEqual(blob)
// round-trip: тем же паролём ключ восстанавливается (неверный пароль покрыт wallet.test.ts)
expect(await decryptPrivateKey(blob, 'P@ss', { subject_type: 'participant', subject_id: 'ant' })).toBe(wif)
}, 60000)
})
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import { decryptPrivateKey, encryptPrivateKey } from '../src/vault'
import type { VaultSubject } from '../src/vault'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PW = 'correct-horse-battery-staple-12'
const SUBJECT: VaultSubject = { subject_type: 'participant', subject_id: 'ant' }
describe('vault: Argon2id + AES-256-GCM', () => {
it('round-trip: encrypt → decrypt тем же паролем даёт исходный ключ', async () => {
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
expect(blob.cipher_version).toBe('aes-256-gcm-v1')
expect(blob.kdf_version).toBe('argon2id-v1')
const restored = await decryptPrivateKey(blob, PW, SUBJECT)
expect(restored).toBe(KEY)
})
it('каждое шифрование — новые salt и nonce', async () => {
const a = await encryptPrivateKey(KEY, PW, SUBJECT)
const b = await encryptPrivateKey(KEY, PW, SUBJECT)
expect(a.salt).not.toBe(b.salt)
expect(a.nonce).not.toBe(b.nonce)
expect(a.ciphertext).not.toBe(b.ciphertext)
})
it('неверный пароль → VaultDecryptionFailed', async () => {
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
const err = await decryptPrivateKey(blob, 'wrong-password-000000000000', SUBJECT).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
})
it('подмена subject_id НЕ ломает расшифровку: AAD не зависит от account (решение 2026-06-15)', async () => {
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
// другой subject_id, тот же тип и пароль → расшифровка проходит (id не в AAD).
const restored = await decryptPrivateKey(blob, PW, { subject_type: 'participant', subject_id: 'petrov' })
expect(restored).toBe(KEY)
})
it('подмена типа субъекта (AAD) ломает расшифровку', async () => {
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
const err = await decryptPrivateKey(blob, PW, { subject_type: 'coop', subject_id: 'ant' }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
})
}, 30000)
@@ -0,0 +1,82 @@
import { PrivateKey } from '@wharfkit/antelope'
import { base64url } from 'jose'
import { afterEach, describe, expect, it } from 'vitest'
import { type KeyValidityWindow, verifyDocumentOffline } from '../src/index'
import { signDocument } from '../src/signing'
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
const KEY1 = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PUB1 = PrivateKey.from(KEY1).toPublic().toString()
const ACCOUNT = 'ant'
// Второй ключ — результат ротации (Story 3.3). Генерим, чтобы не хардкодить.
const key2 = PrivateKey.generate('K1')
const KEY2 = key2.toString()
const PUB2 = key2.toPublic().toString()
// key1 активен в блоках [100,200], затем ротация на key2 [201, сейчас].
const authority: KeyValidityWindow[] = [
{ public_key: PUB1, valid_from_block: 100, valid_to_block: 200 },
{ public_key: PUB2, valid_from_block: 201, valid_to_block: null },
]
async function signWith(wif: string, payload: string): Promise<string> {
storeUnlocked({ account: ACCOUNT, publicKey: PrivateKey.from(wif).toPublic().toString(), privateKey: wif })
const jws = await signDocument({ payload })
wipeKeystore()
return jws
}
afterEach(() => wipeKeystore())
describe('verifyDocumentOffline: backward-валидность (Story 2.5)', () => {
it('подпись старым ключом валидна, если ключ был активен на момент подписи (хотя сейчас ротирован)', async () => {
const jws = await signWith(KEY1, 'протокол собрания №7')
const res = await verifyDocumentOffline({ jws, signedAtBlock: 150, authority })
expect(res.valid).toBe(true)
expect(res.matched_key).toBe(PUB1)
})
it('та же подпись на блоке ПОСЛЕ ротации → невалидна (key_not_active_at_signing)', async () => {
const jws = await signWith(KEY1, 'протокол собрания №7')
const res = await verifyDocumentOffline({ jws, signedAtBlock: 250, authority })
expect(res.valid).toBe(false)
expect(res.reason).toBe('key_not_active_at_signing')
})
it('подпись текущим ключом в его окне → валидна', async () => {
const jws = await signWith(KEY2, 'протокол собрания №8')
const res = await verifyDocumentOffline({ jws, signedAtBlock: 300, authority })
expect(res.valid).toBe(true)
expect(res.matched_key).toBe(PUB2)
})
it('подделка payload → signature_mismatch', async () => {
const jws = await signWith(KEY1, 'оригинальный документ')
const [h, , s] = jws.split('.')
const tampered = `${h}.${base64url.encode(new TextEncoder().encode('подменённый документ'))}.${s}`
const res = await verifyDocumentOffline({ jws: tampered, signedAtBlock: 150, authority })
expect(res.valid).toBe(false)
expect(res.reason).toBe('signature_mismatch')
})
it('чужой ключ (нет в хронологии подписанта) → signature_mismatch', async () => {
const jws = await signWith(KEY2, 'документ')
const onlyKey1: KeyValidityWindow[] = [{ public_key: PUB1, valid_from_block: 100, valid_to_block: 200 }]
const res = await verifyDocumentOffline({ jws, signedAtBlock: 150, authority: onlyKey1 })
expect(res.valid).toBe(false)
expect(res.reason).toBe('signature_mismatch')
})
it('некорректный JWS → malformed_jws', async () => {
expect((await verifyDocumentOffline({ jws: 'a.b', signedAtBlock: 1, authority })).reason).toBe('malformed_jws')
})
it('неподдерживаемый alg → unsupported_alg', async () => {
const h = base64url.encode(new TextEncoder().encode(JSON.stringify({ alg: 'RS256' })))
const p = base64url.encode(new TextEncoder().encode('x'))
const sig = base64url.encode(new Uint8Array(64))
const res = await verifyDocumentOffline({ jws: `${h}.${p}.${sig}`, signedAtBlock: 1, authority })
expect(res.reason).toBe('unsupported_alg')
})
})
+167
View File
@@ -0,0 +1,167 @@
import type { PrivateKey as PrivateKeyType } from '@wharfkit/antelope'
import { PrivateKey } from '@wharfkit/antelope'
import { base64url } from 'jose'
import { describe, expect, it } from 'vitest'
import { type CoopChainLink, verifyOffline } from '../src/index'
// Цепь доверия ano → voskhod → vostok(issuer). Ключи генерим, чтобы не хардкодить.
const anoKey = PrivateKey.generate('K1')
const voskhodKey = PrivateKey.generate('K1')
const issuerKey = PrivateKey.generate('K1') // vostok — издатель, его ключом подписан cert
const PUB_ANO = anoKey.toPublic().toString()
const PUB_VOSKHOD = voskhodKey.toPublic().toString()
const PUB_ISSUER = issuerKey.toPublic().toString()
const CHAIN: CoopChainLink[] = [
{ account: 'ano', public_key: PUB_ANO },
{ account: 'voskhod', public_key: PUB_VOSKHOD },
{ account: 'vostok', public_key: PUB_ISSUER },
]
// Полный доверенный кэш (chain_manifests_cache) — все звенья известны.
const TRUSTED: Record<string, string> = { ano: PUB_ANO, voskhod: PUB_VOSKHOD, vostok: PUB_ISSUER }
const NOW = 1_900_000_000_000 // фиксированный «сейчас» (мс)
const FUTURE_EXP = Math.floor(NOW / 1000) + 3600 // +1ч
const PAST_EXP = Math.floor(NOW / 1000) - 10
interface CertOpts {
chain?: CoopChainLink[]
exp?: number
alg?: string
signWith?: PrivateKeyType
schemaVersion?: string
}
/** Собрать compact JWS-сертификат (формат CertificateService, Story 1.8). */
function makeCert(opts: CertOpts = {}): string {
const signer = opts.signWith ?? issuerKey
const header = base64url.encode(JSON.stringify({ alg: opts.alg ?? 'ES256K', typ: 'JWT', kid: signer.toPublic().toString() }))
const payload = base64url.encode(JSON.stringify({
coopname: 'vostok',
coop_chain: opts.chain ?? CHAIN,
exp: opts.exp ?? FUTURE_EXP,
sub: 'uuid-1',
jti: 'serial-123',
claim_schema_version: opts.schemaVersion ?? '1',
}))
const signingInput = `${header}.${payload}`
const sig = signer.signMessage(new TextEncoder().encode(signingInput))
const rs = sig.data.array.slice(1) // [recovery(1), r(32), s(32)] → R||S
return `${signingInput}.${base64url.encode(rs)}`
}
describe('verifyOffline: офлайн-проверка удостоверения (Story 4.4)', () => {
it('валидный сертификат с доверенной цепью и якорем → valid, issuer', async () => {
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(true)
expect(res.issuer).toBe('vostok')
expect(res.reason).toBeUndefined()
})
it('exp в прошлом → expired', async () => {
const res = await verifyOffline(makeCert({ exp: PAST_EXP }), { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(false)
expect(res.reason).toBe('expired')
})
it('цепь не укоренена в известном ano (чужой якорь) → untrusted_anchor', async () => {
const other = PrivateKey.generate('K1').toPublic().toString()
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, trustAnchor: other, now: NOW })
expect(res.valid).toBe(false)
expect(res.reason).toBe('untrusted_anchor')
})
it('звено издателя не совпадает с доверенным кэшем → untrusted_issuer', async () => {
const foreign = PrivateKey.generate('K1').toPublic().toString()
const res = await verifyOffline(makeCert(), {
trustedKeys: { ano: PUB_ANO, voskhod: PUB_VOSKHOD, vostok: foreign },
now: NOW,
})
expect(res.valid).toBe(false)
expect(res.reason).toBe('untrusted_issuer')
})
it('без доверенного кэша ключей → fail-closed (untrusted_issuer)', async () => {
const res = await verifyOffline(makeCert(), { trustAnchor: PUB_ANO, now: NOW })
expect(res.valid).toBe(false)
expect(res.reason).toBe('untrusted_issuer')
})
it('подпись чужим ключом при валидной цепи → signature_mismatch', async () => {
// cert подписан foreignKey, но coop_chain заявляет издателя PUB_ISSUER;
// verifyOffline сверяет подпись с ключом издателя из цепи (не с kid) → провал.
const cert = makeCert({ signWith: PrivateKey.generate('K1') })
const res = await verifyOffline(cert, { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(false)
expect(res.reason).toBe('signature_mismatch')
})
it('high-S подпись (не каноническая, как у jose/Node) → принимается через low-S нормализацию', async () => {
const cert = makeCert()
const [h, p, s] = cert.split('.')
const rs = base64url.decode(s)
// S → n − S (переводим каноническую low-S подпись wharfkit в high-S вариант).
const n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n
let sVal = 0n
for (const b of rs.slice(32, 64))
sVal = (sVal << 8n) | BigInt(b)
let high = n - sVal
const highRs = new Uint8Array(rs)
for (let i = 63; i >= 32; i--) {
highRs[i] = Number(high & 0xFFn)
high >>= 8n
}
const highCert = `${h}.${p}.${base64url.encode(highRs)}`
const res = await verifyOffline(highCert, { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(true)
expect(res.issuer).toBe('vostok')
})
it('некорректный JWS (две части) → malformed_certificate', async () => {
const res = await verifyOffline('a.b', { trustedKeys: TRUSTED, now: NOW })
expect(res.reason).toBe('malformed_certificate')
})
it('неподдерживаемый alg → unsupported_alg', async () => {
const res = await verifyOffline(makeCert({ alg: 'RS256' }), { trustedKeys: TRUSTED, now: NOW })
expect(res.reason).toBe('unsupported_alg')
})
it('пустой coop_chain → malformed_certificate', async () => {
const res = await verifyOffline(makeCert({ chain: [] }), { trustedKeys: TRUSTED, now: NOW })
expect(res.reason).toBe('malformed_certificate')
})
it('версия схемы старее min_supported_version → unsupported_schema_version (Story 4.10)', async () => {
const res = await verifyOffline(makeCert({ schemaVersion: '0' }), { trustedKeys: TRUSTED, now: NOW, minSchemaVersion: '1' })
expect(res.valid).toBe(false)
expect(res.reason).toBe('unsupported_schema_version')
})
it('версия схемы = min_supported_version → ось схемы пройдена, cert валиден (Story 4.10)', async () => {
const res = await verifyOffline(makeCert({ schemaVersion: '1' }), { trustedKeys: TRUSTED, now: NOW, minSchemaVersion: '1' })
expect(res.valid).toBe(true)
expect(res.issuer).toBe('vostok')
})
it('без minSchemaVersion ось схемы не гейтит — даже старая версия проходит (Story 4.10)', async () => {
const res = await verifyOffline(makeCert({ schemaVersion: '0' }), { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(true)
})
it('не делает сетевых запросов (fetch недоступен) → всё равно verdict', async () => {
const orig = globalThis.fetch
globalThis.fetch = (() => {
throw new Error('сеть запрещена в офлайн-проверке')
}) as typeof fetch
try {
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, now: NOW })
expect(res.valid).toBe(true)
}
finally {
globalThis.fetch = orig
}
})
})
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import { encryptPrivateKey } from '../src/vault'
import {
getWallet,
isWalletUnlocked,
lockWallet,
unlockWallet,
Wallet,
} from '../src/wallet'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PW = 'correct-horse-battery-staple-12'
const ACCOUNT = 'ant'
const API = 'http://stub'
/** Подменяет fetch так, чтобы GET vault отдавал заранее зашифрованный blob. */
async function stubVaultFetch(password = PW): Promise<void> {
const blob = await encryptPrivateKey(KEY, password, { subject_type: 'participant', subject_id: ACCOUNT })
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => blob }) as any))
}
beforeEach(() => {
lockWallet()
})
afterEach(() => {
vi.unstubAllGlobals()
lockWallet()
})
describe('wallet: unlock + in-memory keystore + getWallet', () => {
it('unlock → getWallet отдаёт account и деривованный publicKey', async () => {
await stubVaultFetch()
const wallet = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
expect(wallet.account).toBe(ACCOUNT)
expect(wallet.publicKey).toMatch(/^PUB_K1_/)
expect(isWalletUnlocked()).toBe(true)
const again = await getWallet()
expect(again.publicKey).toBe(wallet.publicKey)
}, 30000)
it('неверный пароль → VaultDecryptionFailed, keystore остаётся пуст', async () => {
await stubVaultFetch(PW)
const err = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: 'wrong-password-000000000000' })
.then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
expect(isWalletUnlocked()).toBe(false)
}, 30000)
it('getWallet при запертом кошельке → WalletLocked', async () => {
const err = await getWallet().then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
})
it('сеть недоступна → NetworkError', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) }) as any))
const err = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW }).then(() => null, e => e)
expect(err).toBeInstanceOf(AuthV2Error)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.NetworkError)
})
})
describe('wallet: несериализуемость приватного ключа', () => {
it('JSON.stringify(wallet) не раскрывает приватный ключ', async () => {
await stubVaultFetch()
const wallet = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
const serialized = JSON.stringify(wallet)
expect(serialized).not.toContain(KEY)
expect(JSON.parse(serialized)).toEqual({ account: ACCOUNT, publicKey: wallet.publicKey })
// приватного ключа нет ни в одном перечислимом свойстве обёртки
expect(Object.values(wallet as unknown as Record<string, unknown>)).not.toContain(KEY)
expect(wallet).toBeInstanceOf(Wallet)
}, 30000)
})
describe('wallet: lock/wipe', () => {
it('lockWallet затирает keystore → getWallet бросает; идемпотентно', async () => {
await stubVaultFetch()
await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
expect(isWalletUnlocked()).toBe(true)
lockWallet()
expect(isWalletUnlocked()).toBe(false)
lockWallet() // повторно — без ошибки
const err = await getWallet().then(() => null, e => e)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
}, 30000)
})
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"strict": true,
"strictNullChecks": true,
"noEmit": true,
"esModuleInterop": true,
"skipDefaultLibCheck": true,
"skipLibCheck": true
},
"include": ["src/**/*", "test/**/*"]
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Браузерный таргет cross-runtime smoke (Story 9.13): vitest browser mode,
* playwright + chromium headless. Гоняет ТОЛЬКО test/cross-runtime/** —
* остальные юнит-тесты пакета остаются Node-таргетом (`pnpm test`).
*/
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/cross-runtime/**/*.test.ts'],
browser: {
enabled: true,
provider: 'playwright',
name: 'chromium',
headless: true,
screenshotFailures: false,
},
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.7.21-1",
"version": "2026.6.9-18",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+1 -8
View File
@@ -35,14 +35,7 @@ RUN npm install -g pnpm@10.33.0
COPY . .
# --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..."
RUN pnpm install --frozen-lockfile --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.7.21-1",
"version": "2026.6.9-18",
"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: 'capital',
type: 'blagorost',
title: 'Целевая потребительская программа "Благорост"',
announce: '',
description: '',
@@ -1,6 +1,6 @@
/**
* Фаза 01 — программы УХД (id=3, type='generator') и Благорост
* (id=4, type='capital') + capital::setconfig.
* (id=4, type='blagorost') + 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: 'capital',
type: 'blagorost',
}
await blockchain.api.transact({
actions: [{
@@ -300,8 +300,8 @@ async function ensureAgreementsAndWallet(blockchain: Blockchain, fix: IFixture):
}
// Согласие с ЦПП «Благорост» — для инвестиций в программу/компонент.
// Блокировка идёт по своему пути; для безопасности дёргаем при первом запуске.
await signAgreement(blockchain, fix.username, 'capital').catch((e) => {
log(`signAgreement capital для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
await signAgreement(blockchain, fix.username, 'blagorost').catch((e) => {
log(`signAgreement blagorost для ${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: 'capital',
type: 'blagorost',
}
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: 'capital',
type: 'blagorost',
}
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 = 'capital'
export const capitalProgramName = 'blagorost'
// ledger2::accounts.id хранит счёт со смещением *1000 (80 → 80000).
export const circulationAccountId = 80_000
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.7.21-1",
"version": "2026.6.9-18",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
@@ -1,7 +1,5 @@
#pragma once
#include <eosio/binary_extension.hpp>
using namespace eosio;
using std::string;
@@ -41,18 +39,10 @@ namespace Capital {
asset program_membership_distributed = asset(0, _root_govern_symbol); ///< Распределенная сумма членских взносов по программе
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
config config; ///< Управляемая конфигурация контракта
asset program_expense_pool = asset(0, _root_govern_symbol); ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
asset program_expense_reserved = asset(0, _root_govern_symbol); ///< Сумма, зарезервированная под approved/authorized программные расходы
// ВНИМАНИЕ: поля программных расходов добавлены ПОСЛЕ 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 программные расходы
config config; ///< Управляемая конфигурация контракта
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
};
@@ -63,16 +53,6 @@ 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 Обновляет глобальное состояние новыми значениями.
*
@@ -123,12 +103,8 @@ 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;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
s.program_expense_pool += amount;
});
}
@@ -141,16 +117,12 @@ 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(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
eosio::check(itr->program_expense_pool >= 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);
pool -= amount;
reserved += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
s.program_expense_pool -= amount;
s.program_expense_reserved += amount;
});
}
@@ -163,16 +135,12 @@ 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(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
eosio::check(itr->program_expense_reserved >= 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);
reserved -= amount;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
s.program_expense_reserved -= amount;
s.program_expense_pool += amount;
});
}
@@ -186,15 +154,11 @@ 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(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
eosio::check(itr->program_expense_reserved >= 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);
reserved -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
s.program_expense_reserved -= amount;
});
}
@@ -208,15 +172,11 @@ 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(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
eosio::check(itr->program_expense_pool >= amount,
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
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);
pool -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
s.program_expense_pool -= amount;
});
}
}// namespace State
+2 -3
View File
@@ -80,13 +80,12 @@ 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 = "capital"_n; ///< Кошелёк программы "Благорост"
static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелёк программы "Благорост"
static const std::set<eosio::name> soviet_actions = {
"joincoop"_n, //регистрация пайщика
"leavecoop"_n, //выход пайщика из кооператива (возврат паевого взноса)
//MEET
"creategm"_n,//предложение повестки планового общего собрание
"completegm"_n, //решение общего собрания пайщиков
@@ -61,7 +61,6 @@ 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
@@ -243,15 +242,6 @@ 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,37 +252,6 @@ 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,7 +17,6 @@
#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"
@@ -1,71 +0,0 @@
#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,12 +12,6 @@
#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,12 +85,6 @@ 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);
@@ -1,35 +0,0 @@
/**
* @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);
}
@@ -1,76 +0,0 @@
/**
* @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);
}
}
@@ -1,41 +0,0 @@
/**
* @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);
}
@@ -1,95 +0,0 @@
#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
@@ -1,57 +0,0 @@
/**
* @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,7 +49,6 @@
#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,9 +139,6 @@ 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);
@@ -1,38 +0,0 @@
/**
* @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,22 +12,14 @@
void soviet::migrate() {
require_auth(_soviet); // Проверяем авторизацию
// Откат 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;
});
}
// Миграция 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;
});
}
// ────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.7.21-1",
"version": "2026.6.9-18",
"private": true,
"type": "module",
"scripts": {
+117 -3
View File
@@ -5,8 +5,13 @@
"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,
@@ -28,5 +33,114 @@
"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 замаскирует значение)."
}
]
}
}
]
}
@@ -1,126 +0,0 @@
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;
},
};
@@ -0,0 +1,166 @@
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;
},
};
@@ -0,0 +1,53 @@
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;
},
};
@@ -0,0 +1,101 @@
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;
},
};
@@ -0,0 +1,100 @@
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;
},
};
@@ -0,0 +1,44 @@
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;
},
};
@@ -0,0 +1,50 @@
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;
},
};
@@ -0,0 +1,48 @@
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;
},
};
@@ -0,0 +1,47 @@
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;
},
};
@@ -0,0 +1,47 @@
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;
},
};
@@ -0,0 +1,49 @@
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;
},
};
@@ -0,0 +1,74 @@
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;
},
};
@@ -0,0 +1,80 @@
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