189 lines
23 KiB
Markdown
189 lines
23 KiB
Markdown
# Направление платформы: аутентификация, деньги, стандарты, скорость
|
||
|
||
> Ратифицировано оркестратором №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 + доступность, а свою сессию всё равно держать ради учёта токенов).
|
||
|
||
**Решения модели аккаунта (их не делает ни одна библиотека — ратифицированы здесь):**
|
||
1. Ключ личности — пара `(provider, sub)` в таблице `identities`. Почта — только ПОДСКАЗКА для
|
||
связывания и только при `email_verified` (Google прямо предупреждает: почта меняется, primary
|
||
identifier из неё делать нельзя).
|
||
2. ⚠ **Коллизия почты — дыра, найденная скептиком:** в `00001_identity.sql` `users.email` NOT NULL
|
||
с уникальным индексом по `lower(email)`. Что происходит, когда `(google, subX)` приносит почту,
|
||
уже занятую другим пользователем, и обновляем ли мы `users.email` при каждом входе — обязано быть
|
||
решено ДО первого входа: именно здесь тихий баг связывания становится захватом аккаунта.
|
||
3. Ротация идентификатора сессии на границе входа (защита от фиксации) — обязательна.
|
||
4. ⚠ **Журнал входов** (время, провайдер, класс устройства/IP) и ручка «отозвать все мои сессии» —
|
||
день-один для сервиса, продающего токены: таблица сессий чистится свипом и аудитом не является.
|
||
5. Первая НЕаутентифицированная ручка (`/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) | **ВЗЯТЬ — доказано исполнением 05.08** (ниже); фолбэк overlay 3.1→3.0 не понадобился |
|
||
| `sqlc` для денежных/квотных таблиц | v1.31.1 | взять ДО того, как эти таблицы появятся: компиляционная проверка SQL на денежных путях, рантайм-зависимостей ноль |
|
||
| `golang.org/x/time/rate` | v0.15.0 | лимиты в процессе; долговечные пер-пользовательские — в Postgres |
|
||
|
||
**Доказательство исполнением по кодогену (05.08, $0, вне репозитория):** `oapi-codegen` v2.8.0 в
|
||
режиме `std-http-server` + `strict-server` на ратифицированной копии `openapi.yaml` (983 строки,
|
||
`openapi: 3.1.0`) — **exit 0, 2981 строка, `go build` чистый**; рантайм-граф прирастает одним
|
||
модулем (`oapi-codegen/runtime`, транзитивно uuid + go-jsonmerge), роутер не тянется — совместимо с
|
||
пином stdlib. Две проверки конструкций 3.1, на которых спотыкался генератор типов фронта:
|
||
✅ нулевой `kind` сгенерирован ПРАВИЛЬНО — `Kind *TermKind \`json:"kind"\`` без `omitempty`, то есть
|
||
«поле присутствует всегда, значение может быть `null`», ровно правило контракта §2.8;
|
||
⚠ **условие `if action=promote → dst` НЕ исполняется генерированным кодом** (`Dst *string
|
||
\`json:"dst,omitempty"\``) — та же слепота к 3.1-условиям, что у `openapi-typescript` (К-11).
|
||
Следствие, которое обязана знать каждая следующая сессия: **констрейнт БД
|
||
`bank_decisions_promote_has_dst` — ЕДИНСТВЕННЫЙ исполнитель этого правила на сервере**, не
|
||
подстраховка; удалять или ослаблять его нельзя, а любое новое условное требование спеки обязано
|
||
получить носителя в схеме или явную проверку в хендлере.
|
||
|
||
**Оставляем как есть:** 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 — да (граф крошечный, шума нет).
|
||
|
||
**Тест-пол — тот, что поймал бы дефекты этой приёмки** (иначе стандарт бесполезен):
|
||
1. Тест на РЕАЛЬНО сконфигурированном `http.Server`, а не на mux под `httptest`: полу-кормленный POST
|
||
обязан получить закрытие по `ReadTimeout`; SIGTERM обязан дренировать запросы (сегодня падают оба —
|
||
PD-2 и PD-9; ни один mux-тест этот класс не видит).
|
||
2. Инвариант сессии НЕЗАВИСИМЫМ оракулом: SHA-256 считается ВНЕ `auth.Digest`, плюс ассерт «плейнтекст
|
||
токена в БД не находится» (PD-1: свойство истинно, но моя посадка его не уронила).
|
||
3. Нативный фаззинг `go test -fuzz` на NDJSON-декодере, оракулы — инварианты PD-10.
|
||
4. Дисциплина посадок мутаций (`ENGINEERING_STANDARDS.md` §3.3) как постоянная проверка того, что
|
||
свойства ЗАПИНЕНЫ, а не просто истинны.
|
||
5. Контрактные тесты против 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 на главу, а контракт уже читает юниты ПО ГЛАВЕ, подписывает банк
|
||
частями и отдаёт экспорт ссылкой. Риск не в экране чтения, а в четырёх местах, где сегодняшняя форма
|
||
вынудила бы клиента держать книгу целиком. Требования, которые платформа обязана исполнить:
|
||
|
||
1. **Сжатие ответов.** stdlib `net/http` НЕ сжимает — значит все выкладки «289 КБ против 46 КБ gzip»
|
||
аспирационны, пока `Content-Encoding` не назван требованием (платформа или edge). Самая дешёвая
|
||
победа для «фронт должен летать»; исполнить с первыми списочными ручками.
|
||
2. **Условные запросы (`ETag`/`If-None-Match` → 304)** на списочных чтениях. Дополняет дельта-чтения
|
||
и, в отличие от них, работает для банка, который переписывается целиком.
|
||
3. **Юниты — только пер-главно, никогда пер-книжно.** Это несущее свойство памяти всего дизайна
|
||
(рабочий набор 27 КБ против ~62 МБ); одна «удобная» ручка обнулила бы его молча — ревью-вопрос на
|
||
каждое изменение контракта.
|
||
4. **Экспорт — артефакт по ссылке, сборка текста книги на клиенте запрещена явно** (иначе та же
|
||
проблема памяти заходит с чёрного хода на экране выгрузки).
|
||
5. **События потока сервер ВПРАВЕ склеивать** (рекомендация ~4 применения/с), клиент обязан терпеть
|
||
скачки счётчиков — иначе прогон на 9500 юнитов становится неограниченным источником рендера.
|
||
6. **Заголовок главы ограничить по длине на сервере** — единственная неограниченная строка в списке
|
||
из 5000 строк, всё остальное там целые числа.
|
||
7. **Поиск.** Пер-книжного поиска в контракте нет вовсе. Развилка: серверный полнотекстовый (Postgres)
|
||
· клиентский, но ТОЛЬКО в открытой главе / по списку заголовков (безопасен по памяти) · отложить.
|
||
Решать при экране поиска, не раньше.
|
||
8. **Живой канал для до-прогонных состояний** (`uploading`/`parsing`, библиотека без прогонов) — его
|
||
нет; при этом контракт запрещает фронту опрашивать read-модель. Дыра одинаково латентностная и
|
||
продуктовая; закрывается либо строкой в спеке «до старта прогона состояние опрашивается», либо
|
||
пользовательским потоком (он же закрыл бы пуш экспорта К-12).
|
||
9. **SSE поверх HTTP/1.1** упирается в лимит ~6 соединений на источник при нескольких вкладках —
|
||
на dev-стенде это выглядит как загадочное зависание; одна строка в доке стенда снимает класс.
|
||
10. **Персист манифеста глав (строка 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 нет, потребителя у неё не существует.
|