textmachine/platform/docs/PLATFORM_DIRECTION.md

177 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Направление платформы: аутентификация, деньги, стандарты, скорость
> Ратифицировано оркестратором №14 (05.08.2026) по направлению владельца 04.08 и ресёрч-пакету
> из четырёх направлений (OAuth · биллинг · индустриальные стандарты · производительность фронта),
> каждое со скептик-пассом. **Все пины и цены сверены ЖИВЬЁМ 0405.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) | ⚠ **ратифицирован ПОРЯДОК, не выбор**: его поддержка 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 — да (граф крошечный, шума нет).
**Тест-пол — тот, что поймал бы дефекты этой приёмки** (иначе стандарт бесполезен):
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.41.5 с ре-ингеста движка на книге 23 МБ.
Бюджеты (в батарею фронта по мере появления экранов): DOM ≤1400 узлов на тяжёлом экране ·
виртуализованный список ≤60 строк независимо от размера коллекции · медиана кадра прокрутки ≤17 мс,
p95 ≤33 мс · ни одной длинной задачи >200 мс на взаимодействие · страница списка ≤300 КБ ·
стартовый JS-роут ≤200 КБ gzip · ≤4 применения событий в секунду. ⚠ Инструмент — расширение уже
существующего Playwright-харнесса (CDP-метрики + longtask), библиотеку `web-vitals` НЕ брать:
RUM в MVP нет, потребителя у неё не существует.