226 lines
28 KiB
Markdown
226 lines
28 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. Коллизия почты решена ДО первого входа (миграция `00005`): `users.email` — NULLABLE и БЕЗ
|
||
уникального индекса, неизвестная пара всегда создаёт НОВЫЙ аккаунт. Цена и разбор — архив-слайс
|
||
журнала зоны, `platform/docs/archive/platform-PROGRESS-P0-P3.md:496`=`### Политика коллизии почты`
|
||
(в ЖИВОМ журнале раздела нет — он закрыт вместе с эрой P0–P3); строка пина — `STACK_DECISIONS.md` §9.
|
||
3. Ротация идентификатора сессии на границе входа (защита от фиксации) — обязательна.
|
||
4. ⚠ **Журнал входов** (время, провайдер, класс устройства/IP) и ручка «отозвать все мои сессии» —
|
||
день-один для сервиса, продающего токены: таблица сессий чистится свипом и аудитом не является.
|
||
5. Первая НЕаутентифицированная ручка (`/auth/login`) — первая же поверхность для ботов: лимит
|
||
на исходящие state-записи обязателен вместе с ней.
|
||
|
||
## 2. Кредиты и лимиты (П-5/П-7) — решения владельца 05.08
|
||
|
||
**Оплаты нет и в бете не будет.** Первые аккаунты — пробные, ключи провайдеров
|
||
предоплачены владельцем. ⚠ **Автоматического фри-тира на бете НЕТ** (слово владельца 16.08, D39.138
|
||
п.2л, PD-104): дефолт `TM_PLATFORM_SIGNUP_GRANT_USD` = **0**, кредит начисляется руками
|
||
(`tmplatformctl grant`); возврат автоматического гранта идёт ВМЕСТЕ с суточным агрегатным потолком,
|
||
который его ограничивает, а тот принадлежит платежам. Платёжный провайдер не выбирается, не проектируется и в бэклог зоны как
|
||
работа не заходит; вернуться — когда появится решение продавать.
|
||
|
||
**Модель лимита — БАЛАНС кредитов, а не окна с обнулением** (владелец 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). Настраивается пер-аккаунт; «накинуть кредитов» = одна запись леджера типа `grant`, отдельного кода фри-тира не
|
||
существует. Это и есть стандартная механика (Modal и другие делают так же): начисление и есть весь
|
||
фри-тир. Нужна минимальная админ-поверхность — защищённая ручка или CLI-команда, пишущая грант.
|
||
|
||
**Схема (строить с П-7):** `credit_ledger` (append-only, знаковые целые микро-доллары, типы
|
||
`grant|hold|hold_release|settlement|adjustment`; `UNIQUE(user_id, source, source_id)` — ключ идемпотентности, скоупленный АККАУНТОМ (почему в нём обязателен `user_id` — комментарий миграции `00007_credits.sql`);
|
||
`purchase` добавится, если появится продажа), `reservations` (одна открытая на `engine_run_id`),
|
||
`account_balances` (кэш в ТОЙ ЖЕ транзакции, что вставка в леджер, + тест-инвариант
|
||
`balance == SUM(ledger)`). Правки строк не существует: ошибка чинится новой записью. Только целые
|
||
микро-доллары; на шве расчёта округляем ВВЕРХ (леджер движка — нижняя граница).
|
||
|
||
**Защита и свежесть — ДВА РАЗНЫХ механизма, строим ОБА (решение владельца 05.08 «сделаем нормально»;
|
||
моё прочтение его слов — записано так, чтобы дешёво поправить, если прочитал не то):**
|
||
|
||
1. **Защита — холд + потолок движку.** В той же транзакции, что допускает задачу в очередь, ДО спавна `tmctl` ставим холд и передаём движку пер-книжный потолок ≤ остатка баланса.
|
||
Жёсткий стоп исполняет САМ движок, у которого механизм уже есть ⇒ перерасход физически невозможен,
|
||
даже если платформа во время прогона слепа. Это рубеж корректности, и он НЕ зависит от доставки
|
||
событий (поток at-least-once и на краше теряет хвост).
|
||
2. **Свежесть — версионированное денежное событие в словаре потока.** Движок эмитит
|
||
НАКОПИТЕЛЬНЫЙ счётчик потраченного на границах стадии/волны; платформа материализует его
|
||
существующим идемпотентным апсертом `(engine_run_id, seq)`. Накопительная семантика делает
|
||
повтор и дубль безвредными (берём максимум по прогону). Индикатор остатка перестаёт отставать
|
||
на целую попытку.
|
||
|
||
⚠ **Что это меняет в каноне и чего НЕ меняет.** Отменяется частная доктрина «поток событий цифр не
|
||
несёт» (записана в комменте `00003_usage.sql` и в дизайн-ответе П-5). **D39.84 НЕ затронут**: он
|
||
запрещает суммы на ПОЛЬЗОВАТЕЛЬСКОМ проводе, экране и в INFO-логах — здесь же внутренний канал
|
||
движок→платформа в приватную таблицу; наружу по-прежнему уходит только процент остатка.
|
||
⚠ **Запрет, который обязан ехать вместе с механизмом:** ЭНФОРСМЕНТ на событии строить нельзя —
|
||
иначе корректность квоты повиснет на доставке потока. Событие только показывает; останавливает
|
||
потолок. **Построено:** событие `spend` в словаре потока — `internal/ingest/events.go` `TypeSpend`.
|
||
|
||
## 3. Стандарты и стек: что взять, что не брать
|
||
|
||
> ⚠ **По `sqlc` действует НЕ решение P7, записанное ниже:** владелец сказал **БЕРЁМ** (`D39.153` п.6а,
|
||
> уточнение `D39.154` п.10), пак отработан отдельной сессией и **заленджен 29.08** (`D39.172`, `PD-44`
|
||
> закрыт). Устройство, пин и цена — `STACK_DECISIONS.md`, строка «Кодоген SQL»; граница инструмента и
|
||
> построенный взамен гейт планируемости КАЖДОГО SQL пакета (включая склейки) — `DEFECT_REGISTER.md`,
|
||
> `PD-44`; состав и границы набора — `BACKLOG.md` П-19.
|
||
>
|
||
> ⚠ **`oapi-codegen` ПЕРЕ-ПОДПИСАН `D39.132` п.2б: КАНДИДАТ**, решение за паком, который его возьмёт.
|
||
> Довод отказа P7 (20.08) остаётся действующим аргументом, а не историей:
|
||
>
|
||
> - **`oapi-codegen`.** Довод «на P7 ручек станет больше, значит дешевле сейчас» проверен фактом: на 20.08 — 20
|
||
> операций контракта, из них построено 14; **на 04.09 — 21 и 18; на 05.09 — 21 и 19** (`updateBook` смонтирован паком операций) (пере-счёт: `operationId:` в
|
||
> `openapi.yaml`, записи `contractSurface` в `internal/httpapi/v0.go`; после 05.09 непостроены
|
||
> только `deleteBook` и `getRun`), то есть довод не устарел, а усилился. И КАЖДАЯ требует рукописной проекции read-модели в
|
||
> контрактные словари (`internal/httpapi/project.go`) — генератор даёт имена полей, а переводит
|
||
> словари всё равно человек. Плюс замеренное 05.08: 3.1-условия (`if action=approve → dst`)
|
||
> генератор игнорирует, и исполнителем правила остаётся констрейнт БД. Взамен пак поставил ДРУГОЙ
|
||
> гейт от дрейфа — пофайловый дифф проводных структур против required-списка канона, исполненный
|
||
> тестами (`httpapi.Test*CarriesEveryRequiredField`).
|
||
|
||
Стек менять не надо: правильные куски уже взяты (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) | **КАНДИДАТ** — решение за паком, который его возьмёт (`D39.132` п.2б; отказ P7 и его довод — в баннере выше). Замер 05.08 ниже доказал, что инструмент РАБОТАЕТ, а не что его берут |
|
||
| `sqlc` на свободный от склейки блок `pgstore` (включая денежный) | v1.31.1 | **ВЗЯТ и заленджен 29.08** (`D39.172`, PD-44 закрыт) — устройство и границы в `STACK_DECISIONS.md`, строка «Кодоген SQL» |
|
||
| `golang.org/x/time/rate` | v0.15.0 | лимиты в процессе; долговечные пер-пользовательские — в Postgres |
|
||
|
||
⚠ **Границы той пробы 05.08:** sqlc читал ВСЕ goose-миграции зоны на тот момент — их было СЕМЬ;
|
||
`00008` приехала позже и пробу не проходила (`platform/internal/pgstore/migrations/`, греп
|
||
`00008_auth_state_issuer_and_start_id.sql`).
|
||
|
||
⚠ **Две трения `sqlc`, увиденные исполнением на пробе 05.08 (тот же пин 1.31.1, что взят):** его анализатор
|
||
отвергает запрос, который Postgres принимает (неквалифицированный `user_id` в коррелированных
|
||
подзапросах) — переход означает правку существующего SQL, а не обёртку; и колонки типизуются как
|
||
`pgtype`/`int64`, поэтому `money.MicroUSD` на границе теряется без блока `overrides` (он и стоит в
|
||
`platform/sqlc.yaml` подстановкой `*.*_micro_usd`) — а единый денежный тип и есть то, ради чего
|
||
заведены PD-15/PD-39.
|
||
|
||
**Доказательство исполнением по кодогену (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).
|
||
Следствие, которое обязана знать каждая следующая сессия: **любое условное требование спеки обязано
|
||
получить носителя в схеме или явную проверку в хендлере** — генерированный код его не несёт.
|
||
⚠ Пример, на котором это правило было записано, УСТАРЕЛ 22.08: сама ручка решений снята вместе с
|
||
пер-термной моделью подписи (D39.144, слово владельца 22.08), поэтому у констрейнта
|
||
`bank_decisions_approve_has_dst` больше нет ни одного писателя в Go. Правило от этого не меняется —
|
||
меняется только его иллюстрация.
|
||
|
||
**Оставляем как есть:** stdlib-роутинг (1.22+ закрыл разрыв; `Request.Pattern` — с 1.23),
|
||
pgx без ORM, goose библиотекой, River (в `go.mod` с P4, подключён, мигрируется своим мигратором), «никакого Redis»,
|
||
опаковые серверные сессии (OIDC их НЕ заменяет), stdlib-CSRF, slog, свой RFC 9457 problem+json,
|
||
zonky-стенд Postgres без root, пины линтера/govulncheck, изоляция графа зависимостей от движка.
|
||
**Не берём:** ORM · OpenTelemetry (позже: один сервис, одна БД, APM-бэкенда нет) · Temporal · Vault ·
|
||
feature-flag-платформы · CodeQL (на приватном репо платный) · gosec отдельным гейтом · SBOM/SLSA —
|
||
церемония на этом размере.
|
||
|
||
**Базовые линии безопасности** — `ENGINEERING_STANDARDS.md` §2. ⚠ Поправка скептика, из-за которой
|
||
карта глав там именно такая: ресёрч цитировал нумерацию ASVS 4.0 и пропустил НОВУЮ главу **V10
|
||
«OAuth and OIDC»**, написанную ровно под наше направление. Сверх стандарта: govulncheck-гейт, пины
|
||
Go-модулей с sumdb, Dependabot — да (граф крошечный, шума нет).
|
||
|
||
**Тест-пол.** Четыре его пункта исполнены и живут нормой, а не планом: тест на РЕАЛЬНО
|
||
сконфигурированном `http.Server` вместо mux под `httptest` (`PD-2`, `PD-9`), инвариант сессии
|
||
НЕЗАВИСИМЫМ оракулом (`PD-1`), фаззинг NDJSON-декодера (`PD-10`) — эти строки регистра `fixed`,
|
||
каждая со своим пином; дисциплина посадок мутаций — `ENGINEERING_STANDARDS.md` §3. Полом остаётся
|
||
только пятый пункт: контрактные тесты против OpenAPI-спеки и нагрузочные (k6) — при первых живых
|
||
ручках, не раньше.
|
||
|
||
**Деплой:** одна VM + systemd-юниты, бинари артефактами CI; Postgres сперва там же, управляемый — при
|
||
первой выручке. Не Kubernetes: дети-`tmctl` живут ЧАСАМИ, держат эксклюзивный лок на файлах книги на
|
||
локальном диске, и любой оркестратор, способный переселить под посреди прогона, нам враждебен.
|
||
Юниты написаны — `deploy/tmplatformd.service`, и они же ответ на `PD-13` (осиротевшие процессы
|
||
движка); cgroup-поддерево прогона — `STACK_DECISIONS.md` §15–16.
|
||
|
||
## 4. Скорость: что это требует от платформы и контракта
|
||
|
||
Замер снял главный страх: **пер-главный объём крошечный** — ~3.4 тыс. символов исходника + ~10.2 тыс.
|
||
перевода ≈ 27 КБ строк UTF-16 на главу, а контракт уже читает юниты ПО ГЛАВЕ, читает банк ДЕЛЬТОЙ
|
||
(`?after_version=`) и отдаёт экспорт ссылкой. Риск не в экране чтения, а в четырёх местах, где
|
||
сегодняшняя форма вынудила бы клиента держать книгу целиком. Требования, которые платформа обязана исполнить:
|
||
|
||
1. **Сжатие ответов — ИСПОЛНЕНО P7** и записано нормой в канон 0.3.0, а не в этот док: JSON сжимается
|
||
там же, где строится тело (`internal/httpapi/conditional.go`), `text/event-stream` — никогда, и
|
||
это свойство структурное (поток через ту функцию не проходит), а не дисциплина.
|
||
2. **Условные запросы (`ETag`/`If-None-Match` → 304) — ИСПОЛНЕНО P7** на всех коллекциях, карточке
|
||
книги и `/capabilities`. Валидатор — хеш ТЕЛА, поэтому страница 2 и дельта той же ревизии
|
||
различаются, как того требует канон.
|
||
3. **Юниты — только пер-главно, никогда пер-книжно.** Это несущее свойство памяти всего дизайна
|
||
(рабочий набор 27 КБ против ~62 МБ); одна «удобная» ручка обнулила бы его молча — ревью-вопрос на
|
||
каждое изменение контракта.
|
||
4. **Экспорт — артефакт по ссылке, сборка текста книги на клиенте запрещена явно** (иначе та же
|
||
проблема памяти заходит с чёрного хода на экране выгрузки).
|
||
5. **События потока сервер вправе склеивать — но ТОЛЬКО кадры состояния** (`status`, `progress`,
|
||
`chapter`, `bank`); клиент обязан терпеть скачки счётчиков, иначе прогон на 9500 юнитов
|
||
становится неограниченным источником рендера. ⚠ Сужено батчем 0.3.0 (research/28 Б-6в): `note`
|
||
склеивать НЕЛЬЗЯ — это добавление, и склеенное замечание теряется навсегда, на живом соединении,
|
||
без переподключения и потому без `resync_required`.
|
||
6. **Заголовок главы ограничить по длине на сервере** — единственная неограниченная строка в списке
|
||
из 5000 строк, всё остальное там целые числа.
|
||
7. **Поиск.** Пер-книжного поиска в контракте нет вовсе. Развилка: серверный полнотекстовый (Postgres)
|
||
· клиентский, но ТОЛЬКО в открытой главе / по списку заголовков (безопасен по памяти) · отложить.
|
||
Решать при экране поиска, не раньше.
|
||
8. **Живой канал для до-прогонных состояний — ИСПОЛНЕН P7.** Поток стоит на КНИГЕ, а не на прогоне,
|
||
поэтому `uploading`/`parsing` и появление дерева видны без опроса read-модели. ⚠ Акт 5 закрыл
|
||
вторую половину, которая делала первую бесполезной (Ф-56): книга «в покое» — это ещё и «никакой
|
||
материализации не должна», иначе поток слал `end` и отвечал 204 ровно в те секунды, пока главы
|
||
вот-вот появятся. Остаётся не закрытым только УЧЁТНЫЙ поток (библиотека целиком, пуш экспорта
|
||
К-12) — это пользовательский поток, а не книжный.
|
||
9. **SSE поверх HTTP/1.1** упирается в лимит ~6 соединений на источник при нескольких вкладках —
|
||
на dev-стенде это выглядит как загадочное зависание; одна строка в доке стенда снимает класс.
|
||
10. **Персист манифеста глав — ИСПОЛНЕН** (таблица `chapters`, миграция `00002_readmodel.sql`):
|
||
только он держит чтения на 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 нет, потребителя у неё не существует.
|