22 KiB
Направление платформы: аутентификация, деньги, стандарты, скорость
Ратифицировано оркестратором №14 (05.08.2026) по направлению владельца 04.08 и ресёрч-пакету из четырёх направлений (OAuth · биллинг · индустриальные стандарты · производительность фронта), каждое со скептик-пассом. Все пины и цены сверены ЖИВЬЁМ 04–05.08.2026 против первоисточников (proxy.golang.org, go.dev/dl, npm registry, вендорские доки); пин по памяти не называется — перед установкой сверять заново. Что скептик опроверг — помечено ⚠ прямо в тексте.
1. Вход и аккаунты (П-6)
Форма: BFF — вход через OIDC, сессия остаётся НАША. Google (и любой следующий провайдер) даёт
только СОБЫТИЕ входа; его токены используются один раз в колбэке для доказательства личности и
выбрасываются — ничего вендорского не персистится и не живёт в памяти дольше запроса (требование
владельца о токенах исполнено буквально). Уже построенный слой сессий (internal/auth + pgstore)
остаётся без изменений: он же несёт учёт токенов и мгновенный отзыв, которого self-verifying токен
не даёт. Это ровно тот паттерн, который IETF BCP браузерных приложений (draft-ietf-oauth-browser-based-apps-27,
06.07.2026, в очереди RFC-редактора) называет строго рекомендуемым для приложений с персональными
данными и деньгами.
Библиотеки (протокольный риск отдаём, интеграцию пишем сами):
| Что | Пин | Релиз | Лицензия | Почему |
|---|---|---|---|---|
golang.org/x/oauth2 |
v0.36.0 | 11.02.2026 | BSD-3 | код-флоу + обмен + PKCE-хелперы; сопровождает Go-команда |
github.com/coreos/go-oidc/v3 |
v3.20.0 | 08.07.2026 | Apache-2.0 | discovery, JWKS с рефетчем по неизвестному kid, проверка подписи и claims |
Граф прирастает на 4 модуля (go-oidc · go-jose/v4 · x/oauth2 · compute/metadata). Отвергнуты:
zitadel/oidc (тянет второй роутер + otel + cors в stdlib-first граф), markbates/goth (последний
тег ~год, сидит на jwx v1.2 при живом v3, свой session-store конфликтует с нашим), self-hosted IdP
(Ory/Zitadel/Keycloak — второй stateful-сервис ради одного логина; Zitadel v4 к тому же AGPL),
hosted IdP (связка PII + доступность, а свою сессию всё равно держать ради учёта токенов).
Решения модели аккаунта (их не делает ни одна библиотека — ратифицированы здесь):
- Ключ личности — пара
(provider, sub)в таблицеidentities. Почта — только ПОДСКАЗКА для связывания и только приemail_verified(Google прямо предупреждает: почта меняется, primary identifier из неё делать нельзя). - ⚠ Коллизия почты — дыра, найденная скептиком: в
00001_identity.sqlusers.emailNOT NULL с уникальным индексом поlower(email). Что происходит, когда(google, subX)приносит почту, уже занятую другим пользователем, и обновляем ли мыusers.emailпри каждом входе — обязано быть решено ДО первого входа: именно здесь тихий баг связывания становится захватом аккаунта. - Ротация идентификатора сессии на границе входа (защита от фиксации) — обязательна.
- ⚠ Журнал входов (время, провайдер, класс устройства/IP) и ручка «отозвать все мои сессии» — день-один для сервиса, продающего токены: таблица сессий чистится свипом и аудитом не является.
- Первая НЕаутентифицированная ручка (
/auth/login) — первая же поверхность для ботов: лимит на исходящие state-записи обязателен вместе с ней.
2. Деньги: фри-тир и покупка токенов (П-7)
Форма «как у OpenRouter» — предоплаченный кредитный леджер с резервацией ДО работы. Проверено по публичным докам: OpenRouter — предоплаченные кредиты + фри-тир как счётчик запросов; Anthropic — кредиты + месячные потолки; Modal — фри-тир как ПЕРИОДИЧЕСКИЙ ГРАНТ в тот же леджер (самая чистая механика: отдельного кода фри-тира не существует вовсе). Берём модель Modal для фри-тира.
Минимальная схема (строить с П-7, не раньше): к существующему usage_windows — три таблицы:
credit_ledger (append-only, знаковые целые микро-доллары, типы grant|purchase|hold|hold_release|settlement|adjustment,
UNIQUE(source, source_id) как ключ идемпотентности), reservations (одна открытая на
engine_run_id), account_balances (кэш баланса в ТОЙ ЖЕ транзакции, что вставка в леджер, +
тест-инвариант balance == SUM(ledger)). Правки строк не существует по построению: ошибка чинится
новой записью. Дробей нет нигде — только целые микро-доллары.
Когда резервируем: в той же транзакции Postgres, что допускает задачу в очередь, ДО спавна
tmctl; движку передаём пер-книжный потолок ≤ зарезервированного, и тогда ЕГО собственный
жёсткий стоп остаётся последним рубежом, а наша резервация может быть консервативной, а не
реал-таймовой. Расчёт — на границе попытки, идемпотентно по (engine_run_id, attempt).
⚠ Открытый вопрос владельцу №1 (продуктовый, не технический): платить из России нечем. Paddle собственной политикой блокирует покупателей в России и Беларуси; у западных PSP compliant-пути нет. Для продукта, чей целевой язык — русский, это не деталь выбора вендора, а вопрос рынка и способа оплаты. Решать до того, как строить П-7.
Платёжный провайдер — НЕ выбираем сейчас (пользователей ноль). Когда дойдёт: только merchant-of-record, шорт-лист Paddle vs Stripe Managed Payments — ⚠ поправка скептика: у Stripe с февраля 2026 есть собственный MoR-продукт (Stripe — зарегистрированный продавец, налоги в 80+ странах), что снимает прежний аргумент «Paddle или сам плати VAT OSS»; Lemon Squeezy как независимый игрок мёртв (куплен Stripe в 2024). Покупка проектируется как ОДНА запись леджера с id события PSP в качестве ключа идемпотентности — тогда выбор вендора остаётся дешёвым.
Хвосты, которые нельзя забыть (скептик): чарджбэк приходит после того, как токены потрачены ⇒
леджер обязан переживать отрицательный баланс и иметь политику для прогонов в полёте · MoR продаёт
в валюте покупателя, а леджер — микро-доллары ⇒ либо фиксированные долларовые паки, либо правило FX
на момент покупки · движок отдаёт committed_usd как float64, а леджер целочисленный ⇒ правило
округления на шве расчёта назвать явно (леджер движка = НИЖНЯЯ граница, значит округляем вверх).
⚠ Открытый вопрос владельцу №2: источника денег ВО ВРЕМЯ попытки не существует. Движок держит
эксклюзивный лок (status --json недоступен), его stderr-INFO с ценами парсить запрещено доктриной,
словарь событий денег не несёт. Варианты: (A) версионированное денежное событие в словаре строки
103 — свежесть до стадии, но трогает движок и отменяет действующую доктрину «поток цифр не несёт»;
(B) консервативные пер-книжные потолки, расчёт на границе попытки — ноль правок движка,
пользователь не может перерасходовать сверх суммы открытых холдов, цена — пере-резервация;
(C) нарезка попыток. Рекомендация оркестратора: (B) сейчас, (A) — отдельным решением, если
замер покажет, что пере-резервация душит фри-тир. Строить П-7 можно на (B) без ожидания ответа.
3. Стандарты и стек: что взять, что не брать
Ответ на вопрос владельца «это же писали тысячу раз, есть хорошие библиотеки — так ведь?» — да, и наш стек уже взял правильные куски (pgx · goose · River · stdlib ServeMux/CSRF · slog · опаковые сессии). Менять стек не надо; надо добавить четыре вещи, пока репозиторий — скелет:
| Добавление | Пин | Статус |
|---|---|---|
| OIDC-вход | x/oauth2 v0.36.0 + go-oidc/v3 v3.20.0 | ратифицировано (§1) |
| Кодоген сервера из ратифицированной спеки OpenAPI 3.1 | oapi-codegen/v2 v2.8.0 (17.07.2026) |
⚠ ратифицирован ПОРЯДОК, не выбор: его поддержка 3.1 свежая ⇒ сперва доказательство ИСПОЛНЕНИЕМ на нашем файле 983 строк, вердикт после. Фолбэк — overlay 3.1→3.0, НЕ ogen (тянет otel-подграф) |
sqlc для денежных/квотных таблиц |
v1.31.1 | взять ДО того, как эти таблицы появятся: компиляционная проверка SQL на денежных путях, рантайм-зависимостей ноль |
golang.org/x/time/rate |
v0.15.0 | лимиты в процессе; долговечные пер-пользовательские — в Postgres |
Оставляем как есть: stdlib-роутинг (1.22+ закрыл разрыв; Request.Pattern — с 1.23),
pgx без ORM, goose библиотекой, River (в go.mod не заводить, пока не подключён), «никакого Redis»,
опаковые серверные сессии (OIDC их НЕ заменяет), stdlib-CSRF, slog, свой RFC 9457 problem+json,
zonky-стенд Postgres без root, пины линтера/govulncheck, изоляция графа зависимостей от движка.
Не берём: ORM · OpenTelemetry (позже: один сервис, одна БД, APM-бэкенда нет) · Temporal · Vault ·
feature-flag-платформы · CodeQL (на приватном репо платный) · gosec отдельным гейтом · SBOM/SLSA —
церемония на этом размере.
Базовые линии безопасности: OWASP ASVS 5.0.0, целевой L2 — ⚠ поправка скептика к карте
глав: в 5.0 сессии это V7, аутентификация V6, и есть НОВАЯ глава V10 «OAuth and OIDC» —
именно она написана под наше направление (ресёрч цитировал нумерацию 4.0 и главу V10 пропустил).
Плюс OWASP API Security Top 10 (2023) — API1 BOLA это буквально проверка владения книгой на каждом
/v0-маршруте, API4 — наш PD-2. Плюс уже действующие govulncheck-гейт и пины Go-модулей с sumdb.
Dependabot — да (граф крошечный, шума нет).
Тест-пол — тот, что поймал бы дефекты этой приёмки (иначе стандарт бесполезен):
- Тест на РЕАЛЬНО сконфигурированном
http.Server, а не на mux подhttptest: полу-кормленный POST обязан получить закрытие поReadTimeout; SIGTERM обязан дренировать запросы (сегодня падают оба — PD-2 и PD-9; ни один mux-тест этот класс не видит). - Инвариант сессии НЕЗАВИСИМЫМ оракулом: SHA-256 считается ВНЕ
auth.Digest, плюс ассерт «плейнтекст токена в БД не находится» (PD-1: свойство истинно, но моя посадка его не уронила). - Нативный фаззинг
go test -fuzzна NDJSON-декодере, оракулы — инварианты PD-10. - Дисциплина посадок мутаций (
ENGINEERING_STANDARDS.md§3.3) как постоянная проверка того, что свойства ЗАПИНЕНЫ, а не просто истинны. - Контрактные тесты против OpenAPI-спеки и нагрузочные (k6) — при первых живых ручках, не раньше.
Деплой: одна VM + systemd-юниты, бинари артефактами CI; Postgres сперва там же, управляемый — при
первой выручке. Не Kubernetes: дети-tmctl живут ЧАСАМИ, держат эксклюзивный лок на файлах книги на
локальном диске, и любой оркестратор, способный переселить под посреди прогона, нам враждебен.
Юниты писать сейчас — они же честный ответ на PD-13 (осиротевшие процессы движка): прогон в
cgroup-поддереве платформы, KillMode=mixed, Restart=on-failure, LoadCredential= для DSN и
клиентского секрета OAuth, MemoryMax, песочница ProtectSystem/PrivateTmp бесплатно.
4. Скорость: что это требует от платформы и контракта
Замер снял главный страх: пер-главный объём крошечный — ~3.4 тыс. символов исходника + ~10.2 тыс. перевода ≈ 27 КБ строк UTF-16 на главу, а контракт уже читает юниты ПО ГЛАВЕ, подписывает банк частями и отдаёт экспорт ссылкой. Риск не в экране чтения, а в четырёх местах, где сегодняшняя форма вынудила бы клиента держать книгу целиком. Требования, которые платформа обязана исполнить:
- Сжатие ответов. stdlib
net/httpНЕ сжимает — значит все выкладки «289 КБ против 46 КБ gzip» аспирационны, покаContent-Encodingне назван требованием (платформа или edge). Самая дешёвая победа для «фронт должен летать»; исполнить с первыми списочными ручками. - Условные запросы (
ETag/If-None-Match→ 304) на списочных чтениях. Дополняет дельта-чтения и, в отличие от них, работает для банка, который переписывается целиком. - Юниты — только пер-главно, никогда пер-книжно. Это несущее свойство памяти всего дизайна (рабочий набор 27 КБ против ~62 МБ); одна «удобная» ручка обнулила бы его молча — ревью-вопрос на каждое изменение контракта.
- Экспорт — артефакт по ссылке, сборка текста книги на клиенте запрещена явно (иначе та же проблема памяти заходит с чёрного хода на экране выгрузки).
- События потока сервер ВПРАВЕ склеивать (рекомендация ~4 применения/с), клиент обязан терпеть скачки счётчиков — иначе прогон на 9500 юнитов становится неограниченным источником рендера.
- Заголовок главы ограничить по длине на сервере — единственная неограниченная строка в списке из 5000 строк, всё остальное там целые числа.
- Поиск. Пер-книжного поиска в контракте нет вовсе. Развилка: серверный полнотекстовый (Postgres) · клиентский, но ТОЛЬКО в открытой главе / по списку заголовков (безопасен по памяти) · отложить. Решать при экране поиска, не раньше.
- Живой канал для до-прогонных состояний (
uploading/parsing, библиотека без прогонов) — его нет; при этом контракт запрещает фронту опрашивать read-модель. Дыра одинаково латентностная и продуктовая; закрывается либо строкой в спеке «до старта прогона состояние опрашивается», либо пользовательским потоком (он же закрыл бы пуш экспорта К-12). - SSE поверх HTTP/1.1 упирается в лимит ~6 соединений на источник при нескольких вкладках — на dev-стенде это выглядит как загадочное зависание; одна строка в доке стенда снимает класс.
- Персист манифеста глав (строка 100 единого бэклога) — несущий для латентности чтения: только он держит чтения на read-модели вместо 1.4–1.5 с ре-ингеста движка на книге 23 МБ.
Бюджеты (в батарею фронта по мере появления экранов): DOM ≤1400 узлов на тяжёлом экране ·
виртуализованный список ≤60 строк независимо от размера коллекции · медиана кадра прокрутки ≤17 мс,
p95 ≤33 мс · ни одной длинной задачи >200 мс на взаимодействие · страница списка ≤300 КБ ·
стартовый JS-роут ≤200 КБ gzip · ≤4 применения событий в секунду. ⚠ Инструмент — расширение уже
существующего Playwright-харнесса (CDP-метрики + longtask), библиотеку web-vitals НЕ брать:
RUM в MVP нет, потребителя у неё не существует.