# Стек платформы — пины и обоснования > Зонный документ `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//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 --usd 5 [--note ...] [--key ...]` · `balance --user ` · `logins --user ` · `revoke --user `. ### 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) в скрэтчпаде — вне репозитория и вне зависимостей модуля.