textmachine/platform/docs/STACK_DECISIONS.md

206 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.

# Стек платформы — пины и обоснования
> Зонный документ `platform/`. Пины сверены ЖИВЬЁМ 04.08 и 05.08.2026 (Go-прокси `@latest`,
> postgresql.org, go.dev/dl) — версии по памяти не называются. Библиотеки сессия не ратифицирует:
> таблица уходит оркестратору вместе с деревом.
>
> Общая записка по обоим новым сервисам — `frontend/docs/STACK_DECISIONS.md` §5 (02.08). Здесь —
> платформенная часть с датами релизов и сверкой. Что изменилось за два дня: три библиотечных пина
> §5 (pgx · goose · River) на 04.08 всё ещё последние; по Go последним патчем стенда идёт 1.26.5
> (07.07) — floor `go.mod` оставлен общим с движком, а тулчейн сборки поднят до 1.26.5 (см. ниже).
## Пины
| Что | Пин | Релиз пина | Зачем нам |
|---|---|---|---|
| Go (язык, `go.mod`) | **1.26.4** | 02.06.2026 | Тот же floor, что у движка (`backend/go.mod`) — общий стенд собирает оба модуля одним тулчейном |
| Go (тулчейн сборки, `make tools-check`) | **≥1.26.5** | 07.07.2026 | 1.26.5 несёт security-фиксы `crypto/tls` и `os`; сетевой модуль собирается ими, а не «чем-нибудь 1.26» |
| PostgreSQL | **18.x** (проверено на 18.4), floor **16** | 18.4 — май 2026 | 18 — текущая мажорная (19 в бете, в прод не берём); floor 16, потому что River тестируется на трёх последних мажорных |
| HTTP | stdlib `net/http` + `ServeMux` | — | Роутер-библиотека не нужна: `ServeMux` с 1.22 умеет метод+wildcards, а `Request.Pattern` даёт лог по маршруту, не по пути |
| CSRF | stdlib `http.CrossOriginProtection` | Go 1.25 | Ровно тот механизм, что описан в §5 (Sec-Fetch-Site → Origin), теперь в тулчейне — свой велосипед не пишем |
| Postgres-драйвер | `github.com/jackc/pgx/v5` **v5.10.0** | 03.06.2026 | Живой pool, `pgconn.PgError` для проверки констрейнтов, `stdlib` для goose |
| Миграции | `github.com/pressly/goose/v3` **v3.27.3** | 22.07.2026 | Библиотекой + `embed.FS`; `WithSessionLocker` = advisory-лок, две реплики выкатываются по очереди |
| Очередь | `github.com/riverqueue/river` **v0.42.0** | 31.07.2026 | Пин ПОДТВЕРЖДЁН живой сверкой, но **в `go.mod` НЕ добавлен**: П-3 вне скоупа P0, а зависимость без кода — мусор в графе |
| OIDC-вход | `golang.org/x/oauth2` **v0.36.0** + `github.com/coreos/go-oidc/v3` **v3.20.0** | 11.02.2026 · 08.07.2026 | Ратифицировано `PLATFORM_DIRECTION.md` §1; сверено живьём 05.08. Протокольный риск (PKCE, JWKS с рефетчем по kid, проверка подписи/issuer/audience/exp) отдан библиотекам, интеграция и модель аккаунта — наши. Транзитивно приходит `go-jose/v4` v4.1.4 |
| Рейт-лимит в процессе | `golang.org/x/time` **v0.15.0** | 11.02.2026 | `rate.Limiter` на ОБЕИХ неаутентифицированных ручках, которые ПИШУТ: `/auth/login` (строка состояния) и `/auth/callback` (строка журнала на каждом отказе — замерено ~880 строк/с с одного хоста, пока лимита не было). Долговечные пер-пользовательские лимиты — в Postgres, когда появятся |
| Линтер | `golangci-lint` **2.12.2** | 06.05.2026 | Тот же пин, что у движка: находки версионно-зависимы, разъезд пинов = разные гейты в одном репо |
| Уязвимости | `govulncheck` **v1.6.0** | 09.07.2026 | Отдельная цель `make vuln`, не часть `check`: ей нужна сеть, а батарея обязана быть зелёной на голом клоне офлайн |
**Redis нет** — архитектурное «нет» из §5 в силе: очередь, лизы и рейт-лимиты живут в том же Postgres.
## Что решено этой сессией (сверх §5)
1. **`/healthz``/readyz`.** Liveness ничего не трогает (БД лежит — процесс жив). Readiness с P2
спрашивает не «отвечает ли база», а «та ли это база, под которую собран бинарь»: пинг плюс сверка
`goose_db_version` с максимальной вшитой миграцией. Одного пинга было мало — он успешен и на
Postgres без единой таблицы, то есть в нормальной середине выката, где миграция ещё не накачена
(PD-68). Пустой `TM_PLATFORM_DSN` — легальный старт: сервис поднимается и честно говорит «не
готов». Иначе супервизор убивает здоровый процесс за то, что база моргнула.
2. **Ops-эндпоинты вне версионного префикса.** `/healthz`, `/readyz` — в корне; контрактная
поверхность целиком под `/v0` (базовый путь спеки платформа ПОДТВЕРЖДАЕТ).
3. **Один mux.** Контрактные маршруты регистрируются с префиксом в паттерне, а не вложенным mux'ом
под `StripPrefix`: вложенный получает КОПИЮ запроса, и `Request.Pattern` наружу не возвращается —
лог пришлось бы писать по сырому пути с id книг. Проверено исполнением.
4. **Гард навешен на поддерево, а не на ручки.** Неизвестный путь под `/v0` отвечает 401 раньше 404:
аноним не должен картографировать поверхность.
5. **Заголовки безопасности — на каждом ответе** (`X-Robots-Tag: noindex`, `Cache-Control: no-store`,
`nosniff`, `no-referrer`): ПТ-34 нельзя оставлять на дисциплину автора следующей ручки.
6. **Батарея зоны — `make check`** (build · vet · fmt · lint · test -race), форма скопирована с
`backend/Makefile` вплоть до именования пропущенных тестов: тихий skip читается как покрытие.
7. **Тесты с БД гейтятся `TM_PLATFORM_TEST_DSN`** и создают СВОЮ базу на прогон (дропают в
`t.Cleanup`). Батарея на голом клоне зелёная и офлайн; с DSN — та же батарея плюс схема.
## Что решено сессией P1 (05.08)
8. **Миграции append-only, БЕЗ исключений — включая «до первого деплоя».** Первая редакция этого
пункта разрешала править их на месте, пока «ни одна среда их не применяла». Это опровергнуто
исполнением: goose записывает только НОМЕР (ни имени, ни хеша), поэтому база, доехавшая до
версии 3, на новом наборе рапортует «migrations applied» и не получает ни одной новой таблицы,
а `DownTo` на ней ломается навсегда. Дев-воркфлоу из этого же документа создаёт ровно такую
среду. Поэтому выпущенные `00001``00003` возвращены байт-в-байт, а всё новое приехало
отдельными номерами (`00004` индексы · `00005` вход · `00006` снятие черновика `usage_windows` ·
`00007` кредиты · `00008` `auth_states.issuer` и `.start_id`). Гейт, которого не хватало:
`migrations.sha256` + тест
`TestReleasedMigrationsAreUnchanged` — чтобы изменить выпущенную миграцию, надо осознанно
изменить строку в манифесте, где это видно ревьюеру. Апгрейд со старого релиза проверен
исполнением (`TestDatabaseAtAnOlderReleaseCatchesUp`), down-путь — тоже.
> ⚠ **Цена правила, названная честно: откат НИЖЕ версии 5 недоступен.** Down-путь `00005`
> восстанавливает `users_email_key` и `email NOT NULL` — ровно то, что его же up-путь снял, — а
> обе эти формы нарушаются строками, которые пишет боевой код: `email = NULL` у неподтверждённой
> личности и один подтверждённый адрес на двух аккаунтах (прямое следствие «почта не ключ»).
> Значит `DownTo(<5)` на живой базе падает. Править `00005` нельзя — это и есть append-only, —
> а новая миграция чужой down-текст не заменяет. Данные при этом целы: down транзакционный,
> `Up()` возвращает схему на текущую версию (проверено прогоном: `DownTo(4)` падает на
> `users_email_key`, SQLSTATE 23505). Найдено ревью P2, перепроверено зоной, принято как цена правила.
9. **Ключ личности — `(provider, subject)`; почта не ключ.** `users.email` стала NULLABLE и БЕЗ
уникального индекса; неизвестная пара всегда создаёт НОВЫЙ аккаунт. Разбор и цена решения —
в журнале зоны, раздел «Политика коллизии почты».
10. **Админ-поверхность — CLI (`tmplatformctl`), не HTTP-ручка.** Ручке понадобилась бы вторая
модель авторизации (роли, эскалация, отзыв админской куки) ради пяти операций
(`grant` · `adjust` · `balance` · `logins` · `revoke`), тогда как
граница доверия «есть шелл на машине и доступ к DSN» уже обеспечена машиной. Браузерная панель,
если понадобится, обернёт те же вызовы стора.
10а. **Имя провайдера — `TM_PLATFORM_OIDC_PROVIDER`, и оно должно меняться ВМЕСТЕ с издателем.**
Это первая половина ключа личности. Направить `TM_PLATFORM_OIDC_ISSUER` на другой IdP, оставив
имя прежним, — значит сложить `sub` нового провайдера в старое пространство имён, то есть тихо
связать чужие аккаунты. Переменная называется здесь, потому что в деплой-примере её не было и
оператору нечему было напомнить (найдено ревью P2).
11. **Секреты — через `*_FILE`.** `TM_PLATFORM_DSN_FILE` и `TM_PLATFORM_OIDC_CLIENT_SECRET_FILE`
читаются раньше одноимённых переменных: переменная окружения видна в `/proc/<pid>/environ` и
наследуется каждым ребёнком-`tmctl`. Это же формат `LoadCredential=` systemd (`deploy/`).
12. **`ReadTimeout` есть, `WriteTimeout` нет.** Первый закрывает PD-2 (проверено живой пробой);
второй зарезал бы SSE на фиксированном возрасте.
Поток при этом от хендлера ничего не требует: `net/http` снимает read-дедлайн САМ
`connReader.startBackgroundRead` делает `SetReadDeadline` нулевым временем
(`server.go:687-698`), и для запроса без остатка тела это происходит ДО хендлера, иначе на EOF
тела (`:2059-2062`); по ходу хендлера дедлайн не перевзводится. Проверено исполнением на шести
комбинациях (GET без тела · POST с непрочитанным телом · POST с вычитанным).
**Снимать дедлайн руками ЗАПРЕЩЕНО, и это не стилистика.** На полу-кормленном запросе (тело
анонсировано и не дослано) дренаж внутри записи заголовка ответа — единственное, что ограничивает
соединение, и ограничен он как раз `ReadTimeout`. Снятие дедлайна до записи заголовка убирает эту
границу: замерено — хендлер остаётся внутри `WriteHeader` и через 4 с после ухода клиента, то есть
PD-2 воспроизводится тем самым вызовом, который был заведён как его исправление. Поэтому
`httpapi.ClearReadDeadline` **удалён** (PD-51): случая, где он помогает, нет — на корректном
запросе это no-op, на полу-кормленном вред. `Unwrap` в обёртках остаётся обязательным: через него
поток дотягивается до `Flush`, и это запинено проверкой ошибки `Flush` в
`TestStreamOutlivesReadTimeout`.
13. **Политика сессий: 14 суток бездействия, 30 суток абсолютных.** Раздел существует потому, что
`ENGINEERING_STANDARDS §2` объявил зоне ASVS 5.0 L2, а 7.1.1 требует не значения, а ДОКУМЕНТ:
«the user's session inactivity timeout and absolute maximum session lifetime are documented …
includes justification for any deviations from NIST SP 800-63B re-authentication requirements».
**Уровень — AAL1.** Второго фактора со своей стороны мы не проверяем; что там делает Google —
его дело и в нашу гарантию не входит.
**Сверка с NIST SP 800-63B-4 §2.1.3 (AAL1), дословно:** «A definite reauthentication overall
timeout SHALL be established, which SHOULD be no more than 30 days at AAL1. An inactivity timeout
MAY be applied but is not required at AAL1.»
- **Абсолютный срок — 30 суток. Отклонения нет.** Было 90; 90 — это отклонение от SHOULD, а
обоснования у него не нашлось: на аккаунте лежит тратимый баланс, а повторный вход у уже
залогиненного в Google человека — один клик. Абсолютный срок не рвёт ПРОГОН: прогон живёт
серверным процессом и переживает истечение сессии. Значение переопределяется
`TM_PLATFORM_SESSION_MAX_AGE`, и если владелец хочет 90 — это одна переменная и запись здесь.
- **Срок бездействия — 14 суток.** На AAL1 он не требуется вообще (MAY), так что наличие
строже нормы. Скользит только во второй половине окна — чтобы каждый запрос не писал в БД.
**7.1.2, одновременные сессии.** Ограничения нет, и это решение, а не умолчание: контракт
предусматривает две презентации одной личности одновременно (кука в браузере, Bearer в
десктопе/CLI — D39.84), поэтому лимит ломал бы штатный сценарий. Что стоит вместо лимита: своя
строка на каждый вход, мгновенный отзыв любой из них, `POST /auth/logout-all` и
`tmplatformctl revoke` как «выйти везде», журнал `login_events` как ответ на «откуда входили».
⚠ Показ пользователю списка его сессий (ASVS 7.5.2) не сделан — это работа П-1.
**7.1.3 / 7.6.1, согласование с федеративной сессией.** Наша сессия живёт СВОЕЙ жизнью:
RP-initiated logout и back-channel logout не реализованы. Следствия названы прямо: выход из
Google не завершает нашу сессию, и отзыв доступа на стороне Google — тоже. Единственные границы
— наши два срока и наш отзыв. Это и есть причина, по которой абсолютный срок выровнен по NIST, а
не растянут: пока нет канала «IdP сказал, что сессия кончилась», абсолютный срок — единственное,
что вообще ограничивает жизнь сессии после события на стороне провайдера.
**7.6.2 выполнено:** сессия создаётся только в колбэке потока, который человек начал явным
действием, и провайдер показывает свой экран согласия. Без взаимодействия сессия не появляется.
14. **Против IdP mix-up — параметр `iss` авторизационного ответа (RFC 9207), а не раздельные
redirect URI.** Решение принято ДО второго провайдера намеренно: пока провайдер один, сверка
«конфигурация против самой себя» выглядит работающей и перестаёт ею быть ровно в момент, когда
появляется второй (PD-57).
Что говорит норма. RFC 9700 §4.4.2: «When an OAuth client can only interact with one
authorization server, a mix-up defense is not required. In scenarios where an OAuth client
interacts with two or more authorization servers, however, clients MUST prevent mix-up attacks»,
и обе защиты требуют одного и того же: хранить издателя, которому ушёл запрос, и привязать это к
браузеру. §4.4.2.2 (раздельные redirect URI) — фолбэк: «SHOULD therefore only be used if other
options are not available».
Почему `iss`, а не redirect URI. Альтернатива «`iss` из ID-токена» нам не подходит: у нас чистый
code flow, ID-токен приходит от token endpoint, то есть ПОСЛЕ того, как код уже отдан — а утечка
кода не туда и есть содержание атаки. Фолбэк с раздельными URI не нужен: **Google поддерживает
RFC 9207** — в его discovery-документе `authorization_response_iss_parameter_supported: true`
(сверено живьём 05.08, `https://accounts.google.com/.well-known/openid-configuration`).
Что сделано: `auth_states.issuer` хранит издателя, которому ушёл запрос (миграция 00008), а
колбэк сверяет с ним `iss` ответа простым строковым сравнением до обмена кода (RFC 9207 §2.4) и
отказывает, если параметр СОРВАН, когда провайдер по discovery его шлёт — иначе снятие параметра
и есть обход проверки. Отдельно осталась сверка `st.Provider` с конфигурацией: это наш ключ
маршрутизации, а не идентификатор из нормы, и отвечает она на другой вопрос.
## Как поднять локально
```sh
export TM_PLATFORM_DSN='postgres://user@host:5432/tmplatform?sslmode=disable'
make check # батарея зоны
TM_PLATFORM_MIGRATE=1 go run ./cmd/tmplatformd # миграции + сервер на 127.0.0.1:8080
curl -s localhost:8080/healthz # ok
curl -s localhost:8080/readyz # ready
```
Переменные: `TM_PLATFORM_ADDR` · `TM_PLATFORM_DSN` (или `_DSN_FILE`) · `TM_PLATFORM_MIGRATE` ·
`TM_PLATFORM_TRUSTED_ORIGINS` (через запятую) · `TM_PLATFORM_SESSION_IDLE` ·
`TM_PLATFORM_SESSION_MAX_AGE` · `TM_PLATFORM_INSECURE_COOKIES` (dev, по HTTP) ·
`TM_PLATFORM_OIDC_ISSUER` · `_OIDC_CLIENT_ID` · `_OIDC_CLIENT_SECRET` (или `_FILE`) ·
`_OIDC_REDIRECT_URL` · `TM_PLATFORM_AFTER_LOGIN` · `TM_PLATFORM_SIGNUP_GRANT_USD`.
Вход монтируется, только если задана ВСЯ четвёрка OIDC; половина конфигурации — отказ на старте.
Админ-команды: `tmplatformctl grant --user <id> --usd 5 [--note ...] [--key ...]` ·
`balance --user <id>` · `logins --user <id>` · `revoke --user <id>`.
### Postgres на стенде без root
```sh
# бинарники io.zonky.test.postgres с Maven Central, распакованные в скрэтчпад
pg/bin/initdb -D pgdata -U postgres -A trust --no-locale --encoding=UTF8
pg/bin/pg_ctl -D pgdata -l pg.log -o "-k /tmp -p 55432 -c listen_addresses=" start
export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55432&sslmode=disable'
```
⚠ Сокет кладём в `/tmp` (`-k`): полный путь скрэтчпада длиннее лимита Unix-сокета.
`psql` в пакете zonky НЕТ — только `initdb`/`pg_ctl`/`postgres`; проверять из Go.
⚠ Postgres на стенде отсутствует как системный пакет и sudo нет. Схема и запросы этой сессии
проверены на ЖИВОМ PostgreSQL **18.4**, поднятом без root из бинарников zonky
(`io.zonky.test.postgres`, Maven Central) в скрэтчпаде — вне репозитория и вне зависимостей модуля.