textmachine/platform/docs/PLATFORM_DIRECTION.md

23 KiB
Raw Blame History

Направление платформы: аутентификация, деньги, стандарты, скорость

Ратифицировано оркестратором №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) ВЗЯТЬ — доказано исполнением 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 нет, потребителя у неё не существует.