textmachine/platform/docs/PLATFORM_DIRECTION.md

197 lines
24 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. Кредиты и лимиты (П-5/П-7) — решения владельца 05.08
**Оплаты нет и в бете не будет.** Первые аккаунты — пробные, сидят на фри-тире, ключи провайдеров
предоплачены владельцем. Платёжный провайдер не выбирается, не проектируется и в бэклог зоны как
работа не заходит; вернуться — когда появится решение продавать. ⚠ Прежняя редакция этого раздела
несла вывод «покупателям из России платить нечем» — он был ВЫВЕДЕН из целевого языка перевода, а не
установлен, и снят как необоснованный (владелец 05.08). Язык книги о географии плательщика не говорит.
**Модель лимита — БАЛАНС кредитов, а не окна с обнулением** (владелец 05.08: «не подписки, а покупка
токенов как у OpenRouter»). Следствия, несущие для схемы и контракта:
- Понятия «окно сброса» и `resets_at` НЕТ. Черновик `usage_windows` (00003) в этой форме не годится:
его `period=day|week` + `limit_micro_usd` — подписочная механика. Заменяется балансом и леджером.
- ⚠ Это **отменяет оконную рамку П-5** (страница лимитов «как Claude Code», D39.100/ПТ-35) в части
сброса окон. Что остаётся из D39.100 без изменений: суммы на экран не идут, стоп по исчерпании =
статус `paused` + оповещение. Экран показывает ОСТАТОК (процентом от гранта), а не «сбросится через».
- Вопрос «продолжать ли автоматически после сброса лимитов» отпадает вместе с окнами. Осталось:
после пополнения баланса прогон возобновляется явным действием (кнопка/админ), а не сам.
**Фри-тир = ГРАНТ в леджер, управляется из админки** (владелец 05.08). Дефолт **$5**, настраиваемый
пер-аккаунт; «накинуть кредитов» = одна запись леджера типа `grant`, отдельного кода фри-тира не
существует. Это и есть стандартная механика (Modal и другие делают так же): начисление и есть весь
фри-тир. Нужна минимальная админ-поверхность — защищённая ручка или CLI-команда, пишущая грант.
**Схема (строить с П-7):** `credit_ledger` (append-only, знаковые целые микро-доллары, типы
`grant|hold|hold_release|settlement|adjustment`; `UNIQUE(source, source_id)` — ключ идемпотентности;
`purchase` добавится, если появится продажа), `reservations` (одна открытая на `engine_run_id`),
`account_balances` (кэш в ТОЙ ЖЕ транзакции, что вставка в леджер, + тест-инвариант
`balance == SUM(ledger)`). Правки строк не существует: ошибка чинится новой записью. Только целые
микро-доллары; на шве расчёта округляем ВВЕРХ (леджер движка — нижняя граница).
**Защита и свежесть — ДВА РАЗНЫХ механизма, строим ОБА (решение владельца 05.08 «сделаем нормально»;
моё прочтение его слов — записано так, чтобы дешёво поправить, если прочитал не то):**
1. **Защита — холд + потолок движку (было «вариант B»).** В той же транзакции, что допускает задачу
в очередь, ДО спавна `tmctl` ставим холд и передаём движку пер-книжный потолок ≤ остатка баланса.
Жёсткий стоп исполняет САМ движок, у которого механизм уже есть ⇒ перерасход физически невозможен,
даже если платформа во время прогона слепа. Это рубеж корректности, и он НЕ зависит от доставки
событий (поток at-least-once и на краше теряет хвост).
2. **Свежесть — версионированное денежное событие в словаре потока (было «вариант A»).** Движок
эмитит НАКОПИТЕЛЬНЫЙ счётчик потраченного на границах стадии/волны; платформа материализует его
существующим идемпотентным апсертом `(engine_run_id, seq)`. Накопительная семантика делает
повтор и дубль безвредными (берём максимум по прогону). Индикатор остатка перестаёт отставать
на целую попытку.
**Что это меняет в каноне и чего НЕ меняет.** Отменяется частная доктрина «поток событий цифр не
несёт» (записана в комменте `00003_usage.sql` и в дизайн-ответе П-5). **D39.84 НЕ затронут**: он
запрещает суммы на ПОЛЬЗОВАТЕЛЬСКОМ проводе, экране и в INFO-логах — здесь же внутренний канал
движок→платформа в приватную таблицу; наружу по-прежнему уходит только процент остатка.
**Запрет, который обязан ехать вместе с механизмом:** ЭНФОРСМЕНТ на событии строить нельзя —
иначе корректность квоты повиснет на доставке потока. Событие только показывает; останавливает
потолок. **Носитель работы движка — строка 103 единого бэклога** (словарь событий); заводится
оркестратором отдельной строкой, платформа её не пишет.
## 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.41.5 с ре-ингеста движка на книге 23 МБ.
Бюджеты (в батарею фронта по мере появления экранов): DOM ≤1400 узлов на тяжёлом экране ·
виртуализованный список ≤60 строк независимо от размера коллекции · медиана кадра прокрутки ≤17 мс,
p95 ≤33 мс · ни одной длинной задачи >200 мс на взаимодействие · страница списка ≤300 КБ ·
стартовый JS-роут ≤200 КБ gzip · ≤4 применения событий в секунду. ⚠ Инструмент — расширение уже
существующего Playwright-харнесса (CDP-метрики + longtask), библиотеку `web-vitals` НЕ брать:
RUM в MVP нет, потребителя у неё не существует.