Cabinet

NOTE

Статус: Live (HEAD 5e4ce54, 2026-05-06). Base BC + 4 sub-BC уже работают: profile, customer, apikeys, credentials. Остаётся развитие UI (subscription, stats, oauth) и I4b/I4c keys-features (usage counter, IP allowlist).

Этот README описывает зонтичный BC cabinet, обслуживающий пользовательский личный кабинет Tracium (/cabinet/* UI + /api/cabinet/* HTTP). Читайте, когда нужно понять, какие подсистемы кабинета есть, как они взаимодействуют и что осталось до полного roadmap’а.

Назначение

Самообслуживание customer’а: профиль организации, выпуск API-ключей, управление учётными записями поставщиков (customer credentials), будущие screens — подписка, статистика, журнал событий, OAuth-привязки.

Статус документа

  • Тип знания: current service
  • Статус реализации: 4 sub-BC в backend/internal/core/cabinet/{apikeys,credentials,customer,profile} + UI в web/landing/src/app/cabinet/*
  • Текущее место кода:
    • backend/internal/core/cabinet/apikeys — HMAC-SHA-256 token storage + reveal-once + rotate + scopes/role + admin CRUD (I4a + RBAC Phase 2)
    • backend/internal/core/cabinet/customer — Customer entity (multi-owner, archive)
    • backend/internal/core/cabinet/profile — single-table customer_profile + atomic-outbox PG repo (I1)
    • backend/internal/core/cabinet/credentials/supplier_catalog — Go-const SupplierMeta для 5 поставщиков (ETM/IEK/Systeme/DKC/Russvet)
    • Customer credential CRUD — backend/internal/core/credentials/api/http/handler.go (clientBasePath /api/v1/credentials)
    • UI — web/landing/src/app/cabinet/{profile,keys,credentials,oauth,stats,subscription,security,archived}
  • Что читать дальше: ../../20-architecture/adr/0051-public-api-auth-and-api-key-rbac.md, ../../20-architecture/adr/0041-credentials-pool-and-secrets-isolation.md, ../public-api/README.md

Область действия

Входит:

  • HTTP API под /api/cabinet/* (JWT-cookie auth через nginx auth_request).
  • Профиль customer’а (юр.лицо, контакты, налоговые реквизиты).
  • Выпуск/ротация/блокировка API-ключей с scope-системой (whoami / products:read / search:read сейчас active; orders:write / credentials:* planned).
  • CRUD customer credentials для поставщиков (5 supported: ETM/IEK/Systeme/DKC/Russvet).
  • Правила цен клиента: список, карточка правила, жизненный цикл (активация/пауза/архив) и экран проверки правил на товаре (/cabinet/pricing-rules/playground).
  • Архивирование (soft-delete) аккаунта.

Не входит:

  • Public API (/v1/*) — отдельный edge-слой, JWT не принимает (ADR-0051). См. ../public-api/README.md.
  • Admin API (/api/v1/admin/*) — отдельный flow для платформы; admin может выпускать ключи за customer’ов, но это admin BC.
  • Биллинг и платёжный шлюз — пока не реализовано, planned.
  • OAuth-привязки (Google/Yandex/email-link) — planned, ComingSoon screen.

Публичный контракт

Вход

HTTP (cabinet, JWT-cookie):

Метод + путьПодсистема
GET /api/cabinet/profile / PATCH /api/cabinet/profile / POST /api/cabinet/profile/switch-type / POST /api/cabinet/profile/archiveprofile + customer
GET /api/cabinet/keys / POST /api/cabinet/keys / GET/PATCH/DELETE /api/cabinet/keys/{id} / POST /api/cabinet/keys/{id}/rotate / POST /api/cabinet/keys/{id}/revoke / GET /api/cabinet/scopes/catalogapikeys
GET /api/cabinet/credentials/suppliers/catalogcredentials.supplier_catalog
GET/POST /api/v1/credentials / GET/PATCH /api/v1/credentials/{id}/(secret|status)credentials (customer-scope)

Internal:

  • GET /_internal/auth/api-key/verify — nginx auth_request subrequest (используется public API path; не cabinet-side).

Выход

  • Outbox events для audit:
    • cabinet.apikey.{created,revoked,rotated,renamed,deleted}.v1
    • Profile change events.
  • Чтение customer credentials publish’ится через secret-resolver в ingestion BC (per-credential SessionManager).

Внутренняя архитектура

Каждый sub-BC — clean architecture (api/http/, app/, domain/, infra/pg/).

  • apikeys — самый сложный: HMAC pepper, 31-char trk_live_*/trk_test_* tokens, reveal-once UX, rotation (atomic single-tx), scopes/role с D9 active-vs-planned гейтом, admin-flow с created_by_admin audit field.
  • profile — debounced auto-save, optimistic concurrency через customer_version.
  • customer — owner-member graph, archive не удаляет данные.
  • credentials.supplier_catalog — Go-const SupplierMeta (5 поставщиков); add 6-го требует release backend’а.
  • pricing-rules UI — экраны живут в web/landing/src/app/cabinet/pricing-rules/*, общие компоненты вынесены в пакет @tracium/pricing-rules (web/shared/pricing-rules), чтобы админка и кабинет не расходились в трактовке статусов.

Проверка правил на товаре (playground)

Экран /cabinet/pricing-rules/playground намеренно не считает цену сам. Он вызывает боевой POST /v1/search/proposals — тот же эндпоинт и ту же авторизацию, которыми пользуется интеграция клиента, — и показывает пришедший adjustments[] по шагам. Совпадение с тем, что увидит внешний клиент, обеспечено не повторением формул, а тем, что это один и тот же вызов; отдельный «песочный» расчёт доказывал бы работу песочницы, а не продукта.

Товар выбирается поиском по названию, а не вводом идентификатора: у клиента на руках названия и артикулы, внутренних UUID каталога он не видит нигде. Подсказки даёт GET /v1/products/suggest — тот же ES-typeahead, которым пользуется админка, — открытый сессии кабинета точечно:

  • nginx: location = /v1/products/suggest (точное совпадение бьёт префикс /v1/products), диспетчер bearer/cookie, cookie-ветка через id.conf. Список, карточка, resolve и structured search остаются bearer-only;
  • Go: у этого одного маршрута обёртка RequireScopeOrLegacyJWTFor("products:read", …) вместо RequireScope — иначе сессия без заголовков X-Tr-* была бы отвергнута уже обработчиком.

Почему suggest, а не /v1/products/search. Structured search живёт в Postgres, и по одному свободному тексту его запрос вырождается в полный перебор: ILIKE с ведущим % (индексом не берётся), коррелированный EXISTS в supplier_offers на каждую строку и ORDER BY cp.name. На 1,28 млн канонов это минуты — замер прода 2026-08-17 дал 504 по таймауту шлюза. Подсказки обслуживает Elasticsearch: тот же запрос по названию — около секунды. Structured search остаётся тем, чем задуман: поиском внутри категории с требованиями к характеристикам, где candidate_ids сужает выборку заранее.

В подсказке показываются название, бренд, SKU, поставщик и наличие. Характеристик там нет и быть пока не может: их не отдаёт ни suggest, ни structured search, а признак значимости в публичном контуре не выставлен вовсе. Вывод значимых осей — отдельная доработка каталога и проекции ES.

Запрос уходит с policy.trace.inline_level = "summary" — это потолок для клиентской сессии (raw требует админского скоупа). На этом уровне шаги приходят полностью, а detail собственных правил клиента не редактируется, поэтому playground может назвать сработавшее правило по имени, а не только показать величину.

Каскад правил и разбор вычисления

Несколько правил одного клиента применяются последовательно: каждое следующее считается от цены, полученной предыдущим (CustomerPriceEngine.ComputeLinePrice, current = total). Но дойдут ли они до применения, решает политика объединения (stacking_policy) — и здесь легко ошибиться:

политикачто делает
stackвсе подходящие правила применяются подряд, проценты не складываются, а накладываются (10% затем 5% = −14,5%, не −15%)
best_ofиз правил с этой политикой остаётся одно, с наибольшим приоритетом
priority_wins, exclusive_stopвесь стек схлопывается в ОДНО правило — каскада не будет вовсе

ResolveStacking проверяет исключающие политики первыми: достаточно одного правила с priority_wins, чтобы остальные не применились. Правило «на всё» с этой политикой блокирует каскад у всего поставщика.

Playground показывает под каждым шагом формулу — ставку, восстановленную из «было» и «стало», базу расчёта, приоритет и политику. Для второго и далее шагов явно сказано, что база — результат предыдущего: именно здесь клиенты ожидают арифметическую сумму процентов и получают другое число.

Процент показывается объявленный, а не восстановленный

Ставка, вычисленная из «было» и «стало», почти всегда чуть меньше объявленной: деньги целочисленные, дельта округляется до копейки. Правило «10%» на позиции за 181,53 ₽ даёт дельту 18,15 ₽ вместо 18,153 ₽, то есть 9,9983% — и клиент читает это как ошибку счёта. Поэтому playground берёт процент из самого правила (version.effect.percent, запрос только по тем rule_id, что попали в разбор), а восстановленный остаётся запасным вариантом, если правило уже заархивировано.

Сумма изменения при этом печатается фактическая: округление — правда, врать про неё нельзя, скрывать проценты — тоже.

Сверка цен: наша база офферов против ответа поставщика

В режиме «Живой запрос» экран считает дважды — живой ответ и то же самое из кэша — и показывает обе цены до правил клиента вместе с расхождением. Без второго числа расхождение не увидеть, а именно оно и есть предмет спора по счёту.

Колонка «Ответ поставщика сейчас» заполняется только при source_mode = live. Если живого запроса не было, там прочерк, а не наше же наблюдение: иначе «Расхождение: совпадает» читалось бы как подтверждение цены поставщиком, которого не происходило.

Два обязательства этого блока:

  • причина, по которой живой ответ не использован, выводится словами из price.live_attempt_reason — и это НЕ догадка. Замер прода 2026-08-18: запрос к поставщику не уходил вовсе, причина live_customer_scope_mismatch. Писать в таком случае «поставщик не ответил» значит врать о причине: живой путь разрешён только при CredentialScopeCustomer (livecheck/live_scope_checker.go), системные учётные данные для него не годятся;
  • наличие СВОИХ учётных данных клиента проверяется через GET /api/v1/credentials и пишется рядом с поставщиком. Если своих доступов нет, живой запрос уходит по учётным данным платформы, и такая цена не является контрактной ценой клиента. На проде 2026-08-17 у клиента их ноль, а все шесть записей supplier_credentials имеют scope='system' — то есть персональных цен в системе сейчас нет ни у кого.

Обратная запись живого ответа в нашу базу

Живой ответ поставщика прогревает нашу базу: при успехе PriceResolverEngine.cacheWarm отсоединённо пишет наблюдение через ObservationWriter — UpsertLiveSnapshot (цена + остаток + срок) либо UpsertLivePrice. То есть расхождение, увиденное в ходе клиентского запроса, не теряется.

Оговорка, без которой механизм выглядит работающим, хотя не работает: прогрев случается только при УСПЕШНОЙ живой попытке, а она требует учётных данных со scope=customer. На проде 2026-08-18 таких нет ни одной (все шесть записей — system), поэтому живой путь отклоняется с live_customer_scope_mismatch, к поставщику никто не идёт, расхождения не возникает и прогревать нечего.

Подбор тоже поднимает позиции в очередь прогрева (enqueueProposalFocusOffers), как это давно делают сметы и подбор аналогов. Клиент спросил про позиции — значит их свежесть важна сейчас: замер прода 2026-08-18 показал наблюдения возрастом 6 ч (etm) и 15 ч (russvet) по проверяемому товару.

Важно, чем этот путь отличается от живого запроса: очередь прогрева обслуживается СИСТЕМНЫМИ учётными данными, клиентские ей не нужны. Поэтому подъём работает и тогда, когда живой запрос отклонён по live_customer_scope_mismatch — то есть сегодня это единственный способ, которым интерес клиента доходит до актуализации данных.

Устройство: очередь ключуется парой (supplier, supplier_sku), а OfferCandidate в подборе несёт только OfferRef и SupplierRef, поэтому в слое di живёт адаптер offerSKUResolverAdapter (порт OfferSKUResolver объявлен в infra/http — arch-lint запрещает тому импортировать offers BC). Подъём отсоединён от ответа, ограничен proposalFocusEnqueueLimit = 25 позициями на запрос (иначе популярный поиск затопил бы очередь и вытеснил всё остальное) и укладывается в 5 секунд. Метрика on_demand_enqueued_total разводит источники ярлыком trigger: estimate против search — иначе по ней нельзя понять, что именно греет базу.

Снимок правил перечитывается по таймеру

Движок цен читает правила не из базы, а из снимка в памяти (CustomerSnapshotHolder). До 2026-08-17 снимок собирался ровно один раз, в хуке fx.OnStart: активированное клиентом правило не доходило до расчёта до следующего перезапуска api-server, хотя кабинет уже писал «Применяется к расчётам». Дефект тихий — ошибок нет, просто цена не меняется.

CustomerSnapshotLoader.Run пересобирает снимок каждые PRICING_CUSTOMER_SNAPSHOT_RELOAD_INTERVAL (по умолчанию 30 с). Отсюда следует ожидаемая задержка: между активацией правила и изменением цены проходит до одного интервала плюс время построения проекции. Проверять применение правила сразу после активации бессмысленно.

Путь авторизации (важно при отладке 401). Кука кабинета сама по себе backend’у ничего не говорит: platform/auth.SubjectFromTrustedRequest читает только заголовок jwt, а ставит его nginx через includes/id.conf (auth_request → auth-service). Поэтому cookie-ветка диспетчера /v1/search/proposals обязана включать id.conf:

ВеткаУсловиеЧто даёт identity
@*_search_bearerесть Authorization: Bearerauth_request /_internal/auth/api-key/verify → заголовки X-Tr-*
@*_search_jwt_cookieиначе (сессия кабинета)include id.conf → заголовок jwt

До 2026-08-17 обе прод-ветки (@prod_search_jwt_cookie, @api_domain_search_jwt_cookie) шли pass-through с комментарием «api-server валидирует cookie самостоятельно». Это было неверно — куку не читает никто, и любой cookie-запрос к подбору получал 401. В локальном дереве (nginx-local/templates/tracium.dev.conf.template) id.conf стоял с самого начала, поэтому расхождение не проявлялось на dev-стенде.

Пользователь ≠ клиент: вторая ступень identity

Пройти авторизацию мало — надо ещё понять, от имени какого клиента действует сессия. JWT этого не несёт: auth-service про сущность «клиент» не знает и кладёт в токен только uid. Хуже того, platform/auth.SubjectFromTrustedJWT записывает одно и то же значение (firstNonEmpty(uid, customer_id), где uid побеждает) в оба поля Subject — и UserID, и CustomerID.

Отсюда правило: Subject.CustomerID у cookie-сессии — это идентификатор пользователя, а не клиента. Сопоставление живёт в BC cabinet, в таблице customer_members (WHERE user_id = $1 AND role = 'owner').

Две ступени разрешения, обе рабочие:

ПутьКто резолвит клиента
/api/cabinet/*, /api/v1/pricing/rules, /api/v1/credentialscustomerSubjectDualFlow → cabinet/customer/api/http.SubjectMiddleware(Lookup) — кладёт клиента в контекст кабинета
/v1/search/proposals, /v1/estimates*, ассистентapikeyauth.RequireScopeOrLegacyJWTFor + порт CustomerResolver — замещает Subject.CustomerID настоящим клиентом

Реализация порта — cabinet/customer/api/http.SubjectCustomerResolver (кеш на минуту поверх того же Lookup, чтобы разрешение не добавляло обращение к базе на каждый запрос /v1/*). Ошибка разрешения не рушит запрос: клиент остаётся прежним, факт пишется в auth.customer_resolve_failed — пробел в данных не должен превращаться в отказ обслуживания.

Почему это важно (замер прода 2026-08-17). Публичные пути читали Subject.CustomerID напрямую (search/proposal/infra/http/handler.go строил CustomerContext из него), то есть искали данные клиента по идентификатору пользователя. В логе подбора стоял customer_ref=afc1867d… (пользователь), а ценовые правила принадлежали ec107e7f… (клиент, владелец). Результат: ценовые правила клиента не применялись ни к одному запросу из кабинета — adjustments: [], слой трассы adjustments со статусом ok. Молча, потому что в CustomerRuleAdjustmentApplier «правил не нашлось» и «правила не подошли» — одна и та же ветка. Bearer-путь дефекта не имел: там X-Tr-Customer-Id ставит nginx из записи API-ключа.

Вывод для новых эндпоинтов под /v1/*: если путь берёт клиента из Subject.CustomerID, он обязан получить CustomerResolver. Иначе он тихо работает не с тем арендатором.

Зависимости

  • PostgreSQL — все sub-BC.
  • nginx — auth_request к auth-service (JWT-cookie validation).
  • auth-service — emit JWT after login.
  • outbox — atomic event publish.

Хранилище

ТаблицаНазначение
customerCustomer entity + version
customer_owner_memberOwner graph (multi-owner)
customer_profileSingle-table profile fields
api_keysToken storage (HMAC + scopes + role + env)
customer_credentialsCustomer-scope supplier credentials (encrypted secrets)
outbox_eventsAsync event publication

Конфигурация

Env varDefaultОписание
KEYS_HASH_PEPPER—≥32 bytes, HMAC pepper для API-keys hash. Required.
JWT_SECRET—HS512 secret для auth-service.
CABINET_PROFILE_AUTOSAVE_DEBOUNCE_MS400UI debounce для autosave.

Локальный запуск

make local-prod-up

UI на https://tracium.dev:4444/cabinet, API на https://api.tracium.dev:4444/api/cabinet/*.

Тестирование

  • Unit + integration: go test ./internal/core/cabinet/... + -tags=integration для PG-зависимых.
  • Frontend: cd web/landing && pnpm vitest run — vitest unit tests.
  • E2E: Playwright — web/landing/e2e/cabinet-{profile,keys,credentials}.spec.ts.
  • Live curl-verify: см. tracium_curl_live_verify memory + execution memos для каждого sub-BC.

Наблюдаемость

  • Metrics: apikey_auth_attempts_total{result}, cabinet_profile_save_total, etc.
  • Phase logs: platform/observability.StartPhase.
  • Outbox events для audit-trail.

Открытые вопросы / TODO

См. tracium_path_to_launch.md и tracium_cabinet_design_handoff.md:

  • I4b Keys — usage counter + IP allowlist + expired-tick + cross-env (~70-80 commits). Перепроектировать под ADR-0051 (auth-service / nginx-terminator, не in-process middleware).
  • I4c Keys — manual block + tier-limits + Playground (~45-50 commits).
  • Stats screen — HonestBars + per-key/per-credential time-series.
  • OAuth screen — Google/Yandex/email-link bindings.
  • Subscription/Billing — Billing BC + PSP integration.
  • Security audit log — events_log read-side из outbox.
  • Overview KPI — сейчас минимальный screen с email/userid.
  • Credentials health columns + test-connection — backend customer-DTO extension + UI.

Связанные документы