Обновить доки по закрытию остатков код-стайла

Аудит §2 переписан под решения (var-гейт, LF, дедуп закрыт — дублей нет);
backlog: TD-COMMENTS-IFACE п.1–3 закрыты, TD-STYLE-ANALYZERS закрыт; STATUS.md —
новый блок захода, устаревший блок «Осталось (в backlog)» в шапке удалён; план и
ledger захода.
This commit is contained in:
Rustam Khalimov
2026-09-11 19:01:42 +03:00
parent 713d554dc2
commit a7e38846d7
245 changed files with 32225 additions and 32151 deletions
@@ -1,94 +1,94 @@
#!/usr/bin/env sh
# live-saas-check.sh — живая SaaS-проверка операторского контура (этап 7, Manual-чек-лист).
# Требует: поднятый deal-postgres и запущенный core на :5080 (Development, Local-режим) + DEAL_DEMO.
# Формы: оператор operator/operator (dev-default bootstrap) → тенант → инвайт → /api/join → вход →
# настройки/демо → IDOR → suspend 403 → resume → лимиты → аудит. Ничего не поднимает/не гасит сам.
set -u
BASE="http://localhost:5080"
WORK=$(mktemp -d)
OP="$WORK/op.txt"; USER="$WORK/user.txt"; TMP="$WORK/out.txt"
PASS=0; FAIL=0
note() { echo "$1"; }
check() { # имя, ожидание кода, [фрагменты...]
name="$1"; code="$2"; shift 2
if grep -q "\[HTTP:$code\]" "$TMP"; then
for f in "$@"; do grep -qF "$f" "$TMP" || { echo " [FAIL] $name (нет: $f)"; cat "$TMP"; FAIL=$((FAIL+1)); return; }; done
echo " [PASS] $name"; PASS=$((PASS+1))
else
echo " [FAIL] $name (ожидался HTTP $code)"; cat "$TMP"; FAIL=$((FAIL+1))
fi
}
TS=$(date +%s)
EMAIL="live$TS@test.local"
TENANT_NAME="LiveCheck$TS"
echo "== 1. оператор login (dev-default operator/operator) =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -c "$OP" -X POST "$BASE/api/operator/auth/login" \
-H "Content-Type: application/json" -d '{"login":"operator","password":"operator"}' > "$TMP"
check "operator login -> 200 ok" 200 '"ok":true'
echo "== 2. оператор создаёт тенанта =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" -X POST "$BASE/api/operator/tenants" \
-H "Content-Type: application/json" -d "{\"name\":\"$TENANT_NAME\"}" > "$TMP"
check "create tenant -> 200 id" 200 '"id":'
TID=$(sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' "$TMP" | head -1)
[ -n "$TID" ] && echo " тенант: $TID" || { echo " [FAIL] id тенанта не извлечён"; FAIL=$((FAIL+1)); }
echo "== 3. оператор создаёт инвайт (email+tenantId) =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" -X POST "$BASE/api/operator/invites" \
-H "Content-Type: application/json" -d "{\"email\":\"$EMAIL\",\"tenantId\":\"$TID\"}" > "$TMP"
check "invite -> 200 code" 200 '"code":'
CODE=$(sed -n 's/.*"code":"\([A-Za-z0-9_-]*\)".*/\1/p' "$TMP" | head -1)
[ -n "$CODE" ] && echo " код: $CODE" || { echo " [FAIL] код не извлечён"; FAIL=$((FAIL+1)); }
echo "== 4. активация инвайта (публичный POST /api/join) =="
curl -s -m 20 -w "\n[HTTP:%{http_code}]" -X POST "$BASE/api/join" \
-H "Content-Type: application/json" -d "{\"code\":\"$CODE\",\"email\":\"$EMAIL\",\"password\":\"livepass123\"}" > "$TMP"
check "join -> 200 ok,login" 200 '"ok":true' '"login":"'"$EMAIL"'"'
echo "== 5. вход нового пользователя =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -c "$USER" -X POST "$BASE/api/auth/login" \
-H "Content-Type: application/json" -d "{\"login\":\"$EMAIL\",\"password\":\"livepass123\"}" > "$TMP"
check "user login -> 200" 200 '"ok":true'
echo "== 6. тенант работает (settings + boards + demo-карточка) =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$USER" "$BASE/api/settings" > "$TMP"
check "GET /api/settings -> 200" 200 '"minLen"'
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$USER" "$BASE/api/boards" > "$TMP"
check "GET /api/boards -> 200 массив" 200
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$USER" -X POST "$BASE/api/demo/simulate-lead" > "$TMP"
check "simulate-lead (user) -> 200 inbox" 200 '"col":"inbox"'
echo "== 7. IDOR-негатив: пользователь к операторским ручкам -> 401 =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$USER" "$BASE/api/operator/tenants" > "$TMP"
check "user -> operator tenants -> 401" 401
echo "== 8. suspend тенанта -> новый вход 403 =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" -X POST "$BASE/api/operator/tenants/$TID/suspend" > "$TMP"
check "suspend -> 200 ok" 200 '"ok":true'
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -X POST "$BASE/api/auth/login" \
-H "Content-Type: application/json" -d "{\"login\":\"$EMAIL\",\"password\":\"livepass123\"}" > "$TMP"
check "login suspended -> 403" 403
echo "== 9. resume -> вход снова 200 =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" -X POST "$BASE/api/operator/tenants/$TID/unsuspend" > "$TMP"
check "unsuspend -> 200" 200 '"ok":true'
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -X POST "$BASE/api/auth/login" \
-H "Content-Type: application/json" -d "{\"login\":\"$EMAIL\",\"password\":\"livepass123\"}" > "$TMP"
check "login after resume -> 200" 200 '"ok":true'
echo "== 10. оператор: лимиты тенанта =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" "$BASE/api/operator/tenants/$TID/limit" > "$TMP"
check "GET limit -> 200 budget" 200 '"budget"'
echo "== 11. оператор: аудит-лента содержит события =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" "$BASE/api/operator/audit" > "$TMP"
check "audit -> 200 items" 200 '"items"' 'tenant_created' 'invite_created'
rm -rf "$WORK"
echo "== ИТОГ live SaaS: PASS=$PASS FAIL=$FAIL =="
[ "$FAIL" = "0" ] && echo "LIVE SAAS ПРОЙДЕН" || echo "Провалы: см. выше"
exit $FAIL
#!/usr/bin/env sh
# live-saas-check.sh — живая SaaS-проверка операторского контура (этап 7, Manual-чек-лист).
# Требует: поднятый deal-postgres и запущенный core на :5080 (Development, Local-режим) + DEAL_DEMO.
# Формы: оператор operator/operator (dev-default bootstrap) → тенант → инвайт → /api/join → вход →
# настройки/демо → IDOR → suspend 403 → resume → лимиты → аудит. Ничего не поднимает/не гасит сам.
set -u
BASE="http://localhost:5080"
WORK=$(mktemp -d)
OP="$WORK/op.txt"; USER="$WORK/user.txt"; TMP="$WORK/out.txt"
PASS=0; FAIL=0
note() { echo "$1"; }
check() { # имя, ожидание кода, [фрагменты...]
name="$1"; code="$2"; shift 2
if grep -q "\[HTTP:$code\]" "$TMP"; then
for f in "$@"; do grep -qF "$f" "$TMP" || { echo " [FAIL] $name (нет: $f)"; cat "$TMP"; FAIL=$((FAIL+1)); return; }; done
echo " [PASS] $name"; PASS=$((PASS+1))
else
echo " [FAIL] $name (ожидался HTTP $code)"; cat "$TMP"; FAIL=$((FAIL+1))
fi
}
TS=$(date +%s)
EMAIL="live$TS@test.local"
TENANT_NAME="LiveCheck$TS"
echo "== 1. оператор login (dev-default operator/operator) =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -c "$OP" -X POST "$BASE/api/operator/auth/login" \
-H "Content-Type: application/json" -d '{"login":"operator","password":"operator"}' > "$TMP"
check "operator login -> 200 ok" 200 '"ok":true'
echo "== 2. оператор создаёт тенанта =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" -X POST "$BASE/api/operator/tenants" \
-H "Content-Type: application/json" -d "{\"name\":\"$TENANT_NAME\"}" > "$TMP"
check "create tenant -> 200 id" 200 '"id":'
TID=$(sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' "$TMP" | head -1)
[ -n "$TID" ] && echo " тенант: $TID" || { echo " [FAIL] id тенанта не извлечён"; FAIL=$((FAIL+1)); }
echo "== 3. оператор создаёт инвайт (email+tenantId) =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" -X POST "$BASE/api/operator/invites" \
-H "Content-Type: application/json" -d "{\"email\":\"$EMAIL\",\"tenantId\":\"$TID\"}" > "$TMP"
check "invite -> 200 code" 200 '"code":'
CODE=$(sed -n 's/.*"code":"\([A-Za-z0-9_-]*\)".*/\1/p' "$TMP" | head -1)
[ -n "$CODE" ] && echo " код: $CODE" || { echo " [FAIL] код не извлечён"; FAIL=$((FAIL+1)); }
echo "== 4. активация инвайта (публичный POST /api/join) =="
curl -s -m 20 -w "\n[HTTP:%{http_code}]" -X POST "$BASE/api/join" \
-H "Content-Type: application/json" -d "{\"code\":\"$CODE\",\"email\":\"$EMAIL\",\"password\":\"livepass123\"}" > "$TMP"
check "join -> 200 ok,login" 200 '"ok":true' '"login":"'"$EMAIL"'"'
echo "== 5. вход нового пользователя =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -c "$USER" -X POST "$BASE/api/auth/login" \
-H "Content-Type: application/json" -d "{\"login\":\"$EMAIL\",\"password\":\"livepass123\"}" > "$TMP"
check "user login -> 200" 200 '"ok":true'
echo "== 6. тенант работает (settings + boards + demo-карточка) =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$USER" "$BASE/api/settings" > "$TMP"
check "GET /api/settings -> 200" 200 '"minLen"'
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$USER" "$BASE/api/boards" > "$TMP"
check "GET /api/boards -> 200 массив" 200
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$USER" -X POST "$BASE/api/demo/simulate-lead" > "$TMP"
check "simulate-lead (user) -> 200 inbox" 200 '"col":"inbox"'
echo "== 7. IDOR-негатив: пользователь к операторским ручкам -> 401 =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$USER" "$BASE/api/operator/tenants" > "$TMP"
check "user -> operator tenants -> 401" 401
echo "== 8. suspend тенанта -> новый вход 403 =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" -X POST "$BASE/api/operator/tenants/$TID/suspend" > "$TMP"
check "suspend -> 200 ok" 200 '"ok":true'
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -X POST "$BASE/api/auth/login" \
-H "Content-Type: application/json" -d "{\"login\":\"$EMAIL\",\"password\":\"livepass123\"}" > "$TMP"
check "login suspended -> 403" 403
echo "== 9. resume -> вход снова 200 =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" -X POST "$BASE/api/operator/tenants/$TID/unsuspend" > "$TMP"
check "unsuspend -> 200" 200 '"ok":true'
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -X POST "$BASE/api/auth/login" \
-H "Content-Type: application/json" -d "{\"login\":\"$EMAIL\",\"password\":\"livepass123\"}" > "$TMP"
check "login after resume -> 200" 200 '"ok":true'
echo "== 10. оператор: лимиты тенанта =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" "$BASE/api/operator/tenants/$TID/limit" > "$TMP"
check "GET limit -> 200 budget" 200 '"budget"'
echo "== 11. оператор: аудит-лента содержит события =="
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -b "$OP" "$BASE/api/operator/audit" > "$TMP"
check "audit -> 200 items" 200 '"items"' 'tenant_created' 'invite_created'
rm -rf "$WORK"
echo "== ИТОГ live SaaS: PASS=$PASS FAIL=$FAIL =="
[ "$FAIL" = "0" ] && echo "LIVE SAAS ПРОЙДЕН" || echo "Провалы: см. выше"
exit $FAIL
+212 -212
View File
@@ -1,212 +1,212 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage7-saas.md
Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам.
Docker погашен после live-приёмки 2026-09-08 (см. ниже); БД-зависимые проверки с живыми кредами — Manual (п.36 STATUS.md).
## Live-приёмка 2026-09-08 (Docker Desktop запускался под неё и снова погашен)
- [x] SystemSaaS + SessionsImpersonationMark применены к dev-Postgres (public: 9 таблиц).
- [x] dev-smoke полного gRPC-стека: **PASS 12/12** (trap → down).
- [x] SaaS-curl-приёмка живьём (core :5080): **PASS 15/15** (оператор→тенант→инвайт→join→IDOR 401→suspend 403→resume 200→лимиты→аудит).
- [x] Prod-контур + mTLS + observability: core/tg/ai/ml healthy под mTLS, исходящее mTLS живьём
(/api/tg/status idle, /api/ml/status reachable через Caddy), Caddy 200, promtail→loki→grafana работают.
- [x] backup.sh (pg/minio/data/retention) + restore pg в копию-БД (43 табл./3 схемы идентичны) + restore minio с объектом + restore data.
- [x] Исправлены дефекты: двойная схема MC_HOST_deal (deal-backup-lib.sh), пустой бакет→mv (backup.sh),
Windows/MSYS docker-пути (host_docker_path), loki.yml delete_request_store. Сертификаты mTLS перегенерированы.
- [x] Уборка: deal-контейнеры down, `dotnet build-server shutdown`, порты свободны.
- Подробности: `task-16-live-report.md` (п.12) и `task-16-live-report-2.md` (п.34). Остаток Manual
(живые креды) — п.5 STATUS.md.
## Todos
- [x] Task 1: SystemSaaS-миграция (Operators/OperatorSessions/Invites/TenantLimits/AuditLog в public)
- [x] Task 2: Оператор-auth (модели/порт/сервис auth, bootstrap env DEAL_OPERATOR_*, dev-only дефолтный тенант)
- [x] Task 3: operator-HTTP (эндпоинты оператора)
- [x] Task 4: Аудит (append-only, сервис, события)
- [x] Task 5: Инвайты (генерация/статусы/expiry)
- [x] Task 6: /api/join (активация: пользователь+тенант+провижининг)
- [x] Task 7: Оператор-тенанты (список/статус/suspend/impersonation)
- [x] Task 8: Лимиты-ядро (tenant_limits, период, reset, recorder)
- [x] Task 9: Бюджетный гейт (decorator + fallback + SSE-алерт 60с)
- [x] Task 10: Оператор-лимиты/health
- [x] Task 11: Rate limiting (auth/API/gRPC + LoginAttemptGuard)
- [x] Task 12: Origin-проверка мутаций + security-заголовки (доки)
- [x] Task 13: mTLS (флаг + скрипт сертификатов)
- [x] Task 14: Observability (Serilog JSON) + compose.prod (promtail/loki/grafana/caddy)
- [x] Task 15: Бэкапы (scripts/backup.sh + retention + доки)
- [x] Task 16: Финал (доки/roadmap/STATUS 100% + полный прогон; review pending)
## Pre-flight scan (краткий)
| Пара | Производит/потребляет | Результат |
|---|---|---|
| T1 → T2..T10 | public-таблицы → всё остальное | Чисто |
| T4 | аудит из auth/инвайтов/impersonation | Чисто (сервис аудита раньше потребителей) |
| T8/T9 | лимиты → гейт в PipelineWorker (ИИ-вызов) | Воркер правится аккуратно (не сломать этап-4 пути) |
| T6 | /api/join → провижининг тенанта | TenantProvisioningService готов |
| T11 | rate limit на auth + api | dev-флаг выключен |
| T13/T14 | mTLS/observability — конфиг+скрипты | Живой подъём — Manual |
## Task status
- T1T16: complete (review clean; финальное whole-scope ревью этапа 7 и проекта 07 — GATE PASSED; core 1123, tg 114, ai 50, ml 36).
- **Этап 7 завершён; проект «Дейл» (этапы 0–7) = 100%.** Live-приёмка 2026-09-08: SystemSaaS-миграции
применены, SaaS-curl 15/15, dev-smoke 12/12, prod-контур+mTLS+observability PASS, backup/restore на копии PASS
(см. выше и task-16-live-report*.md). Остаток Manual — только реальные Telegram/LLM-креды (п.5 STATUS.md).
- Plan Task 1 «SystemSaaS — public-таблицы оператора/инвайтов/лимитов/аудита + миграция» (L221–239):
complete. Build Deal.sln 0/0; тесты 830/830 PASS; миграция `SystemSaaS` создана (5 таблиц в public:
operators/operator_sessions/invites/tenant_limits/audit_log; unique operators.Login + partial invites.Email
WHERE Status='pending'; FK OperatorSessions→Operators Cascade, Invites.CreatedById→Operators Restrict,
TenantLimits→Tenants Restrict; AuditLog без FK + индексы At/(TenantId,EventType)) — DDL сверен в файле
миграции, к БД НЕ применена (docker выключен; применение+psql ⚠ Manual). Сущности/конфигурации —
I/Persistence/Entities + I/Persistence по образцу User/Session. Отчёт: task-1-report.md.
- Plan Task 2 «Оператор — модели/порт/сервис auth, bootstrap из env, dev-only дефолтный тенант»
(L241258): complete. Build Deal.sln 0/0; тесты 847/847 PASS (830+17). Модуль Tenants: DTO
StoredOperator/OperatorIdentity/OperatorSession/OperatorLoginResult, порт `IOperatorAuthStore`
(6 методов), `OperatorAuthService` (Login/Logout/ResolveSession, 12 ч — `SessionLifetimeHours`),
`OperatorBootstrapService` (env `DEAL_OPERATOR_LOGIN/PASSWORD`, dev-дефолт operator/operator,
prod-без env → skip; env-ключи константами). Api: `OperatorCookieOptions` (секция OperatorCookies,
кука deal_operator_session, Hours=12=константа модуля, Secure из конфига); `TenantBootstrapService`
— dev-seed дефолтного тенанта только в Development/`DEAL_BOOTSTRAP_DEFAULT_TENANT=1`, провижининг
схем всех тенантов — всегда (Ruling 1). HTTP-контур (endpoints/middleware/Program.cs) — Task 3;
bootstrap-шаг не подключён к старту до EF-адаптера порта (Task 3). Отчёт: task-2-report.md.
- Plan Task 3 «Оператор — HTTP-контур /api/operator/auth + операторская сессия» (L260279): complete.
Build Deal.sln 0/0; тесты 865/865 PASS (847+18). Infrastructure: `OperatorAuthStore`
(I/Persistence/Repositories, public-таблицы, DI в AddDealPersistence). Api: `CurrentOperator`,
AuthHelpers (CurrentOperatorItemKey/OperatorUnauthorizedDetail/Get-SetCurrentOperator),
`OperatorSessionMiddleware` (кука deal_operator_session → Items["CurrentOperator"]; после
SessionMiddleware; ITenantContext не трогает), `OperatorAuthEndpoints` (POST login/logout, GET me;
401 «Требуется вход оператора»; кука 12ч httpOnly SameSite=Lax), `OperatorBootstrapHostedService`
(EnsureOperatorAsync на старте из scope — как TenantBootstrapService; warning при skip в prod и при
частичных env-кредах — ревью T2), Program.cs (секция OperatorCookies, middleware, эндпоинты),
appsettings OperatorCookies. Модуль Tenants: ResolveSession проверяет Status=active (ревью T2),
реестр регистрирует OperatorAuthService/OperatorBootstrapService. Тесты: EF-адаптер на EF InMemory
(пакет только в тест-проекте), HTTP-контур на in-process Kestrel с фейками (login/401/logout/me/
статус/изоляция кук), hosted bootstrap (dev-дефолт, prod-skip, partial-warning). Живая curl-приёмка
на :5080 — ⚠ Manual (docker выключен; эквивалент — HTTP-тесты). Отчёт: task-3-report.md.
- Plan Task 4 «Аудит-поток — AuditService, события входов, чтение оператором» (L281298): complete.
Build Deal.sln 0/0; тесты 890/890 PASS (865+25). Модуль Tenants: `AuditEvents` (11 событий Ruling 4),
`AuditActorTypes`, `AuditRecordDto`/`AuditQueryDto`, порт `IAuditLogStore` (AppendAsync/QueryAsync/
CountAsync — без Update/Delete, append-only), `AuditService` (Append с At=UTC-now, чтение/счёт,
ToDetailJson camelCase, ActorFromUser/Operator; MaxQueryLimit=500/Default=100); LoginResultDto/
OperatorLoginResultDto дополнены UserId+TenantId/OperatorId. Infrastructure: `AuditLogStore`
(public.audit_log, фильтры/At DESC/кламп 1..500, DI в AddDealPersistence). Api: AuthEndpoints и
OperatorAuthEndpoints пишут tenant_login_ok/failed и operator_login_ok/failed (login в DetailJson,
пароль не пишется; IP клиента); `OperatorAuditEndpoints` — GET /api/operator/audit (401 без
операторской сессии; {items,total}; фильтры eventType/actorType/tenantId/from/to/limit; NormalizeLimit).
Тесты: AuditServiceTests (поля/At/filters/append-only рефлексией), AuditLogStoreTests (EF InMemory),
OperatorAuditEndpointsHelpersTests, OperatorAuditEndpointsHttpTests (401/события входов/лента/фильтры),
FakeAuditLogStore; хост дополнен фейк-IAuditLogStore + MapOperatorAuditEndpoints. Живая curl/psql-
приёмка — ⚠ Manual (docker выключен; эквивалент — HTTP-тесты). Отчёт: task-4-report.md.
- Plan Task 5 «Инвайты — сервис/адаптер/операторские ручки + аудит» (L300316): complete.
Build Deal.sln 0/0; тесты 933/933 PASS (890+43). Модуль Tenants: `InviteStatuses`, `InviteDto`,
`InviteCreateResultDto`/`InviteRevokeResultDto` (коды ошибок, тексты — HTTP-слой), порт `IInviteStore`
(Create/GetByCode/List/UpdateStatus+activatedAt/FindActiveByEmail), `InviteCodeGenerator` (url-safe 16 симв.),
`InvitesService` (CreateInviteAsync: email-валидация/антидубль/expiry +72 ч; RevokeAsync — только pending;
List; GetByCode с ленивым expired; Create сам переводит протухший pending в expired — иначе partial unique-
индекс по pending блокирует повторный инвайт). Infrastructure: `InviteStore` (public.invites, DI). Api:
`OperatorInviteCreateRequest`, `OperatorInvitesEndpoints` (GET list → {items}, POST create → {code,email,
tenantId,expiresAt,status}, POST {code}/revoke → {ok:true}; 401 «Требуется вход оператора»; аудит
invite_created/invite_revoked с email+code), Program.cs. Тесты: FakeInviteStore, InvitesServiceTests (26),
InviteStoreTests (6, EF InMemory), InviteCodeGeneratorTests (2), OperatorInvitesEndpointsHttpTests (10,
эквивалент curl create→list→revoke + 401 без оператора), OperatorAuthHttpHost расширен. Живая curl/psql-
приёмка — ⚠ Manual (docker выключен; эквивалент — HTTP-тесты). Отчёт: task-5-report.md.
- Plan Task 6 «Активация инвайта — POST /api/join (пользователь + тенант + провижининг)» (L318339): complete.
Build Deal.sln 0/0; тесты 961/961 PASS (933+28). Модуль Tenants: `JoinResultDto` (коды ошибок), `JoinService`
(валидация кода/email/пароля ≥4/дубля email → CAS-резервирование pending→activated → тенант (существующий
или новый через TenantService.CreateTenantAsync с провижинингом) → пользователь users.login=email Argon2id),
порт `IInviteStore.TryActivateAsync` (условный UPDATE WHERE status='pending' — CAS, не перезаписать
параллельный revoke, ревью T5), `InvitesService.TryActivateAsync`, `AuthService.MinNewPasswordLength` public.
Infrastructure: `InviteStore.TryActivateAsync` (ExecuteUpdateAsync). Api: `JoinRequest`, `JoinEndpoint`
(POST /api/join, публичная без сессии; успех {ok:true,login} без куки; отказы — 400 {detail}; аудит
invite_activated), Program.cs MapJoinEndpoint. Тесты: FakeTenantStore/FakeTenantProvisioner, JoinFlowTests
(19: успех/ошибки/CAS-гонки/повторная активация), JoinEndpointHttpTests (9: эквивалент curl + аудит +
no-cookie). Живая curl/psql-приёмка (провижининг схемы) — ⚠ Manual (docker выключен; эквивалент — HTTP-
тесты). TenantLimits-строка отложена в Task 8 (GetOrCreateAsync лениво создаёт дефолт; см. отчёт).
Отчёт: task-6-report.md.
- Plan Task 7 «Оператор-тенанты — список/создание/статус/приостановка/impersonation» (L341364): complete.
Build Deal.sln 0/0; тесты 961/961 PASS. Отчёт: task-7-report.md. (Строка лимитов в списке/создании — через
порт ITenantLimitStore из Task 8: GET/PATCH лимита оператором — Task 10.)
- Plan Task 8 «Лимиты-ядро — хранилище/период/рекордер/дефолт-бюджет» (L366386): complete. Build Deal.sln 0/0;
тесты 1029/1029 PASS (961+68). Модуль Tenants: TenantLimitPeriods/TokenBudgetDefaults (10 000 000, month),
TokenLimitDefaults, TenantLimitDto/BudgetStateDto (Status/Allowed), TokenBudgetService (месяц календарный/день,
пороги 80/100 целочисленно), порт ITenantLimitStore (GetOrCreate лениво с дефолтом/GetState/AddUsage с ленивым
reset и пересчётом флагов/UpdateBudget со сбросом флагов/TryMarkWarned+NotifiedExhausted CAS). Infrastructure:
TenantLimitStore (EF public.tenant_limits, read-modify-write, часы-инъекция), AiUsageLedger → TokenUsageRecorder
(tenant_limits + lifetime-KV aiTokenUsage, та же точка вызова в GrpcAiClassifier/GrpcAiTools), DI: AddDealPersistence
(дефолт-бюджет параметром) + scoped ITenantLimitStore. Api/Program.cs: env DEAL_DEFAULT_AI_BUDGET → дефолт-бюджет
(фолбэк — константа модуля). Дефолт-бюджет закрыт на всех путях чтения (в т.ч. список тенантов Task 7/10 —
(GetOrCreateAsync лениво). Миграций нет. Отчёт: task-8-report.md.
- Plan Task 9 «Бюджетный гейт ИИ + fallback-декораторы + SSE-уведомления» (L388406): complete. Build Deal.sln 0/0;
тесты 1047/1047 PASS (1029+18, из них +1 — fix-review: флаги порогов выставляет ТОЛЬКО TryMark*, AddUsage их не
трогает — иначе списание «съедало» переход и SSE-тост при естественном расходе не выходил). Отчёт: task-9-report.md.
- Plan Task 10 «Оператор-лимиты/usage/health — эндпоинты» (L408424): complete. Build Deal.sln 0/0;
тесты 1072/1072 PASS (1047+25). Api: `OperatorLimitsEndpoints` (GET /api/operator/limits — сводка
{items:[tenantId,name,budget,period,used,percent,status]} по реестру с ленивым дефолтом лимита; GET/PATCH
/api/operator/tenants/{id}/limit — детали/смена {budget?, period?}: сброс Warned80/NotifiedExhausted через
UpdateBudgetAsync (Task 8), аудит tenant_limit_changed только при реальном изменении (идемпотентный PATCH),
400/404/401 по контракту; CalculatePercent public-хелпер, floor 0..100, безопасен от переполнения long),
`OperatorHealthEndpoints` (GET /api/operator/health — всегда 200 {ok, core:{db:ok|down} (SELECT 1 с таймаутом),
services:[{name,mode:grpc|local,status,reachable}]}; UseLocal=true → {mode:local,status:local,reachable:false},
gRPC-режим → ServiceHealthProbe), `OperatorLimitUpdateRequest`. Infrastructure: `ServiceHealthProbe`
(grpc.health.v1, дедлайн 3 с, канал на вызов; mTLS-конфиг — Task 13) + `ServiceHealthResult`; пакет
Grpc.HealthCheck 2.83.0 в Deal.Infrastructure. Program.cs: AddSingleton<ServiceHealthProbe> + Map*.
Харнесс OperatorAuthHttpHost расширен (лимиты/health/опции/DealDbContext на :5433). Тесты: HTTP-лимиты (9),
HTTP-health Local-режим (2), ServiceHealthProbeTests (4: in-proc health-сервер SERVING/NOT_SERVING, закрытый
порт, дедлайн на «медленном» сервере), хелперы percent (10). Очереди (ТЗ §10) в Task 10 не входят — см. отчёт.
Отчёт: task-10-report.md.
- Plan Task 11 «Rate limiting (приложение + gRPC-ингресс) и защита входа» (L426444): complete. Build Deal.sln 0/0;
тесты 1088/1088 PASS (1072+16). Api: `RateLimitOptions` (секция RateLimit; Enabled=false — код-дефолт и
appsettings; AuthPerMinute 10/ApiPerMinute 600/GrpcIngressPerMinute 600/LoginAttemptsMax 5/LoginAttemptWindowMin 15),
`RateLimitPolicies` (AddDealRateLimiter: политики auth — окно на IP, api — CurrentUser.TenantId/IP анонима +
глобальный лимитер API-партиции; OnRejected → 429 {detail}), `LoginAttemptGuard` (in-memory окно ip|login
5/15 мин → 429 «Слишком много попыток входа…», сброс при успехе, часы-инъекция; активен при Enabled),
`IngressRateLimitInterceptor` (fixed window 1 мин по tenant-id из metadata на общем singleton-лимитере;
health освобождён; RESOURCE_EXHAUSTED). EndpointResults.TooManyRequests. Program.cs: политики/middleware
только при Enabled (порядок Session → Operator → RateLimiter), RequireRateLimiting("auth") на ручках
/api/auth/login и /api/operator/auth/login, AddGrpc-интерцептор + singleton-лимитер при Enabled,
DisableRateLimiting на MapGrpcService/HealthChecks (HTTP-лимитер не режет ингресс — там лимит по tenant-id
интерцептором), LoginAttemptGuard до AuthService в обоих login-эндпоинтах. Харнессы: OperatorAuthHttpHost
(гвард+опции, опциональный параметр), TelegramIngressTestHost (интерцептор+health при опциях). Тесты:
LoginAttemptGuardTests (8: 5 неудач → блок/4 → нет/сброс успехом/разблок заблокированного/истечение окна
по часам/изоляция ключей/disabled/пустой логин), LoginAttemptEndpointHttpTests (2: 5×401 → 429 на ручке,
успех сбрасывает счётчик), RateLimitHttpTests (4: auth 429 {detail}/api 429 через глобальный лимитер/партиция
tenant vs IP анонима/Enabled=false — лимита нет), IngressRateLimitInterceptorTests (2: 3-й вызов тенанта
RESOURCE_EXHAUSTED + окно другого тенанта; health не режется при исчерпанном окне). Отчёт: task-11-report.md.
- Plan Task 12 «Безопасность — Origin-проверка, security-заголовки, CORS-allowlist» (L446460): complete.
Build Deal.sln 0/0; тесты 1101/1101 PASS. Отчёт: task-12-report.md. (CSP/HSTS — на Caddy, Task 14; §10-заготовки.)
- Plan Task 13 «mTLS — флаг/сертификаты в 4 процессах + скрипт генерации» (L462480): complete.
Build Deal.sln 0/0; тесты 1123/1123 PASS (1101+22); telegram 114/114, ai 50/50, ml 36/36 PASS;
scripts/mtls-certs.sh прогнан — deploy/certs (PFX + ca.pem). Отчёт: task-13-report.md.
(grpc_health_probe под mTLS — решено в Task 14: TLS-проба с PEM deal-client.crt/.key из того же скрипта.)
- Plan Task 14 «Observability (Serilog JSON) + compose.prod (Caddy + promtail/loki/grafana)» (L482500):
complete (код/файлы). Build 0/0 всех четырёх sln; тесты 1123/1123 PASS (core), telegram/ai/ml — PASS;
`docker compose -f deploy/compose.prod.yml config` rc=0 (+ профиль observability rc=0); JSON/YAML файлов
observability провалидированы; Serilog-старт процессов (JSON в консоль/файл) и живой подъём PROD — ⚠ Manual.
Отчёт: task-14-report.md.
- Plan Task 15 «Бэкапы — scripts/backup.sh + документация восстановления» (L502514): complete
(код/файлы). `sh -n` backup.sh/restore.sh/deal-backup-lib.sh rc=0; `bash -n` rc=0; error-path-прогоны
(docker off) rc=1 с понятными сообщениями + лог-файлы; retention-логика проверена офлайн
(cutoff по дате в имени, граничный день хранится). Созданы: scripts/backup.sh (4 источника Ruling 8:
pg_dump -Fc docker exec deal-postgres или DEAL_PG_HOST; mc mirror MinIO deal-files — хостовый mc или
разовый minio/mc-контейнер; tar DEAL_TAR_DIRS/DEAL_TAR_VOLUMES; retention RETENTION_DAYS; trap-
очистка .part; лог; rc=0/1), scripts/restore.sh (dropdb+createdb → pg_restore / обратный mc mirror /
распаковка; шаги all|pg|minio|data [TS]), scripts/deal-backup-lib.sh (общие env/хелперы). Доки:
техдок §11 (Tasks 115, Task 15 закрыт) + §13.9 (команды, cron «0 2 * * *»/systemd, retention,
что входит/не входит, порядок restore). Fix-review (ревью Task 15): docker-mc endpoint по умолчанию
http://minio:9000 (алиас compose-сервиса; deal-minio — только container_name dev, в prod-сети его нет),
pg_restore --exit-on-error в restore.sh, доки/примеры на `bash …` (не `sh …`, pipefail), overlay-
warning для restore; §13.9 и task-15-report обновлены. Живой прогон backup.sh и restore-тест — ⚠ Manual (docker
выключен). Отчёт: task-15-report.md.
- Plan Task 16 «Финал — доки, сквозная SaaS-приёмка, полный прогон» (L516544): complete (review pending).
Доки: техдок §1/§5/§6/§7–§11/§13 актуализированы (фактический стек этапа 7: Serilog JSON+compose.prod
observability, dev/prod-развёртывание + Caddy + mTLS, бэкапы→scripts/backup.sh|restore.sh+cron,
безопасность: rate-limit/Origin/ForwardedHeaders/mTLS-флаг/попытки входа/403-suspended/что вне,
§11 TODO → реальные заделы, §13.6/§13.8/§13.9 обновлены, «актуально для этапа N» заголовки);
api-map — сводная секция «Реализовано в Deal» (403-suspended, POST suspend/unsuspend вместо PATCH,
create без budget?, отсутствующие эндпоинты, таблица /api/operator/* + /api/join); roadmap — этапы
0–7 «Выполнено», п.2 «Открытых точек» закрыт; STATUS.md — 100% (103/103, core 1123; Manual-чек-лист
отдельно); user-guide — «Регистрация по приглашению», dev admin/admin, DEAL_DEMO, реальный Telegram
флагом+кредами, «ИИ-бюджет и уведомления», оператор кратко. Финальный прогон: build 0/0 всех четырёх
sln (core/telegram/ai/ml); `dotnet test`: core 1123/1123, telegram 114/114, ai 50/50, ml 36/36 PASS;
`docker compose -f deploy/compose.prod.yml config` rc=0 (+ профиль observability, с env-значениями для
fail-fast переменных); `sh -n` dev-smoke/backup/restore/mtls-certs rc=0. Сквозная SaaS-curl-приёмка и
живые проверки стека (mTLS, бэкап, реальные сервисы) — ⚠ Manual (docker выключен; чек-лист в
task-16-report.md и STATUS.md). Отчёт: task-16-report.md.
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage7-saas.md
Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам.
Docker погашен после live-приёмки 2026-09-08 (см. ниже); БД-зависимые проверки с живыми кредами — Manual (п.36 STATUS.md).
## Live-приёмка 2026-09-08 (Docker Desktop запускался под неё и снова погашен)
- [x] SystemSaaS + SessionsImpersonationMark применены к dev-Postgres (public: 9 таблиц).
- [x] dev-smoke полного gRPC-стека: **PASS 12/12** (trap → down).
- [x] SaaS-curl-приёмка живьём (core :5080): **PASS 15/15** (оператор→тенант→инвайт→join→IDOR 401→suspend 403→resume 200→лимиты→аудит).
- [x] Prod-контур + mTLS + observability: core/tg/ai/ml healthy под mTLS, исходящее mTLS живьём
(/api/tg/status idle, /api/ml/status reachable через Caddy), Caddy 200, promtail→loki→grafana работают.
- [x] backup.sh (pg/minio/data/retention) + restore pg в копию-БД (43 табл./3 схемы идентичны) + restore minio с объектом + restore data.
- [x] Исправлены дефекты: двойная схема MC_HOST_deal (deal-backup-lib.sh), пустой бакет→mv (backup.sh),
Windows/MSYS docker-пути (host_docker_path), loki.yml delete_request_store. Сертификаты mTLS перегенерированы.
- [x] Уборка: deal-контейнеры down, `dotnet build-server shutdown`, порты свободны.
- Подробности: `task-16-live-report.md` (п.12) и `task-16-live-report-2.md` (п.34). Остаток Manual
(живые креды) — п.5 STATUS.md.
## Todos
- [x] Task 1: SystemSaaS-миграция (Operators/OperatorSessions/Invites/TenantLimits/AuditLog в public)
- [x] Task 2: Оператор-auth (модели/порт/сервис auth, bootstrap env DEAL_OPERATOR_*, dev-only дефолтный тенант)
- [x] Task 3: operator-HTTP (эндпоинты оператора)
- [x] Task 4: Аудит (append-only, сервис, события)
- [x] Task 5: Инвайты (генерация/статусы/expiry)
- [x] Task 6: /api/join (активация: пользователь+тенант+провижининг)
- [x] Task 7: Оператор-тенанты (список/статус/suspend/impersonation)
- [x] Task 8: Лимиты-ядро (tenant_limits, период, reset, recorder)
- [x] Task 9: Бюджетный гейт (decorator + fallback + SSE-алерт 60с)
- [x] Task 10: Оператор-лимиты/health
- [x] Task 11: Rate limiting (auth/API/gRPC + LoginAttemptGuard)
- [x] Task 12: Origin-проверка мутаций + security-заголовки (доки)
- [x] Task 13: mTLS (флаг + скрипт сертификатов)
- [x] Task 14: Observability (Serilog JSON) + compose.prod (promtail/loki/grafana/caddy)
- [x] Task 15: Бэкапы (scripts/backup.sh + retention + доки)
- [x] Task 16: Финал (доки/roadmap/STATUS 100% + полный прогон; review pending)
## Pre-flight scan (краткий)
| Пара | Производит/потребляет | Результат |
|---|---|---|
| T1 → T2..T10 | public-таблицы → всё остальное | Чисто |
| T4 | аудит из auth/инвайтов/impersonation | Чисто (сервис аудита раньше потребителей) |
| T8/T9 | лимиты → гейт в PipelineWorker (ИИ-вызов) | Воркер правится аккуратно (не сломать этап-4 пути) |
| T6 | /api/join → провижининг тенанта | TenantProvisioningService готов |
| T11 | rate limit на auth + api | dev-флаг выключен |
| T13/T14 | mTLS/observability — конфиг+скрипты | Живой подъём — Manual |
## Task status
- T1T16: complete (review clean; финальное whole-scope ревью этапа 7 и проекта 07 — GATE PASSED; core 1123, tg 114, ai 50, ml 36).
- **Этап 7 завершён; проект «Дейл» (этапы 0–7) = 100%.** Live-приёмка 2026-09-08: SystemSaaS-миграции
применены, SaaS-curl 15/15, dev-smoke 12/12, prod-контур+mTLS+observability PASS, backup/restore на копии PASS
(см. выше и task-16-live-report*.md). Остаток Manual — только реальные Telegram/LLM-креды (п.5 STATUS.md).
- Plan Task 1 «SystemSaaS — public-таблицы оператора/инвайтов/лимитов/аудита + миграция» (L221–239):
complete. Build Deal.sln 0/0; тесты 830/830 PASS; миграция `SystemSaaS` создана (5 таблиц в public:
operators/operator_sessions/invites/tenant_limits/audit_log; unique operators.Login + partial invites.Email
WHERE Status='pending'; FK OperatorSessions→Operators Cascade, Invites.CreatedById→Operators Restrict,
TenantLimits→Tenants Restrict; AuditLog без FK + индексы At/(TenantId,EventType)) — DDL сверен в файле
миграции, к БД НЕ применена (docker выключен; применение+psql ⚠ Manual). Сущности/конфигурации —
I/Persistence/Entities + I/Persistence по образцу User/Session. Отчёт: task-1-report.md.
- Plan Task 2 «Оператор — модели/порт/сервис auth, bootstrap из env, dev-only дефолтный тенант»
(L241258): complete. Build Deal.sln 0/0; тесты 847/847 PASS (830+17). Модуль Tenants: DTO
StoredOperator/OperatorIdentity/OperatorSession/OperatorLoginResult, порт `IOperatorAuthStore`
(6 методов), `OperatorAuthService` (Login/Logout/ResolveSession, 12 ч — `SessionLifetimeHours`),
`OperatorBootstrapService` (env `DEAL_OPERATOR_LOGIN/PASSWORD`, dev-дефолт operator/operator,
prod-без env → skip; env-ключи константами). Api: `OperatorCookieOptions` (секция OperatorCookies,
кука deal_operator_session, Hours=12=константа модуля, Secure из конфига); `TenantBootstrapService`
— dev-seed дефолтного тенанта только в Development/`DEAL_BOOTSTRAP_DEFAULT_TENANT=1`, провижининг
схем всех тенантов — всегда (Ruling 1). HTTP-контур (endpoints/middleware/Program.cs) — Task 3;
bootstrap-шаг не подключён к старту до EF-адаптера порта (Task 3). Отчёт: task-2-report.md.
- Plan Task 3 «Оператор — HTTP-контур /api/operator/auth + операторская сессия» (L260279): complete.
Build Deal.sln 0/0; тесты 865/865 PASS (847+18). Infrastructure: `OperatorAuthStore`
(I/Persistence/Repositories, public-таблицы, DI в AddDealPersistence). Api: `CurrentOperator`,
AuthHelpers (CurrentOperatorItemKey/OperatorUnauthorizedDetail/Get-SetCurrentOperator),
`OperatorSessionMiddleware` (кука deal_operator_session → Items["CurrentOperator"]; после
SessionMiddleware; ITenantContext не трогает), `OperatorAuthEndpoints` (POST login/logout, GET me;
401 «Требуется вход оператора»; кука 12ч httpOnly SameSite=Lax), `OperatorBootstrapHostedService`
(EnsureOperatorAsync на старте из scope — как TenantBootstrapService; warning при skip в prod и при
частичных env-кредах — ревью T2), Program.cs (секция OperatorCookies, middleware, эндпоинты),
appsettings OperatorCookies. Модуль Tenants: ResolveSession проверяет Status=active (ревью T2),
реестр регистрирует OperatorAuthService/OperatorBootstrapService. Тесты: EF-адаптер на EF InMemory
(пакет только в тест-проекте), HTTP-контур на in-process Kestrel с фейками (login/401/logout/me/
статус/изоляция кук), hosted bootstrap (dev-дефолт, prod-skip, partial-warning). Живая curl-приёмка
на :5080 — ⚠ Manual (docker выключен; эквивалент — HTTP-тесты). Отчёт: task-3-report.md.
- Plan Task 4 «Аудит-поток — AuditService, события входов, чтение оператором» (L281298): complete.
Build Deal.sln 0/0; тесты 890/890 PASS (865+25). Модуль Tenants: `AuditEvents` (11 событий Ruling 4),
`AuditActorTypes`, `AuditRecordDto`/`AuditQueryDto`, порт `IAuditLogStore` (AppendAsync/QueryAsync/
CountAsync — без Update/Delete, append-only), `AuditService` (Append с At=UTC-now, чтение/счёт,
ToDetailJson camelCase, ActorFromUser/Operator; MaxQueryLimit=500/Default=100); LoginResultDto/
OperatorLoginResultDto дополнены UserId+TenantId/OperatorId. Infrastructure: `AuditLogStore`
(public.audit_log, фильтры/At DESC/кламп 1..500, DI в AddDealPersistence). Api: AuthEndpoints и
OperatorAuthEndpoints пишут tenant_login_ok/failed и operator_login_ok/failed (login в DetailJson,
пароль не пишется; IP клиента); `OperatorAuditEndpoints` — GET /api/operator/audit (401 без
операторской сессии; {items,total}; фильтры eventType/actorType/tenantId/from/to/limit; NormalizeLimit).
Тесты: AuditServiceTests (поля/At/filters/append-only рефлексией), AuditLogStoreTests (EF InMemory),
OperatorAuditEndpointsHelpersTests, OperatorAuditEndpointsHttpTests (401/события входов/лента/фильтры),
FakeAuditLogStore; хост дополнен фейк-IAuditLogStore + MapOperatorAuditEndpoints. Живая curl/psql-
приёмка — ⚠ Manual (docker выключен; эквивалент — HTTP-тесты). Отчёт: task-4-report.md.
- Plan Task 5 «Инвайты — сервис/адаптер/операторские ручки + аудит» (L300316): complete.
Build Deal.sln 0/0; тесты 933/933 PASS (890+43). Модуль Tenants: `InviteStatuses`, `InviteDto`,
`InviteCreateResultDto`/`InviteRevokeResultDto` (коды ошибок, тексты — HTTP-слой), порт `IInviteStore`
(Create/GetByCode/List/UpdateStatus+activatedAt/FindActiveByEmail), `InviteCodeGenerator` (url-safe 16 симв.),
`InvitesService` (CreateInviteAsync: email-валидация/антидубль/expiry +72 ч; RevokeAsync — только pending;
List; GetByCode с ленивым expired; Create сам переводит протухший pending в expired — иначе partial unique-
индекс по pending блокирует повторный инвайт). Infrastructure: `InviteStore` (public.invites, DI). Api:
`OperatorInviteCreateRequest`, `OperatorInvitesEndpoints` (GET list → {items}, POST create → {code,email,
tenantId,expiresAt,status}, POST {code}/revoke → {ok:true}; 401 «Требуется вход оператора»; аудит
invite_created/invite_revoked с email+code), Program.cs. Тесты: FakeInviteStore, InvitesServiceTests (26),
InviteStoreTests (6, EF InMemory), InviteCodeGeneratorTests (2), OperatorInvitesEndpointsHttpTests (10,
эквивалент curl create→list→revoke + 401 без оператора), OperatorAuthHttpHost расширен. Живая curl/psql-
приёмка — ⚠ Manual (docker выключен; эквивалент — HTTP-тесты). Отчёт: task-5-report.md.
- Plan Task 6 «Активация инвайта — POST /api/join (пользователь + тенант + провижининг)» (L318339): complete.
Build Deal.sln 0/0; тесты 961/961 PASS (933+28). Модуль Tenants: `JoinResultDto` (коды ошибок), `JoinService`
(валидация кода/email/пароля ≥4/дубля email → CAS-резервирование pending→activated → тенант (существующий
или новый через TenantService.CreateTenantAsync с провижинингом) → пользователь users.login=email Argon2id),
порт `IInviteStore.TryActivateAsync` (условный UPDATE WHERE status='pending' — CAS, не перезаписать
параллельный revoke, ревью T5), `InvitesService.TryActivateAsync`, `AuthService.MinNewPasswordLength` public.
Infrastructure: `InviteStore.TryActivateAsync` (ExecuteUpdateAsync). Api: `JoinRequest`, `JoinEndpoint`
(POST /api/join, публичная без сессии; успех {ok:true,login} без куки; отказы — 400 {detail}; аудит
invite_activated), Program.cs MapJoinEndpoint. Тесты: FakeTenantStore/FakeTenantProvisioner, JoinFlowTests
(19: успех/ошибки/CAS-гонки/повторная активация), JoinEndpointHttpTests (9: эквивалент curl + аудит +
no-cookie). Живая curl/psql-приёмка (провижининг схемы) — ⚠ Manual (docker выключен; эквивалент — HTTP-
тесты). TenantLimits-строка отложена в Task 8 (GetOrCreateAsync лениво создаёт дефолт; см. отчёт).
Отчёт: task-6-report.md.
- Plan Task 7 «Оператор-тенанты — список/создание/статус/приостановка/impersonation» (L341364): complete.
Build Deal.sln 0/0; тесты 961/961 PASS. Отчёт: task-7-report.md. (Строка лимитов в списке/создании — через
порт ITenantLimitStore из Task 8: GET/PATCH лимита оператором — Task 10.)
- Plan Task 8 «Лимиты-ядро — хранилище/период/рекордер/дефолт-бюджет» (L366386): complete. Build Deal.sln 0/0;
тесты 1029/1029 PASS (961+68). Модуль Tenants: TenantLimitPeriods/TokenBudgetDefaults (10 000 000, month),
TokenLimitDefaults, TenantLimitDto/BudgetStateDto (Status/Allowed), TokenBudgetService (месяц календарный/день,
пороги 80/100 целочисленно), порт ITenantLimitStore (GetOrCreate лениво с дефолтом/GetState/AddUsage с ленивым
reset и пересчётом флагов/UpdateBudget со сбросом флагов/TryMarkWarned+NotifiedExhausted CAS). Infrastructure:
TenantLimitStore (EF public.tenant_limits, read-modify-write, часы-инъекция), AiUsageLedger → TokenUsageRecorder
(tenant_limits + lifetime-KV aiTokenUsage, та же точка вызова в GrpcAiClassifier/GrpcAiTools), DI: AddDealPersistence
(дефолт-бюджет параметром) + scoped ITenantLimitStore. Api/Program.cs: env DEAL_DEFAULT_AI_BUDGET → дефолт-бюджет
(фолбэк — константа модуля). Дефолт-бюджет закрыт на всех путях чтения (в т.ч. список тенантов Task 7/10 —
(GetOrCreateAsync лениво). Миграций нет. Отчёт: task-8-report.md.
- Plan Task 9 «Бюджетный гейт ИИ + fallback-декораторы + SSE-уведомления» (L388406): complete. Build Deal.sln 0/0;
тесты 1047/1047 PASS (1029+18, из них +1 — fix-review: флаги порогов выставляет ТОЛЬКО TryMark*, AddUsage их не
трогает — иначе списание «съедало» переход и SSE-тост при естественном расходе не выходил). Отчёт: task-9-report.md.
- Plan Task 10 «Оператор-лимиты/usage/health — эндпоинты» (L408424): complete. Build Deal.sln 0/0;
тесты 1072/1072 PASS (1047+25). Api: `OperatorLimitsEndpoints` (GET /api/operator/limits — сводка
{items:[tenantId,name,budget,period,used,percent,status]} по реестру с ленивым дефолтом лимита; GET/PATCH
/api/operator/tenants/{id}/limit — детали/смена {budget?, period?}: сброс Warned80/NotifiedExhausted через
UpdateBudgetAsync (Task 8), аудит tenant_limit_changed только при реальном изменении (идемпотентный PATCH),
400/404/401 по контракту; CalculatePercent public-хелпер, floor 0..100, безопасен от переполнения long),
`OperatorHealthEndpoints` (GET /api/operator/health — всегда 200 {ok, core:{db:ok|down} (SELECT 1 с таймаутом),
services:[{name,mode:grpc|local,status,reachable}]}; UseLocal=true → {mode:local,status:local,reachable:false},
gRPC-режим → ServiceHealthProbe), `OperatorLimitUpdateRequest`. Infrastructure: `ServiceHealthProbe`
(grpc.health.v1, дедлайн 3 с, канал на вызов; mTLS-конфиг — Task 13) + `ServiceHealthResult`; пакет
Grpc.HealthCheck 2.83.0 в Deal.Infrastructure. Program.cs: AddSingleton<ServiceHealthProbe> + Map*.
Харнесс OperatorAuthHttpHost расширен (лимиты/health/опции/DealDbContext на :5433). Тесты: HTTP-лимиты (9),
HTTP-health Local-режим (2), ServiceHealthProbeTests (4: in-proc health-сервер SERVING/NOT_SERVING, закрытый
порт, дедлайн на «медленном» сервере), хелперы percent (10). Очереди (ТЗ §10) в Task 10 не входят — см. отчёт.
Отчёт: task-10-report.md.
- Plan Task 11 «Rate limiting (приложение + gRPC-ингресс) и защита входа» (L426444): complete. Build Deal.sln 0/0;
тесты 1088/1088 PASS (1072+16). Api: `RateLimitOptions` (секция RateLimit; Enabled=false — код-дефолт и
appsettings; AuthPerMinute 10/ApiPerMinute 600/GrpcIngressPerMinute 600/LoginAttemptsMax 5/LoginAttemptWindowMin 15),
`RateLimitPolicies` (AddDealRateLimiter: политики auth — окно на IP, api — CurrentUser.TenantId/IP анонима +
глобальный лимитер API-партиции; OnRejected → 429 {detail}), `LoginAttemptGuard` (in-memory окно ip|login
5/15 мин → 429 «Слишком много попыток входа…», сброс при успехе, часы-инъекция; активен при Enabled),
`IngressRateLimitInterceptor` (fixed window 1 мин по tenant-id из metadata на общем singleton-лимитере;
health освобождён; RESOURCE_EXHAUSTED). EndpointResults.TooManyRequests. Program.cs: политики/middleware
только при Enabled (порядок Session → Operator → RateLimiter), RequireRateLimiting("auth") на ручках
/api/auth/login и /api/operator/auth/login, AddGrpc-интерцептор + singleton-лимитер при Enabled,
DisableRateLimiting на MapGrpcService/HealthChecks (HTTP-лимитер не режет ингресс — там лимит по tenant-id
интерцептором), LoginAttemptGuard до AuthService в обоих login-эндпоинтах. Харнессы: OperatorAuthHttpHost
(гвард+опции, опциональный параметр), TelegramIngressTestHost (интерцептор+health при опциях). Тесты:
LoginAttemptGuardTests (8: 5 неудач → блок/4 → нет/сброс успехом/разблок заблокированного/истечение окна
по часам/изоляция ключей/disabled/пустой логин), LoginAttemptEndpointHttpTests (2: 5×401 → 429 на ручке,
успех сбрасывает счётчик), RateLimitHttpTests (4: auth 429 {detail}/api 429 через глобальный лимитер/партиция
tenant vs IP анонима/Enabled=false — лимита нет), IngressRateLimitInterceptorTests (2: 3-й вызов тенанта
RESOURCE_EXHAUSTED + окно другого тенанта; health не режется при исчерпанном окне). Отчёт: task-11-report.md.
- Plan Task 12 «Безопасность — Origin-проверка, security-заголовки, CORS-allowlist» (L446460): complete.
Build Deal.sln 0/0; тесты 1101/1101 PASS. Отчёт: task-12-report.md. (CSP/HSTS — на Caddy, Task 14; §10-заготовки.)
- Plan Task 13 «mTLS — флаг/сертификаты в 4 процессах + скрипт генерации» (L462480): complete.
Build Deal.sln 0/0; тесты 1123/1123 PASS (1101+22); telegram 114/114, ai 50/50, ml 36/36 PASS;
scripts/mtls-certs.sh прогнан — deploy/certs (PFX + ca.pem). Отчёт: task-13-report.md.
(grpc_health_probe под mTLS — решено в Task 14: TLS-проба с PEM deal-client.crt/.key из того же скрипта.)
- Plan Task 14 «Observability (Serilog JSON) + compose.prod (Caddy + promtail/loki/grafana)» (L482500):
complete (код/файлы). Build 0/0 всех четырёх sln; тесты 1123/1123 PASS (core), telegram/ai/ml — PASS;
`docker compose -f deploy/compose.prod.yml config` rc=0 (+ профиль observability rc=0); JSON/YAML файлов
observability провалидированы; Serilog-старт процессов (JSON в консоль/файл) и живой подъём PROD — ⚠ Manual.
Отчёт: task-14-report.md.
- Plan Task 15 «Бэкапы — scripts/backup.sh + документация восстановления» (L502514): complete
(код/файлы). `sh -n` backup.sh/restore.sh/deal-backup-lib.sh rc=0; `bash -n` rc=0; error-path-прогоны
(docker off) rc=1 с понятными сообщениями + лог-файлы; retention-логика проверена офлайн
(cutoff по дате в имени, граничный день хранится). Созданы: scripts/backup.sh (4 источника Ruling 8:
pg_dump -Fc docker exec deal-postgres или DEAL_PG_HOST; mc mirror MinIO deal-files — хостовый mc или
разовый minio/mc-контейнер; tar DEAL_TAR_DIRS/DEAL_TAR_VOLUMES; retention RETENTION_DAYS; trap-
очистка .part; лог; rc=0/1), scripts/restore.sh (dropdb+createdb → pg_restore / обратный mc mirror /
распаковка; шаги all|pg|minio|data [TS]), scripts/deal-backup-lib.sh (общие env/хелперы). Доки:
техдок §11 (Tasks 115, Task 15 закрыт) + §13.9 (команды, cron «0 2 * * *»/systemd, retention,
что входит/не входит, порядок restore). Fix-review (ревью Task 15): docker-mc endpoint по умолчанию
http://minio:9000 (алиас compose-сервиса; deal-minio — только container_name dev, в prod-сети его нет),
pg_restore --exit-on-error в restore.sh, доки/примеры на `bash …` (не `sh …`, pipefail), overlay-
warning для restore; §13.9 и task-15-report обновлены. Живой прогон backup.sh и restore-тест — ⚠ Manual (docker
выключен). Отчёт: task-15-report.md.
- Plan Task 16 «Финал — доки, сквозная SaaS-приёмка, полный прогон» (L516544): complete (review pending).
Доки: техдок §1/§5/§6/§7–§11/§13 актуализированы (фактический стек этапа 7: Serilog JSON+compose.prod
observability, dev/prod-развёртывание + Caddy + mTLS, бэкапы→scripts/backup.sh|restore.sh+cron,
безопасность: rate-limit/Origin/ForwardedHeaders/mTLS-флаг/попытки входа/403-suspended/что вне,
§11 TODO → реальные заделы, §13.6/§13.8/§13.9 обновлены, «актуально для этапа N» заголовки);
api-map — сводная секция «Реализовано в Deal» (403-suspended, POST suspend/unsuspend вместо PATCH,
create без budget?, отсутствующие эндпоинты, таблица /api/operator/* + /api/join); roadmap — этапы
0–7 «Выполнено», п.2 «Открытых точек» закрыт; STATUS.md — 100% (103/103, core 1123; Manual-чек-лист
отдельно); user-guide — «Регистрация по приглашению», dev admin/admin, DEAL_DEMO, реальный Telegram
флагом+кредами, «ИИ-бюджет и уведомления», оператор кратко. Финальный прогон: build 0/0 всех четырёх
sln (core/telegram/ai/ml); `dotnet test`: core 1123/1123, telegram 114/114, ai 50/50, ml 36/36 PASS;
`docker compose -f deploy/compose.prod.yml config` rc=0 (+ профиль observability, с env-значениями для
fail-fast переменных); `sh -n` dev-smoke/backup/restore/mtls-certs rc=0. Сквозная SaaS-curl-приёмка и
живые проверки стека (mTLS, бэкап, реальные сервисы) — ⚠ Manual (docker выключен; чек-лист в
task-16-report.md и STATUS.md). Отчёт: task-16-report.md.
@@ -1,50 +1,50 @@
#!/usr/bin/env sh
# run-live-saas.sh — сборка core, запуск на :5080 (Development/Local), live-saas-check, kill с ретраями.
set -u
cd "$(dirname "$0")/../../.." || exit 1 # C:\telbase
echo "== build Deal.Api =="
(cd src/core && dotnet build Deal.Api -v q --nologo) 2>&1 | tail -3 || exit 1
EXE="src/core/Deal.Api/bin/Debug/net10.0/Deal.Api.exe"
[ -f "$EXE" ] || { echo "нет $EXE"; exit 1; }
LOG=".superpowers/sdd/deal-stage7-saas/core-run.log"
echo "== start core =="
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 ConnectionStrings__DealPostgres="Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password" "$EXE" --urls http://localhost:5080 >"$LOG" 2>&1 &
PID=$!
echo "pid=$PID"
ok=""
for i in $(seq 1 40); do
if curl -s -m 3 http://localhost:5080/api/health >/dev/null 2>&1; then ok=1; break; fi
sleep 3
done
if [ -z "$ok" ]; then
echo "core не поднялся за 120 c; лог:"; tail -40 "$LOG"; kill $PID 2>/dev/null; exit 1
fi
echo "core ready"
sh .superpowers/sdd/deal-stage7-saas/live-saas-check.sh
RC=$?
echo "== kill core (ретраи) =="
killed=""
for i in 1 2 3 4 5; do
if kill $PID 2>/dev/null; then sleep 3; else killed=1; break; fi
if ! kill -0 $PID 2>/dev/null; then killed=1; break; fi
done
if [ -z "$killed" ]; then
taskkill //F //PID $PID >/dev/null 2>&1 || true
sleep 2
fi
# страховка: убить возможный осиротевший Deal.Api.exe
taskkill //F //IM Deal.Api.exe >/dev/null 2>&1 || true
# проверить, что порт свободен
if netstat -ano 2>/dev/null | grep -q ":5080 .*LISTENING"; then
echo " [WARN] :5080 ещё слушается"
else
echo " [ok] :5080 свободен"
fi
echo "exit=$RC"
exit $RC
#!/usr/bin/env sh
# run-live-saas.sh — сборка core, запуск на :5080 (Development/Local), live-saas-check, kill с ретраями.
set -u
cd "$(dirname "$0")/../../.." || exit 1 # C:\telbase
echo "== build Deal.Api =="
(cd src/core && dotnet build Deal.Api -v q --nologo) 2>&1 | tail -3 || exit 1
EXE="src/core/Deal.Api/bin/Debug/net10.0/Deal.Api.exe"
[ -f "$EXE" ] || { echo "нет $EXE"; exit 1; }
LOG=".superpowers/sdd/deal-stage7-saas/core-run.log"
echo "== start core =="
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 ConnectionStrings__DealPostgres="Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password" "$EXE" --urls http://localhost:5080 >"$LOG" 2>&1 &
PID=$!
echo "pid=$PID"
ok=""
for i in $(seq 1 40); do
if curl -s -m 3 http://localhost:5080/api/health >/dev/null 2>&1; then ok=1; break; fi
sleep 3
done
if [ -z "$ok" ]; then
echo "core не поднялся за 120 c; лог:"; tail -40 "$LOG"; kill $PID 2>/dev/null; exit 1
fi
echo "core ready"
sh .superpowers/sdd/deal-stage7-saas/live-saas-check.sh
RC=$?
echo "== kill core (ретраи) =="
killed=""
for i in 1 2 3 4 5; do
if kill $PID 2>/dev/null; then sleep 3; else killed=1; break; fi
if ! kill -0 $PID 2>/dev/null; then killed=1; break; fi
done
if [ -z "$killed" ]; then
taskkill //F //PID $PID >/dev/null 2>&1 || true
sleep 2
fi
# страховка: убить возможный осиротевший Deal.Api.exe
taskkill //F //IM Deal.Api.exe >/dev/null 2>&1 || true
# проверить, что порт свободен
if netstat -ano 2>/dev/null | grep -q ":5080 .*LISTENING"; then
echo " [WARN] :5080 ещё слушается"
else
echo " [ok] :5080 свободен"
fi
echo "exit=$RC"
exit $RC
@@ -1,49 +1,49 @@
# Task 1 report — SystemSaaS: public-таблицы Operators/OperatorSessions/Invites/TenantLimits/AuditLog + миграция
**Status:** complete. Build 0/0; unit 830/830 PASS; миграция `SystemSaaS` создана и числится в
`dotnet ef migrations list` (к БД НЕ применена — docker выключен, применение/psql-проверка Manual).
## Состав
- **Сущности** `Deal.Infrastructure/Persistence/Entities/` (1 тип = 1 файл, поля по Rulings 1/2/3/4,
PascalCase, XML-doc на русском):
- `OperatorEntity` — Id (Guid PK), Login (unique, нижний регистр), PasswordHash, Status, CreatedAt.
- `OperatorSessionEntity` — TokenHash (PK), OperatorId (FK), Login (денормализация), ExpiresAt,
CreatedAt (зеркало SessionEntity; срок 12 ч — константа домена, в таблицу не входит).
- `InviteEntity` — Code (PK, url-safe 16 симв.), Email (нормализованный), TenantId nullable
(null = «новый тенант»), Status (default pending), ExpiresAt, ActivatedAt nullable, CreatedById, CreatedAt.
- `TenantLimitEntity` — TenantId (PK), BudgetTokens (bigint), Period (default month), PeriodStart,
UsedTokens, Warned80, NotifiedExhausted, UpdatedAt.
- `AuditLogEntity` — Id (bigint identity PK), At, ActorType, ActorId nullable, TenantId nullable,
EventType, Ip nullable, DetailJson nullable.
- **Конфигурации** `Deal.Infrastructure/Persistence/` (ToTable(..., "public")): `OperatorConfiguration`,
`OperatorSessionConfiguration`, `InviteConfiguration`, `TenantLimitConfiguration`, `AuditLogConfiguration`.
- `DealDbContext`: добавлены DbSet `Operators/OperatorSessions/Invites/TenantLimits/AuditLog` +
5× ApplyConfiguration; XML-doc класса актуализирован.
## DDL миграции (проверено в файле миграции)
- `public`: `operators`, `operator_sessions`, `invites`, `tenant_limits`, `audit_log` (EnsureSchema уже был
в InitialSystem — новые таблицы только со schema "public").
- FK: OperatorSessions→Operators Cascade; Invites.CreatedById→Operators Restrict; TenantLimits→Tenants
Restrict; AuditLog без FK (append-only).
- Unique: `IX_operators_Login`; `IX_invites_Email` — partial `WHERE "Status" = 'pending'` (активные;
после активации/отзыва/expiry email освобождается — глобальную уникальность держит users.Login).
- Индексы: AuditLog(At), AuditLog(TenantId, EventType); операторские — OperatorId/ExpiresAt (зеркало
SessionConfiguration) + авто-индекс Invites.CreatedById.
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit` — 830/830 PASS.
- `dotnet ef migrations add SystemSaaS --project Deal.Infrastructure --startup-project Deal.Api
--context DealDbContext` — Build succeeded; файлы `20260907181413_SystemSaaS.cs`(+Designer),
`DealDbContextModelSnapshot.cs` обновлён.
- `dotnet ef migrations list --no-connect` — InitialSystem, SystemSaaS.
- `database update` НЕ выполнялся (docker выключен) — применение и psql-сверка 5 таблиц/индексов ⚠ Manual.
## Concerns
- Нет. Решение по partial-unique: фильтр на `Status = 'pending'` (см. DDL); если в Task 5/6 понадобится
иной состав «активных» — изменится индекс отдельной миграцией.
- DetailJson — колонка text (прецедент TenantSetting.ValueJson); контент — JSON без секретов.
# Task 1 report — SystemSaaS: public-таблицы Operators/OperatorSessions/Invites/TenantLimits/AuditLog + миграция
**Status:** complete. Build 0/0; unit 830/830 PASS; миграция `SystemSaaS` создана и числится в
`dotnet ef migrations list` (к БД НЕ применена — docker выключен, применение/psql-проверка Manual).
## Состав
- **Сущности** `Deal.Infrastructure/Persistence/Entities/` (1 тип = 1 файл, поля по Rulings 1/2/3/4,
PascalCase, XML-doc на русском):
- `OperatorEntity` — Id (Guid PK), Login (unique, нижний регистр), PasswordHash, Status, CreatedAt.
- `OperatorSessionEntity` — TokenHash (PK), OperatorId (FK), Login (денормализация), ExpiresAt,
CreatedAt (зеркало SessionEntity; срок 12 ч — константа домена, в таблицу не входит).
- `InviteEntity` — Code (PK, url-safe 16 симв.), Email (нормализованный), TenantId nullable
(null = «новый тенант»), Status (default pending), ExpiresAt, ActivatedAt nullable, CreatedById, CreatedAt.
- `TenantLimitEntity` — TenantId (PK), BudgetTokens (bigint), Period (default month), PeriodStart,
UsedTokens, Warned80, NotifiedExhausted, UpdatedAt.
- `AuditLogEntity` — Id (bigint identity PK), At, ActorType, ActorId nullable, TenantId nullable,
EventType, Ip nullable, DetailJson nullable.
- **Конфигурации** `Deal.Infrastructure/Persistence/` (ToTable(..., "public")): `OperatorConfiguration`,
`OperatorSessionConfiguration`, `InviteConfiguration`, `TenantLimitConfiguration`, `AuditLogConfiguration`.
- `DealDbContext`: добавлены DbSet `Operators/OperatorSessions/Invites/TenantLimits/AuditLog` +
5× ApplyConfiguration; XML-doc класса актуализирован.
## DDL миграции (проверено в файле миграции)
- `public`: `operators`, `operator_sessions`, `invites`, `tenant_limits`, `audit_log` (EnsureSchema уже был
в InitialSystem — новые таблицы только со schema "public").
- FK: OperatorSessions→Operators Cascade; Invites.CreatedById→Operators Restrict; TenantLimits→Tenants
Restrict; AuditLog без FK (append-only).
- Unique: `IX_operators_Login`; `IX_invites_Email` — partial `WHERE "Status" = 'pending'` (активные;
после активации/отзыва/expiry email освобождается — глобальную уникальность держит users.Login).
- Индексы: AuditLog(At), AuditLog(TenantId, EventType); операторские — OperatorId/ExpiresAt (зеркало
SessionConfiguration) + авто-индекс Invites.CreatedById.
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit` — 830/830 PASS.
- `dotnet ef migrations add SystemSaaS --project Deal.Infrastructure --startup-project Deal.Api
--context DealDbContext` — Build succeeded; файлы `20260907181413_SystemSaaS.cs`(+Designer),
`DealDbContextModelSnapshot.cs` обновлён.
- `dotnet ef migrations list --no-connect` — InitialSystem, SystemSaaS.
- `database update` НЕ выполнялся (docker выключен) — применение и psql-сверка 5 таблиц/индексов ⚠ Manual.
## Concerns
- Нет. Решение по partial-unique: фильтр на `Status = 'pending'` (см. DDL); если в Task 5/6 понадобится
иной состав «активных» — изменится индекс отдельной миграцией.
- DetailJson — колонка text (прецедент TenantSetting.ValueJson); контент — JSON без секретов.
@@ -1,104 +1,104 @@
# Task 10 report — Оператор: лимиты/usage (GET сводка + GET/PATCH лимита) и health сервисов
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 10 (L408424), Rulings 3/4/9/11.
Проект НЕ git. Docker выключен: psql-проверка строки лимита и реальный gRPC-health сервисов — ⚠ Manual.
Сборка `dotnet build Deal.sln` — 0 warnings/0 errors; `dotnet test Deal.sln`**1072/1072 PASS** (было 1047 до
Task 10; +25 новых за задачу).
## Состав
**Создано — `src/core/Deal.Api/Endpoints/`** (1 тип = 1 файл, XML-doc, константы вместо строк, комментарии русские):
- `OperatorLimitUpdateRequest.cs` — тело PATCH: `{budget?, period?}` (оба опциональны — меняется только заданное;
null — оставить текущее; документирован сброс флагов).
- `OperatorLimitsEndpoints.cs` — ручки лимитов (Ruling 3/11):
- `GET /api/operator/limits` — сводка по всем тенантам `{items:[{tenantId, name, budget, period, used, percent,
status}]}` (реестр через `ITenantRepository` + `ITenantLimitStore.GetStateAsync` на каждый тенант; ленивый
reset периода по пути чтения; строка без расхода видна как «дефолт-бюджет, 0» — ленивый GetOrCreate, Ruling 3);
- `GET /api/operator/tenants/{id}/limit` — детали лимита тенанта (единая форма с ответом PATCH): tenantId, name,
status (тенанта), allowed, budget, period, periodStart, used, remaining, percent, warned80, notifiedExhausted;
- `PATCH /api/operator/tenants/{id}/limit` — смена `{budget?, period?}`: проверка тенанта по реестру (404),
валидация 400 (пустое тело/без полей, бюджет <0, период не month|day), не заданные поля берутся из текущего
состояния, `UpdateBudgetAsync` (Task 8) сбрасывает Warned80/NotifiedExhausted, аудит `tenant_limit_changed`
(DetailJson: tenantId + oldBudget/oldPeriod + budgetTokens/period) — **только при реальном изменении**
(повторный PATCH с теми же значениями идемпотентен, без дубля аудита); бюджет 0 допустим (ИИ запрещён, Ruling 3).
- 401 «Требуется вход оператора» без операторской сессии (как остальные /api/operator/*).
- `CalculatePercent(used, budget)` — public-static хелпер (эталон `NormalizeLimit` у аудита): floor 0..100,
потолок при расходе ≥ бюджета; бюджет ≤0 → 100 (лимит 0 = исчерпан). Безопасен от переполнения long
(расчёт в double — диапазон токенов long его не переполняет; целочисленный used·100/budget переполнялся бы
при used > ~9.2·10¹⁶).
- `OperatorHealthEndpoints.cs` — `GET /api/operator/health` (Ruling 3/9/11): ответ всегда 200
`{ok, core:{db:"ok"|"down"}, services:[{name, mode:"grpc"|"local", status, reachable}]}` (порядок ml → ai →
telegram; форма записи сервиса в Local-режиме — `{mode:"local", status:"local", reachable:false}` как требует
план для UseLocal=true). Проверка БД — `SELECT 1` через DealDbContext (public-схема) с таймаутом 5 с, сбой →
core.db=down без падения ручки (контейнер Postgres не поднят — не роняет операторский health). Сервисы:
UseLocal=true → пометка local без вызова; gRPC-режим → `ServiceHealthProbe` к `Services:*:Endpoint`
(SERVING → ok, ответил не-SERVING → unhealthy, недоступен → down). `ok` сводки — БД доступна и сервисы в
порядке (Local-режим сбоем не считается). Записи сервисов — приватный record `ServiceEntryDto` внутри класса.
**Создано — `src/core/Deal.Infrastructure/Integrations/`**:
- `ServiceHealthResult.cs` — record `(Reachable, Serving)` + статический `Unreachable` (классификация пробы).
- `ServiceHealthProbe.cs` — gRHC-health-проба grpc.health.v1 (клиент `Grpc.Health.V1.Health`, пакет
`Grpc.HealthCheck` 2.83.0 — добавлен в `Deal.Infrastructure.csproj`; версия как у остальных Grpc-пакетов):
дедлайн **3 с** (`HealthTimeoutSeconds`), канал на каждый вызов (dev без TLS — Ruling 2; mTLS-конфигурацию
канала из Ruling 6 добавит Task 13 — как у Grpc*Connection, зафиксировано в remarks), классификация:
Unavailable/DeadlineExceeded/Unimplemented + транспортные ошибки + сработавший дедлайн → Unreachable.
**Изменено:**
- `src/core/Deal.Api/Program.cs` — `AddSingleton<ServiceHealthProbe>()` (после AddDealIntegrations), мэппинг
`MapOperatorLimitsEndpoints()` + `MapOperatorHealthEndpoints()` (после тенантов, Task 7).
- Тестовый харнесс `OperatorAuthHttpHost.cs` — регистрация `FakeTenantLimitStore` (ITenantLimitStore), опций
`Services:Ml|Ai|Telegram` (dev-default UseLocal=true), `ServiceHealthProbe`, `DealDbContext` (Npgsql к
dev-Postgres :5433, `Timeout=3` — недоступность ручка переживает сама) + мэппинг новых групп; добавлена
перегрузка RunAsync со сценарием 7 аргументов (incl. limitStore). Существующие перегрузки/вызовы не тронуты.
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+25):**
- `OperatorLimitsEndpointsHttpTests.cs` (9) — 401 без оператора (все три ручки); сводка (формы: name/budget/
period/used/percent/status, ленивая строка второго тенанта); детали с флагами/остатком/percent; PATCH: сброс
Warned80/NotifiedExhausted + смена бюджета + аудит tenant_limit_changed (DetailJson old/new) — проверка и по
повторному GET; PATCH только period (бюджет сохраняется); идемпотентный повторный PATCH без дубля аудита;
400 на пустое/без полей/отрицательный бюджет/чужой период; 404 неизвестный тенант (GET и PATCH); бюджет 0 →
allowed=false, percent=100.
- `OperatorHealthEndpointsHttpTests.cs` (2) — 401 без сессии; 200 в Local-режиме: services ровно 3 (ml/ai/
telegram), каждая `{mode:"local", status:"local", reachable:false}`; core.db в допуске {ok,down} — при
выключенном docker down, ручка жива.
- `ServiceHealthProbeTests.cs` (4) — in-proc gRPC-health-сервер фейк (эталон AiGrpcTestHost: Kestrel HTTP/2 +
`AddGrpcHealthChecks().AddAsyncCheck("ready")` + MapGrpcHealthChecksService): SERVING → Reachable+Serving;
проверка unhealthy (NOT_SERVING) → Reachable без Serving; закрытый порт → Unreachable (gRPC-недоступность);
«медленный» сервер (4 с > дедлайна 3 с) → Unreachable — health не ждёт дольше таймаута.
- `OperatorLimitsEndpointsHelpersTests.cs` (1 Theory → 10 кейсов) — floor-проценты, потолок при расходе ≥
бюджета (включая long.MaxValue/1 — кейс переполнения), бюджет 0 → 100, крупный бюджет 10¹⁸ без переполнения.
## Проверки
- `dotnet build Deal.sln` — 0 warnings/0 errors (TreatWarningsAsErrors + EnforceCodeStyleInBuild); diagnostics — чисто.
- `dotnet test Deal.sln` — 1072/1072 PASS, 0 fail (1047 до Task 10 + 25; запуск с rebuild по фильтру новых тестов,
затем полный прогон --no-build).
- Миграций/БД не требуется: tenant_limits/реестр — Task 1; EF-записи лимита меняет Task 8-адаптер (без изменений).
psql-проверка строки после PATCH и реальный gRPC-health сервисов (UseLocal=false, поднятый стек) — ⚠ Manual
(docker выключен; эквивалент — HTTP/unit-тесты выше).
## Concerns
- **Путь ручки — `/limit` (единственное число), метод — PATCH** — по тексту плана Task 10
(«GET/PATCH /api/operator/tenants/{id}/limit»); в формулировке задачи встречались .../limits и POST — в план
не вошли (для api-map/техдок Task 16 зафиксировать фактический контракт).
- **«Очереди» из ТЗ §10 в Task 10 не вошли.** План Task 10 и Ruling 11 фиксируют операторский health как
«core/БД/сервисы» (ml/ai/telegram); counts pipeline-очереди в задачах этапа 7 отсутствуют — отдельный
follow-up вне этапа (зафиксировать в task-16/доках).
- **mTLS пробы — Task 13.** Канал ServiceHealthProbe сейчас строится как у Grpc*Connection (plaintext, Ruling 2);
Ruling 6 требует клиентские сертификаты для «Grpc*Client + health-пробы» — Task 13 добавит конфигурацию канала
(код вызова не меняется; зафиксировано в remarks класса).
- **Форма health** — единый ответ 200 с полями состояния (как /api/health), включая Local-режим сервисов
`{mode:"local", reachable:false, status:"local"}` и допуск core.db=down при выключенном Postgres (операторский
обзор не роняет ручку). Если для infra-проб (compose healthcheck) понадобится 503-семантика — это отдельная
ручка, вне Task 10.
- **Проценты — floor 0..100 (потолок).** Показывается 100 при расходе ≥ бюджета; сверхбюджетный расход
неотличим от точного исчерпания в percent (но виден в used/budget/remaining деталей) — сознательно.
## Файлы
Создано: `Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs`, `.../OperatorLimitsEndpoints.cs`,
`.../OperatorHealthEndpoints.cs`; `Deal.Infrastructure/Integrations/ServiceHealthResult.cs`,
`.../ServiceHealthProbe.cs`; тесты `OperatorLimitsEndpointsHttpTests.cs`, `OperatorHealthEndpointsHttpTests.cs`,
`ServiceHealthProbeTests.cs`, `OperatorLimitsEndpointsHelpersTests.cs`. Изменено: `Deal.Infrastructure.csproj`
(Grpc.HealthCheck 2.83.0), `Deal.Api/Program.cs`, `tests/OperatorAuthHttpHost.cs`.
# Task 10 report — Оператор: лимиты/usage (GET сводка + GET/PATCH лимита) и health сервисов
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 10 (L408424), Rulings 3/4/9/11.
Проект НЕ git. Docker выключен: psql-проверка строки лимита и реальный gRPC-health сервисов — ⚠ Manual.
Сборка `dotnet build Deal.sln` — 0 warnings/0 errors; `dotnet test Deal.sln`**1072/1072 PASS** (было 1047 до
Task 10; +25 новых за задачу).
## Состав
**Создано — `src/core/Deal.Api/Endpoints/`** (1 тип = 1 файл, XML-doc, константы вместо строк, комментарии русские):
- `OperatorLimitUpdateRequest.cs` — тело PATCH: `{budget?, period?}` (оба опциональны — меняется только заданное;
null — оставить текущее; документирован сброс флагов).
- `OperatorLimitsEndpoints.cs` — ручки лимитов (Ruling 3/11):
- `GET /api/operator/limits` — сводка по всем тенантам `{items:[{tenantId, name, budget, period, used, percent,
status}]}` (реестр через `ITenantRepository` + `ITenantLimitStore.GetStateAsync` на каждый тенант; ленивый
reset периода по пути чтения; строка без расхода видна как «дефолт-бюджет, 0» — ленивый GetOrCreate, Ruling 3);
- `GET /api/operator/tenants/{id}/limit` — детали лимита тенанта (единая форма с ответом PATCH): tenantId, name,
status (тенанта), allowed, budget, period, periodStart, used, remaining, percent, warned80, notifiedExhausted;
- `PATCH /api/operator/tenants/{id}/limit` — смена `{budget?, period?}`: проверка тенанта по реестру (404),
валидация 400 (пустое тело/без полей, бюджет <0, период не month|day), не заданные поля берутся из текущего
состояния, `UpdateBudgetAsync` (Task 8) сбрасывает Warned80/NotifiedExhausted, аудит `tenant_limit_changed`
(DetailJson: tenantId + oldBudget/oldPeriod + budgetTokens/period) — **только при реальном изменении**
(повторный PATCH с теми же значениями идемпотентен, без дубля аудита); бюджет 0 допустим (ИИ запрещён, Ruling 3).
- 401 «Требуется вход оператора» без операторской сессии (как остальные /api/operator/*).
- `CalculatePercent(used, budget)` — public-static хелпер (эталон `NormalizeLimit` у аудита): floor 0..100,
потолок при расходе ≥ бюджета; бюджет ≤0 → 100 (лимит 0 = исчерпан). Безопасен от переполнения long
(расчёт в double — диапазон токенов long его не переполняет; целочисленный used·100/budget переполнялся бы
при used > ~9.2·10¹⁶).
- `OperatorHealthEndpoints.cs` — `GET /api/operator/health` (Ruling 3/9/11): ответ всегда 200
`{ok, core:{db:"ok"|"down"}, services:[{name, mode:"grpc"|"local", status, reachable}]}` (порядок ml → ai →
telegram; форма записи сервиса в Local-режиме — `{mode:"local", status:"local", reachable:false}` как требует
план для UseLocal=true). Проверка БД — `SELECT 1` через DealDbContext (public-схема) с таймаутом 5 с, сбой →
core.db=down без падения ручки (контейнер Postgres не поднят — не роняет операторский health). Сервисы:
UseLocal=true → пометка local без вызова; gRPC-режим → `ServiceHealthProbe` к `Services:*:Endpoint`
(SERVING → ok, ответил не-SERVING → unhealthy, недоступен → down). `ok` сводки — БД доступна и сервисы в
порядке (Local-режим сбоем не считается). Записи сервисов — приватный record `ServiceEntryDto` внутри класса.
**Создано — `src/core/Deal.Infrastructure/Integrations/`**:
- `ServiceHealthResult.cs` — record `(Reachable, Serving)` + статический `Unreachable` (классификация пробы).
- `ServiceHealthProbe.cs` — gRHC-health-проба grpc.health.v1 (клиент `Grpc.Health.V1.Health`, пакет
`Grpc.HealthCheck` 2.83.0 — добавлен в `Deal.Infrastructure.csproj`; версия как у остальных Grpc-пакетов):
дедлайн **3 с** (`HealthTimeoutSeconds`), канал на каждый вызов (dev без TLS — Ruling 2; mTLS-конфигурацию
канала из Ruling 6 добавит Task 13 — как у Grpc*Connection, зафиксировано в remarks), классификация:
Unavailable/DeadlineExceeded/Unimplemented + транспортные ошибки + сработавший дедлайн → Unreachable.
**Изменено:**
- `src/core/Deal.Api/Program.cs` — `AddSingleton<ServiceHealthProbe>()` (после AddDealIntegrations), мэппинг
`MapOperatorLimitsEndpoints()` + `MapOperatorHealthEndpoints()` (после тенантов, Task 7).
- Тестовый харнесс `OperatorAuthHttpHost.cs` — регистрация `FakeTenantLimitStore` (ITenantLimitStore), опций
`Services:Ml|Ai|Telegram` (dev-default UseLocal=true), `ServiceHealthProbe`, `DealDbContext` (Npgsql к
dev-Postgres :5433, `Timeout=3` — недоступность ручка переживает сама) + мэппинг новых групп; добавлена
перегрузка RunAsync со сценарием 7 аргументов (incl. limitStore). Существующие перегрузки/вызовы не тронуты.
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+25):**
- `OperatorLimitsEndpointsHttpTests.cs` (9) — 401 без оператора (все три ручки); сводка (формы: name/budget/
period/used/percent/status, ленивая строка второго тенанта); детали с флагами/остатком/percent; PATCH: сброс
Warned80/NotifiedExhausted + смена бюджета + аудит tenant_limit_changed (DetailJson old/new) — проверка и по
повторному GET; PATCH только period (бюджет сохраняется); идемпотентный повторный PATCH без дубля аудита;
400 на пустое/без полей/отрицательный бюджет/чужой период; 404 неизвестный тенант (GET и PATCH); бюджет 0 →
allowed=false, percent=100.
- `OperatorHealthEndpointsHttpTests.cs` (2) — 401 без сессии; 200 в Local-режиме: services ровно 3 (ml/ai/
telegram), каждая `{mode:"local", status:"local", reachable:false}`; core.db в допуске {ok,down} — при
выключенном docker down, ручка жива.
- `ServiceHealthProbeTests.cs` (4) — in-proc gRPC-health-сервер фейк (эталон AiGrpcTestHost: Kestrel HTTP/2 +
`AddGrpcHealthChecks().AddAsyncCheck("ready")` + MapGrpcHealthChecksService): SERVING → Reachable+Serving;
проверка unhealthy (NOT_SERVING) → Reachable без Serving; закрытый порт → Unreachable (gRPC-недоступность);
«медленный» сервер (4 с > дедлайна 3 с) → Unreachable — health не ждёт дольше таймаута.
- `OperatorLimitsEndpointsHelpersTests.cs` (1 Theory → 10 кейсов) — floor-проценты, потолок при расходе ≥
бюджета (включая long.MaxValue/1 — кейс переполнения), бюджет 0 → 100, крупный бюджет 10¹⁸ без переполнения.
## Проверки
- `dotnet build Deal.sln` — 0 warnings/0 errors (TreatWarningsAsErrors + EnforceCodeStyleInBuild); diagnostics — чисто.
- `dotnet test Deal.sln` — 1072/1072 PASS, 0 fail (1047 до Task 10 + 25; запуск с rebuild по фильтру новых тестов,
затем полный прогон --no-build).
- Миграций/БД не требуется: tenant_limits/реестр — Task 1; EF-записи лимита меняет Task 8-адаптер (без изменений).
psql-проверка строки после PATCH и реальный gRPC-health сервисов (UseLocal=false, поднятый стек) — ⚠ Manual
(docker выключен; эквивалент — HTTP/unit-тесты выше).
## Concerns
- **Путь ручки — `/limit` (единственное число), метод — PATCH** — по тексту плана Task 10
(«GET/PATCH /api/operator/tenants/{id}/limit»); в формулировке задачи встречались .../limits и POST — в план
не вошли (для api-map/техдок Task 16 зафиксировать фактический контракт).
- **«Очереди» из ТЗ §10 в Task 10 не вошли.** План Task 10 и Ruling 11 фиксируют операторский health как
«core/БД/сервисы» (ml/ai/telegram); counts pipeline-очереди в задачах этапа 7 отсутствуют — отдельный
follow-up вне этапа (зафиксировать в task-16/доках).
- **mTLS пробы — Task 13.** Канал ServiceHealthProbe сейчас строится как у Grpc*Connection (plaintext, Ruling 2);
Ruling 6 требует клиентские сертификаты для «Grpc*Client + health-пробы» — Task 13 добавит конфигурацию канала
(код вызова не меняется; зафиксировано в remarks класса).
- **Форма health** — единый ответ 200 с полями состояния (как /api/health), включая Local-режим сервисов
`{mode:"local", reachable:false, status:"local"}` и допуск core.db=down при выключенном Postgres (операторский
обзор не роняет ручку). Если для infra-проб (compose healthcheck) понадобится 503-семантика — это отдельная
ручка, вне Task 10.
- **Проценты — floor 0..100 (потолок).** Показывается 100 при расходе ≥ бюджета; сверхбюджетный расход
неотличим от точного исчерпания в percent (но виден в used/budget/remaining деталей) — сознательно.
## Файлы
Создано: `Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs`, `.../OperatorLimitsEndpoints.cs`,
`.../OperatorHealthEndpoints.cs`; `Deal.Infrastructure/Integrations/ServiceHealthResult.cs`,
`.../ServiceHealthProbe.cs`; тесты `OperatorLimitsEndpointsHttpTests.cs`, `OperatorHealthEndpointsHttpTests.cs`,
`ServiceHealthProbeTests.cs`, `OperatorLimitsEndpointsHelpersTests.cs`. Изменено: `Deal.Infrastructure.csproj`
(Grpc.HealthCheck 2.83.0), `Deal.Api/Program.cs`, `tests/OperatorAuthHttpHost.cs`.
@@ -1,115 +1,115 @@
# Task 11 report — Rate limiting (HTTP auth/api + gRPC-ингресс) и LoginAttemptGuard
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 11 (L426444), Ruling 5/10.
Проект НЕ git. Docker выключен: живые curl-приёмки/проверка поведения в dev-стеке — ⚠ Manual
(эквивалент — HTTP/gRPC-тесты in-process ниже; dev-флаг Enabled=false проверен тестом).
Сборка `dotnet build Deal.sln` — 0 warnings/0 errors; `dotnet test Deal.sln`**1088/1088 PASS**
(было 1072 до Task 11; +16 новых за задачу).
## Состав
**Создано — `src/core/Deal.Api/`** (1 тип = 1 файл, XML-doc, константы, комментарии русские):
- `Configuration/RateLimitOptions.cs` — секция `RateLimit` (appsettings.json + env `RateLimit__*`):
`Enabled` (код-дефолт **false** — dev/тесты, Ruling 5; PROD включает env `RateLimit__Enabled=true`),
`AuthPerMinute=10`, `ApiPerMinute=600`, `GrpcIngressPerMinute=600`, `LoginAttemptsMax=5`,
`LoginAttemptWindowMin=15`.
- `Middleware/RateLimitPolicies.cs``AddDealRateLimiter(options)` (вызывается только при Enabled):
`AddRateLimiter` с политиками `"auth"` (fixed window 1 мин по IP клиента) и `"api"` (по
`CurrentUser.TenantId` либо IP анонима — `Session/OperatorSession` отрабатывают раньше); API-партиция
выставляется и **глобальным лимитером** (`GlobalLimiter`) — весь /api без собственной политики
ограничен по тенанту/IP. `OnRejected` → 429 `{"detail":"Слишком много запросов. Повторите позже"}`
(`RejectedDetail` — единая константа, Ruling 5). `QueueLimit=0` (без очереди ожидания).
- `Http/LoginAttemptGuard.cs` — прикладной guard (singleton): in-memory **фиксированное окно** по ключу
`ip|login`; ≥`LoginAttemptsMax` (5) неудач в окне `LoginAttemptWindowMin` (15) минут → `IsBlocked`,
`Reset` при успешном входе, пустой логин ключа не имеет, окна «выровнены по часам» и прунятся при
обращении (память — только активные ключи окна). Часы — инъекцией `Func<DateTimeOffset>` (эталон
TenantLimitStore) — unit-тесты окна без ожидания. **Активен только при `Enabled=true`** (no-op в dev —
curl-приёмки не режутся, Ruling 5). Текст 429 — `BlockedDetail` «Слишком много попыток входа.
Попробуйте через 15 минут». Multi-instance задел зафиксирован в remarks (общий KV/Redis, техдок §11).
- `Telegram/IngressRateLimitInterceptor.cs` — gRPC-ингресс (:5082): fixed window 1 мин по metadata
`tenant-id` (партиция на тенанта); стандартный `grpc.health.v1.Health` освобождён (префикс
`IngressServiceTokenInterceptor.HealthMethodPrefix` — сделан public, общий для интерцепторов);
превышение — RPC-отказ `RESOURCE_EXHAUSTED` (gRPC-аналог 429). Окно считает **общий
singleton-лимитер** (`CreateLimiter` регистрируется в DI) — экземпляры интерцептора создаются
фреймворком, но партиции/окна общие (иначе лимит не работал бы).
**Изменено:**
- `Deal.Api/Program.cs` — bind секции `RateLimit` (константа имени в шапке), `AddSingleton` опций +
`LoginAttemptGuard`; `AddDealRateLimiter` только при Enabled; `AddGrpc`: интерцептор ингресса +
singleton-лимитер только при Enabled; порядок middleware — `Session → Operator → UseRateLimiter`
(только при Enabled; Ruling 5); `MapGrpcService<TelegramIngressService>().DisableRateLimiting()` и
`MapGrpcHealthChecksService().DisableRateLimiting()` — HTTP-лимитер не режет ингресс (его лимит —
интерцептором по tenant-id; иначе общее окно на IP telegram-service резало бы поток раньше).
- `Endpoints/AuthEndpoints.cs`, `Endpoints/OperatorAuthEndpoints.cs``RequireRateLimiting("auth")`
только на ручках `/login` (Ruling 5: 10/мин на IP именно login; остальные ручки групп — под глобальной
api-политикой); вызов `LoginAttemptGuard` **до** AuthService: блок → 429 `BlockedDetail`; неудачные
попытки (непустой логин) → `RecordFailure` в той же точке, где пишется аудит `*_login_failed`;
успех → `Reset`; 403 suspended-тенанта счётчиком не трогается (не credential-сбой).
- `Http/EndpointResults.cs``TooManyRequests(detail)` (429 `{detail}`).
- `Deal.Api/appsettings.json` — секция `RateLimit` (Enabled=false + значения политик) — dev-дефолт явный.
- `Telegram/IngressServiceTokenInterceptor.cs``HealthMethodPrefix` private → public (общий для
интерцепторов ингресса, XML-doc актуализирован).
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+16):**
- `LoginAttemptGuardTests.cs` (8) — unit с инъекцией часов: 5 неудач → блок; 4 → нет; успех сбрасывает
счётчик (Reset + снова полные 5 до блока); Reset разблокирует заблокированный ключ; истечение окна
по сдвигу часов (>15 мин) снимает блок и начинает новое окно; изоляция ключей (другой IP/логин не
затронуты); Enabled=false → no-op; пустой логин не блокируется.
- `LoginAttemptEndpointHttpTests.cs` (2) — HTTP ручки POST /api/auth/login (OperatorAuthHttpHost с
Enabled-опциями): 5×401 → 6-я попытка (верный пароль) 429 с текстом Ruling 5 (гвард до AuthService);
4 неудачи + успех (200, Set-Cookie) → следующие 2 сбоя обычные 401 (без сброса 2-й был бы 429 —
доказывает Reset через эндпоинт).
- `RateLimitHttpTests.cs` (4) — in-process Kestrel по схеме Program.cs (регистрация политик только при
Enabled, UseRateLimiter после «сессионного» маркера): политика `auth` — превышение окна (2/мин) →
429 `{detail}`; глобальный лимитер api — аноним превысил → 429 `{detail}`; партиция api — после
исчерпания IP-бакета анонима запросы тенанта (маркер CurrentUser по `?tenant=`) проходят, окна
разных тенантов изолированы; `Enabled=false` — 6 запросов подряд без лимита (dev-флаг, acceptance).
- `IngressRateLimitInterceptorTests.cs` (2) — хост TelegramIngressTestHost с Enabled-опциями
(интерцептор + singleton-лимитер + grpc.health.v1): 3-й PushMessage тенанта в минуту (окно 2/мин) →
`RESOURCE_EXHAUSTED` с detail; у другого тенанта собственное окно (проходит); при окне 1/мин health
`Check` отвечает SERVING (лимитом не режется) и окно ингресса остаётся исчерпанным.
**Харнессы:** `OperatorAuthHttpHost.cs` — всегда регистрирует RateLimitOptions (дефолт — выключен) +
LoginAttemptGuard (эндпоинты login принимают его параметром DI); опциональный `rateLimitOptions`
на полной перегрузке (сценарии защиты входа). `TelegramIngressTestHost.cs` — опциональный
`RateLimitOptions`: при Enabled добавляет интерцептор, singleton-лимитер и MapGrpcHealthChecksService
(существующие вызовы без опций не изменены).
## Проверки
- `dotnet build Deal.sln` — 0 warnings/0 errors (TreatWarningsAsErrors + EnforceCodeStyleInBuild);
diagnostics — чисто (проект без ошибок/предупреждений).
- `dotnet test Deal.sln` — 1088/1088 PASS, 0 fail (1072 до Task 11 + 16; запуск новых по фильтру, затем
полный прогон).
- Миграций/БД не требуется. Живой dev-прогон (Enabled=false, curl-приёмки не режутся) — ⚠ Manual
(docker выключен; эквивалент — RateLimitHttpTests.RateLimiterDisabled и дефолт конфига).
## Concerns
- **«RequireRateLimiting на группах auth/operator/auth» (формулировка плана) реализовано точечно** —
политика `auth` (10/мин на IP) стоит ТОЛЬКО на `/login` обеих групп, как фиксирует Ruling 5
(«AuthPerMinute 10/мин на IP для /api/auth/login и /api/operator/auth/login»): наложение 10/мин на всю
группу резало бы `/me`/`/logout` за NAT-ом. Остальные ручки групп — под глобальной api-политикой
(600/мин по тенанту/IP). Для api-map/техдок Task 16 зафиксировать фактический контракт.
- **Ключ по IP за reverse-proxy (PROD).** В compose-prod (Task 14) наружу — Caddy → core:5080, без
`UseForwardedHeaders` RemoteIpAddress всех запросов = IP Caddy, и IP-политики (auth 10/мин, api-аноним)
схлопнутся в один бакет на весь трафик. В рамках Task 11 по плану ForwardedHeaders не вводился —
задел: включить `UseForwardedHeaders` (KnownProxies=Caddy) при настройке PROD либо учесть в Task 16
(техдок §10 «прокси-заголовки»).
- **Текст 429 гварда фиксированный** — «…Попробуйте через 15 минут» (Ruling 5). При смене
`RateLimit:LoginAttemptWindowMin` текст не пересчитывается (намеренно: точная формулировка Ruling).
- **In-memory хранилища** (LoginAttemptGuard, партиции FixedWindowRateLimiter) — память одного
инстанса core; при multi-instance (задел техдок §11) потребуется общий KV/Redis. Зафиксировано в
remarks LoginAttemptGuard.
- **Мусорные вызовы ингресса без tenant-id** партиционируются общим бакетом `missing-tenant-id` (после
окна 600/мин получают RESOURCE_EXHAUSTED до отказа сервиса) — сознательно, см. remarks интерцептора.
## Файлы
Создано: `Deal.Api/Configuration/RateLimitOptions.cs`, `Deal.Api/Middleware/RateLimitPolicies.cs`,
`Deal.Api/Http/LoginAttemptGuard.cs`, `Deal.Api/Telegram/IngressRateLimitInterceptor.cs`; тесты
`LoginAttemptGuardTests.cs`, `LoginAttemptEndpointHttpTests.cs`, `RateLimitHttpTests.cs`,
`IngressRateLimitInterceptorTests.cs`. Изменено: `Deal.Api/Program.cs`,
`Deal.Api/Endpoints/AuthEndpoints.cs`, `Deal.Api/Endpoints/OperatorAuthEndpoints.cs`,
`Deal.Api/Http/EndpointResults.cs`, `Deal.Api/Telegram/IngressServiceTokenInterceptor.cs`,
`Deal.Api/appsettings.json`, `tests/OperatorAuthHttpHost.cs`, `tests/TelegramIngressTestHost.cs`.
# Task 11 report — Rate limiting (HTTP auth/api + gRPC-ингресс) и LoginAttemptGuard
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 11 (L426444), Ruling 5/10.
Проект НЕ git. Docker выключен: живые curl-приёмки/проверка поведения в dev-стеке — ⚠ Manual
(эквивалент — HTTP/gRPC-тесты in-process ниже; dev-флаг Enabled=false проверен тестом).
Сборка `dotnet build Deal.sln` — 0 warnings/0 errors; `dotnet test Deal.sln`**1088/1088 PASS**
(было 1072 до Task 11; +16 новых за задачу).
## Состав
**Создано — `src/core/Deal.Api/`** (1 тип = 1 файл, XML-doc, константы, комментарии русские):
- `Configuration/RateLimitOptions.cs` — секция `RateLimit` (appsettings.json + env `RateLimit__*`):
`Enabled` (код-дефолт **false** — dev/тесты, Ruling 5; PROD включает env `RateLimit__Enabled=true`),
`AuthPerMinute=10`, `ApiPerMinute=600`, `GrpcIngressPerMinute=600`, `LoginAttemptsMax=5`,
`LoginAttemptWindowMin=15`.
- `Middleware/RateLimitPolicies.cs``AddDealRateLimiter(options)` (вызывается только при Enabled):
`AddRateLimiter` с политиками `"auth"` (fixed window 1 мин по IP клиента) и `"api"` (по
`CurrentUser.TenantId` либо IP анонима — `Session/OperatorSession` отрабатывают раньше); API-партиция
выставляется и **глобальным лимитером** (`GlobalLimiter`) — весь /api без собственной политики
ограничен по тенанту/IP. `OnRejected` → 429 `{"detail":"Слишком много запросов. Повторите позже"}`
(`RejectedDetail` — единая константа, Ruling 5). `QueueLimit=0` (без очереди ожидания).
- `Http/LoginAttemptGuard.cs` — прикладной guard (singleton): in-memory **фиксированное окно** по ключу
`ip|login`; ≥`LoginAttemptsMax` (5) неудач в окне `LoginAttemptWindowMin` (15) минут → `IsBlocked`,
`Reset` при успешном входе, пустой логин ключа не имеет, окна «выровнены по часам» и прунятся при
обращении (память — только активные ключи окна). Часы — инъекцией `Func<DateTimeOffset>` (эталон
TenantLimitStore) — unit-тесты окна без ожидания. **Активен только при `Enabled=true`** (no-op в dev —
curl-приёмки не режутся, Ruling 5). Текст 429 — `BlockedDetail` «Слишком много попыток входа.
Попробуйте через 15 минут». Multi-instance задел зафиксирован в remarks (общий KV/Redis, техдок §11).
- `Telegram/IngressRateLimitInterceptor.cs` — gRPC-ингресс (:5082): fixed window 1 мин по metadata
`tenant-id` (партиция на тенанта); стандартный `grpc.health.v1.Health` освобождён (префикс
`IngressServiceTokenInterceptor.HealthMethodPrefix` — сделан public, общий для интерцепторов);
превышение — RPC-отказ `RESOURCE_EXHAUSTED` (gRPC-аналог 429). Окно считает **общий
singleton-лимитер** (`CreateLimiter` регистрируется в DI) — экземпляры интерцептора создаются
фреймворком, но партиции/окна общие (иначе лимит не работал бы).
**Изменено:**
- `Deal.Api/Program.cs` — bind секции `RateLimit` (константа имени в шапке), `AddSingleton` опций +
`LoginAttemptGuard`; `AddDealRateLimiter` только при Enabled; `AddGrpc`: интерцептор ингресса +
singleton-лимитер только при Enabled; порядок middleware — `Session → Operator → UseRateLimiter`
(только при Enabled; Ruling 5); `MapGrpcService<TelegramIngressService>().DisableRateLimiting()` и
`MapGrpcHealthChecksService().DisableRateLimiting()` — HTTP-лимитер не режет ингресс (его лимит —
интерцептором по tenant-id; иначе общее окно на IP telegram-service резало бы поток раньше).
- `Endpoints/AuthEndpoints.cs`, `Endpoints/OperatorAuthEndpoints.cs``RequireRateLimiting("auth")`
только на ручках `/login` (Ruling 5: 10/мин на IP именно login; остальные ручки групп — под глобальной
api-политикой); вызов `LoginAttemptGuard` **до** AuthService: блок → 429 `BlockedDetail`; неудачные
попытки (непустой логин) → `RecordFailure` в той же точке, где пишется аудит `*_login_failed`;
успех → `Reset`; 403 suspended-тенанта счётчиком не трогается (не credential-сбой).
- `Http/EndpointResults.cs``TooManyRequests(detail)` (429 `{detail}`).
- `Deal.Api/appsettings.json` — секция `RateLimit` (Enabled=false + значения политик) — dev-дефолт явный.
- `Telegram/IngressServiceTokenInterceptor.cs``HealthMethodPrefix` private → public (общий для
интерцепторов ингресса, XML-doc актуализирован).
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+16):**
- `LoginAttemptGuardTests.cs` (8) — unit с инъекцией часов: 5 неудач → блок; 4 → нет; успех сбрасывает
счётчик (Reset + снова полные 5 до блока); Reset разблокирует заблокированный ключ; истечение окна
по сдвигу часов (>15 мин) снимает блок и начинает новое окно; изоляция ключей (другой IP/логин не
затронуты); Enabled=false → no-op; пустой логин не блокируется.
- `LoginAttemptEndpointHttpTests.cs` (2) — HTTP ручки POST /api/auth/login (OperatorAuthHttpHost с
Enabled-опциями): 5×401 → 6-я попытка (верный пароль) 429 с текстом Ruling 5 (гвард до AuthService);
4 неудачи + успех (200, Set-Cookie) → следующие 2 сбоя обычные 401 (без сброса 2-й был бы 429 —
доказывает Reset через эндпоинт).
- `RateLimitHttpTests.cs` (4) — in-process Kestrel по схеме Program.cs (регистрация политик только при
Enabled, UseRateLimiter после «сессионного» маркера): политика `auth` — превышение окна (2/мин) →
429 `{detail}`; глобальный лимитер api — аноним превысил → 429 `{detail}`; партиция api — после
исчерпания IP-бакета анонима запросы тенанта (маркер CurrentUser по `?tenant=`) проходят, окна
разных тенантов изолированы; `Enabled=false` — 6 запросов подряд без лимита (dev-флаг, acceptance).
- `IngressRateLimitInterceptorTests.cs` (2) — хост TelegramIngressTestHost с Enabled-опциями
(интерцептор + singleton-лимитер + grpc.health.v1): 3-й PushMessage тенанта в минуту (окно 2/мин) →
`RESOURCE_EXHAUSTED` с detail; у другого тенанта собственное окно (проходит); при окне 1/мин health
`Check` отвечает SERVING (лимитом не режется) и окно ингресса остаётся исчерпанным.
**Харнессы:** `OperatorAuthHttpHost.cs` — всегда регистрирует RateLimitOptions (дефолт — выключен) +
LoginAttemptGuard (эндпоинты login принимают его параметром DI); опциональный `rateLimitOptions`
на полной перегрузке (сценарии защиты входа). `TelegramIngressTestHost.cs` — опциональный
`RateLimitOptions`: при Enabled добавляет интерцептор, singleton-лимитер и MapGrpcHealthChecksService
(существующие вызовы без опций не изменены).
## Проверки
- `dotnet build Deal.sln` — 0 warnings/0 errors (TreatWarningsAsErrors + EnforceCodeStyleInBuild);
diagnostics — чисто (проект без ошибок/предупреждений).
- `dotnet test Deal.sln` — 1088/1088 PASS, 0 fail (1072 до Task 11 + 16; запуск новых по фильтру, затем
полный прогон).
- Миграций/БД не требуется. Живой dev-прогон (Enabled=false, curl-приёмки не режутся) — ⚠ Manual
(docker выключен; эквивалент — RateLimitHttpTests.RateLimiterDisabled и дефолт конфига).
## Concerns
- **«RequireRateLimiting на группах auth/operator/auth» (формулировка плана) реализовано точечно** —
политика `auth` (10/мин на IP) стоит ТОЛЬКО на `/login` обеих групп, как фиксирует Ruling 5
(«AuthPerMinute 10/мин на IP для /api/auth/login и /api/operator/auth/login»): наложение 10/мин на всю
группу резало бы `/me`/`/logout` за NAT-ом. Остальные ручки групп — под глобальной api-политикой
(600/мин по тенанту/IP). Для api-map/техдок Task 16 зафиксировать фактический контракт.
- **Ключ по IP за reverse-proxy (PROD).** В compose-prod (Task 14) наружу — Caddy → core:5080, без
`UseForwardedHeaders` RemoteIpAddress всех запросов = IP Caddy, и IP-политики (auth 10/мин, api-аноним)
схлопнутся в один бакет на весь трафик. В рамках Task 11 по плану ForwardedHeaders не вводился —
задел: включить `UseForwardedHeaders` (KnownProxies=Caddy) при настройке PROD либо учесть в Task 16
(техдок §10 «прокси-заголовки»).
- **Текст 429 гварда фиксированный** — «…Попробуйте через 15 минут» (Ruling 5). При смене
`RateLimit:LoginAttemptWindowMin` текст не пересчитывается (намеренно: точная формулировка Ruling).
- **In-memory хранилища** (LoginAttemptGuard, партиции FixedWindowRateLimiter) — память одного
инстанса core; при multi-instance (задел техдок §11) потребуется общий KV/Redis. Зафиксировано в
remarks LoginAttemptGuard.
- **Мусорные вызовы ингресса без tenant-id** партиционируются общим бакетом `missing-tenant-id` (после
окна 600/мин получают RESOURCE_EXHAUSTED до отказа сервиса) — сознательно, см. remarks интерцептора.
## Файлы
Создано: `Deal.Api/Configuration/RateLimitOptions.cs`, `Deal.Api/Middleware/RateLimitPolicies.cs`,
`Deal.Api/Http/LoginAttemptGuard.cs`, `Deal.Api/Telegram/IngressRateLimitInterceptor.cs`; тесты
`LoginAttemptGuardTests.cs`, `LoginAttemptEndpointHttpTests.cs`, `RateLimitHttpTests.cs`,
`IngressRateLimitInterceptorTests.cs`. Изменено: `Deal.Api/Program.cs`,
`Deal.Api/Endpoints/AuthEndpoints.cs`, `Deal.Api/Endpoints/OperatorAuthEndpoints.cs`,
`Deal.Api/Http/EndpointResults.cs`, `Deal.Api/Telegram/IngressServiceTokenInterceptor.cs`,
`Deal.Api/appsettings.json`, `tests/OperatorAuthHttpHost.cs`, `tests/TelegramIngressTestHost.cs`.
@@ -1,87 +1,87 @@
# Task 12 report — Безопасность HTTP: Origin-проверка мутаций, ForwardedHeaders (замечание T4/T11), CORS-allowlist
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 12 (L446460), Ruling 10(2)/9;
дополнение — замечание ревью T4/T11 (UseForwardedHeaders за Caddy, закрыт concern T11-отчёта «Ключ по IP
за reverse-proxy»). Проект НЕ git. Docker выключен: живые curl-приёмки/проверка в dev-стеке — ⚠ Manual
(эквивалент — HTTP-тесты in-process ниже).
Сборка `dotnet build Deal.sln` — 0 warnings/0 errors; `dotnet test Deal.sln`**1101/1101 PASS**
(было 1088 после Task 11; +13 за задачу: 6 OriginGuard + 7 ForwardedHeaders).
## Состав
**Создано — `src/core/Deal.Api/`** (1 тип = 1 файл, XML-doc, константы, комментарии русские):
- `Configuration/SecurityOptions.cs` — секция `Security` (appsettings + env `Security__*`):
`AllowedOrigins string[]` — единый явный allowlist Origin-проверки и CORS. Пусто — dev-режим
«свой origin запроса» (схема+Host) + CORS-любой; непусто (PROD, Ruling 9) — строгий allowlist + credentials.
- `Configuration/ForwardedHeadersConfig.cs` — секция `ForwardedHeaders` (env `ForwardedHeaders__*`):
`Enabled` (код-дефолт **false** — dev/тесты; PROD включает env), `KnownProxies` (IP), `KnownNetworks`
(CIDR). XML-doc фиксирует «зачем» (audit-IP/rate-limit-IP схлопываются за Caddy) и предупреждение про
семантику пустых списков (см. Concerns).
- `Middleware/OriginGuardMiddleware.cs` — для не-GET/HEAD/OPTIONS запросов `/api` с заголовком Origin:
Origin ∈ {allowlist `Security:AllowedOrigins`} ∪ {«свой» origin запроса: `схема://Host`, схема — с
учётом X-Forwarded-Proto}, иначе **403 `{"detail":"Запрос отклонён: недопустимый Origin"}`**
(`OriginRejectedDetail` — public-константа). Без Origin (curl/сервер-сервер/gRPC) и не-мутации
пропускаются; пустой allowlist — правило «свой origin» (Ruling 10(2)). Регистрируется после
RateLimiter (Ruling 5: Session → Operator → RateLimiter → OriginGuard).
**Изменено:**
- `Deal.Api/Program.cs` — bind `Security`/`ForwardedHeaders` (AddSingleton-инстансы); CORS-политика
«cors»: пустой AllowedOrigins — предикат-«любой» (как раньше), непустой — `WithOrigins`+credentials;
конвейер: `UseForwardedHeaders` (при Enabled) — **первым** (до CORS/сессий/rate-limiter — они читают
RemoteIpAddress/Scheme) → UseCors → Session → Operator → RateLimiter → `UseMiddleware<OriginGuardMiddleware>`
→ эндпоинты. В `public partial class Program` — публичный `BuildForwardedHeadersOptions(ForwardedHeadersConfig)`
(X-Forwarded-For|Proto, ForwardLimit=1, списки — только из конфига; невалидный IP/CIDR — fail-fast
`InvalidOperationException`; пустые списки не допускаются — loopback-фолбэк) + приватный `TryParseCidr`.
- `Deal.Api/appsettings.json` — секции `Security` (AllowedOrigins=[]) и `ForwardedHeaders`
(Enabled=false, KnownProxies=loopback `127.0.0.1`/`::1`, KnownNetworks=[]).
- `Deal.Api/appsettings.Development.json``Security:AllowedOrigins = ["http://localhost:5173"]`
(vite; через прокси Host меняется — Origin 5173 ≠ Host, поэтому нужен явный allowlist).
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+13):**
- `OriginGuardHttpTests.cs` (6) — in-process Kestrel по схеме Program.cs (SecurityOptions в DI,
UseMiddleware): POST `/api` с чужим Origin → 403 `{detail}` (acceptance curl); POST со «своим» Origin
(схема+Host) → ok; POST с Origin из allowlist → ok, чужой на том же хосте → 403; POST без Origin → ok
(acceptance curl «без Origin»); GET и OPTIONS с чужим Origin не проверяются (не-мутации/preflight).
- `ForwardedHeadersHttpTests.cs` (7) — unit `BuildForwardedHeadersOptions`: KnownProxies/KnownIPNetworks
из конфига (проверка Prefix/PrefixLength, флагов, ForwardLimit=1); пустые списки → loopback-фолбэк;
невалидный IP/CIDR («garbage», `/33`) → InvalidOperationException. HTTP: доверенный loopback-прокси
(KnownProxies) применяет X-Forwarded-For/Proto → приложение видит IP конечного клиента (TEST-NET-3) и
https; доверие подсетью (KnownNetworks `127.0.0.0/8`) работает; клиент вне списков доверия → заголовки
игнорируются (спуфинг XFF невозможен).
## Проверки
- `dotnet build Deal.sln` — 0 warnings/0 errors (TreatWarningsAsErrors); diagnostics — чисто.
- `dotnet test Deal.sln` — 1101/1101 PASS, 0 fail (запуск новых по фильтру, затем полный прогон).
- Живой dev-прогон/curl (мутация с `Origin: http://evil` → 403, без Origin → ok) — ⚠ Manual
(docker выключен; эквивалент — OriginGuardHttpTests + CORS-дефолт конфига).
## Concerns
- **SecurityHeadersMiddleware (п. Files плана) НЕ создан** — по решению владельца задачи (п.3 задания):
security-заголовки целиком на edge (Caddyfile, Task 14: nosniff/XFO/Referrer-Policy + CSP/HSTS — статику
и /api наружу отдаёт Caddy, core отвечает JSON; пересмотр Ruling 10(3)). Зафиксировано комментарием в
Program.cs у AddCors и здесь; техдок §10 актуализируется в Task 16.
- **ForwardedHeadersMiddleware: пустые KnownProxies/KnownIPNetworks = «доверять любому клиенту»**
(обнаружено тестом — XFF применялся без списков). Поэтому `BuildForwardedHeadersOptions` пустоту не
допускает: loopback-фолбэк (dev-прокси на хосте); явное перечисление в конфиге замещает его.
Оператор, убравший loopback из appsettings, ничего не ломает — фолбэк страхует.
- **PROD (Task 14):** `ForwardedHeaders__Enabled=true` + KnownNetworks узким CIDR compose-сети (или
KnownProxies — IP Caddy) — иначе доверен только loopback, и audit/rate-limit-IP снова схлопнутся на IP
Caddy. Не перечислять весь Docker-мост `172.16.0.0/12`, если это возможно (доверие получат и сервисы
сети — смогут спуфить XFF к core; они и так в одной сети). .env.prod.example — Task 14.
- **CORS/PROD:** при непустом `Security:AllowedOrigins` CORS-политика становится строгой
(allowlist+credentials) — поведение меняется с «любой origin» на явный список; dev-дефолт (Development)
`http://localhost:5173`. Curl/сервер-сервер OriginGuard не затрагивает (нет Origin).
- **join/будущий UI:** `POST /api/join` — публичная мутация; curl (без Origin) проходит; активационная
страница на домене оператора — same-origin, иная — в allowlist (зафиксировать в техдок §10/Task 16).
- **OriginGuard-правило «свой origin»** реализовано как `схема://Host`, а не сравнение строки с Host без
схемы (Ruling 10(2) «совпасть с Host»): для браузерного Origin это эквивалент (Host в Origin — тот же),
зато корректно работает за Caddy с X-Forwarded-Proto (https) и при нестандартных портах.
## Файлы
Создано: `Deal.Api/Configuration/SecurityOptions.cs`, `Deal.Api/Configuration/ForwardedHeadersConfig.cs`,
`Deal.Api/Middleware/OriginGuardMiddleware.cs`; тесты `OriginGuardHttpTests.cs`,
`ForwardedHeadersHttpTests.cs`. Изменено: `Deal.Api/Program.cs`, `Deal.Api/appsettings.json`,
`Deal.Api/appsettings.Development.json`.
# Task 12 report — Безопасность HTTP: Origin-проверка мутаций, ForwardedHeaders (замечание T4/T11), CORS-allowlist
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 12 (L446460), Ruling 10(2)/9;
дополнение — замечание ревью T4/T11 (UseForwardedHeaders за Caddy, закрыт concern T11-отчёта «Ключ по IP
за reverse-proxy»). Проект НЕ git. Docker выключен: живые curl-приёмки/проверка в dev-стеке — ⚠ Manual
(эквивалент — HTTP-тесты in-process ниже).
Сборка `dotnet build Deal.sln` — 0 warnings/0 errors; `dotnet test Deal.sln`**1101/1101 PASS**
(было 1088 после Task 11; +13 за задачу: 6 OriginGuard + 7 ForwardedHeaders).
## Состав
**Создано — `src/core/Deal.Api/`** (1 тип = 1 файл, XML-doc, константы, комментарии русские):
- `Configuration/SecurityOptions.cs` — секция `Security` (appsettings + env `Security__*`):
`AllowedOrigins string[]` — единый явный allowlist Origin-проверки и CORS. Пусто — dev-режим
«свой origin запроса» (схема+Host) + CORS-любой; непусто (PROD, Ruling 9) — строгий allowlist + credentials.
- `Configuration/ForwardedHeadersConfig.cs` — секция `ForwardedHeaders` (env `ForwardedHeaders__*`):
`Enabled` (код-дефолт **false** — dev/тесты; PROD включает env), `KnownProxies` (IP), `KnownNetworks`
(CIDR). XML-doc фиксирует «зачем» (audit-IP/rate-limit-IP схлопываются за Caddy) и предупреждение про
семантику пустых списков (см. Concerns).
- `Middleware/OriginGuardMiddleware.cs` — для не-GET/HEAD/OPTIONS запросов `/api` с заголовком Origin:
Origin ∈ {allowlist `Security:AllowedOrigins`} ∪ {«свой» origin запроса: `схема://Host`, схема — с
учётом X-Forwarded-Proto}, иначе **403 `{"detail":"Запрос отклонён: недопустимый Origin"}`**
(`OriginRejectedDetail` — public-константа). Без Origin (curl/сервер-сервер/gRPC) и не-мутации
пропускаются; пустой allowlist — правило «свой origin» (Ruling 10(2)). Регистрируется после
RateLimiter (Ruling 5: Session → Operator → RateLimiter → OriginGuard).
**Изменено:**
- `Deal.Api/Program.cs` — bind `Security`/`ForwardedHeaders` (AddSingleton-инстансы); CORS-политика
«cors»: пустой AllowedOrigins — предикат-«любой» (как раньше), непустой — `WithOrigins`+credentials;
конвейер: `UseForwardedHeaders` (при Enabled) — **первым** (до CORS/сессий/rate-limiter — они читают
RemoteIpAddress/Scheme) → UseCors → Session → Operator → RateLimiter → `UseMiddleware<OriginGuardMiddleware>`
→ эндпоинты. В `public partial class Program` — публичный `BuildForwardedHeadersOptions(ForwardedHeadersConfig)`
(X-Forwarded-For|Proto, ForwardLimit=1, списки — только из конфига; невалидный IP/CIDR — fail-fast
`InvalidOperationException`; пустые списки не допускаются — loopback-фолбэк) + приватный `TryParseCidr`.
- `Deal.Api/appsettings.json` — секции `Security` (AllowedOrigins=[]) и `ForwardedHeaders`
(Enabled=false, KnownProxies=loopback `127.0.0.1`/`::1`, KnownNetworks=[]).
- `Deal.Api/appsettings.Development.json``Security:AllowedOrigins = ["http://localhost:5173"]`
(vite; через прокси Host меняется — Origin 5173 ≠ Host, поэтому нужен явный allowlist).
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+13):**
- `OriginGuardHttpTests.cs` (6) — in-process Kestrel по схеме Program.cs (SecurityOptions в DI,
UseMiddleware): POST `/api` с чужим Origin → 403 `{detail}` (acceptance curl); POST со «своим» Origin
(схема+Host) → ok; POST с Origin из allowlist → ok, чужой на том же хосте → 403; POST без Origin → ok
(acceptance curl «без Origin»); GET и OPTIONS с чужим Origin не проверяются (не-мутации/preflight).
- `ForwardedHeadersHttpTests.cs` (7) — unit `BuildForwardedHeadersOptions`: KnownProxies/KnownIPNetworks
из конфига (проверка Prefix/PrefixLength, флагов, ForwardLimit=1); пустые списки → loopback-фолбэк;
невалидный IP/CIDR («garbage», `/33`) → InvalidOperationException. HTTP: доверенный loopback-прокси
(KnownProxies) применяет X-Forwarded-For/Proto → приложение видит IP конечного клиента (TEST-NET-3) и
https; доверие подсетью (KnownNetworks `127.0.0.0/8`) работает; клиент вне списков доверия → заголовки
игнорируются (спуфинг XFF невозможен).
## Проверки
- `dotnet build Deal.sln` — 0 warnings/0 errors (TreatWarningsAsErrors); diagnostics — чисто.
- `dotnet test Deal.sln` — 1101/1101 PASS, 0 fail (запуск новых по фильтру, затем полный прогон).
- Живой dev-прогон/curl (мутация с `Origin: http://evil` → 403, без Origin → ok) — ⚠ Manual
(docker выключен; эквивалент — OriginGuardHttpTests + CORS-дефолт конфига).
## Concerns
- **SecurityHeadersMiddleware (п. Files плана) НЕ создан** — по решению владельца задачи (п.3 задания):
security-заголовки целиком на edge (Caddyfile, Task 14: nosniff/XFO/Referrer-Policy + CSP/HSTS — статику
и /api наружу отдаёт Caddy, core отвечает JSON; пересмотр Ruling 10(3)). Зафиксировано комментарием в
Program.cs у AddCors и здесь; техдок §10 актуализируется в Task 16.
- **ForwardedHeadersMiddleware: пустые KnownProxies/KnownIPNetworks = «доверять любому клиенту»**
(обнаружено тестом — XFF применялся без списков). Поэтому `BuildForwardedHeadersOptions` пустоту не
допускает: loopback-фолбэк (dev-прокси на хосте); явное перечисление в конфиге замещает его.
Оператор, убравший loopback из appsettings, ничего не ломает — фолбэк страхует.
- **PROD (Task 14):** `ForwardedHeaders__Enabled=true` + KnownNetworks узким CIDR compose-сети (или
KnownProxies — IP Caddy) — иначе доверен только loopback, и audit/rate-limit-IP снова схлопнутся на IP
Caddy. Не перечислять весь Docker-мост `172.16.0.0/12`, если это возможно (доверие получат и сервисы
сети — смогут спуфить XFF к core; они и так в одной сети). .env.prod.example — Task 14.
- **CORS/PROD:** при непустом `Security:AllowedOrigins` CORS-политика становится строгой
(allowlist+credentials) — поведение меняется с «любой origin» на явный список; dev-дефолт (Development)
`http://localhost:5173`. Curl/сервер-сервер OriginGuard не затрагивает (нет Origin).
- **join/будущий UI:** `POST /api/join` — публичная мутация; curl (без Origin) проходит; активационная
страница на домене оператора — same-origin, иная — в allowlist (зафиксировать в техдок §10/Task 16).
- **OriginGuard-правило «свой origin»** реализовано как `схема://Host`, а не сравнение строки с Host без
схемы (Ruling 10(2) «совпасть с Host»): для браузерного Origin это эквивалент (Host в Origin — тот же),
зато корректно работает за Caddy с X-Forwarded-Proto (https) и при нестандартных портах.
## Файлы
Создано: `Deal.Api/Configuration/SecurityOptions.cs`, `Deal.Api/Configuration/ForwardedHeadersConfig.cs`,
`Deal.Api/Middleware/OriginGuardMiddleware.cs`; тесты `OriginGuardHttpTests.cs`,
`ForwardedHeadersHttpTests.cs`. Изменено: `Deal.Api/Program.cs`, `Deal.Api/appsettings.json`,
`Deal.Api/appsettings.Development.json`.
@@ -1,110 +1,110 @@
# Task 13 report — mTLS: флаг DEAL_MTLS_*, скрипт сертификатов, каналы core-сервисов под mTLS
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 13 (L462480), Ruling 6; дополнение —
замечание ревью T10: в скоуп вошёл и `ServiceHealthProbe` (операторский health по gRPC к сервисам —
mTLS-клиент). Проект НЕ git. Docker off: живого mTLS-рукопожатия между контейнерами нет — ⚠ Manual
(эквивалент — unit-проверки загрузки/валидации на сгенерированных в памяти сертификатах + полный прогон
`scripts/mtls-certs.sh` на хосте с openssl-verify).
Сборка всех sln 0/0 (Deal.sln, Deal.Telegram.sln, Deal.Ai.sln, Deal.Ml.sln); `sh -n scripts/mtls-certs.sh` rc=0;
`dotnet test Deal.sln`**1123/1123 PASS** (было 1101 после Task 12; +22 за задачу: 12 MtlsOptions + 10
MtlsCertificates); сервисные прогоны: telegram 114/114, ai 50/50, ml 36/36.
## Состав
**Скрипт:**
- `scripts/mtls-certs.sh` (POSIX sh, openssl): dev-CA (CN=Deal mTLS Dev CA, 10 лет) + серверные PFX
`core/telegram-service/ai-service/ml-service` (825 дн.; SAN `localhost,<compose-имя>,host.docker.internal,
127.0.0.1`) + общий клиентский `deal-client.pfx``deploy/certs/`. Пароль PFX — env
`DEAL_MTLS_CERT_PASSWORD` (dev-дефолт). Повторный запуск без `-f` не перезаписывает CA; временные
CSR/ключи — в `mktemp -d` с trap-очисткой; subject-имена — config-файлами openssl (без `-subj /CN=…`
работает и в Git Bash/MSYS). Шапка-инструкция: env-блок DEAL_MTLS_*, пометка «в репозиторий/образ не
попадает — .dockerignore; dev-compose остаётся plaintext; PROD env передаёт compose-prod (Task 14)».
- `.dockerignore`: `deploy/certs` (сертификаты не попадают в build-контекст/образ).
- Скрипт реально прогнан на хосте: файлы в `deploy/certs/`, `openssl verify -CAfile ca.pem` для всех пяти
сертификатов — OK.
**Код — классы конфигурации/сертификатов (1 тип = 1 файл, XML-doc, env только, Ruling 13):**
- `MtlsOptions` — Enabled/ServerCertPfx/ServerCertPassword/ClientCertPfx/ClientCertPassword/CaPem; env
`DEAL_MTLS_*`; `FromConfiguration(IConfiguration)` + `IsEnabled` («1»/«true»). Копии шаблона в 4 процессах
(core-Infrastructure + TG/AI/ML — у сервисов свои sln, общий код не вынести; как ServiceTokenInterceptor):
`Deal.Infrastructure/Integrations/MtlsOptions.cs`, `Deal.Telegram/MtlsOptions.cs`, `Deal.Ai/MtlsOptions.cs`,
`Deal.Ml/MtlsOptions.cs`.
- `MtlsCertificates``Load(MtlsOptions)` → null при флаге off (режим plaintext не меняется), иначе CA+сервер+
клиент с fail-fast на пустые/битые пути и пароли (`InvalidOperationException` с env-ключом и путём);
серверная проверка клиентского сертификата для Kestrel (`ValidateClientCertificate`) и клиентский
`CreateClientHttpHandler()` (SocketsHttpHandler + SslOptions: клиентский сертификат +
RemoteCertificateValidationCallback). Проверка второй стороны — цепочка на нашу CA (`CustomRootTrust`, без
revocation; dev-CA вне системного хранилища — стандартная проверка дала бы chain-ошибку); hostname-проверка
(SAN) у клиента отдельно — несовпадение имени = отказ. Экземпляр живёт до конца процесса (IDisposable нет —
сертификаты держат Kestrel/каналы). Копии в 4 процессах (в AI/ML клиентская часть шаблона не
задействуется — оговорено в XML-doc).
**Код — применение (серверы Kestrel, флаг → HTTPS+RequireCertificate):**
- `Deal.Api/Program.cs` — при `DEAL_MTLS_ENABLED=1`: сертификаты грузятся сразу (fail-fast до Build);
gRPC-ингресс :5082 — `UseHttps` с серверным сертификатом core + `ClientCertificateMode.RequireCertificate`
+ валидация на CA; основной HTTP :5080 остаётся http (TLS наружу — Caddy, Ruling 9). Стартовый лог
транспорта (mTLS/plaintext; пути/пароли не логируются).
- `TelegramServiceHost`/`AiServiceHost`/`MlServiceHost` — тот же паттерн для :5101/:5102/:5103;
`Program.cs` сервисов логируют фактический режим.
- `CoreIngressClient` (telegram-service, исходящий в core-ингресс) — канал с клиентским сертификатом + CA
при флаге (сертификаты прокинуты регистрацией `ICoreIngressClient` из хоста).
**Код — клиенты core (включая замечание ревью T10):**
- `MlGrpcConnection`/`AiGrpcConnection`/`TelegramGrpcConnection` — опциональный параметр
`MtlsCertificates?` (null = как было, dev-каналы тестов не меняются): при флаге канал получает
`HttpHandler` с клиентским сертификатом и проверкой CA сервера. service-token остаётся в обоих режимах.
- `ServiceHealthProbe` — тот же mTLS-клиент: опциональные сертификаты в конструкторе; канал пробы на вызов
(дедлайн 3 с не меняется).
- `AddDealIntegrations(...)` — опциональный 4-й параметр `mtlsCertificates`, проброс в три транспорта
(существующие вызовы/DI-тесты не ломаются).
- `MtlsOptions` в core читается в `Deal.Api/Program.cs` и регистрируется singleton.
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+22):**
- `MtlsOptionsTests.cs` (12) — конфиг-парсинг: пустой env → disabled+пустые пути (dev-дефолт); «1»/«true»
(любой регистр) → enabled, «0»/«false»/мусор/нет ключа → disabled; все env-ключи → поля (пути обрезаются).
- `MtlsCertificatesTests.cs` (10) — выбор режима (флаг off → Load=null и файлы не читаются вовсе);
fail-fast: пустой путь CA / отсутствующий файл / неверный пароль PFX (в тексте — env-ключ и путь);
загрузка корректных файлов (CA+сервер+клиент, HasPrivateKey у PFX); серверная валидация: «свой» клиент
(chain-ошибки стандартного хранилища) принят, клиент чужой CA отвергнут, null/прочие ошибки отвергнуты;
клиентский хендлер несёт клиентский сертификат и callback проверки сервера. Сертификаты фиктивные —
`CertificateRequest` в памяти → временные ca.pem/*.pfx (по плану); вскрыт нюанс .NET: `Create(issuer…)`
не привязывает приватный ключ — PFX собран через `CopyWithPrivateKey` (иначе HasPrivateKey=false и
рукопожатие mTLS невозможно).
## Проверки
- `dotnet build` каждого sln (core + 3 сервиса) — 0 warnings/0 errors (TreatWarningsAsErrors); diagnostics — чисто.
- `dotnet test Deal.sln` — 1123/1123 PASS (0 fail); telegram 114, ai 50, ml 36 — все PASS.
- `sh -n scripts/mtls-certs.sh` — rc=0; реальный прогон скрипта — файлы сгенерированы, все 5 цепочек
`openssl verify` — OK.
- Живое mTLS-рукопожатие между процессами/контейнерами — ⚠ Manual (docker off; эквивалент — unit-проверки
загрузки/валидации + openssl-verify артефактов).
## Concerns
- **AI/ML несут полный env-набор DEAL_MTLS_* (в т.ч. клиентский PFX), хотя исходящих каналов не имеют** —
осознанно (Ruling 6 задаёт общую env-схему для всех процессов; `MtlsCertificates.Load` грузит все три роли,
единый fail-fast). compose-prod (Task 14) монтирует deploy/certs и передаёт одинаковый набор всем сервисам.
- **Kestrel-сертификаты с EphemeralKeySet** (Windows-хранилище не засоряется); в Linux-контейнерах флаг
не влияет. MtlsCertificates без IDisposable: время жизни — процесс (Kestrel/каналы держат ссылки).
- **healthcheck'и compose**: при mTLS `grpc_health_probe` (dev-образец) в PROD должен ходить с `-tls`/
клиентским сертификатом или остаться на отдельном plaintext-порту — вопрос compose-prod (Task 14), здесь
не решался.
- **Режим каналов и схемы endpoint**: при флаге endpoint'ы сервисов/ингресса должны быть `https://…`
(compose-prod env, Task 14); код каналов сам схему не переключает (dev-дефолты `http://localhost:51xx`
остаются для plaintext-режима).
- **Скрипт-артефакты** `deploy/certs/` (ca.key, PFX) сгенерированы при проверке и остались на диске —
это штатный вывод скрипта; в репозиторий/образ не попадают (проект не git; .dockerignore).
- **Каталог `deploy/certs` не содержит README-пометки** — пометка в шапке скрипта и `.dockerignore`
(Ruling 6: «.dockerignore/README-пометка» — выбран первый вариант).
## Файлы
Создано: `scripts/mtls-certs.sh`; `Deal.Infrastructure/Integrations/MtlsOptions.cs`,
`Deal.Infrastructure/Integrations/MtlsCertificates.cs`; `Deal.Telegram/MtlsOptions.cs`,
`Deal.Telegram/MtlsCertificates.cs`; `Deal.Ai/MtlsOptions.cs`, `Deal.Ai/MtlsCertificates.cs`;
`Deal.Ml/MtlsOptions.cs`, `Deal.Ml/MtlsCertificates.cs`; тесты `MtlsOptionsTests.cs`, `MtlsCertificatesTests.cs`.
Изменено: `Deal.Api/Program.cs` (mTLS-блок, Kestrel-ингресс, DI-проброс, стартовый лог),
`Deal.Infrastructure/ServiceCollectionExtensions.cs` (AddDealIntegrations), `Ml/Ai/TelegramGrpcConnection.cs`,
`ServiceHealthProbe.cs`, `TelegramServiceHost.cs`, `AiServiceHost.cs`, `MlServiceHost.cs`, их `Program.cs`
(лог режима), `Core/CoreIngressClient.cs`, `.dockerignore`.
# Task 13 report — mTLS: флаг DEAL_MTLS_*, скрипт сертификатов, каналы core-сервисов под mTLS
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 13 (L462480), Ruling 6; дополнение —
замечание ревью T10: в скоуп вошёл и `ServiceHealthProbe` (операторский health по gRPC к сервисам —
mTLS-клиент). Проект НЕ git. Docker off: живого mTLS-рукопожатия между контейнерами нет — ⚠ Manual
(эквивалент — unit-проверки загрузки/валидации на сгенерированных в памяти сертификатах + полный прогон
`scripts/mtls-certs.sh` на хосте с openssl-verify).
Сборка всех sln 0/0 (Deal.sln, Deal.Telegram.sln, Deal.Ai.sln, Deal.Ml.sln); `sh -n scripts/mtls-certs.sh` rc=0;
`dotnet test Deal.sln`**1123/1123 PASS** (было 1101 после Task 12; +22 за задачу: 12 MtlsOptions + 10
MtlsCertificates); сервисные прогоны: telegram 114/114, ai 50/50, ml 36/36.
## Состав
**Скрипт:**
- `scripts/mtls-certs.sh` (POSIX sh, openssl): dev-CA (CN=Deal mTLS Dev CA, 10 лет) + серверные PFX
`core/telegram-service/ai-service/ml-service` (825 дн.; SAN `localhost,<compose-имя>,host.docker.internal,
127.0.0.1`) + общий клиентский `deal-client.pfx``deploy/certs/`. Пароль PFX — env
`DEAL_MTLS_CERT_PASSWORD` (dev-дефолт). Повторный запуск без `-f` не перезаписывает CA; временные
CSR/ключи — в `mktemp -d` с trap-очисткой; subject-имена — config-файлами openssl (без `-subj /CN=…`
работает и в Git Bash/MSYS). Шапка-инструкция: env-блок DEAL_MTLS_*, пометка «в репозиторий/образ не
попадает — .dockerignore; dev-compose остаётся plaintext; PROD env передаёт compose-prod (Task 14)».
- `.dockerignore`: `deploy/certs` (сертификаты не попадают в build-контекст/образ).
- Скрипт реально прогнан на хосте: файлы в `deploy/certs/`, `openssl verify -CAfile ca.pem` для всех пяти
сертификатов — OK.
**Код — классы конфигурации/сертификатов (1 тип = 1 файл, XML-doc, env только, Ruling 13):**
- `MtlsOptions` — Enabled/ServerCertPfx/ServerCertPassword/ClientCertPfx/ClientCertPassword/CaPem; env
`DEAL_MTLS_*`; `FromConfiguration(IConfiguration)` + `IsEnabled` («1»/«true»). Копии шаблона в 4 процессах
(core-Infrastructure + TG/AI/ML — у сервисов свои sln, общий код не вынести; как ServiceTokenInterceptor):
`Deal.Infrastructure/Integrations/MtlsOptions.cs`, `Deal.Telegram/MtlsOptions.cs`, `Deal.Ai/MtlsOptions.cs`,
`Deal.Ml/MtlsOptions.cs`.
- `MtlsCertificates``Load(MtlsOptions)` → null при флаге off (режим plaintext не меняется), иначе CA+сервер+
клиент с fail-fast на пустые/битые пути и пароли (`InvalidOperationException` с env-ключом и путём);
серверная проверка клиентского сертификата для Kestrel (`ValidateClientCertificate`) и клиентский
`CreateClientHttpHandler()` (SocketsHttpHandler + SslOptions: клиентский сертификат +
RemoteCertificateValidationCallback). Проверка второй стороны — цепочка на нашу CA (`CustomRootTrust`, без
revocation; dev-CA вне системного хранилища — стандартная проверка дала бы chain-ошибку); hostname-проверка
(SAN) у клиента отдельно — несовпадение имени = отказ. Экземпляр живёт до конца процесса (IDisposable нет —
сертификаты держат Kestrel/каналы). Копии в 4 процессах (в AI/ML клиентская часть шаблона не
задействуется — оговорено в XML-doc).
**Код — применение (серверы Kestrel, флаг → HTTPS+RequireCertificate):**
- `Deal.Api/Program.cs` — при `DEAL_MTLS_ENABLED=1`: сертификаты грузятся сразу (fail-fast до Build);
gRPC-ингресс :5082 — `UseHttps` с серверным сертификатом core + `ClientCertificateMode.RequireCertificate`
+ валидация на CA; основной HTTP :5080 остаётся http (TLS наружу — Caddy, Ruling 9). Стартовый лог
транспорта (mTLS/plaintext; пути/пароли не логируются).
- `TelegramServiceHost`/`AiServiceHost`/`MlServiceHost` — тот же паттерн для :5101/:5102/:5103;
`Program.cs` сервисов логируют фактический режим.
- `CoreIngressClient` (telegram-service, исходящий в core-ингресс) — канал с клиентским сертификатом + CA
при флаге (сертификаты прокинуты регистрацией `ICoreIngressClient` из хоста).
**Код — клиенты core (включая замечание ревью T10):**
- `MlGrpcConnection`/`AiGrpcConnection`/`TelegramGrpcConnection` — опциональный параметр
`MtlsCertificates?` (null = как было, dev-каналы тестов не меняются): при флаге канал получает
`HttpHandler` с клиентским сертификатом и проверкой CA сервера. service-token остаётся в обоих режимах.
- `ServiceHealthProbe` — тот же mTLS-клиент: опциональные сертификаты в конструкторе; канал пробы на вызов
(дедлайн 3 с не меняется).
- `AddDealIntegrations(...)` — опциональный 4-й параметр `mtlsCertificates`, проброс в три транспорта
(существующие вызовы/DI-тесты не ломаются).
- `MtlsOptions` в core читается в `Deal.Api/Program.cs` и регистрируется singleton.
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+22):**
- `MtlsOptionsTests.cs` (12) — конфиг-парсинг: пустой env → disabled+пустые пути (dev-дефолт); «1»/«true»
(любой регистр) → enabled, «0»/«false»/мусор/нет ключа → disabled; все env-ключи → поля (пути обрезаются).
- `MtlsCertificatesTests.cs` (10) — выбор режима (флаг off → Load=null и файлы не читаются вовсе);
fail-fast: пустой путь CA / отсутствующий файл / неверный пароль PFX (в тексте — env-ключ и путь);
загрузка корректных файлов (CA+сервер+клиент, HasPrivateKey у PFX); серверная валидация: «свой» клиент
(chain-ошибки стандартного хранилища) принят, клиент чужой CA отвергнут, null/прочие ошибки отвергнуты;
клиентский хендлер несёт клиентский сертификат и callback проверки сервера. Сертификаты фиктивные —
`CertificateRequest` в памяти → временные ca.pem/*.pfx (по плану); вскрыт нюанс .NET: `Create(issuer…)`
не привязывает приватный ключ — PFX собран через `CopyWithPrivateKey` (иначе HasPrivateKey=false и
рукопожатие mTLS невозможно).
## Проверки
- `dotnet build` каждого sln (core + 3 сервиса) — 0 warnings/0 errors (TreatWarningsAsErrors); diagnostics — чисто.
- `dotnet test Deal.sln` — 1123/1123 PASS (0 fail); telegram 114, ai 50, ml 36 — все PASS.
- `sh -n scripts/mtls-certs.sh` — rc=0; реальный прогон скрипта — файлы сгенерированы, все 5 цепочек
`openssl verify` — OK.
- Живое mTLS-рукопожатие между процессами/контейнерами — ⚠ Manual (docker off; эквивалент — unit-проверки
загрузки/валидации + openssl-verify артефактов).
## Concerns
- **AI/ML несут полный env-набор DEAL_MTLS_* (в т.ч. клиентский PFX), хотя исходящих каналов не имеют** —
осознанно (Ruling 6 задаёт общую env-схему для всех процессов; `MtlsCertificates.Load` грузит все три роли,
единый fail-fast). compose-prod (Task 14) монтирует deploy/certs и передаёт одинаковый набор всем сервисам.
- **Kestrel-сертификаты с EphemeralKeySet** (Windows-хранилище не засоряется); в Linux-контейнерах флаг
не влияет. MtlsCertificates без IDisposable: время жизни — процесс (Kestrel/каналы держат ссылки).
- **healthcheck'и compose**: при mTLS `grpc_health_probe` (dev-образец) в PROD должен ходить с `-tls`/
клиентским сертификатом или остаться на отдельном plaintext-порту — вопрос compose-prod (Task 14), здесь
не решался.
- **Режим каналов и схемы endpoint**: при флаге endpoint'ы сервисов/ингресса должны быть `https://…`
(compose-prod env, Task 14); код каналов сам схему не переключает (dev-дефолты `http://localhost:51xx`
остаются для plaintext-режима).
- **Скрипт-артефакты** `deploy/certs/` (ca.key, PFX) сгенерированы при проверке и остались на диске —
это штатный вывод скрипта; в репозиторий/образ не попадают (проект не git; .dockerignore).
- **Каталог `deploy/certs` не содержит README-пометки** — пометка в шапке скрипта и `.dockerignore`
(Ruling 6: «.dockerignore/README-пометка» — выбран первый вариант).
## Файлы
Создано: `scripts/mtls-certs.sh`; `Deal.Infrastructure/Integrations/MtlsOptions.cs`,
`Deal.Infrastructure/Integrations/MtlsCertificates.cs`; `Deal.Telegram/MtlsOptions.cs`,
`Deal.Telegram/MtlsCertificates.cs`; `Deal.Ai/MtlsOptions.cs`, `Deal.Ai/MtlsCertificates.cs`;
`Deal.Ml/MtlsOptions.cs`, `Deal.Ml/MtlsCertificates.cs`; тесты `MtlsOptionsTests.cs`, `MtlsCertificatesTests.cs`.
Изменено: `Deal.Api/Program.cs` (mTLS-блок, Kestrel-ингресс, DI-проброс, стартовый лог),
`Deal.Infrastructure/ServiceCollectionExtensions.cs` (AddDealIntegrations), `Ml/Ai/TelegramGrpcConnection.cs`,
`ServiceHealthProbe.cs`, `TelegramServiceHost.cs`, `AiServiceHost.cs`, `MlServiceHost.cs`, их `Program.cs`
(лог режима), `Core/CoreIngressClient.cs`, `.dockerignore`.
@@ -1,161 +1,161 @@
# Task 14 report — Observability (Serilog JSON) + compose.prod (Caddy + promtail/loki/grafana)
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 14 (L482500), Rulings 6/7/9; источники —
compose.dev.yml (эталон), T13 (mTLS). Проект НЕ git. **Docker-движок выключен**: docker up/down и живые
подъёмы не выполнялись; всё живое — ⚠ Manual и помечено ниже. Проверено без движка: сборки всех sln 0/0,
тесты PASS, `docker compose -f deploy/compose.prod.yml config` rc=0 (CLI compose v5.3.1, config — без движка),
JSON/YAML-валидация файлов observability.
## Решения
- **Serilog, а не встроенный JSON-консоль**: пакет `Serilog.AspNetCore 10.0.0` (net10.0) ставится
безболезненно — версия и весь transitive-closure (Serilog 4.3.1, Extensions.Hosting/Logging,
Formatting.Compact, Settings.Configuration, Sinks.Console/Debug/File) уже в локальном NuGet-кэше
(LeadRadar-стек на той же машине); restore офлайн, новые пакеты не тянут сеть. Это соответствует
Ruling 7 (Serilog во всех 4 процессах). Добавлен ОДИН PackageReference на хост — консоль/файл/формат
приходят транзитивно.
- **Конфигурация кодом, а не секцией appsettings**: у трёх сервисов appsettings.json нет (весь конфиг —
env, Ruling 13); единый код-набор с env-переопределениями (`DEAL_LOG_LEVEL`, `DEAL_LOGS_DIR`) не
расходится между процессами (зафиксировано в XML-doc DealLogging). Консоль — **CompactJsonFormatter**
(одна JSON-строка на событие, `@t/@mt/@l`; в Development — текстовая разметка, «dev можно текст»),
rolling-файл `data/logs/deal-<процесс>.json` (RollingInterval.Day, 30 файлов). Правило «секреты не
логируются» (Ruling 13): access-логи пишут метод/путь/статус без query/заголовков/тел.
- **Запрос-логирование — «базово» без новых пакетов и без OTel** (OTel-метрики/Prometheus задекларированы
вне этапа, Ruling 7): HTTP-запросы core — `HttpAccessLogMiddleware` (метод/путь/статус/мс, одна строка;
SSE логируется по завершении потока; исключение — строка + rethrow); RPC четырёх gRPC-поверхностей
(3 сервиса + ингресс core :5082) — `RpcCallLoggingInterceptor` (метод/grpc-статус/мс; gRPC-health не
логируется — пробы каждые ~5 с; HTTP-слой ингресса middleware пропускает по Content-Type
application/grpc — HTTP-статус gRPC всегда 200). Только unary: все Deal-RPC unary (как
ServiceTokenInterceptor). Интерцептор зарегистрирован первым в цепочке AddGrpc — видны и отказы 401/429.
- **Место конфигурации логирования — production-точка входа, не Host.Create**: у трёх сервисов
`*ServiceHost.Create` получил опциональный хук `Action<WebApplicationBuilder>? configureBuilder`
(по образцу существующего seam'а configureServices), Program.cs вызывает
`DealLogging.Configure(builder, "telegram|ai|ml")`. Интеграционные тесты хост поднимают БЕЗ хука —
тесты не пишут файлы-логи и не меняют своё логирование (внутри хостов остались только access-логи
интерцептора, это штатный вывод).
- **grpc_health_probe под mTLS**: healthcheck'и compose.prod — `CMD-SHELL`-ветвление по runtime-env
`DEAL_MTLS_ENABLED`: 0/пусто — plaintext-проба как в dev; 1 — TLS-проба `-tls -tls-ca-cert -tls-client-cert
-tls-client-key -tls-server-name=localhost`. grpc_health_probe принимает только PEM, поэтому
`scripts/mtls-certs.sh` дополнен экспортом `deal-client.crt`/`deal-client.key` (chmod 600) из общего
deal-client.pfx; скрипт перегенерирован на хосте — `deploy/certs/` теперь полный (ca.pem/ca.key + 5 PFX +
PEM-пара клиента). Ветка TLS требует смены схем endpoint'ов на https:// (`.env.prod.example`,
замечание task-13-report) — в compose закомментировано/задокументировано.
- **compose.prod**: секреты fail-fast `${VAR:?...}` из `.env.prod` (шаблон без дефолтных паролей);
observability — ПРОФИЛЬ `observability` (loki/promtail/grafana не поднимаются без `--profile`).
## Состав
**Логи — Serilog (Ruling 7), по файлу на хост (1 тип = 1 файл, XML-doc, константы):**
- `DealLogging.cs` ×4 (копии шаблона: у сервисов свои sln — как MtlsOptions, Task 13): `Deal.Api/Logging/`,
`Deal.Telegram/`, `Deal.Ai/`, `Deal.Ml/`. `Configure(builder, processName)``builder.Host.UseSerilog(...)`
(отложенно, при Build): `MinimumLevel.Is(DEAL_LOG_LEVEL или Information)` + override
`Grpc`→Information (+ core: `Microsoft.EntityFrameworkCore`→Warning), `Enrich.FromLogContext()`,
rolling-файл `data/logs/deal-<имя>-.json` (30 дней), консоль JSON (не-dev)/текст (Development).
- `RpcCallLoggingInterceptor.cs` ×4: core `Deal.Api/Telegram/` (ингресс), `Deal.Telegram/Deal.Ai/Deal.Ml`
(сервисы). Регистрация первой в AddGrpc (Program.cs core; Host.Create сервисов).
- `Deal.Api/Middleware/HttpAccessLogMiddleware.cs` — access-лог HTTP core (первый в конвейере после
UseForwardedHeaders; gRPC-ингресс пропускает).
- csproj'ы 4 хостов: `<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />`.
- Program.cs 4 хостов: core — вызов `DealLogging.Configure(builder, "core")`; сервисы — вызов через
configureBuilder-хук (processName `telegram`/`ai`/`ml`).
**PROD-деплой (Rulings 6/9):**
- `deploy/compose.prod.yml` — README-шапка (состав, запуск с `--env-file`, fail-fast, mTLS-инструкция,
логи, frontend-сборка) + сервисы: postgres/minio (без host-портов, volume'ы), core (:5080+:5082,
PROD-флаги RateLimit/куки-Secure/ForwardedHeaders-KnownNetworks 172.16.0.0/12/Security:AllowedOrigins,
MinIO, mTLS env, volume /app/data + монтирование deploy/certs в /etc/deal/certs:ro), telegram-service
(:5101, сессии /data/sessions), ai-service (:5102), ml-service (:5103, /data/ml), caddy (:80/:443,
depends_on core healthy; статика ../src/frontend/dist:/srv:ro + /data /config volume'ы), профиль
observability: loki (3.4.2) + promtail (3.4.2, docker.sock:ro, positions на volume) + grafana
(11.5.2, `127.0.0.1:3001:3000`, provisioning+dashboards volume'ы). restart: unless-stopped у всех.
Healthcheck'и grpc_health_probe (core — 10s/retries 10/start 15s, сервисы — 5s), postgres — pg_isready.
- `deploy/caddy/Caddyfile``https://deal.example` (плейсхолдер; комментарий: домен → убрать
`tls internal`/Cloudflare-origin), security-заголовки (nosniff/X-Frame-Options: DENY/Referrer-Policy),
CSP/HSTS — закомментированы-заготовки (Ruling 10(3): nonce-механика Vue), `handle /api/*`
`reverse_proxy core:5080`, статика `/srv` с SPA-fallback (try_files → /index.html).
- `deploy/observability/promtail.yml` — docker_sd (docker.sock), relabel container/service (compose-метка)
/stream; `deploy/observability/loki.yml` — single-binary, filesystem, tsdb, retention 168h (compactor
retention_enabled); `deploy/observability/grafana/provisioning/datasources/datasources.yml` (Loki),
`provisioning/dashboards/dashboards.yml` (папка «Дейл»), `dashboards/Deal-Health.json` (минимальный:
активность логов 4 процессов, Error/Fatal по процессам, счётчик ошибок за 5м — Ruling 7: дашборды по
логам/health, без коммерческих плагинов).
- `deploy/.env.prod.example` — все секреты пустые (без значений-дефолтов; fail-fast через `:?` в compose);
несекретные дефолты и mTLS/endpoint-инструкция комментариями.
**Скрипт:** `scripts/mtls-certs.sh` — дополнен PEM-экспортом клиентского сертификата для probe
(шапка/rm-список/вывод обновлены); `sh -n` rc=0; скрипт прогнан — `deploy/certs/` полный набор.
**Доки (кратко, по заданию; полная актуализация §7/§8/§9/§10 и api-map — Task 16):**
- техдок: заголовок §13 → «этапов 6–7»; новый блок `§13.8 «Этап 7 — SaaS-контур»` (оператор/инвайты/
join/лимиты/аудит/rate-limit/mTLS/логи/compose.prod/быстрый сценарий оператора); §11 — блок
«Выполнено на этапе 7 (код, Tasks 1–14)» + обновлённый TODO (Task 15/16, Manual, заделы).
- roadmap: этап 7 в «Выполнено» (код Tasks 1–14; остались Task 15/16; Manual-пункты отдельно),
блок «Оставшиеся этапы» → «Этап 8+» (заделы).
- Ledger `.superpowers/sdd/deal-stage7-saas/progress.md`: Todos 12–14 отмечены, статусы Tasks 1214.
## Проверки
- `dotnet build` всех sln (Deal.sln, Deal.Telegram.sln, Deal.Ai.sln, Deal.Ml.sln) — 0 warnings / 0 errors
(TreatWarningsAsErrors), включая новые пакеты/файлы.
- `dotnet test`: core **1123/1123 PASS**, telegram 114/114, ai 50/50, ml 36/36 — все PASS.
- `docker compose -f deploy/compose.prod.yml config` — rc=0 (значения env подставлены inline; fail-fast
`:?` срабатывает при пустых секретах — проверено), в т.ч. `--profile observability` и вариант
`DEAL_MTLS_ENABLED=1` + https-endpoint'ы — rc=0.
- YAML observability (promtail/loki/grafana provisioning) и JSON дашборда Deal-Health.json —
провалидированы (python yaml/json).
- `sh -n scripts/mtls-certs.sh` rc=0; прогон скрипта — полный набор файлов в deploy/certs (PFX ×5 +
ca.pem/ca.key + deal-client.crt/key).
## Manual (живое — не запускалось, docker off)
- Старт процессов (dev, без docker): JSON/текст-консоль Serilog и появление rolling-файла
`data/logs/deal-*.json`. У core Development (launchSettings) → консоль текст, файл JSON всегда; PROD
(compose) — JSON-консоль (docker-логи). Acceptance Task 14 «старт Api показывает JSON-логи» — этим
пунктом.
- Живой подъём `compose.prod.yml` (+ профиль observability: promtail→loki→grafana, дашборд Deal-Health),
`caddy validate` Caddyfile, mTLS-рукопожатие контейнеров и TLS-ветка healthcheck'ей.
- Сервисные интеграционные прогоны под Serilog-хуком (конфигурация вызывается только из Program.cs —
in-proc тесты её не покрывают; эквивалент — код-ревью + сборки).
## Concerns
- **Caddyfile и observability-конфиги валидированы синтаксически (YAML/JSON/compose), но не «живым»
инструментом** (`caddy validate`, promtail/loki `-verify-config`) — инструментов/движка нет; образы
(caddy:2.9.1, loki/promtail:3.4.2, grafana:11.5.2) при подъёме стоит обновить до актуальных patch.
- **Dashboard-запросы Loki** завязаны на компакт-формат Serilog (`"@l":"Error"` регэкспом по сырой строке)
и метку `service` из compose (relabel promtail) — при смене формата/меток править Deal-Health.json.
- **Serilog-дублирование консоли**: `UseSerilog` заменяет провайдеры Microsoft (стандартное поведение
Serilog.AspNetCore); живого старта не было — при первом прогоне проверить отсутствие двойных строк.
- **mTLS-ветка healthcheck'ей** требует PEM-артефактов (`deal-client.crt/.key`) и смены схем endpoint'ов на
https:// — и то и другое задокументировано (скрипт/шапка compose/.env.prod.example); конфиг в обоих
режимах rc=0.
- **Access-логи интерцептора пишутся и в интеграционных тестах сервисов** (регистрация в Host.Create) —
штатный вывод в stdout тестов, на результат не влияет (прогоны PASS).
- Доки §7/§8/§9/§10 и api-map «Этап 7» остаются на Task 16 (полная актуализация); здесь — §13.8/§11/roadmap
кратко, как указано в задании.
## Файлы
Создано: `DealLogging.cs` ×4 (Deal.Api/Logging, Deal.Telegram, Deal.Ai, Deal.Ml);
`RpcCallLoggingInterceptor.cs` ×4 (Deal.Api/Telegram, Deal.Telegram, Deal.Ai, Deal.Ml);
`Deal.Api/Middleware/HttpAccessLogMiddleware.cs`; `deploy/compose.prod.yml`; `deploy/caddy/Caddyfile`;
`deploy/.env.prod.example`; `deploy/observability/{promtail.yml,loki.yml}`;
`deploy/observability/grafana/provisioning/{datasources/datasources.yml,dashboards/dashboards.yml}`;
`deploy/observability/grafana/dashboards/Deal-Health.json`; `task-14-report.md`.
Изменено: csproj'ы 4 хостов (+Serilog.AspNetCore 10.0.0); Program.cs ×4 (core — вызов DealLogging/
middleware/интерцептор ингресса; сервисы — configureBuilder-хук); TelegramServiceHost/AiServiceHost/
MlServiceHost (хук configureBuilder + регистрация интерцептора); `scripts/mtls-certs.sh` (PEM-экспорт
deal-client); deploy/certs (перегенерированы скриптом — полный набор);
`docs/technical/Техническая-документация-Дейл.md` (§13.8 + §11); roadmap; progress.md.
## Fix-раздел (ревью Task 14)
- **Important — uid датасорса Loki**: в `deploy/observability/grafana/provisioning/datasources/datasources.yml`
добавлен фиксированный `uid: loki` — панели `Deal-Health.json` ссылаются на datasource `uid: loki`
(иначе Grafana сгенерировала бы другой uid и дашборд не подхватил бы Loki). Проверено: uid в
datasources.yml == uid в панелях дашборда; provisioning-dashboards (`dashboards.yml`) ссылается на
каталог, а не на uid, — согласовано.
- **Minor — тег minio**: в `deploy/compose.prod.yml` образ закреплён `minio/minio:RELEASE.2025-04-22T22-12-26Z`
(комментарий про обновление), как у остальных образов.
- Перепроверка: `docker compose --profile observability -f deploy/compose.prod.yml config` rc=0;
сборки не требовались (изменены только конфиги).
# Task 14 report — Observability (Serilog JSON) + compose.prod (Caddy + promtail/loki/grafana)
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 14 (L482500), Rulings 6/7/9; источники —
compose.dev.yml (эталон), T13 (mTLS). Проект НЕ git. **Docker-движок выключен**: docker up/down и живые
подъёмы не выполнялись; всё живое — ⚠ Manual и помечено ниже. Проверено без движка: сборки всех sln 0/0,
тесты PASS, `docker compose -f deploy/compose.prod.yml config` rc=0 (CLI compose v5.3.1, config — без движка),
JSON/YAML-валидация файлов observability.
## Решения
- **Serilog, а не встроенный JSON-консоль**: пакет `Serilog.AspNetCore 10.0.0` (net10.0) ставится
безболезненно — версия и весь transitive-closure (Serilog 4.3.1, Extensions.Hosting/Logging,
Formatting.Compact, Settings.Configuration, Sinks.Console/Debug/File) уже в локальном NuGet-кэше
(LeadRadar-стек на той же машине); restore офлайн, новые пакеты не тянут сеть. Это соответствует
Ruling 7 (Serilog во всех 4 процессах). Добавлен ОДИН PackageReference на хост — консоль/файл/формат
приходят транзитивно.
- **Конфигурация кодом, а не секцией appsettings**: у трёх сервисов appsettings.json нет (весь конфиг —
env, Ruling 13); единый код-набор с env-переопределениями (`DEAL_LOG_LEVEL`, `DEAL_LOGS_DIR`) не
расходится между процессами (зафиксировано в XML-doc DealLogging). Консоль — **CompactJsonFormatter**
(одна JSON-строка на событие, `@t/@mt/@l`; в Development — текстовая разметка, «dev можно текст»),
rolling-файл `data/logs/deal-<процесс>.json` (RollingInterval.Day, 30 файлов). Правило «секреты не
логируются» (Ruling 13): access-логи пишут метод/путь/статус без query/заголовков/тел.
- **Запрос-логирование — «базово» без новых пакетов и без OTel** (OTel-метрики/Prometheus задекларированы
вне этапа, Ruling 7): HTTP-запросы core — `HttpAccessLogMiddleware` (метод/путь/статус/мс, одна строка;
SSE логируется по завершении потока; исключение — строка + rethrow); RPC четырёх gRPC-поверхностей
(3 сервиса + ингресс core :5082) — `RpcCallLoggingInterceptor` (метод/grpc-статус/мс; gRPC-health не
логируется — пробы каждые ~5 с; HTTP-слой ингресса middleware пропускает по Content-Type
application/grpc — HTTP-статус gRPC всегда 200). Только unary: все Deal-RPC unary (как
ServiceTokenInterceptor). Интерцептор зарегистрирован первым в цепочке AddGrpc — видны и отказы 401/429.
- **Место конфигурации логирования — production-точка входа, не Host.Create**: у трёх сервисов
`*ServiceHost.Create` получил опциональный хук `Action<WebApplicationBuilder>? configureBuilder`
(по образцу существующего seam'а configureServices), Program.cs вызывает
`DealLogging.Configure(builder, "telegram|ai|ml")`. Интеграционные тесты хост поднимают БЕЗ хука —
тесты не пишут файлы-логи и не меняют своё логирование (внутри хостов остались только access-логи
интерцептора, это штатный вывод).
- **grpc_health_probe под mTLS**: healthcheck'и compose.prod — `CMD-SHELL`-ветвление по runtime-env
`DEAL_MTLS_ENABLED`: 0/пусто — plaintext-проба как в dev; 1 — TLS-проба `-tls -tls-ca-cert -tls-client-cert
-tls-client-key -tls-server-name=localhost`. grpc_health_probe принимает только PEM, поэтому
`scripts/mtls-certs.sh` дополнен экспортом `deal-client.crt`/`deal-client.key` (chmod 600) из общего
deal-client.pfx; скрипт перегенерирован на хосте — `deploy/certs/` теперь полный (ca.pem/ca.key + 5 PFX +
PEM-пара клиента). Ветка TLS требует смены схем endpoint'ов на https:// (`.env.prod.example`,
замечание task-13-report) — в compose закомментировано/задокументировано.
- **compose.prod**: секреты fail-fast `${VAR:?...}` из `.env.prod` (шаблон без дефолтных паролей);
observability — ПРОФИЛЬ `observability` (loki/promtail/grafana не поднимаются без `--profile`).
## Состав
**Логи — Serilog (Ruling 7), по файлу на хост (1 тип = 1 файл, XML-doc, константы):**
- `DealLogging.cs` ×4 (копии шаблона: у сервисов свои sln — как MtlsOptions, Task 13): `Deal.Api/Logging/`,
`Deal.Telegram/`, `Deal.Ai/`, `Deal.Ml/`. `Configure(builder, processName)``builder.Host.UseSerilog(...)`
(отложенно, при Build): `MinimumLevel.Is(DEAL_LOG_LEVEL или Information)` + override
`Grpc`→Information (+ core: `Microsoft.EntityFrameworkCore`→Warning), `Enrich.FromLogContext()`,
rolling-файл `data/logs/deal-<имя>-.json` (30 дней), консоль JSON (не-dev)/текст (Development).
- `RpcCallLoggingInterceptor.cs` ×4: core `Deal.Api/Telegram/` (ингресс), `Deal.Telegram/Deal.Ai/Deal.Ml`
(сервисы). Регистрация первой в AddGrpc (Program.cs core; Host.Create сервисов).
- `Deal.Api/Middleware/HttpAccessLogMiddleware.cs` — access-лог HTTP core (первый в конвейере после
UseForwardedHeaders; gRPC-ингресс пропускает).
- csproj'ы 4 хостов: `<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />`.
- Program.cs 4 хостов: core — вызов `DealLogging.Configure(builder, "core")`; сервисы — вызов через
configureBuilder-хук (processName `telegram`/`ai`/`ml`).
**PROD-деплой (Rulings 6/9):**
- `deploy/compose.prod.yml` — README-шапка (состав, запуск с `--env-file`, fail-fast, mTLS-инструкция,
логи, frontend-сборка) + сервисы: postgres/minio (без host-портов, volume'ы), core (:5080+:5082,
PROD-флаги RateLimit/куки-Secure/ForwardedHeaders-KnownNetworks 172.16.0.0/12/Security:AllowedOrigins,
MinIO, mTLS env, volume /app/data + монтирование deploy/certs в /etc/deal/certs:ro), telegram-service
(:5101, сессии /data/sessions), ai-service (:5102), ml-service (:5103, /data/ml), caddy (:80/:443,
depends_on core healthy; статика ../src/frontend/dist:/srv:ro + /data /config volume'ы), профиль
observability: loki (3.4.2) + promtail (3.4.2, docker.sock:ro, positions на volume) + grafana
(11.5.2, `127.0.0.1:3001:3000`, provisioning+dashboards volume'ы). restart: unless-stopped у всех.
Healthcheck'и grpc_health_probe (core — 10s/retries 10/start 15s, сервисы — 5s), postgres — pg_isready.
- `deploy/caddy/Caddyfile``https://deal.example` (плейсхолдер; комментарий: домен → убрать
`tls internal`/Cloudflare-origin), security-заголовки (nosniff/X-Frame-Options: DENY/Referrer-Policy),
CSP/HSTS — закомментированы-заготовки (Ruling 10(3): nonce-механика Vue), `handle /api/*`
`reverse_proxy core:5080`, статика `/srv` с SPA-fallback (try_files → /index.html).
- `deploy/observability/promtail.yml` — docker_sd (docker.sock), relabel container/service (compose-метка)
/stream; `deploy/observability/loki.yml` — single-binary, filesystem, tsdb, retention 168h (compactor
retention_enabled); `deploy/observability/grafana/provisioning/datasources/datasources.yml` (Loki),
`provisioning/dashboards/dashboards.yml` (папка «Дейл»), `dashboards/Deal-Health.json` (минимальный:
активность логов 4 процессов, Error/Fatal по процессам, счётчик ошибок за 5м — Ruling 7: дашборды по
логам/health, без коммерческих плагинов).
- `deploy/.env.prod.example` — все секреты пустые (без значений-дефолтов; fail-fast через `:?` в compose);
несекретные дефолты и mTLS/endpoint-инструкция комментариями.
**Скрипт:** `scripts/mtls-certs.sh` — дополнен PEM-экспортом клиентского сертификата для probe
(шапка/rm-список/вывод обновлены); `sh -n` rc=0; скрипт прогнан — `deploy/certs/` полный набор.
**Доки (кратко, по заданию; полная актуализация §7/§8/§9/§10 и api-map — Task 16):**
- техдок: заголовок §13 → «этапов 6–7»; новый блок `§13.8 «Этап 7 — SaaS-контур»` (оператор/инвайты/
join/лимиты/аудит/rate-limit/mTLS/логи/compose.prod/быстрый сценарий оператора); §11 — блок
«Выполнено на этапе 7 (код, Tasks 1–14)» + обновлённый TODO (Task 15/16, Manual, заделы).
- roadmap: этап 7 в «Выполнено» (код Tasks 1–14; остались Task 15/16; Manual-пункты отдельно),
блок «Оставшиеся этапы» → «Этап 8+» (заделы).
- Ledger `.superpowers/sdd/deal-stage7-saas/progress.md`: Todos 12–14 отмечены, статусы Tasks 1214.
## Проверки
- `dotnet build` всех sln (Deal.sln, Deal.Telegram.sln, Deal.Ai.sln, Deal.Ml.sln) — 0 warnings / 0 errors
(TreatWarningsAsErrors), включая новые пакеты/файлы.
- `dotnet test`: core **1123/1123 PASS**, telegram 114/114, ai 50/50, ml 36/36 — все PASS.
- `docker compose -f deploy/compose.prod.yml config` — rc=0 (значения env подставлены inline; fail-fast
`:?` срабатывает при пустых секретах — проверено), в т.ч. `--profile observability` и вариант
`DEAL_MTLS_ENABLED=1` + https-endpoint'ы — rc=0.
- YAML observability (promtail/loki/grafana provisioning) и JSON дашборда Deal-Health.json —
провалидированы (python yaml/json).
- `sh -n scripts/mtls-certs.sh` rc=0; прогон скрипта — полный набор файлов в deploy/certs (PFX ×5 +
ca.pem/ca.key + deal-client.crt/key).
## Manual (живое — не запускалось, docker off)
- Старт процессов (dev, без docker): JSON/текст-консоль Serilog и появление rolling-файла
`data/logs/deal-*.json`. У core Development (launchSettings) → консоль текст, файл JSON всегда; PROD
(compose) — JSON-консоль (docker-логи). Acceptance Task 14 «старт Api показывает JSON-логи» — этим
пунктом.
- Живой подъём `compose.prod.yml` (+ профиль observability: promtail→loki→grafana, дашборд Deal-Health),
`caddy validate` Caddyfile, mTLS-рукопожатие контейнеров и TLS-ветка healthcheck'ей.
- Сервисные интеграционные прогоны под Serilog-хуком (конфигурация вызывается только из Program.cs —
in-proc тесты её не покрывают; эквивалент — код-ревью + сборки).
## Concerns
- **Caddyfile и observability-конфиги валидированы синтаксически (YAML/JSON/compose), но не «живым»
инструментом** (`caddy validate`, promtail/loki `-verify-config`) — инструментов/движка нет; образы
(caddy:2.9.1, loki/promtail:3.4.2, grafana:11.5.2) при подъёме стоит обновить до актуальных patch.
- **Dashboard-запросы Loki** завязаны на компакт-формат Serilog (`"@l":"Error"` регэкспом по сырой строке)
и метку `service` из compose (relabel promtail) — при смене формата/меток править Deal-Health.json.
- **Serilog-дублирование консоли**: `UseSerilog` заменяет провайдеры Microsoft (стандартное поведение
Serilog.AspNetCore); живого старта не было — при первом прогоне проверить отсутствие двойных строк.
- **mTLS-ветка healthcheck'ей** требует PEM-артефактов (`deal-client.crt/.key`) и смены схем endpoint'ов на
https:// — и то и другое задокументировано (скрипт/шапка compose/.env.prod.example); конфиг в обоих
режимах rc=0.
- **Access-логи интерцептора пишутся и в интеграционных тестах сервисов** (регистрация в Host.Create) —
штатный вывод в stdout тестов, на результат не влияет (прогоны PASS).
- Доки §7/§8/§9/§10 и api-map «Этап 7» остаются на Task 16 (полная актуализация); здесь — §13.8/§11/roadmap
кратко, как указано в задании.
## Файлы
Создано: `DealLogging.cs` ×4 (Deal.Api/Logging, Deal.Telegram, Deal.Ai, Deal.Ml);
`RpcCallLoggingInterceptor.cs` ×4 (Deal.Api/Telegram, Deal.Telegram, Deal.Ai, Deal.Ml);
`Deal.Api/Middleware/HttpAccessLogMiddleware.cs`; `deploy/compose.prod.yml`; `deploy/caddy/Caddyfile`;
`deploy/.env.prod.example`; `deploy/observability/{promtail.yml,loki.yml}`;
`deploy/observability/grafana/provisioning/{datasources/datasources.yml,dashboards/dashboards.yml}`;
`deploy/observability/grafana/dashboards/Deal-Health.json`; `task-14-report.md`.
Изменено: csproj'ы 4 хостов (+Serilog.AspNetCore 10.0.0); Program.cs ×4 (core — вызов DealLogging/
middleware/интерцептор ингресса; сервисы — configureBuilder-хук); TelegramServiceHost/AiServiceHost/
MlServiceHost (хук configureBuilder + регистрация интерцептора); `scripts/mtls-certs.sh` (PEM-экспорт
deal-client); deploy/certs (перегенерированы скриптом — полный набор);
`docs/technical/Техническая-документация-Дейл.md` (§13.8 + §11); roadmap; progress.md.
## Fix-раздел (ревью Task 14)
- **Important — uid датасорса Loki**: в `deploy/observability/grafana/provisioning/datasources/datasources.yml`
добавлен фиксированный `uid: loki` — панели `Deal-Health.json` ссылаются на datasource `uid: loki`
(иначе Grafana сгенерировала бы другой uid и дашборд не подхватил бы Loki). Проверено: uid в
datasources.yml == uid в панелях дашборда; provisioning-dashboards (`dashboards.yml`) ссылается на
каталог, а не на uid, — согласовано.
- **Minor — тег minio**: в `deploy/compose.prod.yml` образ закреплён `minio/minio:RELEASE.2025-04-22T22-12-26Z`
(комментарий про обновление), как у остальных образов.
- Перепроверка: `docker compose --profile observability -f deploy/compose.prod.yml config` rc=0;
сборки не требовались (изменены только конфиги).
@@ -1,128 +1,128 @@
# Task 15 report — Бэкапы: scripts/backup.sh + restore.sh + документация
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 15 (L502514), Ruling 8 (L171181);
источники — compose.dev.yml/compose.prod.yml (имена контейнеров/томов/creds), техдок §9/§11/§13.
Проект НЕ git. **Docker выключен**: живой прогон backup.sh и restore-тест — ⚠ Manual (см. ниже).
Проверено без движка: `sh -n`/`bash -n` всех трёх скриптов rc=0; error-path-прогоны (rc=1 + понятные
сообщения + лог-файлы); retention-логика прогнана офлайн на синтетических снапшотах.
## Решения
- **Два скрипта + общая либа** (один тип = один файл): `scripts/backup.sh`, `scripts/restore.sh`
(зеркальные шаги), `scripts/deal-backup-lib.sh` — env-дефолты и хелперы (поиск контейнеров,
выбор/запуск mc, die/log/trap). `set -euo pipefail`; shebang bash; синтаксис совместим с `sh -n`.
- **Postgres (источник 1)** — `pg_dump -Fc` (custom, сжатие) всей БД `deal` (public + tenant_*) →
`$BACKUP_DIR/pg/backup-YYYYMMDD-HHMMSS.dump`. Дефолт: `docker exec deal-postgres` (без пароля —
локальный socket; паттерн §13.5). Docker-контейнер ищется по env `DEAL_PG_CONTAINER` → имени
`deal-postgres` → compose-метке сервиса `postgres` (compose.prod БЕЗ container_name — покрыто).
Альтернатива: задан `DEAL_PG_HOST` → прямое `pg_dump` (PGPASSWORD, в лог не светится).
Имя env-секрета `DEAL_PG_PASSWORD` совпадает с compose.prod.
- **MinIO (источник 2)** — `mc mirror` бакета `deal-files``$BACKUP_DIR/minio/backup-<TS>/`
(бэкап = выгрузка ИЗ MinIO). Режим mc: хостовый клиент `mc` (есть в PATH) → иначе разовый контейнер
`minio/mc` (`DEAL_MC_IMAGE`; тег НЕ захардкожен — в проде фиксируется env, комментарий в либе с
примером) в docker-сети контейнера MinIO (обнаружение как у PG). Endpoint по умолчанию: docker-режим —
`http://minio:9000` (алиас compose-сервиса — есть и в compose.dev, и в compose.prod); host-режим —
`http://localhost:9000` (dev, опубликованный порт). Нестандартная схема — `DEAL_MINIO_ENDPOINT`.
Секреты — env-алиасом `MC_HOST_deal`
(в mc-конфиг не пишутся, не логируются); прод-fallback имён `MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD`.
Local-режим без MinIO — `DEAL_MINIO_SKIP=1` (шаг с warning, rc остаётся 0).
- **Файловые данные (источники 3+4)** — tar в `$BACKUP_DIR/data/backup-<TS>.tar.gz`. Host-режим:
каталоги `DEAL_TAR_DIRS` (дефолт `attachments ml telegram_sessions` — фактические имена в `data/`;
контейнерный путь сессий `/data/sessions`) внутри `DEAL_DATA_DIR`; отсутствующие — warning-пропуск.
Docker-volume'ы (Ruling 8) — `DEAL_TAR_VOLUMES`: busybox-контейнер тарит каждый том
(`backup-<TS>.<volume>.tar.gz`). В archive попадает и encryption.key/attachments core-data при указании
тома `deal_api_data`. BACKUP_DIR по умолчанию `data/backups` — НЕ внутри тарируемых каталогов.
- **Retention (источник 4 по списку Ruling 8)** — удаление по дате `YYYYMMDD` из имени (как просил
Task 15, а не `find -mtime`): cutoff = today `RETENTION_DAYS` (GNU `date -d`; при недоступности —
warning и пропуск, прогон не валит). Проверено: граничный день (14-й) хранится, старше — удаляются;
при ежедневном запуске ~15 копий (эквивалент `find -mtime +14`). Дефолт 14 (env `RETENTION_DAYS`).
- **Безопасность/качество** — trap-очистка только `.part`-артефактов текущего прогона; лог — консоль +
`$BACKUP_DIR/logs/backup|restore-YYYYMM.log` через `tee` (pipefail сохраняет rc); секреты не логируются;
имена файлов `backup-YYYYMMDD-HHMMSS.*` (Ruling 8); понятные die-сообщения; валидация `RETENTION_DAYS`
и TS; «не запускать параллельно» — в шапке.
- **restore.sh** — шаги `all|pg|minio|data [TS]` (TS из аргумента или самый свежий pg-снапшот; для
отдельных шагов — самый свежий своего рода). pg (docker): `docker cp``dropdb --if-exists` +
`createdb``pg_restore --no-owner --exit-on-error` (БД пересоздаётся целиком — консистентный снимок
схем; core должен быть остановлен — сообщение об этом при ошибке dropdb); pg (DEAL_PG_HOST):
`pg_restore --clean --if-exists --exit-on-error` (overlay поверх существующей БД). minio: обратный
`mc mirror --overwrite` (+ `--remove` при `DEAL_MINIO_MIRROR_REMOVE=1`); overlay-warning
(лишние объекты не удаляются) — в шапке и §13.9.
data: распаковка в `DEAL_DATA_DIR` или в volume'ы (busybox). Скрипт сервисы НЕ останавливает — порядок
(stop → restore → start) документирован в шапке и §13.9.
- **Документация** — техдок §13.9 (новый; команды, cron «0 2 * * *» + systemd-таймер, retention,
что входит/не входит, порядок восстановления, env-таблица-сводка, prod-пример) и §11 (Task 15 закрыт:
заголовок «Tasks 1–15», буллет бэкапов, TODO — только Task 16 + Manual). Техдок §9 (детальный
restore-раздел) — осознанно в Task 16 по плану (L507508); §13.9 ссылается на это.
## Состав
- `scripts/backup.sh` — ежедневный бэкап (заголовок с cron/systemd-примерами; шаги pg/minio/data +
retention; trap; лог; rc 0/1).
- `scripts/restore.sh` — восстановление (all|pg|minio|data [TS]; зеркальные env).
- `scripts/deal-backup-lib.sh` — общие env-дефолты + хелперы (log/die/docker-ok/container_running/
compose_container/resolve_pg_container/resolve_minio_container/minio_network/select_mc_mode/mc_cmd).
- `docs/technical/Техническая-документация-Дейл.md` — §11 (этап 7 «Tasks 1–15», бэкапы реализованы),
§13.9 «Бэкапы и восстановление».
- `.superpowers/sdd/deal-stage7-saas/progress.md` — строка Task 15.
## Проверки (выполнено, без docker)
- `sh -n scripts/backup.sh` rc=0; `sh -n scripts/restore.sh` rc=0; `sh -n scripts/deal-backup-lib.sh` rc=0;
`bash -n` всех трёх rc=0.
- Error-path (docker off, временный BACKUP_DIR): `backup.sh` → rc=1 «Docker недоступен и DEAL_PG_HOST
не задан…»; `RETENTION_DAYS=abc` → rc=1 «должно быть целым числом»; `restore.sh nope` → usage + rc=1;
`restore.sh pg 20269999-123456` → «дамп не найден»; `restore.sh` (без дампов) → «нет дампов…».
Лог-файлы создаются, `.part`-артефакты не остаются (trap).
- Retention-логика офлайн: cutoff верный (today−14), удалены только снапшоты со «старой» датой в имени,
граничный день сохранён (kept=4/deleted=2 на синтетике).
- Сборки/тесты .NET не нужны (изменений кода нет).
## Manual (живое — не запускалось, docker off)
- Реальный прогон `scripts/backup.sh` на поднятом dev/prod-стеке (pg_dump, mc mirror, busybox-tar томов).
- Restore-тест (dropdb/createdb → pg_restore, обратный mirror, распаковка; «0 2 * * *»-сценарий,
ежемесячный тест на отдельном инстансе).
- Проверка docker-run mc/busybox (pull образов, сеть контейнера, bind `BACKUP_DIR`) и docker-томов
(`DEAL_TAR_VOLUMES`, имена `deploy_deal_*` — зависят от compose-проекта).
## Concerns
- `DEAL_TAR_VOLUMES`-режим предполагает известные имена docker-томов (префикс compose-проекта —
обычно `deploy_`); авто-обнаружение томов по контейнерам не делал (scope Task 15) — подсказка
`docker volume ls | grep deal_` в доке.
- mc/busybox-образы тянутся из registry при первом docker-run (на проде зафиксируйте `DEAL_MC_IMAGE`).
- Хостовый tar покрывает host-режим; полностью-docker-деплой файлов — только через `DEAL_TAR_VOLUMES`
(задокументировано). Retry/частичные сбои mc-шага оставляют снапшот дня пропущенным (не ложный успех).
- Прямой pg_restore (DEAL_PG_HOST) использует `--clean --if-exists` без пересоздания БД — семантика чуть
мягче docker-пути (документировано в шапке restore.sh).
## Файлы
- `scripts/backup.sh` (new), `scripts/restore.sh` (new), `scripts/deal-backup-lib.sh` (new)
- `docs/technical/Техническая-документация-Дейл.md` (§11, §13.9)
- `.superpowers/sdd/deal-stage7-saas/progress.md` (Task 15)
## Fix-раздел (ревью Task 15)
1. **MinIO endpoint (important)** — дефолт docker-режима и примеры исправлены с
`http://deal-minio:9000` на `http://minio:9000`: compose.prod не задаёт container_name, DNS
`deal-minio` в prod-сети не существует, а `minio` — алиас compose-сервиса, резолвится и в compose.dev
(там container_name deal-minio, но сервис-алиас тоже есть — core сам ходит на `minio:9000`), и в
compose.prod. Правки: `select_mc_mode` (lib), комментарии либы, prod-пример cron в шапке backup.sh и
§13.9; host-режим остаётся `http://localhost:9000` (dev с опубликованным портом); нестандартная схема —
`DEAL_MINIO_ENDPOINT`. Дополнено: в prod порты MinIO не публикуются — хостовый mc не достанет MinIO,
нужен docker-режим (дефолт).
2. **pg_restore --exit-on-error** — добавлен в оба пути restore.sh (docker и DEAL_PG_HOST): без флага
pg_restore продолжает после ошибок и может вернуть rc=0 при частичном сбое — теперь любая ошибка
останавливает restore и даёт rc=1.
3. **bash vs sh (pipefail)** — shebang уже `#!/usr/bin/env bash` (проверено); примеры и доки
(`bash scripts/backup.sh` / `bash scripts/restore.sh`, cron/systemd) переведены с `sh …` на bash
(в шапках скриптов и §13.9) — `sh scripts/…` на dash падал бы на `set -o pipefail`.
4. **Overlay-warning** — restore minio/data дописывают поверх текущих данных (лишние объекты не
удаляются); предупреждение добавлено в шапку restore.sh и §13.9 (для бакета — `DEAL_MINIO_MIRROR_REMOVE=1`,
для каталогов/томов — ручная очистка перед распаковкой).
Перепроверено после правок: `sh -n` rc=0 и `bash -n` rc=0 (все три скрипта); error-path-прогоны
(backup без docker → rc=1 с сообщением; restore all с несуществующим TS → rc=1 «дамп не найден»);
грепом подтверждено отсутствие `deal-minio:9000` в дефолтах/примерах (остался только поясняющий
комментарий в lib). Живой прогон по-прежнему ⚠ Manual.
# Task 15 report — Бэкапы: scripts/backup.sh + restore.sh + документация
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 15 (L502514), Ruling 8 (L171181);
источники — compose.dev.yml/compose.prod.yml (имена контейнеров/томов/creds), техдок §9/§11/§13.
Проект НЕ git. **Docker выключен**: живой прогон backup.sh и restore-тест — ⚠ Manual (см. ниже).
Проверено без движка: `sh -n`/`bash -n` всех трёх скриптов rc=0; error-path-прогоны (rc=1 + понятные
сообщения + лог-файлы); retention-логика прогнана офлайн на синтетических снапшотах.
## Решения
- **Два скрипта + общая либа** (один тип = один файл): `scripts/backup.sh`, `scripts/restore.sh`
(зеркальные шаги), `scripts/deal-backup-lib.sh` — env-дефолты и хелперы (поиск контейнеров,
выбор/запуск mc, die/log/trap). `set -euo pipefail`; shebang bash; синтаксис совместим с `sh -n`.
- **Postgres (источник 1)** — `pg_dump -Fc` (custom, сжатие) всей БД `deal` (public + tenant_*) →
`$BACKUP_DIR/pg/backup-YYYYMMDD-HHMMSS.dump`. Дефолт: `docker exec deal-postgres` (без пароля —
локальный socket; паттерн §13.5). Docker-контейнер ищется по env `DEAL_PG_CONTAINER` → имени
`deal-postgres` → compose-метке сервиса `postgres` (compose.prod БЕЗ container_name — покрыто).
Альтернатива: задан `DEAL_PG_HOST` → прямое `pg_dump` (PGPASSWORD, в лог не светится).
Имя env-секрета `DEAL_PG_PASSWORD` совпадает с compose.prod.
- **MinIO (источник 2)** — `mc mirror` бакета `deal-files``$BACKUP_DIR/minio/backup-<TS>/`
(бэкап = выгрузка ИЗ MinIO). Режим mc: хостовый клиент `mc` (есть в PATH) → иначе разовый контейнер
`minio/mc` (`DEAL_MC_IMAGE`; тег НЕ захардкожен — в проде фиксируется env, комментарий в либе с
примером) в docker-сети контейнера MinIO (обнаружение как у PG). Endpoint по умолчанию: docker-режим —
`http://minio:9000` (алиас compose-сервиса — есть и в compose.dev, и в compose.prod); host-режим —
`http://localhost:9000` (dev, опубликованный порт). Нестандартная схема — `DEAL_MINIO_ENDPOINT`.
Секреты — env-алиасом `MC_HOST_deal`
(в mc-конфиг не пишутся, не логируются); прод-fallback имён `MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD`.
Local-режим без MinIO — `DEAL_MINIO_SKIP=1` (шаг с warning, rc остаётся 0).
- **Файловые данные (источники 3+4)** — tar в `$BACKUP_DIR/data/backup-<TS>.tar.gz`. Host-режим:
каталоги `DEAL_TAR_DIRS` (дефолт `attachments ml telegram_sessions` — фактические имена в `data/`;
контейнерный путь сессий `/data/sessions`) внутри `DEAL_DATA_DIR`; отсутствующие — warning-пропуск.
Docker-volume'ы (Ruling 8) — `DEAL_TAR_VOLUMES`: busybox-контейнер тарит каждый том
(`backup-<TS>.<volume>.tar.gz`). В archive попадает и encryption.key/attachments core-data при указании
тома `deal_api_data`. BACKUP_DIR по умолчанию `data/backups` — НЕ внутри тарируемых каталогов.
- **Retention (источник 4 по списку Ruling 8)** — удаление по дате `YYYYMMDD` из имени (как просил
Task 15, а не `find -mtime`): cutoff = today `RETENTION_DAYS` (GNU `date -d`; при недоступности —
warning и пропуск, прогон не валит). Проверено: граничный день (14-й) хранится, старше — удаляются;
при ежедневном запуске ~15 копий (эквивалент `find -mtime +14`). Дефолт 14 (env `RETENTION_DAYS`).
- **Безопасность/качество** — trap-очистка только `.part`-артефактов текущего прогона; лог — консоль +
`$BACKUP_DIR/logs/backup|restore-YYYYMM.log` через `tee` (pipefail сохраняет rc); секреты не логируются;
имена файлов `backup-YYYYMMDD-HHMMSS.*` (Ruling 8); понятные die-сообщения; валидация `RETENTION_DAYS`
и TS; «не запускать параллельно» — в шапке.
- **restore.sh** — шаги `all|pg|minio|data [TS]` (TS из аргумента или самый свежий pg-снапшот; для
отдельных шагов — самый свежий своего рода). pg (docker): `docker cp``dropdb --if-exists` +
`createdb``pg_restore --no-owner --exit-on-error` (БД пересоздаётся целиком — консистентный снимок
схем; core должен быть остановлен — сообщение об этом при ошибке dropdb); pg (DEAL_PG_HOST):
`pg_restore --clean --if-exists --exit-on-error` (overlay поверх существующей БД). minio: обратный
`mc mirror --overwrite` (+ `--remove` при `DEAL_MINIO_MIRROR_REMOVE=1`); overlay-warning
(лишние объекты не удаляются) — в шапке и §13.9.
data: распаковка в `DEAL_DATA_DIR` или в volume'ы (busybox). Скрипт сервисы НЕ останавливает — порядок
(stop → restore → start) документирован в шапке и §13.9.
- **Документация** — техдок §13.9 (новый; команды, cron «0 2 * * *» + systemd-таймер, retention,
что входит/не входит, порядок восстановления, env-таблица-сводка, prod-пример) и §11 (Task 15 закрыт:
заголовок «Tasks 1–15», буллет бэкапов, TODO — только Task 16 + Manual). Техдок §9 (детальный
restore-раздел) — осознанно в Task 16 по плану (L507508); §13.9 ссылается на это.
## Состав
- `scripts/backup.sh` — ежедневный бэкап (заголовок с cron/systemd-примерами; шаги pg/minio/data +
retention; trap; лог; rc 0/1).
- `scripts/restore.sh` — восстановление (all|pg|minio|data [TS]; зеркальные env).
- `scripts/deal-backup-lib.sh` — общие env-дефолты + хелперы (log/die/docker-ok/container_running/
compose_container/resolve_pg_container/resolve_minio_container/minio_network/select_mc_mode/mc_cmd).
- `docs/technical/Техническая-документация-Дейл.md` — §11 (этап 7 «Tasks 1–15», бэкапы реализованы),
§13.9 «Бэкапы и восстановление».
- `.superpowers/sdd/deal-stage7-saas/progress.md` — строка Task 15.
## Проверки (выполнено, без docker)
- `sh -n scripts/backup.sh` rc=0; `sh -n scripts/restore.sh` rc=0; `sh -n scripts/deal-backup-lib.sh` rc=0;
`bash -n` всех трёх rc=0.
- Error-path (docker off, временный BACKUP_DIR): `backup.sh` → rc=1 «Docker недоступен и DEAL_PG_HOST
не задан…»; `RETENTION_DAYS=abc` → rc=1 «должно быть целым числом»; `restore.sh nope` → usage + rc=1;
`restore.sh pg 20269999-123456` → «дамп не найден»; `restore.sh` (без дампов) → «нет дампов…».
Лог-файлы создаются, `.part`-артефакты не остаются (trap).
- Retention-логика офлайн: cutoff верный (today−14), удалены только снапшоты со «старой» датой в имени,
граничный день сохранён (kept=4/deleted=2 на синтетике).
- Сборки/тесты .NET не нужны (изменений кода нет).
## Manual (живое — не запускалось, docker off)
- Реальный прогон `scripts/backup.sh` на поднятом dev/prod-стеке (pg_dump, mc mirror, busybox-tar томов).
- Restore-тест (dropdb/createdb → pg_restore, обратный mirror, распаковка; «0 2 * * *»-сценарий,
ежемесячный тест на отдельном инстансе).
- Проверка docker-run mc/busybox (pull образов, сеть контейнера, bind `BACKUP_DIR`) и docker-томов
(`DEAL_TAR_VOLUMES`, имена `deploy_deal_*` — зависят от compose-проекта).
## Concerns
- `DEAL_TAR_VOLUMES`-режим предполагает известные имена docker-томов (префикс compose-проекта —
обычно `deploy_`); авто-обнаружение томов по контейнерам не делал (scope Task 15) — подсказка
`docker volume ls | grep deal_` в доке.
- mc/busybox-образы тянутся из registry при первом docker-run (на проде зафиксируйте `DEAL_MC_IMAGE`).
- Хостовый tar покрывает host-режим; полностью-docker-деплой файлов — только через `DEAL_TAR_VOLUMES`
(задокументировано). Retry/частичные сбои mc-шага оставляют снапшот дня пропущенным (не ложный успех).
- Прямой pg_restore (DEAL_PG_HOST) использует `--clean --if-exists` без пересоздания БД — семантика чуть
мягче docker-пути (документировано в шапке restore.sh).
## Файлы
- `scripts/backup.sh` (new), `scripts/restore.sh` (new), `scripts/deal-backup-lib.sh` (new)
- `docs/technical/Техническая-документация-Дейл.md` (§11, §13.9)
- `.superpowers/sdd/deal-stage7-saas/progress.md` (Task 15)
## Fix-раздел (ревью Task 15)
1. **MinIO endpoint (important)** — дефолт docker-режима и примеры исправлены с
`http://deal-minio:9000` на `http://minio:9000`: compose.prod не задаёт container_name, DNS
`deal-minio` в prod-сети не существует, а `minio` — алиас compose-сервиса, резолвится и в compose.dev
(там container_name deal-minio, но сервис-алиас тоже есть — core сам ходит на `minio:9000`), и в
compose.prod. Правки: `select_mc_mode` (lib), комментарии либы, prod-пример cron в шапке backup.sh и
§13.9; host-режим остаётся `http://localhost:9000` (dev с опубликованным портом); нестандартная схема —
`DEAL_MINIO_ENDPOINT`. Дополнено: в prod порты MinIO не публикуются — хостовый mc не достанет MinIO,
нужен docker-режим (дефолт).
2. **pg_restore --exit-on-error** — добавлен в оба пути restore.sh (docker и DEAL_PG_HOST): без флага
pg_restore продолжает после ошибок и может вернуть rc=0 при частичном сбое — теперь любая ошибка
останавливает restore и даёт rc=1.
3. **bash vs sh (pipefail)** — shebang уже `#!/usr/bin/env bash` (проверено); примеры и доки
(`bash scripts/backup.sh` / `bash scripts/restore.sh`, cron/systemd) переведены с `sh …` на bash
(в шапках скриптов и §13.9) — `sh scripts/…` на dash падал бы на `set -o pipefail`.
4. **Overlay-warning** — restore minio/data дописывают поверх текущих данных (лишние объекты не
удаляются); предупреждение добавлено в шапку restore.sh и §13.9 (для бакета — `DEAL_MINIO_MIRROR_REMOVE=1`,
для каталогов/томов — ручная очистка перед распаковкой).
Перепроверено после правок: `sh -n` rc=0 и `bash -n` rc=0 (все три скрипта); error-path-прогоны
(backup без docker → rc=1 с сообщением; restore all с несуществующим TS → rc=1 «дамп не найден»);
грепом подтверждено отсутствие `deal-minio:9000` в дефолтах/примерах (остался только поясняющий
комментарий в lib). Живой прогон по-прежнему ⚠ Manual.
@@ -1,60 +1,60 @@
# Live-приёмка (вторая серия, без внешних кредов) — prod-контур + mTLS + backup/restore
Дата: 2026-09-08. Docker Desktop запущен. Закрывает Manual-пункты 3, 4, 6 чек-листа STATUS.md.
Проект НЕ git. Продолжение `task-16-live-report.md` (dev-smoke 12/12 + SaaS 15/15).
## 1. Backup + restore на копии — PASS (найдены и исправлены 3 дефекта скриптов)
Прогон на dev-хранилищах (deal-postgres/deal-minio, `docker compose -f deploy/compose.dev.yml up -d postgres minio`):
- `backup.sh` (bash): **pg** (docker exec pg_dump -Fc, 84K) → **minio** (docker-mc mirror бакета deal-files)
**data** (busybox tar docker-томов deploy_deal_tg_sessions/deal_ml_data/deal_api_data) → **retention** (0 удалено).
- `restore.sh pg` в копию-БД `deal_restore_test` (dropdb+createdb+pg_restore): после сверки **43 таблицы / 3 схемы
идентичны**, `users=2 tenants=2 sessions=30` в основной БД и копии. Копия удалена.
- `restore.sh minio` с реальным объектом: залит live-test.txt (30B) → backup → удалён из бакета →
`DEAL_MINIO_MIRROR_REMOVE=1 restore.sh minio` → объект восстановлен. Снапшот и объект убраны.
- `restore.sh data` (host-ветка): распаковка backup-<TS>.tar.gz → data/ — OK.
**Исправленные дефекты** (проявились только живьём; на Linux-prod часть не воспроизводится):
1. `scripts/deal-backup-lib.sh` mc_cmd: MC_HOST_deal собирался как `http://user:pass@http://minio:9000`
(двойная схема) — mc отвергал alias. Теперь схема выносится из endpoint в начало URL.
2. `scripts/backup.sh` backup_minio: пустой бакет → mc mirror не создаёт целевую директорию → mv падал.
Теперь `mkdir -p "$part"` до mirror.
3. Windows/MSYS: docker не понимает `/c/...` пути и ломает контейнерные `/out`,`/in` (конвертация в
`C:/Program Files/Git/...`). В `deal-backup-lib.sh` добавлен `host_docker_path()` (cygpath -m) +
`MSYS_NO_PATHCONV=1`; применён в docker run -v (mc, tar томов) и docker cp (restore pg). На Linux —
no-op.
## 2. Prod-контур (compose.prod.yml) + mTLS + observability — PASS
Сертификаты перегенерированы (`scripts/mtls-certs.sh -f`): теперь полный набор deploy/certs (ca.pem/ca.key,
4×-server.pfx, deal-client.pfx/.crt/.key). PFX-цепочки проверены `openssl verify` — OK.
Подъём: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml --profile observability up -d --build`
(фиктивные env-секреты, DEAL_MTLS_ENABLED=1, внутренние endpoint'ы https://). .env.prod создавался только на
время прогона и удалён после down.
- **mTLS-здоровье**: core/telegram/ai/ml — все **healthy** (grpc_health_probe с -tls, клиентский PEM deal-client).
- **Исходящее mTLS core→сервисы**: login admin/admin (Caddy TLS) → `GET /api/tg/status` =
`{"phase":"idle"...}` (живой gRPC по https://telegram-service:5101) и `GET /api/ml/status` =
`reachable:true` (модель spam/t:order на месте) — рукопожатия с клиентским сертификатом работают.
- **Caddy**: `https://deal.example/api/health``{"ok":true,"service":"deal"}`; фронт (src/frontend/dist) HTTP 200.
- **Observability**: loki/promtail/grafana подняты; в Loki реально пишутся логи (labels container/service/stream;
count_over_time: ai-service 70, caddy 25 за 5 мин); Grafana `/api/health` 200.
- **Исправлен дефект** `deploy/observability/loki.yml`: Loki 3.x падал с `compactor.delete-request-store should be
configured when retention is enabled` → добавлен `delete_request_store: filesystem`.
## 3. Уборка
- prod-стек: `docker compose ... down` (все контейнеры и сеть удалены); `.env.prod` удалён.
- dev-хранилища: `docker compose -f deploy/compose.dev.yml down`.
- Проверено: deal-контейнеров нет, dotnet/Deal-процессов нет, порты (80/443/5080/5082/5433/3001/5101-5103)
свободны. Образы deploy-{core,telegram,ai,ml}-service оставлены (пересборка не нужна; удалить — docker rmi).
- Временные артефакты (data/backups снапшоты, тест-объект MinIO, `data;C`/`backups;C` от старых MSYS-прогонов)
удалены.
## Остаток Manual (только с живыми кредами/копией)
Реальный Telegram-вход (api_id/api_hash/QR) и LLM-вызовы; restore-тест полного цикла на изолированной копии
томов; реальный домен/сертификаты Caddy (в прогоне — `tls internal` + фиктивный .env.prod).
# Live-приёмка (вторая серия, без внешних кредов) — prod-контур + mTLS + backup/restore
Дата: 2026-09-08. Docker Desktop запущен. Закрывает Manual-пункты 3, 4, 6 чек-листа STATUS.md.
Проект НЕ git. Продолжение `task-16-live-report.md` (dev-smoke 12/12 + SaaS 15/15).
## 1. Backup + restore на копии — PASS (найдены и исправлены 3 дефекта скриптов)
Прогон на dev-хранилищах (deal-postgres/deal-minio, `docker compose -f deploy/compose.dev.yml up -d postgres minio`):
- `backup.sh` (bash): **pg** (docker exec pg_dump -Fc, 84K) → **minio** (docker-mc mirror бакета deal-files)
**data** (busybox tar docker-томов deploy_deal_tg_sessions/deal_ml_data/deal_api_data) → **retention** (0 удалено).
- `restore.sh pg` в копию-БД `deal_restore_test` (dropdb+createdb+pg_restore): после сверки **43 таблицы / 3 схемы
идентичны**, `users=2 tenants=2 sessions=30` в основной БД и копии. Копия удалена.
- `restore.sh minio` с реальным объектом: залит live-test.txt (30B) → backup → удалён из бакета →
`DEAL_MINIO_MIRROR_REMOVE=1 restore.sh minio` → объект восстановлен. Снапшот и объект убраны.
- `restore.sh data` (host-ветка): распаковка backup-<TS>.tar.gz → data/ — OK.
**Исправленные дефекты** (проявились только живьём; на Linux-prod часть не воспроизводится):
1. `scripts/deal-backup-lib.sh` mc_cmd: MC_HOST_deal собирался как `http://user:pass@http://minio:9000`
(двойная схема) — mc отвергал alias. Теперь схема выносится из endpoint в начало URL.
2. `scripts/backup.sh` backup_minio: пустой бакет → mc mirror не создаёт целевую директорию → mv падал.
Теперь `mkdir -p "$part"` до mirror.
3. Windows/MSYS: docker не понимает `/c/...` пути и ломает контейнерные `/out`,`/in` (конвертация в
`C:/Program Files/Git/...`). В `deal-backup-lib.sh` добавлен `host_docker_path()` (cygpath -m) +
`MSYS_NO_PATHCONV=1`; применён в docker run -v (mc, tar томов) и docker cp (restore pg). На Linux —
no-op.
## 2. Prod-контур (compose.prod.yml) + mTLS + observability — PASS
Сертификаты перегенерированы (`scripts/mtls-certs.sh -f`): теперь полный набор deploy/certs (ca.pem/ca.key,
4×-server.pfx, deal-client.pfx/.crt/.key). PFX-цепочки проверены `openssl verify` — OK.
Подъём: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml --profile observability up -d --build`
(фиктивные env-секреты, DEAL_MTLS_ENABLED=1, внутренние endpoint'ы https://). .env.prod создавался только на
время прогона и удалён после down.
- **mTLS-здоровье**: core/telegram/ai/ml — все **healthy** (grpc_health_probe с -tls, клиентский PEM deal-client).
- **Исходящее mTLS core→сервисы**: login admin/admin (Caddy TLS) → `GET /api/tg/status` =
`{"phase":"idle"...}` (живой gRPC по https://telegram-service:5101) и `GET /api/ml/status` =
`reachable:true` (модель spam/t:order на месте) — рукопожатия с клиентским сертификатом работают.
- **Caddy**: `https://deal.example/api/health``{"ok":true,"service":"deal"}`; фронт (src/frontend/dist) HTTP 200.
- **Observability**: loki/promtail/grafana подняты; в Loki реально пишутся логи (labels container/service/stream;
count_over_time: ai-service 70, caddy 25 за 5 мин); Grafana `/api/health` 200.
- **Исправлен дефект** `deploy/observability/loki.yml`: Loki 3.x падал с `compactor.delete-request-store should be
configured when retention is enabled` → добавлен `delete_request_store: filesystem`.
## 3. Уборка
- prod-стек: `docker compose ... down` (все контейнеры и сеть удалены); `.env.prod` удалён.
- dev-хранилища: `docker compose -f deploy/compose.dev.yml down`.
- Проверено: deal-контейнеров нет, dotnet/Deal-процессов нет, порты (80/443/5080/5082/5433/3001/5101-5103)
свободны. Образы deploy-{core,telegram,ai,ml}-service оставлены (пересборка не нужна; удалить — docker rmi).
- Временные артефакты (data/backups снапшоты, тест-объект MinIO, `data;C`/`backups;C` от старых MSYS-прогонов)
удалены.
## Остаток Manual (только с живыми кредами/копией)
Реальный Telegram-вход (api_id/api_hash/QR) и LLM-вызовы; restore-тест полного цикла на изолированной копии
томов; реальный домен/сертификаты Caddy (в прогоне — `tls internal` + фиктивный .env.prod).
@@ -1,40 +1,40 @@
# Live-приёмка этапа 7 (после Task 16) — dev-smoke 12/12 + SaaS 15/15
Дата: 2026-09-08. Docker Desktop запущен пользователем специально под live-проверки.
Закрывает Manual-пункты 1–2 чек-листа Task 16/STATUS.md живьём. Проект НЕ git.
## 1. System-миграции (public-схема) — применены
- `SystemSaaS`: Operators/OperatorSessions/Invites/TenantLimits/AuditLog + `SessionsImpersonationMark`.
- psql подтвердил: 9 таблиц в public (включая `__EFMigrationsHistory`).
## 2. dev-smoke полного gRPC-стека — PASS 12/12
`sh scripts/dev-smoke.sh` на `deploy/compose.dev.yml` (postgres+minio+telegram/ai/ml/core, gRPC-режим):
подъём → health → login admin/admin → `/api/tg/status` idle (живой gRPC-статус) → simulate-lead →
карточка в inbox → trash → обучающий сигнал spam → ML-флашер выгрузил outbox (outbox:0, класс `spam`
в модели ml-service). Скрипт погасил стек сам (trap → down).
## 3. SaaS-контур — PASS 15/15 (curl-приёмка живьём, core :5080, Local-Postgres :5433)
Скрипты: `.superpowers/sdd/deal-stage7-saas/live-saas-check.sh` + обёртка `run-live-saas.sh`
(build → старт Deal.Api в Development/DEAL_DEMO=1 с явной `ConnectionStrings__DealPostgres`
health → прогон сценария → гарантированный kill с ретраями → проверка порта).
Шаги: оператор login (operator/operator) → создать тенанта → инвайт (код 16 симв.) → публичный
`POST /api/join` → вход нового пользователя → `/api/settings` + `/api/boards` + demo-карточка →
IDOR-негатив: пользователь к операторским ручкам = **401** → suspend → login = **403** → resume →
login = **200** → лимиты (`GET /api/operator/.../limit` → бюджет) → аудит-лента (события есть).
## 4. Уборка
- `docker compose -f deploy/compose.dev.yml down` — контейнеры deal-* удалены (volumes сохранены).
- `dotnet build-server shutdown` — MSBuild/компиляторные ноды погашены.
- Проверено: deal-контейнеров и dotnet-процессов нет; порты 5080/5082/5433/5101-5103 свободны.
## Итог
Живое подтверждение этапа 7 получено: операторский SaaS-контур работает на реальном Postgres,
IDOR-защита, suspend/resume и аудит подтверждены. Остаются Manual с живыми кредами/копией:
prod-compose подъём (Caddy/observability), mTLS-рукопожатие gRPC, реальный Telegram/LLM,
backup.sh+restore-тест на копии (п.36 STATUS.md).
# Live-приёмка этапа 7 (после Task 16) — dev-smoke 12/12 + SaaS 15/15
Дата: 2026-09-08. Docker Desktop запущен пользователем специально под live-проверки.
Закрывает Manual-пункты 1–2 чек-листа Task 16/STATUS.md живьём. Проект НЕ git.
## 1. System-миграции (public-схема) — применены
- `SystemSaaS`: Operators/OperatorSessions/Invites/TenantLimits/AuditLog + `SessionsImpersonationMark`.
- psql подтвердил: 9 таблиц в public (включая `__EFMigrationsHistory`).
## 2. dev-smoke полного gRPC-стека — PASS 12/12
`sh scripts/dev-smoke.sh` на `deploy/compose.dev.yml` (postgres+minio+telegram/ai/ml/core, gRPC-режим):
подъём → health → login admin/admin → `/api/tg/status` idle (живой gRPC-статус) → simulate-lead →
карточка в inbox → trash → обучающий сигнал spam → ML-флашер выгрузил outbox (outbox:0, класс `spam`
в модели ml-service). Скрипт погасил стек сам (trap → down).
## 3. SaaS-контур — PASS 15/15 (curl-приёмка живьём, core :5080, Local-Postgres :5433)
Скрипты: `.superpowers/sdd/deal-stage7-saas/live-saas-check.sh` + обёртка `run-live-saas.sh`
(build → старт Deal.Api в Development/DEAL_DEMO=1 с явной `ConnectionStrings__DealPostgres`
health → прогон сценария → гарантированный kill с ретраями → проверка порта).
Шаги: оператор login (operator/operator) → создать тенанта → инвайт (код 16 симв.) → публичный
`POST /api/join` → вход нового пользователя → `/api/settings` + `/api/boards` + demo-карточка →
IDOR-негатив: пользователь к операторским ручкам = **401** → suspend → login = **403** → resume →
login = **200** → лимиты (`GET /api/operator/.../limit` → бюджет) → аудит-лента (события есть).
## 4. Уборка
- `docker compose -f deploy/compose.dev.yml down` — контейнеры deal-* удалены (volumes сохранены).
- `dotnet build-server shutdown` — MSBuild/компиляторные ноды погашены.
- Проверено: deal-контейнеров и dotnet-процессов нет; порты 5080/5082/5433/5101-5103 свободны.
## Итог
Живое подтверждение этапа 7 получено: операторский SaaS-контур работает на реальном Postgres,
IDOR-защита, suspend/resume и аудит подтверждены. Остаются Manual с живыми кредами/копией:
prod-compose подъём (Caddy/observability), mTLS-рукопожатие gRPC, реальный Telegram/LLM,
backup.sh+restore-тест на копии (п.36 STATUS.md).
@@ -1,85 +1,85 @@
# Task 16 report — Финал: доки, roadmap/STATUS 100%, полный прогон, сквозная сводка
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 16 (L516544) + Self-Review (L546585);
источники — техдок §5/§7–§11/§13, api-map, roadmap, STATUS.md, user-guide, отчёты Tasks 115,
фактический код (Program.cs, эндпоинты, contracts). Проект НЕ git. **Docker выключен**: живые приёмки —
⚠ Manual (чек-лист ниже); всё остальное прогнано (build/test/config/syntax).
## Доки
- **Техдок `docs/technical/Техническая-документация-Дейл.md`** — актуализирован под фактическое
состояние этапа 7 (замечания ревью закрыты):
- §5 — фактические имена RPC/сервисов из `src/contracts/*.proto` (`deal.telegram.v1`:
`TelegramService` 16 RPC + `IngressService` PushMessage/SyncDialogs/ReportStatus; `deal.ml.v1`:
Predict/Status/Reset/TrainBatch; `deal.ai.v1`: Filter/Classify/GenerateKeywords/EvaluateFit) вместо
дизайн-имён; безопасность сервисов — service-token + mTLS за флагом;
- §6 «Лимиты токенов» — фактический механизм (TokenUsageRecorder → tenant_limits, гейт-декораторы);
- §7 — фактический стек наблюдаемости: Serilog JSON во всех 4 процессах + access-логи HTTP/gRPC +
compose.prod profile observability (promtail/loki 7 сут./grafana 127.0.0.1:3001); OTel — задел;
- §8 — фактическое развёртывание: dev-compose + prod-compose (Caddy 80/443, без host-портов у
хранилищ, mTLS-env, профиль observability), порядок первого запуска prod, env-переменные,
CI/CD (скрипты; внешний CI/k8s — заделы);
- §9 — фактические бэкапы: ссылки на `scripts/backup.sh`/`restore.sh`, cron/systemd → §13.9;
- §10 — фактическая безопасность: rate-limit (политики+LoginAttemptGuard), Origin-проверка,
ForwardedHeaders (KnownProxies/KnownNetworks), security-заголовки (core+Caddy), mTLS-флаг,
приостановка → 403, аудит append-only; что вне — Cloudflare/k8s/биллинг/UI;
- §11 — этап 7 переведён в «выполнено» (Tasks 1–16, финальный прогон), TODO-список сокращён до
реальных заделов (OTel-метрики, multi-instance rate-limit, мгновенный разлогин suspended, ML-экспорт,
reclassify на реальном ИИ, мультиаккаунтность, k8s/биллинг/саморегистрация/UI-админки, purge
audit_log/tenant_limits) + Manual-живые проверки;
- §13 — заголовок «актуально для этапов 0–7», §13.6 (ожидается 1123 PASS + финальный прогон Task 16),
§13.8 (заголовок Tasks 1–14 + указатель Task 16; ссылка api-map закрыта), §13.9 (ссылка на §9);
шапка документа: Версия 1.0, дата 2026-09-08; §1-стек (наблюдаемость/прокси) уточнены.
- **api-map `docs/api/api-map.md`** — новая сводная секция «§6. Реализовано в Deal»: расхождения/
решения (вход suspended → **403** «Учётная запись приостановлена…» вместо приёмочного «401»;
статус тенанта — **POST `/suspend`/`/unsuspend` вместо PATCH {status}**; create тенанта без `budget?`
лимит отдельной ручкой; отсутствующие эндпоинты и почему) + компактная таблица SaaS-ручек
`/api/operator/*` и `/api/join` (API-only, фронт не вызывает; кука `deal_operator_session`).
- **Roadmap `docs/superpowers/plans/2026-09-05-deal-roadmap.md`** — этап 7 «Выполнено» (Tasks 1–16,
финальные числа, Manual-пункты); «Оставшиеся этапы» → «Следующие этапы (после 0–7)» с заделами 8+;
«Открытые точки» — п.2 закрыт (оператор/инвайты реализованы, dev-seed admin/admin dev-only).
- **STATUS `docs/superpowers/STATUS.md`** — 100% (этапы 07, 103/103 задач, core 1123 + сервисы
114/50/36); «Что умеет сейчас» + SaaS-контур; Manual-чек-лист вынесен отдельным разделом; заделы 8+.
- **User-guide `docs/user-guide/Инструкция-пользователя-Дейл.md`** — разделы перенумерованы; новый
«1. Регистрация по приглашению» (72 ч, email-совпадение, оператор), «2. Вход» (dev admin/admin),
«3. Telegram» (реальные api_id/api_hash + полный стек; dev — демо-статус), новый «9. ИИ-бюджет и
уведомления» (80%/100% + локальный режим, приостановка), оператор — кратко/вне пользователя.
- **Ledger `progress.md`** — todo: Task 15/16 [x] (дубль Task 11 убран); Task 16 status complete
(review pending).
## Проверки (прогнано, docker выключен)
- Build: `scripts/build.sh` (Deal.sln) + `dotnet build` telegram/ai/ml sln — **0 warnings / 0 errors**
у всех четырёх.
- Тесты: core `dotnet test tests/Deal.Tests.Unit`**1123/1123 PASS** (0 failed, 7 s); telegram **114/114**,
ai **50/50**, ml **36/36** PASS.
- `docker compose -f deploy/compose.prod.yml config`**rc=0**; c `--profile observability` — rc=0
(переменные fail-fast заданы фиктивными значениями; без них rc=1 по замыслу — секреты без дефолтов).
- `sh -n` scripts/dev-smoke.sh, backup.sh, restore.sh, mtls-certs.sh — **rc=0**; `bash -n backup.sh` rc=0.
- Ничего не запускалось и не оставлено в фоне (серверы/контейнеры не поднимались).
## Manual-чек-лист (docker/живые креды; НЕ выполнялось в Task 16)
1. Применить system-миграцию `SystemSaaS` (`dotnet ef database update --context DealDbContext`) и
прогнать сквозную SaaS-curl-приёмку: оператор login → создать тенанта → инвайт → `/api/join`
вход тенанта → работа `/api` (me/settings) → лимит-бюджет мал → симуляция ИИ-вызова (recorder) →
fallback-декоратор → тост-флаг в tenant_limits → аудит-лента → suspend → login **403** → resume →
IDOR-негативы (оператор против тенант-ручек, чужой tenantId/инвайт/email).
Эквивалент без docker — HTTP-тесты задач 2–11 (операторские харнессы на in-process Kestrel: 401/403,
suspend→login 403→resume, CAS-активация join, IDOR).
2. Живой dev-smoke полного стека: `sh scripts/dev-smoke.sh` (подъём → health → login → /api/tg/status →
simulate-lead → флашер MlOutbox → /api/ml/status; trap → down).
3. Подъём `deploy/compose.prod.yml` (+ `--profile observability`): Caddy 80/443, mTLS-env, Loki/Grafana.
4. mTLS-рукопожатие внутреннего gRPC: сертификаты уже сгенерированы `scripts/mtls-certs.sh`
(`deploy/certs/`: PFX процессов, deal-client PFX+PEM); живая проверка контейнеров.
5. Реальный Telegram-вход (api_id/api_hash/QR) и LLM-вызовы — с кредами.
6. Прогон `scripts/backup.sh` (pg/MinIO/tar, retention) и restore-тест `scripts/restore.sh` на копии.
## Concerns
- Core-тесты остаются 1123 (Task 15/16 добавляли только файлы/скрипты/доки, кода не меняли).
- Полный стек (живые docker/mTLS/бэкап/LLM/Telegram) — ⚠ Manual и помечен в отчётах/STATUS/техдоке;
авто-эквиваленты — HTTP-тесты и `compose config` rc=0.
- Проверка `compose.prod.yml config` требует значений fail-fast переменных (без дефолтов — по замыслу
Ruling 9): в прогоне использованы фиктивные значения, ничего не сохранено.
- STATUS/roadmap объявляют этапы 0–7 выполненными с оговоркой «Manual-чек-лист отдельно».
# Task 16 report — Финал: доки, roadmap/STATUS 100%, полный прогон, сквозная сводка
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 16 (L516544) + Self-Review (L546585);
источники — техдок §5/§7–§11/§13, api-map, roadmap, STATUS.md, user-guide, отчёты Tasks 115,
фактический код (Program.cs, эндпоинты, contracts). Проект НЕ git. **Docker выключен**: живые приёмки —
⚠ Manual (чек-лист ниже); всё остальное прогнано (build/test/config/syntax).
## Доки
- **Техдок `docs/technical/Техническая-документация-Дейл.md`** — актуализирован под фактическое
состояние этапа 7 (замечания ревью закрыты):
- §5 — фактические имена RPC/сервисов из `src/contracts/*.proto` (`deal.telegram.v1`:
`TelegramService` 16 RPC + `IngressService` PushMessage/SyncDialogs/ReportStatus; `deal.ml.v1`:
Predict/Status/Reset/TrainBatch; `deal.ai.v1`: Filter/Classify/GenerateKeywords/EvaluateFit) вместо
дизайн-имён; безопасность сервисов — service-token + mTLS за флагом;
- §6 «Лимиты токенов» — фактический механизм (TokenUsageRecorder → tenant_limits, гейт-декораторы);
- §7 — фактический стек наблюдаемости: Serilog JSON во всех 4 процессах + access-логи HTTP/gRPC +
compose.prod profile observability (promtail/loki 7 сут./grafana 127.0.0.1:3001); OTel — задел;
- §8 — фактическое развёртывание: dev-compose + prod-compose (Caddy 80/443, без host-портов у
хранилищ, mTLS-env, профиль observability), порядок первого запуска prod, env-переменные,
CI/CD (скрипты; внешний CI/k8s — заделы);
- §9 — фактические бэкапы: ссылки на `scripts/backup.sh`/`restore.sh`, cron/systemd → §13.9;
- §10 — фактическая безопасность: rate-limit (политики+LoginAttemptGuard), Origin-проверка,
ForwardedHeaders (KnownProxies/KnownNetworks), security-заголовки (core+Caddy), mTLS-флаг,
приостановка → 403, аудит append-only; что вне — Cloudflare/k8s/биллинг/UI;
- §11 — этап 7 переведён в «выполнено» (Tasks 1–16, финальный прогон), TODO-список сокращён до
реальных заделов (OTel-метрики, multi-instance rate-limit, мгновенный разлогин suspended, ML-экспорт,
reclassify на реальном ИИ, мультиаккаунтность, k8s/биллинг/саморегистрация/UI-админки, purge
audit_log/tenant_limits) + Manual-живые проверки;
- §13 — заголовок «актуально для этапов 0–7», §13.6 (ожидается 1123 PASS + финальный прогон Task 16),
§13.8 (заголовок Tasks 1–14 + указатель Task 16; ссылка api-map закрыта), §13.9 (ссылка на §9);
шапка документа: Версия 1.0, дата 2026-09-08; §1-стек (наблюдаемость/прокси) уточнены.
- **api-map `docs/api/api-map.md`** — новая сводная секция «§6. Реализовано в Deal»: расхождения/
решения (вход suspended → **403** «Учётная запись приостановлена…» вместо приёмочного «401»;
статус тенанта — **POST `/suspend`/`/unsuspend` вместо PATCH {status}**; create тенанта без `budget?`
лимит отдельной ручкой; отсутствующие эндпоинты и почему) + компактная таблица SaaS-ручек
`/api/operator/*` и `/api/join` (API-only, фронт не вызывает; кука `deal_operator_session`).
- **Roadmap `docs/superpowers/plans/2026-09-05-deal-roadmap.md`** — этап 7 «Выполнено» (Tasks 1–16,
финальные числа, Manual-пункты); «Оставшиеся этапы» → «Следующие этапы (после 0–7)» с заделами 8+;
«Открытые точки» — п.2 закрыт (оператор/инвайты реализованы, dev-seed admin/admin dev-only).
- **STATUS `docs/superpowers/STATUS.md`** — 100% (этапы 07, 103/103 задач, core 1123 + сервисы
114/50/36); «Что умеет сейчас» + SaaS-контур; Manual-чек-лист вынесен отдельным разделом; заделы 8+.
- **User-guide `docs/user-guide/Инструкция-пользователя-Дейл.md`** — разделы перенумерованы; новый
«1. Регистрация по приглашению» (72 ч, email-совпадение, оператор), «2. Вход» (dev admin/admin),
«3. Telegram» (реальные api_id/api_hash + полный стек; dev — демо-статус), новый «9. ИИ-бюджет и
уведомления» (80%/100% + локальный режим, приостановка), оператор — кратко/вне пользователя.
- **Ledger `progress.md`** — todo: Task 15/16 [x] (дубль Task 11 убран); Task 16 status complete
(review pending).
## Проверки (прогнано, docker выключен)
- Build: `scripts/build.sh` (Deal.sln) + `dotnet build` telegram/ai/ml sln — **0 warnings / 0 errors**
у всех четырёх.
- Тесты: core `dotnet test tests/Deal.Tests.Unit`**1123/1123 PASS** (0 failed, 7 s); telegram **114/114**,
ai **50/50**, ml **36/36** PASS.
- `docker compose -f deploy/compose.prod.yml config`**rc=0**; c `--profile observability` — rc=0
(переменные fail-fast заданы фиктивными значениями; без них rc=1 по замыслу — секреты без дефолтов).
- `sh -n` scripts/dev-smoke.sh, backup.sh, restore.sh, mtls-certs.sh — **rc=0**; `bash -n backup.sh` rc=0.
- Ничего не запускалось и не оставлено в фоне (серверы/контейнеры не поднимались).
## Manual-чек-лист (docker/живые креды; НЕ выполнялось в Task 16)
1. Применить system-миграцию `SystemSaaS` (`dotnet ef database update --context DealDbContext`) и
прогнать сквозную SaaS-curl-приёмку: оператор login → создать тенанта → инвайт → `/api/join`
вход тенанта → работа `/api` (me/settings) → лимит-бюджет мал → симуляция ИИ-вызова (recorder) →
fallback-декоратор → тост-флаг в tenant_limits → аудит-лента → suspend → login **403** → resume →
IDOR-негативы (оператор против тенант-ручек, чужой tenantId/инвайт/email).
Эквивалент без docker — HTTP-тесты задач 2–11 (операторские харнессы на in-process Kestrel: 401/403,
suspend→login 403→resume, CAS-активация join, IDOR).
2. Живой dev-smoke полного стека: `sh scripts/dev-smoke.sh` (подъём → health → login → /api/tg/status →
simulate-lead → флашер MlOutbox → /api/ml/status; trap → down).
3. Подъём `deploy/compose.prod.yml` (+ `--profile observability`): Caddy 80/443, mTLS-env, Loki/Grafana.
4. mTLS-рукопожатие внутреннего gRPC: сертификаты уже сгенерированы `scripts/mtls-certs.sh`
(`deploy/certs/`: PFX процессов, deal-client PFX+PEM); живая проверка контейнеров.
5. Реальный Telegram-вход (api_id/api_hash/QR) и LLM-вызовы — с кредами.
6. Прогон `scripts/backup.sh` (pg/MinIO/tar, retention) и restore-тест `scripts/restore.sh` на копии.
## Concerns
- Core-тесты остаются 1123 (Task 15/16 добавляли только файлы/скрипты/доки, кода не меняли).
- Полный стек (живые docker/mTLS/бэкап/LLM/Telegram) — ⚠ Manual и помечен в отчётах/STATUS/техдоке;
авто-эквиваленты — HTTP-тесты и `compose config` rc=0.
- Проверка `compose.prod.yml config` требует значений fail-fast переменных (без дефолтов — по замыслу
Ruling 9): в прогоне использованы фиктивные значения, ничего не сохранено.
- STATUS/roadmap объявляют этапы 0–7 выполненными с оговоркой «Manual-чек-лист отдельно».
@@ -1,73 +1,73 @@
# Task 2 report — Оператор: модели/порт/сервис auth, bootstrap из env, dev-only дефолтный тенант
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 847/847 PASS (830 → +17 новых).
Docker выключен — живые проверки (curl/psql) не выполнялись, ⚠ Manual (Task 3).
## Состав
### Созданы — `src/core/Deal.Modules.Tenants/Application/` (namespace `Deal.Modules.Tenants.Application.*`)
- **Модели** `Models/` (1 тип = 1 файл, эталон User/Session-модели Task 13 этапа 1):
- `StoredOperatorDto` — Id/Login/Status/PasswordHash (оператор не принадлежит тенанту — TenantId нет).
- `OperatorIdentityDto` — Id/Login/Status (ответ разрешения сессии, без секретов).
- `OperatorSessionDto` — TokenHash (SHA-256)/OperatorId/Login/ExpiresAt (таблица operator_sessions).
- `OperatorLoginResultDto` — Login/Token; пустые оба = «Неверный логин или пароль оператора» (401).
- `IOperatorAuthStore.cs` — порт (отдельный от `IAuthStore`): `FindByLoginAsync` / `CreateAsync` /
`FindSessionByTokenHashAsync` / `CreateSessionAsync` / `DeleteSessionAsync` / `DeleteExpiredSessionsAsync`.
Реализация (EF-адаптер) — Task 3 вместе с регистрацией в DI.
- `OperatorAuthService.cs` — Login/Logout/ResolveSession (эталон AuthService): нормализация логина
(lowercase/trim), Argon2id через `IPasswordHasher`, сессии через `SessionTokens` (raw наружу, хэш в БД);
`SessionLifetimeHours = 12` (Ruling 1) — единый источник «12»; resolve по денормализованному в сессию
логину + очистка протухших сессий. Бизнес-отказы — кодами/null, тексты фиксирует HTTP-слой (Task 3).
- `OperatorBootstrapService.cs` — идемпотентный bootstrap оператора: env-ключи как константы
(`LoginEnvKey = "DEAL_OPERATOR_LOGIN"`, `PasswordEnvKey = "DEAL_OPERATOR_PASSWORD"`), дефолты
operator/operator; `EnsureOperatorAsync(login, password, allowDevelopmentDefaults, ct)`
в Development при отсутствии кред берёт дефолты, в Production без кред возвращает null (шаг пропущен,
warning логирует хост), существующего оператора не пересоздаёт и пароль не перезаписывает.
**Не подключён к старту** — хост-шаг (TenantBootstrapService или отдельный hosted) + EF-адаптер
порта добавляются в Task 3 (в Modify Task 3: «регистрация OperatorAuthService/IOperatorAuthStore»).
### Изменён — `src/core/Deal.Api/Hosting/TenantBootstrapService.cs`
Dev-seed дефолтного тенанта + admin стал dev-only (Ruling 1): создаётся только в Development или при
`DEAL_BOOTSTRAP_DEFAULT_TENANT=1` (константы `DefaultTenantBootstrapEnvKey/EnabledValue`, env читается
через `IConfiguration`, окружение — `IHostEnvironment`); провижининг схем ВСЕХ тенантов реестра —
всегда. XML-doc класса актуализирован.
### Создан — `src/core/Deal.Api/Configuration/OperatorCookieOptions.cs`
Секция `OperatorCookies` (эталон `CookieOptions`): `Name = "deal_operator_session"` (отдельная от
тенантной `deal_session` — изоляция сессий по имени куки), `Hours` с код-дефолтом = единому источнику
`OperatorAuthService.SessionLifetimeHours` (12), `Secure` из конфига. Подключение секции и использова-
ние куки — Task 3 (endpoints/middleware).
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/`
- `FakeOperatorAuthStore.cs` — in-memory реализация порта (не фильтрует протухшие при поиске — сервис
сам учитывает ExpiresAt; журнал `Calls` для порядка операций; эталон FakeAuthStore).
- `OperatorAuthServiceTests.cs` — login ok (вход « Operator » → нормализация, сессия ровно
`SessionLifetimeHours`=12 ч в `Assert.InRange`), неверный пароль, неизвестный логин, resolve
(живая/протухшая/удалён оператор/без токена), logout (с токеном/без — no-op). 10 тестов.
- `OperatorBootstrapServiceTests.cs` — dev-дефолт (без/с пустыми env), идемпотентность (один оператор,
хэш не перезаписывается), prod без env → skip (null, хранилище не тронуто), prod с env → создание
с нормализацией логина, существующий оператор → no-op. 6 тестов.
- `OperatorCookieOptionsTests.cs` — имя `deal_operator_session``deal_session`, `Hours` = 12 =
`OperatorAuthService.SessionLifetimeHours`, Secure=false по умолчанию. 2 теста.
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit --no-build` — 847/847 PASS (830 + 17).
- Живой старт/curl/psql — не выполнялись (docker выключен): эндпоинты и middleware — Task 3,
применение миграции SystemSaaS к dev-PG — Manual.
## Concerns
- HTTP-контур (login/logout/me, `OperatorSessionMiddleware`, `HttpContext.Items["CurrentOperator"]`,
Program.cs, DI адаптера) — по плану Task 3 (Files: Task 3), в этой задаче не делался; сервисный слой
(ResolveSession/Logout) готов как его основа.
- Bootstrap оператора не зарегистрирован hosted-сервисом: без EF-адаптера `IOperatorAuthStore`
(Task 3) стартовая регистрация сейчас сломала бы приложение. В Development по умолчанию оператор
появится только после Task 3; env-семантика покрыта unit.
- «Удалён оператор при живой сессии» → resolve даёт null, сессия остаётся до expiry-очистки (зеркало
поведения AuthService для пользователей — не ухудшение).
# Task 2 report — Оператор: модели/порт/сервис auth, bootstrap из env, dev-only дефолтный тенант
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 847/847 PASS (830 → +17 новых).
Docker выключен — живые проверки (curl/psql) не выполнялись, ⚠ Manual (Task 3).
## Состав
### Созданы — `src/core/Deal.Modules.Tenants/Application/` (namespace `Deal.Modules.Tenants.Application.*`)
- **Модели** `Models/` (1 тип = 1 файл, эталон User/Session-модели Task 13 этапа 1):
- `StoredOperatorDto` — Id/Login/Status/PasswordHash (оператор не принадлежит тенанту — TenantId нет).
- `OperatorIdentityDto` — Id/Login/Status (ответ разрешения сессии, без секретов).
- `OperatorSessionDto` — TokenHash (SHA-256)/OperatorId/Login/ExpiresAt (таблица operator_sessions).
- `OperatorLoginResultDto` — Login/Token; пустые оба = «Неверный логин или пароль оператора» (401).
- `IOperatorAuthStore.cs` — порт (отдельный от `IAuthStore`): `FindByLoginAsync` / `CreateAsync` /
`FindSessionByTokenHashAsync` / `CreateSessionAsync` / `DeleteSessionAsync` / `DeleteExpiredSessionsAsync`.
Реализация (EF-адаптер) — Task 3 вместе с регистрацией в DI.
- `OperatorAuthService.cs` — Login/Logout/ResolveSession (эталон AuthService): нормализация логина
(lowercase/trim), Argon2id через `IPasswordHasher`, сессии через `SessionTokens` (raw наружу, хэш в БД);
`SessionLifetimeHours = 12` (Ruling 1) — единый источник «12»; resolve по денормализованному в сессию
логину + очистка протухших сессий. Бизнес-отказы — кодами/null, тексты фиксирует HTTP-слой (Task 3).
- `OperatorBootstrapService.cs` — идемпотентный bootstrap оператора: env-ключи как константы
(`LoginEnvKey = "DEAL_OPERATOR_LOGIN"`, `PasswordEnvKey = "DEAL_OPERATOR_PASSWORD"`), дефолты
operator/operator; `EnsureOperatorAsync(login, password, allowDevelopmentDefaults, ct)`
в Development при отсутствии кред берёт дефолты, в Production без кред возвращает null (шаг пропущен,
warning логирует хост), существующего оператора не пересоздаёт и пароль не перезаписывает.
**Не подключён к старту** — хост-шаг (TenantBootstrapService или отдельный hosted) + EF-адаптер
порта добавляются в Task 3 (в Modify Task 3: «регистрация OperatorAuthService/IOperatorAuthStore»).
### Изменён — `src/core/Deal.Api/Hosting/TenantBootstrapService.cs`
Dev-seed дефолтного тенанта + admin стал dev-only (Ruling 1): создаётся только в Development или при
`DEAL_BOOTSTRAP_DEFAULT_TENANT=1` (константы `DefaultTenantBootstrapEnvKey/EnabledValue`, env читается
через `IConfiguration`, окружение — `IHostEnvironment`); провижининг схем ВСЕХ тенантов реестра —
всегда. XML-doc класса актуализирован.
### Создан — `src/core/Deal.Api/Configuration/OperatorCookieOptions.cs`
Секция `OperatorCookies` (эталон `CookieOptions`): `Name = "deal_operator_session"` (отдельная от
тенантной `deal_session` — изоляция сессий по имени куки), `Hours` с код-дефолтом = единому источнику
`OperatorAuthService.SessionLifetimeHours` (12), `Secure` из конфига. Подключение секции и использова-
ние куки — Task 3 (endpoints/middleware).
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/`
- `FakeOperatorAuthStore.cs` — in-memory реализация порта (не фильтрует протухшие при поиске — сервис
сам учитывает ExpiresAt; журнал `Calls` для порядка операций; эталон FakeAuthStore).
- `OperatorAuthServiceTests.cs` — login ok (вход « Operator » → нормализация, сессия ровно
`SessionLifetimeHours`=12 ч в `Assert.InRange`), неверный пароль, неизвестный логин, resolve
(живая/протухшая/удалён оператор/без токена), logout (с токеном/без — no-op). 10 тестов.
- `OperatorBootstrapServiceTests.cs` — dev-дефолт (без/с пустыми env), идемпотентность (один оператор,
хэш не перезаписывается), prod без env → skip (null, хранилище не тронуто), prod с env → создание
с нормализацией логина, существующий оператор → no-op. 6 тестов.
- `OperatorCookieOptionsTests.cs` — имя `deal_operator_session``deal_session`, `Hours` = 12 =
`OperatorAuthService.SessionLifetimeHours`, Secure=false по умолчанию. 2 теста.
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit --no-build` — 847/847 PASS (830 + 17).
- Живой старт/curl/psql — не выполнялись (docker выключен): эндпоинты и middleware — Task 3,
применение миграции SystemSaaS к dev-PG — Manual.
## Concerns
- HTTP-контур (login/logout/me, `OperatorSessionMiddleware`, `HttpContext.Items["CurrentOperator"]`,
Program.cs, DI адаптера) — по плану Task 3 (Files: Task 3), в этой задаче не делался; сервисный слой
(ResolveSession/Logout) готов как его основа.
- Bootstrap оператора не зарегистрирован hosted-сервисом: без EF-адаптера `IOperatorAuthStore`
(Task 3) стартовая регистрация сейчас сломала бы приложение. В Development по умолчанию оператор
появится только после Task 3; env-семантика покрыта unit.
- «Удалён оператор при живой сессии» → resolve даёт null, сессия остаётся до expiry-очистки (зеркало
поведения AuthService для пользователей — не ухудшение).
@@ -1,73 +1,73 @@
# Task 3 report — Оператор: HTTP-контур /api/operator/auth + операторская сессия
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 865/865 PASS (847 → +18 новых).
Docker выключен — живая curl-приёмка на :5080 не выполнялась, ⚠ Manual. Эквивалент приёмки (login →
кука deal_operator_session + me; 401; logout; изоляция кук) покрыт HTTP-тестами на in-process Kestrel.
## Состав
### Создан — `src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs`
EF-адаптер `IOperatorAuthStore` (эталон AuthStore): public.operators/operator_sessions; маппинг DTO↔
сущности вручную; поиск сессии не возвращает протухшие (`ExpiresAt > now`); удаления — `ExecuteDeleteAsync`.
DI: `AddDealPersistence` (ServiceCollectionExtensions.cs) — `AddScoped<IOperatorAuthStore, OperatorAuthStore>()`.
### Созданы — `src/core/Deal.Api/`
- `Http/CurrentOperator.cs``CurrentOperator(OperatorId, Login, Status)` (отдельный от CurrentUser).
- `Http/AuthHelpers.cs` (изменён) — `CurrentOperatorItemKey = "CurrentOperator"`, `OperatorUnauthorizedDetail =
"Требуется вход оператора"`, `SetCurrentOperator`/`GetCurrentOperator`.
- `Middleware/OperatorSessionMiddleware.cs` — кука из `OperatorCookies:Name` (deal_operator_session) →
scoped `OperatorAuthService.ResolveSessionAsync` (scope через RequestServices, как SessionMiddleware) →
`Items["CurrentOperator"]`; pass-through (сам 401 не отдаёт); ITenantContext не трогает (Reset не нужен).
- `Endpoints/OperatorAuthEndpoints.cs` — группа `/api/operator/auth` (Ruling 5/11: совпадает с
`/api/operator/auth/login` политики rate-limit): POST login (LoginRequest; кука httpOnly/SameSite=Lax/
Path=/ /MaxAge=Hours(12)/Secure из конфига + `{ok:true,login}`), POST logout (всегда ok, кука удаляется),
GET me (`{login,ok}`; без операторской сессии — 401 `OperatorUnauthorizedDetail`); ошибка логина — 401
«Неверный логин или пароль оператора». Аудит-вызовы — Task 4 (заглушки нет).
- `Hosting/OperatorBootstrapHostedService.cs` — встраивание `EnsureOperatorAsync` в старт (после
TenantBootstrapService; scoped OperatorBootstrapService резолвится в собственном scope из
IServiceScopeFactory — как TenantService в TenantBootstrapService): dev-дефолт operator/operator;
**warning при skip в prod** и при **частичных env-кредах** (задана одна из DEAL_OPERATOR_LOGIN/
PASSWORD — ревью T2, не молчаливый дефолт); секреты в логи не пишутся.
- `Program.cs` (изменён) — секция `OperatorCookies` (`Configure<OperatorCookieOptions>`), hosted-шаг
оператора, `UseMiddleware<OperatorSessionMiddleware>()` после SessionMiddleware,
`MapOperatorAuthEndpoints()` после MapAuthEndpoints. appsettings.json/Development.json — секция
`OperatorCookies {Name=deal_operator_session, Secure=false}`.
### Изменён — `src/core/Deal.Modules.Tenants/Application/`
- `OperatorAuthService.cs` — **ревью T2 (1): ResolveSession проверяет Status оператора** — сессия
разрешается только для `active` (`ActiveStatus` const); удалённый/приостановленный → null (401 на HTTP).
- `TenantModuleRegistrar.cs` — `AddScoped<OperatorAuthService>()` + `AddScoped<OperatorBootstrapService>()`.
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/`
- `OperatorAuthStoreTests.cs` — адаптер (unit-маппинг минимально): create/find round-trip оператора и
сессии, expired-сессия → null, неизвестный логин → null. На EF InMemory (пакет
`Microsoft.EntityFrameworkCore.InMemory` добавлен **только в тест-проект**; ExecuteDeleteAsync
InMemory не поддерживает — delete-пути за Postgres, ⚠ Manual). 4 теста.
- `OperatorAuthHttpHost.cs` — in-process Kestrel (эталон MlGrpcTestHost): SessionMiddleware +
OperatorSessionMiddleware + MapAuthEndpoints + MapOperatorAuthEndpoints на фейк-хранилищах и
FakePasswordHasher; порядок как в Program.cs (Session → Operator → эндпоинты).
- `OperatorAuthEndpointsHttpTests.cs` — login (200 + кука с атрибутами httponly/samesite=lax/
max-age=43200), неверный пароль → 401 «Неверный логин или пароль оператора», me без сессии → 401
«Требуется вход оператора», login→me, login→logout→me 401 (+logout no-op-ok), **статус оператора**
(suspended: login 200, me 401), **изоляция кук** (deal_session не даёт /api/operator/auth/me; кука
оператора не даёт /api/auth/me — обе 401). 7 тестов.
- `OperatorBootstrapHostedServiceTests.cs` — bootstrap-вызов: dev-дефолт, partial-dev → warning +
дефолты, prod без env → warning+skip, partial-prod → warning+skip, prod/dev с env → создание
нормализованного оператора, пароль не логируется. 6 тестов.
- `OperatorAuthServiceTests.cs` — +1 тест: ResolveSession при неактивном операторе → null.
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit --no-build` — 865/865 PASS (847 + 18).
- Живой старт/curl/psql — не выполнялись (docker выключен): curl-приёмка Task 3 (login operator/operator
на :5080, me, изоляция кук) — ⚠ Manual; эквивалент покрыт HTTP-тестами выше.
## Concerns
- План (Files) упоминал хелпер `RequireOperator` в AuthHelpers: реализован как 401-гейт ручки me
(стиль AuthEndpoints — проверка `GetCurrentOperator` + `EndpointResults.Unauthorized`); отдельный
метод-«дублёр» не вводился — в будущих операторских ручках (Task 4/5/7/10) гейт повторяется тем же
паттерном.
- DeleteSession/DeleteExpiredSessions адаптера unit-проверены быть не могут (ExecuteDeleteAsync — только
реляционный провайдер): проверка за Postgres (Manual). Это причина добавления InMemory-пакета в тесты.
- OperatorSessionMiddleware unit-отдельно не тестируется — покрыт сквозными HTTP-тестами (реальная
middleware-цепочка + минимальные API, фейк-хранилища).
# Task 3 report — Оператор: HTTP-контур /api/operator/auth + операторская сессия
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 865/865 PASS (847 → +18 новых).
Docker выключен — живая curl-приёмка на :5080 не выполнялась, ⚠ Manual. Эквивалент приёмки (login →
кука deal_operator_session + me; 401; logout; изоляция кук) покрыт HTTP-тестами на in-process Kestrel.
## Состав
### Создан — `src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs`
EF-адаптер `IOperatorAuthStore` (эталон AuthStore): public.operators/operator_sessions; маппинг DTO↔
сущности вручную; поиск сессии не возвращает протухшие (`ExpiresAt > now`); удаления — `ExecuteDeleteAsync`.
DI: `AddDealPersistence` (ServiceCollectionExtensions.cs) — `AddScoped<IOperatorAuthStore, OperatorAuthStore>()`.
### Созданы — `src/core/Deal.Api/`
- `Http/CurrentOperator.cs``CurrentOperator(OperatorId, Login, Status)` (отдельный от CurrentUser).
- `Http/AuthHelpers.cs` (изменён) — `CurrentOperatorItemKey = "CurrentOperator"`, `OperatorUnauthorizedDetail =
"Требуется вход оператора"`, `SetCurrentOperator`/`GetCurrentOperator`.
- `Middleware/OperatorSessionMiddleware.cs` — кука из `OperatorCookies:Name` (deal_operator_session) →
scoped `OperatorAuthService.ResolveSessionAsync` (scope через RequestServices, как SessionMiddleware) →
`Items["CurrentOperator"]`; pass-through (сам 401 не отдаёт); ITenantContext не трогает (Reset не нужен).
- `Endpoints/OperatorAuthEndpoints.cs` — группа `/api/operator/auth` (Ruling 5/11: совпадает с
`/api/operator/auth/login` политики rate-limit): POST login (LoginRequest; кука httpOnly/SameSite=Lax/
Path=/ /MaxAge=Hours(12)/Secure из конфига + `{ok:true,login}`), POST logout (всегда ok, кука удаляется),
GET me (`{login,ok}`; без операторской сессии — 401 `OperatorUnauthorizedDetail`); ошибка логина — 401
«Неверный логин или пароль оператора». Аудит-вызовы — Task 4 (заглушки нет).
- `Hosting/OperatorBootstrapHostedService.cs` — встраивание `EnsureOperatorAsync` в старт (после
TenantBootstrapService; scoped OperatorBootstrapService резолвится в собственном scope из
IServiceScopeFactory — как TenantService в TenantBootstrapService): dev-дефолт operator/operator;
**warning при skip в prod** и при **частичных env-кредах** (задана одна из DEAL_OPERATOR_LOGIN/
PASSWORD — ревью T2, не молчаливый дефолт); секреты в логи не пишутся.
- `Program.cs` (изменён) — секция `OperatorCookies` (`Configure<OperatorCookieOptions>`), hosted-шаг
оператора, `UseMiddleware<OperatorSessionMiddleware>()` после SessionMiddleware,
`MapOperatorAuthEndpoints()` после MapAuthEndpoints. appsettings.json/Development.json — секция
`OperatorCookies {Name=deal_operator_session, Secure=false}`.
### Изменён — `src/core/Deal.Modules.Tenants/Application/`
- `OperatorAuthService.cs` — **ревью T2 (1): ResolveSession проверяет Status оператора** — сессия
разрешается только для `active` (`ActiveStatus` const); удалённый/приостановленный → null (401 на HTTP).
- `TenantModuleRegistrar.cs` — `AddScoped<OperatorAuthService>()` + `AddScoped<OperatorBootstrapService>()`.
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/`
- `OperatorAuthStoreTests.cs` — адаптер (unit-маппинг минимально): create/find round-trip оператора и
сессии, expired-сессия → null, неизвестный логин → null. На EF InMemory (пакет
`Microsoft.EntityFrameworkCore.InMemory` добавлен **только в тест-проект**; ExecuteDeleteAsync
InMemory не поддерживает — delete-пути за Postgres, ⚠ Manual). 4 теста.
- `OperatorAuthHttpHost.cs` — in-process Kestrel (эталон MlGrpcTestHost): SessionMiddleware +
OperatorSessionMiddleware + MapAuthEndpoints + MapOperatorAuthEndpoints на фейк-хранилищах и
FakePasswordHasher; порядок как в Program.cs (Session → Operator → эндпоинты).
- `OperatorAuthEndpointsHttpTests.cs` — login (200 + кука с атрибутами httponly/samesite=lax/
max-age=43200), неверный пароль → 401 «Неверный логин или пароль оператора», me без сессии → 401
«Требуется вход оператора», login→me, login→logout→me 401 (+logout no-op-ok), **статус оператора**
(suspended: login 200, me 401), **изоляция кук** (deal_session не даёт /api/operator/auth/me; кука
оператора не даёт /api/auth/me — обе 401). 7 тестов.
- `OperatorBootstrapHostedServiceTests.cs` — bootstrap-вызов: dev-дефолт, partial-dev → warning +
дефолты, prod без env → warning+skip, partial-prod → warning+skip, prod/dev с env → создание
нормализованного оператора, пароль не логируется. 6 тестов.
- `OperatorAuthServiceTests.cs` — +1 тест: ResolveSession при неактивном операторе → null.
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit --no-build` — 865/865 PASS (847 + 18).
- Живой старт/curl/psql — не выполнялись (docker выключен): curl-приёмка Task 3 (login operator/operator
на :5080, me, изоляция кук) — ⚠ Manual; эквивалент покрыт HTTP-тестами выше.
## Concerns
- План (Files) упоминал хелпер `RequireOperator` в AuthHelpers: реализован как 401-гейт ручки me
(стиль AuthEndpoints — проверка `GetCurrentOperator` + `EndpointResults.Unauthorized`); отдельный
метод-«дублёр» не вводился — в будущих операторских ручках (Task 4/5/7/10) гейт повторяется тем же
паттерном.
- DeleteSession/DeleteExpiredSessions адаптера unit-проверены быть не могут (ExecuteDeleteAsync — только
реляционный провайдер): проверка за Postgres (Manual). Это причина добавления InMemory-пакета в тесты.
- OperatorSessionMiddleware unit-отдельно не тестируется — покрыт сквозными HTTP-тестами (реальная
middleware-цепочка + минимальные API, фейк-хранилища).
@@ -1,66 +1,66 @@
# Task 4 report — Аудит-поток: AuditService, события входов, чтение оператором
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 890/890 PASS (865 → +25 новых).
Docker выключен — curl/psql-приёмка (failed login → запись audit, GET /api/operator/audit на :5080)
⚠ Manual; эквивалент покрыт HTTP-тестами на in-process Kestrel (OperatorAuthHttpHost) с фейками.
## Состав
### Создано — модуль `src/core/Deal.Modules.Tenants/Application/`
- `AuditEvents.cs` — каталог 11 событий Ruling 4 (tenant_login_ok/failed, operator_login_ok/failed,
invite_created/revoked/activated, tenant_created/status_changed/limit_changed, impersonation_started).
- `AuditActorTypes.cs` — типы акторов (tenant|operator|system) — нет «магических» строк.
- `Models/AuditRecordDto.cs` — запись аудита: EventType, ActorType, ActorId?, TenantId?, Ip?, DetailJson,
At (проставляет сервис), Id (БД). Документация: без секретов в DetailJson.
- `Models/AuditQueryDto.cs` — фильтр чтения: EventType?, ActorType?, TenantId?, From?, To?, Limit.
- `IAuditLogStore.cs` — порт: AppendAsync/QueryAsync/CountAsync. **Update/Delete отсутствуют** (append-only).
- `AuditService.cs` — AppendAsync (At=UTC-now через store), QueryAsync/CountAsync, `ToDetailJson`
(camelCase), хелперы акторов `ActorFromUser`/`ActorFromOperator`; константы MaxQueryLimit=500,
DefaultQueryLimit=100. Регистрация AddScoped в `TenantModuleRegistrar.cs`.
- `AuthService.cs`/`OperatorAuthService.cs` + `Models/LoginResultDto`/`OperatorLoginResultDto`
результат login дополнен UserId+TenantId / OperatorId (опциональные поля, старые вызовы не сломаны):
для audit-записи tenant_login_ok/operator_login_ok с идентификатором актора.
### Создано — `src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs`
EF-адаптер (public.audit_log): Append = Add+SaveChanges (Id не копируется — identity БД); Query — фильтры
EventType/ActorType/TenantId/At-range, At DESC, Take с клампом 1..500; CountAsync по фильтру без учёта limit.
DI: `AddDealPersistence` (`ServiceCollectionExtensions.cs`) — `AddScoped<IAuditLogStore, AuditLogStore>()`.
### Создано/изменено — `src/core/Deal.Api/`
- `Endpoints/AuthEndpoints.cs` (изм.) — login: успех → tenant_login_ok (actorId/tenantId из результата),
неудача → tenant_login_failed (только при непустой попытке; login попытки — в DetailJson; пароль не пишется).
- `Endpoints/OperatorAuthEndpoints.cs` (изм.) — зеркально: operator_login_ok/failed.
- `Endpoints/OperatorAuditEndpoints.cs` (нов.) — GET /api/operator/audit?eventType=&actorType=&tenantId=
&from=&to=&limit=; 401 «Требуется вход оператора» без операторской сессии; ответ {items, total} (total —
полное число по фильтру); публичный хелпер `NormalizeLimit` (дефолт 100, кламп 1..500).
- `Program.cs` (изм.) — `MapOperatorAuditEndpoints()` после MapOperatorAuthEndpoints.
IP клиента — `context.Connection.RemoteIpAddress` (без порта).
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/`
- `AuditServiceTests.cs` (9) — AppendAsync ставит At=UTC-now и сохраняет все поля; Query DESC + фильтры
(event/actor/tenant/At-range); Count без учёта limit; ToDetailJson (camelCase); ActorFromUser/Operator;
**append-only рефлексией**: у IAuditLogStore ровно AppendAsync/QueryAsync/CountAsync, у AuditService нет
Update/Delete-методов.
- `AuditLogStoreTests.cs` (4) — EF-адаптер на InMemory: маппинг полей round-trip, фильтры/сортировка At DESC,
Count, кламп limit=500.
- `OperatorAuditEndpointsHelpersTests.cs` (3 факта + theory×3) — NormalizeLimit (null→100, ≤0→1, >500→500).
- `OperatorAuditEndpointsHttpTests.cs` (6) — 401 без операторской сессии; operator_login_ok/failed и
tenant_login_ok/failed с полями (actorId, tenantId, IP 127.0.0.1, login в DetailJson); GET аудита — items
новыми сверху + total; query-фильтры actorType/eventType.
- `FakeAuditLogStore.cs` — in-memory порт (identity-Id 1..N, фильтры/сортировка/кламп как у адаптера).
- `OperatorAuthHttpHost.cs` (изм.) — регистрация фейк-IAuditLogStore (опциональный параметр) +
MapOperatorAuditEndpoints; старые сценарии не изменены.
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit` — 890/890 PASS (865 + 25).
- Живая curl/psql-приёмка (login-события в БД, GET /api/operator/audit на :5080) — ⚠ Manual (docker выключен);
эквивалент покрыт HTTP-тестами на in-process Kestrel.
## Concerns
- Пустой/пробельный login не пишет tenant_login_failed/operator_login_failed (нет «реальной попытки») —
осознанно; неверный пароль пишется всегда. Вход suspended-тенанта добавит Task 7 (там же расширится вызов).
- Дублирование NormalizeLogin/ClientIp в двух endpoint-файлах — намеренно (эндпоинты автономны; вынос в
общий хелпер — если понадобится третьему потребителю).
- DetailJson не валидируется как JSON на запись (доверие вызывающему; в коде пишется только через
AuditService.ToDetailJson). Идентичность Id генерирует БД (identity) — приложение не задаёт.
# Task 4 report — Аудит-поток: AuditService, события входов, чтение оператором
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 890/890 PASS (865 → +25 новых).
Docker выключен — curl/psql-приёмка (failed login → запись audit, GET /api/operator/audit на :5080)
⚠ Manual; эквивалент покрыт HTTP-тестами на in-process Kestrel (OperatorAuthHttpHost) с фейками.
## Состав
### Создано — модуль `src/core/Deal.Modules.Tenants/Application/`
- `AuditEvents.cs` — каталог 11 событий Ruling 4 (tenant_login_ok/failed, operator_login_ok/failed,
invite_created/revoked/activated, tenant_created/status_changed/limit_changed, impersonation_started).
- `AuditActorTypes.cs` — типы акторов (tenant|operator|system) — нет «магических» строк.
- `Models/AuditRecordDto.cs` — запись аудита: EventType, ActorType, ActorId?, TenantId?, Ip?, DetailJson,
At (проставляет сервис), Id (БД). Документация: без секретов в DetailJson.
- `Models/AuditQueryDto.cs` — фильтр чтения: EventType?, ActorType?, TenantId?, From?, To?, Limit.
- `IAuditLogStore.cs` — порт: AppendAsync/QueryAsync/CountAsync. **Update/Delete отсутствуют** (append-only).
- `AuditService.cs` — AppendAsync (At=UTC-now через store), QueryAsync/CountAsync, `ToDetailJson`
(camelCase), хелперы акторов `ActorFromUser`/`ActorFromOperator`; константы MaxQueryLimit=500,
DefaultQueryLimit=100. Регистрация AddScoped в `TenantModuleRegistrar.cs`.
- `AuthService.cs`/`OperatorAuthService.cs` + `Models/LoginResultDto`/`OperatorLoginResultDto`
результат login дополнен UserId+TenantId / OperatorId (опциональные поля, старые вызовы не сломаны):
для audit-записи tenant_login_ok/operator_login_ok с идентификатором актора.
### Создано — `src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs`
EF-адаптер (public.audit_log): Append = Add+SaveChanges (Id не копируется — identity БД); Query — фильтры
EventType/ActorType/TenantId/At-range, At DESC, Take с клампом 1..500; CountAsync по фильтру без учёта limit.
DI: `AddDealPersistence` (`ServiceCollectionExtensions.cs`) — `AddScoped<IAuditLogStore, AuditLogStore>()`.
### Создано/изменено — `src/core/Deal.Api/`
- `Endpoints/AuthEndpoints.cs` (изм.) — login: успех → tenant_login_ok (actorId/tenantId из результата),
неудача → tenant_login_failed (только при непустой попытке; login попытки — в DetailJson; пароль не пишется).
- `Endpoints/OperatorAuthEndpoints.cs` (изм.) — зеркально: operator_login_ok/failed.
- `Endpoints/OperatorAuditEndpoints.cs` (нов.) — GET /api/operator/audit?eventType=&actorType=&tenantId=
&from=&to=&limit=; 401 «Требуется вход оператора» без операторской сессии; ответ {items, total} (total —
полное число по фильтру); публичный хелпер `NormalizeLimit` (дефолт 100, кламп 1..500).
- `Program.cs` (изм.) — `MapOperatorAuditEndpoints()` после MapOperatorAuthEndpoints.
IP клиента — `context.Connection.RemoteIpAddress` (без порта).
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/`
- `AuditServiceTests.cs` (9) — AppendAsync ставит At=UTC-now и сохраняет все поля; Query DESC + фильтры
(event/actor/tenant/At-range); Count без учёта limit; ToDetailJson (camelCase); ActorFromUser/Operator;
**append-only рефлексией**: у IAuditLogStore ровно AppendAsync/QueryAsync/CountAsync, у AuditService нет
Update/Delete-методов.
- `AuditLogStoreTests.cs` (4) — EF-адаптер на InMemory: маппинг полей round-trip, фильтры/сортировка At DESC,
Count, кламп limit=500.
- `OperatorAuditEndpointsHelpersTests.cs` (3 факта + theory×3) — NormalizeLimit (null→100, ≤0→1, >500→500).
- `OperatorAuditEndpointsHttpTests.cs` (6) — 401 без операторской сессии; operator_login_ok/failed и
tenant_login_ok/failed с полями (actorId, tenantId, IP 127.0.0.1, login в DetailJson); GET аудита — items
новыми сверху + total; query-фильтры actorType/eventType.
- `FakeAuditLogStore.cs` — in-memory порт (identity-Id 1..N, фильтры/сортировка/кламп как у адаптера).
- `OperatorAuthHttpHost.cs` (изм.) — регистрация фейк-IAuditLogStore (опциональный параметр) +
MapOperatorAuditEndpoints; старые сценарии не изменены.
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit` — 890/890 PASS (865 + 25).
- Живая curl/psql-приёмка (login-события в БД, GET /api/operator/audit на :5080) — ⚠ Manual (docker выключен);
эквивалент покрыт HTTP-тестами на in-process Kestrel.
## Concerns
- Пустой/пробельный login не пишет tenant_login_failed/operator_login_failed (нет «реальной попытки») —
осознанно; неверный пароль пишется всегда. Вход suspended-тенанта добавит Task 7 (там же расширится вызов).
- Дублирование NormalizeLogin/ClientIp в двух endpoint-файлах — намеренно (эндпоинты автономны; вынос в
общий хелпер — если понадобится третьему потребителю).
- DetailJson не валидируется как JSON на запись (доверие вызывающему; в коде пишется только через
AuditService.ToDetailJson). Идентичность Id генерирует БД (identity) — приложение не задаёт.
@@ -1,83 +1,83 @@
# Task 5 report — Инвайты: сервис/адаптер/операторские ручки + аудит
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 933/933 PASS (890 → +43 новых).
Docker выключен — живая curl/psql-приёмка (create → list → revoke на :5080, 401 без оператора)
⚠ Manual; эквивалент покрыт HTTP-тестами на in-process Kestrel (OperatorAuthHttpHost) с фейками.
## Состав
### Создано — модуль `src/core/Deal.Modules.Tenants/Application/`
- `InviteStatuses.cs` — статусы Ruling 2: pending/activated/revoked/expired (константы 1:1 со значениями БД;
«активным» считается pending — зеркало partial unique-индекса invites.Email по pending).
- `Models/InviteDto.cs` — строка public.invites (Code/Email/TenantId/Status/ExpiresAt/ActivatedAt/CreatedById/CreatedAt).
- `Models/InviteCreateResultDto.cs` — результат CreateInviteAsync: Ok/Error (`invalidEmail`|`duplicateActive`)/Invite.
- `Models/InviteRevokeResultDto.cs` — результат RevokeAsync: Ok/Error (`notFound`|`notPending`)/Invite (фактическое
состояние для аудита).
- `IInviteStore.cs` — порт: CreateAsync/GetByCodeAsync/ListAsync/UpdateStatusAsync(code, status, activatedAt)
(bool — была ли строка)/FindActiveByEmailAsync. UpdateStatus универсален — им же Task 6 выполнит активацию
(activatedAt), ленивый expired и отзыв.
- `InviteCodeGenerator.cs` — код: 12 случайных байт → Base64Url ровно 16 симв. (CodeLength), без префикса (Ruling 2).
- `InvitesService.cs` — CreateInviteAsync (нормализация/валидация email, антидубль «активное на email»,
expiry = +72 ч — константа `ExpiryHours`; tenantId null = «новый тенант», задан = существующий), RevokeAsync
(только pending → revoked), ListAsync, GetByCodeAsync. **Ленивый expired**: pending+истёкшее при чтении
(GetByCode/List) возвращается со статусом expired И переход сохраняется (иначе partial unique-индекс по
pending не пустил бы новый инвайт на тот же email после истечения); CreateInviteAsync при нахождении
протухшего pending сам переводит его в expired и создаёт новый. Валидация email — `[GeneratedRegex]`
(sanitize-уровень: один '@', домен с точкой, без пробелов, ≤200 симв. — ширина колонки). Сервис не бросает
исключений для бизнес-отказов — коды ошибок, тексты на HTTP-слое (паттерн AuthService/ChangePassword).
### Создано/изменено — `src/core/Deal.Infrastructure/`
- `Persistence/Repositories/InviteStore.cs` (нов.) — EF-адаптер public.invites (ручной маппинг DTO↔сущности);
List — CreatedAt DESC; FindActiveByEmail — только pending (без учёта ExpiresAt — expired переводит сервис).
- `ServiceCollectionExtensions.cs` (изм.) — `AddDealPersistence`: `AddScoped<IInviteStore, InviteStore>()`;
XML-doc списка адаптеров дополнен.
- Модуль Tenants: `TenantModuleRegistrar.cs` (изм.) — `AddScoped<InvitesService>()` + доки.
### Создано/изменено — `src/core/Deal.Api/`
- `Endpoints/OperatorInviteCreateRequest.cs` (нов.) — тело POST {email, tenantId?}.
- `Endpoints/OperatorInvitesEndpoints.cs` (нов.) — группа `/api/operator/invites` (тег operator-invites):
GET "" (list → `{items:[...]}` полных строк, expired проставляется), POST "" (create → `{code, email,
tenantId, expiresAt, status}` по плану Task 5), POST `/{code}/revoke` (revoke → `{ok:true}`). 401 «Требуется
вход оператора» без операторской сессии. Ошибки: create — 400 «Некорректный email» / «Для этого email уже
есть активное приглашение» (текст плана), revoke — 404 «Приглашение не найдено» / 400 «Отозвать можно только
ожидающее активации приглашение». Аудит: invite_created/invite_revoked, актор operator (ActorId из сессии,
TenantId null, IP клиента), email+code в DetailJson (Ruling 4).
- `Program.cs` (изм.) — `app.MapOperatorInvitesEndpoints()` после MapOperatorAuditEndpoints.
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/`
- `FakeInviteStore.cs` — in-memory порт (List DESC; UpdateStatus атомарен, false при отсутствии кода;
FindActiveByEmail — pending; Calls-журнал).
- `InvitesServiceTests.cs` (26 кейсов) — создание (код 16 url-safe/expiry 72 ч/нормализация/автор/tenantId
null и заданный), невалидный email (theory: null/пустой/без домена-точки/пробелы/двойной @/длиннее 200),
дубль на pending → duplicateActive, **протухший pending → auto-expire + новый создаётся**, revoked →
позволяет новый, revoke pending/не найден/не-pending (activated|revoked|expired), **expiry при чтении
протухшего** (GetByCode: статус + сохранение), live pending без изменений, List (DESC + ленивый expired +
не-pending не трогаются).
- `InviteStoreTests.cs` (6) — EF-адаптер на InMemory: round-trip маппинга, List DESC, UpdateStatus
(+ActivatedAt), false для неизвестного кода, FindActiveByEmail (только pending), null для неизвестного кода.
- `InviteCodeGeneratorTests.cs` (2) — 16 url-safe символов, уникальность.
- `OperatorInvitesEndpointsHttpTests.cs` (10) — эквивалент curl-минимума: 401 без оператора (create/list/
revoke), **create → list → revoke** полный сценарий (+ повторный revoke → 400; аудит invite_created/
invite_revoked с email+code), tenantId в ответе create, дубль активного email → 400, revoked позволяет новый,
невалидный email → 400, revoke неизвестного → 404.
- `OperatorAuthHttpHost.cs` (изм.) — монтирование MapOperatorInvitesEndpoints + регистрация фейк-IInviteStore;
добавлена перегрузка RunAsync со сценарием `(base, operator, user, invite, audit)`; старые сценарии не изменены.
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit` — 933/933 PASS (890 + 43).
- Живая curl/psql-приёмка (ручки на :5080, 401 без оператора) — ⚠ Manual (docker выключен); эквивалент —
HTTP-тесты на in-process Kestrel.
## Concerns
- **Имя пути отзыва**: диспетч задачи упоминал `POST /{code}/cancel`, но план Task 5 и каталог аудита
(Ruling 4: invite_revoked) фиксируют **`POST /{code}/revoke`** — реализован revoke (план — источник истины).
- **Ленивый expired сохраняется в БД** при GetByCode/List и в CreateInviteAsync при найденном протухшем
pending: это необходимо, иначе частичный unique-индекс invites.Email по pending блокировал бы новый инвайт
после истечения старого (иначе 500 на insert). Записей на чтение немного (по строке на протухший pending).
- Тексты revoke-ошибок и invalid-email — новые фиксированные строки HTTP-слоя (в плане задан только текст
дубля); при желании унифицировать с Task 6 (Join) — там свои тексты активации.
- Валидация email — sanitize-уровень (не RFC): локальная часть/домен без пробелов, домен с точкой, ≤200.
Приглашения — ручной ввод оператора; достаточный минимум зафиксирован тестами.
- Аудит invite_created/invite_revoked: TenantId события = null (операторские события, как login-события T4);
целевой tenantId инвайта виден в самой строке public.invites.
# Task 5 report — Инвайты: сервис/адаптер/операторские ручки + аудит
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 933/933 PASS (890 → +43 новых).
Docker выключен — живая curl/psql-приёмка (create → list → revoke на :5080, 401 без оператора)
⚠ Manual; эквивалент покрыт HTTP-тестами на in-process Kestrel (OperatorAuthHttpHost) с фейками.
## Состав
### Создано — модуль `src/core/Deal.Modules.Tenants/Application/`
- `InviteStatuses.cs` — статусы Ruling 2: pending/activated/revoked/expired (константы 1:1 со значениями БД;
«активным» считается pending — зеркало partial unique-индекса invites.Email по pending).
- `Models/InviteDto.cs` — строка public.invites (Code/Email/TenantId/Status/ExpiresAt/ActivatedAt/CreatedById/CreatedAt).
- `Models/InviteCreateResultDto.cs` — результат CreateInviteAsync: Ok/Error (`invalidEmail`|`duplicateActive`)/Invite.
- `Models/InviteRevokeResultDto.cs` — результат RevokeAsync: Ok/Error (`notFound`|`notPending`)/Invite (фактическое
состояние для аудита).
- `IInviteStore.cs` — порт: CreateAsync/GetByCodeAsync/ListAsync/UpdateStatusAsync(code, status, activatedAt)
(bool — была ли строка)/FindActiveByEmailAsync. UpdateStatus универсален — им же Task 6 выполнит активацию
(activatedAt), ленивый expired и отзыв.
- `InviteCodeGenerator.cs` — код: 12 случайных байт → Base64Url ровно 16 симв. (CodeLength), без префикса (Ruling 2).
- `InvitesService.cs` — CreateInviteAsync (нормализация/валидация email, антидубль «активное на email»,
expiry = +72 ч — константа `ExpiryHours`; tenantId null = «новый тенант», задан = существующий), RevokeAsync
(только pending → revoked), ListAsync, GetByCodeAsync. **Ленивый expired**: pending+истёкшее при чтении
(GetByCode/List) возвращается со статусом expired И переход сохраняется (иначе partial unique-индекс по
pending не пустил бы новый инвайт на тот же email после истечения); CreateInviteAsync при нахождении
протухшего pending сам переводит его в expired и создаёт новый. Валидация email — `[GeneratedRegex]`
(sanitize-уровень: один '@', домен с точкой, без пробелов, ≤200 симв. — ширина колонки). Сервис не бросает
исключений для бизнес-отказов — коды ошибок, тексты на HTTP-слое (паттерн AuthService/ChangePassword).
### Создано/изменено — `src/core/Deal.Infrastructure/`
- `Persistence/Repositories/InviteStore.cs` (нов.) — EF-адаптер public.invites (ручной маппинг DTO↔сущности);
List — CreatedAt DESC; FindActiveByEmail — только pending (без учёта ExpiresAt — expired переводит сервис).
- `ServiceCollectionExtensions.cs` (изм.) — `AddDealPersistence`: `AddScoped<IInviteStore, InviteStore>()`;
XML-doc списка адаптеров дополнен.
- Модуль Tenants: `TenantModuleRegistrar.cs` (изм.) — `AddScoped<InvitesService>()` + доки.
### Создано/изменено — `src/core/Deal.Api/`
- `Endpoints/OperatorInviteCreateRequest.cs` (нов.) — тело POST {email, tenantId?}.
- `Endpoints/OperatorInvitesEndpoints.cs` (нов.) — группа `/api/operator/invites` (тег operator-invites):
GET "" (list → `{items:[...]}` полных строк, expired проставляется), POST "" (create → `{code, email,
tenantId, expiresAt, status}` по плану Task 5), POST `/{code}/revoke` (revoke → `{ok:true}`). 401 «Требуется
вход оператора» без операторской сессии. Ошибки: create — 400 «Некорректный email» / «Для этого email уже
есть активное приглашение» (текст плана), revoke — 404 «Приглашение не найдено» / 400 «Отозвать можно только
ожидающее активации приглашение». Аудит: invite_created/invite_revoked, актор operator (ActorId из сессии,
TenantId null, IP клиента), email+code в DetailJson (Ruling 4).
- `Program.cs` (изм.) — `app.MapOperatorInvitesEndpoints()` после MapOperatorAuditEndpoints.
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/`
- `FakeInviteStore.cs` — in-memory порт (List DESC; UpdateStatus атомарен, false при отсутствии кода;
FindActiveByEmail — pending; Calls-журнал).
- `InvitesServiceTests.cs` (26 кейсов) — создание (код 16 url-safe/expiry 72 ч/нормализация/автор/tenantId
null и заданный), невалидный email (theory: null/пустой/без домена-точки/пробелы/двойной @/длиннее 200),
дубль на pending → duplicateActive, **протухший pending → auto-expire + новый создаётся**, revoked →
позволяет новый, revoke pending/не найден/не-pending (activated|revoked|expired), **expiry при чтении
протухшего** (GetByCode: статус + сохранение), live pending без изменений, List (DESC + ленивый expired +
не-pending не трогаются).
- `InviteStoreTests.cs` (6) — EF-адаптер на InMemory: round-trip маппинга, List DESC, UpdateStatus
(+ActivatedAt), false для неизвестного кода, FindActiveByEmail (только pending), null для неизвестного кода.
- `InviteCodeGeneratorTests.cs` (2) — 16 url-safe символов, уникальность.
- `OperatorInvitesEndpointsHttpTests.cs` (10) — эквивалент curl-минимума: 401 без оператора (create/list/
revoke), **create → list → revoke** полный сценарий (+ повторный revoke → 400; аудит invite_created/
invite_revoked с email+code), tenantId в ответе create, дубль активного email → 400, revoked позволяет новый,
невалидный email → 400, revoke неизвестного → 404.
- `OperatorAuthHttpHost.cs` (изм.) — монтирование MapOperatorInvitesEndpoints + регистрация фейк-IInviteStore;
добавлена перегрузка RunAsync со сценарием `(base, operator, user, invite, audit)`; старые сценарии не изменены.
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit` — 933/933 PASS (890 + 43).
- Живая curl/psql-приёмка (ручки на :5080, 401 без оператора) — ⚠ Manual (docker выключен); эквивалент —
HTTP-тесты на in-process Kestrel.
## Concerns
- **Имя пути отзыва**: диспетч задачи упоминал `POST /{code}/cancel`, но план Task 5 и каталог аудита
(Ruling 4: invite_revoked) фиксируют **`POST /{code}/revoke`** — реализован revoke (план — источник истины).
- **Ленивый expired сохраняется в БД** при GetByCode/List и в CreateInviteAsync при найденном протухшем
pending: это необходимо, иначе частичный unique-индекс invites.Email по pending блокировал бы новый инвайт
после истечения старого (иначе 500 на insert). Записей на чтение немного (по строке на протухший pending).
- Тексты revoke-ошибок и invalid-email — новые фиксированные строки HTTP-слоя (в плане задан только текст
дубля); при желании унифицировать с Task 6 (Join) — там свои тексты активации.
- Валидация email — sanitize-уровень (не RFC): локальная часть/домен без пробелов, домен с точкой, ≤200.
Приглашения — ручной ввод оператора; достаточный минимум зафиксирован тестами.
- Аудит invite_created/invite_revoked: TenantId события = null (операторские события, как login-события T4);
целевой tenantId инвайта виден в самой строке public.invites.
@@ -1,89 +1,89 @@
# Task 6 report — Активация инвайта: POST /api/join (пользователь + тенант + провижининг)
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 961/961 PASS (933 → +28 новых).
Docker выключен — живой curl/psql-сценарий (оператор создаёт инвайт → /api/join → psql: тенант + схема
провижинена + пользователь + invite activated; повторный join → 400) ⚠ Manual; эквивалент покрыт
HTTP-тестами на in-process Kestrel (JoinEndpointHttpTests) + unit на фейках (JoinFlowTests, 28 кейсов).
## Состав
### Создано/изменено — модуль `src/core/Deal.Modules.Tenants/Application/`
- `Models/JoinResultDto.cs` (нов.) — результат ActivateAsync: Ok + Error-коды (notFound/expired/used/revoked/
emailMismatch/emailTaken/passwordTooShort; тексты — HTTP-слой), при успехе Login/UserId/TenantId.
- `JoinService.cs` (нов.) — координатор активации: (1) чтение кода `InvitesService.GetByCodeAsync` (ленивый
expired, Task 5), (2) не-pending статус → used/revoked/expired, (3) сверка email (нормализация — общий
`InvitesService.NormalizeEmail`), (4) пароль ≥4 (единый источник `AuthService.MinNewPasswordLength`),
(5) глобальная уникальность email предпроверкой `IAuthStore.FindUserByLoginAsync` (users.login unique),
(6) **CAS-резервирование** pending→activated, (7) тенант: TenantId инвайта задан → присоединение (без
провижининга), пуст → `TenantService.CreateTenantAsync(name ?? email, новый Guid)` (провижинит схему сам),
(8) `authStore.CreateUserAsync` (login=email, хэш `IPasswordHasher`/Argon2id). Ошибки — кодами без
исключений (паттерн AuthService/InvitesService); успех: пользователь+тенант создаются ТОЛЬКО победителем
гонки (проигравший CAS ничего не создаёт).
- `IInviteStore.cs` (изм.) — новый метод `TryActivateAsync(code, activatedAt, ct)` — атомарный условный
переход (CAS) с контрактом «только из pending; иначе false без изменений» (ревью T5: не перезаписать
параллельный revoke).
- `InvitesService.cs` (изм.) — `TryActivateAsync(code, ct)` — прокси CAS с ActivatedAt=now.
- `AuthService.cs` (изм.) — `MinNewPasswordLength` стал public (единый источник «4» для join и смены пароля).
- `TenantModuleRegistrar.cs` (изм.) — `AddScoped<JoinService>()`.
### Изменено — `src/core/Deal.Infrastructure/`
- `Persistence/Repositories/InviteStore.cs``TryActivateAsync`: `ExecuteUpdateAsync` с
`WHERE Code=@code AND Status='pending'` (один SQL-оператор, строки ≥1 → true).
### Создано — `src/core/Deal.Api/Endpoints/`
- `JoinRequest.cs` (нов.) — тело {code, email, name?, password}.
- `JoinEndpoint.cs` (нов.) — **POST /api/join** (публичная, без сессии; вне /api/operator): успех
`{ok:true, login}`, кука НЕ ставится (план Task 6: далее обычный /api/auth/login); отказы — 400 {detail}:
«Приглашение не найдено» / «Срок действия приглашения истёк» / «Приглашение уже использовано» (activated) /
«Приглашение отозвано» (revoked) / «Email не совпадает с приглашением» / «Этот email уже зарегистрирован» /
«Пароль слишком короткий (минимум 4 символа)» (текст как в AuthEndpoints). Аудит успеха —
`invite_activated` (актор tenant: ActorId=новый UserId, TenantId, детали email+code). MapJoinEndpoint в
`Program.cs` после MapOperatorInvitesEndpoints.
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/`
- `FakeTenantStore.cs` (нов.) — ITenantRepository с поддержкой CreateAsync (реестр join-потока).
- `FakeTenantProvisioner.cs` (нов.) — ITenantProvisioner, журналирует провижиненные схемы (ассерт «вызван
1 раз» / «ни разу» для существующего тенанта).
- `FakeInviteStore.cs` (изм.) — TryActivateAsync с CAS-семантикой; класс рас-запечатан (Race-симуляция в
JoinFlowTests), метод virtual.
- `JoinFlowTests.cs` (нов., 19 кейсов: 14 фактов + 5 theory) — успех (новый тенант: пользователь+тенант+провижинер 1 раз+invite
activated; имя name ?? email; существующий тенант без провижининга), unknown/expired (ленивый переход
сохраняется)/activated/revoked код, email mismatch (инвайт остаётся pending), email занят (без побочных
эффектов), короткий пароль (theory), **CAS**: повторная активация → used без дублей; параллельный revoke/
активация, успевшие до CAS → revoked/used без создания пользователя/тенанта (Race-подкласс фейка);
store-контракт TryActivateAsync после revoke → false (revoke не перезаписан), unknown → false.
- `JoinEndpointHttpTests.cs` (нов., 9 кейсов) — эквивалент curl: успех (200, {ok,login}, без Set-Cookie,
аудит invite_activated с актором/email/code), повторный join → 400 used, чужой email → 400, revoked → 400,
expired → 400, unknown → 400, короткий пароль → 400, занятый email → 400, инвайт на существующий тенант
(без создания нового).
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors; проверено и по diagnostics).
- `dotnet test tests/Deal.Tests.Unit` — 961/961 PASS (933 + 28 новых).
- Живой curl/psql-сценарий Task 6 (реальный TenantProvisioningService и Postgres) — ⚠ Manual (docker
выключен); эквивалент — HTTP-тесты на in-process Kestrel с фейками + EF-CAS (условный UPDATE) завязан
на реляционный провайдер.
## Concerns
- **TenantLimits-строка при активации НЕ создаётся** (в плане Task 6: «вставка через порт ITenantLimitStore
из Task 8; до Task 8 допускается прямая вставка»): порт лимитов — зона Task 8 (GetOrCreateAsync с
дефолт-бюджетом из `TokenBudgetDefaults`), до него вводить одноразовый seam не стал; ленивое создание
строки с дефолт-бюджетом при первом чтении/списании (Ruling 3 «Reset — ленивый», Task 8/9/10) покрывает
поведение, acceptance Task 6 строку лимитов не проверяет. Если нужно жёсткое eager-создание — добавить
вызов порта в JoinService при реализации Task 8.
- **HTTP-код для истёкшего инвайта — 400** (план Task 6 прямо перечисляет 400 «Срок действия приглашения
истёк»; Ruling 2 называет это «410-семантикой» — то есть смыслом «ресурс больше недоступен», EndpointResults.Gone
в этой ручке не используется, чтобы все отказы активации были однородными 400 как в плане).
- **Сообщения used/revoked различаются** («Приглашение уже использовано» / «Приглашение отозвано» — тексты
плана Task 6). Если требование «не раскрывать статус кода» жёстче — свести оба к одному тексту в
JoinEndpoint (тесты поменяются точечно).
- Пользователь/тенант создаются ПОСЛЕ CAS-резервирования: сбой создания (например, провижининг) — серверная
500, инвайт остаётся activated; аномалия видна оператору в списке/аудите (зафиксировано в XML-doc
JoinService). Обратный порядок позволил бы «осиротить» тенант при гонке двух активаций — CAS-первым надёжнее.
- Узкая гонка «email занят между предпроверкой и CreateUserAsync» не перехватывается (unique-индекс users.login
даст 500, не 400): на единственном инстансе core при существующих путях создания пользователей (join + Task 7)
окно практически отсутствует; при желании — обработать DbUpdateException в адаптере/эндпоинте позже.
- EF-CAS (ExecuteUpdateAsync) InMemory-провайдером не исполняется — тест CAS-перехода на EF-адаптере ⚠ Manual
(Postgres); семантика покрыта фейком и JoinFlowTests (как удаления AuthStore, см. OperatorAuthStoreTests).
- FakeInviteStore рас-запечатан, TryActivateAsync virtual — только для детерминированной Race-симуляции в
JoinFlowTests (семантика фейка не менялась).
# Task 6 report — Активация инвайта: POST /api/join (пользователь + тенант + провижининг)
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 961/961 PASS (933 → +28 новых).
Docker выключен — живой curl/psql-сценарий (оператор создаёт инвайт → /api/join → psql: тенант + схема
провижинена + пользователь + invite activated; повторный join → 400) ⚠ Manual; эквивалент покрыт
HTTP-тестами на in-process Kestrel (JoinEndpointHttpTests) + unit на фейках (JoinFlowTests, 28 кейсов).
## Состав
### Создано/изменено — модуль `src/core/Deal.Modules.Tenants/Application/`
- `Models/JoinResultDto.cs` (нов.) — результат ActivateAsync: Ok + Error-коды (notFound/expired/used/revoked/
emailMismatch/emailTaken/passwordTooShort; тексты — HTTP-слой), при успехе Login/UserId/TenantId.
- `JoinService.cs` (нов.) — координатор активации: (1) чтение кода `InvitesService.GetByCodeAsync` (ленивый
expired, Task 5), (2) не-pending статус → used/revoked/expired, (3) сверка email (нормализация — общий
`InvitesService.NormalizeEmail`), (4) пароль ≥4 (единый источник `AuthService.MinNewPasswordLength`),
(5) глобальная уникальность email предпроверкой `IAuthStore.FindUserByLoginAsync` (users.login unique),
(6) **CAS-резервирование** pending→activated, (7) тенант: TenantId инвайта задан → присоединение (без
провижининга), пуст → `TenantService.CreateTenantAsync(name ?? email, новый Guid)` (провижинит схему сам),
(8) `authStore.CreateUserAsync` (login=email, хэш `IPasswordHasher`/Argon2id). Ошибки — кодами без
исключений (паттерн AuthService/InvitesService); успех: пользователь+тенант создаются ТОЛЬКО победителем
гонки (проигравший CAS ничего не создаёт).
- `IInviteStore.cs` (изм.) — новый метод `TryActivateAsync(code, activatedAt, ct)` — атомарный условный
переход (CAS) с контрактом «только из pending; иначе false без изменений» (ревью T5: не перезаписать
параллельный revoke).
- `InvitesService.cs` (изм.) — `TryActivateAsync(code, ct)` — прокси CAS с ActivatedAt=now.
- `AuthService.cs` (изм.) — `MinNewPasswordLength` стал public (единый источник «4» для join и смены пароля).
- `TenantModuleRegistrar.cs` (изм.) — `AddScoped<JoinService>()`.
### Изменено — `src/core/Deal.Infrastructure/`
- `Persistence/Repositories/InviteStore.cs``TryActivateAsync`: `ExecuteUpdateAsync` с
`WHERE Code=@code AND Status='pending'` (один SQL-оператор, строки ≥1 → true).
### Создано — `src/core/Deal.Api/Endpoints/`
- `JoinRequest.cs` (нов.) — тело {code, email, name?, password}.
- `JoinEndpoint.cs` (нов.) — **POST /api/join** (публичная, без сессии; вне /api/operator): успех
`{ok:true, login}`, кука НЕ ставится (план Task 6: далее обычный /api/auth/login); отказы — 400 {detail}:
«Приглашение не найдено» / «Срок действия приглашения истёк» / «Приглашение уже использовано» (activated) /
«Приглашение отозвано» (revoked) / «Email не совпадает с приглашением» / «Этот email уже зарегистрирован» /
«Пароль слишком короткий (минимум 4 символа)» (текст как в AuthEndpoints). Аудит успеха —
`invite_activated` (актор tenant: ActorId=новый UserId, TenantId, детали email+code). MapJoinEndpoint в
`Program.cs` после MapOperatorInvitesEndpoints.
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/`
- `FakeTenantStore.cs` (нов.) — ITenantRepository с поддержкой CreateAsync (реестр join-потока).
- `FakeTenantProvisioner.cs` (нов.) — ITenantProvisioner, журналирует провижиненные схемы (ассерт «вызван
1 раз» / «ни разу» для существующего тенанта).
- `FakeInviteStore.cs` (изм.) — TryActivateAsync с CAS-семантикой; класс рас-запечатан (Race-симуляция в
JoinFlowTests), метод virtual.
- `JoinFlowTests.cs` (нов., 19 кейсов: 14 фактов + 5 theory) — успех (новый тенант: пользователь+тенант+провижинер 1 раз+invite
activated; имя name ?? email; существующий тенант без провижининга), unknown/expired (ленивый переход
сохраняется)/activated/revoked код, email mismatch (инвайт остаётся pending), email занят (без побочных
эффектов), короткий пароль (theory), **CAS**: повторная активация → used без дублей; параллельный revoke/
активация, успевшие до CAS → revoked/used без создания пользователя/тенанта (Race-подкласс фейка);
store-контракт TryActivateAsync после revoke → false (revoke не перезаписан), unknown → false.
- `JoinEndpointHttpTests.cs` (нов., 9 кейсов) — эквивалент curl: успех (200, {ok,login}, без Set-Cookie,
аудит invite_activated с актором/email/code), повторный join → 400 used, чужой email → 400, revoked → 400,
expired → 400, unknown → 400, короткий пароль → 400, занятый email → 400, инвайт на существующий тенант
(без создания нового).
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors; проверено и по diagnostics).
- `dotnet test tests/Deal.Tests.Unit` — 961/961 PASS (933 + 28 новых).
- Живой curl/psql-сценарий Task 6 (реальный TenantProvisioningService и Postgres) — ⚠ Manual (docker
выключен); эквивалент — HTTP-тесты на in-process Kestrel с фейками + EF-CAS (условный UPDATE) завязан
на реляционный провайдер.
## Concerns
- **TenantLimits-строка при активации НЕ создаётся** (в плане Task 6: «вставка через порт ITenantLimitStore
из Task 8; до Task 8 допускается прямая вставка»): порт лимитов — зона Task 8 (GetOrCreateAsync с
дефолт-бюджетом из `TokenBudgetDefaults`), до него вводить одноразовый seam не стал; ленивое создание
строки с дефолт-бюджетом при первом чтении/списании (Ruling 3 «Reset — ленивый», Task 8/9/10) покрывает
поведение, acceptance Task 6 строку лимитов не проверяет. Если нужно жёсткое eager-создание — добавить
вызов порта в JoinService при реализации Task 8.
- **HTTP-код для истёкшего инвайта — 400** (план Task 6 прямо перечисляет 400 «Срок действия приглашения
истёк»; Ruling 2 называет это «410-семантикой» — то есть смыслом «ресурс больше недоступен», EndpointResults.Gone
в этой ручке не используется, чтобы все отказы активации были однородными 400 как в плане).
- **Сообщения used/revoked различаются** («Приглашение уже использовано» / «Приглашение отозвано» — тексты
плана Task 6). Если требование «не раскрывать статус кода» жёстче — свести оба к одному тексту в
JoinEndpoint (тесты поменяются точечно).
- Пользователь/тенант создаются ПОСЛЕ CAS-резервирования: сбой создания (например, провижининг) — серверная
500, инвайт остаётся activated; аномалия видна оператору в списке/аудите (зафиксировано в XML-doc
JoinService). Обратный порядок позволил бы «осиротить» тенант при гонке двух активаций — CAS-первым надёжнее.
- Узкая гонка «email занят между предпроверкой и CreateUserAsync» не перехватывается (unique-индекс users.login
даст 500, не 400): на единственном инстансе core при существующих путях создания пользователей (join + Task 7)
окно практически отсутствует; при желании — обработать DbUpdateException в адаптере/эндпоинте позже.
- EF-CAS (ExecuteUpdateAsync) InMemory-провайдером не исполняется — тест CAS-перехода на EF-адаптере ⚠ Manual
(Postgres); семантика покрыта фейком и JoinFlowTests (как удаления AuthStore, см. OperatorAuthStoreTests).
- FakeInviteStore рас-запечатан, TryActivateAsync virtual — только для детерминированной Race-симуляции в
JoinFlowTests (семантика фейка не менялась).
+142 -142
View File
@@ -1,142 +1,142 @@
# Task 7 report — Оператор-тенанты: create/список/детали, suspend/unsuspend, impersonation
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 1003/1003 PASS (961 → +42 новых за две
итерации: +32 первично, +10 в fix по ревью). Docker выключен — применение миграции
`SessionsImpersonationMark` к БД, реальный провижининг схем (TenantProvisioningService) и живая
curl/psql-приёмка ⚠ Manual; эквивалент curl-минимума покрыт HTTP-тестами на in-process Kestrel
(OperatorTenantsEndpointsHttpTests, 18 кейсов) + unit на фейках/сервисах.
## Fix (ревью, раунд 2)
- **Добавлен POST /api/operator/tenants (create):** тело {name, email?} (см. решение про budget? ниже);
создаёт тенанта (Status active) через `TenantService.CreateTenantAsync` (строка реестра + провижининг
схемы — в хосте фейк `FakeTenantProvisioner`, реальный провижининг ⚠ Manual) + аудит `tenant_created`
(актор-оператор, TenantId нового тенанта, детали {tenantId, name, email?}) + возврат созданного тенанта
{id, name, status, createdAt}; при email — дополнительно {ownerEmail, initialPassword} (владелец создан).
Ошибки: 400 «Имя тенанта обязательно» / «Некорректный email» / «Этот email уже зарегистрирован»; 401 без
операторской сессии. Тесты: service 5 (успех+провижининг 1 раз, email-владелец с одноразовым паролем,
email занят/невалиден/пустое имя без побочных эффектов) + HTTP 5 (401; успех+аудит tenant_created;
email-владелец: raw-пароль не в хранилище; 400 пустое имя/занятый email).
- **`tenant_created` на join-пути НЕ добавлен** (проверено): событие по каталогу Ruling 4/`AuditEvents`
«Оператор создал тенанта»; join создаёт тенанта как следствие активации пользователем и пишет только
`invite_activated` (план Task 6, отчёт Task 6); Task 16-приёмка аудит-ленту tenant_created не требует.
- **Зафиксированные решения (в коде-комментариях и здесь — для api-map/техдок Task 16):**
(а) **suspended → HTTP 403** с текстом плана «Учётная запись приостановлена. Обратитесь к оператору»
(семантика: учётка существует, доступ запрещён; неверные учётные данные остаются 401 без раскрытия
статуса). Acceptance плана Task 7/Task 16 формулирует «login … 401» — финальный ответ 403, решение
зафиксировано в AuthEndpoints и здесь, приёмочный текст не менялся;
(б) **impersonation suspended-тенанта разрешён** (операторский доступ, полностью аудируется
impersonation_started/stopped; ИИ-расход всё равно заморожен бюджетным гейтом Task 9) — зафиксировано в
XML-doc `AuthService.ImpersonateAsync` и здесь (заметка для техдок §10, Task 16);
(в) **PATCH /tenants/{id} {status} заменён на явные POST /suspend и /unsuspend** (аудит тот же
tenant_status_changed) — контракт-отклонение для api-map Task 16;
(г) **`budget?` в create не принимается** до Task 8/10: применение бюджета требует порта лимитов
(ITenantLimitStore/TokenBudgetDefaults, Task 8; PATCH /tenants/{id}/limit — Task 10). Прецеденты:
лимит-поля списка отложены планом Task 7, join-строка лимитов отложена в Task 6 (ленивый GetOrCreate).
Поле-заглушка «принять и не применить» не вводилось (молчаливая потеря бюджета оператора);
(д) **email при create = пользователь-владелец сразу** (план Task 7) с **одноразовым паролем**
(16 url-safe символов; наружу — один раз в ответе; в БД — только Argon2id-хэш; в аудит/логи не пишется;
владелец меняет его после первого входа). Прямой ввод пароля оператором в контракте плана не предусмотрен,
а пользователь без пароля неиспользуем (change-password требует старый) — решение зафиксировано в
`TenantCreateResultDto`/`TenantAdminService` и здесь. Если владелец не нужен — email опускается, владелец
заводится инвайтом (Ruling 2), уже реализовано Task 5/6.
## Состав
### Создано/изменено — модуль `src/core/Deal.Modules.Tenants/Application/`
- `TenantStatuses.cs` (нов.) — константы `Active`/`Suspended` (колонка public.tenants.Status; единый источник
для suspend-гейта и операторских ручек).
- `Models/TenantCreateResultDto.cs` (нов.) — результат create: Ok + Error-коды (nameRequired/invalidEmail/
emailTaken), Tenant + OwnerUserId/OwnerLogin/InitialPassword (одноразовый пароль владельца, только в ответе).
- `Models/TenantListItemDto.cs`, `Models/TenantDetailDto.cs`, `Models/TenantStatusChangeResultDto.cs`,
`Models/ImpersonationResultDto.cs`, `Models/LogoutResultDto.cs` (нов.) — результаты сервисов (паттерн
JoinResultDto: Ok + Error-коды, тексты на HTTP-слое).
- `TenantAdminService.cs` (нов./изм.) — операторский реестр: `CreateAsync(name, email?)` (тенант active +
провижининг через `TenantService`; email → владелец с одноразовым паролем, предпроверка уникальности
users.login до создания тенанта), `ListAsync` (реестр + счётчик пользователей), `GetAsync` (детали +
пользователи), `ChangeStatusAsync` (suspend/unsuspend; идемпотентно — Changed=false при том же статусе,
аудит не дублируется).
- `AuthService.cs` (изм.) — конструктор + `ITenantRepository`; **suspend-гейт логина** (Ruling 10(5)):
после проверки пароля статус тенанта — suspended → `LoginResultDto.ErrorTenantSuspended` с UserId/TenantId
(порядок «сначала пароль»: неверный пароль не раскрывает приостановку); `ImpersonateAsync(tenantId, login?,
operatorId)` — tenant-сессия целевого пользователя (логин задан и обязан принадлежать тенанту; пуст — первый
пользователь по CreatedAt) с маркером `SessionDto.ImpersonatedByOperatorId` (пароль НЕ меняется/не нужен);
`LogoutAsync` возвращает `LogoutResultDto?` для удалённой impersonation-сессии (аудит stopped).
- `Models/LoginResultDto.cs` (изм.) — опциональный `Error` + `ErrorTenantSuspended`.
- `Models/SessionDto.cs` (изм.) — `ImpersonatedByOperatorId` (Guid?, null — обычная сессия).
- `IAuthStore.cs` (изм.) — `ListUsersByTenantIdAsync` (пользователи тенанта по CreatedAt, затем Login).
- `ITenantRepository.cs` (изм.) — `UpdateStatusAsync(id, status)` → bool (запись существовала).
- `TenantService.cs` (изм.) — `ActiveStatus`-константа заменена на `TenantStatuses.Active`.
- `AuditEvents.cs` (изм.) — добавлено `ImpersonationStopped = "impersonation_stopped"` (ревью: полный аудит
start/stop; каталог теперь 12 событий; `tenant_created` был в каталоге с Task 4, теперь пишется).
- `TenantModuleRegistrar.cs` (изм.) — `AddScoped<TenantAdminService>()`.
### Изменено — `src/core/Deal.Infrastructure/`
- `Persistence/Entities/SessionEntity.cs` + `Repositories/AuthStore.cs` — маркер `ImpersonatedByOperatorId`
(маппинг DTO↔сущность в обе стороны) и `ListUsersByTenantIdAsync` (EF, OrderBy CreatedAt/Login).
- `Persistence/Repositories/TenantRepository.cs``UpdateStatusAsync` (отслеживаемая запись + SaveChanges —
проверяемо на InMemory-провайдере в отличие от ExecuteUpdateAsync).
- **Миграция `20260907192419_SessionsImpersonationMark`** (нов.) — `sessions.ImpersonatedByOperatorId uuid null`
в public (создана `dotnet ef migrations add`, к БД НЕ применена — docker выключен, ⚠ Manual).
### Создано/изменено — `src/core/Deal.Api/`
- `Endpoints/OperatorTenantCreateRequest.cs` (нов.) — тело {name, email?}.
- `Endpoints/OperatorTenantImpersonateRequest.cs` (нов.) — тело {login?}.
- `Endpoints/OperatorTenantsEndpoints.cs` (нов./изм.) — группа `/api/operator/tenants` (401 без операторской
сессии): `POST ""` (create {name, email?} → {id,name,status,createdAt} + аудит `tenant_created`; при email —
{ownerEmail, initialPassword}), `GET ""` → {items:[{id,name,status,createdAt,usersCount}]}, `GET /{id:guid}`
детали+users, `POST /{id:guid}/suspend` и `/unsuspend` → {ok,status} + аудит `tenant_status_changed` (только
при реальном изменении; 404 «Тенант не найден»), `POST /{id:guid}/impersonate` → {sessionToken, expiresAt,
tenantId, login} + аудит `impersonation_started` (DetailJson targetLogin+tenantId; 404 тенант/пользователь,
400 нет пользователей). Токен используется как значение куки deal_session (Acceptance: «работает как
deal_session»).
- `Endpoints/AuthEndpoints.cs` (изм.) — login suspended-тенанта → **403** «Учётная запись приостановлена.
Обратитесь к оператору» + tenant_login_failed с **TenantId и ActorId** (ревью T4: failed-логины suspended-
тенанта пишут tenantId); logout удалённой impersonation-сессии → аудит `impersonation_stopped` (актор —
оператор по маркеру сессии, TenantId + login).
- `Http/EndpointResults.cs` (изм.) — `Forbidden(detail)` (403 {detail}).
- `Program.cs` (изм.) — `MapOperatorTenantsEndpoints()`.
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/` (+42)
- `AuthServiceTests` (изм., +9): suspended → ErrorTenantSuspended без сессии; wrong-password на suspended →
generic (не раскрывает статус); impersonation по логину (маркер оператора в сессии), без логина (первый
пользователь), чужой тенант/нет пользователя/нет тенанта/нет пользователей; logout impersonation →
LogoutResultDto, обычной сессии → null.
- `TenantAdminServiceTests` (нов., 11): create (тенант+провижининг 1 раз; email-владелец: нормализация,
хэш одноразового пароля в хранилище; email занят/невалиден/пустое имя — без побочных эффектов), список со
счётчиками, детали, suspend/unsuspend (Changed), идемпотентный повторный suspend, not-found.
- `AuthStoreTests` (нов., 2, EF InMemory): ListUsersByTenantId (только тенант, порядок), маркер сессии.
- `TenantRepositoryTests` (нов., 2, EF InMemory): UpdateStatusAsync true/false.
- `OperatorTenantsEndpointsHttpTests` (нов., 18) — эквивалент curl-минимума плана на Kestrel+фейках: 401 без
оператора (create/list/detail/suspend/impersonate); **create → 200 {id,...} + аудит tenant_created**, create с
email (владелец создан, raw-пароль не в хранилище), 400 пустое имя/занятый email; список/детали со
счётчиками; **suspend → login 403-текст → аудит failed c tenantId → unsuspend → login ok**; идемпотентный
suspend без дубля аудита; 404 несуществующего тенанта; **impersonate → sessionToken работает как deal_session
на /api/auth/me, операторский контур для него 401 (нет пересечения), logout → impersonation_stopped + me
401**; дефолт-первый пользователь; 404/400 ошибки. Фейки: FakeAuthStore/FakeTenantStore (+ListUsers/
UpdateStatus), FakeTenantRepository/FakeTenantRegistry/ThrowingTenantRepository — реализованы новые методы;
OperatorAuthHttpHost расширен (tenantStore + ITenantProvisioner-фейк + Map).
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors.
- `dotnet test Deal.sln` — 1003/1003 PASS (961 + 42; filtered-прогон классов Task 7: 29/29 за раунд 2).
- Применение `SessionsImpersonationMark` (`dotnet ef database update --context DealDbContext`) и реальный
провижининг схемы при create — ⚠ Manual (docker выключен); эквивалент провижининга — `FakeTenantProvisioner`
(service-тест: вызван ровно один раз, схема `tenant_<id>`), маркер сессии — EF InMemory round-trip
в AuthStoreTests.
## Concerns
- HTTP-коды/контрактные решения (403, PATCH→POST suspend|unsuspend, budget?-не-принимается, email-владелец с
одноразовым паролем, impersonation suspended разрешён) — зафиксированы в разделе «Fix (ревью, раунд 2)» и в
XML-doc/комментариях кода; актуализация api-map/техдок (включая приёмочный текст «login 401» → финальный
403) — Task 16.
- **Маркер impersonation — колонка `sessions.ImpersonatedByOperatorId`** (+миграция): без него logout не
отличил бы impersonation-сессию от обычной для аудита stopped (ревью «полный аудит»). Сессии истекают сами —
expired impersonation без logout событие stopped не пишет (документировано в AuthService/LogoutResultDto).
- Пользователь-владелец при create создаётся ПОСЛЕ провижининга тенанта: сбой на этом шаге — серверная 500,
тенант остаётся без владельца (аномалия видна оператору; email-предпроверка закрывает типовой случай).
- Счётчики пользователей в списке считаются per-tenant чтением пользователей (N+1 на масштабах админки
осознан; сводка usage/лимитов — Task 810).
- Отчёты Task 3/4 фиксировали каталог аудита «11 событий Ruling 4» — после ревью добавлено 12-е
(`impersonation_stopped`), а `tenant_created` теперь реально пишется операторским create; актуализация
каталога в техдок — Task 16.
# Task 7 report — Оператор-тенанты: create/список/детали, suspend/unsuspend, impersonation
**Status:** complete. Build Deal.sln 0 warnings / 0 errors; unit 1003/1003 PASS (961 → +42 новых за две
итерации: +32 первично, +10 в fix по ревью). Docker выключен — применение миграции
`SessionsImpersonationMark` к БД, реальный провижининг схем (TenantProvisioningService) и живая
curl/psql-приёмка ⚠ Manual; эквивалент curl-минимума покрыт HTTP-тестами на in-process Kestrel
(OperatorTenantsEndpointsHttpTests, 18 кейсов) + unit на фейках/сервисах.
## Fix (ревью, раунд 2)
- **Добавлен POST /api/operator/tenants (create):** тело {name, email?} (см. решение про budget? ниже);
создаёт тенанта (Status active) через `TenantService.CreateTenantAsync` (строка реестра + провижининг
схемы — в хосте фейк `FakeTenantProvisioner`, реальный провижининг ⚠ Manual) + аудит `tenant_created`
(актор-оператор, TenantId нового тенанта, детали {tenantId, name, email?}) + возврат созданного тенанта
{id, name, status, createdAt}; при email — дополнительно {ownerEmail, initialPassword} (владелец создан).
Ошибки: 400 «Имя тенанта обязательно» / «Некорректный email» / «Этот email уже зарегистрирован»; 401 без
операторской сессии. Тесты: service 5 (успех+провижининг 1 раз, email-владелец с одноразовым паролем,
email занят/невалиден/пустое имя без побочных эффектов) + HTTP 5 (401; успех+аудит tenant_created;
email-владелец: raw-пароль не в хранилище; 400 пустое имя/занятый email).
- **`tenant_created` на join-пути НЕ добавлен** (проверено): событие по каталогу Ruling 4/`AuditEvents`
«Оператор создал тенанта»; join создаёт тенанта как следствие активации пользователем и пишет только
`invite_activated` (план Task 6, отчёт Task 6); Task 16-приёмка аудит-ленту tenant_created не требует.
- **Зафиксированные решения (в коде-комментариях и здесь — для api-map/техдок Task 16):**
(а) **suspended → HTTP 403** с текстом плана «Учётная запись приостановлена. Обратитесь к оператору»
(семантика: учётка существует, доступ запрещён; неверные учётные данные остаются 401 без раскрытия
статуса). Acceptance плана Task 7/Task 16 формулирует «login … 401» — финальный ответ 403, решение
зафиксировано в AuthEndpoints и здесь, приёмочный текст не менялся;
(б) **impersonation suspended-тенанта разрешён** (операторский доступ, полностью аудируется
impersonation_started/stopped; ИИ-расход всё равно заморожен бюджетным гейтом Task 9) — зафиксировано в
XML-doc `AuthService.ImpersonateAsync` и здесь (заметка для техдок §10, Task 16);
(в) **PATCH /tenants/{id} {status} заменён на явные POST /suspend и /unsuspend** (аудит тот же
tenant_status_changed) — контракт-отклонение для api-map Task 16;
(г) **`budget?` в create не принимается** до Task 8/10: применение бюджета требует порта лимитов
(ITenantLimitStore/TokenBudgetDefaults, Task 8; PATCH /tenants/{id}/limit — Task 10). Прецеденты:
лимит-поля списка отложены планом Task 7, join-строка лимитов отложена в Task 6 (ленивый GetOrCreate).
Поле-заглушка «принять и не применить» не вводилось (молчаливая потеря бюджета оператора);
(д) **email при create = пользователь-владелец сразу** (план Task 7) с **одноразовым паролем**
(16 url-safe символов; наружу — один раз в ответе; в БД — только Argon2id-хэш; в аудит/логи не пишется;
владелец меняет его после первого входа). Прямой ввод пароля оператором в контракте плана не предусмотрен,
а пользователь без пароля неиспользуем (change-password требует старый) — решение зафиксировано в
`TenantCreateResultDto`/`TenantAdminService` и здесь. Если владелец не нужен — email опускается, владелец
заводится инвайтом (Ruling 2), уже реализовано Task 5/6.
## Состав
### Создано/изменено — модуль `src/core/Deal.Modules.Tenants/Application/`
- `TenantStatuses.cs` (нов.) — константы `Active`/`Suspended` (колонка public.tenants.Status; единый источник
для suspend-гейта и операторских ручек).
- `Models/TenantCreateResultDto.cs` (нов.) — результат create: Ok + Error-коды (nameRequired/invalidEmail/
emailTaken), Tenant + OwnerUserId/OwnerLogin/InitialPassword (одноразовый пароль владельца, только в ответе).
- `Models/TenantListItemDto.cs`, `Models/TenantDetailDto.cs`, `Models/TenantStatusChangeResultDto.cs`,
`Models/ImpersonationResultDto.cs`, `Models/LogoutResultDto.cs` (нов.) — результаты сервисов (паттерн
JoinResultDto: Ok + Error-коды, тексты на HTTP-слое).
- `TenantAdminService.cs` (нов./изм.) — операторский реестр: `CreateAsync(name, email?)` (тенант active +
провижининг через `TenantService`; email → владелец с одноразовым паролем, предпроверка уникальности
users.login до создания тенанта), `ListAsync` (реестр + счётчик пользователей), `GetAsync` (детали +
пользователи), `ChangeStatusAsync` (suspend/unsuspend; идемпотентно — Changed=false при том же статусе,
аудит не дублируется).
- `AuthService.cs` (изм.) — конструктор + `ITenantRepository`; **suspend-гейт логина** (Ruling 10(5)):
после проверки пароля статус тенанта — suspended → `LoginResultDto.ErrorTenantSuspended` с UserId/TenantId
(порядок «сначала пароль»: неверный пароль не раскрывает приостановку); `ImpersonateAsync(tenantId, login?,
operatorId)` — tenant-сессия целевого пользователя (логин задан и обязан принадлежать тенанту; пуст — первый
пользователь по CreatedAt) с маркером `SessionDto.ImpersonatedByOperatorId` (пароль НЕ меняется/не нужен);
`LogoutAsync` возвращает `LogoutResultDto?` для удалённой impersonation-сессии (аудит stopped).
- `Models/LoginResultDto.cs` (изм.) — опциональный `Error` + `ErrorTenantSuspended`.
- `Models/SessionDto.cs` (изм.) — `ImpersonatedByOperatorId` (Guid?, null — обычная сессия).
- `IAuthStore.cs` (изм.) — `ListUsersByTenantIdAsync` (пользователи тенанта по CreatedAt, затем Login).
- `ITenantRepository.cs` (изм.) — `UpdateStatusAsync(id, status)` → bool (запись существовала).
- `TenantService.cs` (изм.) — `ActiveStatus`-константа заменена на `TenantStatuses.Active`.
- `AuditEvents.cs` (изм.) — добавлено `ImpersonationStopped = "impersonation_stopped"` (ревью: полный аудит
start/stop; каталог теперь 12 событий; `tenant_created` был в каталоге с Task 4, теперь пишется).
- `TenantModuleRegistrar.cs` (изм.) — `AddScoped<TenantAdminService>()`.
### Изменено — `src/core/Deal.Infrastructure/`
- `Persistence/Entities/SessionEntity.cs` + `Repositories/AuthStore.cs` — маркер `ImpersonatedByOperatorId`
(маппинг DTO↔сущность в обе стороны) и `ListUsersByTenantIdAsync` (EF, OrderBy CreatedAt/Login).
- `Persistence/Repositories/TenantRepository.cs``UpdateStatusAsync` (отслеживаемая запись + SaveChanges —
проверяемо на InMemory-провайдере в отличие от ExecuteUpdateAsync).
- **Миграция `20260907192419_SessionsImpersonationMark`** (нов.) — `sessions.ImpersonatedByOperatorId uuid null`
в public (создана `dotnet ef migrations add`, к БД НЕ применена — docker выключен, ⚠ Manual).
### Создано/изменено — `src/core/Deal.Api/`
- `Endpoints/OperatorTenantCreateRequest.cs` (нов.) — тело {name, email?}.
- `Endpoints/OperatorTenantImpersonateRequest.cs` (нов.) — тело {login?}.
- `Endpoints/OperatorTenantsEndpoints.cs` (нов./изм.) — группа `/api/operator/tenants` (401 без операторской
сессии): `POST ""` (create {name, email?} → {id,name,status,createdAt} + аудит `tenant_created`; при email —
{ownerEmail, initialPassword}), `GET ""` → {items:[{id,name,status,createdAt,usersCount}]}, `GET /{id:guid}`
детали+users, `POST /{id:guid}/suspend` и `/unsuspend` → {ok,status} + аудит `tenant_status_changed` (только
при реальном изменении; 404 «Тенант не найден»), `POST /{id:guid}/impersonate` → {sessionToken, expiresAt,
tenantId, login} + аудит `impersonation_started` (DetailJson targetLogin+tenantId; 404 тенант/пользователь,
400 нет пользователей). Токен используется как значение куки deal_session (Acceptance: «работает как
deal_session»).
- `Endpoints/AuthEndpoints.cs` (изм.) — login suspended-тенанта → **403** «Учётная запись приостановлена.
Обратитесь к оператору» + tenant_login_failed с **TenantId и ActorId** (ревью T4: failed-логины suspended-
тенанта пишут tenantId); logout удалённой impersonation-сессии → аудит `impersonation_stopped` (актор —
оператор по маркеру сессии, TenantId + login).
- `Http/EndpointResults.cs` (изм.) — `Forbidden(detail)` (403 {detail}).
- `Program.cs` (изм.) — `MapOperatorTenantsEndpoints()`.
### Созданы тесты — `src/core/tests/Deal.Tests.Unit/` (+42)
- `AuthServiceTests` (изм., +9): suspended → ErrorTenantSuspended без сессии; wrong-password на suspended →
generic (не раскрывает статус); impersonation по логину (маркер оператора в сессии), без логина (первый
пользователь), чужой тенант/нет пользователя/нет тенанта/нет пользователей; logout impersonation →
LogoutResultDto, обычной сессии → null.
- `TenantAdminServiceTests` (нов., 11): create (тенант+провижининг 1 раз; email-владелец: нормализация,
хэш одноразового пароля в хранилище; email занят/невалиден/пустое имя — без побочных эффектов), список со
счётчиками, детали, suspend/unsuspend (Changed), идемпотентный повторный suspend, not-found.
- `AuthStoreTests` (нов., 2, EF InMemory): ListUsersByTenantId (только тенант, порядок), маркер сессии.
- `TenantRepositoryTests` (нов., 2, EF InMemory): UpdateStatusAsync true/false.
- `OperatorTenantsEndpointsHttpTests` (нов., 18) — эквивалент curl-минимума плана на Kestrel+фейках: 401 без
оператора (create/list/detail/suspend/impersonate); **create → 200 {id,...} + аудит tenant_created**, create с
email (владелец создан, raw-пароль не в хранилище), 400 пустое имя/занятый email; список/детали со
счётчиками; **suspend → login 403-текст → аудит failed c tenantId → unsuspend → login ok**; идемпотентный
suspend без дубля аудита; 404 несуществующего тенанта; **impersonate → sessionToken работает как deal_session
на /api/auth/me, операторский контур для него 401 (нет пересечения), logout → impersonation_stopped + me
401**; дефолт-первый пользователь; 404/400 ошибки. Фейки: FakeAuthStore/FakeTenantStore (+ListUsers/
UpdateStatus), FakeTenantRepository/FakeTenantRegistry/ThrowingTenantRepository — реализованы новые методы;
OperatorAuthHttpHost расширен (tenantStore + ITenantProvisioner-фейк + Map).
## Проверки
- `dotnet build Deal.sln` — 0 warnings / 0 errors.
- `dotnet test Deal.sln` — 1003/1003 PASS (961 + 42; filtered-прогон классов Task 7: 29/29 за раунд 2).
- Применение `SessionsImpersonationMark` (`dotnet ef database update --context DealDbContext`) и реальный
провижининг схемы при create — ⚠ Manual (docker выключен); эквивалент провижининга — `FakeTenantProvisioner`
(service-тест: вызван ровно один раз, схема `tenant_<id>`), маркер сессии — EF InMemory round-trip
в AuthStoreTests.
## Concerns
- HTTP-коды/контрактные решения (403, PATCH→POST suspend|unsuspend, budget?-не-принимается, email-владелец с
одноразовым паролем, impersonation suspended разрешён) — зафиксированы в разделе «Fix (ревью, раунд 2)» и в
XML-doc/комментариях кода; актуализация api-map/техдок (включая приёмочный текст «login 401» → финальный
403) — Task 16.
- **Маркер impersonation — колонка `sessions.ImpersonatedByOperatorId`** (+миграция): без него logout не
отличил бы impersonation-сессию от обычной для аудита stopped (ревью «полный аудит»). Сессии истекают сами —
expired impersonation без logout событие stopped не пишет (документировано в AuthService/LogoutResultDto).
- Пользователь-владелец при create создаётся ПОСЛЕ провижининга тенанта: сбой на этом шаге — серверная 500,
тенант остаётся без владельца (аномалия видна оператору; email-предпроверка закрывает типовой случай).
- Счётчики пользователей в списке считаются per-tenant чтением пользователей (N+1 на масштабах админки
осознан; сводка usage/лимитов — Task 810).
- Отчёты Task 3/4 фиксировали каталог аудита «11 событий Ruling 4» — после ревью добавлено 12-е
(`impersonation_stopped`), а `tenant_created` теперь реально пишется операторским create; актуализация
каталога в техдок — Task 16.
@@ -1,79 +1,79 @@
# Task 8 report — Лимиты-ядро: TenantLimits (бюджет токенов, период, ленивый reset, recorder расхода)
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 8 (L366386), Ruling 3/4. Проект НЕ git.
Сборка `dotnet build Deal.sln` — 0 warnings/0 errors; `dotnet test Deal.sln`**1029/1029 PASS** (было 961; +68 новых).
## Состав
**Создано — модуль `src/core/Deal.Modules.Tenants/Application/`** (1 тип = 1 файл, XML-doc, комментарии русские):
- `TokenLimitPeriods.cs` — типы периода: `Month="month"`/`Day="day"` (1:1 со значениями БД).
- `TokenBudgetDefaults.cs` — дефолт нового тенанта: `DefaultBudgetTokens = 10_000_000`, `DefaultPeriod = month`
(Ruling 3) + готовый набор `Default` (`TokenLimitDefaults`); константы остаются фолбэком env-переопределения.
- `Models/TokenLimitDefaults.cs` — record `(BudgetTokens, Period)`: параметры лениво создаваемой строки.
- `Models/TenantLimitDto.cs` — строка public.tenant_limits (без статуса тенанта).
- `Models/BudgetStateDto.cs` — `{TenantId, BudgetTokens, Period, PeriodStart, UsedTokens, Status, Allowed,
Warned80, NotifiedExhausted}` 1:1 со списком плана; Allowed = статус active && бюджет не исчерпан.
- `TokenBudgetService.cs` — период-математика: `IsPeriodExpired` (месяц календарный +1 месяц / день +1 сутки,
now ≥ конца окна), пороги 80% (`budget budget/5` целочисленно, без double) и 100%, остаток `RemainingTokens`.
- `ITenantLimitStore.cs` — порт: `GetOrCreateAsync(tenantId, ct, defaults?)` (лениво с дефолтом, «закрыт путь
чтения»), `GetStateAsync`, `AddUsageAsync` (ленивый reset + инкремент + пересчёт флагов одним сохранением),
`UpdateBudgetAsync` (сброс флагов; отбрасывает отрицательный бюджет/чужой период), `TryMarkWarnedAsync`/
`TryMarkNotifiedExhaustedAsync` (CAS-установка флага, возврат «только что установлен» — для SSE-алерта Task 9).
**Создано/изменено — `src/core/Deal.Infrastructure/`**:
- `Persistence/Repositories/TenantLimitStore.cs` (создан) — EF-адаптер на DealDbContext (public.tenant_limits):
read-modify-write отслеживаемой строки (НЕ ExecuteSql, Ruling 3: одиночный инстанс, конкурентность на
тенанта сериализована воркер-гейтами); часы инъекцией `Func<DateTimeOffset>` (эталон MlStatusCache) — тесты
reset на фиксированном «сейчас»; статус тенанта для BudgetStateDto читается из public.tenants тем же
контекстом (нет строки → suspended/Allowed=false — безопасный дефолт).
- `Integrations/AiUsageLedger.cs` → **переименован в `Integrations/TokenUsageRecorder.cs`** (расширен): `AddAsync`
пишет (1) инкремент UsedTokens в tenant_limits по usage.Total (ITenantLimitStore, Guid из ITenantContext) и
(2) по-прежнему lifetime-сумму {prompt, completion, total} в KV aiTokenUsage (формат этапа 6 не тронут).
usage.Total=0 → KV пишется, строка лимита не заводится; usage null → no-op.
- `ServiceCollectionExtensions.cs` — `AddDealPersistence(TokenLimitDefaults? tenantLimitDefaults = null)`:
scoped `ITenantLimitStore` → `TenantLimitStore` с дефолтом (null → константа модуля); в ветке
`Services:Ai:UseLocal=false` `AddScoped<AiUsageLedger>` → `AddScoped<TokenUsageRecorder>`.
- `Integrations/GrpcAiClassifier.cs`, `Integrations/GrpcAiTools.cs` — тип/имена поля и ctor `TokenUsageRecorder`
(точка вызова прежняя — успешные RPC после ответа), XML-doc актуализированы (Ruling 3).
**Изменено — `src/core/Deal.Api/Program.cs`**: чтение env `DEAL_DEFAULT_AI_BUDGET` (константа ключа в шапке),
`ResolveDefaultAiBudget` (нечисловое/≤0 → `TokenBudgetDefaults.DefaultBudgetTokens`), период — всегда month;
`AddDealPersistence(tenantLimitDefaults)` (комментарий: значение читается на старте, ленивый GetOrCreate на
путях чтения/записи, в т.ч. список тенантов Task 7/10).
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+68, из них новых 31, остальное — расширения сценариев)**: `FakeTenantLimitStore.cs`
(поведение зеркалит EF-адаптер: ленивый GetOrCreate/reset/флаги/TryMark*, статус тенанта настраивается),
`TokenBudgetServiceTests.cs` (границы месяца/дня — ровно на конце окна, «31 января + месяц» календарный,
пороги 80/100, остаток, дефолты), `TenantLimitStoreTests.cs` (acceptance: запись с PeriodStart прошлого месяца
обнуляет UsedTokens и ставит PeriodStart=now; 700+500 → 500; списание 700+100=800 выставляет Warned80; исчерпание
→ NotifiedExhausted и Allowed=false; смена бюджета сбрасывает флаги; TryMark* один раз на порог; невалидные
аргументы UpdateBudget → throw), `TokenUsageRecorderTests.cs` (списание total + lifetime-KV; накопление; null/no-op;
total=0 → KV без строки лимита; вне tenant-контекста → throw). Обновлены хелперы/ассерты GrpcAiClassifierTests/
GrpcAiToolsTests/PipelineWorkerGrpcAiTests (списание в FakeTenantLimitStore: 540/3700/900/360/210/2530), DI-тест
IntegrationsDiTests (scoped-фейк ITenantLimitStore в BuildProvider — recorder резолвится при UseLocal=false).
## Проверки
- `dotnet build Deal.sln` — 0 warnings/0 errors (TreatWarningsAsErrors); diagnostics — чисто.
- `dotnet test Deal.sln` — 1029/1029 PASS, 0 fail (запуск с rebuild; счётчики: 961 до Task 8 + 68).
- Миграций нет: таблица/конфигурация tenant_limits — Task 1 (SystemSaaS); модель не менялась. Применение к БД
(docker выключен) и psql — ⚠ Manual, как в задачах 1–7.
## Concerns
- **Env vs константа (Ruling 3 + контекст задачи).** Дефолт-бюджет читается из `DEAL_DEFAULT_AI_BUDGET` в
Program.cs; константа `TokenBudgetDefaults` остаётся источником фолбэка и периодом month — оба требования
закрыты, значение регистрируется в адаптере один раз на старте. Смена env требует рестарта core (как остальные
выборы конфигурации, Ruling 6).
- **Два учёта не транзакционны друг с другом** (tenant_limits в DealDbContext и KV в TenantDbContext тенанта —
разные контексты): порядок — лимиты → lifetime-KV. Сбой лимит-записи всплывает вызывающему (как и KV-сбой на
этапе 6); рассинхрон на одиночном инстансе не ожидается.
- **ok=false классификации тоже списывается** (usage ответа модели был, Ruling 3 «успешные RPC») — точка вызова
recorder'а сохранена 1:1 с этапом 6 (тест ok=false: 900 списано); локальный fallback воркера не затронут.
- **Строка лимита при нулевом usage не создаётся** (KV пишется, как раньше) — ленивый GetOrCreate остаётся
первому ненулевому списанию или операторскому чтению (Task 7/10 list тоже закрыт через порт).
- Регистрация `ITenantLimitStore` — в AddDealPersistence (EF-адаптер), а не в AddDealIntegrations (задача Task 9
регистрирует там гейт/декораторы и TokenBudgetService); в IntegrationsDiTests scoped-фейк хранилища подставлен
в BuildProvider по паттерну остальных фейков.
- Для Task 9 готовы: GetState/AddUsage-возврат BudgetStateDto c Allowed/Status, TryMarkWarned/NotifiedExhausted
(CAS), сброс флагов при UpdateBudget (Task 10 PATCH), дефолт-бюджет строки операторского чтения.
# Task 8 report — Лимиты-ядро: TenantLimits (бюджет токенов, период, ленивый reset, recorder расхода)
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 8 (L366386), Ruling 3/4. Проект НЕ git.
Сборка `dotnet build Deal.sln` — 0 warnings/0 errors; `dotnet test Deal.sln`**1029/1029 PASS** (было 961; +68 новых).
## Состав
**Создано — модуль `src/core/Deal.Modules.Tenants/Application/`** (1 тип = 1 файл, XML-doc, комментарии русские):
- `TokenLimitPeriods.cs` — типы периода: `Month="month"`/`Day="day"` (1:1 со значениями БД).
- `TokenBudgetDefaults.cs` — дефолт нового тенанта: `DefaultBudgetTokens = 10_000_000`, `DefaultPeriod = month`
(Ruling 3) + готовый набор `Default` (`TokenLimitDefaults`); константы остаются фолбэком env-переопределения.
- `Models/TokenLimitDefaults.cs` — record `(BudgetTokens, Period)`: параметры лениво создаваемой строки.
- `Models/TenantLimitDto.cs` — строка public.tenant_limits (без статуса тенанта).
- `Models/BudgetStateDto.cs` — `{TenantId, BudgetTokens, Period, PeriodStart, UsedTokens, Status, Allowed,
Warned80, NotifiedExhausted}` 1:1 со списком плана; Allowed = статус active && бюджет не исчерпан.
- `TokenBudgetService.cs` — период-математика: `IsPeriodExpired` (месяц календарный +1 месяц / день +1 сутки,
now ≥ конца окна), пороги 80% (`budget budget/5` целочисленно, без double) и 100%, остаток `RemainingTokens`.
- `ITenantLimitStore.cs` — порт: `GetOrCreateAsync(tenantId, ct, defaults?)` (лениво с дефолтом, «закрыт путь
чтения»), `GetStateAsync`, `AddUsageAsync` (ленивый reset + инкремент + пересчёт флагов одним сохранением),
`UpdateBudgetAsync` (сброс флагов; отбрасывает отрицательный бюджет/чужой период), `TryMarkWarnedAsync`/
`TryMarkNotifiedExhaustedAsync` (CAS-установка флага, возврат «только что установлен» — для SSE-алерта Task 9).
**Создано/изменено — `src/core/Deal.Infrastructure/`**:
- `Persistence/Repositories/TenantLimitStore.cs` (создан) — EF-адаптер на DealDbContext (public.tenant_limits):
read-modify-write отслеживаемой строки (НЕ ExecuteSql, Ruling 3: одиночный инстанс, конкурентность на
тенанта сериализована воркер-гейтами); часы инъекцией `Func<DateTimeOffset>` (эталон MlStatusCache) — тесты
reset на фиксированном «сейчас»; статус тенанта для BudgetStateDto читается из public.tenants тем же
контекстом (нет строки → suspended/Allowed=false — безопасный дефолт).
- `Integrations/AiUsageLedger.cs` → **переименован в `Integrations/TokenUsageRecorder.cs`** (расширен): `AddAsync`
пишет (1) инкремент UsedTokens в tenant_limits по usage.Total (ITenantLimitStore, Guid из ITenantContext) и
(2) по-прежнему lifetime-сумму {prompt, completion, total} в KV aiTokenUsage (формат этапа 6 не тронут).
usage.Total=0 → KV пишется, строка лимита не заводится; usage null → no-op.
- `ServiceCollectionExtensions.cs` — `AddDealPersistence(TokenLimitDefaults? tenantLimitDefaults = null)`:
scoped `ITenantLimitStore` → `TenantLimitStore` с дефолтом (null → константа модуля); в ветке
`Services:Ai:UseLocal=false` `AddScoped<AiUsageLedger>` → `AddScoped<TokenUsageRecorder>`.
- `Integrations/GrpcAiClassifier.cs`, `Integrations/GrpcAiTools.cs` — тип/имена поля и ctor `TokenUsageRecorder`
(точка вызова прежняя — успешные RPC после ответа), XML-doc актуализированы (Ruling 3).
**Изменено — `src/core/Deal.Api/Program.cs`**: чтение env `DEAL_DEFAULT_AI_BUDGET` (константа ключа в шапке),
`ResolveDefaultAiBudget` (нечисловое/≤0 → `TokenBudgetDefaults.DefaultBudgetTokens`), период — всегда month;
`AddDealPersistence(tenantLimitDefaults)` (комментарий: значение читается на старте, ленивый GetOrCreate на
путях чтения/записи, в т.ч. список тенантов Task 7/10).
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+68, из них новых 31, остальное — расширения сценариев)**: `FakeTenantLimitStore.cs`
(поведение зеркалит EF-адаптер: ленивый GetOrCreate/reset/флаги/TryMark*, статус тенанта настраивается),
`TokenBudgetServiceTests.cs` (границы месяца/дня — ровно на конце окна, «31 января + месяц» календарный,
пороги 80/100, остаток, дефолты), `TenantLimitStoreTests.cs` (acceptance: запись с PeriodStart прошлого месяца
обнуляет UsedTokens и ставит PeriodStart=now; 700+500 → 500; списание 700+100=800 выставляет Warned80; исчерпание
→ NotifiedExhausted и Allowed=false; смена бюджета сбрасывает флаги; TryMark* один раз на порог; невалидные
аргументы UpdateBudget → throw), `TokenUsageRecorderTests.cs` (списание total + lifetime-KV; накопление; null/no-op;
total=0 → KV без строки лимита; вне tenant-контекста → throw). Обновлены хелперы/ассерты GrpcAiClassifierTests/
GrpcAiToolsTests/PipelineWorkerGrpcAiTests (списание в FakeTenantLimitStore: 540/3700/900/360/210/2530), DI-тест
IntegrationsDiTests (scoped-фейк ITenantLimitStore в BuildProvider — recorder резолвится при UseLocal=false).
## Проверки
- `dotnet build Deal.sln` — 0 warnings/0 errors (TreatWarningsAsErrors); diagnostics — чисто.
- `dotnet test Deal.sln` — 1029/1029 PASS, 0 fail (запуск с rebuild; счётчики: 961 до Task 8 + 68).
- Миграций нет: таблица/конфигурация tenant_limits — Task 1 (SystemSaaS); модель не менялась. Применение к БД
(docker выключен) и psql — ⚠ Manual, как в задачах 1–7.
## Concerns
- **Env vs константа (Ruling 3 + контекст задачи).** Дефолт-бюджет читается из `DEAL_DEFAULT_AI_BUDGET` в
Program.cs; константа `TokenBudgetDefaults` остаётся источником фолбэка и периодом month — оба требования
закрыты, значение регистрируется в адаптере один раз на старте. Смена env требует рестарта core (как остальные
выборы конфигурации, Ruling 6).
- **Два учёта не транзакционны друг с другом** (tenant_limits в DealDbContext и KV в TenantDbContext тенанта —
разные контексты): порядок — лимиты → lifetime-KV. Сбой лимит-записи всплывает вызывающему (как и KV-сбой на
этапе 6); рассинхрон на одиночном инстансе не ожидается.
- **ok=false классификации тоже списывается** (usage ответа модели был, Ruling 3 «успешные RPC») — точка вызова
recorder'а сохранена 1:1 с этапом 6 (тест ok=false: 900 списано); локальный fallback воркера не затронут.
- **Строка лимита при нулевом usage не создаётся** (KV пишется, как раньше) — ленивый GetOrCreate остаётся
первому ненулевому списанию или операторскому чтению (Task 7/10 list тоже закрыт через порт).
- Регистрация `ITenantLimitStore` — в AddDealPersistence (EF-адаптер), а не в AddDealIntegrations (задача Task 9
регистрирует там гейт/декораторы и TokenBudgetService); в IntegrationsDiTests scoped-фейк хранилища подставлен
в BuildProvider по паттерну остальных фейков.
- Для Task 9 готовы: GetState/AddUsage-возврат BudgetStateDto c Allowed/Status, TryMarkWarned/NotifiedExhausted
(CAS), сброс флагов при UpdateBudget (Task 10 PATCH), дефолт-бюджет строки операторского чтения.
+104 -104
View File
@@ -1,104 +1,104 @@
# Task 9 report — Бюджетный гейт ИИ (декораторы + Local-fallback) и SSE-алерты 80/100%
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 9 (L388406), Ruling 3/5/7/11, замечание T7
(suspended замораживает ИИ — учтено в гейте через `BudgetStateDto.Allowed`). Проект НЕ git.
Сборка `dotnet build Deal.sln` — 0 warnings/0 errors; `dotnet test Deal.sln`**1047/1047 PASS** (было 1029 до Task 9;
+18 новых за задачу, из них +1 — fix-review).
## Fix (по review Task 9: флаги порогов выставляет только TryMark*)
**Проблема:** `TenantLimitStore.AddUsageAsync` (Task 8) выставлял `Warned80`/`NotifiedExhausted` через `|=` прямо при
списании → переход порога «съедался» записью: планировщик `BudgetAlertScheduler` на следующем проходе видел
`TryMark* = false`, и SSE-тост 80%/исчерпан при естественном расходе не публиковался никогда (срабатывала только
смена бюджета оператором, сбрасывающая флаги).
**Изменено:**
- `I/Persistence/Repositories/TenantLimitStore.cs` + `T/FakeTenantLimitStore.cs``AddUsageAsync` теперь только
инкрементирует `UsedTokens` (одно сохранение); установку флагов убрана. Флаги выставляет ТОЛЬКО `TryMark*`
(планировщик, момент фактического перехода порога). Проверено: `GetStateAsync.Allowed` не зависит от флагов —
считается от `Status == active && !IsExhausted(UsedTokens, BudgetTokens)` (used/budget), флаги — только индикаторы
«тост отправлен» для dedupe.
- `TM/Application/ITenantLimitStore.cs` — XML-doc актуализированы (AddUsage без флагов; TryMark* — единственный
установщик, Task 9).
- `A/Hosting/BudgetAlertScheduler.cs` — remark актуализирован (естественный расход виден ближайшим проходом).
- Тесты Task 8 (`T/TenantLimitStoreTests.cs`): `AddUsageAsync_Crossing80Percent_SetsWarned80`
`..._DoesNotSetWarned80`, `AddUsageAsync_Exhaustion_SetsNotifiedExhausted``..._DoesNotSetFlagsButDisallows`
(списание не ставит флаги; `Allowed=false` при исчерпании считается от used/budget).
- Новый тест планировщика `BudgetAlertSchedulerTests.RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold`:
AddUsage(850) → проход = ровно один тост 80%; повторный проход — без тоста; AddUsage(200, пересечение 100%) →
ещё ровно один тост (100%); повторный — пусто.
**Проверки fix:** `dotnet build Deal.sln` 0/0; `dotnet test Deal.sln` — 1047/1047 PASS (TokenBudgetServiceTests/
TenantLimitStoreTests/BudgetAlertSchedulerTests/TokenUsageRecorderTests — 30/30, полный прогон 1047/1047).
## Состав
**Создано — `src/core/Deal.Infrastructure/Integrations/`** (1 тип = 1 файл, XML-doc, комментарии русские):
- `BudgetedAiClassifier.cs` — декоратор порта `IAiClassifier` (порядок Grpc → Budgeted → наружу). Перед каждым
вызовом — гейт `ITenantLimitStore.GetStateAsync``BudgetStateDto.Allowed` (активен И бюджет не исчерпан;
лимит 0 запрещает ИИ с нуля; suspended трактуется Not Allowed, Ruling 3/10(5)). Запрещено → Local-реализация
`LocalAiClassifier` (фильтр `{pass:true, skipped:true}`, разбор ядра) — семантика aiEnabled=false/aiFail,
приём не блокируется, платный ИИ и его списание не происходят; разрешено → платный исполнитель как есть
(ошибки `AiUnavailableException` пробрасываются — ветки воркера не меняются).
- `BudgetedAiTools.cs` — декоратор порта `IAiTools`: при запрете гейта `EvaluateFitAsync` бросает
`AiUnavailableException` (воркер Discovery уходит в эвристику, код не меняется), `GenerateKeywordsAsync` отдаёт
мягкую ошибку `{ok:false, keywords:[], error}` (Ruling 3/11, эндпоинт отвечает HTTP 200). Тексты запрета
различают «исчерпан»/«приостановлен» (стабильные строки, Ruling 13).
**Создано — `src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs`** (эталон StorageTickScheduler): фоновый цикл 60 с,
первый проход сразу после старта, in-flight guard (Interlocked), graceful stop. Проход: реестр тенантов
(`ITenantRepository`) → каждый тенант в собственном scope → `TryMarkWarnedAsync`/`TryMarkNotifiedExhaustedAsync`
(CAS Task 8) → при true публикуется SSE-тост в канал тенанта: «ИИ-бюджет израсходован на 80%» /
«ИИ-бюджет исчерпан — обработка в локальном режиме», icon `bell`, тип `toast` (Ruling 11: новых SSE-типов нет);
без подписчиков — no-op (Ruling 5). Сбой одного тенанта не валит проход.
**Изменено:**
- `I/Integrations/ServiceCollectionExtensions.cs` (`AddDealIntegrations`) — gRPC-ветка (`UseLocal=false`):
регистрация `LocalAiClassifier` (fallback) и декораторов фабрикой поверх `GrpcAiClassifier`/`GrpcAiTools`
(зависимости — scoped `ITenantLimitStore`/`ITenantContext` + `ILogger`); Local-режим не тронут (Local и так
бесплатный — декоратор не нужен). Гейт через `ITenantLimitStore.GetStateAsync` (альтернатива «ITokenBudgetGate»
из плана: Task 8 отдаёт готовые Allowed/Status в `BudgetStateDto`, отдельного порта-гейта не создаём).
- `A/Program.cs``AddHostedService<BudgetAlertScheduler>()` после Bootstrap (реестр провижинен до первого
прохода) + актуализированы комментарии AI-режима.
- Тесты: `IntegrationsDiTests` (UseLocal=false → наружу `BudgetedAiClassifier`/`BudgetedAiTools`, Grpc-адаптеры
разрешимы под ними), `PipelineWorkerGrpcAiTests.CreateContext(port, limits?, budgeted?)` + remarks.
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+17, из них 16 новых + 1 pipeline-путь):**
- `BudgetedAiClassifierTests.cs` (6) — лимит 0 → Local-ветка (фильтр pass+skipped / разбор ядра, платный фейк не
вызван), лимит большой → платный фейк вызван и результат его, suspended → Local (фильтр и классификация),
Local-fallback == прямому вызову `LocalAiClassifier`.
- `BudgetedAiToolsTests.cs` (7) — исчерпано → `AiUnavailableException` (EvaluateFit), лимит 0 → исключение,
suspended → исключение/мягкая ошибка, Allowed → делегирование платному, GenerateKeywords исчерпано → мягкий
`{ok:false,...}`.
- `BudgetAlertSchedulerTests.cs` (3) — тост один раз на порог (два прохода: A=80% один тост, B=исчерпан — 80%+100%
по одному разу, повторный проход пуст), тенант ниже порога — без тоста и без флага, «уже исчерпан с нуля флагов»
→ оба тоста ровно по одному разу.
- `PipelineWorkerGrpcAiTests.Pump_BudgetExhausted_GateUsesLocalClassifierWithoutPaidRpc` — Acceptance «карточка
создаётся при исчерпании через Local»: реальный воркер + `BudgetedAiClassifier` над реальным GrpcAiClassifier к
in-proc фейк-ai-service; бюджет исчерпан → RPC 0 (Filter/Classify), карточка в inbox из Local-разбора, списаний
нет (UsedTokens не изменился), pump-счётчики AiStored=1/AiFail=0.
## Проверки
- `dotnet build Deal.sln` — 0 warnings/0 errors (TreatWarningsAsErrors + EnforceCodeStyleInBuild); diagnostics — чисто.
- `dotnet test Deal.sln` — 1046/1046 PASS, 0 fail (счётчики: 1029 до Task 9 + 17). Миграций/БД не требуется
(tenant_limits/флаги — Task 1/8); docker/psql — ⚠ Manual, как в задачах 1–8.
## Concerns
- **Флаги = «тост отправлен», устанавливаются только TryMark* (закрыто review-fix, см. раздел Fix).** Естественный
расход, пересекающий порог, теперь виден ближайшим проходом планировщика (60 с): ровно один тост на порог за период.
AddUsage флаги не трогает; `Allowed`/гейт считаются от used/budget.
- **Декоратор = «классификатор ответил» для воркера.** Local-fallback `ClassifyAsync` возвращает разбор (не бросает),
поэтому воркер на ИИ-пути ставит IsVacancyKnown=true и учит ML (как на успешной классификации) — это цена выбранной
планом семантики «исчерпано → Local-реализации (классификатор/фильтр)»; ветка aiFail/aiEnabled=false осталась бы,
если бы гейт бросал `AiUnavailableException`. Поведение соответствует плану (LocalAiClassifier — эталон fallback).
- **DI-тест** `IntegrationsDiTests` обновлён под декораторы (тип наружу — Budgeted*, Grpc-адаптеры разрешимы под ними).
- Для Task 10 готовы: `BudgetStateDto`-гейт в декораторах, флаги/сброс в UpdateBudget, один тост на порог за период.
## Файлы
Создано: `src/core/Deal.Infrastructure/Integrations/BudgetedAiClassifier.cs`, `.../BudgetedAiTools.cs`,
`src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs`; тесты `BudgetedAiClassifierTests.cs`, `BudgetedAiToolsTests.cs`,
`BudgetAlertSchedulerTests.cs` (+1 сценарий в `PipelineWorkerGrpcAiTests.cs`). Изменено: `ServiceCollectionExtensions.cs`
(AddDealIntegrations), `Program.cs` (hosted-служба + комментарии), `IntegrationsDiTests.cs`.
# Task 9 report — Бюджетный гейт ИИ (декораторы + Local-fallback) и SSE-алерты 80/100%
План: `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Task 9 (L388406), Ruling 3/5/7/11, замечание T7
(suspended замораживает ИИ — учтено в гейте через `BudgetStateDto.Allowed`). Проект НЕ git.
Сборка `dotnet build Deal.sln` — 0 warnings/0 errors; `dotnet test Deal.sln`**1047/1047 PASS** (было 1029 до Task 9;
+18 новых за задачу, из них +1 — fix-review).
## Fix (по review Task 9: флаги порогов выставляет только TryMark*)
**Проблема:** `TenantLimitStore.AddUsageAsync` (Task 8) выставлял `Warned80`/`NotifiedExhausted` через `|=` прямо при
списании → переход порога «съедался» записью: планировщик `BudgetAlertScheduler` на следующем проходе видел
`TryMark* = false`, и SSE-тост 80%/исчерпан при естественном расходе не публиковался никогда (срабатывала только
смена бюджета оператором, сбрасывающая флаги).
**Изменено:**
- `I/Persistence/Repositories/TenantLimitStore.cs` + `T/FakeTenantLimitStore.cs``AddUsageAsync` теперь только
инкрементирует `UsedTokens` (одно сохранение); установку флагов убрана. Флаги выставляет ТОЛЬКО `TryMark*`
(планировщик, момент фактического перехода порога). Проверено: `GetStateAsync.Allowed` не зависит от флагов —
считается от `Status == active && !IsExhausted(UsedTokens, BudgetTokens)` (used/budget), флаги — только индикаторы
«тост отправлен» для dedupe.
- `TM/Application/ITenantLimitStore.cs` — XML-doc актуализированы (AddUsage без флагов; TryMark* — единственный
установщик, Task 9).
- `A/Hosting/BudgetAlertScheduler.cs` — remark актуализирован (естественный расход виден ближайшим проходом).
- Тесты Task 8 (`T/TenantLimitStoreTests.cs`): `AddUsageAsync_Crossing80Percent_SetsWarned80`
`..._DoesNotSetWarned80`, `AddUsageAsync_Exhaustion_SetsNotifiedExhausted``..._DoesNotSetFlagsButDisallows`
(списание не ставит флаги; `Allowed=false` при исчерпании считается от used/budget).
- Новый тест планировщика `BudgetAlertSchedulerTests.RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold`:
AddUsage(850) → проход = ровно один тост 80%; повторный проход — без тоста; AddUsage(200, пересечение 100%) →
ещё ровно один тост (100%); повторный — пусто.
**Проверки fix:** `dotnet build Deal.sln` 0/0; `dotnet test Deal.sln` — 1047/1047 PASS (TokenBudgetServiceTests/
TenantLimitStoreTests/BudgetAlertSchedulerTests/TokenUsageRecorderTests — 30/30, полный прогон 1047/1047).
## Состав
**Создано — `src/core/Deal.Infrastructure/Integrations/`** (1 тип = 1 файл, XML-doc, комментарии русские):
- `BudgetedAiClassifier.cs` — декоратор порта `IAiClassifier` (порядок Grpc → Budgeted → наружу). Перед каждым
вызовом — гейт `ITenantLimitStore.GetStateAsync``BudgetStateDto.Allowed` (активен И бюджет не исчерпан;
лимит 0 запрещает ИИ с нуля; suspended трактуется Not Allowed, Ruling 3/10(5)). Запрещено → Local-реализация
`LocalAiClassifier` (фильтр `{pass:true, skipped:true}`, разбор ядра) — семантика aiEnabled=false/aiFail,
приём не блокируется, платный ИИ и его списание не происходят; разрешено → платный исполнитель как есть
(ошибки `AiUnavailableException` пробрасываются — ветки воркера не меняются).
- `BudgetedAiTools.cs` — декоратор порта `IAiTools`: при запрете гейта `EvaluateFitAsync` бросает
`AiUnavailableException` (воркер Discovery уходит в эвристику, код не меняется), `GenerateKeywordsAsync` отдаёт
мягкую ошибку `{ok:false, keywords:[], error}` (Ruling 3/11, эндпоинт отвечает HTTP 200). Тексты запрета
различают «исчерпан»/«приостановлен» (стабильные строки, Ruling 13).
**Создано — `src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs`** (эталон StorageTickScheduler): фоновый цикл 60 с,
первый проход сразу после старта, in-flight guard (Interlocked), graceful stop. Проход: реестр тенантов
(`ITenantRepository`) → каждый тенант в собственном scope → `TryMarkWarnedAsync`/`TryMarkNotifiedExhaustedAsync`
(CAS Task 8) → при true публикуется SSE-тост в канал тенанта: «ИИ-бюджет израсходован на 80%» /
«ИИ-бюджет исчерпан — обработка в локальном режиме», icon `bell`, тип `toast` (Ruling 11: новых SSE-типов нет);
без подписчиков — no-op (Ruling 5). Сбой одного тенанта не валит проход.
**Изменено:**
- `I/Integrations/ServiceCollectionExtensions.cs` (`AddDealIntegrations`) — gRPC-ветка (`UseLocal=false`):
регистрация `LocalAiClassifier` (fallback) и декораторов фабрикой поверх `GrpcAiClassifier`/`GrpcAiTools`
(зависимости — scoped `ITenantLimitStore`/`ITenantContext` + `ILogger`); Local-режим не тронут (Local и так
бесплатный — декоратор не нужен). Гейт через `ITenantLimitStore.GetStateAsync` (альтернатива «ITokenBudgetGate»
из плана: Task 8 отдаёт готовые Allowed/Status в `BudgetStateDto`, отдельного порта-гейта не создаём).
- `A/Program.cs``AddHostedService<BudgetAlertScheduler>()` после Bootstrap (реестр провижинен до первого
прохода) + актуализированы комментарии AI-режима.
- Тесты: `IntegrationsDiTests` (UseLocal=false → наружу `BudgetedAiClassifier`/`BudgetedAiTools`, Grpc-адаптеры
разрешимы под ними), `PipelineWorkerGrpcAiTests.CreateContext(port, limits?, budgeted?)` + remarks.
**Тесты — `src/core/tests/Deal.Tests.Unit/` (+17, из них 16 новых + 1 pipeline-путь):**
- `BudgetedAiClassifierTests.cs` (6) — лимит 0 → Local-ветка (фильтр pass+skipped / разбор ядра, платный фейк не
вызван), лимит большой → платный фейк вызван и результат его, suspended → Local (фильтр и классификация),
Local-fallback == прямому вызову `LocalAiClassifier`.
- `BudgetedAiToolsTests.cs` (7) — исчерпано → `AiUnavailableException` (EvaluateFit), лимит 0 → исключение,
suspended → исключение/мягкая ошибка, Allowed → делегирование платному, GenerateKeywords исчерпано → мягкий
`{ok:false,...}`.
- `BudgetAlertSchedulerTests.cs` (3) — тост один раз на порог (два прохода: A=80% один тост, B=исчерпан — 80%+100%
по одному разу, повторный проход пуст), тенант ниже порога — без тоста и без флага, «уже исчерпан с нуля флагов»
→ оба тоста ровно по одному разу.
- `PipelineWorkerGrpcAiTests.Pump_BudgetExhausted_GateUsesLocalClassifierWithoutPaidRpc` — Acceptance «карточка
создаётся при исчерпании через Local»: реальный воркер + `BudgetedAiClassifier` над реальным GrpcAiClassifier к
in-proc фейк-ai-service; бюджет исчерпан → RPC 0 (Filter/Classify), карточка в inbox из Local-разбора, списаний
нет (UsedTokens не изменился), pump-счётчики AiStored=1/AiFail=0.
## Проверки
- `dotnet build Deal.sln` — 0 warnings/0 errors (TreatWarningsAsErrors + EnforceCodeStyleInBuild); diagnostics — чисто.
- `dotnet test Deal.sln` — 1046/1046 PASS, 0 fail (счётчики: 1029 до Task 9 + 17). Миграций/БД не требуется
(tenant_limits/флаги — Task 1/8); docker/psql — ⚠ Manual, как в задачах 1–8.
## Concerns
- **Флаги = «тост отправлен», устанавливаются только TryMark* (закрыто review-fix, см. раздел Fix).** Естественный
расход, пересекающий порог, теперь виден ближайшим проходом планировщика (60 с): ровно один тост на порог за период.
AddUsage флаги не трогает; `Allowed`/гейт считаются от used/budget.
- **Декоратор = «классификатор ответил» для воркера.** Local-fallback `ClassifyAsync` возвращает разбор (не бросает),
поэтому воркер на ИИ-пути ставит IsVacancyKnown=true и учит ML (как на успешной классификации) — это цена выбранной
планом семантики «исчерпано → Local-реализации (классификатор/фильтр)»; ветка aiFail/aiEnabled=false осталась бы,
если бы гейт бросал `AiUnavailableException`. Поведение соответствует плану (LocalAiClassifier — эталон fallback).
- **DI-тест** `IntegrationsDiTests` обновлён под декораторы (тип наружу — Budgeted*, Grpc-адаптеры разрешимы под ними).
- Для Task 10 готовы: `BudgetStateDto`-гейт в декораторах, флаги/сброс в UpdateBudget, один тост на порог за период.
## Файлы
Создано: `src/core/Deal.Infrastructure/Integrations/BudgetedAiClassifier.cs`, `.../BudgetedAiTools.cs`,
`src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs`; тесты `BudgetedAiClassifierTests.cs`, `BudgetedAiToolsTests.cs`,
`BudgetAlertSchedulerTests.cs` (+1 сценарий в `PipelineWorkerGrpcAiTests.cs`). Изменено: `ServiceCollectionExtensions.cs`
(AddDealIntegrations), `Program.cs` (hosted-служба + комментарии), `IntegrationsDiTests.cs`.