textmachine/platform/docs/STACK_DECISIONS.md

24 KiB
Raw Blame History

Стек платформы — пины и обоснования

Зонный документ 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)

  1. Миграции append-only, БЕЗ исключений — включая «до первого деплоя». Первая редакция этого пункта разрешала править их на месте, пока «ни одна среда их не применяла». Это опровергнуто исполнением: goose записывает только НОМЕР (ни имени, ни хеша), поэтому база, доехавшая до версии 3, на новом наборе рапортует «migrations applied» и не получает ни одной новой таблицы, а DownTo на ней ломается навсегда. Дев-воркфлоу из этого же документа создаёт ровно такую среду. Поэтому выпущенные 0000100003 возвращены байт-в-байт, а всё новое приехало отдельными номерами (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, перепроверено зоной, принято как цена правила.

  2. Ключ личности — (provider, subject); почта не ключ. users.email стала NULLABLE и БЕЗ уникального индекса; неизвестная пара всегда создаёт НОВЫЙ аккаунт. Разбор и цена решения — в журнале зоны, раздел «Политика коллизии почты».

  3. Админ-поверхность — CLI (tmplatformctl), не HTTP-ручка. Ручке понадобилась бы вторая модель авторизации (роли, эскалация, отзыв админской куки) ради пяти операций (grant · adjust · balance · logins · revoke), тогда как граница доверия «есть шелл на машине и доступ к DSN» уже обеспечена машиной. Браузерная панель, если понадобится, обернёт те же вызовы стора. 10а. Имя провайдера — TM_PLATFORM_OIDC_PROVIDER, и оно должно меняться ВМЕСТЕ с издателем. Это первая половина ключа личности. Направить TM_PLATFORM_OIDC_ISSUER на другой IdP, оставив имя прежним, — значит сложить sub нового провайдера в старое пространство имён, то есть тихо связать чужие аккаунты. Переменная называется здесь, потому что в деплой-примере её не было и оператору нечему было напомнить (найдено ревью P2).

  4. Секреты — через *_FILE. TM_PLATFORM_DSN_FILE и TM_PLATFORM_OIDC_CLIENT_SECRET_FILE читаются раньше одноимённых переменных: переменная окружения видна в /proc/<pid>/environ и наследуется каждым ребёнком-tmctl. Это же формат LoadCredential= systemd (deploy/).

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

  6. Политика сессий: 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 выполнено: сессия создаётся только в колбэке потока, который человек начал явным действием, и провайдер показывает свой экран согласия. Без взаимодействия сессия не появляется.

  7. Против 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 с конфигурацией: это наш ключ маршрутизации, а не идентификатор из нормы, и отвечает она на другой вопрос.

Как поднять локально

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

# бинарники 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) в скрэтчпаде — вне репозитория и вне зависимостей модуля.