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
409 changed files with 28384 additions and 178 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
+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/
+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,
},
},
})
+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 замаскирует значение)."
}
]
}
}
]
}
@@ -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;
},
};
@@ -0,0 +1,57 @@
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.8: multi-party critical actions. Таблица `pending_critical_actions`
* в coop_domain_db: критическое действие (исключение пайщика, смена ролей совета,
* force-recovery, смена типов верификации) живёт в pending-состоянии до сбора 2 подписей
* (инициатор + ≥1 член совета) в окне ≤24ч, иначе истекает. `confirmations` — jsonb-массив
* `{by, at}`; `payload` — параметры действия (хэшируется в audit при финализации, Story 6.10).
*/
export default {
name: 'coopid: pending_critical_actions',
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 pending_critical_actions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
action_type text NOT NULL,
actor_id text NOT NULL,
target_id text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL DEFAULT 'pending',
confirmations jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
finalized_at timestamptz
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_pending_critical_actions_status ON pending_critical_actions (status, expires_at);`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_pending_critical_actions_target ON pending_critical_actions (target_id);`);
logger.info('pending_critical_actions создана');
return true;
} catch (e) {
logger.error(`pending_critical_actions 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.9 не реализован.');
return false;
},
};
+4
View File
@@ -73,6 +73,7 @@
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
"@aws-sdk/client-s3": "^3.1035.0",
"@casl/ability": "^7.0.0",
"@coopenomics/factory": "workspace:*",
"@coopenomics/inter": "workspace:*",
"@coopenomics/notifications": "workspace:*",
@@ -106,6 +107,7 @@
"@wharfkit/contract": "^1.1.5",
"@wharfkit/session": "^1.4.0",
"@wharfkit/wallet-plugin-privatekey": "^1.1.0",
"@willsoto/nestjs-prometheus": "^6",
"apollo-server-express": "^3.13.0",
"axios": "^1.6.2",
"bcryptjs": "^2.4.3",
@@ -144,6 +146,7 @@
"joi": "^17.13.1",
"joi-to-json": "^4.3.0",
"joi-to-typescript": "^4.13.0",
"jose": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"jspdf": "^4.2.0",
"libxmljs2": "^0.37.0",
@@ -161,6 +164,7 @@
"passport-jwt": "^4.0.0",
"pdf-lib": "^1.17.1",
"pm2": "^5.1.0",
"prom-client": "^15",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.2",
"socket.io-client": "^4.7.5",
+8
View File
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule } from '@nestjs/throttler';
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
// Infrastructure modules
import { DatabaseModule } from './infrastructure/database/database.module';
@@ -29,6 +30,7 @@ import { FreeDecisionDomainModule } from './domain/free-decision/free-decision.m
import { AgreementDomainModule } from './domain/agreement/agreement-domain.module';
import { ParticipantDomainModule } from './domain/participant/participant-domain.module';
import { AuthDomainModule } from './domain/auth/auth.module';
import { AuthV2Module } from './application/auth-v2/auth-v2.module';
import { AgendaDomainModule } from './domain/agenda/agenda-domain.module';
import { CooplaceDomainModule } from './domain/cooplace/cooplace.module';
import { DesktopDomainModule } from './domain/desktop/desktop-domain.module';
@@ -93,6 +95,11 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
},
]),
ScheduleModule.forRoot(), // @Interval/@Cron — нужен outbox-worker'у Центра уведомлений
// Prometheus pull-метрики (Story 9.11, NFR28): GET /metrics в Prometheus
// exposition format + процессные метрики Node на глобальном реестре prom-client
// (туда же пишут доменные счётчики AuthMetricsService). Доступ снаружи режет
// edge (Caddy, Story 9.2) — как и нативный /metrics authentik.
PrometheusModule.register({ defaultMetrics: { enabled: true } }),
// Infrastructure modules
MongooseModule.forRoot(config.mongoose.url),
DatabaseModule,
@@ -113,6 +120,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
}),
// Domain modules
AuthDomainModule,
AuthV2Module,
RegistrationDomainModule,
OnboardingDomainModule,
AgendaDomainModule,
@@ -0,0 +1,135 @@
import type { SessionsService } from '../sessions/sessions.service';
import type { TwoFactorService } from '../two-factor/two-factor.service';
import type { RecoveryStrategyService } from '../recovery/recovery-strategy.service';
import type { SecurityIncidentService } from '../security/security-incident.service';
import { RecoveryStrategy } from '~/domain/auth-v2/recovery-strategy/recovery-strategy.types';
import { AccountSecurityResolver } from './account-security.resolver';
const USER = { id: 'u1', username: 'payer1', role: 'user' };
const IP = '203.0.113.7';
function build() {
const sessions = {
list: jest.fn(async () => [
{
id: 's1',
device: 'Firefox',
ip: '10.0.0.1',
createdAt: '2026-06-13T00:00:00Z',
lastSeenAt: '2026-06-14T00:00:00Z',
current: true,
},
]),
revoke: jest.fn(async () => undefined),
revokeAll: jest.fn(async () => ({ revoked: 3 })),
};
const twoFactor = {
beginEnrollment: jest.fn(async () => ({ secret: 'BASE32SECRET', otpauthUri: 'otpauth://totp/x' })),
activate: jest.fn(async () => undefined),
disable: jest.fn(async () => undefined),
};
const recoveryStrategy = {
getStrategy: jest.fn(async () => RecoveryStrategy.OfflineCode),
setStrategy: jest.fn(async () => undefined),
};
const incidents = {
report: jest.fn(async () => ({ revoked: 2 })),
};
const resolver = new AccountSecurityResolver(
sessions as unknown as SessionsService,
twoFactor as unknown as TwoFactorService,
recoveryStrategy as unknown as RecoveryStrategyService,
incidents as unknown as SecurityIncidentService,
);
return { resolver, sessions, twoFactor, recoveryStrategy, incidents };
}
describe('AccountSecurityResolver', () => {
it('getSessions маппит сессии в snake_case и передаёт refresh-токен текущей сессии', async () => {
const { resolver, sessions } = build();
const out = await resolver.getSessions(USER, 'rt-current');
expect(sessions.list).toHaveBeenCalledWith('u1', 'rt-current');
expect(out).toEqual([
{
id: 's1',
device: 'Firefox',
ip: '10.0.0.1',
created_at: '2026-06-13T00:00:00Z',
last_seen_at: '2026-06-14T00:00:00Z',
current: true,
},
]);
});
it('getRecoveryStrategy возвращает стратегию пайщика', async () => {
const { resolver, recoveryStrategy } = build();
const out = await resolver.getRecoveryStrategy(USER);
expect(recoveryStrategy.getStrategy).toHaveBeenCalledWith('u1');
expect(out).toBe(RecoveryStrategy.OfflineCode);
});
it('revokeSession завершает сессию по id с IP для аудита', async () => {
const { resolver, sessions } = build();
const ok = await resolver.revokeSession({ session_id: 's1' }, USER, IP);
expect(ok).toBe(true);
expect(sessions.revoke).toHaveBeenCalledWith('u1', 's1', IP);
});
it('revokeAllSessions возвращает число завершённых', async () => {
const { resolver, sessions } = build();
const out = await resolver.revokeAllSessions(USER, IP);
expect(sessions.revokeAll).toHaveBeenCalledWith('u1', IP);
expect(out).toEqual({ revoked: 3 });
});
it('enrollTwoFactor маппит otpauthUri в otpauth_uri', async () => {
const { resolver, twoFactor } = build();
const out = await resolver.enrollTwoFactor(USER);
expect(twoFactor.beginEnrollment).toHaveBeenCalledWith('u1', 'payer1');
expect(out).toEqual({ secret: 'BASE32SECRET', otpauth_uri: 'otpauth://totp/x' });
});
it('activateTwoFactor подтверждает второй фактор кодом', async () => {
const { resolver, twoFactor } = build();
const ok = await resolver.activateTwoFactor({ code: '123456' }, USER, IP);
expect(ok).toBe(true);
expect(twoFactor.activate).toHaveBeenCalledWith('u1', '123456', IP);
});
it('disableTwoFactor отключает второй фактор кодом', async () => {
const { resolver, twoFactor } = build();
const ok = await resolver.disableTwoFactor({ code: '654321' }, USER, IP);
expect(ok).toBe(true);
expect(twoFactor.disable).toHaveBeenCalledWith('u1', '654321', IP);
});
it('setRecoveryStrategy меняет стратегию со step-up кодом', async () => {
const { resolver, recoveryStrategy } = build();
const ok = await resolver.setRecoveryStrategy({ strategy: RecoveryStrategy.Council, code: '000111' }, USER, IP);
expect(ok).toBe(true);
expect(recoveryStrategy.setStrategy).toHaveBeenCalledWith('u1', RecoveryStrategy.Council, '000111', IP);
});
it('reportNotMe зовёт report с source=settings и подозрительной сессией', async () => {
const { resolver, incidents } = build();
const out = await resolver.reportNotMe({ session_id: 's9' }, USER, IP);
expect(incidents.report).toHaveBeenCalledWith({
subjectId: 'u1',
ip: IP,
source: 'settings',
reportedSessionId: 's9',
});
expect(out).toEqual({ revoked: 2 });
});
it('reportNotMe без session_id передаёт reportedSessionId=null', async () => {
const { resolver, incidents } = build();
await resolver.reportNotMe({}, USER, IP);
expect(incidents.report).toHaveBeenCalledWith({
subjectId: 'u1',
ip: IP,
source: 'settings',
reportedSessionId: null,
});
});
});
@@ -0,0 +1,170 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import { ClientIp, RefreshTokenHeader } from '~/application/auth/decorators/request-meta.decorator';
import { RecoveryStrategy } from '~/domain/auth-v2/recovery-strategy/recovery-strategy.types';
import { SessionsService } from '../sessions/sessions.service';
import { TwoFactorService } from '../two-factor/two-factor.service';
import { RecoveryStrategyService } from '../recovery/recovery-strategy.service';
import { SecurityIncidentService } from '../security/security-incident.service';
import {
AccountSessionDTO,
ReportNotMeInputDTO,
RevokeSessionInputDTO,
RevokedSessionsResultDTO,
SetRecoveryStrategyInputDTO,
TwoFactorCodeInputDTO,
TwoFactorEnrollmentDTO,
} from './dto/account-security.dto';
interface ICurrentUser {
id: string;
username: string;
role?: string;
}
/**
* GraphQL-фасад самообслуживания безопасности аккаунта (Фаза 2 миграции REST→GraphQL/SDK).
* Заменяет REST-контроллеры `coop/sessions`, `coop/2fa`, `coop/recovery/strategy` и
* JWT-метод `coop/security/not-me` — фронт ходит через @coopenomics/sdk (Zeus), нового
* способа взаимодействия с бэкендом наружу не появляется.
*
* Все операции — для текущего залогиненного пайщика (subject = `user.id`) под
* `GqlJwtAuthGuard`. IP и refresh-токен текущей сессии — транспорт, берутся из
* request-meta декораторов, не из GraphQL-переменных.
*/
@Resolver()
export class AccountSecurityResolver {
constructor(
private readonly sessions: SessionsService,
private readonly twoFactor: TwoFactorService,
private readonly recoveryStrategy: RecoveryStrategyService,
private readonly incidents: SecurityIncidentService,
) {}
@Query(() => [AccountSessionDTO], {
name: 'getSessions',
description: 'Активные сессии текущего пайщика (текущая помечается current)',
})
@UseGuards(GqlJwtAuthGuard)
async getSessions(
@CurrentUser() user: ICurrentUser,
@RefreshTokenHeader() currentRefreshToken: string | null,
): Promise<AccountSessionDTO[]> {
const sessions = await this.sessions.list(user.id, currentRefreshToken);
return sessions.map((s) => ({
id: s.id,
device: s.device,
ip: s.ip,
created_at: s.createdAt,
last_seen_at: s.lastSeenAt,
current: s.current,
}));
}
@Query(() => RecoveryStrategy, {
name: 'getRecoveryStrategy',
description: 'Текущая стратегия восстановления доступа пайщика',
})
@UseGuards(GqlJwtAuthGuard)
async getRecoveryStrategy(@CurrentUser() user: ICurrentUser): Promise<RecoveryStrategy> {
return this.recoveryStrategy.getStrategy(user.id);
}
@Mutation(() => Boolean, {
name: 'revokeSession',
description: 'Завершить конкретную сессию пайщика',
})
@UseGuards(GqlJwtAuthGuard)
async revokeSession(
@Args('data', { type: () => RevokeSessionInputDTO }) data: RevokeSessionInputDTO,
@CurrentUser() user: ICurrentUser,
@ClientIp() ip: string | null,
): Promise<boolean> {
await this.sessions.revoke(user.id, data.session_id, ip);
return true;
}
@Mutation(() => RevokedSessionsResultDTO, {
name: 'revokeAllSessions',
description: 'Завершить все сессии пайщика',
})
@UseGuards(GqlJwtAuthGuard)
async revokeAllSessions(
@CurrentUser() user: ICurrentUser,
@ClientIp() ip: string | null,
): Promise<RevokedSessionsResultDTO> {
return this.sessions.revokeAll(user.id, ip);
}
@Mutation(() => TwoFactorEnrollmentDTO, {
name: 'enrollTwoFactor',
description: 'Начать подключение второго фактора: выпустить секрет и otpauth-URI для QR',
})
@UseGuards(GqlJwtAuthGuard)
async enrollTwoFactor(@CurrentUser() user: ICurrentUser): Promise<TwoFactorEnrollmentDTO> {
const challenge = await this.twoFactor.beginEnrollment(user.id, user.username);
return { secret: challenge.secret, otpauth_uri: challenge.otpauthUri };
}
@Mutation(() => Boolean, {
name: 'activateTwoFactor',
description: 'Подтвердить подключение второго фактора первым кодом',
})
@UseGuards(GqlJwtAuthGuard)
async activateTwoFactor(
@Args('data', { type: () => TwoFactorCodeInputDTO }) data: TwoFactorCodeInputDTO,
@CurrentUser() user: ICurrentUser,
@ClientIp() ip: string | null,
): Promise<boolean> {
await this.twoFactor.activate(user.id, data.code, ip);
return true;
}
@Mutation(() => Boolean, {
name: 'disableTwoFactor',
description: 'Отключить второй фактор (требует валидный код)',
})
@UseGuards(GqlJwtAuthGuard)
async disableTwoFactor(
@Args('data', { type: () => TwoFactorCodeInputDTO }) data: TwoFactorCodeInputDTO,
@CurrentUser() user: ICurrentUser,
@ClientIp() ip: string | null,
): Promise<boolean> {
await this.twoFactor.disable(user.id, data.code, ip);
return true;
}
@Mutation(() => Boolean, {
name: 'setRecoveryStrategy',
description: 'Сменить стратегию восстановления (требует step-up второго фактора)',
})
@UseGuards(GqlJwtAuthGuard)
async setRecoveryStrategy(
@Args('data', { type: () => SetRecoveryStrategyInputDTO }) data: SetRecoveryStrategyInputDTO,
@CurrentUser() user: ICurrentUser,
@ClientIp() ip: string | null,
): Promise<boolean> {
await this.recoveryStrategy.setStrategy(user.id, data.strategy, data.code, ip);
return true;
}
@Mutation(() => RevokedSessionsResultDTO, {
name: 'reportNotMe',
description: 'Сигнал «Это не я»: немедленно завершить все сессии пайщика',
})
@UseGuards(GqlJwtAuthGuard)
async reportNotMe(
@Args('data', { type: () => ReportNotMeInputDTO }) data: ReportNotMeInputDTO,
@CurrentUser() user: ICurrentUser,
@ClientIp() ip: string | null,
): Promise<RevokedSessionsResultDTO> {
return this.incidents.report({
subjectId: user.id,
ip,
source: 'settings',
reportedSessionId: data.session_id ?? null,
});
}
}
@@ -0,0 +1,90 @@
import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { RecoveryStrategy } from '~/domain/auth-v2/recovery-strategy/recovery-strategy.types';
/**
* GraphQL-контракт самообслуживания безопасности аккаунта пайщика (Фаза 2 миграции
* REST→GraphQL/SDK): активные сессии (Story 3.7), второй фактор (3.6), стратегия
* восстановления (3.5), сигнал «Это не я» (3.10). Зеркало доменных типов auth-v2,
* поля — snake_case по канону GraphQL этого репозитория; резолвер маппит camelCase
* сервисов в snake_case этих DTO.
*
* Зачем GraphQL, а не REST: единый типизированный фасад фронта — @coopenomics/sdk
* (Zeus). Нового способа взаимодействия с бэкендом наружу не появляется; bearer
* живёт только в SDK. Транспорт (IP, refresh-токен текущей сессии) — через
* request-meta декораторы, не как GraphQL-переменные.
*/
registerEnumType(RecoveryStrategy, {
name: 'RecoveryStrategy',
description: 'Разрешённый канал восстановления доступа пайщика (активен ровно один)',
});
/** Активная сессия пайщика — устройство, с которого выполнен вход. */
@ObjectType('AccountSession')
export class AccountSessionDTO {
@Field(() => String, { description: 'Идентификатор сессии (для точечного завершения)' })
id!: string;
@Field(() => String, { description: 'Устройство входа (User-Agent); заглушка, если метаданные не сохранялись' })
device!: string;
@Field(() => String, { description: 'IP входа; заглушка, если метаданные не сохранялись' })
ip!: string;
@Field(() => String, { description: 'Время создания сессии (ISO)' })
created_at!: string;
@Field(() => String, { description: 'Последняя зафиксированная активность (ISO)' })
last_seen_at!: string;
@Field(() => Boolean, { description: 'Текущая сессия (с которой выполнен запрос)' })
current!: boolean;
}
/** Результат массового завершения сессий. */
@ObjectType('RevokedSessionsResult')
export class RevokedSessionsResultDTO {
@Field(() => Int, { description: 'Сколько активных сессий завершено' })
revoked!: number;
}
/** Вызов на подключение второго фактора: данные для ручного ввода и QR. */
@ObjectType('TwoFactorEnrollment')
export class TwoFactorEnrollmentDTO {
@Field(() => String, { description: 'Base32-секрет для ручного ввода в приложение-аутентификатор' })
secret!: string;
@Field(() => String, { description: 'otpauth://-URI для QR-кода' })
otpauth_uri!: string;
}
/** Вход на завершение конкретной сессии. */
@InputType('RevokeSessionInput')
export class RevokeSessionInputDTO {
@Field(() => String, { description: 'Идентификатор завершаемой сессии' })
session_id!: string;
}
/** Вход на операции второго фактора, требующие TOTP-кода (активация/отключение). */
@InputType('TwoFactorCodeInput')
export class TwoFactorCodeInputDTO {
@Field(() => String, { description: 'Одноразовый код из приложения-аутентификатора' })
code!: string;
}
/** Вход на смену стратегии восстановления (требует step-up второго фактора). */
@InputType('SetRecoveryStrategyInput')
export class SetRecoveryStrategyInputDTO {
@Field(() => RecoveryStrategy, { description: 'Новая стратегия восстановления' })
strategy!: RecoveryStrategy;
@Field(() => String, { description: 'TOTP-код для подтверждения смены (step-up)' })
code!: string;
}
/** Вход на сигнал «Это не я» из настроек ЛК. */
@InputType('ReportNotMeInput')
export class ReportNotMeInputDTO {
@Field(() => String, { nullable: true, description: 'Идентификатор подозрительной сессии (опционально, из настроек)' })
session_id?: string | null;
}
@@ -0,0 +1,22 @@
import { SetMetadata } from '@nestjs/common';
/** Ключ метаданных, который читает `AuditActionInterceptor` (Story 8.5). */
export const AUDIT_ACTION = 'coopid:audit-action';
/** Описание метки авто-аудита: категория действия (по умолчанию `admin`). */
export interface AuditActionMeta {
category: string;
}
/**
* Помечает резолвер/endpoint для автоматического аудита админ-действия (Story 8.5):
* `AuditActionInterceptor` пишет `audit_events` без ручного вызова `AuditService.record`
* в каждом резолвере (защита от человеческого фактора). Имя события строится из имени
* хэндлера: `coopid.<category>.<handlerName>`.
*
* @example `@AuditAction()` → `coopid.admin.changeRoles`
*/
export function AuditAction(category = 'admin'): MethodDecorator & ClassDecorator {
const meta: AuditActionMeta = { category };
return SetMetadata(AUDIT_ACTION, meta);
}
@@ -0,0 +1,115 @@
import { CallHandler, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { firstValueFrom, of, throwError } from 'rxjs';
import { AuditActionInterceptor } from './audit-action.interceptor';
import type { AuditService } from './audit.service';
function makeInterceptor(meta: { category: string } | undefined) {
const record = jest.fn((_rec: Parameters<AuditService['record']>[0]) => Promise.resolve());
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(meta) } as unknown as Reflector;
const interceptor = new AuditActionInterceptor(reflector, { record } as unknown as AuditService);
return { interceptor, record };
}
function httpCtx(handlerName: string, req: Record<string, unknown>): ExecutionContext {
return {
getType: () => 'http',
getHandler: () => ({ name: handlerName }),
getClass: () => class {},
switchToHttp: () => ({ getRequest: () => req }),
} as unknown as ExecutionContext;
}
/** Фейковый GraphQL ExecutionContext (как в authorization.guard.spec). */
function gqlCtx(handlerName: string, args: Record<string, unknown>, reqObj: unknown): ExecutionContext {
const gqlArgs = [{}, args, { req: reqObj }, {}];
return {
getType: () => 'graphql',
getHandler: () => ({ name: handlerName }),
getClass: () => class {},
getArgs: () => gqlArgs,
getArgByIndex: (i: number) => gqlArgs[i],
switchToHttp: () => ({ getRequest: () => ({}) }),
} as unknown as ExecutionContext;
}
const okHandler = (value: unknown = { ok: true }): CallHandler => ({ handle: () => of(value) });
const errHandler = (err: unknown): CallHandler => ({ handle: () => throwError(() => err) });
const flush = () => new Promise((r) => setImmediate(r));
describe('AuditActionInterceptor — авто-аудит admin-действий (Story 8.5)', () => {
it('нет @AuditAction → запись не делается, хэндлер проходит', async () => {
const { interceptor, record } = makeInterceptor(undefined);
const res = await firstValueFrom(interceptor.intercept(httpCtx('changeRoles', { user: {} }), okHandler('R')));
await flush();
expect(res).toBe('R');
expect(record).not.toHaveBeenCalled();
});
it('HTTP success: event=coopid.admin.<handler>, subjectId=target_id, actor, result=success', async () => {
const { interceptor, record } = makeInterceptor({ category: 'admin' });
const req = { user: { username: 'chief' }, params: { target_id: 'p1' }, query: {}, body: { role: 'member' } };
await firstValueFrom(interceptor.intercept(httpCtx('changeRoles', req), okHandler()));
await flush();
expect(record).toHaveBeenCalledWith({
event: 'coopid.admin.changeRoles',
subjectId: 'p1',
actor: 'chief',
result: 'success',
context: { target_id: 'p1', role: 'member' },
});
});
it('subjectId фолбэк на id, если нет target_id', async () => {
const { interceptor, record } = makeInterceptor({ category: 'admin' });
const req = { user: { username: 'chief' }, params: {}, query: {}, body: { id: 'x9' } };
await firstValueFrom(interceptor.intercept(httpCtx('grantCapability', req), okHandler()));
await flush();
expect(record).toHaveBeenCalledWith(expect.objectContaining({ event: 'coopid.admin.grantCapability', subjectId: 'x9' }));
});
it('GraphQL: args берутся из data', async () => {
const { interceptor, record } = makeInterceptor({ category: 'admin' });
const ctx = gqlCtx('excludeParticipant', { data: { target_id: 'p7', reason: 'x' } }, { user: { username: 'chief' } });
await firstValueFrom(interceptor.intercept(ctx, okHandler()));
await flush();
expect(record).toHaveBeenCalledWith(expect.objectContaining({
event: 'coopid.admin.excludeParticipant',
subjectId: 'p7',
context: { target_id: 'p7', reason: 'x' },
}));
});
it('секреты выкинуты в _redacted (вкл. вложенность), запись проходит secret-blacklist', async () => {
const { interceptor, record } = makeInterceptor({ category: 'admin' });
const req = {
user: { username: 'chief' },
params: { target_id: 'p1' },
query: {},
body: { access_token: 'T', nested: { private_key: 'K', keep: 1 }, signature: 'S', ok: 'v' },
};
await firstValueFrom(interceptor.intercept(httpCtx('rotate', req), okHandler()));
await flush();
const ctx = record.mock.calls[0][0].context as Record<string, unknown>;
expect(ctx.ok).toBe('v');
expect(ctx.access_token).toBeUndefined();
expect(ctx.signature).toBeUndefined();
expect(ctx._redacted).toEqual(expect.arrayContaining(['access_token', 'signature']));
expect((ctx.nested as Record<string, unknown>).private_key).toBeUndefined();
expect((ctx.nested as Record<string, unknown>)._redacted).toEqual(['private_key']);
expect((ctx.nested as Record<string, unknown>).keep).toBe(1);
});
it('ошибка резолвера → result=failure + rethrow исходной ошибки', async () => {
const { interceptor, record } = makeInterceptor({ category: 'admin' });
const boom = new Error('boom');
const req = { user: { username: 'chief' }, params: { target_id: 'p1' }, query: {}, body: {} };
await expect(firstValueFrom(interceptor.intercept(httpCtx('changeRoles', req), errHandler(boom)))).rejects.toBe(boom);
await flush();
expect(record).toHaveBeenCalledWith(expect.objectContaining({
event: 'coopid.admin.changeRoles',
result: 'failure',
context: expect.objectContaining({ _error: 'boom' }),
}));
});
});
@@ -0,0 +1,98 @@
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
import { Observable, catchError, tap, throwError } from 'rxjs';
import { AuditService } from './audit.service';
import { AUDIT_ACTION, type AuditActionMeta } from './audit-action.decorator';
/** Ключи, имена которых нельзя оставлять в audit-metadata (как secret-blacklist 8.2). */
const SECRET_KEY_PATTERNS = ['password', 'private_key', 'token', 'secret', 'signature'];
/**
* Авто-аудит админ-действий (Story 8.5). Резолвер/endpoint, помеченный `@AuditAction`,
* пишет `audit_events` автоматически: `event = coopid.<category>.<handlerName>`,
* `subject_id = args.target_id || args.id`, `result = success|failure`,
* `metadata = sanitizeArgs(args)` (ключи-секреты выкинуты).
*
* Извлечение user/args из GraphQL и HTTP — тем же приёмом, что `AuthorizationGuard` (6.4).
* Запись best-effort (в `.catch`): аудит не подменяет исходный результат/ошибку резолвера.
* Применяется точечно `@UseInterceptors(AuditActionInterceptor)` на admin-контроллере,
* НЕ как глобальный APP_INTERCEPTOR (канон 6.4/6.5).
*/
@Injectable()
export class AuditActionInterceptor implements NestInterceptor {
private readonly logger = new Logger(AuditActionInterceptor.name);
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const meta = this.reflector.getAllAndOverride<AuditActionMeta | undefined>(AUDIT_ACTION, [
context.getHandler(),
context.getClass(),
]);
if (!meta) return next.handle(); // endpoint не помечен — не аудируем
const { user, args } = this.extract(context);
const event = `coopid.${meta.category}.${context.getHandler().name}`;
const subjectId = (args.target_id ?? args.id ?? null) as string | null;
const actor = (user?.username ?? null) as string | null;
const metadata = this.sanitizeArgs(args);
return next.handle().pipe(
tap(() => {
void this.record({ event, subjectId, actor, result: 'success', context: metadata });
}),
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
void this.record({ event, subjectId, actor, result: 'failure', context: { ...metadata, _error: message } });
return throwError(() => error);
}),
);
}
/** best-effort: провал аудита не должен ронять/подменять ответ резолвера. */
private async record(rec: Parameters<AuditService['record']>[0]): Promise<void> {
try {
await this.audit.record(rec);
} catch (e) {
this.logger.error(`audit-action запись не удалась (${rec.event}): ${e instanceof Error ? e.message : String(e)}`);
}
}
private extract(context: ExecutionContext): { user: { username?: string } | undefined; args: Record<string, unknown> } {
if (context.getType<GqlContextType>() === 'graphql') {
const gql = GqlExecutionContext.create(context);
const req = gql.getContext().req;
const a = gql.getArgs<Record<string, unknown>>();
const args = (a?.data ?? a?.filter ?? a ?? {}) as Record<string, unknown>;
return { user: req?.user, args };
}
const req = context.switchToHttp().getRequest();
const args = { ...(req?.params ?? {}), ...(req?.query ?? {}), ...(req?.body ?? {}) } as Record<string, unknown>;
return { user: req?.user, args };
}
/**
* Рекурсивно убирает ключи-секреты (на всех уровнях вложенности); выкинутые имена —
* в `_redacted[]`. Именно удаление, а не маска значения: secret-blacklist `AuditService`
* бросает на запретный КЛЮЧ, поэтому ключ оставлять нельзя.
*/
private sanitizeArgs(input: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
const redacted: string[] = [];
for (const [key, value] of Object.entries(input)) {
if (SECRET_KEY_PATTERNS.some((p) => key.toLowerCase().includes(p))) {
redacted.push(key);
continue;
}
out[key] = value && typeof value === 'object' && !Array.isArray(value)
? this.sanitizeArgs(value as Record<string, unknown>)
: value;
}
if (redacted.length) out._redacted = redacted;
return out;
}
}
@@ -0,0 +1,69 @@
import { AuditService, assertContextHasNoSecrets } from './audit.service';
/**
* Story 8.2: структурированные audit-поля. DataSource замокан через spy на приватный
* getDataSource — проверяем форму INSERT (включая первоклассную колонку user_agent) и
* инвариант secret-blacklist, без реального coop_domain_db.
*/
describe('AuditService — структурированные поля (Story 8.2)', () => {
function setup() {
const query = jest.fn((_sql: string, _params: unknown[]) => Promise.resolve(undefined));
const service = new AuditService();
jest.spyOn(service as unknown as { getDataSource: () => Promise<unknown> }, 'getDataSource').mockResolvedValue({ query });
return { service, query };
}
it('record пишет user_agent отдельным параметром (а не в context)', async () => {
const { service, query } = setup();
await service.record({
event: 'coopid.login.successful',
subjectId: 'u1',
actor: 'ant',
result: 'success',
ip: '1.2.3.4',
userAgent: 'Chrome/120',
context: { device_new: true },
});
expect(query).toHaveBeenCalledTimes(1);
const [sql, params] = query.mock.calls[0] as [string, unknown[]];
expect(sql).toContain('user_agent');
expect(sql).toContain('INSERT INTO audit_events');
// Порядок: event, subject_id, actor, result, context(json), ip, user_agent.
expect(params[0]).toBe('coopid.login.successful');
expect(params[1]).toBe('u1');
expect(params[2]).toBe('ant');
expect(params[3]).toBe('success');
expect(JSON.parse(params[4] as string)).toEqual({ device_new: true });
expect(params[5]).toBe('1.2.3.4');
expect(params[6]).toBe('Chrome/120');
});
it('userAgent отсутствует → null', async () => {
const { service, query } = setup();
await service.record({ event: 'e', result: 'success' });
const [, params] = query.mock.calls[0] as [string, unknown[]];
expect(params[6]).toBeNull();
});
it('context/ip/subject/actor по умолчанию: пустой json, null', async () => {
const { service, query } = setup();
await service.record({ event: 'e', result: 'failure' });
const [, params] = query.mock.calls[0] as [string, unknown[]];
expect(params[1]).toBeNull(); // subject_id
expect(params[2]).toBeNull(); // actor
expect(JSON.parse(params[4] as string)).toEqual({}); // context
expect(params[5]).toBeNull(); // ip
});
it('secret-blacklist: ключ с token/secret/password/private_key/signature → throw (вкл. вложенность)', async () => {
const { service, query } = setup();
await expect(service.record({ event: 'e', result: 'success', context: { access_token: 'x' } })).rejects.toThrow(/секрет/);
await expect(service.record({ event: 'e', result: 'success', context: { nested: { api_secret: 'x' } } })).rejects.toThrow(/секрет/);
// Запись в БД не дошла.
expect(query).not.toHaveBeenCalled();
});
it('assertContextHasNoSecrets пропускает безопасные ключи (user_agent, ip_unknown)', () => {
expect(() => assertContextHasNoSecrets({ user_agent: 'Chrome', ip_unknown: 'internal_call' })).not.toThrow();
});
});
@@ -0,0 +1,97 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { DataSource } from 'typeorm';
import config from '~/config/config';
export type AuditResult = 'success' | 'failure' | 'degraded';
export interface AuditRecord {
event: string;
subjectId?: string | null;
actor?: string | null;
result: AuditResult;
context?: Record<string, unknown>;
ip?: string | null;
/**
* User-Agent инициатора (форензика, Story 8.2). Первоклассная колонка `user_agent`
* (миграция V2.4.11). Отсутствует → null; причину отсутствия (internal call и т.п.)
* вызывающий ставит флагом в `context` (конвенция explicit-null-with-reason,
* docs/audit/event-schema.md), сам сервис её не домысливает.
*/
userAgent?: string | null;
}
/** Ключи, которым нечего делать в audit-контексте ни на каком уровне вложенности. */
const SECRET_KEY_PATTERNS = ['password', 'private_key', 'token', 'secret', 'signature'];
/**
* Blacklist-инвариант аудита (архитектура CoopID): попытка записать секрет
* в context — программная ошибка, бросаем сразу, а не маскируем.
*/
export function assertContextHasNoSecrets(context: Record<string, unknown>, path = ''): void {
for (const [key, value] of Object.entries(context)) {
const lower = key.toLowerCase();
if (SECRET_KEY_PATTERNS.some((p) => lower.includes(p)))
throw new Error(`AuditService: ключ «${path}${key}» похож на секрет — запись в audit_events запрещена`);
if (value && typeof value === 'object' && !Array.isArray(value))
assertContextHasNoSecrets(value as Record<string, unknown>, `${path}${key}.`);
}
}
/**
* Запись в append-only audit_events (coop_domain_db). Подключение ленивое:
* coop-postgres недоступен → ошибка только у вызывающего, запуск coopback
* не зависит от этого сервиса.
*/
@Injectable()
export class AuditService implements OnModuleDestroy {
private readonly logger = new Logger(AuditService.name);
private ds: DataSource | null = null;
private initializing: Promise<DataSource> | null = null;
private getDataSource(): Promise<DataSource> {
if (this.ds?.isInitialized) return Promise.resolve(this.ds);
if (!this.initializing) {
this.initializing = new DataSource({
type: 'postgres',
host: config.coopDomainDb.host,
port: config.coopDomainDb.port,
username: config.coopDomainDb.username,
password: config.coopDomainDb.password,
database: config.coopDomainDb.database,
})
.initialize()
.then((ds) => {
this.ds = ds;
return ds;
})
.finally(() => {
this.initializing = null;
});
}
return this.initializing;
}
async record(record: AuditRecord): Promise<void> {
const context = record.context ?? {};
assertContextHasNoSecrets(context);
const ds = await this.getDataSource();
await ds.query(
`INSERT INTO audit_events (event, subject_id, actor, result, context, ip, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
record.event,
record.subjectId ?? null,
record.actor ?? null,
record.result,
JSON.stringify(context),
record.ip ?? null,
record.userAgent ?? null,
],
);
this.logger.log(`audit: ${record.event} subject=${record.subjectId ?? '-'} result=${record.result}`);
}
async onModuleDestroy(): Promise<void> {
if (this.ds?.isInitialized) await this.ds.destroy();
}
}
@@ -0,0 +1,108 @@
# CoopID — схема audit-событий (`audit_events`)
Канонический контракт аудита аутентификации CoopID. Источник истины для следователя/
compliance: каждое событие безопасности пишется в append-only таблицу `audit_events`
(coop_domain_db) через `AuditService.record`.
> Истории: 8.1 (схема + append-only + партиции), **8.2 (структурированные поля — этот
> документ)**, 8.3/8.4 (проводка OIDC/key-rotation), 8.7 (лог-санитайзер).
## Таблица `audit_events`
Append-only: помесячные партиции `PARTITION BY RANGE`, триггеры запрещают `UPDATE`/`DELETE`/
`TRUNCATE` (`RAISE EXCEPTION 'audit_events is append-only'`). `GRANT INSERT,SELECT` приложению
(`coop_app_user`), `GRANT SELECT` читателю аудита (`coop_audit_reader`). Подробности — миграции
`V2.4.0` + `V2.4.11`.
| Колонка | Тип | Семантика |
|---|---|---|
| `id` | `bigint` IDENTITY | PK (часть с `created_at` для партиции) |
| `event` | `text` NOT NULL | Имя события (см. каталог ниже). **= AC `action`** |
| `subject_id` | `text` | Над кем/над чем событие (пайщик, действие) |
| `actor` | `text` | Кто инициировал (username). Канон CoopID сверх AC |
| `result` | `text` NOT NULL | `success` \| `failure` \| `degraded` |
| `context` | `jsonb` NOT NULL `{}` | Доп. метаданные события. **= AC `metadata`** |
| `ip` | `inet` | IP инициатора, либо `null` |
| `user_agent` | `text` | User-Agent инициатора, либо `null` (Story 8.2) |
| `created_at` | `timestamptz` NOT NULL `now()` | Время. **= AC `timestamp`** |
**Дрейф имён ↔ AC (прав код, см. 8.1):** боевая таблица создана в `V2.4.0` до формализации
AC; имена `event`/`context`/`created_at` сохранены (партиционированная append-only таблица,
~30 точек записи) и семантически равны AC `action`/`metadata`/`timestamp`. `actor`
дополнительная колонка CoopID (AC отдельного инициатора не выделяет).
## Обязательные поля при записи
`AuditService.record({ event, subjectId?, actor?, result, context?, ip?, userAgent? })`:
- **обязательны**: `event`, `result`;
- остальные — заполняются, когда данные доступны; отсутствующее значение → `null`
(`subjectId`/`ip`/`userAgent`) либо `{}` (`context`).
## Конвенция explicit-null-with-reason
Если поле физически отсутствует — пишем `null`, а **причину отсутствия** кладём флагом в
`context`. Причину знает только вызывающий (internal call? нет HTTP-заголовка?), поэтому
`AuditService` её НЕ домысливает — это caller-side-конвенция:
```ts
// внутренний вызов без HTTP-контекста: IP неизвестен по причине
await audit.record({
event: 'coopid.session.revoked_all',
subjectId: userId,
result: 'success',
ip: null,
context: { ip_unknown: 'internal_call' },
});
```
Так следователь отличает «IP не записали по ошибке» от «IP осознанно отсутствует, потому
что вызов внутренний».
## Инвариант secret-blacklist
`context` НЕ должен содержать секретов. Ключ (на любом уровне вложенности), чьё имя содержит
`password` / `private_key` / `token` / `secret` / `signature`, приводит к `throw` в
`assertContextHasNoSecrets` — это программная ошибка вызывающего, а не повод маскировать.
Поэтому в `context` нельзя называть ключ так, чтобы он попал под blacklist (например, для
хэша подписи использовать `payload_hash`, а не `signature`).
## Каталог имён событий
Self-service / вход:
- `coopid.login.successful` — успешный вход (этап 2 verify-timestamp), `actor`+UA+IP.
- `coopid.verify.timestamp` — проверка подписи timestamp.
- `coopid.auth.degraded` — degraded-вход при недоступном COOPOS (`result: degraded`).
- `coopid.logout` — RP-initiated logout.
- `coopid.session.revoked` / `coopid.session.revoked_all` — отзыв сессии(й).
Recovery / 2FA:
- `coopid.recovery.requested` / `confirmed` / `cancelled` — жизненный цикл recovery.
- `coopid.recovery.strategy_changed` — смена recovery-стратегии.
- `coopid.2fa.enabled` / `coopid.2fa.disabled` — управление TOTP.
Безопасность:
- `coopid.security.account_locked` — escalating-lockout (трекер хэширован).
- `coopid.security.suspicious_login_reported` — «это не я».
- `WeakPasswordRejected` — отклонён слабый пароль при регистрации/смене.
Критические действия (multi-party, Эпик 6):
- `CriticalActionConfirmed` / `CriticalActionExpired` — финализация/истечение (оба подписанта
+ `payload_hash`).
- `ForceRecoveryConsentRequested` / `ForceRecoveryConsentGranted` / `ForceRecoveryAuthorized` /
`ForceRecoveryDenied` — force-recovery rules.
- `KeyRevokedManually` — ручной отзыв скомпрометированного ключа (`reason`+`chairman_id`).
Назначаемые роли (Story 6.11):
- `CapabilitySetAssigned` / `CapabilitySetRevoked` — назначение/отзыв председателем набора
возможностей пайщику (`set_key`+`expires_at`; actor=председатель, subject=пайщик).
OIDC-операции (Story 8.3, источник — native-события authentik через webhook):
- `OidcLoginSuccess` — успешный вход (authentik `login`).
- `OidcLogout` — выход (authentik `logout`).
- `OidcTokenIssued` — выдача токена при авторизации приложения (authentik `authorize_application`).
- `Authentik<Action>` — зеркало прочих native-событий authentik (`AuthentikLoginFailed`,
`AuthentikSuspiciousRequest`, …; result=failure для security-действий). Любое будущее
подписанное событие потечёт автоматически.
- (отложено) `OidcTokenRevoked`/refresh — в authentik 2026.2 нет надёжного native-action.
Key rotation (`KeyRotated` с цепочкой `RecoveryInitiated`/`RecoveryConfirmed`) — Story 8.4.
@@ -0,0 +1,76 @@
import { Module } from '@nestjs/common';
import { RedisModule } from '~/infrastructure/redis/redis.module';
import { AuthV2InfrastructureModule } from '~/infrastructure/auth-v2/auth-v2-infrastructure.module';
import { TokenApplicationModule } from '~/application/token/token-application.module';
import { TWO_FACTOR_VERIFIER } from '~/domain/auth-v2/ports/two-factor.port';
import { RECOVERY_FINALIZATION_PORT } from '~/domain/auth-v2/ports/recovery-finalization.port';
import { AuditService } from './audit/audit.service';
import { AuditActionInterceptor } from './audit/audit-action.interceptor';
import { AuthentikEventsController } from './authentik-events.controller';
import { SessionBindingService } from './session-binding/session-binding.service';
import { SessionBindingController } from './session-binding/session-binding.controller';
import { VaultService } from './vault/vault.service';
import { VaultController } from './vault/vault.controller';
import { VerifyTimestampService } from './verify-timestamp/verify-timestamp.service';
import { VerifyTimestampController } from './verify-timestamp/verify-timestamp.controller';
import { DeviceTrackingService } from './device-tracking/device-tracking.service';
import { NewDeviceNotificationService } from './device-tracking/new-device-notification.service';
import { SecurityEventNotificationService } from './security-events/security-event-notification.service';
import { CertificateService } from './certificate/certificate.service';
import { CertSettingsService } from './certificate/cert-settings.service';
import { CertificateResolver } from './certificate/certificate.resolver';
import { CoopIdClaimsPolicyController } from './certificate/coopid-claims-policy.controller';
import { CoopIdSchemaPolicyController } from './certificate/coopid-schema-policy.controller';
import { AuthorizationModule } from './authorization/authorization.module';
import { AuthMetricsModule } from './metrics/auth-metrics.module';
import { VerificationTypesService } from './verification/verification-types.service';
import { VerificationRulesService } from './verification/verification-rules.service';
import { VerificationRuleGuard } from './verification/verification-rule.guard';
import { LogoutService } from './logout/logout.service';
import { LogoutController } from './logout/logout.controller';
import { AuthRateLimitGuard } from './rate-limit/auth-rate-limit.guard';
import { RecoveryService } from './recovery/recovery.service';
import { RecoveryConfirmService } from './recovery/recovery-confirm.service';
import { OfflineRecoveryService } from './recovery/offline-recovery.service';
import { RecoveryStrategyService } from './recovery/recovery-strategy.service';
import { RecoveryFinalizationService } from './recovery/recovery-finalization.service';
import { RecoveryController } from './recovery/recovery.controller';
import { RefreshService } from './refresh/refresh.service';
import { RefreshController } from './refresh/refresh.controller';
import { MigrationService } from './migration/migration.service';
import { MigrationController } from './migration/migration.controller';
import { TwoFactorService } from './two-factor/two-factor.service';
import { SessionsService } from './sessions/sessions.service';
import { SecurityIncidentService } from './security/security-incident.service';
import { SecurityIncidentController } from './security/security-incident.controller';
import { CriticalActionsService } from './critical-actions/critical-actions.service';
import { ForceRecoveryService } from './force-recovery/force-recovery.service';
import { ForceRecoveryController } from './force-recovery/force-recovery.controller';
import { KeyRevocationService } from './key-revocation/key-revocation.service';
import { CapabilitySetService } from './authorization/capability-set.service';
import { AuthorizationResolver } from './authorization/authorization.resolver';
import { AccountSecurityResolver } from './account-security/account-security.resolver';
import { CriticalActionsResolver } from './critical-actions/critical-actions.resolver';
/**
* auth-v2 (CoopID): новый контур аутентификации. Живёт рядом с legacy `auth/`
* до Phase 3 миграции (Эпик 7). Здесь: аудит, приёмник событий authentik,
* session_binding_token-мост, vault-хранилище и второй этап (timestamp-verify);
* certificate/id_token — Story 1.8. BLOCKCHAIN_PORT/USER_DOMAIN_SERVICE — @Global.
*/
@Module({
imports: [RedisModule, AuthV2InfrastructureModule, TokenApplicationModule, AuthorizationModule, AuthMetricsModule],
// SecurityIncidentController/ForceRecoveryController остаются REST только ради magic-link
// `:token`-эндпоинтов (клик из письма без SDK-контекста); их JWT-методы переведены в
// GraphQL/SDK (AccountSecurityResolver/CriticalActionsResolver, Фаза 2 миграции).
controllers: [AuthentikEventsController, SessionBindingController, VaultController, VerifyTimestampController, CoopIdClaimsPolicyController, CoopIdSchemaPolicyController, LogoutController, RefreshController, RecoveryController, MigrationController, SecurityIncidentController, ForceRecoveryController],
providers: [
AuditService, AuditActionInterceptor, SessionBindingService, VaultService, VerifyTimestampService, CertificateService, CertSettingsService, VerificationTypesService, VerificationRulesService, VerificationRuleGuard, LogoutService, RefreshService, MigrationService, AuthRateLimitGuard, RecoveryService, RecoveryConfirmService, OfflineRecoveryService, RecoveryStrategyService, RecoveryFinalizationService, TwoFactorService, DeviceTrackingService, NewDeviceNotificationService, SecurityEventNotificationService, SessionsService, SecurityIncidentService, CriticalActionsService, ForceRecoveryService, KeyRevocationService, CapabilitySetService, AuthorizationResolver, CertificateResolver, AccountSecurityResolver, CriticalActionsResolver,
// Узкий verifier-порт для потребителей (recovery Story 3.2, 2FA-вход) → тот же сервис.
{ provide: TWO_FACTOR_VERIFIER, useExisting: TwoFactorService },
// Финализация recovery (Story 3.3): ротация ключа через registrator::changekey + vault + отзыв сессий + аудит.
{ provide: RECOVERY_FINALIZATION_PORT, useExisting: RecoveryFinalizationService },
],
exports: [AuditService, SessionBindingService, VaultService, VerifyTimestampService, CertificateService, VerificationTypesService, VerificationRulesService, VerificationRuleGuard, LogoutService, TwoFactorService, TWO_FACTOR_VERIFIER],
})
export class AuthV2Module {}
@@ -0,0 +1,91 @@
import { assertContextHasNoSecrets } from './audit/audit.service';
import { mapAuthentikEvent } from './authentik-events.controller';
describe('mapAuthentikEvent (Story 1.5 + 8.3)', () => {
describe('policy_execution → слабый пароль (Story 1.5)', () => {
it('passing=false ⇒ WeakPasswordRejected', () => {
const r = mapAuthentikEvent({
event_action: 'policy_execution',
passing: false,
event_user: 'ant',
messages: ['too short'],
created: '2026-06-13T00:00:00',
});
expect(r).toEqual({
event: 'WeakPasswordRejected',
subjectId: 'ant',
actor: 'ant',
result: 'failure',
context: { messages: ['too short'], authentik_created: '2026-06-13T00:00:00' },
});
});
it('passing=true ⇒ null (не наш кейс)', () => {
expect(mapAuthentikEvent({ event_action: 'policy_execution', passing: true })).toBeNull();
});
});
describe('OIDC-операции → семантические Oidc* (Story 8.3)', () => {
it('login ⇒ OidcLoginSuccess (success) + ip + app в контексте', () => {
const r = mapAuthentikEvent({
event_action: 'login',
event_user: 'ant',
client_ip: '1.2.3.4',
app: 'CoopID',
created: '2026-06-13T10:00:00',
});
expect(r).toEqual({
event: 'OidcLoginSuccess',
subjectId: 'ant',
actor: 'ant',
result: 'success',
ip: '1.2.3.4',
context: { authentik_action: 'login', app: 'CoopID', authentik_created: '2026-06-13T10:00:00' },
});
});
it('logout ⇒ OidcLogout', () => {
expect(mapAuthentikEvent({ event_action: 'logout', event_user: 'ant' })?.event).toBe('OidcLogout');
});
it('authorize_application ⇒ OidcTokenIssued с app', () => {
const r = mapAuthentikEvent({ event_action: 'authorize_application', event_user: 'ant', app: 'Gitea' });
expect(r?.event).toBe('OidcTokenIssued');
expect(r?.result).toBe('success');
expect(r?.context?.app).toBe('Gitea');
});
});
describe('native-события authentik → Authentik* (Story 8.3)', () => {
it('login_failed ⇒ AuthentikLoginFailed (failure)', () => {
const r = mapAuthentikEvent({ event_action: 'login_failed', event_user: 'ant', client_ip: '9.9.9.9' });
expect(r?.event).toBe('AuthentikLoginFailed');
expect(r?.result).toBe('failure');
expect(r?.ip).toBe('9.9.9.9');
});
it('suspicious_request ⇒ AuthentikSuspiciousRequest (failure)', () => {
const r = mapAuthentikEvent({ event_action: 'suspicious_request', event_user: 'ant' });
expect(r?.event).toBe('AuthentikSuspiciousRequest');
expect(r?.result).toBe('failure');
});
it('неизвестный action ⇒ Authentik<PascalCase> (success по умолчанию)', () => {
const r = mapAuthentikEvent({ event_action: 'user_write', event_user: 'ant' });
expect(r?.event).toBe('AuthentikUserWrite');
expect(r?.result).toBe('success');
});
});
it('нет event_action ⇒ null', () => {
expect(mapAuthentikEvent({})).toBeNull();
expect(mapAuthentikEvent({ event_user: 'ant' })).toBeNull();
});
it('контекст любого замапленного события проходит secret-blacklist аудита', () => {
for (const action of ['login', 'logout', 'authorize_application', 'login_failed', 'suspicious_request', 'user_write']) {
const r = mapAuthentikEvent({ event_action: action, event_user: 'ant', app: 'X' });
expect(() => assertContextHasNoSecrets(r?.context ?? {})).not.toThrow();
}
});
});
@@ -0,0 +1,128 @@
import { timingSafeEqual } from 'node:crypto';
import {
Body,
Controller,
Headers,
HttpCode,
Logger,
Post,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import config from '~/config/config';
import { AuditService, AuditRecord, AuditResult } from './audit/audit.service';
/** Payload webhook-mapping'ов authentik (coopid-webhook-body / coopid-oidc-webhook-body). */
export interface AuthentikWebhookBody {
severity?: string;
event_action?: string;
event_user?: string | null;
// policy_execution (Story 1.5)
passing?: boolean | null;
messages?: string[] | null;
// OIDC / native-события (Story 8.3)
client_ip?: string | null;
app?: string | null;
created?: string | null;
}
/**
* Семантические имена для ключевых OIDC-операций. Остальные подписанные
* native-события authentik зеркалятся как `Authentik<Action>` (Story 8.3 — AC:
* Oidc* для операций + Authentik* для native-событий). Все OIDC-операции
* выполняет authentik, контроллер узнаёт о них только через этот webhook.
*/
const OIDC_EVENT_MAP: Record<string, string> = {
login: 'OidcLoginSuccess',
logout: 'OidcLogout',
authorize_application: 'OidcTokenIssued',
};
/** Native-действия authentik, для которых result = failure (а не success). */
const FAILURE_ACTIONS = new Set(['login_failed', 'suspicious_request', 'policy_exception']);
function pascalCase(action: string): string {
return action
.split(/[_\s]+/)
.filter(Boolean)
.map((w) => w[0].toUpperCase() + w.slice(1))
.join('');
}
/**
* Маппинг webhook-события authentik в audit-запись. Чистая функция (юнит-тесты).
* - policy_execution + passing=false ⇒ слабый пароль (Story 1.5; execution_logging
* включён ТОЛЬКО у парольной политики CoopID).
* - login/logout/authorize_application ⇒ семантические Oidc* (Story 8.3).
* - прочие подписанные native-события ⇒ Authentik<Action> (Story 8.3).
*/
export function mapAuthentikEvent(body: AuthentikWebhookBody): AuditRecord | null {
const action = body?.event_action;
if (!action) return null;
if (action === 'policy_execution') {
if (body.passing !== false) return null;
return {
event: 'WeakPasswordRejected',
subjectId: body.event_user ?? null,
actor: body.event_user ?? null,
result: 'failure',
context: {
messages: body.messages ?? [],
authentik_created: body.created ?? null,
},
};
}
const event = OIDC_EVENT_MAP[action] ?? `Authentik${pascalCase(action)}`;
const result: AuditResult = FAILURE_ACTIONS.has(action) ? 'failure' : 'success';
return {
event,
subjectId: body.event_user ?? null,
actor: body.event_user ?? null,
result,
ip: body.client_ip ?? null,
context: {
authentik_action: action,
app: body.app ?? null,
authentik_created: body.created ?? null,
},
};
}
function tokenMatches(provided: string | undefined, expected: string): boolean {
if (!provided || !expected) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
/**
* Internal-приёмник событий authentik (Story 1.5). Не маршрутизируется caddy —
* достижим только из docker-сети; дополнительно защищён shared-токеном.
*/
@Controller('coop/internal')
export class AuthentikEventsController {
private readonly logger = new Logger(AuthentikEventsController.name);
constructor(private readonly audit: AuditService) {}
@Post('authentik-events')
@HttpCode(204)
async handle(
@Headers('x-authentik-webhook-token') token: string | undefined,
@Body() body: AuthentikWebhookBody,
): Promise<void> {
if (!tokenMatches(token, config.authV2.webhookToken)) throw new UnauthorizedException();
const record = mapAuthentikEvent(body);
if (!record) return; // не наш кейс — молча принимаем, authentik не должен ретраить
try {
await this.audit.record(record);
} catch (e) {
this.logger.error(`Не удалось записать audit ${record.event}: ${e instanceof Error ? e.message : e}`);
throw new ServiceUnavailableException('audit storage unavailable');
}
}
}
@@ -0,0 +1,56 @@
# CASL-авторизация auth-v2 (Эпик 6)
Реальный `@casl/ability`-фундамент авторизации платформы. Заменяет роле-ориентированный
`@AuthRoles` на capability-ориентированный `can(action, Subject)` (Layered Authorization Pattern).
## Слои (Эпик 6)
| Layer | Что | Story |
|-------|-----|-------|
| 1 — Static Ability | `AbilityFactory.createForParticipant` собирает Ability из core-ролей по статической матрице | 6.1 (этот код) |
| 2 — `access_rules` matrix | декларативные allow/deny из БД мерджатся в Ability; инвалидация активных сессий через Redis pub/sub | 6.2 |
| 3 — PolicyHandler registry | политики с DB-lookup (`@PolicyHandler('same-coop-voting')`) | 6.3 |
| 4 — `AuthorizationGuard` | единый guard на все 4 слоя; HTTP REST + GraphQL; читает Ability из session-стора | 6.4 |
## Роли (канон платформы)
`CoreRole = 'User' | 'Member' | 'Chairman'` (`core-roles.ts`), маппинг из `user.role` JWT.
Иерархия аддитивна: Chairman ⊃ Member ⊃ User. Это существующий канон контроллера
(CLAUDE.md «3 базовые роли») — НЕ вводить синонимы `participant`/`council_member`.
## Матрица Layer 1 (статическая)
| Субъект | User (пайщик) | Member (совет) | Chairman (председатель) |
|---------|---------------|----------------|--------------------------|
| Certificate | read (свой) | — | — |
| Session | read/update (свои) | — | — |
| RecoveryStrategy | manage (своя, вкл. 2FA) | — | — |
| Participant | — | read | update (роли, 6.6) |
| VerificationRule | — | read | manage |
| CoopSettings | — | — | manage |
| CriticalAction | — | read + **confirm** (6.8) | + create (инициатор) |
| Capability | — | — | create (6.7) |
| AuditEvent | — | read | (наследует) |
Критические действия (исключение, смена ролей совета, force-recovery) финализируются только
двумя подписями (Story 6.8) — Chairman инициирует (`create`), Member подтверждает (`confirm`).
## Контракт миграции marketplace-matrix → эта CASL
На ветке `marketplace2` есть CASL-совместимый зачаток
(`extensions/marketplace/application/access/marketplace-access-matrix.ts`,
`Record<role, Record<resource, action[]>>` + `canAccess`) с пометкой «Phase 2 → CASL `defineAbility`».
Платформенный дом этой миграции — **здесь**. Правила трансляции:
- `Resource:action` (например `Order:create`) → `can('create', 'Order')`.
- Квалификаторы охвата `:own` / `:own-KU` / `:to-self` → CASL `conditions`
(`can('read', 'Order', { owner: username })` вместо ручной проверки в resolver'е).
- `:all` (надмножество) → правило без condition; иерархия охвата (`:all` удовлетворяет `:own`)
в CASL выражается естественно: правило без условия матчит любой экземпляр.
- `manage` — тот же CASL-wildcard.
- Развёртка грантов для фронта (`marketplace-grants.ts` `expandGrantsForRoles`) →
`@casl/ability/extra` `permittedFieldsOf` / прямой обход `ability.rules` для `meta.requires`.
Marketplace-домен (ресурсы Order/Offer/KU…) остаётся в своём расширении и переключает источник
policy на эту платформенную CASL без правки call-site guard'ов (interface сохраняется). Перенос —
задача ветки marketplace, не Эпика 6 (так обе ветки мерджатся в dev без конфликтов файлов).
@@ -0,0 +1,210 @@
import { subject } from '@casl/ability';
import {
AccessRuleEffect,
AccessRulePrincipalKind,
type AccessRuleRecord,
type IAccessRulesRepository,
} from '~/domain/auth-v2/ports/access-rules.port';
import type { ICapabilitySetsRepository } from '~/domain/auth-v2/ports/capability-sets.port';
import { AbilityFactory } from './ability.factory';
import { deserializeAbility, serializeAbility } from './ability.serialization';
/** Stub репозитория Layer 2 — для тестов Layer 1 (правил нет). */
const emptyRepo: IAccessRulesRepository = {
findForPrincipal: async () => [],
findForCapabilitySets: async () => [],
insert: async () => undefined,
deleteExpired: async () => 0,
};
/** Stub репозитория наборов — пайщику ничего не назначено. */
const emptySets: ICapabilitySetsRepository = {
listSets: async () => [],
findSet: async () => null,
listActiveSetKeys: async () => [],
listAssignments: async () => [],
assign: async () => undefined,
revoke: async () => false,
};
describe('AbilityFactory — Layer 1 static ability (Story 6.1)', () => {
const factory = new AbilityFactory(emptyRepo, emptySets);
const ownCert = subject('Certificate', { owner: 'ant' });
const foreignCert = subject('Certificate', { owner: 'bob' });
describe('participant (role=user)', () => {
const ability = factory.createForParticipant({ username: 'ant', role: 'user' });
it('управляет своими сущностями, но не чужими', () => {
expect(ability.can('read', ownCert)).toBe(true);
expect(ability.can('read', foreignCert)).toBe(false);
expect(ability.can('update', subject('Session', { owner: 'ant' }))).toBe(true);
expect(ability.can('manage', subject('RecoveryStrategy', { owner: 'ant' }))).toBe(true);
});
it('не имеет админ-прав и не участвует в critical actions', () => {
expect(ability.can('read', 'Participant')).toBe(false);
expect(ability.can('manage', 'VerificationRule')).toBe(false);
expect(ability.can('confirm', 'CriticalAction')).toBe(false);
expect(ability.can('create', 'CriticalAction')).toBe(false);
});
});
describe('council member (role=member)', () => {
const ability = factory.createForParticipant({ username: 'eve', role: 'member' });
it('наследует права пайщика над своими сущностями', () => {
expect(ability.can('read', subject('Certificate', { owner: 'eve' }))).toBe(true);
});
it('read-only надзор + подтверждает critical action, но не инициирует/не модерирует', () => {
expect(ability.can('read', 'Participant')).toBe(true);
expect(ability.can('read', 'VerificationRule')).toBe(true);
expect(ability.can('read', 'AuditEvent')).toBe(true);
expect(ability.can('confirm', 'CriticalAction')).toBe(true);
expect(ability.can('create', 'CriticalAction')).toBe(false);
expect(ability.can('manage', 'VerificationRule')).toBe(false);
expect(ability.can('update', 'Participant')).toBe(false);
});
});
describe('chairman (role=chairman)', () => {
const ability = factory.createForParticipant({ username: 'chief', role: 'chairman' });
it('модерирует + инициирует critical action + наследует Member/User', () => {
expect(ability.can('manage', 'VerificationRule')).toBe(true);
expect(ability.can('manage', 'CoopSettings')).toBe(true);
expect(ability.can('update', 'Participant')).toBe(true);
expect(ability.can('create', 'Capability')).toBe(true);
expect(ability.can('create', 'CriticalAction')).toBe(true);
expect(ability.can('confirm', 'CriticalAction')).toBe(true);
expect(ability.can('read', subject('Certificate', { owner: 'chief' }))).toBe(true);
});
});
describe('платформенный admin / неизвестная роль → пустая Ability', () => {
it.each(['admin', 'superuser', undefined, null])('role=%s ничего не может', (role) => {
const ability = factory.createForParticipant({ username: 'x', role: role as string });
expect(ability.can('read', 'Participant')).toBe(false);
expect(ability.can('read', subject('Certificate', { owner: 'x' }))).toBe(false);
expect(ability.can('manage', 'VerificationRule')).toBe(false);
});
});
describe('сериализация в Redis-session (pack → unpack) сохраняет решения', () => {
it('round-trip сохраняет правила и условия владения', () => {
const ability = factory.createForParticipant({ username: 'chief', role: 'chairman' });
const restored = deserializeAbility(serializeAbility(ability));
expect(restored.can('manage', 'VerificationRule')).toBe(true);
expect(restored.can('create', 'CriticalAction')).toBe(true);
expect(restored.can('read', subject('Certificate', { owner: 'chief' }))).toBe(true);
expect(restored.can('read', subject('Certificate', { owner: 'other' }))).toBe(false);
});
});
});
describe('AbilityFactory — Layer 2 access_rules merge (Story 6.2)', () => {
const factory = new AbilityFactory(emptyRepo, emptySets);
function rule(partial: Partial<AccessRuleRecord> & Pick<AccessRuleRecord, 'action' | 'resourceType'>): AccessRuleRecord {
return {
subjectType: AccessRulePrincipalKind.Role,
subjectId: 'User',
effect: AccessRuleEffect.Allow,
conditions: null,
...partial,
};
}
it('allow-правило добавляет возможность сверх статической матрицы', () => {
const ability = factory.createForParticipant(
{ username: 'ant', role: 'user' },
[rule({ action: 'vote', resourceType: 'CriticalAction' })],
);
expect(ability.can('vote', 'CriticalAction')).toBe(true);
});
it('deny-правило перекрывает статический allow (precedence)', () => {
const ability = factory.createForParticipant(
{ username: 'chief', role: 'chairman' },
[rule({ effect: AccessRuleEffect.Deny, action: 'manage', resourceType: 'VerificationRule' })],
);
expect(ability.can('manage', 'VerificationRule')).toBe(false);
expect(ability.can('read', 'VerificationRule')).toBe(false);
});
it('conditions сужают доступ до совпадающих экземпляров', () => {
const ability = factory.createForParticipant(
{ username: 'ant', role: 'user' },
[rule({ action: 'vote', resourceType: 'CriticalAction', conditions: { branch: 'b1' } })],
);
expect(ability.can('vote', subject('CriticalAction', { branch: 'b1' }))).toBe(true);
expect(ability.can('vote', subject('CriticalAction', { branch: 'b2' }))).toBe(false);
});
it('createForParticipantWithRules читает репозиторий по core-ролям+username и мерджит', async () => {
const repo: IAccessRulesRepository = {
findForPrincipal: jest.fn(async () => [rule({ action: 'vote', resourceType: 'CriticalAction' })]),
findForCapabilitySets: async () => [],
insert: async () => undefined,
deleteExpired: async () => 0,
};
const f = new AbilityFactory(repo, emptySets);
const ability = await f.createForParticipantWithRules({ username: 'eve', role: 'member' });
expect(repo.findForPrincipal).toHaveBeenCalledWith(['User', 'Member'], 'eve');
expect(ability.can('vote', 'CriticalAction')).toBe(true);
expect(ability.can('read', 'Participant')).toBe(true); // статика Member сохранена
});
});
describe('AbilityFactory — назначаемые наборы возможностей merge (Story 6.11)', () => {
function setRule(setKey: string, action: string, resourceType: string): AccessRuleRecord {
return {
subjectType: AccessRulePrincipalKind.CapabilitySet,
subjectId: setKey,
effect: AccessRuleEffect.Allow,
action,
resourceType,
conditions: null,
};
}
it('правила назначенного набора добавляются к Ability пайщика поверх core-роли', async () => {
const accessRules: IAccessRulesRepository = {
findForPrincipal: async () => [],
findForCapabilitySets: jest.fn(async (keys: string[]) =>
keys.includes('cashier') ? [setRule('cashier', 'read', 'PaymentRegistry'), setRule('cashier', 'confirm', 'PaymentRegistry')] : [],
),
insert: async () => undefined,
deleteExpired: async () => 0,
};
const sets: ICapabilitySetsRepository = {
...emptySets,
listActiveSetKeys: jest.fn(async () => ['cashier']),
};
const f = new AbilityFactory(accessRules, sets);
const ability = await f.createForParticipantWithRules({ username: 'kate', role: 'user' });
expect(sets.listActiveSetKeys).toHaveBeenCalledWith('kate');
expect(accessRules.findForCapabilitySets).toHaveBeenCalledWith(['cashier']);
expect(ability.can('read', 'PaymentRegistry')).toBe(true);
expect(ability.can('confirm', 'PaymentRegistry')).toBe(true);
expect(ability.can('read', subject('Certificate', { owner: 'kate' }))).toBe(true); // core User сохранён
});
it('без назначенных наборов набор-правила не запрашиваются (пустой setKeys → пусто)', async () => {
const accessRules: IAccessRulesRepository = {
findForPrincipal: async () => [],
findForCapabilitySets: jest.fn(async () => []),
insert: async () => undefined,
deleteExpired: async () => 0,
};
const f = new AbilityFactory(accessRules, emptySets);
const ability = await f.createForParticipantWithRules({ username: 'bob', role: 'user' });
expect(accessRules.findForCapabilitySets).toHaveBeenCalledWith([]);
expect(ability.can('read', 'PaymentRegistry')).toBe(false);
});
});
@@ -0,0 +1,121 @@
import { Inject, Injectable } from '@nestjs/common';
import { AbilityBuilder, createMongoAbility } from '@casl/ability';
import {
AccessRuleEffect,
ACCESS_RULES_REPOSITORY,
type AccessRuleRecord,
type IAccessRulesRepository,
} from '~/domain/auth-v2/ports/access-rules.port';
import {
CAPABILITY_SETS_REPOSITORY,
type ICapabilitySetsRepository,
} from '~/domain/auth-v2/ports/capability-sets.port';
import type { AppAbility, CoopAction, CoopSubject } from './ability.types';
import { mapUserRoleToCoreRoles } from './core-roles';
/** Пайщик, для которого собирается Ability (минимум из JWT-сессии). */
export interface IAbilitySubjectUser {
username: string;
role?: string | null;
}
/**
* CASL-авторизация (Эпик 6). Layer 1 (Story 6.1) — статическая матрица «роль→
* возможности». Layer 2 (Story 6.2) — декларативные `access_rules` из coop_domain_db,
* мерджатся поверх статики. Матрица аддитивна: Chairman наследует Member, Member — User.
*
* Ownership self-субъектов (свой Certificate/Session/RecoveryStrategy) вшит в условие
* `{ owner: username }` прямо в Ability — реальный CASL (в отличие от marketplace
* `canAccess`, где ownership проверял resolver). DB-зависимые политики — Layer 3 (Story 6.3).
*/
@Injectable()
export class AbilityFactory {
constructor(
@Inject(ACCESS_RULES_REPOSITORY)
private readonly accessRules: IAccessRulesRepository,
@Inject(CAPABILITY_SETS_REPOSITORY)
private readonly capabilitySets: ICapabilitySetsRepository,
) {}
/**
* Синхронная сборка: Layer 1 static + merge переданных `access_rules` (Layer 2).
* Чистая (без IO) — `accessRules` инжектится вызывающим, что делает merge тестируемым.
*/
createForParticipant(user: IAbilitySubjectUser, accessRules: AccessRuleRecord[] = []): AppAbility {
const coreRoles = mapUserRoleToCoreRoles(user.role);
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
const owner = { owner: user.username };
// --- Layer 1: статическая матрица ---
// User — любой пайщик: полный контроль над собственными сущностями.
if (coreRoles.includes('User')) {
can('read', 'Certificate', owner);
can(['read', 'update'], 'Session', owner);
can('manage', 'RecoveryStrategy', owner); // вкл. настройки 2FA
}
// Member — член совета: read-only надзор + роль второго подписанта critical-action.
if (coreRoles.includes('Member')) {
can('read', 'Participant');
can('read', 'VerificationRule');
can('read', 'CriticalAction');
can('read', 'AuditEvent');
can('confirm', 'CriticalAction'); // второй подписант (Story 6.8)
}
// Chairman — председатель: write-модерация + инициация critical-action.
if (coreRoles.includes('Chairman')) {
can('manage', 'VerificationRule');
can('manage', 'CoopSettings');
can('update', 'Participant'); // назначение ролей (Story 6.6)
can('create', 'Capability'); // выдача точечных capabilities (Story 6.7)
can('manage', 'CapabilitySet'); // назначение наборов возможностей (Story 6.11)
can('create', 'CriticalAction'); // инициатор; финал — только 2 подписи (Story 6.8)
}
// --- Layer 2: access_rules. allow — первыми, deny — последними, чтобы deny
// перекрывал любой предыдущий allow (CASL берёт последнее матчащее правило). ---
const apply = (rule: AccessRuleRecord, kind: 'allow' | 'deny'): void => {
const action = rule.action as CoopAction;
const subject = rule.resourceType as CoopSubject;
const fn = kind === 'allow' ? can : cannot;
if (rule.conditions) {
fn(action, subject, rule.conditions);
}
else {
fn(action, subject);
}
};
for (const rule of accessRules) {
if (rule.effect === AccessRuleEffect.Allow) {
apply(rule, 'allow');
}
}
for (const rule of accessRules) {
if (rule.effect === AccessRuleEffect.Deny) {
apply(rule, 'deny');
}
}
return build();
}
/**
* Полная сборка Ability пайщика: читает `access_rules` (Layer 2) из БД и мерджит
* поверх Layer 1. Это «AbilityFactory читает access_rules» из AC. Свежие правила
* видит при каждой сборке (= новый логин/пересборка); активные сессии инвалидируются
* через Redis pub/sub (publisher Story 6.2, подписчик Story 6.4).
*/
async createForParticipantWithRules(user: IAbilitySubjectUser): Promise<AppAbility> {
const coreRoles = mapUserRoleToCoreRoles(user.role);
// Источники правил L2 объединяются в один список (allow-first/deny-last делает
// createForParticipant): (1) core-роли + персональные гранты пайщика; (2) правила
// назначенных пайщику наборов возможностей (Story 6.11). Движок — один (CASL).
const setKeys = await this.capabilitySets.listActiveSetKeys(user.username);
const [principalRules, setRules] = await Promise.all([
this.accessRules.findForPrincipal(coreRoles, user.username),
this.accessRules.findForCapabilitySets(setKeys),
]);
return this.createForParticipant(user, [...principalRules, ...setRules]);
}
}
@@ -0,0 +1,22 @@
import { createMongoAbility } from '@casl/ability';
import type { AppAbility } from './ability.types';
/**
* Сериализация Ability для Redis-session (Story 6.1). `ability.rules` —
* JSON-сериализуемый массив правил; `serializeAbility` кладёт его в строку для
* session-стора, `deserializeAbility` восстанавливает Ability. Связывание со
* session-стором и чтение Ability на каждом запросе — Story 6.4 (`AuthorizationGuard`).
*
* Redis (а не JWT-claim): права активной сессии должны отзываться при правке
* `access_rules` через Redis pub/sub (Story 6.2), чего JWT-claim не позволяет.
*
* Позиционная упаковка (`@casl/ability/extra` packRules) для компактности — позже:
* subpath требует `moduleResolution: bundler/node16`, а raw-rules JSON корректен и сейчас.
*/
export function serializeAbility(ability: AppAbility): string {
return JSON.stringify(ability.rules);
}
export function deserializeAbility(packed: string): AppAbility {
return createMongoAbility(JSON.parse(packed));
}
@@ -0,0 +1,41 @@
import type { MongoAbility } from '@casl/ability';
/**
* Словарь CASL-авторизации auth-v2 (Story 6.1). Реальный `@casl/ability`-фундамент,
* в который по Phase-2-пометке мигрирует marketplace-access-matrix (ветка
* `marketplace2`): `Resource:action` → `can(action, Subject)`, квалификаторы
* `:own`/`:own-KU` → CASL `conditions`.
*/
/**
* Действия. База CRUD + `manage` (CASL-wildcard «всё над субъектом») + доменные
* глаголы: `confirm` — второй подписант critical-action (Story 6.8); `vote` —
* голос в решении (Story 6.3, same-coop policy).
*/
export type CoopAction = 'manage' | 'create' | 'read' | 'update' | 'delete' | 'confirm' | 'vote';
/** Субъекты (ресурсы) домена auth-v2. `all` — CASL-wildcard для платформенного админа. */
export type CoopSubject =
| 'Participant'
| 'Session'
| 'Certificate'
| 'VerificationRule'
| 'CoopSettings'
| 'RecoveryStrategy'
| 'CriticalAction'
| 'Role'
| 'Capability'
/** Именованный набор возможностей (Story 6.11) — управляет председатель. */
| 'CapabilitySet'
| 'AuditEvent'
// --- Субъекты доступа к рабочим столам/страницам (grant-строки для desktop
// meta.requires). Имена — предложение на согласование при разводке desktop-gating;
// на границе access_rules это свободные строки, переименовываются точечно.
/** Стол бухгалтера (доступ набора `accountant`). */
| 'AccountingDesk'
/** Реестр платежей / будущий стол кассира (доступ набора `cashier`). */
| 'PaymentRegistry'
| 'all';
/** Тип Ability пайщика — собирается `AbilityFactory.createForParticipant`. */
export type AppAbility = MongoAbility<[CoopAction, CoopSubject]>;
@@ -0,0 +1,34 @@
import type { IAccessRulesRepository } from '~/domain/auth-v2/ports/access-rules.port';
import { AccessRulesCleanupService } from './access-rules-cleanup.service';
function makeService(deleteExpired = jest.fn(async () => 0)) {
const repo: IAccessRulesRepository = {
findForPrincipal: jest.fn(async () => []),
findForCapabilitySets: jest.fn(async () => []),
insert: jest.fn(async () => undefined),
deleteExpired,
};
return { service: new AccessRulesCleanupService(repo), deleteExpired };
}
describe('AccessRulesCleanupService — уборка истёкших access_rules (Story 6.7)', () => {
it('purgeExpired зовёт deleteExpired с текущим моментом (Date)', async () => {
const { service, deleteExpired } = makeService(jest.fn(async () => 3));
await service.purgeExpired();
expect(deleteExpired).toHaveBeenCalledTimes(1);
expect(deleteExpired).toHaveBeenCalledWith(expect.any(Date));
});
it('нечего удалять (0) → проходит без ошибок', async () => {
const { service, deleteExpired } = makeService(jest.fn(async () => 0));
await expect(service.purgeExpired()).resolves.toBeUndefined();
expect(deleteExpired).toHaveBeenCalledTimes(1);
});
it('сбой репозитория пробрасывается (виден в логах планировщика, не глотается)', async () => {
const { service } = makeService(jest.fn(async () => { throw new Error('db down'); }));
await expect(service.purgeExpired()).rejects.toThrow('db down');
});
});
@@ -0,0 +1,35 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import {
ACCESS_RULES_REPOSITORY,
type IAccessRulesRepository,
} from '~/domain/auth-v2/ports/access-rules.port';
/**
* Фоновая уборка истёкших точечных прав `access_rules` (Story 6.7, TTL capability).
*
* Истёкшие правила УЖЕ инертны: read-path (`findForPrincipal`/`findForCapabilitySets`)
* исключает их по `expires_at <= now()`, доступа они не дают. Этот cron лишь удаляет
* мёртвые строки, чтобы таблица не росла бесконечно (гигиена + скорость выборки прав).
* Удаление безопасно по построению — трогает только строки с непустым `expires_at` в
* прошлом; бессрочные и ещё действующие не затрагиваются. Без отзыва сессий и без
* аудита: ничьи фактические права не меняются (в отличие от явной revoke в 6.7-UI).
*
* Прецедент cron'а в auth-v2 — `CriticalActionsService.expireStale` (Story 6.8).
*/
@Injectable()
export class AccessRulesCleanupService {
private readonly logger = new Logger(AccessRulesCleanupService.name);
constructor(
@Inject(ACCESS_RULES_REPOSITORY)
private readonly accessRules: IAccessRulesRepository,
) {}
/** Cron: ежедневно в полночь удаляет истёкшие capability-правила. */
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async purgeExpired(): Promise<void> {
const removed = await this.accessRules.deleteExpired(new Date());
if (removed) this.logger.log(`access_rules: удалено истёкших правил=${removed}`);
}
}
@@ -0,0 +1,29 @@
import { ForbiddenException } from '@nestjs/common';
/**
* Внутренняя причина отказа авторизации (для лога/диагностики). Наружу НЕ уходит:
* клиенту отдаётся обобщённый `authorization_denied` без указания, какой именно слой
* отказал (AC Story 6.4 — «не раскрывает специфику для security»). Статусы — enum.
*/
export enum AuthorizationDenialReason {
/** В запросе нет авторизованного пайщика (JWT-guard не проставил user). */
NoSubjectUser = 'no_subject_user',
/** Ability (Layer 1+2) не разрешает пару action+subject. */
InsufficientAbility = 'insufficient_ability',
/** Политика Layer 3 вернула false. */
PolicyDenied = 'policy_denied',
}
/** Публичный код отказа — единый, без раскрытия слоя/правила. */
export const AUTHORIZATION_DENIED_CODE = 'authorization_denied';
/**
* 403 с обобщённым кодом. Точную причину (reason) логирует вызывающий — она не
* попадает в ответ. Формат тела совместим с auth-v2 ({ error, error_description }).
*/
export function authorizationDenied(): ForbiddenException {
return new ForbiddenException({
error: AUTHORIZATION_DENIED_CODE,
error_description: 'Недостаточно прав для выполнения операции',
});
}
@@ -0,0 +1,74 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import config from '~/config/config';
import { AuthorizationGuard } from './authorization.guard';
import { PolicyService } from './policy.service';
import type { CheckAbilityRequirement } from './check-ability.decorator';
function makeGuard(requirement: CheckAbilityRequirement | undefined): {
guard: AuthorizationGuard;
ensure: jest.Mock;
} {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(requirement) } as unknown as Reflector;
const ensure = jest.fn().mockResolvedValue(undefined);
const guard = new AuthorizationGuard(reflector, { ensure } as unknown as PolicyService);
return { guard, ensure };
}
/** Фейковый GraphQL ExecutionContext: getArgs() отдаёт [root, args, ctx, info]. */
function gqlContext(args: Record<string, unknown>, reqObj: unknown): ExecutionContext {
const gqlArgs = [{}, args, { req: reqObj }, {}];
return {
getType: () => 'graphql',
getHandler: () => () => undefined,
getClass: () => class {},
getArgs: () => gqlArgs,
getArgByIndex: (i: number) => gqlArgs[i],
switchToHttp: () => ({ getRequest: () => ({}) }),
} as unknown as ExecutionContext;
}
function httpContext(reqObj: Record<string, unknown>): ExecutionContext {
return {
getType: () => 'http',
getHandler: () => () => undefined,
getClass: () => class {},
switchToHttp: () => ({ getRequest: () => reqObj }),
} as unknown as ExecutionContext;
}
describe('AuthorizationGuard — единый guard 4 слоёв (Story 6.4)', () => {
it('нет @CheckAbility → пропускает (не наш endpoint)', async () => {
const { guard, ensure } = makeGuard(undefined);
await expect(guard.canActivate(gqlContext({}, { user: {} }))).resolves.toBe(true);
expect(ensure).not.toHaveBeenCalled();
});
it('server-secret → служебный обход без проверки', async () => {
const { guard, ensure } = makeGuard({ action: 'update', subject: 'Participant' });
const ctx = gqlContext({}, { user: { username: 'x', role: 'user' }, headers: { 'server-secret': config.server_secret } });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(ensure).not.toHaveBeenCalled();
});
it('GraphQL: извлекает user + args.data и делегирует PolicyService.ensure', async () => {
const { guard, ensure } = makeGuard({ action: 'update', subject: 'Participant' });
const user = { username: 'chief', role: 'chairman' };
await guard.canActivate(gqlContext({ data: { id: 'p1' } }, { user, headers: {} }));
expect(ensure).toHaveBeenCalledWith({ action: 'update', subject: 'Participant' }, user, { id: 'p1' });
});
it('HTTP REST: извлекает user + params/query/body и делегирует ensure', async () => {
const { guard, ensure } = makeGuard({ action: 'read', subject: 'Certificate' });
const user = { username: 'ant', role: 'user' };
const ctx = httpContext({ user, headers: {}, params: { id: 'c1' }, query: {}, body: { owner: 'ant' } });
await guard.canActivate(ctx);
expect(ensure).toHaveBeenCalledWith({ action: 'read', subject: 'Certificate' }, user, { id: 'c1', owner: 'ant' });
});
it('отказ PolicyService пробрасывается (canActivate отклоняется)', async () => {
const { guard, ensure } = makeGuard({ action: 'manage', subject: 'CoopSettings' });
ensure.mockRejectedValueOnce(new Error('forbidden'));
await expect(guard.canActivate(gqlContext({}, { user: { username: 'ant', role: 'user' }, headers: {} }))).rejects.toThrow();
});
});
@@ -0,0 +1,60 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
import config from '~/config/config';
import type { IAbilitySubjectUser } from './ability.factory';
import { CHECK_ABILITY, type CheckAbilityRequirement } from './check-ability.decorator';
import { PolicyService } from './policy.service';
/**
* Единый guard всех 4 слоёв авторизации (Story 6.4). Читает `@CheckAbility` с
* метода/класса; если требования нет — пропускает (endpoint не под CASL). Покрывает
* и GraphQL-резолверы, и HTTP REST: извлекает user и атрибуты ресурса из обоих видов
* контекста, затем делегирует `PolicyService.ensure` (Layer 1+2 Ability → Layer 3
* политика → Layer 4 императивно — общий вычислитель). Отказ = `403` с обобщённым
* кодом (специфика — только в лог).
*
* Применяется точечно на резолверах auth-v2 (миграция с `@AuthRoles` — Story 6.5),
* НЕ глобально как APP_GUARD: legacy `auth/` остаётся на `@AuthRoles`.
*/
@Injectable()
export class AuthorizationGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly policyService: PolicyService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requirement = this.reflector.getAllAndOverride<CheckAbilityRequirement | undefined>(CHECK_ABILITY, [
context.getHandler(),
context.getClass(),
]);
if (!requirement) return true; // endpoint не объявил требование CASL — не наше дело
const { user, resource, serverSecret } = this.extract(context);
// Серверный секрет — служебный обход (как RolesGuard); межсервисные вызовы.
if (serverSecret && serverSecret === config.server_secret) return true;
await this.policyService.ensure(requirement, user, resource);
return true;
}
/** Достаёт user + атрибуты ресурса + server-secret из GraphQL или HTTP контекста. */
private extract(context: ExecutionContext): {
user: IAbilitySubjectUser | undefined;
resource: Record<string, unknown> | undefined;
serverSecret: string | undefined;
} {
if (context.getType<GqlContextType>() === 'graphql') {
const gql = GqlExecutionContext.create(context);
const req = gql.getContext().req;
const args = gql.getArgs<Record<string, unknown>>();
const resource = (args?.data ?? args?.filter ?? args) as Record<string, unknown> | undefined;
return { user: req?.user, resource, serverSecret: req?.headers?.['server-secret'] };
}
const req = context.switchToHttp().getRequest();
const resource = { ...(req?.params ?? {}), ...(req?.query ?? {}), ...(req?.body ?? {}) };
return { user: req?.user, resource, serverSecret: req?.headers?.['server-secret'] };
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { AuthV2InfrastructureModule } from '~/infrastructure/auth-v2/auth-v2-infrastructure.module';
import { AbilityFactory } from './ability.factory';
import { PolicyRegistry } from './policy.registry';
import { PolicyService } from './policy.service';
import { AuthorizationGuard } from './authorization.guard';
import { AccessRulesCleanupService } from './access-rules-cleanup.service';
import { SameCoopVotingPolicy } from './policies/same-coop-voting.policy';
/**
* CASL-авторизация auth-v2 (Эпик 6). Story 6.1 — `AbilityFactory` (Layer 1);
* Story 6.2 — Layer 2 (`access_rules`); Story 6.3 — Layer 3 (`PolicyRegistry` +
* политики `@PolicyHandler`); Story 6.4 — `PolicyService` (общий вычислитель L1–L4)
* + единый `AuthorizationGuard` (REST + GraphQL). Резолверы переходят на
* `@CheckAbility`+guard в Story 6.5.
*/
@Module({
imports: [DiscoveryModule, AuthV2InfrastructureModule],
providers: [AbilityFactory, PolicyRegistry, PolicyService, AuthorizationGuard, AccessRulesCleanupService, SameCoopVotingPolicy],
exports: [AbilityFactory, PolicyRegistry, PolicyService, AuthorizationGuard],
})
export class AuthorizationModule {}
@@ -0,0 +1,96 @@
import type { CapabilitySetService } from './capability-set.service';
import { AuthorizationResolver } from './authorization.resolver';
const CURRENT_USER = { id: 'u1', username: 'chairman1', role: 'chairman' };
function build() {
const service = {
listSets: jest.fn(async () => [
{
setKey: 'accountant',
title: 'Бухгалтер',
description: 'Стол бухгалтера',
builtin: true,
coopname: null,
grants: [{ action: 'read', resource: 'AccountingDesk' }],
},
]),
listForParticipant: jest.fn(async () => [
{
username: 'payer1',
setKey: 'cashier',
grantedBy: 'chairman1',
grantedAt: '2026-06-13T00:00:00Z',
expiresAt: null,
},
]),
getMyAccess: jest.fn(async () => ({
sets: ['accountant'],
grants: [{ action: 'read', resource: 'AccountingDesk' }],
})),
assign: jest.fn(async () => undefined),
revoke: jest.fn(async () => undefined),
};
const resolver = new AuthorizationResolver(service as unknown as CapabilitySetService);
return { resolver, service };
}
describe('AuthorizationResolver', () => {
it('getCapabilitySets маппит в snake_case вместе с грантами', async () => {
const { resolver } = build();
expect(await resolver.getCapabilitySets()).toEqual([
{
set_key: 'accountant',
title: 'Бухгалтер',
description: 'Стол бухгалтера',
builtin: true,
coopname: null,
grants: [{ action: 'read', resource: 'AccountingDesk' }],
},
]);
});
it('getParticipantCapabilitySets маппит назначение в snake_case', async () => {
const { resolver, service } = build();
const out = await resolver.getParticipantCapabilitySets('payer1');
expect(service.listForParticipant).toHaveBeenCalledWith('payer1');
expect(out).toEqual([
{
username: 'payer1',
set_key: 'cashier',
granted_by: 'chairman1',
granted_at: '2026-06-13T00:00:00Z',
expires_at: null,
},
]);
});
it('getMyAccess передаёт username/role текущего пайщика и маппит гранты', async () => {
const { resolver, service } = build();
const out = await resolver.getMyAccess(CURRENT_USER);
expect(service.getMyAccess).toHaveBeenCalledWith({ username: 'chairman1', role: 'chairman' });
expect(out).toEqual({ sets: ['accountant'], grants: [{ action: 'read', resource: 'AccountingDesk' }] });
});
it('assignCapabilitySet ставит grantedBy = текущий пользователь и маппит set_key', async () => {
const { resolver, service } = build();
const ok = await resolver.assignCapabilitySet(
{ username: 'payer1', set_key: 'cashier', expires_at: null },
CURRENT_USER,
);
expect(ok).toBe(true);
expect(service.assign).toHaveBeenCalledWith({
username: 'payer1',
setKey: 'cashier',
grantedBy: 'chairman1',
expiresAt: null,
});
});
it('revokeCapabilitySet зовёт revoke с revokedBy = текущий пользователь', async () => {
const { resolver, service } = build();
const ok = await resolver.revokeCapabilitySet({ username: 'payer1', set_key: 'cashier' }, CURRENT_USER);
expect(ok).toBe(true);
expect(service.revoke).toHaveBeenCalledWith('payer1', 'cashier', 'chairman1');
});
});
@@ -0,0 +1,117 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import { CapabilitySetService } from './capability-set.service';
import { AuthorizationGuard } from './authorization.guard';
import { CheckAbility } from './check-ability.decorator';
import {
AssignCapabilitySetInputDTO,
CapabilitySetAssignmentDTO,
CapabilitySetDTO,
ParticipantAccessDTO,
RevokeCapabilitySetInputDTO,
} from './dto/capability-set.dto';
interface ICurrentUser {
id: string;
username: string;
role?: string;
}
/**
* GraphQL-фасад назначаемых наборов возможностей и эффективного доступа (Story 6.11).
* Заменяет REST-контроллеры `coop/capability-sets` и `coop/access` — фронт ходит через
* @coopenomics/sdk (Zeus), нового способа взаимодействия с бэкендом наружу не появляется.
*
* Авторизация — тот же субстрат Эпика 6: `@CheckAbility` + `AuthorizationGuard` (уже
* GraphQL-aware). Председатель управляет наборами (`manage CapabilitySet`), читает каталог
* (`read`); `getMyAccess` — собственный доступ текущего пайщика (без CASL-требования).
*/
@Resolver()
export class AuthorizationResolver {
constructor(private readonly service: CapabilitySetService) {}
@Query(() => [CapabilitySetDTO], {
name: 'getCapabilitySets',
description: 'Каталог наборов возможностей с правами, которые они открывают',
})
@UseGuards(GqlJwtAuthGuard, AuthorizationGuard)
@CheckAbility('read', 'CapabilitySet')
async getCapabilitySets(): Promise<CapabilitySetDTO[]> {
const sets = await this.service.listSets();
return sets.map((s) => ({
set_key: s.setKey,
title: s.title,
description: s.description,
builtin: s.builtin,
coopname: s.coopname,
grants: s.grants.map((g) => ({ action: g.action, resource: g.resource })),
}));
}
@Query(() => [CapabilitySetAssignmentDTO], {
name: 'getParticipantCapabilitySets',
description: 'Активные наборы возможностей, назначенные пайщику',
})
@UseGuards(GqlJwtAuthGuard, AuthorizationGuard)
@CheckAbility('read', 'CapabilitySet')
async getParticipantCapabilitySets(
@Args('username', { type: () => String }) username: string,
): Promise<CapabilitySetAssignmentDTO[]> {
const assignments = await this.service.listForParticipant(username);
return assignments.map((a) => ({
username: a.username,
set_key: a.setKey,
granted_by: a.grantedBy,
granted_at: a.grantedAt,
expires_at: a.expiresAt,
}));
}
@Query(() => ParticipantAccessDTO, {
name: 'getMyAccess',
description: 'Эффективный доступ текущего пайщика (основание гейтинга столов и страниц)',
})
@UseGuards(GqlJwtAuthGuard)
async getMyAccess(@CurrentUser() user: ICurrentUser): Promise<ParticipantAccessDTO> {
const access = await this.service.getMyAccess({ username: user.username, role: user.role });
return {
sets: access.sets,
grants: access.grants.map((g) => ({ action: g.action, resource: g.resource })),
};
}
@Mutation(() => Boolean, {
name: 'assignCapabilitySet',
description: 'Назначить пайщику набор возможностей (управляет председатель)',
})
@UseGuards(GqlJwtAuthGuard, AuthorizationGuard)
@CheckAbility('manage', 'CapabilitySet')
async assignCapabilitySet(
@Args('data', { type: () => AssignCapabilitySetInputDTO }) data: AssignCapabilitySetInputDTO,
@CurrentUser() user: ICurrentUser,
): Promise<boolean> {
await this.service.assign({
username: data.username,
setKey: data.set_key,
grantedBy: user.username,
expiresAt: data.expires_at ?? null,
});
return true;
}
@Mutation(() => Boolean, {
name: 'revokeCapabilitySet',
description: 'Отозвать у пайщика набор возможностей (управляет председатель)',
})
@UseGuards(GqlJwtAuthGuard, AuthorizationGuard)
@CheckAbility('manage', 'CapabilitySet')
async revokeCapabilitySet(
@Args('data', { type: () => RevokeCapabilitySetInputDTO }) data: RevokeCapabilitySetInputDTO,
@CurrentUser() user: ICurrentUser,
): Promise<boolean> {
await this.service.revoke(data.username, data.set_key, user.username);
return true;
}
}
@@ -0,0 +1,115 @@
import { NotFoundException } from '@nestjs/common';
import {
AccessRuleEffect,
AccessRulePrincipalKind,
type AccessRuleRecord,
type IAccessRulesInvalidationPublisher,
type IAccessRulesRepository,
} from '~/domain/auth-v2/ports/access-rules.port';
import type { CapabilitySet, ICapabilitySetsRepository } from '~/domain/auth-v2/ports/capability-sets.port';
import type { AuditService } from '../audit/audit.service';
import type { AbilityFactory } from './ability.factory';
import { CapabilitySetService } from './capability-set.service';
const ACCOUNTANT: CapabilitySet = {
setKey: 'accountant',
title: 'Бухгалтер',
description: 'Стол бухгалтера',
builtin: true,
coopname: null,
};
function setRule(setKey: string, action: string, resource: string): AccessRuleRecord {
return {
subjectType: AccessRulePrincipalKind.CapabilitySet,
subjectId: setKey,
effect: AccessRuleEffect.Allow,
action,
resourceType: resource,
conditions: null,
};
}
function build() {
const repo = {
listSets: jest.fn(async (): Promise<CapabilitySet[]> => [ACCOUNTANT]),
findSet: jest.fn(async (key: string): Promise<CapabilitySet | null> => (key === 'accountant' ? ACCOUNTANT : null)),
listActiveSetKeys: jest.fn(async (_username: string): Promise<string[]> => ['accountant']),
listAssignments: jest.fn(async (_username: string) => []),
assign: jest.fn(async (): Promise<void> => undefined),
revoke: jest.fn(async (_username: string, _setKey: string): Promise<boolean> => true),
} satisfies ICapabilitySetsRepository;
const accessRules = {
findForPrincipal: jest.fn(async (): Promise<AccessRuleRecord[]> => []),
findForCapabilitySets: jest.fn(async (keys: string[]): Promise<AccessRuleRecord[]> =>
keys.includes('accountant') ? [setRule('accountant', 'read', 'AccountingDesk')] : [],
),
insert: jest.fn(async (): Promise<void> => undefined),
deleteExpired: jest.fn(async (): Promise<number> => 0),
} satisfies IAccessRulesRepository;
const invalidation = { publish: jest.fn(async (): Promise<void> => undefined) } satisfies IAccessRulesInvalidationPublisher;
const ability = { rules: [{ action: 'read', subject: 'AccountingDesk', inverted: false }] };
const abilityFactory = {
createForParticipantWithRules: jest.fn(async () => ability),
} as unknown as AbilityFactory;
const audit = { record: jest.fn(async (): Promise<void> => undefined) } as unknown as AuditService & { record: jest.Mock };
const service = new CapabilitySetService(repo, accessRules, invalidation, abilityFactory, audit);
return { service, repo, accessRules, invalidation, abilityFactory, audit };
}
describe('CapabilitySetService — назначаемые наборы (Story 6.11)', () => {
it('assign: валидирует набор, пишет назначение, инвалидирует сессии, аудирует', async () => {
const { service, repo, invalidation, audit } = build();
await service.assign({ username: 'kate', setKey: 'accountant', grantedBy: 'chief', expiresAt: null });
expect(repo.findSet).toHaveBeenCalledWith('accountant');
expect(repo.assign).toHaveBeenCalledWith({ username: 'kate', setKey: 'accountant', grantedBy: 'chief', expiresAt: null });
expect(invalidation.publish).toHaveBeenCalledWith({ subjectType: AccessRulePrincipalKind.Participant, subjectId: 'kate' });
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ event: 'CapabilitySetAssigned', subjectId: 'kate', actor: 'chief', result: 'success' }),
);
});
it('assign: несуществующий набор → NotFound, без записи/инвалидации/аудита', async () => {
const { service, repo, invalidation, audit } = build();
await expect(service.assign({ username: 'kate', setKey: 'ghost', grantedBy: 'chief' })).rejects.toBeInstanceOf(NotFoundException);
expect(repo.assign).not.toHaveBeenCalled();
expect(invalidation.publish).not.toHaveBeenCalled();
expect(audit.record).not.toHaveBeenCalled();
});
it('revoke: снимает набор, инвалидирует, аудирует', async () => {
const { service, repo, invalidation, audit } = build();
await service.revoke('kate', 'accountant', 'chief');
expect(repo.revoke).toHaveBeenCalledWith('kate', 'accountant');
expect(invalidation.publish).toHaveBeenCalledWith({ subjectType: AccessRulePrincipalKind.Participant, subjectId: 'kate' });
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ event: 'CapabilitySetRevoked', subjectId: 'kate', actor: 'chief', result: 'success', context: { set_key: 'accountant' } }),
);
});
it('revoke: нет активного назначения → NotFound, без инвалидации/аудита', async () => {
const { service, repo, invalidation, audit } = build();
repo.revoke.mockResolvedValueOnce(false);
await expect(service.revoke('kate', 'accountant', 'chief')).rejects.toBeInstanceOf(NotFoundException);
expect(invalidation.publish).not.toHaveBeenCalled();
expect(audit.record).not.toHaveBeenCalled();
});
it('listSets отдаёт каталог наборов, обогащённый их грантами', async () => {
const { service } = build();
const sets = await service.listSets();
expect(sets).toHaveLength(1);
expect(sets[0]).toMatchObject({ setKey: 'accountant', title: 'Бухгалтер' });
expect(sets[0].grants).toEqual([{ action: 'read', resource: 'AccountingDesk' }]);
});
it('getMyAccess отдаёт активные наборы + плоские гранты из Ability (allow, без дублей)', async () => {
const { service, abilityFactory } = build();
const access = await service.getMyAccess({ username: 'kate', role: 'user' });
expect(abilityFactory.createForParticipantWithRules).toHaveBeenCalledWith({ username: 'kate', role: 'user' });
expect(access.sets).toEqual(['accountant']);
expect(access.grants).toEqual([{ action: 'read', resource: 'AccountingDesk' }]);
});
});
@@ -0,0 +1,31 @@
import { SetMetadata } from '@nestjs/common';
import type { CoopAction, CoopSubject } from './ability.types';
/** Ключ метаданных, который читает `AuthorizationGuard` (Story 6.4). */
export const CHECK_ABILITY = 'coopid:check-ability';
/**
* Требование авторизации на endpoint: пара `action`+`subject` (проверяется по Ability,
* Layer 1+2) и опциональная `policy` (Layer 3, исполняется `PolicyRegistry`). Guard
* объединяющий все слои — Story 6.4; здесь — декларация требования и его тип.
*/
export interface CheckAbilityRequirement {
action: CoopAction;
subject: CoopSubject;
/** Имя политики Layer 3 (`@PolicyHandler(name)`); если задано — guard её исполнит. */
policy?: string;
}
/**
* Объявляет требование авторизации (Story 6.5 заменяет им `@AuthRoles` в auth-v2).
*
* @example `@CheckAbility('vote', 'CriticalAction', { policy: 'same-coop-voting' })`
*/
export function CheckAbility(
action: CoopAction,
subject: CoopSubject,
options?: { policy?: string },
): MethodDecorator & ClassDecorator {
const requirement: CheckAbilityRequirement = { action, subject, policy: options?.policy };
return SetMetadata(CHECK_ABILITY, requirement);
}

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