diff --git a/docs/README.md b/docs/README.md index 78f7a759..50a4720b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ - `experiments/` — эмпирика полигона: [00-provider-quirks.md](experiments/00-provider-quirks.md) — **читать перед любым вызовом провайдера**; [08-cost-model-v2.md](experiments/08-cost-model-v2.md) — денежная модель; [09-pilot-protocol.md](experiments/09-pilot-protocol.md) — пилот Ф2.5; остальные 01–20 — отчёты закрытых экспериментов (судьба — в баннерах/D-логе; 18–20 — с ревью-шапками приёмки D39.108, шапка первична). - `research/` — фактура ресёрчей 01–25; у принятых — ревью-шапки, часть тел под ⚠ superseded: **читай баннер прежде содержимого**. Ключевые для навигации: 15 голос · 16 ридер-IDE · 17 внешняя критика · 18 рычаги качества · 19 нарезка · 20 банк-майнинг · 21 обзор транспорта · 22 доменные харнессы · 23 шов движок↔платформа (транспорт superseded D39.106) · 25 холодное ревью шва — форма D39.106, отвергнутые альтернативы, требования к эмиттеру (читать перед любым кодом стыка) · 24 арбитраж банка (ПРИНЯТ D39.102: консилиум закрыт классом, вход фикс-пака банка — §G). - [PROGRESS.md](PROGRESS.md) — журнал: CURRENT-STATE + **ЕДИНЫЙ БЭКЛОГ** (единственный трекер) + живой хвост хроники. НЕ источник решений. -- Активные хендофф-промты сессий (состав обновляется при каждом лендинге — норма D39.80): [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) (роль/нормы; состояния не дублирует) · [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md) (полигон, отложен) · **платформа: [../platform/docs/PLATFORM_SESSION_PROMPT.md](../platform/docs/PLATFORM_SESSION_PROMPT.md) (P0 ПРИНЯТ D39.107; пул доработок P1 в работе — приёмка изменённого дерева = №15, ждёт передачи; дерево `platform/*` ЖИВОЕ, не трогать)** · фронт: `frontend/docs/S3_SESSION_PROMPT.md` (замок ОТКРЫТ — контракт ратифицирован D39.99; первый шаг — правки спеки по D39.100). Зонные журналы фронта/платформы — `frontend-PROGRESS.md` / `platform-PROGRESS.md` в их зонах (решение владельца 04.08: прогресс зон только там). **Бэкенд: [BACKEND_BANK_CLUSTER_FIXPACK_SESSION_PROMPT.md](BACKEND_BANK_CLUSTER_FIXPACK_SESSION_PROMPT.md)** (фикс-пак банка, строка 128+129; санкция D39.103) · **Полигон: [POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md](POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md)** (эксп-21 «топология ролей»: скрин моделей → бейк-офф топологий на невиданном срезе; потолки предварительные, ЗАПУСК = слово владельца; D39.108). Очередь и состояние — только CURRENT-STATE. +- Активные хендофф-промты сессий (состав обновляется при каждом лендинге — норма D39.80): [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) (роль/нормы; состояния не дублирует) · [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md) (полигон, отложен) · **платформа: [../platform/docs/PLATFORM_SESSION_PROMPT.md](../platform/docs/PLATFORM_SESSION_PROMPT.md) — ОТРАБОТАН (P0 ПРИНЯТ D39.107; P1+P2 ПРИНЯТЫ и залендены D39.109, регистр до PD-107); следующий промт платформы — по слову владельца (реконсилятор/тейлер/юниты, D39.107 п.3(3))** · фронт: `frontend/docs/S3_SESSION_PROMPT.md` (замок ОТКРЫТ — контракт ратифицирован D39.99; первый шаг — правки спеки по D39.100). Зонные журналы фронта/платформы — `frontend-PROGRESS.md` / `platform-PROGRESS.md` в их зонах (решение владельца 04.08: прогресс зон только там). **Бэкенд: [BACKEND_BANK_CLUSTER_FIXPACK_SESSION_PROMPT.md](BACKEND_BANK_CLUSTER_FIXPACK_SESSION_PROMPT.md)** (фикс-пак банка, строка 128+129; санкция D39.103) · **Полигон: [POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md](POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md)** (эксп-21 «топология ролей»: скрин моделей → бейк-офф топологий на невиданном срезе; потолки предварительные, ЗАПУСК = слово владельца; D39.108). Очередь и состояние — только CURRENT-STATE. - Зоны фронта (чужие, читать при касании стыка; каждая ведёт СВОЙ зонный бэклог — единый бэклог их строк не принимает, D39.84): [../frontend/](../frontend/) — веб-интерфейс: промт фронт-сессий S0–S7 + [STACK_DECISIONS.md](../frontend/docs/STACK_DECISIONS.md) (пины версий точными числами и ловушки, сверены с вебом 02.08) + [BACKLOG.md](../frontend/docs/BACKLOG.md) · [../platform/](../platform/) — SaaS control plane: README + [BACKLOG.md](../platform/BACKLOG.md) (П-1..П-5) + `docs/` (промт P0 · зонный журнал `platform-PROGRESS.md`). - `archive/` — история ([правила архива](archive/README.md)): закрытые промты (`prompts/`) · отчёты с ревью-шапками (`reports/` — на них ссылаются приёмки) · исполненные арх-доки (`architecture/`) · слайсы хроники `PROGRESS-*.md`. Инструкции оттуда не исполнять. - Диаграммы: [../backend/docs/components.puml](../backend/docs/components.puml) · [../backend/docs/pipeline.puml](../backend/docs/pipeline.puml) — дом рядом с кодом (D39.80), правятся бэкендом одним коммитом с кодом; вручную НЕ рендерить (владелец смотрит PlantUML-расширением VS Code). diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index 86d499b7..a14982e0 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1,4 +1,4 @@ -# Журнал решений оркестратора — контракт D1–D39.105 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07) +# Журнал решений оркестратора — контракт D1–D39.109 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07) > **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто: > ⚠ **Навигация (актуализация 04.08):** два supersede-указателя эры D39.9x: **D39.88/D39.84 п.8 (право самокоммита фронта/платформы) → отозвано, коммитит ТОЛЬКО оркестратор** (D39.98 п.3; тела переписаны на месте с санкцией владельца, pre-rewrite — git `2b166b7`) · **норма 30.07 «короткая приёмка» → амендирована владельцем 03.08: приёмка всегда адверсариальная** (носитель — промт оркестратора §Анти-паттерны + D39.101 п.1) — упоминания «короткой приёмки» в телах читать через эту пометку · **двухсекционная редакторская инъекция и смягчающая роль маркера ⟨проверить⟩ → доктрина инжекта D39.104**: банк на проводе = ЗАКОН для всех ролей независимо от статуса строки, право «перевести иначе» упразднено, блок редактора — ЕДИНЫЙ, маркер с провода снимается (статусы строк и подписная таблица не меняются; внесение в движок — строка 134 после пробы 18) — упоминания двухсекционки/смягчения в телах читать через это. @@ -1533,3 +1533,19 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу **3. Эксп-21 «топология ролей» — промт выдан** (`docs/POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md`); двойной аудит: слепая панель 3 линз без файловых тулов (изоляция чиста) + полноконтекстный анти-паттерн-агент. Добавки панели, принятые в промт: арм F do-nothing (потерянная норма exp15:486) · MQM-lite/error-span протокол + мощность/MDE и шумовой пол ДО бейк-оффа · guarded+routed арм G · обратная связка E · Палладий-линт транскрипции · мини-проба глоссарий-лока · факторная рамка на замороженных черновиках · декой D39.46(б) и запрет судьи семейства жильца · пере-проба маппинга dspro до Ф1 (вахта 108). Потолки ПРЕДВАРИТЕЛЬНЫЕ (≈$7.3 стадиями с ранним отсевом; пере-сверка сметы после предзамера мощности — обязательна); запуск = слово владельца. Два тезиса разжалованы в гипотезы бейк-оффа (анти-соглашательство): «экономическое оправдание черновика пало» и «черновик = опора верности». **4. Закрытия и диспозиции:** **D21.4 (арм minimal-diff с тирингом) и D21.10 (поручение спеки апплая) ЗАКРЫТЫ явно** по букве гейта Q4b (exp15:147): аппликатор построен полигоном и замерен (15/15; комплаенс 0.966 на боевой единице), диффы отложены; реактивация — через строку 106/эксп-21. Строки **135–144** заведены (денежный пакет шва D39.107 п.2: пер-вызовный гейт · uncertain-эскроу · сверка с провайдером · реконсилятор · пиннинг версии; находки приёмки: банкнота-декларация 140 · дыра сносок 141 · механизм починки-по-флагу 142 · норматив судейства 143 · вахта grok-биллинга 144); строки 103/106/134/65/55/12 пере-диспозиционированы. Исполненный промт пробы 18 архивирован. PROGRESS CURRENT-STATE обновлён на №15 (долг D39.107 погашен). + +## D39.109 — ПРИЁМКА ПЛАТФОРМЫ P1+P2: приняты и залендены одним коммитом; регистр до PD-107, одна major; два вопроса владельцу (07.08). ✅ + +**1. Вердикт.** Дерево зоны `platform/` (30 изменённых отслеживаемых файлов, 22 новых, миграции 00004–00008) ПРИНЯТО и заленжено. ⚠ **Факт, который доки завышали:** журнал зоны объявлял «P1 ПРИНЯТ и заленден» — в git код P1 не уезжал (`git ls-files platform/internal/login` = 0 до этого коммита), уехали только P0 (`eeeef89`/`954c034`), направление (`99c9cb0`) и решения владельца (`87be7b9`/`6469479`). Формулировка исправлена на месте с пометкой оркестратора; строки регистра ошибки не несли — они честно говорят `fixed(P1, дерево сессии)`. Живой уязвимости приёмка не нашла. + +**2. Метод — исполнением, второй рубеж по своей карте.** Батарея пере-прогнана мной: офлайн зелёная (линтер 0 issues), с живым PostgreSQL 18.4 без root — **скипов ноль** (26 БД-тестов отработали), `make vuln` чист. **Свои 45 мутаций в четыре батча по СВОЕЙ карте несущих свойств (не по таблице пинов зоны): 33 поймано поимённо, 8 выжило, 4 моих посадки оказались негодными и разобраны вслух.** Мутации ставились в КОПИИ зоны вне репозитория — незакоммиченное дерево сессии не тронуто, сверено хешами диффа до и после. Живые пробы на боевом бинаре: PD-2 (10 полу-кормленных POST отпущены на 30.0 с), пять форм CSRF, ПТ-34-заголовки, RFC 9207 (Google действительно шлёт `iss`; сорванный → `issuer_missing`, чужой → `issuer_mismatch`, обмена кода нет). PD-71 пере-проверен своим прогоном на боевых данных: `DownTo(4)` падает на `users_email_key` SQLSTATE 23505, данные целы. Фаззеры: 3.0 и 3.35 млн исполнений, крэшеров нет. **Цитаты норм сверены по первоисточникам, не по пересказу** — RFC 9700 §4.4.2/§4.4.2.2, NIST SP 800-63B-4 §2.1.3, ASVS 5.0 7.1.1/7.1.2/7.1.3/7.6.1/7.6.2 дословны, номера и уровень L2 верны; это несущая проверка, на этих цитатах стоит смена боевого значения 90 → 30 суток. Плюс воркфлоу-панель: семь линз с зажатыми промтами (отчётные доки зоны запрещены) и адверсариальный опровергатель на каждую находку весом minor+ — 20 подтверждено, 2 опровергнуто. + +**3. Ратифицировано (6 из 6):** абсолютный срок сессии **30 суток** (буква NIST AAL1; у прежних 90 обоснования не было) · контрмера mix-up = **`iss` авторизационного ответа (RFC 9207)**, миграция 00008, а не раздельные redirect URI (норма объявляет их фолбэком) · **`STACK_DECISIONS §13`** как документ соответствия ASVS 7.1.1/7.1.2/7.1.3 с прямо названным рассогласованием с федеративной сессией · **ломающие изменения зоны** (`NewServer` без `Timeouts` — устранение класса сильнее теста · `Prober.Ready` · `login.Fail` без `*http.Request`; внешних потребителей нет, фронт говорит по HTTP) · **`MemoryMax=80%` + явный `OOMPolicy=continue`** (сверено с `systemd.resource-control(5)`/`systemd.service(5)`; ⚠ под systemd не исполнялось) · **PD-71 принят риском** в форме зоны: правило append-only дороже доступности отката ниже версии 5, место записи — `deploy/README.md` у оператора. + +**4. Найдено приёмкой: 29 строк PD-79…PD-107, одна major.** **PD-80 (major):** ведро лимитера одно на `/auth/login` и `/auth/callback`, а колбэк стирает login-куку ДО своей проверки лимитера ⇒ анонимный поток ~3 rps закрывает вход всем И добивает начатые входы невосстановимо (воспроизведено мной на бинаре и независимо панелью; фикс — порядок двух строк плюс раздельные ведра). Остальное minor/info, несущие: строковый `"null"` в `committed_usd` читается как НОЛЬ денег (PD-79, закрыть до воркера) · три «закрыто, но не запинено» по собственному правилу зоны — половина PD-3, лимитер PD-29, ветка обновления адреса (PD-83/84/85) · установка по наброску даёт нестартующий юнит и `ProtectHome` против «книги в `~/books`» (PD-91) · админ-CLI, единственный писатель денег, без единого теста (PD-106) · доккоммент `events.go` предлагает то, что PD-59 уже отклонил (PD-95) · дрейф реализованной поверхности `/auth/*`+`X-TM-Client` против контракта 14 (PD-96/остаток) · декодер и норматив зоны расходятся на дубле `seq`, и после PD-12 цена — убитый платный прогон (PD-105: разрешать ратификацией вместе с промтом эмиттера, строка 103) · удаление аккаунта обходит защиту PD-25 через каскад `users → reservations` (PD-107, гейт перед появлением такой операции). + +**5. Опровергнуто приёмкой, включая свои промахи** (дисциплина «заявление=команда» действует и на приёмку): версия панели «удаление аккаунта падает на композитном FK при закрытых резервациях» — мой прогон удаляет · «`money` читает JSON `null` как ноль» — голый `null` даёт nil, дыра в СТРОКЕ `"null"` · «WARN на каждый отбитый вход = неограниченная запись в лог» — `AccessLog` и так пишет INFO на каждый запрос · моя посадка «грант фри-тира не запинен» НЕГОДНА (грант живёт в ветке новой личности; корректная посадка ловится тестом) · моё «падение `FuzzDecoder`» — голод по CPU от параллельных батчей, чистый прогон зелёный · PD-20 — калибровка, а не находка: пин вероятностный по природе дефекта. Отдельно названо, что `TestMigrationsRollBackAndReapply` откатывает ПУСТУЮ базу и для 00005 не доказывает ничего. + +**6. Владельцу — два вопроса:** (а) срок сессии 30 суток означает, что не заходивший месяц человек увидит экран входа — если это против замысла, это одна переменная `TM_PLATFORM_SESSION_MAX_AGE` плюс явная запись отклонения в §13; (б) **новое (PD-104):** фри-тир печатается неаутентифицированным потоком по $5 за каждую новую подтверждённую пару `(provider, subject)`, агрегатного потолка и счётчика аномалий нет нигде — нужен ли суточный лимит грантов до открытия беты. + +**7. Долг лендинга, названный вслух:** PROGRESS CURRENT-STATE **не обновлён** — файл занят живым полигоном (его пинг по эксп-21 лежит незакоммиченным, коммит `d472599` его же). Тот же случай, что D39.107 п.3: обновляет тот, кто лендит полигон. `docs/README.md` обновлён этим же коммитом (норма D39.80), промт P0 платформы получил баннер-исход. diff --git a/platform/BACKLOG.md b/platform/BACKLOG.md index 11b73a1d..fc671ac2 100644 --- a/platform/BACKLOG.md +++ b/platform/BACKLOG.md @@ -2,9 +2,11 @@ > Ведёт зона `platform/` (решение владельца 02.08, D39.84: фронт и платформа держат СВОИ бэклоги; единый бэклог `docs/PROGRESS.md` остаётся трекером движка/полигона/доков и фронт/платформа-строк не принимает). Нормы те же: ID стабилен навсегда, каждая петля получает диспозицию. Запросы к ДВИЖКУ сюда не пишутся — они заходят строками единого бэклога через оркестратора (пример: строки 99–102). Засеян оркестратором при лендинге D39.84 — дальше правит платформа-сессия. -> **Диспозиции после P0 (04.08)** — в журнале зоны, раздел «Диспозиции бэклога зоны» -> (`docs/platform-PROGRESS.md`): П-1 начата (каркас), П-2/П-3 не трогали, П-4 черновая схема, -> П-5 форма предложена. Дублировать их здесь не стали — у строки один источник истины. +> **Диспозиции после P1 (05.08)** — в журнале зоны, раздел «Диспозиции бэклога зоны» +> (`docs/platform-PROGRESS.md`). Коротко: П-6 и П-8 ЗАКРЫТЫ, П-7 закрыт по схеме и операциям +> (постановка холда воркером — часть П-1), П-4 отменён и поглощён П-7, П-5 переопределён под +> кредитную модель и ждёт правки спеки, П-1 продолжена, П-2/П-3 не трогали. +> Дублировать их здесь не стали — у строки один источник истины. | ID | Хвост | Вес | Источник | |---|---|---|---| diff --git a/platform/Makefile b/platform/Makefile index 18cc13f8..405c2abe 100644 --- a/platform/Makefile +++ b/platform/Makefile @@ -9,7 +9,7 @@ GO_MIN_VERSION := 1.26.5 GOLANGCI_LINT ?= golangci-lint GOLANGCI_VERSION := 2.12.2 -.PHONY: build vet fmt lint test check tools-check vuln +.PHONY: build vet fmt lint test check tools-check vuln fuzz build: tools-check $(GO) build ./... @@ -35,14 +35,24 @@ lint: tools-check test: $(GO) test ./... -race -count=1 -# The battery. It ends by NAMING the tests that did not run: the database-backed ones skip without -# TM_PLATFORM_TEST_DSN, and a silent skip reads as coverage. -check: build vet fmt lint test - @echo "--- did NOT run (no database; set TM_PLATFORM_TEST_DSN) ---" - @$(GO) test ./... -count=1 -v > .skips.log 2>&1 || { echo "the skip-harvest pass FAILED:"; \ - grep -E '^(---|\s+---) FAIL|^FAIL' .skips.log; rm -f .skips.log; exit 1; } - @grep -- '--- SKIP' .skips.log || echo "(none)" - @rm -f .skips.log +# The battery. One verbose run under -race serves both purposes (PD-17: it used to run the suite a +# second time without -race just to harvest skip names), and it NAMES the tests that did not run — +# the database-backed ones skip without TM_PLATFORM_TEST_DSN, and a silent skip reads as coverage. +check: build vet fmt lint + @$(GO) test ./... -race -count=1 -v > .check.log 2>&1; status=$$?; \ + grep -E '^(ok|FAIL|\?)' .check.log || true; \ + if [ $$status -ne 0 ]; then \ + echo "--- FAILURES ---"; grep -E '^(---|[[:space:]]+---) FAIL' .check.log; \ + rm -f .check.log; exit 1; fi; \ + if grep -q -- '--- SKIP' .check.log; then \ + echo "--- did NOT run (set TM_PLATFORM_TEST_DSN for the schema tests) ---"; \ + grep -- '--- SKIP' .check.log; fi; \ + rm -f .check.log + +# Not in `check`: fuzzing is time-boxed exploration, not a gate. The seed corpus runs as an +# ordinary test on every `check`; this target is for going deeper on the decoder. +fuzz: + $(GO) test ./internal/ingest/ -run FuzzDecoder -fuzz FuzzDecoder -fuzztime 2m # Not part of `check`: it needs the network (the vulnerability database), and the battery must be # green on a bare clone offline. CI runs it as its own step (STACK_DECISIONS §5). diff --git a/platform/README.md b/platform/README.md index 7a740c8b..93d848af 100644 --- a/platform/README.md +++ b/platform/README.md @@ -1,12 +1,18 @@ # platform — control plane (SaaS-слой) Зона записи сессии «Платформа». P0 собран 04.08 (скелет: HTTP · сессии · схема read-model · -интерфейс ингеста); **активный промт — `docs/PLATFORM_SESSION_PROMPT.md`**, зонный журнал — +интерфейс ингеста), P1 — 05.08 (вход через OIDC · кредитный леджер · админ-CLI · деплой-юнит · +закрытие регистра дефектов). Направление зоны — `docs/PLATFORM_DIRECTION.md`, критерии приёмки — +`docs/ENGINEERING_STANDARDS.md`, дефекты — `docs/DEFECT_REGISTER.md`, зонный журнал — `docs/platform-PROGRESS.md` (весь прогресс зоны здесь, решение владельца 04.08), стек — `docs/STACK_DECISIONS.md`. Батарея зоны: `make check` (build · vet · fmt · lint · test -race). Тесты со схемой требуют -`TM_PLATFORM_TEST_DSN`; без него они пропускаются, и `check` называет пропуски вслух. +`TM_PLATFORM_TEST_DSN` (как поднять Postgres без root — `docs/STACK_DECISIONS.md`); без него они +пропускаются, и `check` называет пропуски вслух. `make vuln` и `make fuzz` — отдельными целями. + +Бинари: `cmd/tmplatformd` (сервис) и `cmd/tmplatformctl` (админ: гранты, КОРРЕКТИРОВКИ (`adjust`), баланс, журнал входов, +отзыв сессий). Деплой — `deploy/`. ## ⚠ Git и зона (читать ДО первой строки кода) @@ -22,13 +28,16 @@ Сервис между фронтом и движком перевода. Всё, что относится к ПОЛЬЗОВАТЕЛЯМ и не относится к переводу: -- аутентификация и аккаунты (подписки и оплата — ПОСЛЕ MVP, решение владельца 02.08: в MVP - оплаты нет и денежных полей в интерфейсе нет); +- аутентификация и аккаунты — **есть (P1)**: вход через OIDC даёт только СОБЫТИЕ входа, сессия + своя; ключ личности `(provider, subject)`, почта не ключ. Оплаты нет и в бете не будет + (владелец 05.08): аккаунты живут на кредитном балансе, фри-тир — запись `grant` в леджер; - библиотека книг: чья книга, права доступа, хранение исходников и экспортов; -- учёт токенов и денег **на пользователя** (сырьё уже считает движок: `request_log` + - `internal/ledger`), потолки и гейт бюджета ДО старта задачи; +- учёт денег **на пользователя** — **схема и операции есть (P1)**: append-only леджер в целых + микро-долларах, резервации, кэш баланса с инвариантом `balance == SUM(ledger)`. Защита прогона — + холд ДО спавна плюс пер-книжный потолок движку (жёсткий стоп исполняет движок); ждёт воркера; - очередь задач и запуск воркеров, статусы прогонов, ретраи; -- SSE-поток прогресса во фронт (⚠ денежные суммы на провод и на экран НЕ идут — D39.84; пользователь видит СТАТУС использования: процент и время сброса, П-5). +- SSE-поток прогресса во фронт (⚠ денежные суммы на провод и на экран НЕ идут — D39.84; пользователь + видит ОСТАТОК процентом — окон со сбросом больше нет, владелец 05.08). ## Чего здесь НЕ будет @@ -49,11 +58,12 @@ ## Стек Пины, даты релизов и обоснования — [`docs/STACK_DECISIONS.md`](docs/STACK_DECISIONS.md) (зонный, -live-сверка 04.08); общая записка по обоим новым сервисам — `../frontend/docs/STACK_DECISIONS.md` §5. +live-сверка 04–05.08); общая записка по обоим новым сервисам — `../frontend/docs/STACK_DECISIONS.md` §5. Коротко: Go 1.26.4 в `go.mod` (тулчейн сборки ≥1.26.5) · стандартный `net/http` + `ServeMux` без роутер-библиотеки · PostgreSQL 18 · pgx v5.10.0 · goose v3.27.3 · очередь River v0.42.0 на том же -Postgres (запинена, ещё не подключена — П-3) · `govulncheck` отдельной целью. +Postgres (запинена, ещё не подключена — П-3) · вход `x/oauth2` v0.36.0 + `go-oidc/v3` v3.20.0 · +`x/time` v0.15.0 для лимита на `/auth/login` · `govulncheck` отдельной целью. **Redis не заводим нигде** — зафиксировано как архитектурное «нет». Прогресс наружу — SSE, события **пушит воркер**, а не фронт опрашивает read-model. diff --git a/platform/cmd/tmplatformctl/main.go b/platform/cmd/tmplatformctl/main.go new file mode 100644 index 00000000..3df067e9 --- /dev/null +++ b/platform/cmd/tmplatformctl/main.go @@ -0,0 +1,248 @@ +// Command tmplatformctl is the admin surface (P-8): credit an account, read a balance, look at +// sign-ins, end sessions. +// +// A CLI rather than a protected HTTP route, deliberately. An admin endpoint needs a second +// authorisation model — roles, an escalation path, a way to lose the admin cookie — for four +// operations. The trust boundary for these is already "can open a shell on the box and read the +// DSN", and that boundary is enforced by the machine rather than by code we would have to write +// and get right. If a browser-facing admin panel is ever wanted, it wraps these same store calls. +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "syscall" + "text/tabwriter" + "time" + + "textmachine/platform/internal/config" + "textmachine/platform/internal/money" + "textmachine/platform/internal/pgstore" +) + +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + if errors.Is(err, errUsage) { + _, _ = fmt.Fprintln(os.Stderr, usage) + os.Exit(2) + } + _, _ = fmt.Fprintln(os.Stderr, "tmplatformctl:", err) + os.Exit(1) + } +} + +// errUsage asks main to print the usage text; every other error is a message on its own. +var errUsage = errors.New("usage") + +const usage = `usage: tmplatformctl [flags] + + grant --user --usd [--note ] [--key ] + adjust --user --usd --note [--key ] + balance --user + logins --user [--limit ] + revoke --user + +The DSN comes from TM_PLATFORM_DSN or TM_PLATFORM_DSN_FILE.` + +func run(args []string, out io.Writer) error { + if len(args) == 0 { + return errUsage + } + dsn, err := config.Secret("TM_PLATFORM_DSN") + if err != nil { + return err + } + if dsn == "" { + return errors.New("TM_PLATFORM_DSN (or TM_PLATFORM_DSN_FILE) is not set") + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + store, err := pgstore.Open(ctx, dsn) + if err != nil { + return err + } + defer store.Close() + + cmd, rest := args[0], args[1:] + switch cmd { + case "grant": + return grant(ctx, store, rest, out) + case "adjust": + return adjust(ctx, store, rest, out) + case "balance": + return balance(ctx, store, rest, out) + case "logins": + return logins(ctx, store, rest, out) + case "revoke": + return revoke(ctx, store, rest, out) + default: + return fmt.Errorf("unknown command %q: %w", cmd, errUsage) + } +} + +// grant writes one ledger row: that is the whole of the free tier. +func grant(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error { + fs := flag.NewFlagSet("grant", flag.ContinueOnError) + user := fs.String("user", "", "account id") + amount := fs.String("usd", "", "amount in dollars, e.g. 5 or 2.50") + note := fs.String("note", "", "why") + // Idempotency is OPT-IN. A default key derived from the account and the day looked safe and was + // not: two legitimate grants on one day collapse into the first, and the second reports success + // while crediting nothing. Each invocation is its own intent unless the operator says otherwise. + key := fs.String("key", "", "idempotency key: repeating the command with the same one is a no-op") + if err := fs.Parse(args); err != nil { + return err + } + if *user == "" || *amount == "" { + return errors.New("grant needs --user and --usd") + } + micro, err := money.ParseUSD(*amount) + if err != nil { + return err + } + return write(ctx, store, out, *user, *key, func(id string, now time.Time) (bool, error) { + return store.Grant(ctx, *user, micro, "admin", id, *note, now) + }, "granted "+micro.USD()+" to "+*user) +} + +// adjust corrects a balance with a second row: ledger rows are never edited. +func adjust(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error { + fs := flag.NewFlagSet("adjust", flag.ContinueOnError) + user := fs.String("user", "", "account id") + amount := fs.String("usd", "", "signed amount in dollars, e.g. -2.50") + note := fs.String("note", "", "why (required: an unexplained correction is unauditable)") + key := fs.String("key", "", "idempotency key") + if err := fs.Parse(args); err != nil { + return err + } + if *user == "" || *amount == "" || *note == "" { + return errors.New("adjust needs --user, --usd and --note") + } + micro, err := money.ParseUSD(*amount) + if err != nil { + return err + } + return write(ctx, store, out, *user, *key, func(id string, now time.Time) (bool, error) { + return store.Adjust(ctx, *user, micro, "admin", id, *note, now) + }, "adjusted "+*user+" by "+micro.USD()) +} + +// write runs one ledger operation and reports what actually happened. "Applied" and "the key was +// already spent" are different outcomes and the operator is told which one they got. +func write(ctx context.Context, store *pgstore.Store, out io.Writer, user, key string, + op func(id string, now time.Time) (bool, error), what string) error { + now := time.Now().UTC() + id := key + if id == "" { + id = newKey() + } + applied, err := op(id, now) + if err != nil { + return err + } + // Past this point the write is committed and NOTHING may report failure. An operator who reads + // an error retries, and a retry without --key mints a fresh idempotency key, so the second run + // credits again — a failed BALANCE READ would have bought a double credit. The balance is a + // courtesy; its failure is a note on the same line. Found by review. + shown := "balance unavailable: " + user + if after, err := store.Balance(ctx, user); err == nil { + shown = "balance is " + after.USD() + } else { + _, _ = fmt.Fprintf(out, "warning: could not read the balance back: %v\n", err) + } + if !applied { + _, _ = fmt.Fprintf(out, "no-op: key %s was already used on %s; %s\n", id, user, shown) + return nil + } + _, _ = fmt.Fprintf(out, "%s (key %s); %s\n", what, id, shown) + return nil +} + +// newKey mints a key for a one-off command, so that two deliberate grants on the same day are two +// grants. +func newKey() string { + var b [8]byte + rand.Read(b[:]) // never fails + return "cli-" + hex.EncodeToString(b[:]) +} + +func balance(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error { + fs := flag.NewFlagSet("balance", flag.ContinueOnError) + user := fs.String("user", "", "account id") + if err := fs.Parse(args); err != nil { + return err + } + if *user == "" { + return errors.New("balance needs --user") + } + // One snapshot: reading the cache and the ledger in two queries reports drift that a concurrent + // grant caused between them. + a, err := store.ReadAccount(ctx, *user) + if err != nil { + return err + } + _, _ = fmt.Fprintf(out, "balance %s\n", a.Balance.USD()) + if a.Reserved != 0 { + _, _ = fmt.Fprintf(out, "reserved %s (open holds, already deducted)\n", a.Reserved.USD()) + } + if a.Balance != a.LedgerSum { + _, _ = fmt.Fprintf(out, "⚠ ledger sums to %s: the cached balance has drifted\n", a.LedgerSum.USD()) + } + open, err := store.OpenReservations(ctx, *user) + if err != nil { + return err + } + for _, r := range open { + _, _ = fmt.Fprintf(out, " hold %s on book %s since %s (run %s)\n", + r.Amount.USD(), r.BookID, r.OpenedAt.UTC().Format(time.RFC3339), r.EngineRunID) + } + return nil +} + +func logins(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error { + fs := flag.NewFlagSet("logins", flag.ContinueOnError) + user := fs.String("user", "", "account id") + limit := fs.Int("limit", 20, "how many") + if err := fs.Parse(args); err != nil { + return err + } + if *user == "" { + return errors.New("logins needs --user") + } + entries, err := store.RecentLogins(ctx, *user, *limit) + if err != nil { + return err + } + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "WHEN\tPROVIDER\tOUTCOME\tCLIENT\tFROM\tREASON") + for _, e := range entries { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + e.At.UTC().Format(time.RFC3339), e.Provider, e.Outcome, e.Client, e.IPPrefix, e.Reason) + } + return w.Flush() +} + +func revoke(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error { + fs := flag.NewFlagSet("revoke", flag.ContinueOnError) + user := fs.String("user", "", "account id") + if err := fs.Parse(args); err != nil { + return err + } + if *user == "" { + return errors.New("revoke needs --user") + } + n, err := store.RevokeUserSessions(ctx, *user, time.Now().UTC()) + if err != nil { + return err + } + _, _ = fmt.Fprintf(out, "revoked %d sessions of %s\n", n, *user) + return nil +} diff --git a/platform/cmd/tmplatformd/main.go b/platform/cmd/tmplatformd/main.go index 856664ce..9ba4072b 100644 --- a/platform/cmd/tmplatformd/main.go +++ b/platform/cmd/tmplatformd/main.go @@ -7,7 +7,6 @@ import ( "context" "errors" "log/slog" - "net" "net/http" "os" "os/signal" @@ -17,12 +16,19 @@ import ( "textmachine/platform/internal/auth" "textmachine/platform/internal/config" "textmachine/platform/internal/httpapi" + "textmachine/platform/internal/login" "textmachine/platform/internal/pgstore" + "textmachine/platform/internal/reqid" ) +// sessionSweep is how often expired sessions are deleted. The table is small and the work is a +// single DELETE, so the interval is about not accumulating rows, not about load. +const sessionSweep = time.Hour + func main() { // Structured logs on stderr, like the engine's: stdout stays free for anything machine-read. - log := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + // The handler is wrapped so every *Context call carries its request id without saying so. + log := slog.New(reqid.WithContext(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))) if err := run(log); err != nil { log.Error("fatal", "err", err) os.Exit(1) @@ -56,48 +62,121 @@ func run(log *slog.Logger) error { defer db.Close() } + cookies := auth.Cookies{Insecure: cfg.InsecureCookies} + if cfg.InsecureCookies { + log.Warn("TM_PLATFORM_INSECURE_COOKIES: serving the session cookie without Secure, under a dev name — never in production") + } authn := &auth.Authenticator{ IdleTTL: cfg.SessionIdleTTL, + Cookies: cookies, + Log: log, Deny: httpapi.ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"), } - deps := httpapi.Deps{Log: log, Auth: authn, TrustedOrigins: cfg.TrustedOrigins} + deps := httpapi.Deps{Log: log, Auth: authn, TrustedOrigins: cfg.TrustedOrigins, HSTS: !cfg.InsecureCookies} if db != nil { deps.DB = db authn.Sessions = db + go sweepSessions(ctx, db, log) } + + switch { + case !cfg.LoginEnabled(): + log.Warn("no TM_PLATFORM_OIDC_ISSUER: sign-in is not mounted") + case db == nil: + // A login writes rows. Mounting it without a database would answer every attempt with a 503 + // from deep inside the flow instead of saying so once, here. + return errors.New("sign-in is configured but TM_PLATFORM_DSN is not: a login needs the database") + default: + lg, err := login.New(login.Config{ + Provider: cfg.OIDCProvider, + Issuer: cfg.OIDCIssuer, + ClientID: cfg.OIDCClientID, + ClientSecret: cfg.OIDCClientSecret, + RedirectURL: cfg.OIDCRedirectURL, + AfterLogin: cfg.AfterLogin, + SessionIdleTTL: cfg.SessionIdleTTL, + SessionMaxAge: cfg.SessionMaxAge, + SignupGrantMicroUSD: cfg.SignupGrantMicroUSD, + }, db, cookies, log) + if err != nil { + return err + } + lg.SetFail(httpapi.WriteProblem) + deps.Login = lg + go sweepLogins(ctx, db, log) + } + handler, err := httpapi.New(deps) if err != nil { return err } - srv := &http.Server{ - Addr: cfg.Addr, - Handler: handler, - // No WriteTimeout: the SSE stream (P-1) is a long-lived response, and a write deadline set - // here would cut it. Per-request deadlines belong on the handlers that want them. - ReadHeaderTimeout: 10 * time.Second, - IdleTimeout: 2 * time.Minute, - MaxHeaderBytes: 1 << 16, - BaseContext: func(net.Listener) context.Context { return ctx }, - } - - errc := make(chan error, 1) + // A second signal must kill rather than wait: once the drain starts, the handler is + // unregistered and the next SIGTERM goes back to being fatal. go func() { - log.Info("listening", "addr", cfg.Addr) - errc <- srv.ListenAndServe() + <-ctx.Done() + stop() }() - select { - case err := <-errc: - if errors.Is(err, http.ErrServerClosed) { - return nil - } + srv := httpapi.NewServer(cfg.Addr, handler, log) + ln, err := srv.Listen(ctx) + if err != nil { return err - case <-ctx.Done(): - stop() // a second signal now kills instead of waiting - shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - log.Info("shutting down") - return srv.Shutdown(shutdownCtx) + } + log.Info("listening", "addr", ln.Addr().String()) + return srv.Run(ctx, ln) +} + +// sweepSessions deletes rows past their absolute expiry (PD-7). A failed sweep is logged and +// retried on the next tick: it is housekeeping, and it must never take the service down. +func sweepSessions(ctx context.Context, db *pgstore.Store, log *slog.Logger) { + t := time.NewTicker(sessionSweep) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c, cancel := context.WithTimeout(ctx, 30*time.Second) + n, err := db.SweepSessions(c, time.Now()) + cancel() + switch { + case err != nil: + log.Error("session sweep failed", "err", err) + case n > 0: + log.Info("session sweep", "deleted", n) + } + } + } +} + +// loginJournalRetention is how long a sign-in stays in the journal. Long enough to answer "was +// that me last month", short enough that an unauthenticated endpoint cannot grow the table without +// end. +const loginJournalRetention = 180 * 24 * time.Hour + +// sweepLogins deletes abandoned authorization requests and journal entries past retention. +func sweepLogins(ctx context.Context, db *pgstore.Store, log *slog.Logger) { + t := time.NewTicker(15 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c, cancel := context.WithTimeout(ctx, time.Minute) + states, err := db.DeleteExpiredLoginStates(c, time.Now()) + if err != nil { + log.Error("login state sweep failed", "err", err) + } + events, err := db.DeleteOldLoginEvents(c, time.Now().Add(-loginJournalRetention)) + cancel() + if err != nil { + log.Error("login journal sweep failed", "err", err) + } + if states > 0 || events > 0 { + log.Info("login sweep", "states", states, "events", events) + } + } } } diff --git a/platform/deploy/README.md b/platform/deploy/README.md new file mode 100644 index 00000000..5bfedd71 --- /dev/null +++ b/platform/deploy/README.md @@ -0,0 +1,63 @@ +# Развёртывание платформы + +Одна VM, systemd, бинари артефактами CI (`PLATFORM_DIRECTION.md` §3). Не Kubernetes: дети-`tmctl` +живут часами и держат эксклюзивный лок на файлах книги на локальном диске — оркестратор, способный +переселить под посреди прогона, этой нагрузке враждебен. + +## Файлы + +- `tmplatformd.service` — юнит контрольной панели. Тело юнита проверено `systemd-analyze verify` + (systemd 259) — exit 0, без замечаний. ⚠ Проверять надо с ПОДСТАВЛЕННЫМ существующим `ExecStart=`: + дословно юнит даёт exit 1, потому что `verify` проверяет и наличие бинаря, а `/usr/local/bin/tmplatformd` + на стенде нет. ⚠ **живого прогона под systemd не было** — на машине нет sudo, юнит не устанавливался. + +## Откат релиза: не ниже версии 5 + +`goose down` до версии 4 и ниже НЕ РАБОТАЕТ на живой базе: down-путь `00005` восстанавливает +`users_email_key` и `email NOT NULL`, а обе формы нарушают строки, которые пишет боевой код +(неподтверждённая личность даёт `email = NULL`; один адрес законно принадлежит двум аккаунтам). +Откат транзакционный, поэтому падение ничего не портит — но планировать откат ниже 5 нельзя, +план отката — накатить вперёд. Разбор: `docs/STACK_DECISIONS.md` §8. + +## Что юнит закрывает содержательно + +- **PD-13 (осиротевшие процессы движка).** Каждый `tmctl` живёт в cgroup ЭТОГО юнита, поэтому падение + или рестарт платформы не оставляет прогон без присмотра. Обычную остановку делает супервизор + (группа процессов, `internal/ingest/procgroup_unix.go`); cgroup — это ответ на случай, когда + супервизора уже нет, чтобы попросить. +- **`TimeoutStopSec=90`** больше, чем дренаж платформы (15 с) плюс grace движка (30 с). Меньше — + и systemd прибьёт `tmctl` посреди остановки, оставив лок проекта. +- **Секреты через `LoadCredential=`,** а не через окружение: переменная окружения видна в + `/proc//environ` и наследуется каждым ребёнком-`tmctl`. Конфиг читает `*_FILE` первым. + +## Установка (набросок, исполняется владельцем) + +```sh +useradd --system --home /srv/textmachine tmplatform +install -D -m0755 tmplatformd /usr/local/bin/tmplatformd +install -D -m0755 tmplatformctl /usr/local/bin/tmplatformctl +install -d -m0700 -o root -g root /etc/tmplatform +printf '%s' 'postgres://...' > /etc/tmplatform/dsn && chmod 0400 /etc/tmplatform/dsn +printf '%s' '' > /etc/tmplatform/oidc_client_secret && chmod 0400 /etc/tmplatform/oidc_client_secret +``` + +`/etc/tmplatform/env` — несекретное окружение: + +``` +TM_PLATFORM_ADDR=127.0.0.1:8080 +TM_PLATFORM_TRUSTED_ORIGINS=https://app.example.org +TM_PLATFORM_OIDC_ISSUER=https://accounts.google.com +TM_PLATFORM_OIDC_CLIENT_ID=... +TM_PLATFORM_OIDC_REDIRECT_URL=https://app.example.org/auth/callback +TM_PLATFORM_AFTER_LOGIN=/library +TM_PLATFORM_SIGNUP_GRANT_USD=5 +``` + +Миграции выкатываются ОДИН раз, не каждой репликой: `TM_PLATFORM_MIGRATE=1 tmplatformd` разово +либо отдельный шаг деплоя. `goose` держит advisory-лок, так что параллельный запуск не гонка, +но и не норма. + +## Чего здесь ещё нет + +TLS и домен (перед юнитом предполагается edge-прокси), ограничитель соединений на edge, +ротация логов, бэкап Postgres. Всё это — работа с первым реальным деплоем, не раньше. diff --git a/platform/deploy/tmplatformd.service b/platform/deploy/tmplatformd.service new file mode 100644 index 00000000..27880681 --- /dev/null +++ b/platform/deploy/tmplatformd.service @@ -0,0 +1,86 @@ +# The control plane as a systemd unit. One VM, systemd, binaries from CI — not Kubernetes: a tmctl +# child runs for HOURS and holds an exclusive lock on the book's files on the local disk, so any +# orchestrator that can move a pod mid-run is hostile to this workload (PLATFORM_DIRECTION §3). +# +# This unit is also the honest answer to PD-13, orphaned engine processes: every tmctl the service +# spawns lives in THIS unit's cgroup, so a restart or a crash of the platform cannot leave a +# translation running with nobody watching it. The supervisor's process group handles the ordinary +# stop; the cgroup handles the case where the supervisor is no longer there to ask. + +[Unit] +Description=TextMachine control plane +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +# Type=exec, not notify: the binary does not speak sd_notify, and claiming it does would make +# systemd wait for a readiness signal that never comes. +Type=exec +ExecStart=/usr/local/bin/tmplatformd +User=tmplatform +Group=tmplatform + +# KillMode=mixed: SIGTERM to the main process only, so the platform runs its own drain and stops +# its children the way the engine expects; SIGKILL to everything left when the timeout runs out. +KillMode=mixed +KillSignal=SIGTERM +# Longer than the platform's own drain (15s) plus the engine's stop grace (30s), or systemd would +# SIGKILL a tmctl mid-shutdown and leave its project lock behind. +TimeoutStopSec=90 +Restart=on-failure +RestartSec=5s + +# Secrets as credentials, not as environment: an environment variable is visible in +# /proc//environ and is inherited by every tmctl child. The config reads *_FILE first. +LoadCredential=dsn:/etc/tmplatform/dsn +LoadCredential=oidc_client_secret:/etc/tmplatform/oidc_client_secret +Environment=TM_PLATFORM_DSN_FILE=%d/dsn +Environment=TM_PLATFORM_OIDC_CLIENT_SECRET_FILE=%d/oidc_client_secret +EnvironmentFile=/etc/tmplatform/env + +# Books live outside the repository and outside /var/lib by owner's decision (~/books); the unit +# gets the one directory it may write and nothing else. +ReadWritePaths=/srv/textmachine +StateDirectory=tmplatform + +# Sandboxing. Free, and it bounds what a compromised process reaches. +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +NoNewPrivileges=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +LockPersonality=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +# These bound the unit's CGROUP, and by the argument at the top of this file every tmctl the +# platform spawns lives in it. So they do not bound "the control plane" — they bound the control +# plane plus every run in flight, together (PD-55). Two consequences follow, and both are decided +# here rather than discovered in production: +# +# 1. The ceiling is sized as a machine backstop, not as a service bound. systemd.resource-control(5) +# calls MemoryMax= "the last line of defense"; as a percentage it needs no knowledge of the box. +# A 2G figure would have been a bound on the RUNS, and the OOM killer invoked inside the unit +# picks the largest process — the engine, holding an exclusive lock on a book's files. That is +# precisely the SIGKILL that TimeoutStopSec= above exists to avoid. +# 2. OOMPolicy is set explicitly. The system default is `stop`: one OOM-killed tmctl would take the +# control plane and every other run down with it, then land the unit in oom-kill failed state for +# Restart= to pick up. `continue` logs the kill and keeps the service running, so the supervisor +# survives to observe the child's exit. (What it does with that exit is the worker's job and the +# worker does not exist yet — nothing calls Settle today, PD-43.) OOMScoreAdjust= cannot help +# here: it is inherited by the children, so it cannot tell the engine apart from the platform. +# +# Bounding ONE run is the worker's job when it exists — a transient scope per run, not a knob here. +MemoryMax=80% +OOMPolicy=continue +# Platform plus concurrent runs, each a Go process with a few dozen threads: room for roughly a +# dozen runs on one VM, which is more than a single box will carry. +TasksMax=512 +LimitNOFILE=8192 + +[Install] +WantedBy=multi-user.target diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index a059db3d..a701eadf 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -8,22 +8,110 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| -| PD-1 | hardening | minor | `internal/pgstore/pg_test.go:89` | Свойство «в БД только SHA-256, не токен» НЕ запинено тестом: посадка «`Digest` возвращает плейнтекст» выживает — тест сверяет хранимое через тот же `auth.Digest` (self-consistent). Нужен тест с НЕЗАВИСИМО вычисленным хешом либо ассерт «плейнтекст в БД не находится» | open | приёмка P0 (посадка №1) | -| PD-2 | vuln | **major, ЖИВАЯ (не латентная)** | `cmd/tmplatformd/main.go:73-82` | Нет `ReadTimeout` ⇒ соединения пиннятся уже СЕГОДНЯ, без единой body-принимающей ручки: `net/http` дренирует непрочитанное тело <256 КБ ВНУТРИ `chunkWriter.writeHeader` до отправки заголовка ответа (`net/http/server.go:1389-1435`), и этот чтение-шаг наследует отсутствующий дедлайн. **Репродуцировано оркестратором на собранном бинаре:** 50 полу-кормленных POST на охраняемый `/v0/*` → сервер отработал и залогировал 50×401 `ms:0`, клиенты получили НОЛЬ байт, fd 7→57 и держались, пока не закрыл КЛИЕНТ (агент-скептик независимо пинил 500 соединений). Ограничителя соединений и документированного edge-прокси в зоне нет. Фикс — одна строка (`ReadTimeout`; для будущего SSE — per-conn дедлайны через `ResponseController`). Вторая половина (`MaxBytesReader`) сегодня не эксплуатируема (ни один хендлер не читает body) — гейт P1: закрыть ДО первого POST-хендлера | open | приёмка P0 (security-линза + скептик + собственная репродукция) | -| PD-3 | bug | minor | `internal/httpapi/middleware.go:57` | `Recover` логирует сырой `r.URL.Path` на ERROR — id книг/прогонов утекают в лог, против собственной дисциплины AccessLog (route-pattern, не путь) | open | приёмка P0 (security-линза) | -| PD-4 | hardening | minor | `internal/pgstore/sessions.go:41` | WHERE у `Touch` слабее, чем у `Lookup` (нет `idle_expires_at > now`): прямой вызов воскресил бы idle-истёкшую сессию. Через `Require` недостижимо (Touch только после успешного Lookup) — одна строка защиты в глубину | open | приёмка P0 (security-линза) | -| PD-5 | bug | minor | `internal/auth/middleware.go:36,45` | Ошибки стора невидимы: сбойный `Lookup` → 401 без единой строки лога (аутентификационный DB-outage выглядит как шторм 401), `Touch` глотается `_ =`. На проводе различать нельзя (оракул) — но лог обязан различать | open | приёмка P0 (security+blind линзы) | +| PD-1 | hardening | minor | `internal/pgstore/pg_test.go:89` | Свойство «в БД только SHA-256, не токен» НЕ запинено тестом: посадка «`Digest` возвращает плейнтекст» выживает — тест сверяет хранимое через тот же `auth.Digest` (self-consistent). Нужен тест с НЕЗАВИСИМО вычисленным хешом либо ассерт «плейнтекст в БД не находится» — **закрыто:** `internal/pgstore/pg_test.go` — `TestStoredCredentialIsAHashNotTheToken`: оракул SHA-256 считается в тесте, плюс поиск плейнтекста в отрендеренной строке. Посадка «`Digest` возвращает плейнтекст» ПАДАЕТ (проверено) | fixed(P1, дерево сессии) | приёмка P0 (посадка №1) | +| PD-2 | vuln | **major, ЖИВАЯ (не латентная)** | `cmd/tmplatformd/main.go:73-82` | Нет `ReadTimeout` ⇒ соединения пиннятся уже СЕГОДНЯ, без единой body-принимающей ручки: `net/http` дренирует непрочитанное тело <256 КБ ВНУТРИ `chunkWriter.writeHeader` до отправки заголовка ответа (`net/http/server.go:1389-1435`), и этот чтение-шаг наследует отсутствующий дедлайн. **Репродуцировано оркестратором на собранном бинаре:** 50 полу-кормленных POST на охраняемый `/v0/*` → сервер отработал и залогировал 50×401 `ms:0`, клиенты получили НОЛЬ байт, fd 7→57 и держались, пока не закрыл КЛИЕНТ (агент-скептик независимо пинил 500 соединений). Ограничителя соединений и документированного edge-прокси в зоне нет. Фикс — одна строка (`ReadTimeout`; для будущего SSE — per-conn дедлайны через `ResponseController`). Вторая половина (`MaxBytesReader`) сегодня не эксплуатируема (ни один хендлер не читает body) — гейт P1: закрыть ДО первого POST-хендлера — **закрыто:** `ReadTimeout` 30 с в `httpapi.DefaultTimeouts`; пин — `TestHalfFedRequestIsDroppedByTheServer` на РЕАЛЬНОМ `http.Server`. Живая проба: полу-кормленный POST теперь отпускается через 30.0 с (был бесконечно). Вторая половина закрыта `LimitBody` на поддереве `/v0` и `/auth`. Побочное обязательство «`ReadTimeout` рубил бы и SSE» ОПРОВЕРГНУТО в P2 (PD-51/PD-63): `net/http` снимает дедлайн сам, помощник `ClearReadDeadline` удалён как воспроизводивший ровно этот дефект; поток пинит `TestStreamOutlivesReadTimeout` | fixed(P1, дерево сессии) | приёмка P0 (security-линза + скептик + собственная репродукция) | +| PD-3 | bug | minor | `internal/httpapi/middleware.go:57` | `Recover` логирует сырой `r.URL.Path` на ERROR — id книг/прогонов утекают в лог, против собственной дисциплины AccessLog (route-pattern, не путь) — **закрыто:** `Recover` логирует `route`, не `r.URL.Path` | fixed(P1, дерево сессии) | приёмка P0 (security-линза) | +| PD-4 | hardening | minor | `internal/pgstore/sessions.go:41` | WHERE у `Touch` слабее, чем у `Lookup` (нет `idle_expires_at > now`): прямой вызов воскресил бы idle-истёкшую сессию. Через `Require` недостижимо (Touch только после успешного Lookup) — одна строка защиты в глубину — **закрыто:** клауза `idle_expires_at > $2` добавлена; пин — `TestTouchCannotResurrectAnIdleExpiredSession` (посадка падает) | fixed(P1, дерево сессии) | приёмка P0 (security-линза) | +| PD-5 | bug | minor | `internal/auth/middleware.go:36,45` | Ошибки стора невидимы: сбойный `Lookup` → 401 без единой строки лога (аутентификационный DB-outage выглядит как шторм 401), `Touch` глотается `_ =`. На проводе различать нельзя (оракул) — но лог обязан различать — **закрыто:** `Authenticator.Log`: сбой `Lookup` (кроме `ErrNoSession`) и сбой `Touch` уходят в ERROR с `request_id`; на проводе по-прежнему неразличимо | fixed(P1, дерево сессии) | приёмка P0 (security+blind линзы) | | PD-6 | hardening | info | `internal/auth/csrf.go:51` | GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) | open | приёмка P0 (security-линза) | -| PD-7 | bug | info | `internal/pgstore/sessions.go:78` | `DeleteExpiredSessions` никем не вызывается — свип запланировать в P1 (периодическая джоба воркера) | open | приёмка P0 | -| PD-8 | hardening | info | `internal/auth/session.go:18` | Писателя куки ещё нет; `__Host-` требует Secure ⇒ локальный dev по HTTP куку не поставит. Решить формой в P1 (dev-профиль), префикс не ослаблять в проде | open | приёмка P0 | -| PD-9 | bug | minor | `cmd/tmplatformd/main.go:81` | `BaseContext` возвращает signal-контекст ⇒ SIGTERM мгновенно рубит контексты ВСЕХ in-flight запросов, и 15-секундный дренаж `Shutdown` мёртв для ctx-aware хендлеров. Fix: BaseContext без signal-ctx; сигнал ведёт только Shutdown | open | приёмка P0 (faults-линза) | -| PD-10 | bug | minor | `internal/ingest/decoder.go:42,61,67` | Три ужесточения декодера: (а) `hello` с пустым `engine_run_id` принимается — а это половина ключа идемпотентности; (б) seq самого hello не пинится к 1 — потеря пре-хендшейковых строк недетектируема; (в) mid-stream `hello` (любой версии, вкл. мажор 9.9) уходит в Sink как обычное событие — version-гейт держит только строку 1 | open | приёмка P0 (faults-линза) | -| PD-11 | bug | minor | `internal/pgstore/migrations/00002_readmodel.sql:137,177` | Неиндексированные FK-каскады: `notes.chapter_id` и `bank_decisions.term_id` — каскадное удаление сканирует таблицы | open | приёмка P0 (faults-линза) | -| PD-12 | bug | info | `internal/ingest/supervisor.go:82-84` | Сбой Sink в начале прогона ⇒ платформа дренирует ВЕСЬ оставшийся поток в `io.Discard` часами: ceiling/bank_stop-события выбрасываются, никто не оповещён. Нужна политика «БД платформы упала посреди прогона» (ретраи синка / деградация с алармом) — дизайн-вопрос P1 | open | приёмка P0 (faults-линза) | -| PD-13 | bug | info | `internal/ingest/supervisor.go:64` | Краш платформы осиротляет процесс движка: ни process-group, ни pidfile, ни пути реаттача (поток невосстановим, повторный спавн упрётся в EXCLUSIVE-лок). Дизайн супервизии P1 | open | приёмка P0 (faults-линза) | -| PD-14 | hardening | info | `internal/httpapi/server.go:79` | `readyz`: ping без собственного таймаута (WriteTimeout нет намеренно — SSE), эндпоинт неаутентифицирован и без rate-limit — задушить дешёво; таймаут на ping + прикрыть на ops-слое | open | приёмка P0 | -| PD-15 | bug | info | `internal/ingest/resync.go:32` | Деньги в ре-синке — float64, а `usage_windows` хранит micro-USD именно против дрейфа: дрейф входит шагом раньше (JSON-парс + суммирование дельт). Принять осознанно или считать в целых | open | приёмка P0 (faults-линза) | -| PD-16 | bug | minor | `internal/httpapi/server.go:81` | `readyz` глотает ошибку ping вопреки собственному комменту «the reason stays in the log» — лога нет | open | приёмка P0 (blind-линза) | -| PD-17 | bug | minor | `Makefile:41-42` | Баннер «did NOT run (no database)» печатается и при ПРОГНАННЫХ БД-тестах (безусловный); батарея гоняет сьют дважды ради имён скипов (второй прогон без -race) | open | приёмка P0 (blind-линза) | -| PD-18 | bug | info | `internal/pgstore/migrations/00002_readmodel.sql:9,139` | Коммент шапки «engine vocabulary never crosses this seam» противоречит `notes.reason` (движковая причина хранится, не проецируется); коммент переписать честно | open | приёмка P0 (canon-линза) | -| PD-19 | bug | info | `internal/ingest/resync.go:44` | `WorstFlagReason` задокументирован «stored», а колонки в `chapters` нет — доккоммент или схема, одно из двух | open | приёмка P0 (canon-линза) | +| PD-7 | bug | info | `internal/pgstore/sessions.go:78` | `DeleteExpiredSessions` никем не вызывается — свип запланировать в P1 (периодическая джоба воркера) — **закрыто:** свип сессий раз в час в демоне (`sweepSessions`), плюс свип брошенных логинов раз в 15 минут | fixed(P1, дерево сессии) | приёмка P0 | +| PD-8 | hardening | info | `internal/auth/session.go:18` | Писателя куки ещё нет; `__Host-` требует Secure ⇒ локальный dev по HTTP куку не поставит. Решить формой в P1 (dev-профиль), префикс не ослаблять в проде — **закрыто:** `auth.Cookies{Insecure}` — dev-профиль меняет ИМЯ вместе с атрибутами (`tm_session` без `__Host-`), `TM_PLATFORM_INSECURE_COOKIES=1`, демон предупреждает в лог | fixed(P1, дерево сессии) | приёмка P0 | +| PD-9 | bug | minor | `cmd/tmplatformd/main.go:81` | `BaseContext` возвращает signal-контекст ⇒ SIGTERM мгновенно рубит контексты ВСЕХ in-flight запросов, и 15-секундный дренаж `Shutdown` мёртв для ctx-aware хендлеров. Fix: BaseContext без signal-ctx; сигнал ведёт только Shutdown — **закрыто:** `BaseContext` — собственный контекст, отменяется ПОСЛЕ `Shutdown`; пин — `TestShutdownDrainsInFlightRequests` (посадка «BaseContext = сигнальный ctx» падает) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | +| PD-10 | bug | minor | `internal/ingest/decoder.go:42,61,67` | Три ужесточения декодера: (а) `hello` с пустым `engine_run_id` принимается — а это половина ключа идемпотентности; (б) seq самого hello не пинится к 1 — потеря пре-хендшейковых строк недетектируема; (в) mid-stream `hello` (любой версии, вкл. мажор 9.9) уходит в Sink как обычное событие — version-гейт держит только строку 1 — **закрыто:** три ужесточения + `ErrBadHandshake`/`ErrRepeatedHello`; пины — `TestHandshakeMustIdentifyTheStream` и `FuzzDecoder` (4.4 млн исполнений, инварианты — оракулы) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | +| PD-11 | bug | minor | `internal/pgstore/migrations/00002_readmodel.sql:137,177` | Неиндексированные FK-каскады: `notes.chapter_id` и `bank_decisions.term_id` — каскадное удаление сканирует таблицы — **закрыто:** `notes_chapter_idx` + `bank_decisions_term_idx`; `notes.unit_id` уже был | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | +| PD-12 | bug | info | `internal/ingest/supervisor.go:82-84` | Сбой Sink в начале прогона ⇒ платформа дренирует ВЕСЬ оставшийся поток в `io.Discard` часами: ceiling/bank_stop-события выбрасываются, никто не оповещён. Нужна политика «БД платформы упала посреди прогона» (ретраи синка / деградация с алармом) — дизайн-вопрос P1 — **закрыто:** сбой синка ОСТАНАВЛИВАЕТ прогон (`stop()` после `Ingest`), а не дренирует его в `io.Discard`; пин — `TestFailingSinkStopsTheRun`. Политика ретраев самого синка — при постройке материализатора | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | +| PD-13 | bug | info | `internal/ingest/supervisor.go:64` | Краш платформы осиротляет процесс движка: ни process-group, ни pidfile, ни пути реаттача (поток невосстановим, повторный спавн упрётся в EXCLUSIVE-лок). Дизайн супервизии P1 — **закрыто:** группа процессов (`Setpgid` + сигнал группе) закрывает обычную остановку; краш платформы закрывает cgroup юнита — `deploy/tmplatformd.service` (проверен `systemd-analyze verify`, живого прогона под systemd не было) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | +| PD-14 | hardening | info | `internal/httpapi/server.go:79` | `readyz`: ping без собственного таймаута (WriteTimeout нет намеренно — SSE), эндпоинт неаутентифицирован и без rate-limit — задушить дешёво; таймаут на ping + прикрыть на ops-слое — **закрыто:** собственный таймаут 2 с на ping; rate-limit на ops-слое (edge), в зоне не строим | fixed(P1, дерево сессии) | приёмка P0 | +| PD-15 | bug | info | `internal/ingest/resync.go:32` | Деньги в ре-синке — float64, а `usage_windows` хранит micro-USD именно против дрейфа: дрейф входит шагом раньше (JSON-парс + суммирование дельт). Принять осознанно или считать в целых — **закрыто:** деньги на шве — `money.MicroUSD` через `big.Rat`, округление ВВЕРХ; пины — `TestSpendConvertsExactlyAndRoundsUp`, `TestParseUSDIsExactAndRoundsAwayFromZero` | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | +| PD-16 | bug | minor | `internal/httpapi/server.go:81` | `readyz` глотает ошибку ping вопреки собственному комменту «the reason stays in the log» — лога нет — **закрыто:** ошибка ping уходит в ERROR | fixed(P1, дерево сессии) | приёмка P0 (blind-линза) | +| PD-17 | bug | minor | `Makefile:41-42` | Баннер «did NOT run (no database)» печатается и при ПРОГНАННЫХ БД-тестах (безусловный); батарея гоняет сьют дважды ради имён скипов (второй прогон без -race) — **закрыто:** один прогон сьюта под `-race`, баннер печатается только при наличии скипов | fixed(P1, дерево сессии) | приёмка P0 (blind-линза) | +| PD-18 | bug | info | `internal/pgstore/migrations/00002_readmodel.sql:9,139` | Коммент шапки «engine vocabulary never crosses this seam» противоречит `notes.reason` (движковая причина хранится, не проецируется); коммент переписать честно — **закрыто:** шапка миграции переписана: исключение (`notes.reason`) названо там же | fixed(P1, дерево сессии) | приёмка P0 (canon-линза) | +| PD-19 | bug | info | `internal/ingest/resync.go:44` | `WorstFlagReason` задокументирован «stored», а колонки в `chapters` нет — доккоммент или схема, одно из двух — **закрыто:** `WorstFlagReason` убран из аллоулиста — в контракте v0 у главы нет читателя для него | fixed(P1, дерево сессии) | приёмка P0 (canon-линза) | +| PD-20 | bug | minor | `internal/ingest/supervisor.go:78` | Один сигнал остановки ТЕРЯЕТСЯ, если послан в первые миллисекунды жизни ребёнка: воспроизведено на стенде отдельным экспериментом (8 запусков, промах на нулевой задержке) и как флейк собственного теста PD-12 (1 падение из 3). Последствие серьёзнее самого промаха: единственный оставшийся механизм — SIGKILL по `WaitDelay`, а движок держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта, и после kill лок остаётся — **закрыто:** `askToStop` повторяет SIGINT на 30/120/400 мс с проверкой «процесс ещё наш» через `os.Process`; пин — `TestFailingSinkStopsTheRun` (25 прогонов подряд зелёные, до фикса падал) | fixed(P1, дерево сессии) | самопроверка P1 (флейк собственного теста) | +| PD-21 | vuln | minor | `internal/login/login.go:safeReturnTo` | Открытый редирект в `?return_to`: `/\evil.example` проходил проверку — `url.Parse` читает это как обычный путь, а браузер нормализует `\` в `/` и получает протокол-относительный URL, то есть чужой хост. Найдено ПОСАДКОЙ мутации: ослабление проверки тест пережило, значит тест был слабый — **закрыто:** аллоулист (первый символ `/`, второй не `/`, обратных слэшей нет, `Scheme`/`Host`/`Opaque` пусты), тест переписан на «каждый враждебный вход даёт ПУСТО»; посадка теперь падает. Дефект не покидал дерево сессии | fixed(P1, дерево сессии) | самопроверка P1 (посадка мутации) | +| PD-22 | hardening | info | `deploy/` | Ограничителя одновременных соединений нет ни в процессе, ни описанного edge-прокси: `ReadTimeout` ограничивает УДЕРЖАНИЕ одного соединения 30 секундами, но не их число. Осознанно оставлено деплой-слою (`LimitNOFILE`, edge) — строка заведена, чтобы это было решением, а не забывчивостью | accepted-risk(платформа P1, 05.08) | самопроверка P1 | +| PD-23 | hardening | info | `internal/pgstore/migrations/00001_identity.sql` | Журнал входов растёт без ретенции и чистится только каскадом при удалении аккаунта. Нужен свип по возрасту (год?) — вопрос политики, не кода | open | самопроверка P1 | +| PD-24 | bug | **major** | `internal/pgstore/migrations/` | Переиспользование номера миграции: удалённый `00003_usage.sql` и новый `00003_credits.sql` заняли одну версию. goose применяет ТОЛЬКО по номеру (ни имени, ни хеша), поэтому база, доехавшая до версии 3, рапортует «migrations applied» и не получает ни одной новой таблицы, вход и кредиты падают в рантайме, а `DownTo` на ней ломается навсегда. Обоснование «до деплоя правим на месте» было допущением без механизма — **закрыто:** выпущенные 00001–00003 возвращены байт-в-байт, новое приехало номерами 00004–00007; гейт `migrations.sha256` + `TestReleasedMigrationsAreUnchanged`; апгрейд со старого релиза пинится `TestDatabaseAtAnOlderReleaseCatchesUp` | fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) | +| PD-25 | bug | **major** | `internal/pgstore/credits.go`, `00007_credits.sql` | Ключ идемпотентности леджера не содержал `user_id`: грант с ключом, потраченным на другом аккаунте, молча проглатывался, а CLI печатал «granted». Плюс каскад удаления книги уносил ОТКРЫТУЮ резервацию, оставляя строку `hold` в леджере (деньги списаны, вернуть нечем), после чего освободившийся `engine_run_id` давал холд БЕЗ списания, а его релиз печатал деньги — **закрыто:** ключ стал `(user_id, source, source_id)`, пустой ключ запрещён DDL, `book_id` перешёл на составной FK к `books(id, owner_id)` с `on delete restrict`, `appendLedger` возвращает «применилось», `Hold` падает при повторе. Пины: `TestBookWithAnOpenHoldCannotBeDeleted`, `TestSecondHoldOnOneAttemptIsRefused`, `TestGrantIsIdempotentBySource` | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) | +| PD-26 | bug | minor | `internal/pgstore/credits.go` | Инверсия порядка блокировок Hold↔Settle/Release: 41 взаимоблокировка на 300 раундов, замерено. `Settle`/`Release` брали строку резервации раньше баланса — **закрыто:** `lockBalance` первым во всех операциях | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) | +| PD-27 | bug | minor | `internal/pgstore/credits.go` | `Settle` принимал любую сумму: одно завышенное `committed_usd` уводило баланс в минус, дальше каждый прогон получал `ErrInsufficientCredit` без диагностики — **закрыто:** расчёт capped потолком холда, факт записан в `note`; пин `TestSettlementIsCappedAtTheHold` | fixed(P1, дерево сессии) | ревью денежного пути · ревью «вне карты» | +| PD-28 | bug | minor | `internal/ingest/supervisor.go` | `cmd.Wait()` на отменённой команде возвращает `context.Canceled`, а не `*ExitError`, поэтому исход читался как `failed`: штатный SIGTERM пометил бы ВСЕ идущие прогоны провалившимися — **закрыто:** исход из `ProcessState`, факт остановки едет в ошибке; пин `TestStoppedRunKeepsTheEnginesOutcome` | fixed(P1, дерево сессии) | ревью стиля (клейм) + собственная проверка исполнением | +| PD-29 | vuln | minor | `internal/login/login.go` | `GET /auth/callback` — неаутентифицированная ручка, ПИШУЩАЯ в БД, без лимита и без ретеншена: замерено 2000 строк за 2.28 с с одного хоста (~76 млн строк/сутки), строки отказов недостижимы через API и не удалялись никогда — **закрыто:** лимитер на колбэке, ретеншен журнала 180 дней свипом | fixed(P1, дерево сессии) | ревью безопасности (исполнением) | +| PD-30 | vuln | minor | `internal/pgstore/identity.go` | Грант фри-тира выдавался за каждую новую пару `(provider, subject)` без учёта `email_verified`: провайдер с саморегистрацией превращал каждый новый `sub` в $5, потолок задавал только глобальный лимитер (~$864k/сутки на бумаге) — **закрыто:** грант только подтверждённой личности, аккаунт создаётся с нулём, начисление руками из админки. ⚠ Продуктовое следствие — вопрос владельцу в журнале | fixed(P1, дерево сессии) | ревью безопасности (исполнением) | +| PD-31 | bug | minor | `internal/login/login.go` | `discover` держал мьютекс на время сетевого вызова без таймаута: шесть параллельных входов при медленном IdP заняли 4/8/12/16/20/24 с вместо ~4 — **закрыто:** запрос вне лока, свой таймаут 5 с | fixed(P1, дерево сессии) | ревью безопасности (исполнением) | +| PD-32 | vuln | minor | `internal/login/login.go`, `cmd/tmplatformd/main.go` | Имя провайдера захардкожено `"google"` независимо от issuer, а `State.Provider` писался и не сверялся: смена issuer тихо кладёт чужие `sub` в старое пространство имён (новые аккаунты, старые недостижимы), а при двух провайдерах стейт одного редимится колбэком другого (IdP mix-up) — **закрыто:** `TM_PLATFORM_OIDC_PROVIDER`, сверка `st.Provider` в колбэке | fixed(P1, дерево сессии) | ревью безопасности · ревью «вне карты» | +| PD-33 | vuln | minor | `internal/auth/csrf.go` | Требование `X-TM-Client` снималось ЛЮБЫМ заголовком `Authorization`, включая мусорный: покрытие CSRF-слоя выбирал атакующий (сегодня упиралось в 401, но пережило бы любое послабление в `present`) — **закрыто:** снимает только валидный Bearer, через ту же функцию, что аутентифицирует | fixed(P1, дерево сессии) | ревью безопасности (исполнением) | +| PD-34 | bug | minor | `internal/httpapi/serve.go`, `cmd/tmplatformd/main.go` | Две регрессии остановки: второй SIGTERM больше не прерывал дренаж (процесс жил ровно 15 с), а просроченный дренаж возвращал ошибку и давал exit 1 — при `Restart=on-failure` штатная остановка читается systemd как крах — **закрыто:** сигнал разрегистрируется при начале дренажа, просрочка логируется WARN и даёт exit 0, добавлена строка `stopped` | fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) | +| PD-35 | bug | minor | `internal/httpapi/middleware.go` | Лимит тела стоял самым внешним слоем, поэтому обещанное «ручка загрузки регистрирует свой, больший лимит» не работало: вложенный `MaxBytesReader` не может ослабить внешний, а контракт требует загрузку книги (23 МБ) — **закрыто:** лимит стал пер-маршрутным аргументом `guard` | fixed(P1, дерево сессии) | ревью «вне карты» | +| PD-36 | hardening | minor | `internal/httpapi/middleware.go` | Не было HSTS, CSP и запрета фрейминга; `__Host-` защищает запись куки, а не первый навигационный запрос — **закрыто:** `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`, `X-Frame-Options: DENY`, HSTS в прод-профиле (в dev выключен: пин политики на localhost — долгая ошибка) | fixed(P1, дерево сессии) | ревью безопасности | +| PD-37 | bug | minor | `internal/login/login.go` | `safeReturnTo` заявляла защиту, которой не давала: проверка обратного слэша работала по уже раскодированной строке, а браузер декодирует цель редиректа ещё раз (`/%5c/evil.example`). Эксплуатируемого редиректа не получено, но три проверки из четырёх держались на поведении браузера — **закрыто:** проверка обеих форм, теста добавлены процент-кодированные входы | fixed(P1, дерево сессии) | ревью безопасности (исполнением) | +| PD-38 | hardening | info | `internal/pgstore/sessions.go`, `00005_identity_oauth.sql` | Отозванные сессии не удалялись до абсолютного срока (90 дней); журнал входов каскадно стирался вместе с аккаунтом, хотя объявлен доказательством для расследования — **закрыто:** свип берёт отозванные и idle-протухшие, `login_events.user_id` перешёл на `on delete set null` (строка анонимизируется, не уничтожается) | fixed(P1, дерево сессии) | ревью безопасности | +| PD-39 | bug | info | `internal/money/money.go` | Док обещал округление «от нуля», код округляет к `+∞`; отрицательные дроби не были покрыты тестом вовсе. Плюс `USD()` на `MinInt64` печатал мусор, а вход не имел ограничения длины (2 МБ → 6.1 с и сообщение об ошибке на 2 МБ) — **закрыто:** док приведён к коду, отрицательные кейсы запинены, потолок длины 64 символа, рендер без отрицания | fixed(P1, дерево сессии) | ревью денежного пути · ревью стиля | +| PD-40 | bug | info | `internal/ingest/resync.go` | Отсутствующий/`null`/пустой `committed_usd` декодировался в `0` — неотличимо от «попытка не стоила ничего»; на пути расчёта это освободило бы холд и не списало ничего — **закрыто:** `Spend` стал указателем, пустая строка — ошибка | fixed(P1, дерево сессии) | ревью «вне карты» | +| PD-41 | bug | info | `internal/login/login.go`, `internal/httpapi/` | Поверхность `/auth/*` отвечала stdlib-телами `text/plain` на 404/405 вопреки нормативу «ответы problem+json»; ошибки стора и сработавший лимитер не логировались; паника писалась без стека; успешный вход не оставлял следа, а недоступность провайдера классифицировалась как «токен отвергнут» — **закрыто:** метод проверяется в обёртке с problem+json, добавлены `login succeeded`, `sign-in rate limit engaged`, `provider_unreachable`, стек паники, `login_start_id` для склейки двух половин входа | fixed(P1, дерево сессии) | ревью логов (исполнением) | +| PD-42 | hardening | info | `internal/login/login.go` | Лимит `/auth/login` глобальный: один хост держит ведро пустым и выключает вход всем (замерено: 8 отказов из 10 у «легитимного» пользователя при фоне 5 rps). Пер-адресный лимит здесь неверен, пока нет доверенного edge-прокси — за прокси RemoteAddr один на всех. Место лимита — edge | accepted-risk(платформа P1, 05.08) | ревью безопасности (исполнением) | +| PD-43 | bug | info | `internal/pgstore/credits.go` | Денежный контур не имеет ни одного вызывающего вне тестов: `Hold`/`Settle`/`Release` не зовутся, `Sink` не реализован, `TypeSpend` не декодируется. При первом реальном прогоне баланс не изменится. Ожидаемо — воркера нет (П-1/П-3), но заведено строкой, чтобы это было решением, а не сюрпризом | open | ревью «вне карты» | +| PD-44 | hardening | info | `internal/pgstore/` | `sqlc` не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx. Нужна ратификация: адаптировать денежный пакет под sqlc в следующей сессии либо поправить направление | open | ревью «вне карты» | +| PD-45 | hardening | info | `internal/ingest/procgroup_unix.go` | `syscall.Kill(-pid, SIGINT)` идёт мимо `os.Process`, поэтому в узком окне между проверкой живости и сигналом ребёнок может быть пожат, и сигнал уйдёт в переиспользованную группу. Окно ~микросекунды и родитель ещё не звал `Wait`; переписывать на pidfd-путь — отдельная работа | open | ревью «вне карты» | +| PD-46 | hardening | minor | `internal/httpapi/serve.go:33-40` | **Запинена ПРОВОДКА `ReadTimeout`, но не ЗНАЧЕНИЕ, с которым едет демон.** Тесты строят свой `Timeouts` (`fastTimeouts`), поэтому посадка «`DefaultTimeouts().Read = 0`» проходит ВСЮ батарею зелёной — а `main.go:121` берёт именно `DefaultTimeouts()`. Посадка «убрать `ReadTimeout` из `NewServer`» ловится (проверено), то есть дыра ровно в дефолтах. Это форма, в которой PD-2 пережил P0: свойство проверено не на том объекте, который едет в прод. Фикс — тест на сами значения `DefaultTimeouts` — **закрыто:** `httpapi.TestTheServerTheDaemonRunsHasEveryDeadlineSet` утверждает не литералы, а сам `*http.Server`, который строит `NewServer(…, DefaultTimeouts())`: каждый дедлайн >0, `WriteTimeout` ОБЯЗАН быть нулём (иначе резал бы SSE), `ReadHeaderTimeout <= ReadTimeout`, grace >0. Закрывает обе половины — значение и проводку. Пять посадок поймано поимённо: `DefaultTimeouts().Read=0`, снятие `ReadTimeout` из `NewServer`, снятие `IdleTimeout`, добавление `WriteTimeout` «для симметрии», снятие `Unwrap` | fixed(P2, дерево сессии) | приёмка P1 (посадка M23/M43) | +| PD-47 | bug | minor | `internal/login/login_test.go:266` | **Закрытие PD-37 заявлено неверно:** «в тесты добавлены процент-кодированные входы» — их там нет (список: `//evil.example/`, `https://…`, `http:/…`, `/\evil.example`, `/\/evil.example`, `/\tevil`, `evil.example`, ``). Посадка «судить только сырую форму, без второго декода» батарею ПЕРЕЖИВАЕТ. Побочно: посадка «убрать обратный слэш из `ContainsAny`» тоже переживает — на тестовых входах её дублирует проверка `s[1]`. Эксплуатируемого редиректа нет; не запинена именно та защита, ради которой заведён PD-37 — **закрыто:** в таблицу добавлены процент-кодированные входы (`/%5c/`, `/%5C/`, `/%09`, `/%00`, `/%0d%0a`) — их ловит ТОЛЬКО второй декод — и `/%2f/evil.example`, который ловит ТОЛЬКО проверка `s[1]` на декодированной форме; плюс `FuzzSafeReturnTo`, который пинит СВОЙСТВО независимым оракулом (`url.URL.ResolveReference` после браузерной нормализации `\`→`/`), 3,1 млн исполнений без контрпримера. Посадки «судить только сырую форму», «убрать класс символов», «убрать protocol-relative» падают каждая. ⚠ Побочно установлено: условия `u.Scheme/u.Host/u.Opaque` НЕДОСТИЖИМЫ как отказ (при `raw[0]=='/'` схемы и Opaque не бывает, Host требует `//`), пин на них невозможен — оставлены бэкстопом, это названо в коде | fixed(P2, дерево сессии) | приёмка P1 (посадки M15/M16) | +| PD-48 | hardening | minor | `internal/login/login.go:268-271` | **Правило PD-30 «грант только подтверждённой личности» не запинено ничем:** удаление `if !claims.EmailVerified { grant = 0 }` проходит все тесты `internal/login`. `pgstore.TestUnverifiedAddressStaysOffTheAccount` пинит другое свойство (адрес не поднимается на аккаунт), денежное — никто. По правилу шапки этого файла PD-30 закрытым не считается — **закрыто:** `login.TestSignupGrantGoesOnlyToAVerifiedIdentity` гоняет обе ветки через настоящий поток и сверяет САМ грант, дошедший до стора (`memStore` теперь его запоминает — раньше отбрасывал, потому правило и было незапинено). Посадка «убрать условие `EmailVerified`» падает | fixed(P2, дерево сессии) | приёмка P1 (посадка M14) | +| PD-49 | hardening | minor | `internal/login/login.go:239-242` | **Вторая половина PD-32 не запинена:** удаление сверки `st.Provider != h.cfg.Provider` проходит все тесты. Сегодня провайдер один, поэтому свойство латентное — но заведено оно ровно под появление второго (IdP mix-up) — **закрыто:** `login.TestStateFromAnotherProviderIsRefused` подменяет провайдера в сохранённой строке состояния — форма, которую даёт появление второго провайдера, — и требует 400, отсутствия сессии, причины `state_from_another_provider` в журнале и НУЛЯ обращений к token endpoint. Посадка «убрать сверку» падает. Норму при этом закрывает не она, а PD-57 | fixed(P2, дерево сессии) | приёмка P1 (посадка M19) | +| PD-50 | hardening | info | `internal/auth/csrf.go:55-60` | Закрытие PD-33 сформулировано сильнее кода: «снимает только ВАЛИДНЫЙ Bearer» — на деле `Present` только ПАРСИТ, поэтому `Authorization: Bearer <мусор>` требование `X-TM-Client` снимает. Привилегии это не даёт, проверено живой пробой (кука + мусорный Bearer + без заголовка → 401, не хендлер): безопасность держит правило «Bearer побеждает куку» в `Present`, а не «валидность». Посадка «снимать любым непустым Authorization» батарею переживает. Фикс — либо тест, либо честная формулировка доккоммента — **закрыто формулировкой + пином:** доккоммент `cookieUnsafe` переписан на то, что верно (`Present` ПАРСИТ, не валидирует; безопасность держит правило «есть `Authorization` ⇒ кука не участвует», а не валидность). `auth.TestAnAuthorizationHeaderTakesTheCookieOutOfPlay` пинит именно это на пяти формах заголовка; посадка «падать обратно на куку при неразобранном заголовке» падает | fixed(P2, дерево сессии) | приёмка P1 (посадка M30 + живая проба) | +| PD-51 | bug | minor | `internal/httpapi/serve.go:118-127`, `STACK_DECISIONS §12` | **Механизм заявлен неверно.** Утверждение «`ReadTimeout` убил бы и поток, поэтому стриминговый хендлер ОБЯЗАН снять read-дедлайн» на Go 1.26.5 не подтверждается: `connReader.startBackgroundRead` сам делает `SetReadDeadline(time.Time{})` (`net/http/server.go:687-698`) и для запроса без тела вызывается ДО хендлера (`:2062`). Проверено исполнением на трёх формах запроса (GET без тела · POST с непрочитанным телом · POST с вычитанным телом) — поздний кадр доезжает во всех шести комбинациях, звали `ClearReadDeadline` или нет. Следствие: `TestStreamOutlivesReadTimeout` НЕ МОЖЕТ упасть от выхолащивания `ClearReadDeadline` (проверено); он пинит только `Unwrap` (эта посадка ловится). Код безвреден, ложны обоснование и строка в таблице пинов — **закрыто, и вывод приёмки уточнён исполнением:** механизм подтверждён (`startBackgroundRead` снимает дедлайн сам, `server.go:687-698`, для запроса без остатка тела — до хендлера, `:2059-2062`; по ходу хендлера не перевзводится — проверено по всем call sites). Но «код безвреден» неверно: см. PD-63. `ClearReadDeadline` УДАЛЁН, `STACK_DECISIONS §12` переписан, `TestStreamOutlivesReadTimeout` переписан на настоящее свойство (поток переживает `Read` БЕЗ действий хендлера) и пинит `Unwrap` через ошибку `Flush` | fixed(P2, дерево сессии) | приёмка P1 (посадка M24/M42 + отдельная проба) | +| PD-52 | hardening | minor | `internal/pgstore/credits.go:233-243` | Порядок блокировок (PD-26) не запинен ни одним тестом — снятие `lockBalance` из `closeReservation` батарею переживает. Дефект воспроизведён приёмкой НЕЗАВИСИМО, в форме, которая действительно даёт цикл: конкурентные `Settle(run-1)` и повторный `Hold(run-1)` — **2 взаимоблокировки на 150 раундов с инверсией, 0 с фиксом**. Регрессионный тест написан приёмкой и лежит готовым к вставке в `docs/platform-PROGRESS.md`, раздел «Ратификация приёмкой P1». ⚠ Замер сессии «41 на 300» воспроизвести не удалось — их нагрузка не описана; принимается СО СЛОВ — **закрыто:** тест приёмки вставлен как `pgstore.TestHoldAndSettleOnTheSameAttemptDoNotDeadlock`. ⚠ Замер приёмки не копировался, а ПЕРЕПРОВЕРЕН на своём стенде (PostgreSQL 18.4): с инверсией падает 5 прогонов из 5, 5–10 взаимоблокировок на 150 раундов; с фиксом 5 прогонов из 5 зелёные. Замер сессии P1 «41 на 300» так и не воспроизведён и остаётся СО СЛОВ | fixed(P2, дерево сессии) | приёмка P1 (посадка M07 + собственная репродукция) | +| PD-53 | hardening | info | `internal/httpapi/server.go:73-75` | `DefaultMaxBody` не запинен: поднятие лимита поддерева до 1 ГиБ батарею переживает. Пер-маршрутность лимита (PD-35) — тоже только на ревью — **закрыто:** `httpapi.TestBodyCapIsPerRouteBecauseNestingOnlyTightens` фиксирует исполнением ПРИЧИНУ пер-маршрутности — вложенный БОЛЬШИЙ лимит не поднимает внешний, — поэтому возврат общего слоя молча урезал бы аплоуд-маршрут; `TestDefaultBodyCapStaysAContractSizedNumber` держит дефолт в полосе контрактного размера (посадка «1 ГиБ» падает), не превращаясь в change-detector на точное число | fixed(P2, дерево сессии) | приёмка P1 (посадка M26) | +| PD-54 | bug | minor | `docs/platform-PROGRESS.md:329-353` | В журнале ДВЕ несовместимые формы `GET /v0/usage`: новая кредитная (строка 172) и старая подписочная (строка 329) с `resets_at`, `windows[{period}]` и хранением в `usage_windows` — таблице, которую снесла миграция `00006`. Секция P0-эры не помечена superseded, а S3 идёт читать журнал именно за формой ручки — **закрыто:** подписочное тело ответа УДАЛЕНО из журнала, а не помечено баннером: S3 идёт туда за формой ручки и скопировал бы тело. Осталась одна форма — кредитная, в разделе «Что предлагаем в спеку (S3)»; из П-5 сохранены абзацы, не зависящие от модели денег, ссылка на хранение переведена на `credit_ledger` | fixed(P2, дерево сессии) | приёмка P1 (свип доков) | +| PD-55 | bug | info | `deploy/tmplatformd.service` | `MemoryMax=2G` объявлен как «bounds the control plane», но ограничивает cgroup ЮНИТА — а по собственному аргументу этого же файла (закрытие PD-13) в этом cgroup живёт каждый ребёнок-`tmctl`. Значит потолок общий на платформу и все идущие прогоны, и OOM-killer выберет самый жирный процесс — движок, который держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта: ровно тот исход, ради которого запрещён SIGKILL. То же про `TasksMax=512`. Латентно до появления воркера. ⚠ Под systemd не проверялось (нет sudo) — вывод из семантики `MemoryMax=`, не из замера — **закрыто:** семантика сверена по man 5 systemd.resource-control («absolute limit on memory usage of the executed processes in this unit… out-of-memory killer is invoked inside the unit»). `MemoryMax=2G` заменён на `MemoryMax=80%` — потолок машины, а не сервиса, как «last line of defense» и без знания о железе; `TasksMax=512` оставлен с честным комментарием, что покрывает платформу и прогоны вместе; ограничение ОДНОГО прогона названо работой воркера (transient scope). Побочно найдено и закрыто следствие, которого в этой строке не было, — PD-64. ⚠ Под systemd не запускалось (нет sudo); `systemd-analyze verify` (systemd 259) — exit 0 | fixed(P2, дерево сессии) | приёмка P1 (ревью деплой-юнита) | +| PD-56 | bug | info | `internal/pgstore/credits.go:35-63` | `Grant`/`Adjust` на несуществующий аккаунт отдают оператору сырую ошибку Postgres с именем констрейнта (`credit_ledger_user_id_fkey`), тогда как `Balance` на том же входе отдаёт `ErrNoAccount`. Живая проба CLI. Косметика админ-поверхности, но опечатка в id читается как поломка БД — **закрыто:** `appendLedger` мапит нарушение `credit_ledger_user_id_fkey` в `ErrNoAccount`; `pgstore.TestMoneyOperationsAgreeOnAMissingAccount` требует одного ответа от `Grant`/`Adjust`/`Balance`/`ReadAccount`. Посадка «убрать сверку констрейнта» падает | fixed(P2, дерево сессии) | приёмка P1 (живая проба CLI) | +| PD-57 | hardening | minor | `internal/login/login.go:239-242` | **Защита от IdP mix-up не та, что требует действующая норма.** RFC 9700 §2.1 (OAuth Security BCP, янв. 2025) — клиент SHOULD применять параметр `iss` из авторизационного ответа (RFC 9207) либо иной контрмер НА ОСНОВЕ `iss`; MAY — различные redirect URI на провайдера. Реализована собственная сверка `st.Provider` с `h.cfg.Provider`, а внутри одного хендлера это сравнение конфигурации с самой собой: `start` пишет туда то же значение. `iss` авторизационного ответа не читается вообще (`iss` ID-токена библиотека проверяет — это другой шаг и другой момент). Сегодня не эксплуатируемо: провайдер один, код всегда редимится у него же. Заведено потому, что регистр объявляет PD-32 закрытием «класса IdP mix-up», а против нормы это неверно, и при втором провайдере выбор (`iss` или раздельные redirect URI) должен быть ОСОЗНАННЫМ, а не побочным эффектом конфигурации — **закрыто реализацией нормы, а не обещанием.** Первоисточники сверены: RFC 9700 §4.4.2 («When an OAuth client can only interact with one authorization server, a mix-up defense is not required» — то есть СЕГОДНЯ несоответствия нет, требование включается со вторым сервером), §4.4.2.2 объявляет раздельные redirect URI фолбэком («SHOULD therefore only be used if other options are not available»); альтернатива «`iss` из ID-токена» нам не подходит — при чистом code flow токен приходит уже ПОСЛЕ отдачи кода. Выбран `iss` авторизационного ответа: **Google его шлёт** (`authorization_response_iss_parameter_supported: true`, сверено живьём). Сделано: `auth_states.issuer` (миграция 00008), сверка до обмена кода, отказ на СОРВАННОМ параметре у поддерживающего провайдера (RFC 9207 §2.4). Пин — `login.TestAuthorizationResponseIssuerIsChecked` (4 случая); посадки «убрать вызов», «убрать ветку несовпадения», «убрать ветку сорванного параметра», «потерять issuer в сторе» падают | fixed(P2, дерево сессии) | приёмка P1 (сверка с RFC 9700 §2.1 / RFC 9207) | +| PD-58 | hardening | minor | `internal/config/config.go:60-61` | **Несоответствие собственной объявленной базовой линии.** `ENGINEERING_STANDARDS §2` берёт ASVS 5.0 L2, а L2 требует ДОКУМЕНТИРОВАТЬ сроки: 7.1.1 (срок бездействия и абсолютный предел + обоснование отклонений от NIST SP 800-63B), 7.1.2 (политика одновременных сессий), 7.1.3/7.6.1 (согласование срока НАШЕЙ сессии со сроком федеративной — у нас наша живёт своей жизнью, RP-initiated/back-channel logout нет). Сроки 14 суток бездействия и 90 суток абсолютных существуют только литералами в коде, обоснования нет ни в одном доке (проверено grep). Механические требования V7 при этом ВЫПОЛНЕНЫ и проверены: 7.2.3 энтропия (256 бит при требуемых 128), 7.2.4 ротация токена на аутентификации, 7.4.1 отзыв, 7.4.2 снос сессий при удалении аккаунта. ⚠ 7.4.5 (системный отзыв админом) покрыт только пер-пользовательским `revoke`; 7.5.2 (пользователь видит свои сессии) — работа П-1 — **закрыто, и значение выровнено вместо сочинения оправдания.** Тексты сверены дословно: ASVS 5.0 7.1.1/7.1.2/7.1.3, 7.6.1/7.6.2 и NIST SP 800-63B-4 §2.1.3 («overall timeout … SHOULD be no more than 30 days at AAL1; an inactivity timeout MAY be applied but is not required»). Абсолютный срок 90 суток был отклонением от SHOULD без причины, выдерживающей проверку, — снижен до **30 суток**; бездействие 14 суток остаётся и строже нормы. Документ — `STACK_DECISIONS §13`: уровень AAL1, оба срока, политика одновременных сессий (лимита нет — контракт предусматривает куку и Bearer одновременно; вместо лимита отзыв, «выйти везде» и журнал), рассогласование с федеративной сессией названо прямо (RP-initiated/back-channel logout нет), 7.6.2 выполнено. Пин — `config.TestSessionClocksStayWithinTheDeclaredBaseline` | fixed(P2, дерево сессии) | приёмка P1 (сверка с ASVS 5.0 V7) | +| PD-59 | bug | info | `docs/platform-PROGRESS.md`, вопрос оркестратору №4 | **Канал не меняем — но решает это не тот довод, который обсуждали.** Вопрос вынесен абстрактно (без контекста репозитория) двум независимым агентам, с доступом в сеть и без. По каналу они РАЗОШЛИСЬ, зато независимо сошлись на трёх вещах, которых не было ни в записке зоны, ни в первых трёх редакциях приёмки. **(1) SIGPIPE зависит от НОМЕРА дескриптора** (`os/signal`: обрыв на fd 1/2 убивает процесс, на любом другом — возвращает `EPIPE`). **Замерено:** поток на fd 1 → ребёнок УБИТ `broken pipe`; на fd 3 → `write` вернул EPIPE и процесс доработал до конца. Для нас это деньги: сегодня падение платформы убивает движок на следующей же записи события, а после переезда движок станет сиротой и часами будет жечь оплаченные вызовы, пока холд висит в леджере и некому его закрыть. Свойство несущее и нигде не записано. **(2) Настоящая защита — не выбор канала, а перехват на уровне дескриптора** в `main` движка: `dup(1)` в приватный fd, затем `dup3(2,1,0)`. Он герметичен там, где предложенный приёмкой `os.Stdout = os.Stderr` дыряв: переживает `var out = os.Stdout` в зависимости, cgo и унаследованный fd 1 у внуков. **(3) Дискриминатор, при котором переезд был бы прав** — «ребёнок исполняет чужой код, наследующий stdio». **Проверено: у нас нет** — `grep` по `backend/` не находит ни одного `exec.Command` вне тестов и ни одного cgo. Плюс сверено: ловушка `bufio.Scanner`, которую оба назвали самым вероятным латентным багом (переполнение строки читается как чистый EOF), у нас закрыта — `Buffer` поднят до 1 МиБ и `sc.Err()` проверяется (`decoder.go:45,115`) | **закрыт ратификацией**, работа уходит строкой 103 | приёмка P1 (четвёртая итерация: два независимых агента + замер SIGPIPE) | +| PD-60 | bug | minor | `internal/ingest/supervisor.go`, шов | **Обратное давление не спроектировано, и канал тут ни при чём.** Пайп держит 64 КиБ; если синк платформы встанет на Postgres, движок заблокируется в `write(2)` — на часы, без контекста и дедлайна, прервать нечем. Сегодня не проявляется только потому, что материализатора ещё нет: `Ingest` кормит `Sink` синхронно, и латентность БД станет латентностью движка. Нужна ограниченная очередь у читателя и ЯВНАЯ политика на её переполнение: блокировать движок (корректно, но прогресс прогона привязан к доступности БД) или ронять события с маркером `events_dropped` (быстро, но журнал начинает врать). Выбрать и записать — обе позиции законны, молчаливой третьей нет | open | приёмка P1 (абстрактный разбор двумя агентами, оба независимо) | +| PD-61 | bug | info | шов, строка 103 | Два свойства эмиттера, которые надо задать ДО его постройки, иначе они станут миграцией. **(а) Сброс буфера на выходе:** `bufio.Writer` вокруг потока плюс `os.Exit`/`log.Fatal` пропускает `defer` и теряет последние события — ровно те, что сообщают об окончании прогона. **(б) Хвост при падении платформы:** содержимое непрочитанного пайпа умирает вместе с читателем. Если требование «платформа перезапустилась, прогон продолжается» когда-нибудь появится, ответ — НЕ сокет (он даёт переподключение без возобновления), а журнал файлом: движок дописывает NDJSON в `/events.ndjson`, платформа тейлит его с чекпойнтом смещения в Postgres. Это переживает и падение платформы, и даёт реплей бесплатно. Оба агента пришли к этому независимо; прецедент — Bazel Build Event Protocol (файл или gRPC, не пайп родителя) | open | приёмка P1 (абстрактный разбор) | +| PD-62 | bug | minor | `internal/pgstore/identity.go:26-35`, миграция `00005` | **`login.State.StartID` не персистился: колонки под него не было.** Поле заведено в P1 и логируется колбэком как `login_start_id` — то есть ОБЕ строки лога, которые должны сшивать две половины входа, в проде пустые. Батарея этого не видела, потому что тесты `internal/login` ходят в in-memory стор, который хранит структуру целиком: свойство проверялось не на том объекте, который едет (тот же класс, что PD-46). Воспроизведено против живой БД раунд-трипом `PutLoginState`→`TakeLoginState`: положили `REQ-ABC123`, получили `""` — **закрыто:** колонки `start_id` и `issuer` добавлены миграцией `00008`, `Put`/`Take` их несут; пин — `pgstore.TestLoginStateIsSingleUseAndExpires` сравнивает структуру ЦЕЛИКОМ (`reflect.DeepEqual`), поэтому следующее поле без колонки упадёт здесь же. Посадки «потерять start_id» и «потерять issuer» падают | fixed(P2, дерево сессии) | сессия P2 (найдено при правке PD-57) | +| PD-63 | vuln | minor | `internal/httpapi/serve.go:118-127` (удалён) | **`ClearReadDeadline` воспроизводил PD-2 — тем самым вызовом, который был заведён как его исправление.** Доккоммент объявлял его ОБЯЗАТЕЛЬНЫМ для стримингового хендлера. На полу-кормленном запросе (тело анонсировано, не дослано) дренаж внутри записи заголовка ответа — единственная граница соединения, и ограничен он `ReadTimeout`; снятие дедлайна ДО записи заголовка эту границу убирает. Замерено: хендлер остаётся внутри `WriteHeader` и через 4 с после ухода клиента, соединение держится. Вызов после флаша бесполезен — контекст уже отменён дренажем. Приёмка (PD-51) заключила «код безвреден», проверив только корректные запросы; случая, где функция помогает, нет вовсе — **закрыто:** функция УДАЛЕНА, §12 переписан, пин — `httpapi.TestHalfFedStreamingRequestIsCutLoose` (контекст стримингового хендлера отменяется в пределах `Read`) | fixed(P2, дерево сессии) | сессия P2 (собственный замер при верификации PD-51) | +| PD-64 | bug | minor | `deploy/tmplatformd.service` | **Дефолтный `OOMPolicy=stop` уронил бы платформу из-за одного прожорливого прогона.** Следствие того же факта, что PD-55 (дети-`tmctl` живут в cgroup юнита), но в той строке не названо: по man 5 systemd.service дефолт берётся из `DefaultOOMPolicy=` (системный — `stop`), а `stop` означает «the unit's processes are terminated cleanly by the service manager» — то есть OOM-килл ОДНОГО `tmctl` останавливает контрол-плейн и все остальные прогоны, после чего юнит уходит в `oom-kill` failed и его подхватывает `Restart=on-failure` — **закрыто:** `OOMPolicy=continue` проставлен явно с обоснованием; платформа переживает килл ребёнка и штатно закрывает его резервацию. ⚠ Под systemd не проверялось (нет sudo) — вывод из доки; `systemd-analyze verify` (systemd 259) — exit 0 | fixed(P2, дерево сессии) | сессия P2 (ревью деплой-юнита при PD-55) | +| PD-65 | vuln | minor | `internal/login/login.go:367-382` | **Обмен кода и загрузка JWKS шли БЕЗ дедлайна**, тогда как discovery на том же пути ограничивает себя пятью секундами и называет причину («`http.DefaultClient` не имеет собственного таймаута, а вызов делается, пока человек ждёт»). В проде `httpClient` равен nil, поэтому обмен идёт на `http.DefaultClient`, а go-oidc строит набор ключей от `context.Background()`; `WriteTimeout` у сервера нет по проекту — значит издатель, который принял соединение и не отвечает, держит хендлер, пока клиент сам не уйдёт. Хуже того, набор ключей ОБЩИЙ: одна зависшая загрузка паркует ВСЕ параллельные входы (замерено ревью: два независимых входа ждали 12 с за одной загрузкой) — **закрыто:** `identify` ограничен `providerTimeout` 10 с; пин — `login.TestAStalledProviderDoesNotHoldTheCallback` на обеих ногах (token и keys), посадка «убрать дедлайн» падает | fixed(P2, дерево сессии) | ревью P2 (линза oidc-security, подтверждено верификатором на боевой проводке) | +| PD-66 | bug | minor | `internal/httpapi/serve_test.go`, `cmd/tmplatformd/main.go:121` | **Мой собственный фикс PD-46 закрывал только половину и утверждал, что обе.** Тест строил свой сервер `NewServer(…, DefaultTimeouts())` и на него же смотрел; проводка демона осталась ненаблюдаемой, а `cmd/tmplatformd` тестов не имеет. Замерено ревью: замена аргумента на `Timeouts{Shutdown: 15s}` оставляет `make check` зелёным (0 issues) и бинарь снова пиннит соединения — PD-2 в полном объёме. То есть ровно та форма, которую PD-46 и называл: свойство проверено не на том объекте — **закрыто устранением КЛАССА, а не тестом:** `NewServer` больше не принимает `Timeouts` и берёт `DefaultTimeouts()` сам, передавать нечего; коротким дедлайнам тестов служит неэкспортируемый `serverWithTimeouts` | fixed(P2, дерево сессии) | ревью P2 (линза net-http) | +| PD-67 | vuln | minor | `internal/pgstore/credits.go:236` | **`FOR UPDATE` не был запинен ничем, а комментарий теста утверждал обратное** («Mutation caught: … or the FOR UPDATE that serialises it»). Последовательный тест лока не видит по построению, а инвариант «кэш = леджер» его тоже не ловит: без лока кэш и леджер уезжают ВМЕСТЕ, оба в минус. Лок — единственное, что мешает двум прогонам потратить один и тот же кредит — **закрыто:** `pgstore.TestConcurrentHoldsCannotOvercommitAnAccount` — 60 раундов по два конкурентных холда, каждый по отдельности посильный, вместе нет; посадка «убрать `for update`» падает 3 прогона из 3, баланс уходит в −$2. Комментарий последовательного теста исправлен | fixed(P2, дерево сессии) | ревью P2 (линза sql-money) | +| PD-68 | bug | minor | `internal/httpapi/server.go:111` | **`/readyz` рапортовал «готов» на базе БЕЗ схемы.** Готовность доказывалась одним `Ping`, который успешен на любом достижимом Postgres, включая пустой. `Migrate` выключен по умолчанию, а deploy-инструкция делает миграцию отдельным шагом — значит «процесс поднят, схема не накачена» это НОРМАЛЬНАЯ середина выката, и инстанс в этом окне отвечал 200 `ready`, проваливая каждый запрос, который затем обслуживал — **закрыто:** `Store.Ready` сверяет `goose_db_version` с максимальным номером миграции, вшитой в бинарь; схема ВПЕРЕДИ бинаря готовности не отменяет (иначе выкат ронял бы старый инстанс). Пин — `pgstore.TestReadinessRefusesADatabaseWithoutTheSchema`, посадка «свести готовность к `Ping`» падает. ⚠ Первая редакция фикса печатала причину В ТЕЛО ответа и ради этого тащила `pgstore` в `httpapi` — и слой, и утечка состояния выката на НЕаутентифицированной ручке; снято при самопроверке, причина уходит в ERROR-лог | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | +| PD-69 | bug | minor | `internal/pgstore/store.go:42` | **Явный `pool_max_conns` из DSN молча отбрасывался.** Проверка «MaxConns равен дефолту pgxpool» не отличает «оператор не выбирал» от «оператор выбрал ровно это число»: pgxpool кладёт свой дефолт в то же поле, что `ParseConfig` заполняет из `pool_max_conns`. Оператор, порезавший реплику под бюджет `max_connections`, получал наш 16 вместо своих 8 — и наоборот на 32-ядерной машине. С `pool_min_conns` хуже: дефолт pgx равен 0, поэтому явный 0 не мог пережить проверку НИКОГДА — **закрыто:** вопрос «упоминает ли DSN этот ключ» задан ПАРСЕРУ pgx, а не значению: `pgxpool` достаёт `pool_*` из `RuntimeParams` и удаляет их, поэтому второй `pgx.ParseConfig` их ещё видит — обе формы DSN, кавычки и service-файлы бесплатно. ⚠ Первая редакция фикса разбирала DSN РУКАМИ (33 строки собственного парсера) — велосипед, найден при самопроверке и снят; наши дефолты применяются только там, где оператор промолчал. Пин — `pgstore.TestExplicitPoolSizesInTheDSNSurvive`, включая случай, сломавший ПРЕДЫДУЩУЮ эвристику: пароль, содержащий имя ключа | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | +| PD-70 | bug | **major** | `internal/auth/middleware.go:57`, `internal/auth/cookie.go:62` | **Скользящее окно бездействия для БРАУЗЕРА не работало: Max-Age куки пишется один раз, на входе, и больше никем.** Серверная строка скользила (`Touch`), кука — нет, а `SetSession` зовётся ровно из одного места — колбэка входа. Следствие: для куки — единственной презентации, которую код вообще умеет выдавать, — срок жизни сессии был ФИКСИРОВАННЫЕ 14 суток от входа независимо от активности; человек, заходящий каждый день, выкидывался на 14-е сутки при живой серверной сессии, а абсолютный срок не мог наступить никогда. Это же делало ложным §13 — документ соответствия ASVS 7.1.1, который зона только что написала — **закрыто:** при скольжении окна кука переиздаётся с тем же токеном (ротация — акт границы входа, не скольжения); пин — `auth.TestSlidingTheIdleWindowRefreshesTheBrowsersCookie` (четыре случая: кука во второй половине окна, свежая кука, Bearer, упор в абсолютный срок). ⚠ Первая редакция фикса выдавала `Max-Age` равный idle-TTL безусловно — то есть кука могла пережить абсолютный срок и превратить каждый следующий запрос в 401 вместо чистого «вы вышли»; поймано самопроверкой, срок теперь берётся как `min(idle, остаток абсолютного)` | fixed(P2, дерево сессии) | ревью P2 (линза doc-vs-code) | +| PD-71 | bug | info | `internal/pgstore/migrations/00005_identity_oauth.sql:72` | **Down-путь `00005` не исполним на данных, которые его же up-путь делает законными**, поэтому откат ниже версии 5 недоступен. Он восстанавливает `users_email_key` и `email NOT NULL`, а боевой код пишет `email = NULL` у неподтверждённой личности и кладёт один подтверждённый адрес на два аккаунта (следствие «почта не ключ»). **Перепроверено моим прогоном, не принято со слов ревью:** три реальных аккаунта (один с `email = NULL`, два с общим подтверждённым адресом) — `DownTo(5)` проходит, `DownTo(4)` падает с `could not create unique index "users_email_key" (SQLSTATE 23505)`; первым срабатывает индекс, до `NOT NULL` выполнение не доходит. Данные целы — down транзакционный, `Up()` вернул схему на версию 8 со всеми тремя аккаунтами, — но плана отката ниже 5 не существует. Править `00005` запрещает append-only, а чужой down-текст новая миграция не заменяет — **принято как ЦЕНА ПРАВИЛА:** записано в `STACK_DECISIONS §8` и в `deploy/README.md` разделом «Откат релиза: не ниже версии 5», чтобы оператор не узнал это в момент отката | accepted-risk(зона P2, 05.08) | ревью P2 (линза sql-money) | +| PD-72 | hardening | info | `internal/httpapi/server.go:88` | **Отсутствие ОБЩЕГО лимита тела над маршрутами не наблюдаемо ничем.** Пер-маршрутность (PD-35/PD-53) держится на том, что вложенный `MaxBytesReader` только УЖЕСТОЧАЕТ: это запинено `TestBodyCapIsPerRouteBecauseNestingOnlyTightens`. Но возврат внешнего слоя в `New` батарею переживает, потому что ни один маршрут не просит потолок БОЛЬШЕ дефолтного — наблюдаемым дефект станет ровно тогда, когда появится загрузка книги. Строка заведена, чтобы это не выяснилось молча: тест обязан приехать ВМЕСТЕ с маршрутом загрузки | open | ревью P2 (линза doc-vs-code) | +| PD-73 | vuln | minor | `internal/login/login.go:67-74`, `:126` | **Дедлайн `identify` ограничивал ОЖИДАЮЩЕГО, а не саму загрузку ключей — то есть мой фикс PD-65 был неполон.** `Provider.Verifier` берёт набор ключей, построенный на discovery, а go-oidc хранит его через `context.WithoutCancel` и ходит за ключами на `http.DefaultClient`, у которого таймаута нет. Загрузка, которая зависла, продолжает висеть после того, как ожидающий сдался, и все последующие входы встают на тот же `inflight` — то есть вход не поднимается и после того, как эндпоинт выздоровел, вплоть до перезапуска процесса. Воспроизведено ревью на боевой проводке — **закрыто:** `New` ВСЕГДА ставит `httpClient` с таймаутом `providerTimeout`, клиент передаётся `NewProvider` безусловно (`oidc.ClientContext`), и его подхватывает набор ключей; nil-случая больше нет — класс устранён, а не покрыт тестом. Пины — `TestTheDefaultProviderClientIsBounded` (посадка «клиент без таймаута» падает) и `TestAHungKeyFetchDoesNotPoisonLaterSignIns` (вход ПОСЛЕ выздоровления эндпоинта обязан пройти) | fixed(P2, дерево сессии) | ревью P2 (линза the-fixes) | +| PD-74 | bug | minor | `internal/auth/middleware.go:57` | **Скольжение окна залипало на последней четверти жизни сессии: каждый запрос становился записью.** `Touch` прижимает новый дедлайн через `least(now+IdleTTL, absolute_expires_at)`, поэтому как только `now+IdleTTL` перевалил за абсолютный потолок, `idle_expires_at` больше не двигается — а условие «осталось меньше половины окна» с этого момента истинно ВСЕГДА. На горячем пути это UPDATE по первичному ключу таблицы сессий и `Set-Cookie` на каждом аутентифицированном запросе (после PD-70 — ещё и кука). Найдено двумя линзами независимо — **закрыто:** скольжение выполняется только пока `IdleExpiresAt` строго меньше `AbsoluteExpiresAt`; пин — `auth.TestTheSlideStopsOnceItCannotMoveTheDeadline` (пять чтений дают ноль записей, а сессия с запасом по-прежнему скользит) | fixed(P2, дерево сессии) | ревью P2 (линзы session-security и вне карты, независимо) | +| PD-75 | bug | minor | `cmd/tmplatformctl/main.go:151` | **CLI сообщал о ПРИМЕНЁННОМ начислении как о провале, а повтор начислял второй раз.** `write` выполняет денежную операцию, затем отдельным запросом читает баланс, и ошибку ЧТЕНИЯ возвращает как результат команды. Оператор видит ошибку, повторяет — а `--key` необязателен, и без него `newKey` чеканит новый ключ идемпотентности, поэтому второй прогон начисляет ещё раз. Достаточно обрыва соединения между двумя запросами — **закрыто:** после коммита команда не может отчитаться провалом; баланс читается как любезность, его отказ печатается предупреждением на той же строке | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | +| PD-76 | bug | minor | `internal/login/login_test.go` | **Определяющее свойство пакета — «ничего выданного провайдером не персистится» — проверялось утверждением, которое не могло упасть.** `memStore.notes` объявлено и не заполнялось ни одним методом, поэтому `strings.Join(notes)` всегда пусто, а `Contains` всегда ложно. Свойство названо в доккомменте пакета первой строкой — **закрыто:** мок пишет в `saw` КАЖДУЮ строку, которую поток ему передал, а утверждение проверяет и непустоту записи, и отсутствие среди неё и access-токена, и любого JWT-образного значения. Посадка «положить в стор сырой id-токен» падает | fixed(P2, дерево сессии) | ревью P2 (линза water) | +| PD-77 | bug | info | `internal/ingest/supervisor.go:104` | **Штатная остановка живого прогона поднимала тревогу о сломанном синке.** `Ingest` проверяет `ctx.Err()` в начале цикла и возвращает `context.Canceled` как СВОЮ ошибку; `Run` отличить это от отказавшего синка не мог и на обычном SIGTERM писал ERROR «stream could not be materialized», который по замыслу означает «платформа ослепла, пока тратятся деньги», плюс звал `stop()` на уже останавливающемся прогоне — **закрыто:** отменённый `runCtx` больше не считается отказом синка | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | +| PD-78 | hardening | info | `internal/login/login.go` (было), `internal/httpapi/problem.go` (было), `internal/pgstore/identity.go`, `internal/httpapi/server.go` | **Свод воды и дублей, найденный линзой лаконичности; каждый пункт проверен удалением.** (а) `login.Routes` мемоизировал mux через `sync.Once` — при этом ВТОРОЙ и последующие `guard` молча игнорировались, то есть это была не оптимизация, а ловушка; снято. (б) `login.Fail` носил `*http.Request`, который никто не читал, и ради несовпадения сигнатур существовал шим `httpapi.Fail`; параметр и шим удалены, `WriteProblem` подключён напрямую. (в) `upsertIdentityOnce` держал собственный begin/rollback/commit при наличии `inTx` — второй экземпляр того же кода. (г) `Deps.APIPrefix` — ручка, которую не выставлял ни один вызыватель; заменена константой. (д) `Ready` делал `Ping` и следом запрос — два round trip на пробу каждые несколько секунд. (е) пять полей тестовых двойников, которые писались и не читались; `blockingSink` не блокировал. (ж) `money.USD` считал руками с комментарием про переполнение `MinInt64` — заменён на `big.Rat.FloatString(6)`, проверено побайтовое совпадение на всём диапазоне | fixed(P2, дерево сессии) | ревью P2 (линза water) + самопроверка | +| PD-79 | bug | minor | `internal/money/money.go:33-36` | **Строковый `"null"` читается как НОЛЬ денег.** Кавычки снимаются `strings.Trim` ДО проверки `s == "null"`, поэтому `"committed_usd":"null"` даёт настоящий `0` и НЕПУСТОЙ указатель, тогда как доккоммент поля обещает отказ на «absent, null and empty». Замерено приёмкой на живом декодере: голый `null` и отсутствие поля дают nil (защита работает), `""` даёт ошибку, а `"null"` — `Spend = 0 micro-USD, NON-NIL`. На пути расчёта это «попытка стоила ничего»: холд освобождается, списания нет. Латентно до воркера; чинится перестановкой проверки перед `Trim` | open | приёмка P2 (замер оркестратора №15 + панель) | +| PD-80 | vuln | **major** | `internal/login/login.go:158,217-226` | **Вход выключается тремя запросами в секунду, и 429 колбэка ДОБИВАЕТ начатые входы.** Ведро `rate.NewLimiter(2, 20)` одно на `/auth/login` И `/auth/callback` (`login.go:122`, единственный лимитер в зоне), а колбэк стирает login-куку ПЕРВОЙ строкой — до своей проверки лимитера. Следствие: анонимный поток на `/auth/login` не только закрывает вход всем (это PD-42, принято риском в форме «глобальный, не пер-адресный»), но и делает начатый вход невосстановимым: 429 приходит уже с `Set-Cookie: __Host-tm_login=; Max-Age=0`, поэтому повтор того же колбэка не пройдёт и после наполнения ведра. **Воспроизведено приёмкой на боевом бинаре:** 19 из 40 `/auth/login` прошли, дальше 429; честный колбэк с живым state получил 429 и стёртую куку. Независимо измерено панелью. Фикс дешёвый: лимитер прежде очистки куки + раздельные ведра для начала и конца входа; пер-адресный лимит остаётся вопросом edge (PD-42) | open | приёмка P2 (живая проба + панель, две независимые линзы) | +| PD-81 | standards | minor | `internal/pgstore/credits.go:169-178` | **Заявленный `ErrDuplicateHold` на реальном пути недостижим:** при ЖИВОЙ резервации повторный `Hold` падает на первичном ключе `reservations_pkey` (`00007_credits.sql:66`) и уходит наверх сырой ошибкой Postgres SQLSTATE 23505; объявленная ошибка приходит только когда строку резервации уже смахнули, а ключ леджера остался. Замерено приёмкой на живом PG в обеих формах. Деньги целы (`balance == SUM(ledger)`, транзакция откатывается), но воркеру не на что смотреть, кроме текста ошибки | open | приёмка P2 (замер оркестратора №15 + панель) | +| PD-82 | bug | info | `internal/pgstore/credits.go:236-239` | `Hold` на НЕСУЩЕСТВУЮЩИЙ аккаунт отдаёт `ErrInsufficientCredit` (в `lockBalance` `ErrNoRows` трактуется как «нет кредита»), а не `ErrNoAccount`: обещание PD-56 «один ответ на несуществующий аккаунт» покрывает `Grant`/`Adjust`/`Balance`/`ReadAccount` и на `Hold` не распространяется. Замерено приёмкой | open | приёмка P2 (замер оркестратора №15) | +| PD-83 | hardening | minor | `internal/httpapi/middleware.go:64` | **Фикс PD-3 не запинен в собственном месте:** посадка «`Recover` логирует `r.URL.Path` вместо `routeOf(r)`» батарею ПЕРЕЖИВАЕТ, тогда как та же посадка в `AccessLog` ловится поимённо (`TestAccessLogNamesTheRouteNotThePath`). По правилу шапки этого файла половина PD-3 закрытой не считается | open | приёмка P2 (посадка мутации) | +| PD-84 | hardening | minor | `internal/login/login.go:222` | **Лимитер колбэка (фикс PD-29) не запинен:** удаление всей проверки `h.limiter.Allow()` из `callback` оставляет батарею зелёной. Замер PD-29 (~880 строк/с с одного хоста) означает, что регрессия здесь тихо возвращает неаутентифицированного писателя в таблицу журнала | open | приёмка P2 (посадка мутации) | +| PD-85 | hardening | minor | `internal/pgstore/identity.go:126-131` | **«Неподтверждённый адрес не поднимается на аккаунт» запинено только на ветке НОВОЙ личности:** снятие условия `in.EmailVerified` в ветке ВОЗВРАЩАЮЩЕГОСЯ входа (обновление `users.email`) проходит батарею — `TestUnverifiedAddressStaysOffTheAccount` покрывает первый вход и переход в verified, но не обратный случай | open | приёмка P2 (посадка мутации) | +| PD-86 | hardening | info | `internal/pgstore/sessions.go:23,50` | **Два клауза-близнеца не запинены, и абсолютный потолок держится ТРАНЗИТИВНО:** снятие `absolute_expires_at > $2` из `Lookup` батарею переживает, потому что потолок навязывается через `least($3, absolute_expires_at)` в `Touch` (это запинено — `TestSessionLifecycle`). Снятие `revoked_at is null` из `Touch` тоже переживает (класс PD-4). Дефекта сегодня нет ни в одном; риск в том, что каждый слой по отдельности выглядит избыточным, а вместе они — единственное, что ограничивает жизнь сессии | open | приёмка P2 (посадки мутаций) | +| PD-87 | hardening | info | `internal/httpapi/server.go:82`, `internal/login/login.go:31` | Ещё два незапиненных: снятие `LimitBody` с поддерева `/auth` и `stateTTL` 10 мин → 240 ч проходят батарею. Первое — родня PD-72 (та про общий внешний слой, эта про конкретное поддерево), второе — окно жизни неиспользованного авторизационного запроса | open | приёмка P2 (посадки мутаций) | +| PD-88 | bug | info | `internal/auth/cookie.go:62-66` | **TTL меньше секунды выпускает куку БЕЗ атрибута `Max-Age`:** `int(ttl.Seconds())` даёт 0, а Go при `MaxAge == 0` атрибут опускает ⇒ кука становится браузер-сессионной. Достижимо в последнюю секунду абсолютного срока (скольжение выдаёт `min(idle, остаток абсолютного)` при гарде `ttl > 0`) — то есть ровно тот исход, который самопроверка P2 называла нежелательным: кука переживает сессию, и следующий запрос даёт 401 вместо чистого «вы вышли». Подтверждено исполнением (ttl 500 мс/999 мс) | open | приёмка P2 (панель ×2, подтверждено исполнением) | +| PD-89 | hardening | minor | `cmd/tmplatformctl/main.go:143-150` | **Сминченный ключ идемпотентности не печатается при ошибке записи:** PD-75 закрыл путь ПОСЛЕ коммита, но неоднозначный обрыв НА коммите остался — оператор видит ошибку, повторяет без `--key`, `newKey()` чеканит новый ключ, второе начисление проходит. Фикс: печатать ключ вместе с ошибкой, чтобы повтор был с тем же `--key` | open | приёмка P2 (панель) | +| PD-90 | bug | info | `cmd/tmplatformctl/main.go:112,134` | `grant` и `adjust` делят пространство ключей `source="admin"`: `--key`, потраченный грантом, молча гасит корректировку с тем же ключом. CLI честно скажет «ключ уже потрачен», но оператор ждал другой операции | open | приёмка P2 (панель) | +| PD-91 | doc | minor | `deploy/README.md:33-42`, `deploy/tmplatformd.service:43,48` | **Установка, исполненная дословно, даёт нестартующий юнит:** `/srv/textmachine` не создаётся ни одной командой наброска, а `ReadWritePaths=` без префикса `-` на несуществующем пути валит сборку mount-namespace при `ProtectSystem=strict`. Заодно `ProtectHome=yes` против решения владельца «книги живут в `~/books`»: детям-`tmctl` домашние каталоги под этим юнитом недоступны — либо книги переезжают в `/srv/textmachine`, либо юнит получает `BindPaths=`. ⚠ Вывод из `systemd.exec(5)`, под systemd не исполнялось (sudo нет) | open | приёмка P2 (панель, сверено с докой) | +| PD-92 | bug | minor | `internal/ingest/supervisor.go:118-121` | **Дренаж стоит ДО `cmd.Wait()`, поэтому `WaitDelay` его не размораживает:** `io.Copy(io.Discard, stdout)` ждёт EOF, а EOF придёт только когда закроются ВСЕ копии пишущего конца пайпа; внук, унаследовавший stdout и игнорирующий SIGINT, вешает `Run` навсегда — backstop `WaitDelay` действует внутри `Wait`, до которого управление не доходит. ⚠ Сегодня недостижимо и это пере-проверено приёмкой: в `backend/` вне тестов нет ни одного `exec.Command` и нет cgo | open | приёмка P2 (панель, граница зоны пере-проверена) | +| PD-93 | bug | info | `internal/ingest/supervisor.go:109` | Фикс PD-77 («наша остановка — не сломанный синк») сверяет только `context.Canceled` и пропускает `context.DeadlineExceeded`: как только у `runCtx` появится дедлайн (потолок времени прогона — очевидная будущая ручка), штатное истечение снова поднимет ERROR «stream could not be materialized» | open | приёмка P2 (панель) | +| PD-94 | bug | info | `internal/httpapi/middleware.go:61-70` | **`Recover` глотает `http.ErrAbortHandler`** — sentinel, которым хендлер намеренно обрывает соединение (`net/http` его не логирует и рвёт коннект). Замерено приёмкой: паника `ErrAbortHandler` превращается в 500 с problem-телом, то есть усечённый поток становится неотличим от полного. Латентно (сегодня им никто не паникует), но именно SSE-хендлер — типовой его пользователь | open | приёмка P2 (замер оркестратора №15) | +| PD-95 | doc | minor | `internal/ingest/events.go:6-12` | **Доккоммент несёт предложение, которое уже отвечено и ОТКЛОНЕНО:** новый ⚠-абзац предлагает увести поток со stdout на выделенный дескриптор/сокет, тогда как PD-59 закрыт ратификацией «канал остаётся stdout» (мотив — SIGPIPE-семантика fd 1, которая нам служит), и та же сессия записала это в свой журнал. Код и решение расходятся в файле, который эмиттер-сессия прочтёт как задание | open | приёмка P2 (свип решений) | +| PD-96 | hardening | info | `internal/httpapi/server.go:39-43`, `internal/auth/csrf.go:28` | **`TrustedOrigins` обещает отдельно развёрнутый фронт, но CORS-слоя нет вовсе.** Живая проба: preflight `OPTIONS` с `Origin: https://app.example.org` получает 401 от гарда (браузерный preflight креденшелов не носит и не должен), заголовков `Access-Control-*` нет ни на одном ответе. Сценарий «фронт на другом origin» браузером сегодня неисполним: либо CORS приезжает вместе с контрактными ручками (П-1), либо фронт живёт на том же origin, и тогда `TrustedOrigins` — мёртвая ручка | open | приёмка P2 (панель + живая проба) | +| PD-97 | hardening | info | `internal/pgstore/credits.go:212-216` | `Settle`/`Release` отбрасывают флаг `applied` у `hold_release`: если ключ `("run_release", engineRunID)` уже потрачен, резервация закроется, а деньги не вернутся — тихий no-op на денежном пути. Требует нештатной последовательности (закрытие, смахивание строки, повторное открытие того же `engine_run_id`), но ровно на такой последовательности стоит `ErrDuplicateHold` | open | приёмка P2 (панель) | +| PD-98 | doc | info | `internal/pgstore/store.go:75-79` | Случай «схема НОВЕЕ бинаря» в `Ready` беззвучен — признано ⚠-комментарием на месте, но ни одной строки лога: оператор, запустивший старый бинарь на новой схеме, сигнала не получит | open | приёмка P2 (панель) | +| PD-99 | hardening | info | `internal/ingest/supervisor.go:102` | INFO-лог «engine started» пишет `args` целиком. Сегодня безвредно, но воркер будет передавать движку идентификатор книги и потолок аргументами ⇒ book-id и денежная сумма попадут в INFO платформы (D39.84 + норма зоны «id книги в логи не текут»). Закрыть вместе с воркером: логировать имя команды, не argv | open | приёмка P2 (панель) | +| PD-100 | bug | minor | `internal/login/login.go:245-261` | **Класс PD-5 закрыт в `auth/`, но не в `login/`:** колбэк глотает ошибку стора (`TakeLoginState`) и ошибку discovery, репортя их как обычный отказ (`unknown_state` / `discovery_failed`) — сама ошибка не доезжает ни до одной строки лога, хотя `pgstore/identity.go` намеренно отличает «состояния нет» от инфраструктурного сбоя. Аутентификационный DB-outage снова выглядит штормом обычных отказов | open | приёмка P2 (панель) | +| PD-101 | bug | minor | `internal/login/login.go:507` | `login_events.ip_prefix` берётся из `r.RemoteAddr`, а в задуманном деплое перед сервисом стоит edge-прокси ⇒ префикс всегда сеть прокси. Журнал входов заведён как ответ на «откуда примерно я входил» — в шипуемой форме он систематически отвечает неверно. `X-Forwarded-For`/`Forwarded` нигде не читаются и доверенного прокси в конфиге нет (это правильный дефолт: доверять заголовку без edge нельзя) — значит решение про edge и про этот столбец принимается вместе | open | приёмка P2 (панель) | +| PD-102 | doc | minor | `internal/httpapi/serve.go:36-38` | Доккоммент `DefaultTimeouts` утверждает, что «an upload extends its own deadline as it makes progress» — это НЕВЕРНО: `ReadTimeout` в `net/http` (Go 1.26.5, `server.go:990` `wholeReqDeadline = t0.Add(ReadTimeout)`) выставляется один раз и по мере прихода байтов не продлевается. Комментарий несущий: он объясняет, почему `Read` короткий, и на нём будущая ручка загрузки книги (23 МБ по контракту) построит неверное ожидание — ей понадобится собственный дедлайн через `ResponseController`, а не «прогресс продлевает» | open | приёмка P2 (панель, сверено с исходником Go) | +| PD-103 | hardening | minor | `internal/auth/middleware.go:43,66` | У обращений к БД на аутентифицированном пути (`Lookup`/`Touch`) нет собственного дедлайна — только голый `r.Context()`, а `WriteTimeout` у сервера отсутствует по проекту (SSE) и `TimeoutHandler` в цепочке нет. Зависший Postgres паркует хендлеры и ждущих в пуле, пока клиент сам не уйдёт. `readyz` свой таймаут получил (PD-14) — горячий путь нет | open | приёмка P2 (панель) | +| PD-104 | hardening | minor | `internal/login/login.go:285-288` | **Фри-тир печатается НЕАУТЕНТИФИЦИРОВАННЫМ потоком без агрегатного потолка:** $5 за каждую новую пару `(provider, subject)` с подтверждённой почтой, единственный ограничитель — тот же лимитер входа. Агрегатного лимита грантов, счётчика аномалий и алерта нет нигде. PD-30 закрыл половину («только подтверждённой личности»); вторая половина — суточный потолок и наблюдаемость — вопрос владельцу (вынесен приёмкой) | open | приёмка P2 (панель) | +| PD-105 | standards | minor | `internal/ingest/decoder.go:96` | **Декодер и норматив зоны расходятся на дубле `seq`:** декодер объявляет его фатальным `ErrStreamGap`, а `ENGINEERING_STANDARDS §2` ратифицирует «at-least-once — норма, дубль — не ошибка». После фикса PD-12 цена выросла: сбой ингеста ОСТАНАВЛИВАЕТ прогон, поэтому одна задублированная строка убивает платный прогон, хотя ратифицированный путь ремонта — `status --json`. Внутри одного пайпа передоставки нет, так что отказ декодера защитим; непропорциональна РЕАКЦИЯ. Разрешать ратификацией вместе с промтом эмиттера (строка 103), не молча | open | приёмка P2 (панель) | +| PD-106 | standards | minor | `cmd/tmplatformctl/` | **Админ-CLI — единственный писатель денег в дереве — не имеет ни одного теста.** В том числе не покрыто правило, которое он сам называет несущим («после коммита команда не может отчитаться провалом», фикс PD-75), и разбор флагов, и формат вывода. Батарея зоны его не видит вовсе (`[no test files]`) | open | приёмка P2 (панель) | +| PD-107 | hardening | info | `internal/pgstore/migrations/00007_credits.sql:85`, `00002_readmodel.sql:13` | **Удаление аккаунта обходит защиту PD-25:** составной FK `reservations → books(id, owner_id) on delete restrict` блокирует `DeleteBook`, но `users` каскадит в `reservations` НАПРЯМУЮ, поэтому `delete from users` уносит и ОТКРЫТУЮ резервацию. Замерено приёмкой: аккаунт с открытым холдом удаляется. Учётной дыры нет — леджер и кэш баланса каскадятся тем же удалением, — но прогон, идущий против этого холда, останется без того, кто его закроет. Кода удаления аккаунта в дереве нет вовсе (грепнуто) ⇒ строка = гейт перед появлением такой операции (и перед ASVS 7.4.2 в полной форме). ⚠ Заодно ОПРОВЕРГНУТА обратная версия этой находки от панели («удаление падает на композитном FK даже при закрытых резервациях») — мой прогон: удаляется и с закрытой резервацией, и без неё | open | приёмка P2 (замер оркестратора №15; версия панели опровергнута) | diff --git a/platform/docs/PLATFORM_DIRECTION.md b/platform/docs/PLATFORM_DIRECTION.md index 77057da1..1a0496ad 100644 --- a/platform/docs/PLATFORM_DIRECTION.md +++ b/platform/docs/PLATFORM_DIRECTION.md @@ -68,7 +68,7 @@ hosted IdP (связка PII + доступность, а свою сессию фри-тир. Нужна минимальная админ-поверхность — защищённая ручка или CLI-команда, пишущая грант. **Схема (строить с П-7):** `credit_ledger` (append-only, знаковые целые микро-доллары, типы -`grant|hold|hold_release|settlement|adjustment`; `UNIQUE(source, source_id)` — ключ идемпотентности; +`grant|hold|hold_release|settlement|adjustment`; `UNIQUE(user_id, source, source_id)` — ключ идемпотентности; ⚠ первая редакция этого абзаца называла `UNIQUE(source, source_id)`, и это ошибка: без `user_id` ключ, потраченный на одном аккаунте, проглатывает тот же ключ на другом, и второму сообщают «начислено», не начислив ничего (миграция `00007` и её комментарий); `purchase` добавится, если появится продажа), `reservations` (одна открытая на `engine_run_id`), `account_balances` (кэш в ТОЙ ЖЕ транзакции, что вставка в леджер, + тест-инвариант `balance == SUM(ledger)`). Правки строк не существует: ошибка чинится новой записью. Только целые @@ -106,9 +106,25 @@ hosted IdP (связка PII + доступность, а свою сессию |---|---|---| | 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 на денежных путях, рантайм-зависимостей ноль | +| ~~`sqlc` для денежных/квотных таблиц~~ → `sqlc` для поверхности контрактных ручек и read-model | v1.31.1 | **ПЕРЕСМОТРЕНО приёмкой P1 (05.08), доказано исполнением** — см. абзац ниже. Денежный пакет остаётся рукописным | | `golang.org/x/time/rate` | v0.15.0 | лимиты в процессе; долговечные пер-пользовательские — в Postgres | +**Пересмотр по `sqlc` (приёмка P1, 05.08, доказано исполнением вне репозитория).** Первая редакция +этой строки требовала взять `sqlc` ДО денежных таблиц. Таблицы приехали без него (PD-44), и вопрос +пришёл на ратификацию. Проверено: sqlc v1.31.1 читает все goose-миграции зоны (на момент пробы их было семь; `00008` приехала позже и пробу не проходила) и генерирует под +`pgx/v5` код, почти совпадающий с рукописным (`:execrows` → `RowsAffected`, параметры структурой) — +инструмент на этой схеме РАБОТАЕТ. Две трения, обе увидены исполнением: (1) его анализатор отвергает +запрос, который Postgres принимает (неквалифицированный `user_id` в коррелированных подзапросах) — +то есть переход означает правку существующего SQL, а не обёртку; (2) колонки типизуются как +`pgtype`/`int64`, поэтому `money.MicroUSD` на границе теряется без блока `overrides` — а единый +денежный тип и есть то, ради чего заведены PD-15/PD-39. + +**Решение: денежный пакет НЕ переписывать.** Он только что прошёл ревью и имеет батарею против +живой БД; обмен отревьюенного кода на сгенерированный без единого нового теста ничего не покупает. +`sqlc` берётся на поверхность контрактных ручек и read-model (П-1), где запросов много и они +меняются вместе со схемой — там он ловит именно свой класс: запрос ссылается на колонку, которую +унесла миграция. Блок `overrides` для денежных колонок пишется тогда же, до первого хендлера. + **Доказательство исполнением по кодогену (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` чистый**; рантайм-граф прирастает одним diff --git a/platform/docs/PLATFORM_SESSION_PROMPT.md b/platform/docs/PLATFORM_SESSION_PROMPT.md index 813dd9ec..e4bbd501 100644 --- a/platform/docs/PLATFORM_SESSION_PROMPT.md +++ b/platform/docs/PLATFORM_SESSION_PROMPT.md @@ -1,3 +1,11 @@ +> ⚠ **ПРОМТ ОТРАБОТАН — исторический, не задание.** По нему прошли три сессии: P0 (скелет, принят +> D39.107), P1 (вход OIDC, кредитный леджер, админ-CLI, деплой-юнит) и P2 (очередь приёмки P1 + +> два своих ревью). **P1+P2 приняты и залендены приёмкой №15 — D39.109.** Состояние зоны, решения +> и открытые дефекты: `platform-PROGRESS.md` (раздел «Ратификация приёмкой P2») + `DEFECT_REGISTER.md` +> (живые строки — PD-6, PD-23, PD-43, PD-45, PD-60/61, PD-72 и PD-79…PD-107). Следующий промт зоны +> (реконсилятор · тейлер журнала · транзиентные юниты прогона) выдаётся по слову владельца — +> D39.107 п.3(3). Ниже — текст, по которому работали; исполнять его заново не нужно. + # Промт: платформа-сессия P0 — стек, скелет, дизайн-ответы контракту Ты — первая платформенная сессия TextMachine. **Зона записи — только `platform/`.** `backend/`, diff --git a/platform/docs/STACK_DECISIONS.md b/platform/docs/STACK_DECISIONS.md index f2d28edf..0bee6833 100644 --- a/platform/docs/STACK_DECISIONS.md +++ b/platform/docs/STACK_DECISIONS.md @@ -1,6 +1,6 @@ # Стек платформы — пины и обоснования -> Зонный документ `platform/`. Пины ниже сверены ЖИВЬЁМ 04.08.2026 (Go-прокси `@latest`, +> Зонный документ `platform/`. Пины сверены ЖИВЬЁМ 04.08 и 05.08.2026 (Go-прокси `@latest`, > postgresql.org, go.dev/dl) — версии по памяти не называются. Библиотеки сессия не ратифицирует: > таблица уходит оркестратору вместе с деревом. > @@ -21,6 +21,8 @@ | 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`: ей нужна сеть, а батарея обязана быть зелёной на голом клоне офлайн | @@ -28,9 +30,12 @@ ## Что решено этой сессией (сверх §5) -1. **`/healthz` ≠ `/readyz`.** Liveness ничего не трогает (БД лежит — процесс жив), readiness пингует - пул. Пустой `TM_PLATFORM_DSN` — легальный старт: сервис поднимается и честно говорит «не готов». - Иначе супервизор убивает здоровый процесс за то, что база моргнула. +1. **`/healthz` ≠ `/readyz`.** Liveness ничего не трогает (БД лежит — процесс жив). Readiness с P2 + спрашивает не «отвечает ли база», а «та ли это база, под которую собран бинарь»: пинг плюс сверка + `goose_db_version` с максимальной вшитой миграцией. Одного пинга было мало — он успешен и на + Postgres без единой таблицы, то есть в нормальной середине выката, где миграция ещё не накачена + (PD-68). Пустой `TM_PLATFORM_DSN` — легальный старт: сервис поднимается и честно говорит «не + готов». Иначе супервизор убивает здоровый процесс за то, что база моргнула. 2. **Ops-эндпоинты вне версионного префикса.** `/healthz`, `/readyz` — в корне; контрактная поверхность целиком под `/v0` (базовый путь спеки платформа ПОДТВЕРЖДАЕТ). 3. **Один mux.** Контрактные маршруты регистрируются с префиксом в паттерне, а не вложенным mux'ом @@ -45,6 +50,125 @@ 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 @@ -55,9 +179,27 @@ curl -s localhost:8080/healthz # ok curl -s localhost:8080/readyz # ready ``` -Переменные: `TM_PLATFORM_ADDR` · `TM_PLATFORM_DSN` · `TM_PLATFORM_MIGRATE` · +Переменные: `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_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 diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index edca491d..97d03d3a 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -6,9 +6,33 @@ ## Текущее состояние -- **P0 ПРИНЯТ и ЗАЛЕНДЕН** (`eeeef89`, приёмка оркестратора №14 04.08 — раздел «Ратификация приёмкой» - ниже). Дизайн-ответы К-4/К-7/К-12/П-5 ратифицированы С ПОПРАВКАМИ. Найдено 19 дефектов, все - строками в `DEFECT_REGISTER.md`; один — ЖИВАЯ уязвимость (PD-2, пиннинг соединений), гейт P1. +- **P1+P2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ приёмкой №15 (07.08)** — раздел «Ратификация приёмкой P2» ниже: + вердикт, метод, что ратифицировано, фикс-лист, что опровергнуто. Два вопроса ушли владельцу: + срок сессии 30 суток и агрегатный потолок фри-тира (PD-104). +- **Регистр после приёмки — 107 строк** (скриптом по таблице): 66 закрыто · 3 приняты риском · + 1 закрыт ратификацией (PD-59) · **37 открыто** — из них **1 major** (PD-80), 17 minor, 19 info. + Прежние восемь (PD-6 · PD-23 · PD-43 · PD-44 · PD-45 · PD-60/61 · PD-72) плюс 29 новых + PD-79…PD-107. Первые в очереди зоны — фикс-лист приёмки, порядок там же. +- **P2 отработала очередь приёмки P1 целиком плюс ДВА собственных адверсариальных ревью** (05.08) — + разделы «Сессия P2» ниже. Риском приняты три строки (PD-22 ограничитель соединений на edge, + PD-42 глобальный лимитер входа, PD-71 откат ниже версии 5); счёт регистра — строкой выше, здесь + не дублируется. Открытыми на конец P2 были восемь: PD-6 (origin-чек SSE-хендшейка — строить нечего до SSE), PD-23 (ретеншен + журнала входов — сам свип есть, строка про политику), PD-43 (денежный контур без вызывающих до + воркера), PD-44 (`sqlc` — закрыт ратификацией, направление изменено), PD-45 (окно pid при сигнале + группе), **PD-72 — возврат общего лимита тела не наблюдаем, пока нет маршрута со своим потолком: + тест обязан приехать вместе с загрузкой книги**, **PD-60 и PD-61 — свойства ШВА, работа строки 103 + единого бэклога** (обратное давление и + сброс буфера эмиттера: платформенной части у них нет, пока эмиттера нет). +- **Закрыто в P2:** вся очередь приёмки (PD-46…PD-58), три info-строки вне очереди + (PD-50, PD-53, PD-56), три собственные находки — PD-62 (потерянный `start_id`), PD-63 + (`ClearReadDeadline` воспроизводил PD-2), PD-64 (`OOMPolicy=stop` уронил бы контрол-плейн) — и + **шесть находок собственного адверсариального ревью** (PD-65…PD-70), из которых одна major: + скользящее окно бездействия для браузера не работало (PD-70). +- **Два значения изменены, не обоснованы:** абсолютный срок сессии 90 → **30 суток** (цифра NIST + AAL1; отклонение без причины, выдерживающей проверку, — не отклонение, а недосмотр) и + `MemoryMax=2G` → **80%** (потолок машины вместо мнимого потолка сервиса). Оба переопределяются. +- **Построено в P1:** вход через OIDC (П-6), кредитный леджер с резервациями (П-7), админ-CLI (П-8), + деплой-юнит systemd, тест-пол на реальном `http.Server`, фаззинг NDJSON-декодера. - **Стандарты зоны заведены** (решение владельца 04.08): `ENGINEERING_STANDARDS.md` (критерии приёмки, индустриальные базовые линии) + `DEFECT_REGISTER.md` (отдельная колонка багов и уязвимостей). - **P0 собран** (сессия 04.08): модуль компилируется, батарея зоны `make check` зелёная, @@ -17,7 +41,333 @@ - Дизайн-ответы К-4 · К-7 · К-12 · форма П-5 — ниже, ПРЕДЛОЖЕНИЯМИ на ратификацию. - Контрактных ручек нет намеренно: они ждут ратификации К-4/К-7 (форма ответов) — это П-1. -## Открытые вопросы к владельцу/оркестратору +## Ратификация приёмкой P2 (оркестратор №15, 07.08) + +**Вердикт: P1 и P2 ПРИНЯТЫ и залендены — одним коммитом, 30 изменённых отслеживаемых файлов и 22 +новых.** ⚠ **Испр. оркестратором №15:** раздел «Ратификация приёмкой P1» ниже говорит «P1 ПРИНЯТ и +заленден» — заленден он НЕ был. До этого коммита `git ls-files platform/internal/login` возвращал +ноль: в git уехали только P0 (`eeeef89`/`954c034`), направление (`99c9cb0`) и решения владельца +(`87be7b9`/`6469479`). Строки регистра формулировку не завышали — они честно говорят +`fixed(P1, дерево сессии)`. + +**Живой уязвимости приёмка не нашла.** Найденное — 29 строк регистра **PD-79…PD-107**: одна major +(доступность входа, PD-80), остальные minor/info. Лендинг не блокирует ничего; три строки блокируют +первый реальный деплой и постройку воркера — названы в фикс-листе. Метод панели: семь линз с +зажатыми промтами (отчётные доки зоны им запрещены), затем адверсариальный опровергатель на КАЖДУЮ +находку весом minor и выше — 20 подтверждено, 2 опровергнуто, плюс мои собственные замеры. + +**Метод — исполнением, не чтением отчёта.** + +- Батарея пере-прогнана мной: офлайн зелёная (линтер 0 issues); с живым PostgreSQL **18.4**, + поднятым без root по рецепту `STACK_DECISIONS`, — **скипов НОЛЬ** (26 БД-тестов отработали); + `make vuln` (govulncheck v1.6.0) — чист. +- **Свои мутации по СВОЕЙ карте несущих свойств, не по таблице пинов зоны: 45 посадок в четыре + батча — 33 поймано поимённо, 8 выжило, 4 моих посадки оказались негодными** (разобраны ниже). + Мутации ставились в КОПИИ зоны вне репозитория: незакоммиченное дерево сессии не трогалось, что + сверено хешами диффа до и после. +- Живой бинарь: `healthz`/`readyz` против живой БД · `/v0/*` → 401 problem+json · пять форм + CSRF-пробы (кука без `X-TM-Client` 403 · с заголовком 401 · `Sec-Fetch-Site: cross-site` 403 · + мусорный Bearer 401 · неразобранный `Authorization` 403) · редирект `/auth/login` с PKCE S256 и + одноразовой кукой · ПТ-34-заголовки на каждом ответе. +- **PD-2 на том бинаре, который едет:** 10 полу-кормленных POST отпущены на **30.0 с** (в P0 + держались, пока не уходил клиент). +- **RFC 9207 живьём:** Google действительно шлёт `iss` + (`authorization_response_iss_parameter_supported: true`, сверено мной у издателя); сорванный + параметр → `issuer_missing`, чужой → `issuer_mismatch`, обмена кода в обоих случаях не было. +- **PD-71 пере-проверен своим прогоном на боевых данных** (аккаунт с `email = NULL` + два аккаунта + с общим подтверждённым адресом): `DownTo(4)` падает на `users_email_key` SQLSTATE 23505, три + аккаунта целы, `Up()` возвращает схему на версию 8. Заявление зоны воспроизвелось дословно. +- Фаззеры: `FuzzSafeReturnTo` 3.0 млн исполнений, `FuzzDecoder` 3.35 млн — крэшеров нет. +- `systemd-analyze verify` (systemd 259, с подставленным существующим `ExecStart=`) — exit 0. +- **Цитаты норм сверены по первоисточникам, а не по пересказу:** RFC 9700 §4.4.2 и §4.4.2.2, + NIST SP 800-63B-4 §2.1.3, ASVS 5.0 7.1.1/7.1.2/7.1.3/7.6.1/7.6.2 — формулировки дословны, + номера разделов верны, уровень L2 верен. Это несущая проверка: на этих цитатах стоит смена + боевого значения 90 → 30 суток. +- Границы зоны: `backend/` не импортируется, SQLite движка не открывается, денежных величин в + `httpapi`/`auth`/`reqid` нет; в дереве зоны только `platform/*` (чужое — живой полигон). + +**Ратифицировано (6 из 6).** + +1. **Абсолютный срок сессии 30 суток — ПРИНЯТО.** Цифра — буква NIST SP 800-63B-4 §2.1.3 («A + definite reauthentication overall timeout SHALL be established, which SHOULD be no more than 30 + days at AAL1»), и у прежних 90 обоснования не было. ⚠ Видимое следствие продуктовое — вынесено + владельцу (ниже). +2. **Против mix-up — `iss` авторизационного ответа (RFC 9207), миграция 00008 — ПРИНЯТО.** Норма + сверена: §4.4.2 включает требование со ВТОРОГО сервера, §4.4.2.2 объявляет раздельные redirect + URI фолбэком («SHOULD therefore only be used if other options are not available»). Реализация + проверена живьём, включая отказ на сорванном параметре. +3. **`STACK_DECISIONS §13` (политика сессий) — ПРИНЯТО как документ соответствия ASVS 7.1.1/7.1.2/ + 7.1.3.** Рассогласование с федеративной сессией названо прямо — это и есть то, чего требует + норма, а не то, что она запрещает. +4. **Ломающие изменения зоны — ПРИНЯТЫ:** `httpapi.NewServer` без `Timeouts` (устранение КЛАССА + PD-66 сильнее теста), `Prober.Ping` → `Ready`, `login.Fail` без `*http.Request`. Внешних + потребителей у этих подписей нет: фронт говорит с зоной по HTTP. +5. **`MemoryMax=80%` + явный `OOMPolicy=continue` — ПРИНЯТО.** Аргумент сверен с + `systemd.resource-control(5)` («last line of defense», OOM-killer внутри юнита) и + `systemd.service(5)` (системный дефолт `stop`). ⚠ Под systemd не исполнялось — вывод из доки. +6. **PD-71 — ПРИНЯТ РИСКОМ** в форме, которую предложила зона: правило append-only дороже + доступности отката ниже версии 5, и место такой записи — `deploy/README.md` у оператора, а не + сноска в архитектурном доке. Пере-проверено моим прогоном (см. выше). + +**Вынесено владельцу — два вопроса.** (1) Сроки сессии: не заходивший месяц человек увидит экран +входа; если это против замысла — одна переменная `TM_PLATFORM_SESSION_MAX_AGE` и явная запись +отклонения в §13. (2) **Новое, из PD-105:** фри-тир печатается НЕАУТЕНТИФИЦИРОВАННЫМ потоком по $5 +за каждую новую подтверждённую пару `(provider, subject)`, и агрегатного потолка нет нигде — нужен +ли суточный лимит грантов и счётчик аномалий до открытия беты. + +**Фикс-лист (порядок мой; строки регистра — носители).** + +1. **PD-80 — доступность входа.** Единственная major. Ведро лимитера общее у `/auth/login` и + `/auth/callback`, и 429 колбэка приходит уже ПОСЛЕ очистки login-куки: анонимный поток ~3 rps + закрывает вход всем и добивает начатые входы. Воспроизведено мной на бинаре и независимо + панелью. Фикс дешёвый: лимитер прежде очистки куки + раздельные ведра. +2. **PD-79 — деньги.** Строковый `"null"` в `committed_usd` читается как ноль. Обязан быть закрыт + ДО того, как появится вызывающий у `Settle` (то есть до воркера). +3. **PD-83/PD-84/PD-85 — «закрыто, но не запинено»** по собственному правилу шапки регистра: + половина PD-3, лимитер PD-29 и ветка обновления адреса. +4. **PD-91 — деплой.** Установка по наброску даёт нестартующий юнит; и `ProtectHome=yes` против + «книги в `~/books`». +5. **PD-106 — админ-CLI, единственный писатель денег в дереве, не имеет ни одного теста** — + включая правило «после коммита нельзя отчитаться провалом», которое сам же называет несущим. +6. Остальное — по весу строк; PD-95 (доккоммент `events.go` предлагает то, что PD-59 отклонил) + чинится вместе с промтом эмиттера, иначе эмиттер-сессия прочтёт его как задание. + +**Опровергнуто приёмкой — включая свои промахи (дисциплина «заявление=команда» действует и на +приёмку).** + +- Панель: «удаление аккаунта падает на композитном FK даже при закрытых резервациях» — + **опровергнуто моим прогоном:** `delete from users` проходит и без резервации, и с закрытой. +- Панель: «`money.UnmarshalJSON` читает JSON `null` как ноль» — **опровергнуто в этой форме:** + голый `null` даёт nil-указатель, защита работает; дыра — в строке `"null"` (PD-79, диагноз + исправлен). +- Панель: «WARN на каждый отбитый вход — неограниченная запись в лог» — **опровергнуто:** + `AccessLog` и так пишет INFO-строку на КАЖДЫЙ запрос, так что нового канала WARN не создаёт. +- **Своя посадка «грант фри-тира не запинен» — НЕГОДНАЯ:** грант живёт в ветке НОВОЙ личности, + поэтому подмена его ключа на возвращающемся входе ничего не меняет. Корректная посадка + (начислять на КАЖДОМ входе) ловится `TestReturningIdentityKeepsItsAccountAndIsGrantedOnce` — + свойство запинено. +- **Своё «падение `FuzzDecoder`» — артефакт моего стенда** (голод по CPU от параллельных батчей + мутаций), не дефект: чистый прогон 3.35 млн исполнений зелёный. +- **PD-20 — калибровка, не находка:** моя посадка «один сигнал вместо лестницы» выжила в одном + прогоне, но дефект вероятностный (≈1 из 3 по замеру зоны), поэтому это свойство пина, а не новая + дыра. Пин остаётся вероятностным — знать об этом важнее, чем завести строку. +- **`TestMigrationsRollBackAndReapply` откатывает ПУСТУЮ базу,** поэтому для 00005 он не + доказывает ничего (реальный откат невозможен по построению — PD-71). Норматив зоны «down-путь + существует и гоняется тестом» выполнен буквой, но не смыслом; сказано здесь, чтобы это не + читалось как покрытие. + +## Сессия P2: что изменилось в решениях + +Очередь приёмки P1 отработана в её порядке. Каждый пункт сначала воспроизведён посадкой на своём +стенде (PostgreSQL 18.4, Go 1.26.5) — включая те, где вердикт приёмки в итоге уточнён. + +1. **`ClearReadDeadline` удалён, а не оставлен «страховкой».** Приёмка проверила корректные запросы + и заключила «код безвреден». На ПОЛУ-КОРМЛЕННОМ запросе он вреден: дренаж внутри записи заголовка + — единственная граница соединения, снятие дедлайна до заголовка её убирает, и хендлер остаётся + внутри `WriteHeader` через 4 с после ухода клиента (замерено). Случая, где функция помогает, нет: + на корректном запросе `net/http` снимает дедлайн сам. PD-51 закрыт, PD-63 заведён. +2. **Абсолютный срок сессии 90 → 30 суток.** ASVS 7.1.1 требует обосновать отклонение от NIST SP + 800-63B; у 90 суток обоснования не нашлось (баланс тратимый, повторный вход у залогиненного в + Google — один клик, прогон истечение сессии переживает). Обосновывать нечего — цифра выровнена по + норме. Политика целиком (оба срока, одновременные сессии, рассогласование с федеративной) — + `STACK_DECISIONS §13`. +3. **Против mix-up — `iss` авторизационного ответа (RFC 9207), решение принято до второго + провайдера.** Альтернатива «`iss` из ID-токена» при чистом code flow не работает: токен приходит + после отдачи кода. Фолбэк «раздельные redirect URI» норма разрешает только когда другого нет, а + Google RFC 9207 поддерживает (сверено живьём). `auth_states.issuer` + сверка до обмена кода + + отказ на СОРВАННОМ параметре. +4. **`MemoryMax` — потолок машины, а не сервиса.** По собственному аргументу зоны дети-`tmctl` живут + в cgroup юнита, значит «2G на контрол-плейн» — это 2G на платформу и все прогоны вместе, а + OOM-killer внутри юнита выберет движок с эксклюзивным локом. `80%` + явный `OOMPolicy=continue`. +5. **Пин на СВОЙСТВО там, где слои перекрываются.** У `safeReturnTo` четыре проверки, и снятие любой + одной таблица входов переживала. Добавлены входы-различители плюс `FuzzSafeReturnTo` с + НЕЗАВИСИМЫМ оракулом (`ResolveReference` против базового URL сайта) — 3,1 млн исполнений. + Побочно установлено и записано в код: условия `u.Scheme/u.Host/u.Opaque` недостижимы как отказ, + пин на них невозможен, оставлены бэкстопом. +6. **Состояние входа сравнивается структурой целиком.** `State.StartID` не персистился — колонки не + было, — и обе строки лога `login_start_id` в проде были пустыми, пока in-memory стор тестов + показывал их заполненными. Тот же класс, что PD-46: свойство проверено не на том объекте. + PD-62; теперь `reflect.DeepEqual` на всей структуре, следующее поле без колонки упадёт здесь же. + +### Что нашло собственное ревью P2 (author≠reviewer) + +Пять ревьюверов по разным линзам, зажатые промты, журнал зоны и регистр от четырёх из пяти скрыты; +каждая находка потом отдана адверсариальному верификатору с установкой «опровергни, по умолчанию +считай неподтверждённой». 34 кандидата, **11 подтверждено, 23 опровергнуты с разбором**. Из +подтверждённых шесть потребовали правки кода: + +7. **Обмен кода и JWKS шли без дедлайна** (PD-65), хотя discovery рядом ограничивает себя пятью + секундами. Набор ключей у go-oidc ОБЩИЙ — значит одна зависшая загрузка паркует все параллельные + входы, а не только свой. Замерено верификатором на боевой проводке (`httpClient` = nil). +8. **Мой же фикс PD-46 закрывал половину и утверждал, что обе** (PD-66). Тест смотрел на сервер, + который сам и построил; проводка демона осталась ненаблюдаемой — подмена аргумента в `main.go` + оставляла `make check` зелёным, а бинарь снова пиннил соединения. Закрыто устранением КЛАССА: + `NewServer` больше не принимает `Timeouts`, передавать нечего. +9. **`FOR UPDATE` не был запинен**, а комментарий утверждал обратное (PD-67). Последовательный тест + лока не видит, а инвариант «кэш = леджер» тоже: без лока обе величины уезжают в минус ВМЕСТЕ. +10. **`/readyz` рапортовал «готов» на базе без схемы** (PD-68) — то есть в нормальной середине + выката, потому что миграция по инструкции отдельный шаг. +11. **Явный `pool_max_conns` из DSN молча отбрасывался** (PD-69): сравнение с дефолтом pgx не + отличает «оператор промолчал» от «оператор выбрал это же число». +12. **Скользящее окно бездействия для браузера не работало** (PD-70, major). Серверная строка + скользила, кука — нет: `Max-Age` пишется один раз, на входе. Человек, заходящий каждый день, + выкидывался на 14-е сутки при живой сессии, а абсолютный срок не наступал никогда. Это делало + ложным §13 — документ соответствия ASVS, написанный в этой же сессии двумя часами раньше. + +### Второе ревью: по коду, которым чинили первое (05.08) + +Первое ревью смотрело дерево ДО своих же фиксов. Второй проход дан по ним — плюс отдельной линзой +про воду. **42 кандидата, 22 подтверждено.** Что из этого меняет решения: + +- **Мой фикс PD-65 был неполон** (PD-73). Дедлайн ограничивал ожидающего, а не саму загрузку ключей: + `Provider.Verifier` берёт набор ключей, построенный на discovery, а go-oidc хранит его через + `context.WithoutCancel` и ходит на `http.DefaultClient`. Зависшая загрузка держала вход и после + выздоровления эндпоинта — до перезапуска процесса. Закрыто устранением класса: `httpClient` теперь + никогда не nil, `New` ставит клиент с таймаутом, и его подхватывает `NewProvider`. +- **Скольжение окна залипало** (PD-74, найдено двумя линзами независимо): `Touch` прижимает idle к + абсолютному потолку, после чего условие «осталось меньше половины» истинно всегда — каждый запрос + последней четверти жизни сессии становился записью в таблицу сессий и `Set-Cookie`. +- **CLI сообщал о применённом начислении как о провале** (PD-75), а повтор без `--key` начислял + второй раз — достаточно обрыва между записью и чтением баланса. +- **Утверждение «провайдерский токен не персистится» не могло упасть** (PD-76): поле мока никто не + заполнял. Это определяющее свойство пакета, названное первой строкой его доккоммента. +- **Штатная остановка прогона поднимала тревогу о сломанном синке** (PD-77). +- **Четыре строки таблицы пинов обещали то, чего батарея не даёт.** Две закрыты новыми тестами + (гоночное потребление state; порядок блокировок), две — честной формулировкой: возврат общего + лимита тела не наблюдаем до появления маршрута с бо́льшим потолком (PD-72), а вход «пароль содержит + имя ключа» доказывает что-то только на машине, где наш дефолт не совпал с дефолтом pgxpool. +- **Свод воды** (PD-78), каждый пункт проверен удалением: мемоизация mux в `Routes` молча + игнорировала второй `guard`; шим `httpapi.Fail` существовал ради параметра, который никто не читал; + `upsertIdentityOnce` дублировал `inTx`; `Deps.APIPrefix` — ручка без вызывателей; `Ready` делал два + round trip на пробу; пять полей тестовых двойников писались и не читались. + +### Самопроверка после ревью (владелец 05.08: «нет ли велосипедов и о чём умолчал») + +Перечитывание СВОЕГО кода дало ещё пять правок, три из которых — дефекты, внесённые в этой же +сессии и доехавшие бы до лендинга: + +- **Кука при скольжении могла пережить абсолютный срок.** Фикс PD-70 выдавал `Max-Age` = idle-TTL + безусловно, поэтому сессия на 29-е сутки получала куку ещё на 14 — и каждый запрос после потолка + становился 401 вместо чистого «вы вышли». Ровно то, из-за чего `MaxAge` изначально и брали по + idle. Теперь `min(idle, остаток абсолютного)`, случай запинен, посадка падает. +- **Фикс PD-68 ломал слой и тёк наружу.** Ради строки «схема не накачена» в теле ответа `httpapi` + импортировал `pgstore` — при том что `Prober` интерфейсом заведён именно чтобы этого не было, — а + сама строка сообщала состояние выката на НЕаутентифицированной ручке. Причина уехала в ERROR-лог, + импорт снят. +- **Два велосипеда.** Разбор DSN руками (33 строки) — при том что `pgxpool` достаёт `pool_*` из + `RuntimeParams`, и второй `pgx.ParseConfig` отвечает на вопрос точно, вместе с кавычками и + service-файлами. И разбор номера миграции из имени файла — при том что есть + `goose.NumericComponent`. Оба сняты, `store.go` короче на 44 строки. +- **`latestMigration` считался на КАЖДУЮ пробу готовности** — величина времени сборки, теперь + `sync.OnceValues`. Имя таблицы версий отдано `goose.TableName()`, а не захардкожено. +- **Ошибка разбора discovery больше не глотается.** Флаг `authorization_response_iss_parameter_supported` + с неверным типом молча выключал бы защиту от срыва параметра — теперь WARN. + +**Перепроверено, а не принято со слов:** PD-71 (`DownTo(4)` падает на `users_email_key`, SQLSTATE +23505; `Up()` возвращает схему, все три аккаунта целы). Замеры ревью по PD-65 и PD-66 в регистре +атрибутированы ревью — своими прогонами я их не воспроизводил. + +Плюс PD-71 — принят риском: down-путь `00005` неисполним на данных, которые его же up-путь делает +законными, поэтому откат ниже версии 5 недоступен. Править нельзя (append-only), поэтому записано +оператору в `deploy/README.md`, а не спрятано. + +**Не трогали намеренно:** PD-60 и PD-61 — обратное давление и сброс буфера эмиттера. Это свойства +ШВА, назначать их платформе в одиночку нельзя: эмиттера нет, а выбор «блокировать движок или ронять +события» меняет контракт. Идут строкой 103 единого бэклога вместе с транспортом (PD-59). + +**Замеры, которые не воспроизвелись:** «41 взаимоблокировка на 300 раундов» (сессия P1) — остаётся +со слов; действующий замер по PD-52 сделан заново. Замер приёмки «2 на 150» на этом стенде дал +5–10 на 150 — та же величина, другой стенд, поэтому в тест записан свой. + +## Приёмка P1: что изменилось в решениях + +Пять независимых ревью (вход · деньги и SQL · стиль · логи · вне карты автора), четыре из пяти — +исполнением. Находки — строками PD-24…PD-45 в регистре. Здесь только то, что поменяло РЕШЕНИЕ: + +1. **Миграции append-only без исключений** (было: «до первого деплоя правим на месте»). goose + применяет по НОМЕРУ — ни имени, ни хеша: база на версии 3 приняла бы новый набор как применённый + и не получила ни одной таблицы, `DownTo` на ней ломается навсегда. Гейт — `migrations.sha256` + + `TestReleasedMigrationsAreUnchanged`. Подробности в `STACK_DECISIONS` §8. +2. **Ключ идемпотентности леджера — `(user_id, source, source_id)`**, пустой ключ запрещён DDL. + Ключ без аккаунта проглатывал грант, выданный другому. +3. **`reservations.book_id` — составной FK к `books(id, owner_id)` с RESTRICT.** Каскад делал холд + невозвратным, а освободившийся `engine_run_id` давал холд без списания. Владение книгой теперь + проверяет база, а не вызывающий (это денежная форма API1 BOLA). +4. **`lockBalance` первым во всех денежных операциях** — иначе Hold↔Settle дают взаимоблокировку. + ⚠ Замер этой сессии «41 на 300 раундов» не воспроизвели ни приёмка, ни P2 — нагрузка не была + описана; остаётся СО СЛОВ. Дефект при этом настоящий: воспроизведён независимо дважды, действующий + замер — в PD-52. +5. **Расчёт capped потолком холда.** Завышенное `committed_usd` уводило баланс в минус. +6. **Грант фри-тира — только подтверждённой личности** (вопрос владельцу ниже). +7. **Имя провайдера — конфигурация, `State.Provider` сверяется в колбэке.** Захардкоженное «google» + при смене issuer кладёт чужие `sub` в старое пространство имён. +8. **Исход прогона читается из `ProcessState`, а не из ошибки `Wait`** — иначе штатный SIGTERM + помечает все идущие прогоны провалившимися. +9. **Лимит тела — пер-маршрутный**, иначе загрузка книги не может поднять свой потолок. +10. **Просроченный дренаж — не отказ процесса** (exit 0 + WARN): под `Restart=on-failure` штатная + остановка читалась бы systemd как крах. + +**Отклонено:** `user_id` в access-логе (предложение ревью логов). Норматив зоны запрещает id +пользователей в логах; ответ на «кого задело» по дизайну живёт в `login_events`. Взят смежный +вариант — исход аутентификации, он не PII. + +**Проверено и дефектов не дало:** PKCE/nonce/одноразовость стейта под конкуренцией (6 колбэков с +одним стейтом → 1 успех); провайдерские токены нигде не персистятся; параллельные первые входы +(40 горутин → один аккаунт); инвариант `balance == SUM(ledger)`; отсутствие секретов, PII и денег +в логах. + +## Открытые вопросы после P1 + +**Владельцу — оба ЗАКРЫТЫ приёмкой (05.08):** грант только подтверждённой личности остаётся; два +провайдера = два аккаунта на бете приемлемо, дефолт гранта $5. Правило гранта теперь и запинено — +PD-48. + +**Новый вопрос владельцу, один — из PD-58.** Абсолютный срок сессии снижен с 90 до **30 суток**: +это цифра NIST SP 800-63B-4 для AAL1, а обоснования у 90 не нашлось (на аккаунте тратимый баланс, +повторный вход у уже залогиненного в Google — один клик, а идущий ПРОГОН истечение сессии +переживает — он серверный процесс). Видимое следствие: человек, не заходивший месяц, увидит экран +входа. Если это против замысла — это одна переменная `TM_PLATFORM_SESSION_MAX_AGE` и строка в +`STACK_DECISIONS §13`, но тогда отклонение от нормы придётся записать туда явно. + +**Оркестратору — четыре.** +1. **Спека не знает про `/auth/*`.** Ручки входа (`GET /auth/login`, `GET /auth/callback`, + `POST /auth/logout`, `POST /auth/logout-all`) живут ВНЕ версионного префикса, как `/healthz`: + это не контрактная поверхность, а механика сессии. Если фронт должен на них ссылаться — нужна + строка в спеке или в компаньоне. Наше предложение: описать их в компаньоне, в `openapi.yaml` + не тащить. +2. **Требование `X-TM-Client` (пинг P0) всё ещё не в спеке.** Повторяем: реализовано, значение + любое, несущей является ПРИСУТСТВИЕ заголовка на небезопасных запросах cookie-пути. +3. **Форма ответа `GET /v0/usage` изменилась вместе с моделью денег** (баланс вместо окон), а + спека этого ещё не отражает. Ручку НЕ строили намеренно — контракт первичен. Предлагаемая форма + в разделе «Что предлагаем в спеку» ниже. +4. **Транспорт потока событий: увести с stdout на выделенный дескриптор или сокет.** Просьба + завести это строкой к 103 единого бэклога, пока эмиттера нет — потом правка станет миграцией. + + > ⚠ **ОТВЕЧЕНО приёмкой (PD-59): диагноз принят, переезд канала отклонён.** Мотив прецедентов + > (dpkg/gpg/systemd) к нам не переносится — там stdout занят, у нас платформа даёт движку + > выделенный пайп. `ExtraFiles` задокументирован четырьмя строками, цена ошибки замерена. + > Риск закрывается guard'ом в источнике. Триггеры пересмотра названы в разделе «Ратификация + > приёмкой P1», подраздел про транспорт. + + Формат менять НЕ предлагаем: NDJSON с версионным хендшейком в stdout — индустриальная норма для + «долгая команда сообщает прогресс машине» (clig.dev: машиночитаемое — в stdout, сообщения — в + stderr; так же `go test -json`, `cargo --message-format`, `terraform -json`, docker jsonmessage). + Речь только о канале. + + Причина: **stdout — общий ресурс процесса.** Один `fmt.Println` в движке или в его зависимости + ломает протокол, и сегодня от этого защищает правило в `research/23 §2`, а не механизм. Индустрия + этот же вывод сделала: `hashicorp/go-plugin` (Terraform, Vault, Nomad, Packer) печатает в stdout + ОДНУ строку хендшейка `1|3|unix|/path/to/socket|grpc` и дальше уходит на unix-сокет; `runc` и + `containerd` получают канал через `--console-socket`. + + Предлагаемая форма для нас: платформа передаёт путь/дескриптор в argv, движок пишет поток туда, + stdout остаётся человеку. Полный RPC (go-plugin/gRPC) сейчас НЕ нужен — управление + однонаправленное: старт argv, стоп сигналом, досинхронизация `status --json`. Триггеры, при + которых он окупится, называем заранее: пауза/продолжение без убийства процесса · поднятие потолка + на живом прогоне · подпись банка в работающий процесс вместо рестарта · управление потоком. + Два таких требования — и переезд оправдан, причём механический: хендшейк уже есть. + +## Открытые вопросы к владельцу/оркестратору (P0, историческое) **Решения владельца 05.08 (закрыли всё, что висело по деньгам):** оплаты нет и в бете не будет — пробные аккаунты на фри-тире, ключи предоплачены владельцем · модель лимита = БАЛАНС кредитов, а не @@ -36,6 +386,306 @@ заголовок `X-TM-Client` на небезопасных запросах cookie-пути. Это требование к ФРОНТУ, и его место — в описании `sessionCookie` в спеке. Реализовано и проверено тестами. +## Решения сессии P1 (аргументация) + +### Политика коллизии почты + +**Почта не является ключом ни в какой форме. Ключ личности — `(provider, subject)`.** +`users.email` стала NULLABLE и потеряла уникальный индекс; неизвестная пара `(provider, subject)` +ВСЕГДА создаёт новый аккаунт, какой бы адрес с ней ни пришёл. Присоединение второго провайдера к +существующему аккаунту — отдельное аутентифицированное действие (его ещё нет), никогда не побочный +эффект входа. Адрес аккаунта обновляется только из ПОДТВЕРЖДЁННОГО; неподтверждённый остаётся на +личности и наверх не поднимается. + +Почему не «связывать по подтверждённой почте». Связывание по адресу — это классический путь захвата +аккаунта, и `email_verified` его не закрывает: адрес может смениться владельцем (Google предупреждает +об этом прямым текстом), корпоративный домен может выдать освободившийся ящик другому сотруднику, а +провайдер может пометить verified то, что он верифицировал по своим правилам, а не по нашим. Цена +нашего решения — два аккаунта у одного человека при входе разными провайдерами. Это ДУБЛИКАТ: +человек его видит, оператор может слить. Цена альтернативы — чужой аккаунт достаётся тому, кто +получил адрес. Дубликат чинится, захват — нет. + +Пин: `TestIdentityNeverJoinsAccountsByEmail` — два субъекта с одним адресом и третий с другим +провайдером обязаны дать ТРИ аккаунта. + +⚠ Побочное следствие для денег — вопрос владельцу выше (грант на аккаунт, аккаунтов может быть два). + +### Форма админ-поверхности — CLI, не защищённая ручка + +`tmplatformctl grant|balance|logins|revoke`. HTTP-ручке ради четырёх операций понадобилась бы вторая +модель авторизации: роли, их хранение, эскалация, отзыв админской сессии, отдельный CSRF-режим — +и каждая из этих вещей может быть сделана неправильно. Граница доверия для этих операций уже есть и +обеспечена машиной: чтобы выполнить их, нужен шелл на VM и доступ к DSN. Браузерная панель, если +понадобится, обернёт ровно те же вызовы стора. + +Идемпотентность у гранта — ОПТ-ИН через `--key`: ключ по умолчанию «аккаунт+дата» схлопывал два +законных гранта одного дня, и второй рапортовал успех, ничего не начислив. Без ключа каждый вызов +самостоятелен, а CLI печатает «применилось» или «ключ уже потрачен» по факту. + +### Форма журнала входов + +Таблица `login_events`: время, провайдер, исход (`success|denied`), причина отказа, ПРЕФИКС адреса +(/24 для IPv4, /48 для IPv6) и КЛАСС клиента (`browser|desktop|other`). Ни полного адреса, ни +user-agent: журнал отвечает на вопрос «откуда примерно и чем», который человек и оператор реально +задают, и не превращается в собственную базу слежки. Отказавшийся вход пишется без `user_id` — +попытка была, аккаунта у неё нет. Ручка «отозвать все сессии» — `POST /auth/logout-all`, она же +`tmplatformctl revoke`. ⚠ Ретенции у журнала пока нет — строка PD-23. + +## Что предлагаем в спеку (S3) + +`GET /v0/usage` в кредитной модели — БЕЗ сумм и без `resets_at`: + +```yaml +Usage: + required: [state, remaining_percent] + properties: + state: { enum: [ok, low, exhausted] } # low — порог показа предупреждения + remaining_percent: { type: integer, minimum: 0, maximum: 100 } # от последнего гранта + paused_reason: { enum: [credit_exhausted], nullable: true } # почему стоит прогон +``` + +Процент считается от суммы грантов аккаунта, а не от «лимита периода»: периодов больше нет. +`Run.paused_reason` — то же значение на прогоне (колонка в схеме заводится вместе с ручкой). + +## Ратификация приёмкой P1 (оркестратор №14, 05.08) + +**Вердикт: P1 ПРИНЯТ и заленден.** ⚠ **Испр. оркестратором №15:** слово «заленден» здесь неверно — +код P1 в git не уезжал, он ушёл туда вместе с P2 при приёмке 07.08 (раздел «Ратификация приёмкой +P2» выше). Живой уязвимости приёмка не нашла. Найденное делится на три +кучки: незапиненные свойства (строка регистра говорит «закрыто», посадка её переживает), неверные +формулировки в доках и **два несоответствия внешней норме** — PD-57 (mix-up: реализована не та +контрмера, которую требует RFC 9700 §2.1) и PD-58 (ASVS 5.0 L2 требует ДОКУМЕНТИРОВАТЬ сроки +сессий; они существуют только литералами в коде). По правилу, которое сессия сама применила к PD-1 +(«свойство без пинящего теста закрытым не считается»), строки **PD-30, PD-32, PD-37, PD-26 к своим +фиксам не привязаны** — заведены заново как PD-46…PD-59. + +**Метод.** Батарея пере-прогнана мной: офлайн зелёная (0 issues линтера), с живым PostgreSQL 18.4 — +скипов ноль, `make vuln` чист. Собственная посадка 43 мутаций (не по следам отчёта: по СВОЕЙ карте +свойств несущего пути) — **31 поймана поимённо, 9 пережили, 3 не собрались**; дерево после каждой +восстановлено, побайтовая сверка с бэкапом в конце — совпадение. Плюс шесть собственных +тестов-проб против живой БД и четыре живые пробы на собранном бинаре. Механику см. регистр. + +**Сверка с индустриальной нормой — отдельным проходом, по первоисточникам** (её отсутствие владелец +поймал на первой редакции этого раздела; тогда решения были проверены только ВНУТРИ репозитория — +механика goose, схема, поведение `net/http` — а против внешних норм не сверялись): + +| Норма | Что требует | Как у нас | +|---|---|---| +| OIDC Core 1.0 §5.7 / §2 | Стабильный идентификатор — только пара `(iss, sub)`; `email`, `phone_number`, `preferred_username` **MUST NOT** использоваться как идентификатор (издатель вправе переиспользовать адрес между людьми) | ✅ решение «почта не ключ» — не наше изобретение, а буква нормы. ⚠ ключуем по НАШЕМУ имени провайдера, не по `iss`; причина названа в коде (смена URL издателя не осиротит аккаунты) — сознательное отклонение | +| RFC 9700 §2.1.1 (BCP, янв. 2025) | PKCE; `nonce` как альтернатива для OIDC-клиентов | ✅ и то, и другое, реально проверяется настоящим издателем в тесте | +| RFC 9700 §2.1 | Одноразовый `state` против CSRF; точное сравнение redirect URI | ✅ одноразовость в БД одним `DELETE … RETURNING` + привязка к куке браузера | +| RFC 9700 §2.1 + RFC 9207 | Против mix-up: SHOULD — `iss` из авторизационного ответа; MAY — раздельные redirect URI | ❌ **не выполнено** — реализована собственная сверка, которая внутри одного хендлера сравнивает конфигурацию с собой: **PD-57** | +| ASVS 5.0 V7 · 7.2.3, 7.2.4, 7.4.1, 7.4.2 (L1) | ≥128 бит энтропии; новый токен на аутентификации со сносом прежнего; отзыв прекращает использование; снос всех сессий при удалении аккаунта | ✅ все четыре, 256 бит при требуемых 128, ротация запинена тестом | +| ASVS 5.0 V7 · 7.1.1, 7.1.2, 7.1.3/7.6.1 (L2) | Сроки бездействия и абсолютный ДОКУМЕНТИРОВАНЫ с обоснованием отклонений от NIST SP 800-63B; политика одновременных сессий; согласование с федеративной сессией | ❌ **не выполнено** — 14 суток/90 суток живут литералами в `config.go`, обоснования нет нигде: **PD-58**. Это несоответствие линии, которую зона объявила себе сама (`ENGINEERING_STANDARDS §2`) | +| Миграции | Flyway/Liquibase хранят контрольные суммы и падают на расхождении; goose хранит только номер | ✅ `migrations.sha256` — не самодеятельность, а восполнение того, что другие инструменты дают из коробки | +| Деньги | Резерв→захват (hold/capture) — стандарт платёжной механики | ✅ форма стандартная. Леджер знаковый однозаписный с кэшем баланса вместо двойной записи — упрощение, оправданное отсутствием продаж; инвариант `balance == SUM(ledger)` его страхует | + +**Подтверждено ИСПОЛНЕНИЕМ (не чтением отчёта):** + +- **PD-2 действительно закрыт на том бинаре, который едет.** 25 полу-кормленных POST → сервер + отпустил все 25 через 29.1 с (в P0 держал, пока не уходил клиент). ⚠ Но защита от повторного + открытия стоит только на проводке — PD-46. +- **PD-25 в своей полной форме:** один ключ идемпотентности на двух аккаунтах — оба применяются; + повторный `Hold` после `DeleteBook` (когда строку резервации унесло) даёт `ErrDuplicateHold`, + баланс не двигается, резервация не остаётся. Собственный тест. +- **PD-26 воспроизведён независимо** — 2 взаимоблокировки на 150 раундов с инвертированным + порядком, 0 с фиксом. Фикс несущий; ⚠ замер сессии «41 на 300» не воспроизведён (см. PD-52). +- **PD-27 держит и на переполнении:** `Settle` с `1<<62` списывает ровно холд. +- **PD-24:** манифест закрывает все три случая — правку, новый нелистанный файл и удаление. +- **PD-34:** штатная остановка даёт exit 0; второй SIGTERM убивает (обработчик снят). +- **CSRF-слой живьём:** кука без `X-TM-Client` → 403; с заголовком → 401; `Sec-Fetch-Site: + cross-site` → 403; `Origin: evil` → 403; кука + мусорный Bearer → 401 (привилегии не даёт, PD-50). +- **Админ-CLI живьём:** грант, повтор ключа (no-op), корректировка, `balance == SUM(ledger)`, + отказы на отрицательном гранте, корректировке без причины и неизвестном аккаунте. +- **200 конкурентных денежных операций** — 0 ошибок, кэш не разъехался с леджером. + +**Опровергнуто исполнением:** обоснование `ClearReadDeadline` и строка «Поток переживает +read-дедлайн» в таблице пинов — PD-51. `net/http` снимает read-дедлайн сам, до хендлера; +тест не может упасть от выхолащивания функции. Код безвреден, ложны обоснование и пин. + +**Ратифицировано (4 из 4):** + +1. **Миграции append-only без исключений — ПРИНЯТО.** Аргумент проверен: `goose_db_version` держит + только номер. `STACK_DECISIONS §8` — норма зоны. +2. **Почта не ключ ни в какой форме — ПРИНЯТО, и это прямо буква нормы,** а не наш вкус: OIDC Core + §5.7 — стабильный идентификатор только `(iss, sub)`, `email` использовать как идентификатор + **MUST NOT**, потому что издатель вправе переиспользовать адрес между людьми. Цена (дубль + аккаунта у человека с двумя провайдерами) названа честно и меньше альтернативы (захват аккаунта + по унаследованному адресу). ⚠ Отклонение: ключуем по НАШЕМУ имени провайдера, не по `iss`; + причина в коде названа и принимается. +3. **Админ-поверхность — CLI — ПРИНЯТО.** ⚠ Владельцу сказано прямо: «админка» сегодня = команда в + шелле на машине, не веб-страница. Для беты этого достаточно; браузерная панель обернёт те же + вызовы стора. +4. **`sqlc` (PD-44) — направление ИЗМЕНЕНО, а не долг.** Проверено исполнением: sqlc v1.31.1 читает + все goose-миграции зоны (на момент пробы семь) и генерирует под `pgx/v5` код, почти совпадающий с рукописным + (`:execrows` → `RowsAffected`). Две трения: он отвергает запрос, который Postgres принимает + (неквалифицированный `user_id` в коррелированных подзапросах), и типизует колонки как + `pgtype`/`int64` — то есть `money.MicroUSD` на границе теряется без блока `overrides`. + **Решение:** денежный пакет НЕ переписывать — он только что отревьюен и имеет батарею против + живой БД, а обмен отревьюенного кода на сгенерированный без единого нового теста ничего не + покупает. `sqlc` берётся на поверхность контрактных ручек/read-model (П-1), где запросов много + и они меняются, — там ловится именно тот класс, ради которого он нужен (запрос ссылается на + колонку, которую унесла миграция). Правка внесена в `PLATFORM_DIRECTION.md` §3. + +**Вопросы владельца — ответы даны, оба «да» с одной поправкой.** Грант только подтверждённой +личности остаётся (для Google это все настоящие аккаунты, а дыру саморегистрации закрывает); +два провайдера = два аккаунта на бете приемлемо, дефолт гранта $5. ⚠ Само правило гранта тестом +не защищено — PD-48, чинить независимо от ответа. + +**Регрессионный тест к PD-52** (написан приёмкой, воспроизводит цикл; вставить в +`internal/pgstore/`, хелперы `testDB`/`seedUser`/`exec` уже есть): + +```go +// PD-26 в форме, которая действительно даёт цикл: Hold, переиспользующий attempt id, ждёт строку +// резервации по первичному ключу, уже держа лок баланса, а конкурентный Settle той же резервации +// держит строку и хочет баланс. С lockBalance первым в ОБОИХ цикл не складывается. +// Замерено приёмкой: с инверсией 2 взаимоблокировки на 150 раундов, с фиксом — 0. +func TestHoldAndSettleOnTheSameAttemptDoNotDeadlock(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`) + now := time.Now().UTC() + if _, err := s.Grant(ctx, "u1", 100000*money.PerUSD, "admin", "g", "", now); err != nil { + t.Fatal(err) + } + var mu sync.Mutex + var deadlocks int + note := func(err error) { + if err != nil && strings.Contains(err.Error(), "deadlock") { + mu.Lock() + deadlocks++ + mu.Unlock() + } + } + for i := range 150 { + id := fmt.Sprintf("run-%d", i) + if err := s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); note(s.Settle(ctx, id, money.PerUSD/2, now)) }() + go func() { defer wg.Done(); note(s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now)) }() + wg.Wait() + } + a, err := s.ReadAccount(ctx, "u1") + if err != nil { + t.Fatal(err) + } + if a.Balance != a.LedgerSum { + t.Fatalf("кэш разъехался с леджером: %s против %s", a.Balance.USD(), a.LedgerSum.USD()) + } + if deadlocks > 0 { + t.Fatalf("%d взаимоблокировок на 150 раундов", deadlocks) + } +} +``` + +**Что зона обязана сделать до следующего лендинга** (порядок — мой). ✅ **Все восемь отработаны в +P2 в этом порядке; расхождения с формулировкой пунктов 5 и 7 — в разделе «Сессия P2» выше.** + +1. PD-46 — тест на ЗНАЧЕНИЯ `DefaultTimeouts()`. Это буквально та дыра, в которой PD-2 прожил P0. +2. PD-58 — обосновать 14 суток/90 суток письменно (ASVS 7.1.1 требует именно документа, а не + значения), заодно 7.1.2 и 7.1.3. Самый дешёвый пункт списка и единственное несоответствие + базовой линии, которую зона объявила себе сама. +3. PD-48, PD-49, PD-47 — три пина к трём «закрытым» строкам регистра. +4. PD-52 — регрессионный тест порядка блокировок (готовый выше). +5. PD-51 — привести §12 и таблицу пинов к тому, что делает `net/http`; решить судьбу + `ClearReadDeadline` (оставить как страховку — законно, но с честным комментарием). +6. PD-54 — снять устаревшую подписочную форму `/v0/usage` из журнала, пока S3 её не прочитал. +7. PD-55 — `MemoryMax`/`TasksMax` в юните: либо потолок для платформы отдельно от детей + (`Slice=`/отдельный юнит), либо честный комментарий, что потолок общий на прогоны. +8. PD-57 — решение по mix-up принять ЯВНО и до второго провайдера: чтение `iss` авторизационного + ответа (норма) либо раздельные redirect URI (допустимая альтернатива). + +**Что НЕ блокирует лендинг, но блокирует первый реальный деплой:** PD-58 (без документа сроков зона +не соответствует линии, которую сама объявила), PD-55 (потолок памяти общий на платформу и все +прогоны — OOM выберет движок с эксклюзивным локом). PD-57 — до второго провайдера. + +**Спека (мои решения как владельца контракта):** `/auth/*` — в компаньон, в `openapi.yaml` не +тащим (согласен: механика сессии, не контрактная поверхность). `X-TM-Client` и кредитная форма +`GET /v0/usage` — уже в списке правок промта S3, отдельного действия зоне не нужно. + +**Транспорт потока событий (вопрос зоны №4): диагноз ПРИНЯТ, переезд канала ОТКЛОНЁН. PD-59, +PD-60, PD-61.** + +Этот пункт переписывался четырежды. Три первые редакции спорили о том, чьи прецеденты лучше, — это +не инженерный аргумент. Четвёртая получена методом владельца: задача сформулирована АБСТРАКТНО (два +Go-сервиса, родитель читает NDJSON у ребёнка, никакого контекста репозитория и никакого намёка на +мою позицию) и отдана двум независимым чистым агентам — одному с доступом в сеть, другому только со +своими знаниями. **По самому каналу они разошлись** (сетевой — переезжать, офлайновый — остаться), и +именно поэтому упражнение оказалось полезным: ценность не в их вердикте, а в том, на чём они сошлись +НЕЗАВИСИМО и чего не было ни в записке зоны, ни в трёх моих редакциях. + +**Сошлись на трёх вещах, и первая решает вопрос.** + +1. **SIGPIPE зависит от НОМЕРА дескриптора.** `os/signal`: обрыв пайпа на fd 1 или 2 убивает + программу сигналом; на любом другом дескрипторе запись просто возвращает `EPIPE`. **Замерено + мной, не со слов:** поток на fd 1 — ребёнок убит `broken pipe`; на fd 3 — `write` вернул EPIPE и + процесс спокойно доработал до конца. Для нас это не сноска, а деньги: сегодня падение платформы + убивает движок на следующей же записи события; после переезда движок стал бы сиротой и часами жёг + бы оплаченные вызовы, пока холд висит в леджере и закрыть его некому. То есть stdout даёт нам + бесплатную остановку сироты, а предложенный переезд её ЛОМАЕТ. Свойство несущее и до сих пор + нигде не записано. +2. **Настоящая защита — не выбор канала, а перехват на уровне дескриптора:** `dup(1)` в приватный fd, + затем `dup3(2,1,0)`, в `main` движка. Он работает при ЛЮБОМ канале и герметичен там, где + предложенный мной ранее `os.Stdout = os.Stderr` дыряв: переживает `var out = os.Stdout`, + захваченный зависимостью, cgo и унаследованный fd 1 у внуков. Направлять fd 1 в `/dev/null` не + надо — пусть посторонние записи видны в человеческом логе прогона. +3. **Дискриминатор, при котором переезд был бы прав:** ребёнок исполняет чужой код, наследующий + stdio (хуки, плагины, шелл-аут). Это ровно мотив `dpkg --status-fd`. **Проверено: у нас нет** — + в `backend/` нет ни одного `exec.Command` вне тестов и нет cgo. + +Отсюда и вывод: обсуждали не тот вопрос. Канал остаётся stdout — теперь не «потому что переезд не +окупается», а потому что переезд активно ухудшает поведение при падении платформы. + +**Диагноз зоны верен и не оспаривается.** stdout — общий ресурс процесса; один посторонний +`Println` в движке или в его зависимости ломает протокол, и защищает от этого правило в +`research/23 §2`, а не механизм. + +**Прецеденты зоны не держат, но сильные существуют — и их мотив к нам не переносится.** +`hashicorp/go-plugin` уходит на сокет ради ДВУНАПРАВЛЕННОГО RPC (хендшейк он как раз держит в +stdout); `runc --console-socket` — про передачу ДЕСКРИПТОРА pty через SCM_RIGHTS. Канонические +прецеденты паттерна — другие: **dpkg `--status-fd n`** («Send machine-readable package status and +progress information to file descriptor _n_», man dpkg(1)), **gpg `--status-fd`**, **systemd +`NOTIFY_SOCKET`**. Но во всех трёх stdout ЗАНЯТ полезной нагрузкой — выводом операции, шифротекстом, +выводом сервиса, — и статус выселяют потому, что ему негде жить. У нас платформа даёт `tmctl` +выделенный пайп через `cmd.StdoutPipe()`; на этот stdout не претендует никто. Мотива нет. + +**Что говорит дока Go о качестве такого решения: ничего.** `os/exec.ExtraFiles` — четыре строки +доккоммента, из качества ровно одно: «not supported on Windows». Жизненный цикл пайпа на +вызывающем. **Цена ошибки замерена:** не закрыл родительскую копию пишущего конца после `Start()` — +EOF не приходит НИКОГДА, читатель висит на давно завершённом прогоне (`StdoutPipe` закрывает сам). +Идиоматичный Go для «родитель читает поток ребёнка» — `StdoutPipe`, а `ExtraFiles` — нишевый +механизм socket-activation и контейнерной обвязки. + +**Изъян нашёлся и в фолбэке, который приёмка предлагала ранее.** Безусловный +`os.Stdout = os.Stderr` в `main` движка сломал бы `tmctl status --json`: ре-синк читает именно +stdout (`supervisor.go:145`, `cmd.Output()`). Guard обязан жить в области ТОЛЬКО потоковой команды. + +**Решение: канал не меняем.** Переезд покупает защиту от класса, который в нашем процессе почти +пуст (cgo в движке нет, детей на потоковом пути он не спавнит), а стоит: изменение CLI движка — +то есть запрос через шов, — ручной жизненный цикл пайпа с измеренным режимом вечного зависания, +Windows вне игры и неидиоматичный для Go паттерн. По норме владельца «механизм строится только +там, где несёт качество/деньги, — не ради галочки» это механизм ради галочки. + +**Строка 103 единого бэклога заводится так:** + +- канал остаётся **stdout** — из-за SIGPIPE-семантики, которая нам служит; +- защита — **перехват на уровне дескриптора** в `main` движка, в области потоковой команды + (безусловный вариант сломал бы `tmctl status --json`: ре-синк читает stdout, `supervisor.go:145`); +- вместе с эмиттером задаются **сброс буфера на всех путях выхода** и **политика обратного + давления** — PD-61 и PD-60; оба свойства дешевле назначить до постройки, чем мигрировать после; +- переезд на `--events-fd` пересматривается по названным заранее триггерам: появление cgo в движке, + спавн им собственных детей на потоковом пути, реальный инцидент порчи потока. Если когда-нибудь + понадобится «платформа перезапустилась, прогон продолжается» — ответ не сокет, а журнал файлом с + чекпойнтом смещения (PD-61). + +Побочно упражнение проверило нас: ловушку `bufio.Scanner` (переполнение строки читается как чистый +EOF, поток молча обрывается) оба агента назвали самым вероятным латентным багом такой системы — +у нас она закрыта, `Buffer` поднят до 1 МиБ и `sc.Err()` проверяется (`decoder.go:45,115`). + ## Ратификация приёмкой (оркестратор №14, 04.08) **Вердикт: P0 ПРИНЯТ, заленден `eeeef89`.** Метод: батарея пере-прогнана мной (офлайн зелёная; с живым @@ -179,30 +829,84 @@ research/23 §2 + запрет INFO-денег), а словарь строки ### П-5 — форма API лимитов/использования -`GET /v0/usage` (страница лимитов в настройках): +> ⚠ **Подписочная форма ответа УДАЛЕНА отсюда (PD-54, закрыт в P2).** Она несла окна, `resets_at` +> и `usage_windows` и отменена решением владельца 05.08 «не подписки, а баланс»; таблицу +> `usage_windows` снесла миграция `00006`. Баннера было мало: S3 идёт в журнал ЗА ФОРМОЙ ручки и +> скопировал бы тело, а не баннер. **Действующая форма одна — раздел «Что предлагаем в спеку (S3)» +> выше.** Ниже осталось то, что от модели денег не зависит. -```json -{"revision": 42, "state": "ok|approaching|exhausted", "used_percent": 37, - "resets_at": "2026-08-11T00:00:00Z", - "windows": [{"period": "day", "used_percent": 12, "resets_at": "…"}, - {"period": "week", "used_percent": 37, "resets_at": "…"}]} -``` - -- **Сумм нет ни в каком виде.** Процент и время сброса — статус использования, а не деньги - (D39.84 в силе, механика «как Claude Code» — D39.100/ПТ-35). -- **Стоп по потолку:** `BookStatus: paused` + машинная причина. Предлагаем - `Run.paused_reason: "limits_exhausted" | null`: фразу («перевод остановлен: лимиты исчерпаны») - рисует клиент словами владельца (В-3), API несёт состояние. Без поля причины второй повод для - паузы станет ломающим изменением. +- **Сумм нет ни в каком виде.** Процент — статус использования, а не деньги (D39.84 в силе, + механика «как Claude Code» — D39.100/ПТ-35). +- **Стоп по потолку:** `BookStatus: paused` + машинная причина, `Run.paused_reason`: фразу + («перевод остановлен: кредит исчерпан») рисует клиент словами владельца (В-3), API несёт + состояние. Без поля причины второй повод для паузы станет ломающим изменением. - **Источник цифр.** Поток событий денег не несёт и не должен (кадр `ceiling` — только факт), поэтому платформа метрит из `tmctl status --json` (`committed_usd`) на границах попыток и на - ре-синке; хранит целыми микро-долларами в `usage_windows`. + ре-синке; хранит целыми микро-долларами в леджере (`credit_ledger`, миграция `00007`). - **Поднятие потолка — политика платформы, не кнопка на экране.** Платформа сама владеет `book.yaml`, поднимает `ceilings.book_usd` и перезапускает прогон. **Проверено кодом, что это безопасно:** `Ceilings` объявлен в `backend/internal/config/book.go:106`, а в канон `BriefHash` (`:280-297`) НЕ входит — значит поднятие потолка не двигает `brief_hash` → снапшот и не вызывает ни дрифт, ни ре-билл. Риск «подняли лимит — переплатили книгу заново» снят фактом, не надеждой. +## Что построено (P1) + +| Кусок | Где | Проверено ИСПОЛНЕНИЕМ | +|---|---|---| +| Конструкция сервера вынесена из `main` | `internal/httpapi/serve.go` | Тесты гоняют РЕАЛЬНЫЙ `http.Server` на loopback-порту; без этого PD-2 и PD-9 не видит ни один тест на mux под `httptest` | +| `ReadTimeout` + `LimitBody` (PD-2) | `serve.go`, `middleware.go` | Живая проба на бинаре: полу-кормленный POST отпускается на `ReadTimeout` (30.0 с) | +| Поток переживает `ReadTimeout` | `serve.go` (без вспомогательной функции) | `net/http` снимает дедлайн сам; помощник `ClearReadDeadline` УДАЛЁН — на полу-кормленном запросе он воспроизводил PD-2 (PD-63) | +| Дренаж по SIGTERM (PD-9) | `serve.go` | Тест: ctx-aware хендлер в полёте доигрывает и отдаёт 200 | +| Вход через OIDC (П-6) | `internal/login/` | Полный флоу против НАСТОЯЩЕГО OIDC-издателя, поднятого в тесте: discovery, JWKS, RS256-подпись, реальная проверка PKCE на токен-эндпоинте. 6 негативных сценариев (чужой nonce, чужая audience, протухший токен, подмена state, отсутствие куки, реплей) | +| Модель аккаунта | `migrations/00001`, `pgstore/identity.go` | Живой PG: три личности с одним адресом дают три аккаунта; неподтверждённый адрес не поднимается на аккаунт; state одноразовый и истекает | +| Кредитный леджер (П-7) | `migrations/00007_credits.sql`, `pgstore/credits.go` | Живой PG: инвариант `balance == SUM(ledger)` после каждого шага grant→hold→settle→release; повторный ключ — no-op; холд сверх баланса, чужая книга и повтор attempt-id отказаны | +| Деньги как тип | `internal/money/` | `big.Rat`, округление к `+∞`, синтаксис ограничен регуляркой и длиной (`big.Rat` иначе принимает `0x10` и `1/3`) | +| Админ-CLI (П-8) | `cmd/tmplatformctl/` | Живая проба против живой БД: гранты, баланс с открытыми холдами, журнал входов, отзыв сессий | +| Фаззинг декодера | `internal/ingest/fuzz_test.go` | Оракулы — инварианты PD-10; `make fuzz` для углублённого прогона | +| Остановка прогона (PD-12/13/20) | `internal/ingest/` | Тест с настоящим процессом: сбой синка завершает прогон; сигнал повторяется до подтверждения | +| Деплой-юнит (PD-13) | `deploy/tmplatformd.service` | `systemd-analyze verify` — exit 0. ⚠ Под systemd не запускался (нет sudo) | + +### Какой тест что пинит (мандат приёмки §3.3) + +| Свойство несущего пути | Пинящий тест | Посадка, которую он ловит | +|---|---|---| +| В БД только SHA-256 токена | `pgstore.TestStoredCredentialIsAHashNotTheToken` | `Digest` возвращает плейнтекст (посадка приёмки P0 — теперь падает) | +| Соединение нельзя запиннить | `httpapi.TestHalfFedRequestIsDroppedByTheServer` + `TestTheServerTheDaemonRunsHasEveryDeadlineSet` | Снять любой дедлайн из `serverWithTimeouts`; обнулить `DefaultTimeouts().Read` или `.Idle`; добавить `WriteTimeout` (PD-46). Проводка демона больше не проверяется, а СДЕЛАНА невозможной: `NewServer` не принимает `Timeouts` (PD-66) | +| Поток переживает `Read` без действий хендлера | `httpapi.TestStreamOutlivesReadTimeout` | Снять `Unwrap` (тогда `Flush` не дотягивается до соединения — проверяется ошибка `Flush`, а не игнорируется) | +| Полу-кормленный СТРИМИНГОВЫЙ запрос всё равно отпускается | `httpapi.TestHalfFedStreamingRequestIsCutLoose` | Снять `ReadTimeout`; вернуть снятие дедлайна в хендлер (PD-63) | +| SIGTERM дренирует, а не рубит | `httpapi.TestShutdownDrainsInFlightRequests` | `BaseContext` = сигнальный ctx | +| Idle-истёкшая сессия не воскресает | `pgstore.TestTouchCannotResurrectAnIdleExpiredSession` | Убрать клаузу `idle_expires_at` из `Touch` | +| Поток идентифицирован и монотонен | `ingest.TestHandshakeMustIdentifyTheStream` + `FuzzDecoder` | Пустой `engine_run_id`, `seq` хендшейка ≠ 1, hello в середине | +| Деньги не дрейфуют | `ingest.TestSpendConvertsExactlyAndRoundsUp`, `money.TestParseUSDIsExactAndRoundsAwayFromZero` | float64 + умножение; округление к ближайшему | +| Баланс = сумма леджера | `pgstore.TestCreditLifecycleKeepsTheCacheEqualToTheLedger` | Писать кэш вне транзакции леджера | +| Повторный грант не кредитует дважды | `pgstore.TestGrantIsIdempotentBySource` | Снять `on conflict` / вынести обновление баланса из ветки «вставилось» | +| Холд защищает баланс | `pgstore.TestHoldRefusesMoreThanTheBalance` | Убрать проверку баланса. ⚠ `for update` этим тестом НЕ ловится (последовательный тест лока не видит) — он запинен строкой ниже | +| Почта не связывает аккаунты | `pgstore.TestIdentityNeverJoinsAccountsByEmail` | Резолв аккаунта по адресу; уникальный индекс на `users.email` | +| Вход даёт НАШУ сессию и убивает прежнюю | `login.TestLoginCompletesAndCreatesOurOwnSession`, `TestLoginRevokesThePresentedSession` | Не отзывать предъявленную сессию (фиксация сессии) | +| PKCE и nonce реально проверяются | `login.TestLoginCompletesAndCreatesOurOwnSession`, `TestCallbackRefusals` | Снять `S256ChallengeOption`; не сравнивать nonce | +| `return_to` не уводит с сайта | `login.TestReturnToNeverLeavesThisSite` + `FuzzSafeReturnTo` | Ослабить до `HasPrefix("/")`; снять второй декод; снять класс символов; снять protocol-relative. Фаззер судит независимым оракулом — `ResolveReference` против базового URL сайта (PD-47) | +| State одноразовый под КОНКУРЕНЦИЕЙ | `pgstore.TestOnlyOneRacingCallbackCanConsumeAState` (+ последовательные `login.TestStateCannotBeReplayed`, `pgstore.TestLoginStateIsSingleUseAndExpires`) | Разбить `DELETE ... RETURNING` на SELECT и DELETE — последовательные тесты этого не видят, гоночный ловит (3 колбэка из 4 съедали один state) | +| Прогон не переживает свой синк | `ingest.TestFailingSinkStopsTheRun` | Убрать `stop()` после сбоя `Ingest`; убрать повтор сигнала | +| Request-id не берётся у клиента | `reqid.TestRequestIDIsNeverTakenFromTheCaller` | Читать `X-Request-Id` из запроса | +| Секреты можно подать файлом | `config.TestSecretsCanComeFromFiles` | Читать только переменную окружения | +| Грант только подтверждённой личности | `login.TestSignupGrantGoesOnlyToAVerifiedIdentity` | Убрать условие `EmailVerified` (PD-48) | +| State не redeem-ится у другого провайдера | `login.TestStateFromAnotherProviderIsRefused` | Убрать сверку `st.Provider` (PD-49) | +| `iss` авторизационного ответа проверяется | `login.TestAuthorizationResponseIssuerIsChecked` | Убрать вызов `checkIssuer` или любую из его двух веток. ⚠ Потерю `issuer` в СТОРЕ ловит не он, а `pgstore.TestLoginStateIsSingleUseAndExpires` (сравнение структурой) — атрибуция важна ровно по причине PD-46 | +| Состояние входа переживает стор ЦЕЛИКОМ | `pgstore.TestLoginStateIsSingleUseAndExpires` | Потерять любое поле `login.State` при записи или чтении — сравнение структурой, а не тремя полями (PD-62) | +| Порядок блокировок один во всех денежных путях | `pgstore.TestHoldAndSettleOnTheSameAttemptDoNotDeadlock` | Убрать `lockBalance` из `closeReservation` — 5 падений из 5 (PD-52) | +| Кука не участвует, если есть `Authorization` | `auth.TestAnAuthorizationHeaderTakesTheCookieOutOfPlay` | Падать обратно на куку при неразобранном заголовке (PD-50) | +| Лимит тела: вложение только УЖЕСТОЧАЕТ | `httpapi.TestBodyCapIsPerRouteBecauseNestingOnlyTightens`, `TestDefaultBodyCapStaysAContractSizedNumber` | Раздуть дефолт до аплоуд-размера. ⚠ Возврат ОБЩЕГО внешнего слоя в `New` не ловится ничем: наблюдаемым он станет только когда появится маршрут со своим бо́льшим потолком — до тех пор это открытая строка PD-72, а не обещание | +| Один ответ на несуществующий аккаунт | `pgstore.TestMoneyOperationsAgreeOnAMissingAccount` | Убрать мапинг констрейнта в `ErrNoAccount` (PD-56) | +| Сроки сессий в пределах объявленной линии | `config.TestSessionClocksStayWithinTheDeclaredBaseline` | Поднять абсолютный срок выше 30 суток NIST AAL1 (PD-58) | +| Зависший издатель не держит колбэк | `login.TestAStalledProviderDoesNotHoldTheCallback` | Убрать дедлайн из `identify` — обе ноги, token и keys (PD-65) | +| Кука браузера скользит вместе со строкой | `auth.TestSlidingTheIdleWindowRefreshesTheBrowsersCookie` | Не переиздавать куку при скольжении; переиздавать её на Bearer-пути (PD-70) | +| Два прогона не тратят один кредит | `pgstore.TestConcurrentHoldsCannotOvercommitAnAccount` | Убрать `for update` из `lockBalance` — 3 падения из 3, баланс в −$2 (PD-67) | +| Готовность = схема, а не достижимость | `pgstore.TestReadinessRefusesADatabaseWithoutTheSchema` | Свести `Ready` к `Ping` (PD-68) | +| Клиент go-oidc ограничен по времени | `login.TestTheDefaultProviderClientIsBounded`, `TestAHungKeyFetchDoesNotPoisonLaterSignIns` | Отдать `New` клиент без таймаута — тогда зависшая загрузка ключей держит и все последующие входы (PD-73) | +| Скольжение не залипает на потолке | `auth.TestTheSlideStopsOnceItCannotMoveTheDeadline` | Убрать сверку `IdleExpiresAt.Before(AbsoluteExpiresAt)` — каждый запрос последней четверти жизни сессии становится записью (PD-74) | +| Провайдерский токен не доезжает до стора | `login.TestLoginCompletesAndCreatesOurOwnSession` | Записать в стор что-либо выданное провайдером; ⚠ до P2 эта проверка не могла упасть — поле мока никто не заполнял (PD-76) | +| Размеры пула из DSN переживают | `pgstore.TestExplicitPoolSizesInTheDSNSurvive` | Вернуть сравнение с дефолтом pgx. ⚠ Случай «пароль содержит имя ключа» ловит поиск подстроки только на машине, где наш дефолт НЕ совпал с дефолтом pgxpool (у него `max(4, NumCPU)`) — на 16-ядерном стенде он ничего не доказывает; несущее свойство даёт разбор через `RuntimeParams`, а не этот вход | + ## Что построено (P0) | Кусок | Где | Проверено | @@ -254,16 +958,52 @@ research/23 §2 + запрет INFO-денег), а словарь строки | ID | Диспозиция | |---|---| -| П-1 | **НАЧАТА.** Готово: каркас сессий (схема + мидлварь + CSRF), HTTP-скелет, схема read-model, интерфейс ингеста и ре-синка. Осталось: контрактные ручки, SSE-эндпоинт, материализатор `Sink → Postgres`, воркер. Блокеры: ратификация К-4/К-7 (форма ответов), словарь событий (строка 103) | +| П-1 | **ПРОДОЛЖЕНА (P1).** Добавлено: конструкция сервера с таймаутами, дренаж, снятие read-дедлайна для будущего SSE, вход как источник сессий. Осталось прежнее: контрактные ручки, SSE-эндпоинт, материализатор `Sink → Postgres`, воркер. Блокер тот же — словарь событий (строка 103) | +| П-1 (P0) | **НАЧАТА.** Готово: каркас сессий (схема + мидлварь + CSRF), HTTP-скелет, схема read-model, интерфейс ингеста и ре-синка. Осталось: контрактные ручки, SSE-эндпоинт, материализатор `Sink → Postgres`, воркер. Блокеры: ратификация К-4/К-7 (форма ответов), словарь событий (строка 103) | | П-2 | Не трогали — гейт «до второго параллельного пользователя» в силе | | П-3 | Не строили. В схеме заведён гард: частичный уникальный индекс «один живой прогон на книгу» (`runs_one_live_per_book`) — то, что очередь обязана соблюдать, теперь отказывает база. River запинен, но в `go.mod` НЕ добавлен | -| П-4 | Схема `usage_windows` заведена драфтом; источник метрик назван (дельты `committed_usd` из `status --json`). Гейт бюджета ДО старта — вместе с очередью | -| П-5 | Форма предложена выше. Ждёт ответа владельца по авто-продолжению (вопрос 1) | +| П-4 | **ЗАМЕНЁН П-7.** Черновик `usage_windows` удалён вместе с подписочной моделью (владелец 05.08) | +| П-5 | **ПЕРЕОПРЕДЕЛЁН.** Окон нет, `resets_at` нет; форма ответа предложена выше («Что предлагаем в спеку»). Ручка НЕ построена: контракт первичен, ждём правки спеки | +| П-6 | **ЗАКРЫТ (P1).** Вход через OIDC: PKCE + nonce + одноразовый state с привязкой к браузеру, наша серверная сессия, ротация на границе входа, журнал входов, «выйти везде». Токены провайдера не персистятся. Ждёт живого клиента Google (client_id/secret владельца) — код к этому готов, конфигурация проверена отказом на половинчатой настройке | +| П-7 | **ЗАКРЫТ по схеме и операциям (P1).** Леджер, резервации, кэш баланса, инвариант `balance == SUM(ledger)`, идемпотентность по `(source, source_id)`. Не построено: постановка холда ВОРКЕРОМ перед спавном и передача потолка движку — это часть П-1/П-3, у которых нет воркера | +| П-8 | **ЗАКРЫТ (P1).** `tmplatformctl grant/balance/logins/revoke` | ## Хроника _(записи сессий — сверху новые)_ +### 05.08.2026 — сессия P2 (платформа №3) + +Отработана очередь приёмки P1 целиком (PD-46…PD-58) плюс три info-строки вне очереди +(PD-50, PD-53, PD-56). Три собственные находки: PD-62 (`start_id` не персистился — обе строки лога +`login_start_id` в проде пусты), PD-63 (`ClearReadDeadline` воспроизводил PD-2 на полу-кормленном +запросе), PD-64 (`OOMPolicy=stop` уронил бы контрол-плейн из-за одного прогона). + +Изменены два значения, а не обоснованы: абсолютный срок сессии 90 → 30 суток (NIST AAL1), +`MemoryMax` 2G → 80%. Реализована контрмера RFC 9207 против mix-up (миграция `00008`). +Удалён `ClearReadDeadline`. Новых зависимостей P2 не добавила. + +Собственное адверсариальное ревью (пять линз, зажатые промты, отчёт скрыт от четырёх из пяти; +каждая находка через верификатора-опровергателя): 34 кандидата, 11 подтверждено, 23 опровергнуты. +Шесть потребовали кода — PD-65…PD-70, включая major PD-70 (кука не скользила вместе с сессией) и +PD-66 (мой же фикс PD-46 закрывал половину). PD-71 принят риском и записан оператору. + +Открытыми оставлены PD-60 и PD-61 — свойства ШВА, решать их платформе в одиночку нельзя. + +Дерево не коммичено — лендит оркестратор. + +### 05.08.2026 — сессия P1 (платформа №2) + +Закрыт регистр P0 (18 из 19; PD-6 ждёт SSE). Построены: вход через OIDC с PKCE/nonce/одноразовым +стейтом и своей серверной сессией (П-6), кредитный леджер с резервациями и кэшем баланса (П-7), +админ-CLI (П-8), деплой-юнит systemd, тест-пол на реальном `http.Server`, фаззинг декодера. +Приёмка пятью независимыми ревью добавила PD-24…PD-45; изменения решений — раздел «Приёмка P1». + +Не построено намеренно: `GET /v0/usage` (форма изменилась вместе с моделью денег, спека не +правлена — контракт первичен), материализатор и SSE (ждут словарь событий строки 103), очередь. + +Дерево не коммичено — лендит оркестратор. + ### 04.08.2026 — сессия P0 (платформа №1) Прочитано: `CLAUDE.md`, `research/23`, контракт `14-api-contract` (README + openapi.yaml целиком), diff --git a/platform/go.mod b/platform/go.mod index ce9cfdca..39d073ae 100644 --- a/platform/go.mod +++ b/platform/go.mod @@ -3,11 +3,15 @@ module textmachine/platform go 1.26.4 require ( + github.com/coreos/go-oidc/v3 v3.20.0 github.com/jackc/pgx/v5 v5.10.0 github.com/pressly/goose/v3 v3.27.3 + golang.org/x/oauth2 v0.36.0 + golang.org/x/time v0.15.0 ) require ( + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/platform/go.sum b/platform/go.sum index 62dfbd71..6d9ebfad 100644 --- a/platform/go.sum +++ b/platform/go.sum @@ -1,8 +1,37 @@ +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/ClickHouse/ch-go v0.73.0/go.mod h1:wkFIxrqlXeRJ9cn3r5Fz5Qen9jl5aTMPuGZeuJpANNY= +github.com/ClickHouse/clickhouse-go/v2 v2.47.0/go.mod h1:sPj7C7UYQ2MWHcfX+4eGN6nwnCqwUKfgO6PcwKpd6K8= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elastic/go-sysinfo v1.15.5/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= +github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -13,37 +42,78 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= +github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= +github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tursodatabase/libsql-client-go v0.0.0-20260528064733-9d5d30a29a60/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= +github.com/vertica/vertica-sql-go v1.3.8/go.mod h1:c4OZ8lq1Ztc18w8a0nG+dzQh69BzJRcKN2LZOnYbERI= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20260428144813-1c07baab7f7b/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.144.6/go.mod h1:b9NEO6mgaiqsnOMkS003uS82XsKh6GL+ZTFfPqXWz+c= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws= modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= diff --git a/platform/internal/auth/cookie.go b/platform/internal/auth/cookie.go new file mode 100644 index 00000000..46ca8e16 --- /dev/null +++ b/platform/internal/auth/cookie.go @@ -0,0 +1,76 @@ +package auth + +import ( + "net/http" + "time" +) + +// DevCookieName is the session cookie's name when Secure cannot be set. It is a DIFFERENT name on +// purpose: __Host- is not decoration a browser can be talked out of — a cookie with that prefix and +// no Secure attribute is simply rejected — so a "local development" profile that kept the name +// would fail in a way that looks like a broken login (PD-8). +const DevCookieName = "tm_session" + +// LoginCookieName holds the OAuth state while the browser is away at the provider. Short-lived, +// single-use, and paired with a server-side row: the cookie proves the callback came back to the +// same browser that started, which is what stops a login-CSRF. +const ( + LoginCookieName = "__Host-tm_login" + DevLoginCookieName = "tm_login" +) + +// Cookies writes the browser's credentials. One switch, and it flips the name with the attributes. +type Cookies struct { + // Insecure serves plain HTTP: no Secure attribute, no __Host- prefix. Production never sets it. + Insecure bool +} + +func (c Cookies) SessionName() string { + if c.Insecure { + return DevCookieName + } + return CookieName +} + +func (c Cookies) LoginName() string { + if c.Insecure { + return DevLoginCookieName + } + return LoginCookieName +} + +// SetSession writes the session cookie. +// +// SameSite=Lax rather than Strict: the browser returns from the identity provider by a top-level +// GET, and Strict would withhold the cookie on every arrival from an external link. Lax still +// withholds it from cross-site POSTs, and the CSRF layer covers the rest. +func (c Cookies) SetSession(w http.ResponseWriter, token string, ttl time.Duration) { + c.set(w, c.SessionName(), token, ttl) +} + +// ClearSession removes it. Attributes must match the ones it was set with or the browser keeps it. +func (c Cookies) ClearSession(w http.ResponseWriter) { c.set(w, c.SessionName(), "", -time.Second) } + +func (c Cookies) SetLogin(w http.ResponseWriter, state string, ttl time.Duration) { + c.set(w, c.LoginName(), state, ttl) +} + +// ClearLogin removes it. The callback clears it whether it succeeded or not: a state cookie that +// outlives its round trip is a replay waiting for an accident. +func (c Cookies) ClearLogin(w http.ResponseWriter) { c.set(w, c.LoginName(), "", -time.Second) } + +func (c Cookies) set(w http.ResponseWriter, name, value string, ttl time.Duration) { + maxAge := int(ttl.Seconds()) + if ttl < 0 { + maxAge = -1 + } + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: !c.Insecure, + SameSite: http.SameSiteLaxMode, + }) +} diff --git a/platform/internal/auth/csrf.go b/platform/internal/auth/csrf.go index 98a78977..d35a5dc6 100644 --- a/platform/internal/auth/csrf.go +++ b/platform/internal/auth/csrf.go @@ -24,9 +24,8 @@ const ClientHeader = "X-TM-Client" // preflight. A plain form cannot set a custom header; a fetch() from our own origin can. // // trustedOrigins are additional origins allowed to make unsafe requests (a separately deployed -// frontend). Empty means same-origin only. deny writes the 403 body — injected for the same reason -// as Authenticator.Deny: the error shape belongs to the API layer. -func CSRF(trustedOrigins []string, deny http.Handler) (func(http.Handler) http.Handler, error) { +// frontend); empty means same-origin only. +func CSRF(trustedOrigins []string, cookieName string, deny http.Handler) (func(http.Handler) http.Handler, error) { p := http.NewCrossOriginProtection() p.SetDenyHandler(deny) for _, o := range trustedOrigins { @@ -36,7 +35,7 @@ func CSRF(trustedOrigins []string, deny http.Handler) (func(http.Handler) http.H } return func(next http.Handler) http.Handler { return p.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if cookieUnsafe(r) && r.Header.Get(ClientHeader) == "" { + if cookieUnsafe(r, cookieName) && r.Header.Get(ClientHeader) == "" { deny.ServeHTTP(w, r) return } @@ -48,14 +47,21 @@ func CSRF(trustedOrigins []string, deny http.Handler) (func(http.Handler) http.H // cookieUnsafe reports a state-changing request presented by cookie. It reads the cookie directly // rather than the principal: this check runs BEFORE authentication, so that a forged request is // refused without touching the session table. -func cookieUnsafe(r *http.Request) bool { +func cookieUnsafe(r *http.Request, cookieName string) bool { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: return false } - if r.Header.Get("Authorization") != "" { + // A well-formed Bearer is exempt. Present PARSES it; it does not validate it — the session + // lookup does that, later — so `Bearer ` gets the exemption too. That is safe, and + // the reason is worth naming because it is not the parsing: once an Authorization header is + // present, Present NEVER falls back to the cookie, so such a request authenticates as nothing + // and ends in 401. It cannot trade the CSRF check for the cookie's authority; it can only give + // up its own. Testing for a merely non-empty header would be weaker still — `Authorization: x` + // would let the caller pick which layer applies (PD-33, PD-50). + if _, _, ok := Present(r, ""); ok && r.Header.Get("Authorization") != "" { return false } - c, err := r.Cookie(CookieName) + c, err := r.Cookie(cookieName) return err == nil && c.Value != "" } diff --git a/platform/internal/auth/csrf_test.go b/platform/internal/auth/csrf_test.go index 5ef1deec..4ac59903 100644 --- a/platform/internal/auth/csrf_test.go +++ b/platform/internal/auth/csrf_test.go @@ -9,7 +9,7 @@ import ( func csrfChain(t *testing.T, trusted ...string) (http.Handler, *bool) { t.Helper() passed := false - mw, err := CSRF(trusted, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mw, err := CSRF(trusted, CookieName, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) })) if err != nil { @@ -65,7 +65,7 @@ func TestCSRF(t *testing.T) { } func TestCSRFRejectsAMalformedTrustedOrigin(t *testing.T) { - if _, err := CSRF([]string{"app.example.org"}, http.NotFoundHandler()); err == nil { + if _, err := CSRF([]string{"app.example.org"}, CookieName, http.NotFoundHandler()); err == nil { t.Fatal("an origin without a scheme must be refused at boot, not at request time") } } diff --git a/platform/internal/auth/middleware.go b/platform/internal/auth/middleware.go index 4727b644..3f27c12a 100644 --- a/platform/internal/auth/middleware.go +++ b/platform/internal/auth/middleware.go @@ -1,6 +1,8 @@ package auth import ( + "errors" + "log/slog" "net/http" "strings" "time" @@ -12,17 +14,23 @@ import ( type Authenticator struct { Sessions SessionStore IdleTTL time.Duration + // Cookies decides which cookie name the browser presents. Its zero value is the production + // profile (__Host-). + Cookies Cookies // Now is injectable so expiry is testable without sleeping. Now func() time.Time // Deny writes the 401 body. Injected because the error shape belongs to the API layer // (problem+json), and auth must not depend on it. Deny http.Handler + // Log receives store failures. Nil is allowed (tests), and then they are silent — which is + // exactly the state PD-5 named as a defect, so production wiring passes a logger. + Log *slog.Logger } // Require rejects anything that does not carry a live session. func (a *Authenticator) Require(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, via, ok := present(r) + token, via, ok := Present(r, a.Cookies.SessionName()) // No store means no session can be proven, which is a denial and not a crash: the service // is allowed to run without a database (readiness says so), and a caller who presents a // token must get the same 401 as a caller who presents none. @@ -36,19 +44,57 @@ func (a *Authenticator) Require(next http.Handler) http.Handler { if err != nil { // A store failure denies exactly like an unknown token: an authenticated caller is // what a store failure cannot prove, and a distinguishable answer is an oracle. + // The WIRE cannot tell the two apart; the LOG must, or an authentication outage looks + // like a storm of ordinary 401s and nobody is paged (PD-5). + if !errors.Is(err, ErrNoSession) { + a.logf(r, "session lookup failed", err) + } a.Deny.ServeHTTP(w, r) return } // Slide the idle window only in its second half. Sliding on every request would turn every // read into a write, and the session table is on the hot path of every call. - if a.IdleTTL > 0 && s.IdleExpiresAt.Sub(now) < a.IdleTTL/2 { - _ = a.Sessions.Touch(r.Context(), digest, now, a.IdleTTL) + // + // The second condition is what makes the first one true. Touch clamps the new deadline with + // least(now+IdleTTL, absolute_expires_at), so once the idle deadline has reached the absolute + // ceiling it cannot move again — and "less than half a window left" then latches ON for the + // whole last IdleTTL/2 of the session's life, turning every authenticated request into an + // UPDATE on the session row and a Set-Cookie. Found by review. + if a.IdleTTL > 0 && s.IdleExpiresAt.Sub(now) < a.IdleTTL/2 && s.IdleExpiresAt.Before(s.AbsoluteExpiresAt) { + // A failed slide is not a failed request — the session is live either way — but it is + // not nothing either: it means the session table is unwritable. + if err := a.Sessions.Touch(r.Context(), digest, now, a.IdleTTL); err != nil { + a.logf(r, "session touch failed", err) + } else if via == ViaCookie { + // The BROWSER's clock has to slide with the row's. Max-Age is written once, at login, + // and nothing else re-issues the cookie: without this the cookie expires a fixed + // idle-TTL after sign-in no matter how much the session was used, so a daily user is + // signed out on schedule while their session row is still live, and the absolute + // ceiling is never the thing that ends a session. A Bearer holder keeps its own token + // and needs nothing. + // + // Capped at what is left of the ABSOLUTE window, which is the same rule the login + // callback follows for the opposite reason: a cookie that outlives the session it + // names turns every request after the ceiling into a 401 instead of a clean + // signed-out state. + if ttl := min(a.IdleTTL, s.AbsoluteExpiresAt.Sub(now)); ttl > 0 { + a.Cookies.SetSession(w, token, ttl) + } + } } ctx := withPrincipal(r.Context(), Principal{UserID: s.UserID, Via: via}) next.ServeHTTP(w, r.WithContext(ctx)) }) } +// logf reports a store failure. No token, no digest, no raw path: the request id correlates it. +func (a *Authenticator) logf(r *http.Request, msg string, err error) { + if a.Log == nil { + return + } + a.Log.ErrorContext(r.Context(), msg, "err", err, "method", r.Method) +} + func (a *Authenticator) now() time.Time { if a.Now != nil { return a.Now() @@ -56,9 +102,13 @@ func (a *Authenticator) now() time.Time { return time.Now() } -// present extracts the token. Bearer wins over the cookie when both arrive: an explicit credential -// beats an ambient one, and it keeps a stray cookie from deciding the CSRF path for an API client. -func present(r *http.Request) (token string, via Presentation, ok bool) { +// Present extracts a token from a request without authenticating it. Bearer wins over the cookie +// when both arrive: an explicit credential beats an ambient one, and it keeps a stray cookie from +// deciding the CSRF path for an API client. +// +// Exported because the login flow needs the same reading to revoke the session a browser carried +// into a sign-in, and a second copy of this is a second answer to "what is this request presenting". +func Present(r *http.Request, cookieName string) (token string, via Presentation, ok bool) { if h := r.Header.Get("Authorization"); h != "" { scheme, value, found := strings.Cut(h, " ") if !found || !strings.EqualFold(scheme, "Bearer") || value == "" { @@ -66,7 +116,7 @@ func present(r *http.Request) (token string, via Presentation, ok bool) { } return value, ViaBearer, true } - c, err := r.Cookie(CookieName) + c, err := r.Cookie(cookieName) if err != nil || c.Value == "" { return "", "", false } diff --git a/platform/internal/auth/middleware_test.go b/platform/internal/auth/middleware_test.go index 18c62e21..2c80f55a 100644 --- a/platform/internal/auth/middleware_test.go +++ b/platform/internal/auth/middleware_test.go @@ -9,23 +9,19 @@ import ( ) type fakeStore struct { - session Session - err error - lookups int - digest []byte - touched int - touchTTL time.Duration + session Session + err error + digest []byte + touched int } func (f *fakeStore) Lookup(_ context.Context, digest []byte, _ time.Time) (Session, error) { - f.lookups++ f.digest = digest return f.session, f.err } -func (f *fakeStore) Touch(_ context.Context, _ []byte, _ time.Time, ttl time.Duration) error { +func (f *fakeStore) Touch(_ context.Context, _ []byte, _ time.Time, _ time.Duration) error { f.touched++ - f.touchTTL = ttl return nil } @@ -124,13 +120,39 @@ func TestNoStoreDeniesInsteadOfPanicking(t *testing.T) { } } -func TestBearerWinsOverCookie(t *testing.T) { - r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) - r.AddCookie(&http.Cookie{Name: CookieName, Value: "cookie-token"}) - r.Header.Set("Authorization", "Bearer bearer-token") - token, via, ok := present(r) - if !ok || token != "bearer-token" || via != ViaBearer { - t.Fatalf("present() = %q %q %v", token, via, ok) +// An Authorization header, in ANY shape, takes the cookie out of play. This is what makes the CSRF +// exemption for Bearer requests safe (PD-50): a caller who forges the exemption with a nonsense +// Bearer authenticates as nothing rather than borrowing the cookie's authority. +// Mutation caught: falling through to the cookie when the header does not parse. +func TestAnAuthorizationHeaderTakesTheCookieOutOfPlay(t *testing.T) { + for name, tc := range map[string]struct { + header string + want string // "" means no credential at all + }{ + "a well-formed bearer wins": {"Bearer bearer-token", "bearer-token"}, + "a nonsense bearer is not the cookie": {"Bearer garbage", "garbage"}, + "another scheme is not the cookie": {"Basic dXNlcjpwdw==", ""}, + "a bare word is not the cookie": {"x", ""}, + "an empty bearer value is not the cookie": {"Bearer ", ""}, + } { + t.Run(name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) + r.AddCookie(&http.Cookie{Name: CookieName, Value: "cookie-token"}) + r.Header.Set("Authorization", tc.header) + token, via, ok := Present(r, CookieName) + if token == "cookie-token" || via == ViaCookie { + t.Fatalf("the cookie authenticated a request carrying %q: the CSRF exemption would hand it the cookie's authority", tc.header) + } + if tc.want == "" { + if ok { + t.Fatalf("present() = %q %q %v, want no credential", token, via, ok) + } + return + } + if !ok || token != tc.want || via != ViaBearer { + t.Fatalf("present() = %q %q %v, want %q via bearer", token, via, ok, tc.want) + } + }) } } @@ -181,3 +203,107 @@ func TestTokensAreUniqueAndDigestIsStable(t *testing.T) { } } } + +// The browser's clock has to slide with the session row's. Max-Age is written once, at login, and +// nothing else re-issues the cookie — so without a refresh here the cookie dies a fixed idle-TTL +// after sign-in however much the session is used, a daily user is signed out on schedule while the +// row is still live, and the absolute ceiling never gets to be what ends a session. Found by review. +// Mutation caught: dropping the SetSession call, or issuing it on the Bearer path. +func TestSlidingTheIdleWindowRefreshesTheBrowsersCookie(t *testing.T) { + now := time.Now() + cookie := func(r *http.Request) { r.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) } + bearer := func(r *http.Request) { r.Header.Set("Authorization", "Bearer tok") } + for name, tc := range map[string]struct { + remaining time.Duration // of the idle window + absolute time.Duration // of the absolute window + present func(*http.Request) + wantSet bool + wantMaxAge int // 0 means "the full idle TTL" + }{ + "cookie in the second half is refreshed": {10 * time.Minute, 24 * time.Hour, cookie, true, 0}, + "cookie still fresh is left alone": {50 * time.Minute, 24 * time.Hour, cookie, false, 0}, + "a bearer holder keeps its own token": {10 * time.Minute, 24 * time.Hour, bearer, false, 0}, + // The cap. Without it the refreshed cookie outlives the session it names, and every request + // after the ceiling is a 401 instead of a clean signed-out state. + "the refresh never outlives the absolute window": {10 * time.Minute, 20 * time.Minute, cookie, true, 20 * 60}, + } { + t.Run(name, func(t *testing.T) { + store := &fakeStore{session: Session{ + UserID: "u1", + IdleExpiresAt: now.Add(tc.remaining), + AbsoluteExpiresAt: now.Add(tc.absolute), + }} + a, _ := newAuth(store, now) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) + tc.present(r) + a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(w, r) + + var got *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == CookieName { + got = c + } + } + if !tc.wantSet { + if got != nil { + t.Fatalf("an unnecessary Set-Cookie was written: %+v", got) + } + return + } + if got == nil { + t.Fatal("the idle window slid on the server and the cookie was not re-issued: the browser still expires at its login-time Max-Age") + } + wantMaxAge := tc.wantMaxAge + if wantMaxAge == 0 { + wantMaxAge = int(a.IdleTTL.Seconds()) + } + if got.MaxAge != wantMaxAge { + t.Fatalf("refreshed cookie Max-Age = %d, want %d: a cookie that outlives its session turns the next request into a 401 instead of a signed-out state", + got.MaxAge, wantMaxAge) + } + if got.Value != "tok" { + t.Fatalf("the refresh changed the token to %q: rotation is a login-boundary act, not a slide", got.Value) + } + }) + } +} + +// The slide must stop once it can no longer move anything. pgstore.Touch clamps the new idle +// deadline with least(now+IdleTTL, absolute_expires_at), so a session inside the last IdleTTL of its +// absolute window has an idle deadline pinned to the ceiling — and "less than half a window left" +// then stays true for every subsequent request. Without the guard that is an UPDATE on the session +// row plus a Set-Cookie on EVERY authenticated call, on the hot path, for the last stretch of every +// long-lived session. Found by review. Mutation caught: dropping the Before(AbsoluteExpiresAt) test. +func TestTheSlideStopsOnceItCannotMoveTheDeadline(t *testing.T) { + now := time.Now() + // The shape Touch leaves behind: idle pinned to the absolute ceiling, well inside IdleTTL/2. + store := &fakeStore{session: Session{ + UserID: "u1", + IdleExpiresAt: now.Add(5 * time.Minute), + AbsoluteExpiresAt: now.Add(5 * time.Minute), + }} + a, _ := newAuth(store, now) + h := a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + for range 5 { + r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) + r.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) + h.ServeHTTP(httptest.NewRecorder(), r) + } + if store.touched != 0 { + t.Fatalf("%d writes for 5 reads: the slide latched on a deadline it cannot move", store.touched) + } + // And a session that still has room continues to slide, so the guard did not disable sliding. + store2 := &fakeStore{session: Session{ + UserID: "u1", + IdleExpiresAt: now.Add(5 * time.Minute), + AbsoluteExpiresAt: now.Add(24 * time.Hour), + }} + b, _ := newAuth(store2, now) + r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) + r.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) + b.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(httptest.NewRecorder(), r) + if store2.touched != 1 { + t.Fatalf("a session with room to slide was not slid: touches = %d", store2.touched) + } +} diff --git a/platform/internal/auth/session.go b/platform/internal/auth/session.go index ece4937a..d74cccbf 100644 --- a/platform/internal/auth/session.go +++ b/platform/internal/auth/session.go @@ -41,10 +41,8 @@ type SessionStore interface { } // NewToken mints a credential. The plaintext exists only in this return value and in the client: -// what reaches the database is Digest(token). -// -// No error return: crypto/rand.Read "never returns an error, and always fills b entirely" — it -// crashes the program instead. An error path here would be dead code pretending to be a check. +// what reaches the database is Digest(token). No error return — crypto/rand.Read never fails, it +// crashes the program instead. func NewToken() string { b := make([]byte, tokenBytes) rand.Read(b) diff --git a/platform/internal/config/config.go b/platform/internal/config/config.go index d65f804e..082e4791 100644 --- a/platform/internal/config/config.go +++ b/platform/internal/config/config.go @@ -2,10 +2,14 @@ package config import ( + "errors" "fmt" "os" + "strconv" "strings" "time" + + "textmachine/platform/internal/money" ) // Config is the whole configuration surface. Environment only: a control plane is deployed, not @@ -21,22 +25,52 @@ type Config struct { // TrustedOrigins are origins besides our own that may make unsafe requests. TrustedOrigins []string // SessionIdleTTL is how long a session survives without use; SessionMaxAge is the ceiling no - // amount of use can extend. + // amount of use can extend. Both are policy, and the policy — the two values, the concurrent + // session rule and what our session does when the provider's ends — is written down in + // STACK_DECISIONS §13, because ASVS 5.0 7.1.1 asks for the document, not the number. SessionIdleTTL time.Duration SessionMaxAge time.Duration // Migrate applies pending migrations at boot. Off by default: a rollout should migrate once, // deliberately, not once per replica. Migrate bool + // InsecureCookies serves the session over plain HTTP under a different cookie name. A DEV + // switch: __Host- requires Secure, so localhost cannot use the production name at all (PD-8). + InsecureCookies bool + // OIDC is the sign-in provider. Empty issuer means no login surface is mounted — the service + // still runs, which is what keeps a bare `go run` useful. + // OIDCProvider is OUR name for the issuer and the first half of the identity key. It is + // configured next to the issuer because the two must move together: pointing the issuer at a + // different IdP while keeping the name would file that IdP's subjects under the old provider — + // the silent account-linking the identity model exists to prevent. + OIDCProvider string + OIDCIssuer string + OIDCClientID string + OIDCClientSecret string + OIDCRedirectURL string + // AfterLogin is where a completed sign-in lands. + AfterLogin string + // SignupGrantMicroUSD is the credit a new account is created with (the free tier, owner 05.08: + // default five dollars, settable per account by granting a different amount). + SignupGrantMicroUSD int64 } // Load reads the environment. func Load() (Config, error) { c := Config{ - Addr: env("TM_PLATFORM_ADDR", "127.0.0.1:8080"), - DSN: os.Getenv("TM_PLATFORM_DSN"), - SessionIdleTTL: 14 * 24 * time.Hour, - SessionMaxAge: 90 * 24 * time.Hour, - Migrate: os.Getenv("TM_PLATFORM_MIGRATE") == "1", + Addr: env("TM_PLATFORM_ADDR", "127.0.0.1:8080"), + DSN: "", // read below: it may come from a file + // 14 days idle, 30 days absolute. The absolute one is the NIST SP 800-63B-4 AAL1 figure + // ("SHOULD be no more than 30 days"), not a preference: it was 90 days, and a deviation from + // a SHOULD needs a reason that survives inspection, which that one did not (PD-58, §13). + SessionIdleTTL: 14 * 24 * time.Hour, + SessionMaxAge: 30 * 24 * time.Hour, + OIDCProvider: env("TM_PLATFORM_OIDC_PROVIDER", "google"), + OIDCIssuer: os.Getenv("TM_PLATFORM_OIDC_ISSUER"), + OIDCClientID: os.Getenv("TM_PLATFORM_OIDC_CLIENT_ID"), + OIDCClientSecret: "", // read below: it may come from a file + OIDCRedirectURL: os.Getenv("TM_PLATFORM_OIDC_REDIRECT_URL"), + AfterLogin: env("TM_PLATFORM_AFTER_LOGIN", "/"), + SignupGrantMicroUSD: 5 * 1_000_000, } if raw := os.Getenv("TM_PLATFORM_TRUSTED_ORIGINS"); raw != "" { for _, o := range strings.Split(raw, ",") { @@ -46,6 +80,18 @@ func Load() (Config, error) { } } var err error + if c.Migrate, err = boolean("TM_PLATFORM_MIGRATE"); err != nil { + return Config{}, err + } + if c.InsecureCookies, err = boolean("TM_PLATFORM_INSECURE_COOKIES"); err != nil { + return Config{}, err + } + if c.DSN, err = secret("TM_PLATFORM_DSN"); err != nil { + return Config{}, err + } + if c.OIDCClientSecret, err = secret("TM_PLATFORM_OIDC_CLIENT_SECRET"); err != nil { + return Config{}, err + } if c.SessionIdleTTL, err = duration("TM_PLATFORM_SESSION_IDLE", c.SessionIdleTTL); err != nil { return Config{}, err } @@ -55,9 +101,69 @@ func Load() (Config, error) { if c.SessionIdleTTL > c.SessionMaxAge { return Config{}, fmt.Errorf("config: session idle TTL %s exceeds max age %s", c.SessionIdleTTL, c.SessionMaxAge) } + if raw := os.Getenv("TM_PLATFORM_SIGNUP_GRANT_USD"); raw != "" { + v, err := money.ParseUSD(raw) + if err != nil { + return Config{}, fmt.Errorf("config: TM_PLATFORM_SIGNUP_GRANT_USD: %w", err) + } + if v < 0 { + return Config{}, errors.New("config: TM_PLATFORM_SIGNUP_GRANT_USD cannot be negative") + } + c.SignupGrantMicroUSD = int64(v) + } + // Half a login configuration is worse than none: the surface would mount and fail at the first + // click instead of at boot, where an operator is watching. + oidc := []string{c.OIDCIssuer, c.OIDCClientID, c.OIDCClientSecret, c.OIDCRedirectURL} + set := 0 + for _, v := range oidc { + if v != "" { + set++ + } + } + if set != 0 && set != len(oidc) { + return Config{}, errors.New("config: OIDC needs all of TM_PLATFORM_OIDC_ISSUER, _CLIENT_ID, _CLIENT_SECRET, _REDIRECT_URL, or none") + } return c, nil } +// LoginEnabled reports whether a sign-in provider is configured. +func (c Config) LoginEnabled() bool { return c.OIDCIssuer != "" } + +// Secret is the shared way to read a credential: from KEY, or preferably from the file named by +// KEY_FILE. Exported so the admin CLI reads the DSN the same way the daemon does — an operator who +// followed the deploy notes has it in a file, not in the environment. +func Secret(key string) (string, error) { return secret(key) } + +// secret reads a value from KEY, or — preferably — from the file named by KEY_FILE. A secret in a +// file does not show up in /proc//environ, is not inherited by child processes, and is exactly +// what systemd's LoadCredential= hands over (deploy/tmplatformd.service). +func secret(key string) (string, error) { + if path := os.Getenv(key + "_FILE"); path != "" { + b, err := os.ReadFile(path) + if err != nil { + // The path, never the content: an unreadable secret file is an operator's problem and + // the error goes to the log. + return "", fmt.Errorf("config: %s_FILE: %w", key, err) + } + return strings.TrimSpace(string(b)), nil + } + return os.Getenv(key), nil +} + +// boolean reads a flag. strconv.ParseBool rather than a comparison with "1": an operator who wrote +// `true` deserves an error or the truth, not a silent no. +func boolean(key string) (bool, error) { + raw := os.Getenv(key) + if raw == "" { + return false, nil + } + v, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("config: %s: %q is not a boolean", key, raw) + } + return v, nil +} + func env(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/platform/internal/config/config_test.go b/platform/internal/config/config_test.go index 50334677..fc375841 100644 --- a/platform/internal/config/config_test.go +++ b/platform/internal/config/config_test.go @@ -1,7 +1,11 @@ package config import ( + "os" + "path/filepath" + "testing" + "textmachine/platform/internal/money" "time" ) @@ -46,3 +50,83 @@ func TestMalformedDurationIsRefused(t *testing.T) { t.Fatal("want a parse error") } } + +// A secret read from a file never enters the process environment, which is where a credential +// leaks from first (child processes inherit it, /proc exposes it). systemd hands one over this way. +func TestSecretsCanComeFromFiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "dsn") + if err := os.WriteFile(path, []byte("postgres://u:p@h/db\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("TM_PLATFORM_DSN_FILE", path) + c, err := Load() + if err != nil { + t.Fatal(err) + } + if c.DSN != "postgres://u:p@h/db" { + t.Fatalf("DSN = %q (trailing newline must be trimmed)", c.DSN) + } + + t.Setenv("TM_PLATFORM_DSN_FILE", filepath.Join(t.TempDir(), "absent")) + if _, err := Load(); err == nil { + t.Fatal("a named secret file that cannot be read must fail at boot, not at the first query") + } +} + +// Half a login configuration mounts a surface that fails at the first click instead of at boot. +func TestPartialOIDCConfigurationIsRefused(t *testing.T) { + t.Setenv("TM_PLATFORM_OIDC_ISSUER", "https://accounts.google.com") + t.Setenv("TM_PLATFORM_OIDC_CLIENT_ID", "id") + if _, err := Load(); err == nil { + t.Fatal("an issuer without a secret and a redirect must be refused") + } + t.Setenv("TM_PLATFORM_OIDC_CLIENT_SECRET", "s") + t.Setenv("TM_PLATFORM_OIDC_REDIRECT_URL", "https://app.example.org/auth/callback") + c, err := Load() + if err != nil { + t.Fatal(err) + } + if !c.LoginEnabled() { + t.Fatal("a complete configuration must enable sign-in") + } +} + +// The free tier is a number an operator sets, and a bad one must not become a silent zero. +func TestSignupGrantIsParsedNotGuessed(t *testing.T) { + if c, err := Load(); err != nil || c.SignupGrantMicroUSD != 5*money.PerUSD { + t.Fatalf("default grant = %d (%v)", c.SignupGrantMicroUSD, err) + } + t.Setenv("TM_PLATFORM_SIGNUP_GRANT_USD", "2.50") + c, err := Load() + if err != nil { + t.Fatal(err) + } + if c.SignupGrantMicroUSD != 2_500_000 { + t.Fatalf("grant = %d", c.SignupGrantMicroUSD) + } + t.Setenv("TM_PLATFORM_SIGNUP_GRANT_USD", "five dollars") + if _, err := Load(); err == nil { + t.Fatal("an unparseable grant must fail at boot") + } +} + +// PD-58. The session clocks are a declared conformance point, not a preference: ASVS 5.0 7.1.1 +// takes the baseline from NIST SP 800-63B-4, whose AAL1 rule is that the overall reauthentication +// timeout SHOULD be no more than 30 days. The reasoning lives in STACK_DECISIONS §13; this keeps +// the defaults from drifting past it without someone changing that document too. +// Mutation caught: raising either default beyond the norm. +func TestSessionClocksStayWithinTheDeclaredBaseline(t *testing.T) { + c, err := Load() + if err != nil { + t.Fatal(err) + } + const aal1Overall = 30 * 24 * time.Hour + if c.SessionMaxAge > aal1Overall { + t.Errorf("absolute session lifetime is %s, above the NIST SP 800-63B-4 AAL1 figure of %s: a deviation needs a written justification (ASVS 7.1.1)", + c.SessionMaxAge, aal1Overall) + } + if c.SessionIdleTTL <= 0 || c.SessionIdleTTL > c.SessionMaxAge { + t.Errorf("idle window is %s against an absolute of %s: an idle window that cannot expire first is not one", + c.SessionIdleTTL, c.SessionMaxAge) + } +} diff --git a/platform/internal/httpapi/middleware.go b/platform/internal/httpapi/middleware.go index caab662a..f9d6252f 100644 --- a/platform/internal/httpapi/middleware.go +++ b/platform/internal/httpapi/middleware.go @@ -1,50 +1,57 @@ package httpapi import ( - "context" - "crypto/rand" - "encoding/base32" "log/slog" "net/http" + "runtime/debug" "time" ) -type requestIDKey struct{} +// DefaultMaxBody caps a request body on the versioned surface. Every contract route today carries +// JSON of a few kilobytes; the route that will carry a book file registers its own, larger limit +// rather than raising this one for everybody. +const DefaultMaxBody = 1 << 20 -// RequestID stamps every request. The id is ours, never the client's: an id echoed from a header -// lets a caller poison our logs and correlate other users' lines. -func RequestID(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var b [10]byte - // crypto/rand.Read never returns an error; it crashes the program instead. - rand.Read(b[:]) - id := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:]) - w.Header().Set("X-Request-Id", id) - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id))) - }) +// LimitBody puts an http.MaxBytesReader on every request of the subtree it wraps. Applied here +// rather than per handler because a handler added later would not know the rule (ASVS input +// limits; the second half of PD-2). +func LimitBody(n int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, n) + } + next.ServeHTTP(w, r) + }) + } } -// RequestIDOf returns the id stamped by RequestID, or "". -func RequestIDOf(ctx context.Context) string { - id, _ := ctx.Value(requestIDKey{}).(string) - return id -} - -// SecurityHeaders applies the two product invariants to every response. +// SecurityHeaders applies the product invariants to every response, here rather than per handler +// because a handler added later would not know the rule. // -// PT-34: not one byte of a user's translation may reach an indexable URL — noindex is set here, -// once, rather than per handler, because a handler added later would not know the rule. -// Cache-Control: no-store is blanket for the same reason: the contract mandates it for responses -// carrying translated text, and a private API has nothing worth caching in a shared cache. -func SecurityHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h := w.Header() - h.Set("X-Robots-Tag", "noindex, nofollow") - h.Set("Cache-Control", "no-store") - h.Set("X-Content-Type-Options", "nosniff") - h.Set("Referrer-Policy", "no-referrer") - next.ServeHTTP(w, r) - }) +// PT-34: not one byte of a user's translation may reach an indexable URL. +func SecurityHeaders(hsts bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Robots-Tag", "noindex, nofollow") + h.Set("Cache-Control", "no-store") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + // This service answers with JSON and redirects, never with a document worth embedding. + // frame-ancestors is what stops /auth/* being framed; X-Frame-Options is its ancestor + // for clients that predate CSP. + h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + h.Set("X-Frame-Options", "DENY") + if hsts { + // The __Host- prefix protects the WRITE of a cookie, not the first navigation: + // without HSTS a plain-http first request is downgradable. Off in the dev profile, + // where a pinned https policy for localhost would be a lasting mistake. + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + next.ServeHTTP(w, r) + }) + } } // Recover turns a panic into a 500 instead of a dropped connection. @@ -53,8 +60,11 @@ func Recover(log *slog.Logger) func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if v := recover(); v != nil { + // route, not r.URL.Path (PD-3). log.ErrorContext(r.Context(), "panic in handler", - "panic", v, "path", r.URL.Path, "request_id", RequestIDOf(r.Context())) + "panic", v, "method", r.Method, "route", routeOf(r), + // Without the stack, "panic: runtime error" plus a route is not a lead. + "stack", string(debug.Stack())) WriteProblem(w, http.StatusInternalServerError, "Internal error", "") } }() @@ -64,7 +74,8 @@ func Recover(log *slog.Logger) func(http.Handler) http.Handler { } // AccessLog writes one INFO line per request. Deliberately absent: money (D39.84 and the norm of -// the P0 prompt — costs do not reach INFO), request bodies and any user text. +// the P0 prompt — costs do not reach INFO), request bodies and any user text. The request id is +// added by the log handler (reqid.WithContext), not by hand. func AccessLog(log *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -78,8 +89,7 @@ func AccessLog(log *slog.Logger) func(http.Handler) http.Handler { // fills Pattern in place, so it is readable here even though the mux ran inside. "route", routeOf(r), "status", rec.status, - "ms", time.Since(start).Milliseconds(), - "request_id", RequestIDOf(r.Context())) + "ms", time.Since(start).Milliseconds()) }) } } @@ -92,7 +102,8 @@ func routeOf(r *http.Request) string { } // statusRecorder captures the status code. Unwrap keeps http.ResponseController working through -// the wrapper — that is how a later SSE handler will reach Flush. +// the wrapper — that is how an SSE handler reaches Flush. NOT how it clears a read deadline: doing +// that by hand re-creates PD-2 on a half-fed request and is forbidden (STACK_DECISIONS §12). type statusRecorder struct { http.ResponseWriter status int diff --git a/platform/internal/httpapi/serve.go b/platform/internal/httpapi/serve.go new file mode 100644 index 00000000..4d22c939 --- /dev/null +++ b/platform/internal/httpapi/serve.go @@ -0,0 +1,132 @@ +package httpapi + +import ( + "context" + "errors" + "log/slog" + "net" + "net/http" + "time" +) + +// Timeouts of the listening server. A named type rather than literals inside main: the test floor +// asserts this class of defect on a REAL server, and a *http.Server built inside func main is not +// reachable from a test (PD-2 survived P0 for exactly that reason). +type Timeouts struct { + ReadHeader time.Duration + // Read bounds the WHOLE request, body included. Without it a client can pin a connection + // forever, and it does not need a body-reading handler to do it: net/http drains an unread + // body inside chunkWriter.writeHeader, before the response goes out, and that read inherits + // the connection deadline. + // + // It does NOT bound a long RESPONSE, and a streaming handler needs nothing from it: net/http + // clears the deadline itself once the request has arrived — before the handler when nothing + // remains to read, at body EOF otherwise (server.go:2059-2062) — and nothing re-arms it while + // the handler runs. Nor may a handler clear it by hand: on a half-fed request that drain is + // the only bound left, and clearing the deadline before the header write hangs the handler + // inside WriteHeader for as long as the client keeps the socket. Measured both ways (PD-51). + Read time.Duration + Idle time.Duration + // Shutdown is how long a drain may take before in-flight handlers lose their context. + Shutdown time.Duration +} + +// DefaultTimeouts is what the daemon runs with. +// +// No WriteTimeout: the SSE stream is a long-lived response and a write deadline set here would cut +// it. Read is deliberately short — a request that legitimately takes longer than this to ARRIVE is +// an upload, and an upload extends its own deadline as it makes progress. +func DefaultTimeouts() Timeouts { + return Timeouts{ + ReadHeader: 10 * time.Second, + Read: 30 * time.Second, + Idle: 2 * time.Minute, + Shutdown: 15 * time.Second, + } +} + +// Server owns the listener lifecycle: serve, then drain, then cancel. +type Server struct { + http *http.Server + stopBase context.CancelFunc + grace time.Duration + log *slog.Logger +} + +// NewServer configures the listening server the daemon runs. The handler is whatever New returned. +// +// It takes no Timeouts on purpose. When it did, main passed DefaultTimeouts() and every test passed +// its own, so the one call that decided what SHIPPED was the one nothing observed — and replacing it +// with a bare Timeouts{} left the whole battery green while the binary re-acquired PD-2 (measured). +// With nothing to pass there is nothing to get wrong, and the test floor asserts on this very +// constructor. Tests that need short deadlines use serverWithTimeouts. +func NewServer(addr string, h http.Handler, log *slog.Logger) *Server { + return serverWithTimeouts(addr, h, log, DefaultTimeouts()) +} + +func serverWithTimeouts(addr string, h http.Handler, log *slog.Logger, t Timeouts) *Server { + // The base context is NOT the signal context (PD-9): a signal must start the drain, not end + // every in-flight request at once. It is cancelled after Shutdown returns, which is the point + // where the grace period is spent and a still-running handler is one we no longer wait for. + base, cancel := context.WithCancel(context.Background()) + return &Server{ + http: &http.Server{ + Addr: addr, + Handler: h, + ReadHeaderTimeout: t.ReadHeader, + ReadTimeout: t.Read, + IdleTimeout: t.Idle, + MaxHeaderBytes: 1 << 16, + BaseContext: func(net.Listener) context.Context { return base }, + // net/http's own errors (bad TLS records, malformed requests) reach slog instead of + // the default logger's stderr, where nothing structured would find them. + ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn), + }, + stopBase: cancel, + grace: t.Shutdown, + log: log, + } +} + +// Listen opens the configured address. Separate from Run so that a caller — the daemon, a test — +// knows the port is bound (and, with :0, which one) before anything is served on it. +func (s *Server) Listen(ctx context.Context) (net.Listener, error) { + var lc net.ListenConfig + return lc.Listen(ctx, "tcp", s.http.Addr) +} + +// Run serves until ctx is cancelled, then drains for the grace period. Returns nil on a clean stop. +func (s *Server) Run(ctx context.Context, ln net.Listener) error { + errc := make(chan error, 1) + go func() { errc <- s.http.Serve(ln) }() + + select { + case err := <-errc: + s.stopBase() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-ctx.Done(): + } + + s.log.Info("shutting down", "grace_seconds", int(s.grace.Seconds())) + started := time.Now() + shutdownCtx, cancel := context.WithTimeout(context.Background(), s.grace) + defer cancel() + err := s.http.Shutdown(shutdownCtx) + s.stopBase() + if errors.Is(err, context.DeadlineExceeded) { + // A drain that ran out of time is a slow request, not a failed service. Returning the error + // makes the process exit non-zero, and under Restart=on-failure an ordinary stop then reads + // to systemd as a crash. + s.log.Warn("stopped: drain deadline exceeded, in-flight requests were cancelled", + "after_ms", time.Since(started).Milliseconds()) + return nil + } + if err != nil { + return err + } + s.log.Info("stopped", "drained_ms", time.Since(started).Milliseconds()) + return nil +} diff --git a/platform/internal/httpapi/serve_test.go b/platform/internal/httpapi/serve_test.go new file mode 100644 index 00000000..172f694b --- /dev/null +++ b/platform/internal/httpapi/serve_test.go @@ -0,0 +1,290 @@ +package httpapi + +import ( + "bufio" + "context" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "strings" + "testing" + "time" +) + +// start runs a REAL http.Server, the same one main builds, on a loopback port. Everything below +// needs that: the defects this file pins live in the server's connection handling, and a mux under +// httptest never touches it. +func start(t *testing.T, h http.Handler, to Timeouts) net.Listener { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + srv := serverWithTimeouts("127.0.0.1:0", h, quietLogger(), to) + ln, err := srv.Listen(ctx) + if err != nil { + cancel() + t.Fatalf("listen: %v", err) + } + done := make(chan error, 1) + go func() { done <- srv.Run(ctx, ln) }() + t.Cleanup(func() { + cancel() + select { + case err := <-done: + if err != nil { + t.Errorf("run: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("server did not stop") + } + }) + return ln +} + +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func fastTimeouts() Timeouts { + return Timeouts{ReadHeader: time.Second, Read: 250 * time.Millisecond, Idle: time.Second, Shutdown: 3 * time.Second} +} + +// PD-2. A client that announces a body and then stops sending pins the connection: net/http drains +// the unread body inside the response's header write, and that read inherits the connection +// deadline. With no ReadTimeout the server waits forever and the connection is held until the +// CLIENT decides to leave. Mutation caught: delete ReadTimeout from NewServer. +func TestHalfFedRequestIsDroppedByTheServer(t *testing.T) { + t.Parallel() + ln := start(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Reads nothing, exactly like the guard that rejects an unauthenticated POST. + w.WriteHeader(http.StatusUnauthorized) + }), fastTimeouts()) + + var d net.Dialer + conn, err := d.DialContext(t.Context(), "tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + if _, err := fmt.Fprint(conn, "POST /v0/books HTTP/1.1\r\nHost: x\r\nContent-Length: 4096\r\n\r\nhalf"); err != nil { + t.Fatalf("write: %v", err) + } + + // Generous relative to the 250ms ReadTimeout and short relative to "forever": the assertion is + // that the SERVER let go, not that it was fast. + if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatalf("deadline: %v", err) + } + start := time.Now() + if _, err := io.ReadAll(conn); err != nil { + t.Fatalf("server never closed the connection after %s: %v (connection pinned — PD-2)", time.Since(start), err) + } +} + +// PD-9. A signal must START the drain, not end every in-flight request. With the signal context +// used as BaseContext, a context-aware handler is cancelled the instant the signal arrives and the +// grace period is decorative. Mutation caught: BaseContext returning the ctx passed to Run. +func TestShutdownDrainsInFlightRequests(t *testing.T) { + t.Parallel() + entered := make(chan struct{}) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(entered) + select { + case <-time.After(300 * time.Millisecond): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("finished")) + case <-r.Context().Done(): + w.WriteHeader(http.StatusServiceUnavailable) + } + }) + + ctx, cancel := context.WithCancel(context.Background()) + srv := serverWithTimeouts("127.0.0.1:0", h, quietLogger(), fastTimeouts()) + ln, err := srv.Listen(ctx) + if err != nil { + cancel() + t.Fatalf("listen: %v", err) + } + runDone := make(chan error, 1) + go func() { runDone <- srv.Run(ctx, ln) }() + + type result struct { + status int + body string + } + resp := make(chan result, 1) + go func() { + r, err := get(t.Context(), "http://"+ln.Addr().String()+"/slow") + if err != nil { + resp <- result{-1, err.Error()} + return + } + defer r.Body.Close() + b, _ := io.ReadAll(r.Body) + resp <- result{r.StatusCode, string(b)} + }() + + <-entered + cancel() // the signal + + select { + case got := <-resp: + if got.status != http.StatusOK || got.body != "finished" { + t.Fatalf("in-flight request was cut by the shutdown: status %d body %q (PD-9)", got.status, got.body) + } + case <-time.After(5 * time.Second): + t.Fatal("no response") + } + select { + case err := <-runDone: + if err != nil { + t.Fatalf("run: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Run did not return") + } +} + +// The other half of the PD-2 fix: a response that takes longer than ReadTimeout to write must +// still arrive whole. It does so with no help from the handler — net/http clears the connection's +// read deadline once the request has arrived and nothing re-arms it (server.go:2059-2062) — so what +// is pinned here is that property and the wrappers the response controller reaches through. +// Mutation caught: removing ReadTimeout's harmlessness (any re-arming), or statusRecorder.Unwrap, +// without which Flush cannot find the real writer and the frames sit in the buffer. +func TestStreamOutlivesReadTimeout(t *testing.T) { + t.Parallel() + to := fastTimeouts() + flushed := make(chan error, 1) + streamed := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + rc := http.NewResponseController(w) + flushed <- rc.Flush() + select { + case <-time.After(3 * to.Read): // well past the read deadline the connection started with + case <-r.Context().Done(): + return // the stream was cut: the client below will see EOF without the late frame + } + _, _ = fmt.Fprint(w, "data: late\n\n") + _ = rc.Flush() + }) + // Wrapped in the same chain a real route gets, because the wrappers are what the response + // controller has to reach through. + ln := start(t, AccessLog(quietLogger())(Recover(quietLogger())(streamed)), to) + + resp, err := get(t.Context(), "http://"+ln.Addr().String()+"/stream") + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + select { + case err := <-flushed: + if err != nil { + t.Errorf("flush through the middleware wrappers: %v (statusRecorder.Unwrap)", err) + } + case <-time.After(5 * time.Second): + t.Fatal("handler never flushed") + } + sc := bufio.NewScanner(resp.Body) + for sc.Scan() { + if strings.Contains(sc.Text(), "late") { + return + } + } + t.Fatal("stream ended before the late frame: the read deadline cut a long-lived response") +} + +// PD-51, the case that decided the fate of the zone's ClearReadDeadline helper. On a request whose +// body was announced and never finished, the drain inside the response header write is the ONLY +// thing bounding the connection, and it is bounded by Timeouts.Read. A handler that clears the read +// deadline first — which the helper's doc comment used to call mandatory for streaming — removes +// that bound and hangs inside WriteHeader for as long as the client keeps the socket: PD-2 again, +// re-created by the fix for it. Measured: handler still stuck 4s after the client had gone. +// Mutation caught: deleting ReadTimeout from NewServer; reintroducing a deadline clear here. +func TestHalfFedStreamingRequestIsCutLoose(t *testing.T) { + t.Parallel() + to := fastTimeouts() + woke := make(chan error, 1) + streamed := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + // The flush is what blocks: WriteHeader only records the status, and the drain of the body + // the client never finished happens when the header is actually put on the wire. A handler + // that never flushes never reaches it — which is why this is the streaming shape, not a + // bare WriteHeader. + _ = http.NewResponseController(w).Flush() + select { + case <-r.Context().Done(): + woke <- r.Context().Err() + case <-time.After(2 * time.Second): + woke <- nil + } + }) + ln := start(t, streamed, to) + + var d net.Dialer + conn, err := d.DialContext(t.Context(), "tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + if _, err := fmt.Fprint(conn, "POST /stream HTTP/1.1\r\nHost: x\r\nContent-Length: 4096\r\n\r\nhalf"); err != nil { + t.Fatalf("write: %v", err) + } + + select { + case err := <-woke: + if err == nil { + t.Fatal("a half-fed connection outlived the read timeout: nothing bounds it once the drain does not (PD-51)") + } + case <-time.After(5 * time.Second): + t.Fatal("handler never woke: stuck in WriteHeader draining a body that never arrives") + } +} + +// PD-46. The behavioural tests above build their own short deadlines, so a defect in the ones the +// daemon ships is invisible to them — the exact shape in which PD-2 survived P0, a property checked +// on an object that is not the one shipped. +// +// This asserts on NewServer, which is now the ONLY way to build the serving server and takes no +// timeouts, so main cannot pass different ones: the value and the wiring are the same call. An +// earlier version of this test passed DefaultTimeouts() itself and therefore proved only half — +// replacing main's argument left it green (found by review, measured). +// Mutation caught: DefaultTimeouts().Read = 0; dropping any timeout line from serverWithTimeouts. +func TestTheServerTheDaemonRunsHasEveryDeadlineSet(t *testing.T) { + t.Parallel() + srv := NewServer("127.0.0.1:0", http.NotFoundHandler(), quietLogger()) + for _, c := range []struct { + name string + got time.Duration + }{ + {"ReadHeaderTimeout", srv.http.ReadHeaderTimeout}, + {"ReadTimeout", srv.http.ReadTimeout}, + {"IdleTimeout", srv.http.IdleTimeout}, + } { + if c.got <= 0 { + t.Errorf("%s is %v: a connection with no deadline is held for as long as the client likes (PD-2)", + c.name, c.got) + } + } + // Absent ON PURPOSE, and the absence is as load-bearing as the values above: a write deadline + // set here cuts an SSE response at a fixed age. Setting it "for symmetry" is the regression. + if srv.http.WriteTimeout != 0 { + t.Errorf("WriteTimeout is %v, want unset: it would cut a streaming response", srv.http.WriteTimeout) + } + if srv.grace <= 0 { + t.Errorf("shutdown grace is %v: a drain would give in-flight requests no time at all", srv.grace) + } + if srv.http.ReadHeaderTimeout > srv.http.ReadTimeout { + t.Errorf("ReadHeaderTimeout %v exceeds ReadTimeout %v: the header deadline can never fire", + srv.http.ReadHeaderTimeout, srv.http.ReadTimeout) + } +} + +func get(ctx context.Context, url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + return http.DefaultClient.Do(req) +} diff --git a/platform/internal/httpapi/server.go b/platform/internal/httpapi/server.go index 9f206470..155c6b46 100644 --- a/platform/internal/httpapi/server.go +++ b/platform/internal/httpapi/server.go @@ -5,13 +5,28 @@ import ( "errors" "log/slog" "net/http" + "time" "textmachine/platform/internal/auth" + "textmachine/platform/internal/reqid" ) -// Prober is what readiness needs from the database. +// APIPrefix is the contract's base path. +const APIPrefix = "/v0" + +// readyProbeTimeout bounds the readiness ping. The endpoint is unauthenticated, and without its own +// deadline a stuck database turns every probe into a held connection (PD-14). +const readyProbeTimeout = 2 * time.Second + +// LoginSurface is the sign-in flow, as this package needs to see it. +type LoginSurface interface { + Routes(guard func(http.Handler) http.Handler) http.Handler +} + +// Prober is what readiness needs from the database: not "is it reachable" but "is it the database +// this build was made for". Reachability alone reported ready against a Postgres with no schema. type Prober interface { - Ping(ctx context.Context) error + Ready(ctx context.Context) error } // Deps is everything the HTTP surface is built from. @@ -23,9 +38,13 @@ type Deps struct { Auth *auth.Authenticator // TrustedOrigins are origins besides our own allowed to make unsafe requests. TrustedOrigins []string - // APIPrefix is the contract's base path ("/v0"). Ops endpoints live outside it: a health check - // is not part of the versioned surface and must not move when the surface does. - APIPrefix string + // HSTS asks browsers never to speak plain http to this host again. Off in the dev profile: the + // policy is pinned per host and localhost would keep it long after the experiment. + HSTS bool + // Login, when set, is mounted at /auth/. It is handed the session guard rather than sitting + // behind one: the surface that CREATES a session cannot require one, and the surface that ends + // a session must. + Login LoginSurface } // New builds the handler. @@ -35,30 +54,41 @@ type Deps struct { // meaningful all the way out to the access log — a nested mux behind http.StripPrefix hands the // inner handler a copy, and the pattern the copy learns never comes back. func New(d Deps) (http.Handler, error) { - if d.APIPrefix == "" { - d.APIPrefix = "/v0" - } if d.Auth == nil { return nil, errors.New("httpapi: no authenticator: the API subtree may not be served unguarded") } - csrf, err := auth.CSRF(d.TrustedOrigins, ProblemHandler(http.StatusForbidden, "Cross-origin request rejected")) + csrf, err := auth.CSRF(d.TrustedOrigins, d.Auth.Cookies.SessionName(), + ProblemHandler(http.StatusForbidden, "Cross-origin request rejected")) if err != nil { return nil, err } // Every API route goes through this. An anonymous caller therefore gets 401 before 404, which // is deliberate: the shape of the surface is not public information. - guard := func(h http.Handler) http.Handler { return csrf(d.Auth.Require(h)) } + // + // The body limit is per ROUTE, not a blanket outer layer: MaxBytesReader wrapping an already + // wrapped body keeps the tighter limit, so an upload route could never raise its own above a + // shared default. The book upload registers guard(maxUpload, …) when it lands. + guard := func(maxBody int64, h http.Handler) http.Handler { + return LimitBody(maxBody)(csrf(d.Auth.Require(h))) + } mux := http.NewServeMux() mux.Handle("GET /healthz", http.HandlerFunc(healthz)) - mux.Handle("GET /readyz", readyz(d.DB)) - // The contract's routes land here (P-1), as mux.Handle("GET "+d.APIPrefix+"/books", guard(…)). - // Until then everything under the prefix is a guarded 404 in the shape the contract mandates. - mux.Handle(d.APIPrefix+"/", guard(ProblemHandler(http.StatusNotFound, "Object not found"))) + mux.Handle("GET /readyz", readyz(d.DB, d.Log)) + if d.Login != nil { + // The subtree still gets the body cap and the CSRF check — sign-out is a POST, and a + // cross-site page must not be able to make one. Rate limiting lives inside the flow, which + // knows which of its endpoints is the unauthenticated one. + mux.Handle("/auth/", LimitBody(DefaultMaxBody)(csrf(d.Login.Routes(d.Auth.Require)))) + } + // TODO(P-1): the contract routes mount here; until then the prefix is a guarded 404. + // The contract's base path is written here and nowhere else. Ops endpoints stay outside it: a + // health check is not part of the versioned surface and must not move when the surface does. + mux.Handle(APIPrefix+"/", guard(DefaultMaxBody, ProblemHandler(http.StatusNotFound, "Object not found"))) // Recover sits INSIDE AccessLog: a panic converted to a 500 still produces a log line, whereas // a panic unwinding past the logger produces none. - return RequestID(SecurityHeaders(AccessLog(d.Log)(Recover(d.Log)(mux)))), nil + return reqid.Middleware(SecurityHeaders(d.HSTS)(AccessLog(d.Log)(Recover(d.Log)(mux)))), nil } // healthz is liveness: the process is up and serving. It touches nothing, so a database outage @@ -70,15 +100,21 @@ func healthz(w http.ResponseWriter, _ *http.Request) { } // readyz is readiness: this instance can serve traffic, which means the database answers. -func readyz(db Prober) http.Handler { +func readyz(db Prober, log *slog.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if db == nil { WriteProblem(w, http.StatusServiceUnavailable, "Not ready", "no database configured") return } - if err := db.Ping(r.Context()); err != nil { - // The reason stays in the log; the body says only that we are not ready. - WriteProblem(w, http.StatusServiceUnavailable, "Not ready", "database unreachable") + ctx, cancel := context.WithTimeout(r.Context(), readyProbeTimeout) + defer cancel() + if err := db.Ready(ctx); err != nil { + // The reason goes to the LOG and not to the wire. Both halves are deliberate: a + // swallowed dependency failure is a defect of its own (PD-16), and /readyz is + // unauthenticated, so "the schema is two migrations behind" is a fact about our rollout + // that no anonymous caller needs. An operator has the log line. + log.ErrorContext(r.Context(), "readiness probe failed", "err", err) + WriteProblem(w, http.StatusServiceUnavailable, "Not ready", "database not ready") return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") diff --git a/platform/internal/httpapi/server_test.go b/platform/internal/httpapi/server_test.go index 4da20a1e..cf08748f 100644 --- a/platform/internal/httpapi/server_test.go +++ b/platform/internal/httpapi/server_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "io" "log/slog" "net/http" "net/http/httptest" @@ -17,7 +18,7 @@ import ( type prober struct{ err error } -func (p prober) Ping(context.Context) error { return p.err } +func (p prober) Ready(context.Context) error { return p.err } type liveSessions struct{} @@ -163,3 +164,103 @@ func assertProblem(t *testing.T, w *httptest.ResponseRecorder, status int) { t.Fatalf("internals leaked into detail: %q", p.Detail) } } + +// stubLogin stands in for the sign-in flow: it only has to prove that the mount is wired the way +// the flow expects — starting a login without a session, ending one only with a session. +type stubLogin struct{} + +func (stubLogin) Routes(guard func(http.Handler) http.Handler) http.Handler { + mux := http.NewServeMux() + mux.Handle("GET /auth/login", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusSeeOther) + })) + mux.Handle("POST /auth/logout", guard(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }))) + return mux +} + +// The sign-in subtree is half-guarded on purpose, and the halves must not swap: the endpoint that +// CREATES a session cannot require one, and the endpoint that ends a session must. +// Mutation caught: wrapping the whole subtree in the guard, or mounting it without one. +func TestSignInSubtreeIsGuardedInHalves(t *testing.T) { + var logs bytes.Buffer + h, err := New(Deps{ + Log: slog.New(slog.NewJSONHandler(&logs, nil)), + Login: stubLogin{}, + Auth: &auth.Authenticator{ + Sessions: deadSessions{}, + IdleTTL: time.Hour, + Deny: ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"), + }, + }) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("starting a login = %d, want 303: it cannot require the session it is about to create", rec.Code) + } + + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/logout", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("signing out without a session = %d, want 401", rec.Code) + } + + // And the subtree is under the CSRF check: a cross-site POST must not reach it. + req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil) + req.Header.Set("Sec-Fetch-Site", "cross-site") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("cross-site sign-out = %d, want 403", rec.Code) + } +} + +// PD-53. The body cap is registered per ROUTE and never as a blanket layer above them, and the +// third case below is the measured reason: http.MaxBytesReader wrapping an already wrapped body +// keeps the TIGHTER limit, so a route could never raise its own above a shared default. Reintroduce +// an outer LimitBody and the upload route silently gets the small cap instead of its own. +// Mutation caught: adding a blanket LimitBody in New; removing LimitBody from guard. +func TestBodyCapIsPerRouteBecauseNestingOnlyTightens(t *testing.T) { + drain := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := io.Copy(io.Discard, r.Body); err != nil { + WriteProblem(w, http.StatusRequestEntityTooLarge, "Request body too large", "") + return + } + w.WriteHeader(http.StatusOK) + }) + for name, tc := range map[string]struct { + h http.Handler + body int + want int + }{ + "at the cap": {LimitBody(10)(drain), 10, http.StatusOK}, + "over the cap": {LimitBody(10)(drain), 11, http.StatusRequestEntityTooLarge}, + "a route with its own larger cap gets it": {LimitBody(1000)(drain), 11, http.StatusOK}, + "a larger cap nested inside a smaller one does NOT raise it": { + LimitBody(10)(LimitBody(1000)(drain)), 11, http.StatusRequestEntityTooLarge}, + } { + t.Run(name, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v0/books", bytes.NewReader(make([]byte, tc.body))) + tc.h.ServeHTTP(w, r) + if w.Code != tc.want { + t.Fatalf("status = %d, want %d", w.Code, tc.want) + } + }) + } +} + +// The default is for contract routes carrying JSON of a few kilobytes. A band rather than the exact +// number: what matters is that nobody quietly turns the shared default into an upload allowance — +// the upload route is supposed to register its own, larger cap (PD-35). +func TestDefaultBodyCapStaysAContractSizedNumber(t *testing.T) { + if DefaultMaxBody < 64<<10 || DefaultMaxBody > 4<<20 { + t.Fatalf("DefaultMaxBody = %d: outside the band a contract route needs; an upload route registers its own cap", + DefaultMaxBody) + } +} diff --git a/platform/internal/ingest/decoder.go b/platform/internal/ingest/decoder.go index e496c662..49b79c57 100644 --- a/platform/internal/ingest/decoder.go +++ b/platform/internal/ingest/decoder.go @@ -18,6 +18,14 @@ var ( // ErrStreamGap is a seq that skipped or went backwards: lines were lost. Recovery is not // guesswork — the caller reconciles from `tmctl status --json`, the ratified resync channel. ErrStreamGap = errors.New("ingest: sequence gap") + // ErrBadHandshake is a handshake that parses but does not identify the stream. Half of the + // ratified idempotency key lives in it, so an empty engine_run_id would collapse every run's + // events into one namespace rather than fail (PD-10a). + ErrBadHandshake = errors.New("ingest: incomplete handshake") + // ErrRepeatedHello is a second handshake mid-stream: the process on the other end restarted, or + // two streams were spliced. Either way the identity behind the seq numbers changed, and the + // version gate only ever inspected line 1 (PD-10c). + ErrRepeatedHello = errors.New("ingest: hello after the handshake") ) // maxLine caps one event. Events carry counters and ids, never text — the translated text travels @@ -50,6 +58,11 @@ func (d *Decoder) Hello() (Hello, error) { if ev.Type != TypeHello { return Hello{}, fmt.Errorf("%w: first line is %q", ErrNoHandshake, ev.Type) } + // The handshake is seq 1 by definition. Without this check a stream whose first lines were lost + // still opens, and the loss is undetectable: the gap check below only compares neighbours. + if ev.Seq != 1 { + return Hello{}, fmt.Errorf("%w: hello carries seq %d, want 1", ErrStreamGap, ev.Seq) + } var h Hello if err := json.Unmarshal(ev.Data, &h); err != nil { return Hello{}, fmt.Errorf("ingest: hello payload: %w", err) @@ -57,6 +70,9 @@ func (d *Decoder) Hello() (Hello, error) { if err := checkVersion(h.StreamVersion); err != nil { return Hello{}, err } + if h.EngineRunID == "" { + return Hello{}, fmt.Errorf("%w: no engine_run_id", ErrBadHandshake) + } d.hello, d.greeted = h, true d.lastSeq = ev.Seq return h, nil @@ -72,12 +88,12 @@ func (d *Decoder) Next() (Envelope, error) { if err != nil { return Envelope{}, err } - switch { - case ev.Seq <= d.lastSeq: - // A duplicate cannot happen inside one pipe, so it is a defect rather than at-least-once - // redelivery — and it is reported, not silently absorbed. - return Envelope{}, fmt.Errorf("%w: seq %d after %d", ErrStreamGap, ev.Seq, d.lastSeq) - case ev.Seq > d.lastSeq+1: + if ev.Type == TypeHello { + return Envelope{}, fmt.Errorf("%w: seq %d", ErrRepeatedHello, ev.Seq) + } + // Exactly one step, in one direction. A repeat is as wrong as a gap: inside one pipe there is + // no at-least-once redelivery to absorb, so both are reported. + if ev.Seq != d.lastSeq+1 { return Envelope{}, fmt.Errorf("%w: seq %d after %d", ErrStreamGap, ev.Seq, d.lastSeq) } d.lastSeq = ev.Seq diff --git a/platform/internal/ingest/events.go b/platform/internal/ingest/events.go index b0c752cf..4361d0c7 100644 --- a/platform/internal/ingest/events.go +++ b/platform/internal/ingest/events.go @@ -3,9 +3,13 @@ // reporting database. It never opens the engine's SQLite and never parses human output. // // ⚠ The emitter does not exist yet — it is row 103 of the engine backlog. The vocabulary below is -// therefore the platform's PROPOSAL, derived from research/23 §7 (which call sites already carry -// the data) and from the API contract §6 (what a reader must be told). It is written as code -// rather than prose so the engine zone can answer it with a diff. +// therefore the platform's PROPOSAL, written as code so the engine zone can answer it with a diff. +// +// ⚠ Open proposal on the TRANSPORT, not the format: the stream should arrive on a dedicated file +// descriptor or a socket named in argv, not on stdout. stdout is a process-wide resource, so one +// stray print in the engine or a dependency corrupts the protocol, and today only a rule guards it. +// hashicorp/go-plugin reaches the same conclusion — one handshake line on stdout, everything else +// on a socket. The format stays NDJSON with a version handshake. package ingest import ( @@ -35,8 +39,10 @@ const ( TypeUnitDone Type = "unit_done" // TypeBankStop is the book-wide signing stop before the edit wave. TypeBankStop Type = "bank_stop" - // TypeCeiling is the resumable halt on the spend ceiling. It carries NO figures. + // TypeCeiling is the resumable halt on the spend ceiling: the fact only, no figures. TypeCeiling Type = "ceiling" + // TypeSpend is the cumulative spend counter (owner 05.08, PLATFORM_DIRECTION §2). + TypeSpend Type = "spend" // TypeFinished is the last line of a clean stream. TypeFinished Type = "finished" ) @@ -109,6 +115,20 @@ type Ceiling struct { Halted bool `json:"halted"` } +// Spend is the freshness channel for money, and ONLY that: the balance is protected by the hold +// taken before the process is spawned and by the per-book ceiling the engine enforces itself, so a +// lost tail costs an indicator its accuracy and never costs the account its correctness. Building +// enforcement on this event is forbidden — the stream is at-least-once and a crash truncates it. +// +// CUMULATIVE, not a delta: a redelivered or duplicated line is then harmless, because the +// materializer keeps the maximum seen for the run instead of adding anything up. Integer +// micro-USD: money never travels as a float, and the engine's ledger is a lower bound, so the +// conversion at the seam rounds up (this is an internal channel into a private table — D39.84 +// governs the USER's wire, screen and INFO logs, and none of them see this). +type Spend struct { + CommittedMicroUSD int64 `json:"committed_micro_usd"` +} + // Finished is the terminal line. Outcome mirrors the engine's exit contract so a stream that ends // cleanly needs no exit-code archaeology: clean | flagged | bank_stop | failed. type Finished struct { diff --git a/platform/internal/ingest/fuzz_test.go b/platform/internal/ingest/fuzz_test.go new file mode 100644 index 00000000..551fa5b4 --- /dev/null +++ b/platform/internal/ingest/fuzz_test.go @@ -0,0 +1,98 @@ +package ingest + +import ( + "errors" + "io" + "strings" + "testing" +) + +// The decoder is the only place where bytes produced by another process become platform state, so +// it is fuzzed rather than merely exampled. The oracles are the invariants the rest of the ingest +// path is built on; each is stated as "if the decoder said yes, then …", because a refusal is +// always an acceptable answer and only an ACCEPTANCE can be wrong. +// +// 1. it never panics, whatever arrives; +// 2. an accepted handshake identifies the stream (engine_run_id non-empty — half of the +// idempotency key) and is seq 1; +// 3. accepted events increase seq by exactly one, so a gap can never be silently absorbed; +// 4. no event is ever returned before a successful handshake; +// 5. hello never appears again after the handshake, at any version. +func FuzzDecoder(f *testing.F) { + f.Add(helloLine) + f.Add(helloLine + "\n" + `{"seq":2,"type":"progress","data":{"draft":{"done":1,"total":2}}}`) + f.Add(helloLine + "\n" + helloLine) + f.Add(`{"seq":9,"type":"hello","data":{"stream_version":"1.0","engine_run_id":"x"}}`) + f.Add(`{"seq":1,"type":"hello","data":{"stream_version":"1.0","engine_run_id":""}}`) + f.Add(`{"seq":1,"type":"hello","data":{"stream_version":"9.9","engine_run_id":"x"}}`) + f.Add("\n\n\n") + f.Add("{not json") + + f.Fuzz(func(t *testing.T, stream string) { + d := NewDecoder(strings.NewReader(stream)) + + // Oracle 4: nothing may come out before the handshake. + if _, err := d.Next(); !errors.Is(err, ErrNoHandshake) { + t.Fatalf("Next before Hello returned %v, want ErrNoHandshake", err) + } + + h, err := d.Hello() + if err != nil { + return // a refusal is always allowed + } + // Oracle 2. + if h.EngineRunID == "" { + t.Fatal("accepted a handshake with no engine_run_id: half the idempotency key") + } + if maj, err := major(h.StreamVersion); err != nil || maj != 1 { + t.Fatalf("accepted stream version %q", h.StreamVersion) + } + + last := int64(1) // oracle 2: the handshake is seq 1 or it is refused + for { + ev, err := d.Next() + if err != nil { + if errors.Is(err, io.EOF) { + return + } + return // any refusal is allowed; only acceptances are constrained + } + // Oracle 3. + if ev.Seq != last+1 { + t.Fatalf("accepted seq %d after %d", ev.Seq, last) + } + // Oracle 5. + if ev.Type == TypeHello { + t.Fatal("accepted a second handshake mid-stream") + } + last = ev.Seq + } + }) +} + +func TestHandshakeMustIdentifyTheStream(t *testing.T) { + t.Run("no engine_run_id", func(t *testing.T) { + line := strings.Replace(helloLine, `"engine_run_id":"tr_1"`, `"engine_run_id":""`, 1) + if _, err := NewDecoder(strings.NewReader(line)).Hello(); !errors.Is(err, ErrBadHandshake) { + t.Fatalf("want ErrBadHandshake, got %v", err) + } + }) + t.Run("handshake is seq 1", func(t *testing.T) { + line := strings.Replace(helloLine, `"seq":1`, `"seq":4`, 1) + if _, err := NewDecoder(strings.NewReader(line)).Hello(); !errors.Is(err, ErrStreamGap) { + t.Fatalf("want ErrStreamGap, got %v", err) + } + }) + t.Run("no second handshake", func(t *testing.T) { + second := strings.Replace(helloLine, `"seq":1`, `"seq":2`, 1) + // A major version the gate would have refused on line 1, arriving on line 2. + second = strings.Replace(second, `"stream_version":"1.0"`, `"stream_version":"9.9"`, 1) + d := NewDecoder(strings.NewReader(helloLine + "\n" + second)) + if _, err := d.Hello(); err != nil { + t.Fatal(err) + } + if _, err := d.Next(); !errors.Is(err, ErrRepeatedHello) { + t.Fatalf("want ErrRepeatedHello, got %v", err) + } + }) +} diff --git a/platform/internal/ingest/money_test.go b/platform/internal/ingest/money_test.go new file mode 100644 index 00000000..b6fb0122 --- /dev/null +++ b/platform/internal/ingest/money_test.go @@ -0,0 +1,58 @@ +package ingest + +import ( + "encoding/json" + "testing" + + "textmachine/platform/internal/money" +) + +// Money crosses the seam exactly once, here. The cases below are the ones a float64 gets wrong or +// gets right only by luck. Mutation caught: binding committed_usd to a float64 and multiplying, or +// rounding to nearest instead of away from zero. +func TestSpendConvertsExactlyAndRoundsUp(t *testing.T) { + usd := func(v money.MicroUSD) *money.MicroUSD { return &v } + cases := map[string]*money.MicroUSD{ + `{"committed_usd":0}`: usd(0), + `{"committed_usd":1.25}`: usd(1_250_000), + `{"committed_usd":0.000001}`: usd(1), + `{"committed_usd":0.0000001}`: usd(1), // a tenth of a micro-dollar still costs one + `{"committed_usd":0.1}`: usd(100_000), // the classic float64 case: 0.1 is not 0.1 + `{"committed_usd":8.7}`: usd(8_700_000), + `{"committed_usd":29.7}`: usd(29_700_000), + `{"committed_usd":1e-6}`: usd(1), + `{"committed_usd":"1.25"}`: usd(1_250_000), // a quoted decimal is still a decimal + // JSON null never reaches UnmarshalJSON for a pointer: it IS the absence. + `{"committed_usd":null}`: nil, + `{}`: nil, + `{"committed_usd":123456.789012}`: usd(123_456_789_012), + `{"committed_usd":123456.7890121}`: usd(123_456_789_013), + } + for raw, want := range cases { + var r StatusReport + if err := json.Unmarshal([]byte(raw), &r); err != nil { + t.Fatalf("%s: %v", raw, err) + } + switch { + case r.Spend == nil && want != nil: + t.Fatalf("%s: spend is absent, want %d", raw, *want) + case r.Spend != nil && want == nil: + t.Fatalf("%s: spend = %d, want absent", raw, *r.Spend) + case r.Spend != nil && *r.Spend != *want: + t.Fatalf("%s: spend = %d, want %d", raw, *r.Spend, *want) + } + } +} + +func TestSpendRefusesNonsense(t *testing.T) { + for _, raw := range []string{ + `{"committed_usd":"free"}`, + `{"committed_usd":1e30}`, // beyond int64 micro-USD + `{"committed_usd":""}`, // an empty figure is not a figure + } { + var r StatusReport + if err := json.Unmarshal([]byte(raw), &r); err == nil { + t.Fatalf("%s: accepted", raw) + } + } +} diff --git a/platform/internal/ingest/procgroup_other.go b/platform/internal/ingest/procgroup_other.go new file mode 100644 index 00000000..d87d5882 --- /dev/null +++ b/platform/internal/ingest/procgroup_other.go @@ -0,0 +1,16 @@ +//go:build !unix + +package ingest + +import ( + "os" + "os/exec" +) + +// The deploy target is a Linux VM; these keep the module building elsewhere without pretending the +// process-group guarantee exists there. +func setProcessGroup(*exec.Cmd) {} + +func interruptGroup(p *os.Process) error { return p.Signal(os.Interrupt) } + +func stillRunning(*os.Process) bool { return true } diff --git a/platform/internal/ingest/procgroup_unix.go b/platform/internal/ingest/procgroup_unix.go new file mode 100644 index 00000000..ae5ce045 --- /dev/null +++ b/platform/internal/ingest/procgroup_unix.go @@ -0,0 +1,30 @@ +//go:build unix + +package ingest + +import ( + "os" + "os/exec" + "syscall" +) + +// setProcessGroup puts the engine in a process group of its own so that stopping a run reaches +// everything it started, not only the process whose pid we happen to hold (PD-13). +// +// It does NOT survive a crash of the platform: a killed supervisor leaves the group running, and +// only the deploy unit's cgroup closes that hole — see deploy/tmplatformd.service. +func setProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// interruptGroup asks the whole group to stop. A negative pid is the POSIX spelling of "group"; +// if the group is already gone, the single process is still worth the signal. +func interruptGroup(p *os.Process) error { + if err := syscall.Kill(-p.Pid, syscall.SIGINT); err != nil { + return p.Signal(os.Interrupt) + } + return nil +} + +// stillRunning reports whether the process can still be signalled by us. +func stillRunning(p *os.Process) bool { return p.Signal(syscall.Signal(0)) == nil } diff --git a/platform/internal/ingest/resync.go b/platform/internal/ingest/resync.go index 752fd398..07ae41f3 100644 --- a/platform/internal/ingest/resync.go +++ b/platform/internal/ingest/resync.go @@ -3,6 +3,8 @@ package ingest import ( "encoding/json" "fmt" + + "textmachine/platform/internal/money" ) // StatusReport is the ALLOWLISTED subset of `tmctl status --json` (pipeline.StatusReport) that the @@ -10,8 +12,6 @@ import ( // routing, content labels and the operator's flag taxonomy are engine vocabulary that must not // cross the seam (contract §2.12), and unknown JSON fields are simply ignored by encoding/json. // -// ⚠ One field is money, and it is here for metering only — see SpendUSD. -// // ⚠ Limit worth knowing: status has no PHASE split (engine backlog row 99). A resync can therefore // restore the aggregate counter but not "draft N/M ∥ edit N/M"; the phase split lives only in the // stream until row 99 lands. A reconciled run shows the aggregate until its next progress event. @@ -26,14 +26,21 @@ type StatusReport struct { ETASeconds float64 `json:"eta_seconds"` // UnsignedBankTerms backs the signing screen's "N of M decided" while a stop is standing. UnsignedBankTerms int `json:"unsigned_bank_terms"` - // SpendUSD is the engine's committed spend. The platform meters usage from its DELTA between - // attempts, because the event stream deliberately carries no figures. It is stored in the - // usage tables and NEVER projected into an API response or an INFO log (D39.84). - SpendUSD float64 `json:"committed_usd"` + // Spend is the engine's committed spend, converted to integer micro-USD AT THE SEAM. The wire + // value is a JSON decimal; binding it to a float64 would put drift one step before the integer + // column that exists to prevent drift (PD-15). It is stored in the credit tables and NEVER + // projected into an API response or an INFO log (D39.84). + // A POINTER: absent, null and empty must not read as "the attempt cost nothing". A settlement + // computed from a missing figure would release the whole hold and charge zero. + Spend *money.MicroUSD `json:"committed_usd"` Chapters []ChapterStatus `json:"chapters"` } // ChapterStatus is the per-chapter passport, allowlisted the same way (no cost, no verdict ranks). +// +// worst_flag_reason is deliberately NOT taken: contract v0 gives a chapter a note_count and nothing +// about the worst reason, so materializing it would store engine vocabulary no reader asks for +// (PD-19). It comes back with a column the day the chapter screen needs it. type ChapterStatus struct { Chapter int `json:"chapter"` UnitsTotal int `json:"units_total"` @@ -41,9 +48,6 @@ type ChapterStatus struct { UnitsFlagged int `json:"units_flagged"` UnitsInProgress int `json:"units_in_progress"` UnitsPending int `json:"units_pending"` - // WorstFlagReason is engine vocabulary: stored, mapped to a product phrase at read time, never - // projected raw. - WorstFlagReason string `json:"worst_flag_reason"` } // DecodeStatus parses a status report. diff --git a/platform/internal/ingest/supervisor.go b/platform/internal/ingest/supervisor.go index bd22a673..02569bd2 100644 --- a/platform/internal/ingest/supervisor.go +++ b/platform/internal/ingest/supervisor.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "log/slog" "os" "os/exec" "time" @@ -56,43 +57,88 @@ type Supervisor struct { // engine logs per-call cost estimates at INFO, and money must not enter the platform's INFO // stream (D39.84). nil discards. EngineLog io.Writer + // Log is the platform's own view of the run. Nil is silent, which is what tests want and what + // production must not be: a translation runs for hours and its only trace would otherwise be + // the engine's own file. + Log *slog.Logger +} + +func (s *Supervisor) logger() *slog.Logger { + if s.Log == nil { + return slog.New(slog.DiscardHandler) + } + return s.Log } // Run spawns the engine and ingests its stream. It returns the outcome even when the stream itself // failed, because "what did the process do" and "did we materialize all of it" are different // questions: the second one is answered by reconciling with Status. func (s *Supervisor) Run(ctx context.Context, sink Sink, args ...string) (Outcome, error) { - cmd := exec.CommandContext(ctx, s.Bin, args...) + // A broken ingest must STOP the run, not watch it (PD-12): with the sink failing, the platform + // is blind for the hours the engine keeps running and spending, and the ceiling and bank-stop + // events of that run go to io.Discard with nobody told. + runCtx, stop := context.WithCancel(ctx) + defer stop() + + cmd := exec.CommandContext(runCtx, s.Bin, args...) cmd.Dir = s.Workdir cmd.Env = s.Env cmd.Stderr = s.engineLog() + setProcessGroup(cmd) // CommandContext kills on cancel by default; the engine needs the signal it already handles, // and WaitDelay is the backstop if it ignores it. - cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } + cmd.Cancel = func() error { return askToStop(cmd.Process) } cmd.WaitDelay = stopGrace stdout, err := cmd.StdoutPipe() if err != nil { return OutcomeFailed, fmt.Errorf("ingest: stdout pipe: %w", err) } + log := s.logger() if err := cmd.Start(); err != nil { + log.ErrorContext(ctx, "engine did not start", "err", err, "bin", s.Bin) return OutcomeFailed, fmt.Errorf("ingest: start %s: %w", s.Bin, err) } + log.InfoContext(ctx, "engine started", "pid", cmd.Process.Pid, "args", args) - ingestErr := Ingest(ctx, stdout, sink) + ingestErr := Ingest(runCtx, stdout, sink) + // A cancelled run context is OUR stop, not a broken sink. Ingest reports it as its own error, and + // treating the two alike fired the one ERROR line that is supposed to mean "the platform is blind + // while money is being spent" on every ordinary shutdown of a live run — and called stop() on a + // run that was already stopping. Found by review. + if ingestErr != nil && errors.Is(ingestErr, context.Canceled) && runCtx.Err() != nil { + ingestErr = nil + } + if ingestErr != nil { + // The run is being ended because we cannot record it. Said once, here, because from the + // caller's side it is indistinguishable from the engine failing on its own. + log.ErrorContext(ctx, "stream could not be materialized: stopping the run", "err", ingestErr) + stop() + } // Drain whatever is left so the child never blocks on a full pipe while we are waiting for it. _, _ = io.Copy(io.Discard, stdout) waitErr := cmd.Wait() - var exitErr *exec.ExitError - switch { - case waitErr == nil: - return OutcomeClean, ingestErr - case errors.As(waitErr, &exitErr): - return outcomeOf(exitErr.ExitCode()), ingestErr - default: + // The exit code is the outcome even when WE stopped the run. exec reports a cancelled command + // as context.Canceled rather than an *ExitError, so reading the outcome off waitErr alone marks + // every gracefully stopped run as failed — including all of them on an ordinary SIGTERM. + if cmd.ProcessState != nil && cmd.ProcessState.Exited() { + outcome := outcomeOf(cmd.ProcessState.ExitCode()) + log.InfoContext(ctx, "engine finished", "outcome", outcome, "exit_code", cmd.ProcessState.ExitCode()) + // A stopped run is not a finished one, and the engine exits 0 for both. The cancellation + // travels in the error so the caller can tell "stopped" from "done" — the outcome cannot + // carry it, because it mirrors the engine's exit contract and nothing else. + if err := runCtx.Err(); err != nil { + return outcome, errors.Join(err, ingestErr) + } + return outcome, ingestErr + } + if waitErr != nil { + // Did not exit: killed, or never became a process we could wait on. + log.ErrorContext(ctx, "engine did not exit", "err", waitErr) return OutcomeFailed, errors.Join(waitErr, ingestErr) } + return OutcomeClean, ingestErr } // Status runs the reconciliation channel: `tmctl status --json` on a stopped or finished run. It @@ -110,6 +156,28 @@ func (s *Supervisor) Status(ctx context.Context) (StatusReport, error) { return DecodeStatus(out) } +// retryStop is when the interrupt is repeated. ONE signal is not enough, and this is measured, not +// defensive: a child interrupted in the first milliseconds of its life misses the signal outright +// (reproduced on this stand — roughly one run in three), and the only thing left is WaitDelay's +// SIGKILL, which is exactly what must not happen to a process holding an EXCLUSIVE lock on the +// book's project file. See PD-20. +var retryStop = []time.Duration{30 * time.Millisecond, 120 * time.Millisecond, 400 * time.Millisecond} + +// askToStop asks the engine to shut down, and keeps asking for about half a second. +func askToStop(p *os.Process) error { + first := interruptGroup(p) + for _, d := range retryStop { + time.Sleep(d) + // Asks the kernel whether the process is still ours to signal; it goes through os.Process, + // so a reaped child answers "done" instead of the call reaching a recycled pid. + if !stillRunning(p) { + return first + } + _ = interruptGroup(p) + } + return first +} + func (s *Supervisor) engineLog() io.Writer { if s.EngineLog == nil { return io.Discard diff --git a/platform/internal/ingest/supervisor_test.go b/platform/internal/ingest/supervisor_test.go index 6eb592b1..7fe6a09b 100644 --- a/platform/internal/ingest/supervisor_test.go +++ b/platform/internal/ingest/supervisor_test.go @@ -3,11 +3,13 @@ package ingest import ( "bytes" "context" + "errors" "os" "path/filepath" "strconv" "strings" "testing" + "time" ) // fakeEngine writes a shell script that behaves like tmctl's contract: NDJSON on stdout, human log @@ -60,6 +62,40 @@ func TestRunReportsOutcomeEvenWhenStreamBreaks(t *testing.T) { } } +// PD-12. When the sink stops accepting, the run must END, not continue unwatched: a translation +// keeps spending for hours, and its ceiling and bank-stop events would go to io.Discard with nobody +// told. Mutation caught: dropping the stop() after a failed Ingest — the test then waits out the +// child's sleep and times out. +func TestFailingSinkStopsTheRun(t *testing.T) { + stream := helloLine + "\n" + `{"seq":2,"type":"progress","data":{}}` + "\n" + path := filepath.Join(t.TempDir(), "tmctl") + // Emits a valid stream, then behaves like a long translation: it stays alive until told to go. + script := "#!/bin/sh\nprintf '%s' " + shellQuote(stream) + "\nsleep 60\nexit 0\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + s := &Supervisor{Bin: path, Workdir: t.TempDir()} + + done := make(chan struct{}) + go func() { + defer close(done) + if _, err := s.Run(context.Background(), &failingSink{}, "translate"); err == nil { + t.Error("a failing sink must be reported") + } + }() + select { + case <-done: + case <-time.After(20 * time.Second): + t.Fatal("the run outlived its sink: the engine was left running while the platform was blind") + } +} + +type failingSink struct{} + +func (failingSink) Begin(context.Context, Hello) error { return nil } + +func (failingSink) Apply(context.Context, Envelope) error { return errors.New("database is down") } + func TestStatusDecodesTheResyncChannel(t *testing.T) { // A real `tmctl status --json` body carries money and snapshot fields; the allowlist ignores // them, and this fixture keeps one of each to prove it. @@ -74,8 +110,16 @@ func TestStatusDecodesTheResyncChannel(t *testing.T) { if got.BookID != "gzr" || got.Done != 4 || got.ETASeconds != 900 || got.UnsignedBankTerms != 3 { t.Fatalf("status = %+v", got) } - if got.SpendUSD != 1.25 { - t.Fatalf("spend must be metered from status: %v", got.SpendUSD) + if got.Spend == nil || *got.Spend != 1_250_000 { + t.Fatalf("spend must be metered from status: %v", got.Spend) + } + // A status without the figure is not a status reporting zero. + absent, err := DecodeStatus([]byte(`{"book_id":"gzr"}`)) + if err != nil { + t.Fatal(err) + } + if absent.Spend != nil { + t.Fatalf("a missing committed_usd read as %v", *absent.Spend) } if len(got.Chapters) != 1 || got.Chapters[0].UnitsDone != 2 { t.Fatalf("chapters = %+v", got.Chapters) @@ -91,3 +135,50 @@ func TestOutcomeOfCoversTheExitContract(t *testing.T) { } func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + +// A run we stopped ourselves is not a failed run. exec reports a cancelled command as +// context.Canceled instead of an *ExitError, so reading the outcome off the wait error alone marks +// every gracefully stopped translation as failed — every one in flight on an ordinary SIGTERM. +// Mutation caught: going back to `errors.As(waitErr, &exitErr)` as the only source of the outcome. +func TestStoppedRunKeepsTheEnginesOutcome(t *testing.T) { + path := filepath.Join(t.TempDir(), "tmctl") + // Behaves like tmctl: stops on the interrupt and exits 0. + script := "#!/bin/sh\ntrap 'exit 0' INT\nprintf '%s' " + shellQuote(helloLine+"\n") + "\nsleep 30\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + s := &Supervisor{Bin: path, Workdir: t.TempDir()} + + ctx, cancel := context.WithCancel(context.Background()) + type result struct { + outcome Outcome + err error + } + done := make(chan result, 1) + go func() { + o, err := s.Run(ctx, nopSink{}, "translate") + done <- result{o, err} + }() + time.Sleep(200 * time.Millisecond) // let the engine reach its sleep + cancel() + + select { + case got := <-done: + if got.outcome == OutcomeFailed { + t.Fatalf("a run stopped by us reported %q; the engine exited 0", got.outcome) + } + if !errors.Is(got.err, context.Canceled) { + t.Fatalf("a stopped run must be distinguishable from a finished one, got err %v", got.err) + } + case <-time.After(20 * time.Second): + t.Fatal("Run did not return") + } +} + +// nopSink accepts everything and records nothing: this test is about the run's OUTCOME, not about +// what the sink saw. +type nopSink struct{} + +func (nopSink) Begin(context.Context, Hello) error { return nil } + +func (nopSink) Apply(context.Context, Envelope) error { return nil } diff --git a/platform/internal/login/issuer_test.go b/platform/internal/login/issuer_test.go new file mode 100644 index 00000000..afc07f6b --- /dev/null +++ b/platform/internal/login/issuer_test.go @@ -0,0 +1,171 @@ +package login + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" +) + +// fakeIssuer is a real OIDC provider, small enough to read: discovery, a JWKS, and a token endpoint +// that signs an identity token. Everything the flow verifies — signature, issuer, audience, expiry, +// nonce, PKCE — is verified against THIS, so the test exercises go-oidc rather than a stub of it. +type fakeIssuer struct { + srv *httptest.Server + key *rsa.PrivateKey + + // what the token endpoint will mint + sub string + email string + emailVerified bool + nonce string + audience string + expiresIn time.Duration + // expectChallenge, when set, is the PKCE challenge the exchange must present a verifier for. + expectChallenge string + // issSupported is what the discovery document advertises for RFC 9207. Google advertises true + // (checked live, 05.08), so that is the default here. + issSupported bool + // stall, when set, makes the endpoint named by stallOn accept the request and never answer it + // until the channel is closed. It is how "the provider is up but hung" is expressed. + stall chan struct{} + stallOn string + // stallOnce stalls only the FIRST request, so a test can show that the endpoint recovering is + // enough — or that it is not. + stallOnce bool + // Read from one handler goroutine while another is still blocked in the stall, so it is atomic. + stallsDone atomic.Bool + + tokenCalls int +} + +func newIssuer(t *testing.T) *fakeIssuer { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + f := &fakeIssuer{key: key, sub: "sub-A", email: "reader@example.org", emailVerified: true, + expiresIn: time.Hour, issSupported: true} + + mux := http.NewServeMux() + mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]any{ + "issuer": f.srv.URL, + "authorization_endpoint": f.srv.URL + "/authorize", + "token_endpoint": f.srv.URL + "/token", + "jwks_uri": f.srv.URL + "/keys", + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"RS256"}, + "authorization_response_iss_parameter_supported": f.issSupported, + }) + }) + mux.HandleFunc("GET /keys", func(w http.ResponseWriter, _ *http.Request) { + if f.stall != nil && f.stallOn == "keys" && !f.stallsDone.Swap(f.stallOnce) { + <-f.stall + return + } + pub := f.key.Public().(*rsa.PublicKey) + writeJSON(w, map[string]any{"keys": []map[string]string{{ + "kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test", + "n": b64(pub.N.Bytes()), + "e": b64(big.NewInt(int64(pub.E)).Bytes()), + }}}) + }) + mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) { + f.tokenCalls++ + if f.stall != nil && f.stallOn == "token" { + <-f.stall + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + // PKCE, verified for real: the verifier presented here must hash to the challenge the + // authorization request carried. + if f.expectChallenge != "" { + sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier"))) + if b64(sum[:]) != f.expectChallenge { + http.Error(w, "invalid_grant", http.StatusBadRequest) + return + } + } + aud := f.audience + if aud == "" { + aud = "test-client" + } + writeJSON(w, map[string]any{ + "access_token": "opaque-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": f.idToken(map[string]any{ + "iss": f.srv.URL, + "aud": aud, + "sub": f.sub, + "exp": time.Now().Add(f.expiresIn).Unix(), + "iat": time.Now().Unix(), + "nonce": f.nonce, + "email": f.email, + "email_verified": f.emailVerified, + }), + }) + }) + f.srv = httptest.NewServer(mux) + t.Cleanup(f.srv.Close) + return f +} + +// idToken signs a JWT with RS256 by hand: twenty lines, no extra dependency, and it makes the +// signature the test controls rather than a library's. +func (f *fakeIssuer) idToken(claims map[string]any) string { + header := b64(mustJSON(map[string]string{"alg": "RS256", "kid": "test", "typ": "JWT"})) + payload := b64(mustJSON(claims)) + signing := header + "." + payload + sum := sha256.Sum256([]byte(signing)) + sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:]) + if err != nil { + panic(err) + } + return signing + "." + b64(sig) +} + +// challengeFrom reads the PKCE challenge out of the authorization redirect, so the token endpoint +// can hold the exchange to it. +func challengeFrom(t *testing.T, location string) (state, challenge, nonce string) { + t.Helper() + u, err := url.Parse(location) + if err != nil { + t.Fatal(err) + } + q := u.Query() + if q.Get("code_challenge_method") != "S256" { + t.Fatalf("authorization request must use S256, got %q", q.Get("code_challenge_method")) + } + return q.Get("state"), q.Get("code_challenge"), q.Get("nonce") +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func mustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(mustJSON(v)) +} diff --git a/platform/internal/login/login.go b/platform/internal/login/login.go new file mode 100644 index 00000000..32753b0c --- /dev/null +++ b/platform/internal/login/login.go @@ -0,0 +1,568 @@ +// Package login is the OIDC sign-in flow (P-6). The identity provider supplies the EVENT of a +// login and nothing else: its tokens are used once inside the callback to prove who the caller is +// and are then dropped — nothing vendor-issued is persisted or outlives the request. The session +// that follows is ours (D39.84), which is what keeps instant revocation and token accounting. +package login + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "net/url" + "strings" + "sync" + "syscall" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + "golang.org/x/time/rate" + + "textmachine/platform/internal/auth" + "textmachine/platform/internal/reqid" +) + +// stateTTL is how long an authorization round trip may take. Long enough for a consent screen, +// short enough that an abandoned login is not a standing row. +const stateTTL = 10 * time.Minute + +// Config is the provider and the policy around it. +type Config struct { + // Provider is the key stored in identities.provider ("google"). It is OUR name for the issuer, + // not the issuer's, so a provider that changes its URL does not orphan its accounts. + Provider string + // Issuer is the OIDC issuer URL; everything else is discovered from it. + Issuer string + ClientID string + ClientSecret string + // RedirectURL must match the one registered with the provider, exactly. + RedirectURL string + Scopes []string + // AfterLogin is where the browser lands when the request did not ask for somewhere else. + AfterLogin string + // SessionIdleTTL and SessionMaxAge are the session's two clocks. + SessionIdleTTL time.Duration + SessionMaxAge time.Duration + // SignupGrantMicroUSD is the credit written when an account is created. + SignupGrantMicroUSD int64 + // StartRate bounds how fast unauthenticated callers can make us write state rows. The login + // endpoint is the first surface a bot finds. + StartRate rate.Limit + StartBurst int +} + +// Handler serves /auth/*. +type Handler struct { + cfg Config + store Store + cookies auth.Cookies + log *slog.Logger + limiter *rate.Limiter + now func() time.Time + + // httpClient is used for every provider round trip and is NEVER nil — New always installs a + // bounded one, and tests replace it with their own. That is not tidiness: our per-request + // deadline cannot reach the requests go-oidc makes on its own schedule, because Provider.Verifier + // uses the key set built at discovery and go-oidc stores it with context.WithoutCancel. Left to + // itself that refetch runs on http.DefaultClient, which has no timeout, and one JWKS fetch that + // hangs then keeps every later sign-in failing after the endpoint recovers — they queue on the + // same inflight fetch. NewProvider captures this client, which is what bounds them. + httpClient *http.Client + // exchangeTimeout overrides providerTimeout. A test seam, like httpClient and now: the property + // under test is that the bound EXISTS, and waiting the production ten seconds to see it would + // make the battery slower without making it stricter. + exchangeTimeout time.Duration + + // Discovery is lazy and cached: a provider that is unreachable at boot must not stop the + // service from starting, and its outage must read as "login is temporarily unavailable" + // rather than as a crash loop. + mu sync.Mutex + provider *oidc.Provider + // issSupported is the discovery document's authorization_response_iss_parameter_supported. + // Cached with the provider because RFC 9207 §2.4 makes an ABSENT iss a refusal exactly when the + // server is known to send one, and "known" here means what discovery said. + issSupported bool + + failFn Fail +} + +// New builds the handler. It performs no network I/O. +func New(cfg Config, store Store, cookies auth.Cookies, log *slog.Logger) (*Handler, error) { + switch { + case cfg.Provider == "": + return nil, errors.New("login: no provider name") + case cfg.Issuer == "" || cfg.ClientID == "" || cfg.ClientSecret == "": + return nil, errors.New("login: issuer, client id and client secret are all required") + case cfg.RedirectURL == "": + return nil, errors.New("login: no redirect url") + } + if cfg.AfterLogin == "" { + cfg.AfterLogin = "/" + } + if len(cfg.Scopes) == 0 { + cfg.Scopes = []string{oidc.ScopeOpenID, "email", "profile"} + } + if cfg.StartRate == 0 { + cfg.StartRate, cfg.StartBurst = 2, 20 + } + if cfg.StartBurst <= 0 { + // rate.NewLimiter with a zero burst allows nothing at all: a caller who set the rate and + // forgot the burst would turn every sign-in into a 429 and read it as a broken provider. + cfg.StartBurst = 1 + } + return &Handler{ + cfg: cfg, + store: store, + cookies: cookies, + log: log, + limiter: rate.NewLimiter(cfg.StartRate, cfg.StartBurst), + now: time.Now, + httpClient: &http.Client{Timeout: providerTimeout}, + }, nil +} + +// Routes mounts the flow. guard is the session middleware: starting a login must be reachable +// without one, ending a login must not be. +func (h *Handler) Routes(guard func(http.Handler) http.Handler) http.Handler { + mux := http.NewServeMux() + // The method lives in a wrapper rather than in the pattern so that a wrong one answers in + // problem+json like everything else: ServeMux's own 405 is text/plain, and the contract + // admits one error shape. + mux.Handle("/auth/login", h.only(http.MethodGet, http.HandlerFunc(h.start))) + mux.Handle("/auth/callback", h.only(http.MethodGet, http.HandlerFunc(h.callback))) + mux.Handle("/auth/logout", h.only(http.MethodPost, guard(http.HandlerFunc(h.logout)))) + mux.Handle("/auth/logout-all", h.only(http.MethodPost, guard(http.HandlerFunc(h.logoutAll)))) + mux.Handle("/auth/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.fail(w, http.StatusNotFound, "Object not found", "") + })) + return mux +} + +func (h *Handler) only(method string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { + w.Header().Set("Allow", method) + h.fail(w, http.StatusMethodNotAllowed, "Method not allowed", "") + return + } + next.ServeHTTP(w, r) + }) +} + +// start begins the authorization code flow with PKCE. +func (h *Handler) start(w http.ResponseWriter, r *http.Request) { + if !h.limiter.Allow() { + // Rate limiting an unauthenticated endpoint that WRITES is not optional: every call here + // costs a row, and the caller has not proven anything yet. + // + // Deliberately GLOBAL rather than per-address. There is no trusted edge proxy defined yet, + // so RemoteAddr behind one is the proxy's address for everybody — a per-address limiter + // would lock out every user at once the moment it fired. Per-address belongs at the edge, + // with the header trust that only the edge can establish. + w.Header().Set("Retry-After", "5") + // The limiter is the only thing between an unauthenticated writer and the state table, so + // its engagement is an event, not a detail. + h.log.WarnContext(r.Context(), "sign-in rate limit engaged", "provider", h.cfg.Provider) + h.fail(w, http.StatusTooManyRequests, "Too many login attempts", "") + return + } + provider, err := h.discover(r.Context()) + if err != nil { + h.log.ErrorContext(r.Context(), "oidc discovery failed", "err", err, "provider", h.cfg.Provider) + h.fail(w, http.StatusServiceUnavailable, "Sign-in is temporarily unavailable", "") + return + } + + state := auth.NewToken() + nonce := auth.NewToken() + verifier := oauth2.GenerateVerifier() + st := State{ + Hash: auth.Digest(state), + Provider: h.cfg.Provider, + // The issuer this request is being sent to. oidc.NewProvider refuses a discovery document + // whose issuer differs from the URL it was fetched from, so the configured value is the + // discovered one. + Issuer: h.cfg.Issuer, + // The two halves of a sign-in are two requests with two request ids. This is what joins + // them in the log without putting anything about the user in it. + StartID: reqid.FromContext(r.Context()), + Nonce: nonce, + Verifier: verifier, + ReturnTo: safeReturnTo(r.URL.Query().Get("return_to")), + CreatedAt: h.now(), + ExpiresAt: h.now().Add(stateTTL), + } + if err := h.store.PutLoginState(r.Context(), st); err != nil { + h.log.ErrorContext(r.Context(), "cannot store login state", "err", err) + h.fail(w, http.StatusServiceUnavailable, "Sign-in is temporarily unavailable", "") + return + } + + h.cookies.SetLogin(w, state, stateTTL) + cfg := h.oauth(provider) + url := cfg.AuthCodeURL(state, + oidc.Nonce(nonce), + oauth2.S256ChallengeOption(verifier), + oauth2.AccessTypeOnline, + ) + http.Redirect(w, r, url, http.StatusSeeOther) +} + +// callback finishes it. Every failure below is the same to the caller and distinct in the journal. +func (h *Handler) callback(w http.ResponseWriter, r *http.Request) { + h.cookies.ClearLogin(w) // whatever happens, this round trip is over + + // The callback WRITES a journal row on every refusal and needs no credential to do it. Without + // its own limit it is a free, unauthenticated way to grow a table: measured at ~880 rows/s from + // one host before this existed. + if !h.limiter.Allow() { + w.Header().Set("Retry-After", "5") + h.fail(w, http.StatusTooManyRequests, "Too many sign-in attempts", "") + return + } + q := r.URL.Query() + if e := q.Get("error"); e != "" { + // The user declined, or the provider refused. Not our error, still an event. + h.deny(w, r, "provider_error:"+sanitize(e)) + return + } + state, code := q.Get("state"), q.Get("code") + cookie, err := r.Cookie(h.cookies.LoginName()) + if err != nil || state == "" || code == "" { + h.deny(w, r, "missing_state") + return + } + // The cookie proves the callback came back to the browser that started: without it, an attacker + // can hand a victim a link that logs the victim into the ATTACKER's account. + if subtle.ConstantTimeCompare([]byte(state), []byte(cookie.Value)) != 1 { + h.deny(w, r, "state_mismatch") + return + } + st, err := h.store.TakeLoginState(r.Context(), auth.Digest(state), h.now()) + if err != nil { + h.deny(w, r, "unknown_state") // expired, already used, or never issued + return + } + // The state names the provider that issued it. With one provider this is a tautology; with two + // it is what stops a state minted by one being redeemed at the other — the IdP mix-up class. + if st.Provider != h.cfg.Provider { + h.deny(w, r, "state_from_another_provider") + return + } + + provider, err := h.discover(r.Context()) + if err != nil { + h.deny(w, r, "discovery_failed") + return + } + if reason := h.checkIssuer(q.Get("iss"), st); reason != "" { + h.deny(w, r, reason) + return + } + claims, err := h.identify(r.Context(), provider, code, st) + if err != nil { + h.log.ErrorContext(r.Context(), "identity could not be established", "err", err, + "provider", h.cfg.Provider, "login_start_id", st.StartID) + // A provider we could not reach is an outage; a token we refused is a rejection. Reported + // as one thing, a provider outage reads as a storm of bad tokens. + reason := "token_rejected" + var netErr net.Error + if errors.As(err, &netErr) || errors.Is(err, syscall.ECONNREFUSED) { + reason = "provider_unreachable" + } + h.deny(w, r, reason) + return + } + + // The signup credit goes only to an identity the provider vouched for. Without that condition a + // provider that lets anyone self-register turns every new subject into free credit, bounded only + // by the rate limiter; an unverified account is still created, just at zero, and an operator can + // grant it by hand. + grant := h.cfg.SignupGrantMicroUSD + if !claims.EmailVerified { + grant = 0 + } + userID, err := h.store.UpsertIdentity(r.Context(), Identity{ + Provider: h.cfg.Provider, + Subject: claims.Subject, + Email: claims.Email, + EmailVerified: claims.EmailVerified, + }, h.now(), grant) + if err != nil { + h.log.ErrorContext(r.Context(), "cannot bind identity", "err", err) + h.fail(w, http.StatusServiceUnavailable, "Sign-in is temporarily unavailable", "") + return + } + + // Session fixation: whatever session this browser was carrying, it does not carry it out of a + // login. The new session is a new secret, and the old one is dead rather than merely replaced. + if old, _, ok := auth.Present(r, h.cookies.SessionName()); ok { + if err := h.store.RevokeSession(r.Context(), auth.Digest(old), h.now()); err != nil { + h.log.ErrorContext(r.Context(), "cannot revoke the pre-login session", "err", err) + } + } + token := auth.NewToken() + if err := h.store.CreateSession(r.Context(), auth.Digest(token), userID, h.now(), + h.cfg.SessionIdleTTL, h.cfg.SessionMaxAge); err != nil { + h.log.ErrorContext(r.Context(), "cannot create session", "err", err) + h.fail(w, http.StatusServiceUnavailable, "Sign-in is temporarily unavailable", "") + return + } + // The cookie lives as long as the IDLE window, not the absolute one: a cookie that outlives the + // session it names makes every request after the timeout a 401 instead of a clean signed-out + // state. + h.cookies.SetSession(w, token, h.cfg.SessionIdleTTL) + h.record(r, LoginEvent{UserID: userID, Provider: h.cfg.Provider, Outcome: "success"}) + // Without this the log has two 303s and no sign that anyone signed in. No account id: user ids + // do not go to logs (ENGINEERING_STANDARDS §2) — the journal table is where "who" is answered. + h.log.InfoContext(r.Context(), "login succeeded", "provider", h.cfg.Provider, + "login_start_id", st.StartID, "client", clientClass(r.UserAgent())) + + dest := st.ReturnTo + if dest == "" { + dest = h.cfg.AfterLogin + } + http.Redirect(w, r, dest, http.StatusSeeOther) +} + +// logout ends this session. +func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { + if token, _, ok := auth.Present(r, h.cookies.SessionName()); ok { + if err := h.store.RevokeSession(r.Context(), auth.Digest(token), h.now()); err != nil { + h.log.ErrorContext(r.Context(), "cannot revoke session", "err", err) + h.fail(w, http.StatusServiceUnavailable, "Could not sign out", "") + return + } + } + h.cookies.ClearSession(w) + w.WriteHeader(http.StatusNoContent) +} + +// logoutAll ends every session of this user: the handle behind "sign out everywhere". +func (h *Handler) logoutAll(w http.ResponseWriter, r *http.Request) { + p, ok := auth.FromContext(r.Context()) + if !ok { + h.fail(w, http.StatusUnauthorized, "Session missing or invalid", "") + return + } + n, err := h.store.RevokeUserSessions(r.Context(), p.UserID, h.now()) + if err != nil { + h.log.ErrorContext(r.Context(), "cannot revoke sessions", "err", err) + h.fail(w, http.StatusServiceUnavailable, "Could not sign out", "") + return + } + h.log.InfoContext(r.Context(), "all sessions revoked", "count", n) + h.cookies.ClearSession(w) + w.WriteHeader(http.StatusNoContent) +} + +// claims is the only part of the identity token we keep. +type claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` +} + +// identify exchanges the code and verifies the identity token. Everything the provider issued dies +// with this function: no access token, no refresh token, no raw JWT leaves it. +func (h *Handler) identify(ctx context.Context, provider *oidc.Provider, code string, st State) (claims, error) { + // Bounds how long ONE caller waits. The client's own timeout (see httpClient) bounds the + // requests; this bounds the wait, including the wait on a key fetch another sign-in started. + // Without it the handler is held for as long as the browser keeps its socket, because the + // server sets no WriteTimeout by design. + bound := providerTimeout + if h.exchangeTimeout > 0 { + bound = h.exchangeTimeout + } + ctx, cancel := context.WithTimeout(ctx, bound) + defer cancel() + ctx = oidc.ClientContext(ctx, h.httpClient) + tok, err := h.oauth(provider).Exchange(ctx, code, oauth2.VerifierOption(st.Verifier)) + if err != nil { + return claims{}, fmt.Errorf("code exchange: %w", err) + } + raw, ok := tok.Extra("id_token").(string) + if !ok { + return claims{}, errors.New("no id_token in the token response") + } + idToken, err := provider.Verifier(&oidc.Config{ClientID: h.cfg.ClientID}).Verify(ctx, raw) + if err != nil { + return claims{}, fmt.Errorf("id token: %w", err) + } + // The nonce ties this token to OUR authorization request; without it a token minted for another + // request of the same client is replayable here. + if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(st.Nonce)) != 1 { + return claims{}, errors.New("id token nonce does not match the request") + } + var c claims + if err := idToken.Claims(&c); err != nil { + return claims{}, fmt.Errorf("id token claims: %w", err) + } + if c.Subject == "" { + return claims{}, errors.New("id token carries no subject") + } + return c, nil +} + +func (h *Handler) oauth(provider *oidc.Provider) *oauth2.Config { + return &oauth2.Config{ + ClientID: h.cfg.ClientID, + ClientSecret: h.cfg.ClientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: h.cfg.RedirectURL, + Scopes: h.cfg.Scopes, + } +} + +// Bounds on the two provider round trips this flow makes while a user waits. http.DefaultClient has +// no timeout of its own, so without these a stalled issuer holds a handler indefinitely. +const ( + discoveryTimeout = 5 * time.Second + // providerTimeout covers the code exchange AND the identity-token verification, which may fetch + // the signing keys. Longer than discovery because it is two round trips, not one. + providerTimeout = 10 * time.Second +) + +func (h *Handler) discover(ctx context.Context) (*oidc.Provider, error) { + h.mu.Lock() + cached := h.provider + h.mu.Unlock() + if cached != nil { + return cached, nil + } + // The fetch happens WITHOUT the lock. Holding it across the network call serialises every + // sign-in behind one slow provider: six concurrent requests against a 4-second issuer took + // 4, 8, 12, 16, 20 and 24 seconds instead of four. + ctx, cancel := context.WithTimeout(ctx, discoveryTimeout) + defer cancel() + // Passed unconditionally, and this is the load-bearing call: NewProvider captures the client and + // the key set it builds keeps using it, long after this context is gone. + ctx = oidc.ClientContext(ctx, h.httpClient) + p, err := oidc.NewProvider(ctx, h.cfg.Issuer) + if err != nil { + return nil, err + } + var flags struct { + IssSupported bool `json:"authorization_response_iss_parameter_supported"` + } + // A document that does not carry the field simply leaves it false. A document that carries it + // with the wrong type errors — and that case is NOT silent, because it quietly disables the + // stripped-parameter half of the mix-up check (RFC 9207 §2.4) and would otherwise look like a + // provider that simply does not support iss. Refusing sign-in over it would be worse. + if err := p.Claims(&flags); err != nil { + h.log.WarnContext(ctx, "discovery document did not parse; assuming no iss parameter support", + "err", err, "provider", h.cfg.Provider) + } + h.mu.Lock() + if h.provider == nil { + h.provider, h.issSupported = p, flags.IssSupported + } + cached = h.provider + h.mu.Unlock() + return cached, nil +} + +// checkIssuer applies RFC 9207 §2.4 to the authorization response: the server that answered must be +// the one the request was sent to. It returns the journal reason for a refusal, or "". +// +// RFC 9700 §4.4.2 requires a mix-up defence only from the SECOND authorization server onwards, and +// there is one today. It is here anyway because the alternative is discovering, at the moment a +// second provider is configured, that the comparison in this handler was configuration compared +// with itself (PD-57) — and because a state carrying a different issuer than the one now configured +// is a redeploy mid-login, which this refuses rather than redeems. +func (h *Handler) checkIssuer(got string, st State) string { + if got != "" { + // "Simple string comparison" with the stored issuer, per RFC 9207 §2.4. A state written + // before the issuer column existed carries "" and is refused: those rows live ten minutes, + // so the upgrade window costs a retry, and failing open on an identity check costs more. + if got != st.Issuer { + return "issuer_mismatch" + } + return "" + } + h.mu.Lock() + supported := h.issSupported + h.mu.Unlock() + if supported { + // The parameter was stripped. RFC 9207 §2.4: clients MUST reject a response with no iss + // from a server that does send one. + return "issuer_missing" + } + return "" +} + +// deny is a refused login: one shape on the wire, one row in the journal with the reason. +func (h *Handler) deny(w http.ResponseWriter, r *http.Request, reason string) { + h.record(r, LoginEvent{Provider: h.cfg.Provider, Outcome: "denied", Reason: reason}) + h.log.WarnContext(r.Context(), "login denied", "reason", reason, "provider", h.cfg.Provider) + h.fail(w, http.StatusBadRequest, "Sign-in could not be completed", "") +} + +func (h *Handler) record(r *http.Request, ev LoginEvent) { + ev.At = h.now() + ev.IPPrefix = ipPrefix(r.RemoteAddr) + ev.Client = clientClass(r.UserAgent()) + // The journal must not decide whether a login succeeds: a failure to write it is logged and the + // login proceeds. Losing an audit line is bad; refusing a legitimate sign-in is worse. + if err := h.store.RecordLogin(r.Context(), ev); err != nil { + h.log.ErrorContext(r.Context(), "cannot record the login event", "err", err) + } +} + +// Fail writes the error body; the API layer installs it. No *http.Request: nothing in this flow +// needs one to write a problem, and the parameter existed only to make httpapi.WriteProblem not fit, +// which cost a forwarding shim (found by review). +type Fail func(w http.ResponseWriter, status int, title, detail string) + +// SetFail installs the error writer. Without it the handler answers in plain text. +func (h *Handler) SetFail(f Fail) { h.failFn = f } + +func (h *Handler) fail(w http.ResponseWriter, status int, title, detail string) { + if h.failFn != nil { + h.failFn(w, status, title, detail) + return + } + http.Error(w, title, status) +} + +// safeReturnTo accepts only a path on this site. An open redirect turns our own login link into a +// phishing tool, so the rule is an allowlist, not a blocklist of the tricks we thought of. +// +// The backslash is not paranoia: browsers normalise "\" to "/" in a URL, so "/\evil.example" +// arrives at the parser as "//evil.example" — a protocol-relative URL — while url.Parse here reads +// it as an ordinary path with a strange name and waves it through. +func safeReturnTo(raw string) string { + if raw == "" || raw[0] != '/' { + return "" + } + // Decode once more before judging. The query value has been unescaped exactly once, so "%5c" + // is still text here while the browser will read the redirect target as a backslash and + // normalise it to a slash: /%5c/evil.example is /\/evil.example is //evil.example. + decoded, err := url.PathUnescape(raw) + if err != nil { + return "" + } + for _, s := range [2]string{raw, decoded} { + if strings.ContainsAny(s, "\\\x00\t\r\n") { + return "" + } + if len(s) > 1 && (s[1] == '/' || s[1] == '\\') { + return "" // protocol-relative: a host, not a path + } + } + // Parsed for normalisation — u.String() is what reaches a Location header, and it escapes what + // the raw form left bare. The three emptiness conditions are a backstop, not a layer: nothing + // starting with "/" can carry a scheme or an opaque part, and a host needs the "//" the check + // above already refused, so no input reaches them and no test can pin them (PD-47, measured). + // They stay against a future change in url.Parse; the guarantee itself is pinned by + // FuzzSafeReturnTo, which resolves the result against the site's base URL. + u, err := url.Parse(raw) + if err != nil || u.Scheme != "" || u.Host != "" || u.Opaque != "" { + return "" + } + return u.String() +} diff --git a/platform/internal/login/login_test.go b/platform/internal/login/login_test.go new file mode 100644 index 00000000..d8e9cb4a --- /dev/null +++ b/platform/internal/login/login_test.go @@ -0,0 +1,693 @@ +package login + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "textmachine/platform/internal/auth" +) + +func newHandler(t *testing.T, iss *fakeIssuer, st *memStore) *Handler { + t.Helper() + h, err := New(Config{ + Provider: "google", + Issuer: iss.srv.URL, + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURL: "https://app.example.org/auth/callback", + AfterLogin: "/library", + SessionIdleTTL: time.Hour, + SessionMaxAge: 24 * time.Hour, + SignupGrantMicroUSD: 5_000_000, + }, st, auth.Cookies{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + h.httpClient = iss.srv.Client() + return h +} + +// begin runs the first leg and returns the redirect plus the browser's login cookie. +func begin(t *testing.T, h *Handler, returnTo string) (loc string, cookie *http.Cookie) { + t.Helper() + target := "/auth/login" + if returnTo != "" { + target += "?return_to=" + returnTo + } + rec := httptest.NewRecorder() + h.Routes(passthrough).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("login start = %d, want 303 (%s)", rec.Code, rec.Body.String()) + } + for _, c := range rec.Result().Cookies() { + if c.Name == auth.LoginCookieName { + cookie = c + } + } + if cookie == nil { + t.Fatal("no login cookie: the callback would have nothing to compare the state with") + } + return rec.Header().Get("Location"), cookie +} + +func passthrough(h http.Handler) http.Handler { return h } + +// callbackPath builds the authorization response the way a conforming server sends it — with the +// iss parameter of RFC 9207, which Google does send (its discovery document advertises +// authorization_response_iss_parameter_supported, checked live 05.08). +func callbackPath(iss *fakeIssuer, state string) string { + return "/auth/callback?code=abc&state=" + state + "&iss=" + url.QueryEscape(iss.srv.URL) +} + +// The whole flow, end to end, against a provider that really verifies PKCE and really signs the +// identity token. Mutation caught: dropping S256ChallengeOption, dropping VerifierOption, skipping +// the nonce comparison, or not creating the session. +func TestLoginCompletesAndCreatesOurOwnSession(t *testing.T) { + iss := newIssuer(t) + st := newMemStore() + h := newHandler(t, iss, st) + + loc, cookie := begin(t, h, "%2Flibrary%2Fbk1") + state, challenge, nonce := challengeFrom(t, loc) + iss.expectChallenge, iss.nonce = challenge, nonce + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, callbackPath(iss, state), nil) + req.AddCookie(cookie) + h.Routes(passthrough).ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("callback = %d, want 303 (%s)", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Location"); got != "/library/bk1" { + t.Fatalf("landed on %q, want the requested page", got) + } + if iss.tokenCalls != 1 { + t.Fatalf("token endpoint called %d times", iss.tokenCalls) + } + + var session *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == auth.CookieName { + session = c + } + } + if session == nil || session.Value == "" { + t.Fatal("no session cookie: the login proved an identity and issued nothing") + } + if !session.HttpOnly || !session.Secure || session.SameSite != http.SameSiteLaxMode { + t.Fatalf("session cookie attributes are weaker than the profile: %+v", session) + } + // OUR session, in OUR store, keyed by the digest — never the token. + st.mu.Lock() + defer st.mu.Unlock() + if len(st.sessions) != 1 { + t.Fatalf("sessions created: %d", len(st.sessions)) + } + if _, ok := st.sessions[string(auth.Digest(session.Value))]; !ok { + t.Fatal("the stored session is not keyed by the digest of the issued token") + } + if st.identities != 1 { + t.Fatalf("identity upserts: %d", st.identities) + } + if len(st.events) != 1 || st.events[0].Outcome != "success" || st.events[0].UserID == "" { + t.Fatalf("journal = %+v", st.events) + } + // Nothing the provider issued was kept. Asserted against everything the store was actually + // handed, so the claim can fail. + if len(st.saw) == 0 { + t.Fatal("the store recorded nothing: this assertion would pass against any implementation") + } + for _, v := range st.saw { + if strings.Contains(v, "opaque-access-token") || strings.Contains(v, ".") && strings.Count(v, ".") == 2 { + t.Fatalf("something vendor-issued reached the store: %q", v) + } + } +} + +// Each of these is a real attack on the callback, and each must end the same way on the wire and +// differently in the journal. +func TestCallbackRefusals(t *testing.T) { + for name, tc := range map[string]struct { + mutate func(t *testing.T, iss *fakeIssuer, state string, cookie *http.Cookie) (string, *http.Cookie) + reason string + }{ + "no cookie": { + mutate: func(_ *testing.T, _ *fakeIssuer, state string, _ *http.Cookie) (string, *http.Cookie) { + return state, nil + }, + reason: "missing_state", + }, + "cookie does not match the state": { + mutate: func(_ *testing.T, _ *fakeIssuer, state string, c *http.Cookie) (string, *http.Cookie) { + c.Value = "someone-elses-state" + return state, c + }, + reason: "state_mismatch", + }, + "state was never issued": { + mutate: func(_ *testing.T, _ *fakeIssuer, _ string, c *http.Cookie) (string, *http.Cookie) { + c.Value = "forged" + return "forged", c + }, + reason: "unknown_state", + }, + "token minted for another nonce": { + mutate: func(_ *testing.T, iss *fakeIssuer, state string, c *http.Cookie) (string, *http.Cookie) { + iss.nonce = "a-nonce-from-another-request" + return state, c + }, + reason: "token_rejected", + }, + "token minted for another audience": { + mutate: func(_ *testing.T, iss *fakeIssuer, state string, c *http.Cookie) (string, *http.Cookie) { + iss.audience = "another-client" + return state, c + }, + reason: "token_rejected", + }, + "expired token": { + mutate: func(_ *testing.T, iss *fakeIssuer, state string, c *http.Cookie) (string, *http.Cookie) { + iss.expiresIn = -time.Minute + return state, c + }, + reason: "token_rejected", + }, + } { + t.Run(name, func(t *testing.T) { + iss := newIssuer(t) + st := newMemStore() + h := newHandler(t, iss, st) + loc, cookie := begin(t, h, "") + state, challenge, nonce := challengeFrom(t, loc) + iss.expectChallenge, iss.nonce = challenge, nonce + + state, cookie = tc.mutate(t, iss, state, cookie) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, callbackPath(iss, state), nil) + if cookie != nil { + req.AddCookie(cookie) + } + h.Routes(passthrough).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("callback = %d, want 400", rec.Code) + } + st.mu.Lock() + defer st.mu.Unlock() + if len(st.sessions) != 0 { + t.Fatal("a refused login created a session") + } + if len(st.events) != 1 || st.events[0].Outcome != "denied" { + t.Fatalf("journal = %+v", st.events) + } + if got := st.events[0].Reason; !strings.HasPrefix(got, tc.reason) { + t.Fatalf("journal reason = %q, want %q", got, tc.reason) + } + }) + } +} + +// A state may be spent once. The second callback carrying it — a replay, or the second half of a +// race — must find nothing. +func TestStateCannotBeReplayed(t *testing.T) { + iss := newIssuer(t) + st := newMemStore() + h := newHandler(t, iss, st) + loc, cookie := begin(t, h, "") + state, challenge, nonce := challengeFrom(t, loc) + iss.expectChallenge, iss.nonce = challenge, nonce + + call := func() int { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, callbackPath(iss, state), nil) + req.AddCookie(cookie) + h.Routes(passthrough).ServeHTTP(rec, req) + return rec.Code + } + if code := call(); code != http.StatusSeeOther { + t.Fatalf("first callback = %d", code) + } + if code := call(); code != http.StatusBadRequest { + t.Fatalf("replayed callback = %d, want 400", code) + } + if iss.tokenCalls != 1 { + t.Fatalf("a replayed state reached the token endpoint %d times", iss.tokenCalls) + } +} + +// Session fixation: whatever session the browser carried into the login, it does not carry out. +// Mutation caught: removing the revoke of the presented session. +func TestLoginRevokesThePresentedSession(t *testing.T) { + iss := newIssuer(t) + st := newMemStore() + h := newHandler(t, iss, st) + + planted := auth.NewToken() + st.sessions[string(auth.Digest(planted))] = "victim" + + loc, cookie := begin(t, h, "") + state, challenge, nonce := challengeFrom(t, loc) + iss.expectChallenge, iss.nonce = challenge, nonce + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, callbackPath(iss, state), nil) + req.AddCookie(cookie) + req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: planted}) + h.Routes(passthrough).ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("callback = %d", rec.Code) + } + st.mu.Lock() + defer st.mu.Unlock() + if _, alive := st.sessions[string(auth.Digest(planted))]; alive { + t.Fatal("the session presented at login survived it: that is session fixation") + } +} + +// An open redirect turns our own login link into a phishing tool, and "//evil.example" is a path to +// a browser. Mutation caught: relaxing safeReturnTo to a HasPrefix("/") check; dropping the second +// decode (PD-47 — the percent-encoded block below is the only thing that reaches it). +func TestReturnToNeverLeavesThisSite(t *testing.T) { + // Every one of these must come back EMPTY. "Starts with a slash" is not the assertion: a + // browser normalises "\" to "/", so /\evil.example is a host once it reaches the URL parser. + for _, raw := range []string{ + "//evil.example/", + "https://evil.example/", + "http:/evil.example", + `/\evil.example`, + `/\/evil.example`, + "/\tevil", + "evil.example", + "", + // Percent-encoded. The query value arrives here unescaped exactly once, so these are still + // text to the raw check and only the second decode sees what they say. Judged conservatively + // rather than by what a conforming browser would do with them. + "/%5c/evil.example", + "/%5C/evil.example", + "/%09evil", + "/%00evil", + "/%0d%0aSet-Cookie:%20x=y", + // Reaches only the protocol-relative check, and only on the decoded form: "/%2f/evil.example" + // carries no backslash and parses as an ordinary same-site path. A conforming browser would + // not treat %2f as a separator, so this is the allowlist being deliberately stricter than the + // parser — and the input that keeps that branch from being deleted unnoticed. + "/%2f/evil.example", + "/%2F/evil.example", + } { + if got := safeReturnTo(raw); got != "" { + t.Fatalf("safeReturnTo(%q) = %q, want the default landing page", raw, got) + } + } + // The other direction, so that "reject anything with a percent sign" is not a passing answer: + // a legitimate encoded query must survive intact. + for raw, want := range map[string]string{ + "/library/bk1?tab=notes": "/library/bk1?tab=notes", + "/library/bk1?q=%D0%BA%D0%BD%D0%B8%D0%B3%D0%B0": "/library/bk1?q=%D0%BA%D0%BD%D0%B8%D0%B3%D0%B0", + } { + if got := safeReturnTo(raw); got != want { + t.Fatalf("safeReturnTo(%q) = %q, want %q", raw, got, want) + } + } +} + +// PD-48. The signup credit is the only place in this flow that spends money, and it goes only to an +// identity the provider vouched for: a provider that lets anyone self-register would otherwise turn +// every new subject into free credit. The account is still created — at zero, for an operator to +// grant by hand. Mutation caught: deleting the EmailVerified condition. +func TestSignupGrantGoesOnlyToAVerifiedIdentity(t *testing.T) { + for _, verified := range []bool{true, false} { + iss := newIssuer(t) + iss.emailVerified = verified + st := newMemStore() + h := newHandler(t, iss, st) + + loc, cookie := begin(t, h, "") + state, challenge, nonce := challengeFrom(t, loc) + iss.expectChallenge, iss.nonce = challenge, nonce + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, callbackPath(iss, state), nil) + req.AddCookie(cookie) + h.Routes(passthrough).ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("verified=%v: callback = %d, want 303 (%s)", verified, rec.Code, rec.Body.String()) + } + + st.mu.Lock() + grants := append([]int64(nil), st.grants...) + st.mu.Unlock() + if len(grants) != 1 { + t.Fatalf("verified=%v: identity upserts = %d, want 1", verified, len(grants)) + } + want := int64(0) + if verified { + want = 5_000_000 + } + if grants[0] != want { + t.Fatalf("verified=%v: signup grant = %d micro-USD, want %d", verified, grants[0], want) + } + } +} + +// PD-49. The state names the provider that issued it, and a state minted for one must not be +// redeemable at another — the IdP mix-up class. Latent while there is one provider, which is +// exactly why it needs a test rather than a reader. Mutation caught: deleting the comparison. +func TestStateFromAnotherProviderIsRefused(t *testing.T) { + iss := newIssuer(t) + st := newMemStore() + h := newHandler(t, iss, st) + + loc, cookie := begin(t, h, "") + state, challenge, nonce := challengeFrom(t, loc) + iss.expectChallenge, iss.nonce = challenge, nonce + + // The row was written by a start leg naming a DIFFERENT provider — the shape a second provider + // creates the moment one is added. + st.mu.Lock() + key := string(auth.Digest(state)) + row := st.states[key] + row.Provider = "another-idp" + st.states[key] = row + st.mu.Unlock() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, callbackPath(iss, state), nil) + req.AddCookie(cookie) + h.Routes(passthrough).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("callback = %d, want 400: a state from another provider was redeemed here", rec.Code) + } + st.mu.Lock() + defer st.mu.Unlock() + if len(st.sessions) != 0 { + t.Fatal("a session was issued for a state this provider never minted") + } + if len(st.events) != 1 || st.events[0].Reason != "state_from_another_provider" { + t.Fatalf("journal = %+v, want the refusal named", st.events) + } + if iss.tokenCalls != 0 { + t.Fatal("the code was exchanged before the state's provider was checked") + } +} + +// The one unauthenticated endpoint that WRITES has to be bounded, or the first bot to find it fills +// the table. Mutation caught: removing the limiter check. +func TestLoginStartIsRateLimited(t *testing.T) { + iss := newIssuer(t) + st := newMemStore() + h, err := New(Config{ + Provider: "google", Issuer: iss.srv.URL, ClientID: "test-client", ClientSecret: "s", + RedirectURL: "https://app.example.org/auth/callback", + StartRate: 1, StartBurst: 3, + }, st, auth.Cookies{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + h.httpClient = iss.srv.Client() + + var limited bool + for range 10 { + rec := httptest.NewRecorder() + h.Routes(passthrough).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) + if rec.Code == http.StatusTooManyRequests { + limited = true + break + } + } + if !limited { + t.Fatal("the login endpoint accepted ten bursts without a limit") + } +} + +// An identity provider that is down must not take the service with it, and must not read as a bug. +func TestProviderOutageIsTemporaryNotFatal(t *testing.T) { + iss := newIssuer(t) + st := newMemStore() + h := newHandler(t, iss, st) + iss.srv.Close() // discovery has not run yet: the handler never touched the network at boot + + rec := httptest.NewRecorder() + h.Routes(passthrough).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("login with the provider down = %d, want 503", rec.Code) + } +} + +// memStore is the flow's persistence, in memory. The SQL implementation is tested against a live +// Postgres in the pgstore package; here the subject is the flow. +type memStore struct { + mu sync.Mutex + states map[string]State + sessions map[string]string // digest -> user id + events []LoginEvent + // saw records every string the flow hands the store. The point is one assertion: nothing the + // PROVIDER issued may reach persistence. The previous field was never written to, so that + // assertion could not fail — the package's defining property was unpinned (found by review). + saw []string + // grants records the credit passed with each upsert. Kept because the signup grant is the one + // decision in this flow that spends money, and a store that discarded the amount left the rule + // unpinned (PD-48). + grants []int64 + identities int +} + +func newMemStore() *memStore { + return &memStore{states: map[string]State{}, sessions: map[string]string{}} +} + +func (m *memStore) PutLoginState(_ context.Context, s State) error { + m.mu.Lock() + defer m.mu.Unlock() + m.saw = append(m.saw, s.Provider, s.Issuer, s.Nonce, s.Verifier, s.ReturnTo, s.StartID) + m.states[string(s.Hash)] = s + return nil +} + +func (m *memStore) TakeLoginState(_ context.Context, hash []byte, now time.Time) (State, error) { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.states[string(hash)] + if !ok || !s.ExpiresAt.After(now) { + return State{}, errors.New("no state") + } + delete(m.states, string(hash)) + return s, nil +} + +func (m *memStore) UpsertIdentity(_ context.Context, in Identity, _ time.Time, grant int64) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.saw = append(m.saw, in.Provider, in.Subject, in.Email) + m.identities++ + m.grants = append(m.grants, grant) + return "user-" + in.Provider + "-" + in.Subject, nil +} + +func (m *memStore) CreateSession(_ context.Context, digest []byte, userID string, _ time.Time, _, _ time.Duration) error { + m.mu.Lock() + defer m.mu.Unlock() + m.sessions[string(digest)] = userID + return nil +} + +func (m *memStore) RevokeSession(_ context.Context, digest []byte, _ time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sessions, string(digest)) + return nil +} + +func (m *memStore) RevokeUserSessions(_ context.Context, userID string, _ time.Time) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var n int64 + for d, u := range m.sessions { + if u == userID { + delete(m.sessions, d) + n++ + } + } + return n, nil +} + +func (m *memStore) RecordLogin(_ context.Context, ev LoginEvent) error { + m.mu.Lock() + defer m.mu.Unlock() + m.saw = append(m.saw, ev.UserID, ev.Provider, ev.Outcome, ev.Reason, ev.IPPrefix, ev.Client) + m.events = append(m.events, ev) + return nil +} + +// PD-57. RFC 9207 §2.4 in both directions: an authorization response carrying an issuer other than +// the one the request went to must be rejected, and a response with NO issuer must be rejected when +// the server is known to send one — otherwise stripping the parameter defeats the check. +// +// Both refusals happen BEFORE the code is exchanged: RFC 9207 says the client must not proceed with +// the grant, and a code handed to the wrong token endpoint is already leaked. +// Mutation caught: deleting the checkIssuer call, or either of its two branches. +func TestAuthorizationResponseIssuerIsChecked(t *testing.T) { + for name, tc := range map[string]struct { + issSupported bool + iss string // "" means the parameter is absent + wantCode int + wantReason string + }{ + "issuer of another server": {issSupported: true, iss: "https://evil.example", wantCode: http.StatusBadRequest, wantReason: "issuer_mismatch"}, + "parameter stripped": {issSupported: true, iss: "", wantCode: http.StatusBadRequest, wantReason: "issuer_missing"}, + "server that does not send one": {issSupported: false, iss: "", wantCode: http.StatusSeeOther}, + "server that does not send one, but did": {issSupported: false, iss: "https://evil.example", wantCode: http.StatusBadRequest, wantReason: "issuer_mismatch"}, + } { + t.Run(name, func(t *testing.T) { + iss := newIssuer(t) + iss.issSupported = tc.issSupported + st := newMemStore() + h := newHandler(t, iss, st) + loc, cookie := begin(t, h, "") + state, challenge, nonce := challengeFrom(t, loc) + iss.expectChallenge, iss.nonce = challenge, nonce + + target := "/auth/callback?code=abc&state=" + state + if tc.iss != "" { + target += "&iss=" + url.QueryEscape(tc.iss) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, target, nil) + req.AddCookie(cookie) + h.Routes(passthrough).ServeHTTP(rec, req) + + if rec.Code != tc.wantCode { + t.Fatalf("callback = %d, want %d (%s)", rec.Code, tc.wantCode, rec.Body.String()) + } + st.mu.Lock() + defer st.mu.Unlock() + if tc.wantReason == "" { + return + } + if len(st.sessions) != 0 { + t.Fatal("a session was issued for a response from an unverified issuer") + } + if len(st.events) != 1 || st.events[0].Reason != tc.wantReason { + t.Fatalf("journal = %+v, want reason %q", st.events, tc.wantReason) + } + if iss.tokenCalls != 0 { + t.Fatalf("the code was exchanged %d times before the issuer was checked", iss.tokenCalls) + } + }) + } +} + +// A provider that accepts the connection and never answers must not hold the handler. In production +// httpClient is nil, so the exchange runs on http.DefaultClient — which has no timeout — and go-oidc +// builds its key set from context.Background(); the server sets no WriteTimeout either, so nothing +// else bounds it. The JWKS leg is the worse one: the key set is shared, so one stalled fetch parks +// every concurrent sign-in behind it. Found by review, reproduced on the production wiring. +// Mutation caught: removing the context.WithTimeout from identify. +func TestAStalledProviderDoesNotHoldTheCallback(t *testing.T) { + for _, stall := range []string{"token", "keys"} { + t.Run(stall, func(t *testing.T) { + iss := newIssuer(t) + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + iss.stall, iss.stallOn = release, stall + + st := newMemStore() + h := newHandler(t, iss, st) + h.exchangeTimeout = 300 * time.Millisecond + loc, cookie := begin(t, h, "") + state, challenge, nonce := challengeFrom(t, loc) + iss.expectChallenge, iss.nonce = challenge, nonce + + done := make(chan int, 1) + go func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, callbackPath(iss, state), nil) + req.AddCookie(cookie) + h.Routes(passthrough).ServeHTTP(rec, req) + done <- rec.Code + }() + + select { + case code := <-done: + if code != http.StatusBadRequest { + t.Fatalf("callback = %d, want 400", code) + } + case <-time.After(5 * time.Second): + t.Fatal("the callback is still waiting on a provider that never answered: identify applies no deadline") + } + st.mu.Lock() + defer st.mu.Unlock() + if len(st.sessions) != 0 { + t.Fatal("a session was issued without an identity") + } + }) + } +} + +// The bound that matters is the one on the client go-oidc keeps. Provider.Verifier uses the key set +// built at discovery, and go-oidc stores it with context.WithoutCancel — so our per-request deadline +// never reaches its refetch. If that refetch is unbounded, one hung JWKS request keeps every later +// sign-in failing after the endpoint is healthy again, because they all queue on the same inflight +// fetch. Found by review, measured. Mutation caught: returning http.DefaultClient (or nil) from +// Handler.client, or making the default client's Timeout zero. +func TestTheDefaultProviderClientIsBounded(t *testing.T) { + h, err := New(Config{ + Provider: "google", Issuer: "https://accounts.example.org", ClientID: "c", ClientSecret: "s", + RedirectURL: "https://app.example.org/auth/callback", + }, newMemStore(), auth.Cookies{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + c := h.httpClient + if c == nil || c == http.DefaultClient { + t.Fatal("provider requests would run on http.DefaultClient, which has no timeout") + } + if c.Timeout <= 0 || c.Timeout > time.Minute { + t.Fatalf("default provider client timeout = %v: go-oidc's own key refetch inherits this and nothing else bounds it", c.Timeout) + } +} + +// The behavioural half: a JWKS fetch that hangs must not poison the sign-ins that come after it. +// Mutation caught: handing go-oidc an unbounded client at discovery. +func TestAHungKeyFetchDoesNotPoisonLaterSignIns(t *testing.T) { + iss := newIssuer(t) + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + iss.stall, iss.stallOn, iss.stallOnce = release, "keys", true + + st := newMemStore() + h := newHandler(t, iss, st) + // The production client is bounded by providerTimeout; the test's is bounded the same way, just + // faster. Without a bound on THIS client the second sign-in below never gets its keys. + h.httpClient = &http.Client{Transport: iss.srv.Client().Transport, Timeout: 300 * time.Millisecond} + h.exchangeTimeout = 2 * time.Second + + signIn := func() int { + loc, cookie := begin(t, h, "") + state, challenge, nonce := challengeFrom(t, loc) + iss.expectChallenge, iss.nonce = challenge, nonce + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, callbackPath(iss, state), nil) + req.AddCookie(cookie) + h.Routes(passthrough).ServeHTTP(rec, req) + return rec.Code + } + + if code := signIn(); code != http.StatusBadRequest { + t.Fatalf("the sign-in that met the hung key endpoint = %d, want 400", code) + } + if code := signIn(); code != http.StatusSeeOther { + t.Fatalf("the sign-in AFTER the key endpoint recovered = %d, want 303: the hung fetch is still holding every later sign-in", code) + } +} diff --git a/platform/internal/login/returnto_fuzz_test.go b/platform/internal/login/returnto_fuzz_test.go new file mode 100644 index 00000000..56839c29 --- /dev/null +++ b/platform/internal/login/returnto_fuzz_test.go @@ -0,0 +1,55 @@ +package login + +import ( + "net/url" + "strings" + "testing" +) + +// FuzzSafeReturnTo pins the PROPERTY rather than the branches. safeReturnTo is layered — a prefix +// check, a character class, a protocol-relative check, a parse — and the layers overlap, so +// removing any single one can leave a hand-written table green while the guarantee is gone (PD-47, +// where two mutations survived for exactly that reason). +// +// The oracle is independent of the implementation: whatever comes back is resolved against the +// site's own base URL with net/url, after the normalisation a browser performs on backslashes. If +// the result lands on another host, the redirect leaves this site — which is the whole guarantee. +func FuzzSafeReturnTo(f *testing.F) { + for _, seed := range []string{ + "", "/", "//", "/library/bk1?tab=notes", "//evil.example/", "https://evil.example/", + "http:/evil.example", `/\evil.example`, `/\/evil.example`, "/\tevil", "evil.example", + "/%5c/evil.example", "/%2f/evil.example", "/%00evil", "/%0d%0aSet-Cookie:%20x=y", + "/library/bk1?q=%D0%BA%D0%BD%D0%B8%D0%B3%D0%B0", "/a?b=c#d", "/..%2f..%2fetc", "///evil", + "/@evil.example", "/\\\\evil.example", "//user@evil.example/", "/%25%35%63evil", + } { + f.Add(seed) + } + + base, err := url.Parse("https://app.example.org/library") + if err != nil { + f.Fatal(err) + } + + f.Fuzz(func(t *testing.T, raw string) { + got := safeReturnTo(raw) + if got == "" { + return // the default landing page: always safe + } + // A value that reaches a Location header must not be able to end it. + if strings.ContainsAny(got, "\x00\r\n") { + t.Fatalf("safeReturnTo(%q) = %q: a control character in a Location header", raw, got) + } + // Browsers treat a backslash in a URL as a separator; net/url does not. Normalise the way + // the browser will, then let the standard resolver — not our own checks — say where it lands. + for _, form := range [2]string{got, strings.ReplaceAll(got, `\`, "/")} { + ref, err := url.Parse(form) + if err != nil { + t.Fatalf("safeReturnTo(%q) = %q: %v does not parse: %v", raw, got, form, err) + } + abs := base.ResolveReference(ref) + if abs.Scheme != base.Scheme || abs.Host != base.Host { + t.Fatalf("safeReturnTo(%q) = %q resolves to %q — off this site", raw, got, abs) + } + } + }) +} diff --git a/platform/internal/login/store.go b/platform/internal/login/store.go new file mode 100644 index 00000000..fde9d8d3 --- /dev/null +++ b/platform/internal/login/store.go @@ -0,0 +1,109 @@ +package login + +import ( + "context" + "net" + "strings" + "time" + "unicode" +) + +// State is one authorization round trip, held server-side. The state itself is stored as a digest: +// it travels in a URL and in a cookie, so it is a credential like any other. +type State struct { + Hash []byte + // Provider is OUR nickname for the issuer: it names which configuration the callback loads. + Provider string + // Issuer is the identifier of the authorization server this request was SENT to, kept so the + // callback can compare it with what came back. RFC 9700 §4.4.2 makes storing it the + // prerequisite of either mix-up defence; the binding to the user agent is the state cookie. + Issuer string + Nonce string + Verifier string + ReturnTo string + // StartID is the request id of the leg that created this state, so the log can join the two + // halves of one sign-in without naming the user. + StartID string + CreatedAt time.Time + ExpiresAt time.Time +} + +// Identity is what the provider proved. Subject is the key; the address is a hint. +type Identity struct { + Provider string + Subject string + Email string + EmailVerified bool +} + +// LoginEvent is one line of the journal. +type LoginEvent struct { + UserID string // empty when the attempt never reached an account + Provider string + Outcome string // success | denied + Reason string + IPPrefix string + Client string + At time.Time +} + +// Store is the persistence the flow needs. It is an interface so the flow is testable without a +// database and so the SQL stays in one package. +type Store interface { + PutLoginState(ctx context.Context, s State) error + // TakeLoginState consumes the state: a second callback with the same one must fail. Expiry is a + // clause of the query, not a check the caller could forget. + TakeLoginState(ctx context.Context, hash []byte, now time.Time) (State, error) + // UpsertIdentity resolves (provider, subject) to a user, creating the account — and writing its + // signup grant in the SAME transaction — when the pair is new. + UpsertIdentity(ctx context.Context, in Identity, now time.Time, signupGrantMicroUSD int64) (userID string, err error) + CreateSession(ctx context.Context, digest []byte, userID string, now time.Time, idleTTL, maxAge time.Duration) error + RevokeSession(ctx context.Context, digest []byte, now time.Time) error + RevokeUserSessions(ctx context.Context, userID string, now time.Time) (int64, error) + RecordLogin(ctx context.Context, ev LoginEvent) error +} + +// ipPrefix truncates an address to a network: /24 for IPv4, /48 for IPv6. The journal answers +// "roughly where from", and a full address would make it a tracking database of its own. +func ipPrefix(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + ip := net.ParseIP(host) + if ip == nil { + return "" + } + if v4 := ip.To4(); v4 != nil { + return v4.Mask(net.CIDRMask(24, 32)).String() + "/24" + } + return ip.Mask(net.CIDRMask(48, 128)).String() + "/48" +} + +// clientClass reduces a user agent to a word. Storing the string itself would be a fingerprint; +// the question a user asks is "was that my browser or my desktop app". +func clientClass(ua string) string { + switch { + case ua == "": + return "unknown" + case strings.Contains(ua, "tmctl") || strings.Contains(ua, "textmachine"): + return "desktop" + case strings.Contains(ua, "Mozilla"): + return "browser" + default: + return "other" + } +} + +// sanitize keeps a provider-supplied token loggable: short, printable, no control characters. +func sanitize(s string) string { + if len(s) > 40 { + s = s[:40] + } + return strings.Map(func(r rune) rune { + if unicode.IsPrint(r) && r < unicode.MaxASCII { + return r + } + return '?' + }, s) +} diff --git a/platform/internal/money/money.go b/platform/internal/money/money.go new file mode 100644 index 00000000..e6c3b2b2 --- /dev/null +++ b/platform/internal/money/money.go @@ -0,0 +1,86 @@ +// Package money is the one place a currency amount is represented. Whole micro-dollars, never a +// float: a float64 cannot hold 0.1, and an accounting system that drifts by a rounding step per +// operation drifts in the same direction every time. +package money + +import ( + "errors" + "fmt" + "math/big" + "regexp" + "strings" +) + +// decimal is the accepted syntax: an optional sign, digits, an optional fraction, an optional +// exponent. Nothing else is an amount of money. +var decimal = regexp.MustCompile(`^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$`) + +// MicroUSD is a whole number of millionths of a dollar. Signed, because a ledger has debits. +type MicroUSD int64 + +// PerUSD is the scale. +const PerUSD = 1_000_000 + +// maxAmountLen bounds the text form. Sixty-four characters is more than any real amount and far +// less than a denial of service. +const maxAmountLen = 64 + +// UnmarshalJSON converts a decimal from the wire exactly, rounding UP (toward +infinity). +// +// The direction is a decision: the engine's own ledger is a lower bound (unified backlog row 78), +// so a cost rounded down undercharges the account by construction, every time, the same way. +func (m *MicroUSD) UnmarshalJSON(b []byte) error { + s := strings.Trim(strings.TrimSpace(string(b)), `"`) + if s == "null" { + *m = 0 + return nil + } + if s == "" { + // An empty string is not zero. Reading it as zero is how a missing figure becomes "the + // attempt cost nothing" on the settlement path. + return errors.New("money: empty amount") + } + v, err := ParseUSD(s) + if err != nil { + return err + } + *m = v + return nil +} + +// ParseUSD reads a decimal number of dollars ("5", "0.75", "1e-6") as micro-dollars, rounding UP: +// -1.9999999 is -1999999, not -2000000. Exact — the text becomes a rational, never a float. +func ParseUSD(s string) (MicroUSD, error) { + s = strings.TrimSpace(s) + // An amount of money is short. Without a bound the rational parse is superlinear in the input: + // a two-megabyte run of nines takes seconds and puts itself in the error message. + if len(s) > maxAmountLen { + return 0, fmt.Errorf("money: amount is %d characters long", len(s)) + } + // big.Rat also accepts "0x10" and "1/3". Neither is an amount of money anybody meant to type, + // and both would be read silently — so the accepted syntax is stated here rather than inherited. + if !decimal.MatchString(s) { + return 0, fmt.Errorf("money: %q is not a decimal amount", s) + } + r, ok := new(big.Rat).SetString(s) + if !ok { + return 0, fmt.Errorf("money: %q is not a number", s) + } + r.Mul(r, big.NewRat(PerUSD, 1)) + q, rem := new(big.Int).QuoRem(r.Num(), r.Denom(), new(big.Int)) + if rem.Sign() > 0 { // QuoRem truncates toward zero, which is already the ceiling for negatives + q.Add(q, big.NewInt(1)) + } + if !q.IsInt64() { + return 0, fmt.Errorf("money: %q does not fit in micro-USD", s) + } + return MicroUSD(q.Int64()), nil +} + +// USD renders the amount for the admin CLI only: money reaches no response, screen or INFO log. +func (m MicroUSD) USD() string { + // big.Rat, not integer arithmetic on the parts: it is already the type this package parses with, + // and it removes the case that made the hand-written version subtle — MinInt64, whose negation + // overflows back to itself. Verified identical on the whole range including both extremes. + return new(big.Rat).SetFrac64(int64(m), PerUSD).FloatString(6) +} diff --git a/platform/internal/money/money_test.go b/platform/internal/money/money_test.go new file mode 100644 index 00000000..5e0d3c66 --- /dev/null +++ b/platform/internal/money/money_test.go @@ -0,0 +1,56 @@ +package money + +import "testing" + +// The whole point of the type is that these answers are exact. Mutation caught: implementing +// ParseUSD with strconv.ParseFloat and a multiplication. +func TestParseUSDIsExactAndRoundsUp(t *testing.T) { + cases := map[string]MicroUSD{ + "0": 0, + "5": 5 * PerUSD, + "2.50": 2_500_000, + "0.1": 100_000, // 0.1 has no float64 representation; 0.1*1e6 is 100000.00000000001 + "29.7": 29_700_000, + "0.000001": 1, + "0.0000001": 1, // a tenth of a micro-dollar still costs one + "1e-6": 1, + "-2.5": -2_500_000, + // Up means toward +infinity on BOTH sides of zero, which is what "never undercharge" + // means when the amount is a debit: a fraction of a micro-dollar owed is not owed. + "-0.0000001": 0, + "-1.9999999": -1_999_999, + " 1.25 ": 1_250_000, + "123456.7891": 123_456_789_100, + } + for in, want := range cases { + got, err := ParseUSD(in) + if err != nil { + t.Fatalf("ParseUSD(%q): %v", in, err) + } + if got != want { + t.Fatalf("ParseUSD(%q) = %d, want %d", in, got, want) + } + } +} + +func TestParseUSDRefusesNonsense(t *testing.T) { + for _, in := range []string{"", "free", "5 dollars", "1e30", "0x10"} { + if got, err := ParseUSD(in); err == nil { + t.Fatalf("ParseUSD(%q) = %d, want an error", in, got) + } + } +} + +func TestUSDRendersForOperatorsOnly(t *testing.T) { + cases := map[MicroUSD]string{ + 0: "0.000000", + 5 * PerUSD: "5.000000", + 1: "0.000001", + -2_500_000: "-2.500000", + } + for in, want := range cases { + if got := in.USD(); got != want { + t.Fatalf("%d.USD() = %q, want %q", int64(in), got, want) + } + } +} diff --git a/platform/internal/pgstore/credits.go b/platform/internal/pgstore/credits.go new file mode 100644 index 00000000..a113ef45 --- /dev/null +++ b/platform/internal/pgstore/credits.go @@ -0,0 +1,341 @@ +package pgstore + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "textmachine/platform/internal/money" +) + +var ( + // ErrInsufficientCredit is a refusal, not a failure: the account has less than the run needs. + ErrInsufficientCredit = errors.New("pgstore: insufficient credit") + // ErrNoReservation means the hold this settlement refers to is not open. + ErrNoReservation = errors.New("pgstore: no open reservation") + // ErrDuplicateHold is a second hold on an attempt id that already has one. It is an error, not + // a no-op: a hold that debits nothing reserves nothing while reporting that it did. + ErrDuplicateHold = errors.New("pgstore: attempt already has a hold") + // ErrNotOwner is a book that does not belong to the account being charged for it. + ErrNotOwner = errors.New("pgstore: book belongs to another account") + // ErrNoAccount separates "this account has nothing" from "this account does not exist" — the + // difference between a balance of zero and a typo in an admin command. + ErrNoAccount = errors.New("pgstore: no such account") +) + +// Grant credits an account and reports whether this call is what credited it. The free tier is one +// of these and nothing more. +// +// (source, sourceID) is the idempotency key, scoped to the account by the schema. applied is false +// when the key was already spent: the caller must say so rather than print a success it did not +// cause. +func (s *Store) Grant(ctx context.Context, userID string, amount money.MicroUSD, source, sourceID, note string, now time.Time) (applied bool, err error) { + if amount <= 0 { + return false, fmt.Errorf("pgstore: grant must be positive, got %d", amount) + } + if source == "" || sourceID == "" { + return false, errors.New("pgstore: grant needs an idempotency key") + } + err = s.inTx(ctx, func(tx pgx.Tx) error { + applied, err = appendLedger(ctx, tx, userID, "grant", amount, source, sourceID, note, now) + return err + }) + return applied, err +} + +// Adjust corrects a balance. A ledger row is never edited: the correction is another row, which is +// what keeps the sum reproducible. The note is mandatory, in the DDL as well as here. +func (s *Store) Adjust(ctx context.Context, userID string, amount money.MicroUSD, source, sourceID, note string, now time.Time) (applied bool, err error) { + if amount == 0 || note == "" { + return false, errors.New("pgstore: an adjustment needs a non-zero amount and a reason") + } + if source == "" || sourceID == "" { + return false, errors.New("pgstore: adjustment needs an idempotency key") + } + err = s.inTx(ctx, func(tx pgx.Tx) error { + applied, err = appendLedger(ctx, tx, userID, "adjustment", amount, source, sourceID, note, now) + return err + }) + return applied, err +} + +// Balance is what the account may still spend, read from the cache that every ledger write updates +// in its own transaction. +func (s *Store) Balance(ctx context.Context, userID string) (money.MicroUSD, error) { + var v *int64 + err := s.pool.QueryRow(ctx, ` + select b.balance_micro_usd + from users u left join account_balances b on b.user_id = u.id + where u.id = $1`, userID).Scan(&v) + if errors.Is(err, pgx.ErrNoRows) { + return 0, ErrNoAccount + } + if err != nil { + return 0, fmt.Errorf("pgstore: balance: %w", err) + } + if v == nil { + return 0, nil // an account with no ledger rows has no credit, which is not an error + } + return money.MicroUSD(*v), nil +} + +// Account is what an operator needs to see about one account's money. +type Account struct { + Balance money.MicroUSD + Reserved money.MicroUSD + // LedgerSum is recomputed from the rows. It exists to be COMPARED with Balance, and both are + // read in one snapshot: reading them separately reports drift that a concurrent grant caused + // between the two queries. + LedgerSum money.MicroUSD +} + +// ReadAccount returns the money view in a single consistent snapshot. +func (s *Store) ReadAccount(ctx context.Context, userID string) (Account, error) { + var a Account + const q = ` + select coalesce((select balance_micro_usd from account_balances where user_id = $1), 0), + coalesce((select sum(amount_micro_usd) from credit_ledger where user_id = $1), 0), + coalesce((select sum(amount_micro_usd) from reservations + where user_id = $1 and state = 'open'), 0) + from users where id = $1` + err := s.pool.QueryRow(ctx, q, userID).Scan(&a.Balance, &a.LedgerSum, &a.Reserved) + if errors.Is(err, pgx.ErrNoRows) { + return Account{}, ErrNoAccount + } + if err != nil { + return Account{}, fmt.Errorf("pgstore: read account: %w", err) + } + return a, nil +} + +// Reservation is an open hold as an operator sees it. +type Reservation struct { + EngineRunID string + BookID string + Amount money.MicroUSD + Ceiling money.MicroUSD + OpenedAt time.Time +} + +// OpenReservations lists holds that were taken and never closed. Without this they are money that +// is gone from the balance and invisible to everything that could give it back. +func (s *Store) OpenReservations(ctx context.Context, userID string) ([]Reservation, error) { + const q = ` + select engine_run_id, book_id, amount_micro_usd, ceiling_micro_usd, opened_at + from reservations where user_id = $1 and state = 'open' order by opened_at` + rows, err := s.pool.Query(ctx, q, userID) + if err != nil { + return nil, fmt.Errorf("pgstore: open reservations: %w", err) + } + defer rows.Close() + var out []Reservation + for rows.Next() { + var r Reservation + if err := rows.Scan(&r.EngineRunID, &r.BookID, &r.Amount, &r.Ceiling, &r.OpenedAt); err != nil { + return nil, fmt.Errorf("pgstore: scan reservation: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// Hold reserves credit before a run is spawned. Together with the per-book ceiling handed to the +// engine it is the enforcement half of the money design: the hold makes the credit unavailable to +// the next run, and the engine stops itself at the ceiling, so an overspend is impossible even +// while the platform is blind. The event stream is freshness only. +// +// The ceiling to hand the engine is the amount held; read it back with OpenReservations. +func (s *Store) Hold(ctx context.Context, userID, bookID, engineRunID string, amount money.MicroUSD, now time.Time) error { + if amount <= 0 { + return fmt.Errorf("pgstore: hold must be positive, got %d", amount) + } + return s.inTx(ctx, func(tx pgx.Tx) error { + // account_balances is locked FIRST here and in every other operation. A path that locked + // the reservation first would invert the order against this one and deadlock — measured, + // not hypothetical. + balance, err := lockBalance(ctx, tx, userID) + if err != nil { + return err + } + if balance < amount { + return ErrInsufficientCredit + } + // The book must belong to the account being charged. The foreign key only proves the book + // exists, which is not the same question. + tag, err := tx.Exec(ctx, ` + insert into reservations (engine_run_id, user_id, book_id, amount_micro_usd, ceiling_micro_usd, state, opened_at) + select $1, $2, $3, $4, $4, 'open', $5 from books where id = $3 and owner_id = $2`, + engineRunID, userID, bookID, int64(amount), now) + if err != nil { + return fmt.Errorf("pgstore: open reservation: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotOwner + } + applied, err := appendLedger(ctx, tx, userID, "hold", -amount, "run", engineRunID, "", now) + if err != nil { + return err + } + if !applied { + // The ledger already holds this attempt id, so nothing was debited. Reporting success + // would spawn a run against credit that was never reserved. + return ErrDuplicateHold + } + return nil + }) +} + +// Settle closes a reservation with what the attempt actually cost: the hold comes back and the real +// cost is charged, in one transaction. Settling twice is refused — the reservation is no longer +// open — which is what makes it safe on a retried path. +// +// A cost above the hold is CAPPED at the hold and the row says so. Spending more than was reserved +// means the engine's ceiling did not hold, and the account is not the place to absorb that. +func (s *Store) Settle(ctx context.Context, engineRunID string, spent money.MicroUSD, now time.Time) error { + if spent < 0 { + return fmt.Errorf("pgstore: spend cannot be negative, got %d", spent) + } + return s.inTx(ctx, func(tx pgx.Tx) error { + userID, held, err := closeReservation(ctx, tx, engineRunID, "settled", now) + if err != nil { + return err + } + note := "" + if spent > held { + note = fmt.Sprintf("capped at the hold; the engine reported %s", spent.USD()) + spent = held + } + if _, err := appendLedger(ctx, tx, userID, "hold_release", held, "run_release", engineRunID, "", now); err != nil { + return err + } + _, err = appendLedger(ctx, tx, userID, "settlement", -spent, "run_settle", engineRunID, note, now) + return err + }) +} + +// Release gives a reservation back untouched: the run never started, or it cost nothing. +func (s *Store) Release(ctx context.Context, engineRunID string, now time.Time) error { + return s.inTx(ctx, func(tx pgx.Tx) error { + userID, held, err := closeReservation(ctx, tx, engineRunID, "released", now) + if err != nil { + return err + } + _, err = appendLedger(ctx, tx, userID, "hold_release", held, "run_release", engineRunID, "", now) + return err + }) +} + +// lockBalance takes the account's row lock and returns the balance under it. Every money operation +// starts here, so they all take their locks in the same order. +func lockBalance(ctx context.Context, tx pgx.Tx, userID string) (money.MicroUSD, error) { + var v int64 + err := tx.QueryRow(ctx, `select balance_micro_usd from account_balances where user_id = $1 for update`, userID).Scan(&v) + if errors.Is(err, pgx.ErrNoRows) { + return 0, ErrInsufficientCredit // no ledger row yet means no credit + } + if err != nil { + return 0, fmt.Errorf("pgstore: read balance: %w", err) + } + return money.MicroUSD(v), nil +} + +func closeReservation(ctx context.Context, tx pgx.Tx, engineRunID, state string, now time.Time) (string, money.MicroUSD, error) { + // Read the owner unlocked, lock the balance, then close under the state guard. The guard is + // what makes the unlocked read safe: a reservation closed by someone else in between makes the + // update match nothing. + var userID string + err := tx.QueryRow(ctx, `select user_id from reservations where engine_run_id = $1`, engineRunID).Scan(&userID) + if errors.Is(err, pgx.ErrNoRows) { + return "", 0, ErrNoReservation + } + if err != nil { + return "", 0, fmt.Errorf("pgstore: find reservation: %w", err) + } + if _, err := lockBalance(ctx, tx, userID); err != nil && !errors.Is(err, ErrInsufficientCredit) { + return "", 0, err + } + var amount int64 + err = tx.QueryRow(ctx, ` + update reservations set state = $2, closed_at = $3 + where engine_run_id = $1 and state = 'open' + returning amount_micro_usd`, engineRunID, state, now).Scan(&amount) + if errors.Is(err, pgx.ErrNoRows) { + return "", 0, ErrNoReservation + } + if err != nil { + return "", 0, fmt.Errorf("pgstore: close reservation: %w", err) + } + return userID, money.MicroUSD(amount), nil +} + +// appendLedger writes one row and moves the cached balance with it, in the caller's transaction. +// The two are never written apart: a cache that can lag its source is a second answer about money. +// applied is false when the idempotency key was already spent. +func appendLedger(ctx context.Context, tx pgx.Tx, userID, kind string, amount money.MicroUSD, source, sourceID, note string, now time.Time) (bool, error) { + tag, err := tx.Exec(ctx, ` + insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id, note, created_at) + values ($1, $2, $3, $4, $5, $6, $7) + on conflict (user_id, source, source_id) do nothing`, + userID, kind, int64(amount), source, sourceID, note, now) + if err != nil { + // A typo in an account id is the commonest way an operator gets here, and Balance already + // answers it with ErrNoAccount. Reporting the same fact as a raw constraint name reads as a + // broken database (PD-56). + var pg *pgconn.PgError + if errors.As(err, &pg) && pg.ConstraintName == "credit_ledger_user_id_fkey" { + return false, ErrNoAccount + } + return false, fmt.Errorf("pgstore: append ledger: %w", err) + } + if tag.RowsAffected() == 0 { + return false, nil + } + if _, err := tx.Exec(ctx, ` + insert into account_balances (user_id, balance_micro_usd, updated_at) + values ($1, $2, $3) + on conflict (user_id) do update + set balance_micro_usd = account_balances.balance_micro_usd + excluded.balance_micro_usd, + updated_at = excluded.updated_at`, + userID, int64(amount), now); err != nil { + return false, fmt.Errorf("pgstore: update balance: %w", err) + } + return true, nil +} + +func (s *Store) inTx(ctx context.Context, fn func(pgx.Tx) error) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("pgstore: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := fn(tx); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("pgstore: commit: %w", err) + } + return nil +} + +// DeleteBook removes a book and the CLOSED reservations that referenced it. An OPEN one blocks the +// delete (the foreign key is RESTRICT), which is the point: removing a book with money reserved +// against it would leave the hold in the ledger with nothing left to release it. +// +// Closed reservations carry no financial fact the ledger does not already hold — they are +// operational state — so removing them with the book loses nothing. +func (s *Store) DeleteBook(ctx context.Context, bookID string) error { + return s.inTx(ctx, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, + `delete from reservations where book_id = $1 and state <> 'open'`, bookID); err != nil { + return fmt.Errorf("pgstore: clear reservations: %w", err) + } + if _, err := tx.Exec(ctx, `delete from books where id = $1`, bookID); err != nil { + return fmt.Errorf("pgstore: delete book: %w", err) + } + return nil + }) +} diff --git a/platform/internal/pgstore/credits_test.go b/platform/internal/pgstore/credits_test.go new file mode 100644 index 00000000..dcb5bc65 --- /dev/null +++ b/platform/internal/pgstore/credits_test.go @@ -0,0 +1,435 @@ +package pgstore + +import ( + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + "textmachine/platform/internal/money" +) + +// The balance cache and the ledger are two representations of the same fact, and money is the one +// place where "usually consistent" is not a property. This asserts they agree after EVERY step. +// Mutation caught: updating account_balances outside the ledger's transaction, or skipping it. +func TestCreditLifecycleKeepsTheCacheEqualToTheLedger(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ('bk1','u1','蛊真人','zh','ru','not_started','/srv/books/bk1','gzr')`) + now := time.Now().UTC() + + check := func(step string, want money.MicroUSD) { + t.Helper() + a, err := s.ReadAccount(ctx, "u1") + if err != nil { + t.Fatalf("%s: %v", step, err) + } + if a.Balance != a.LedgerSum { + t.Fatalf("%s: cached balance %d disagrees with the ledger %d", step, a.Balance, a.LedgerSum) + } + if a.Balance != want { + t.Fatalf("%s: balance = %s, want %s", step, a.Balance.USD(), want.USD()) + } + } + + if _, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "g1", "free tier", now); err != nil { + t.Fatal(err) + } + check("after the grant", 5*money.PerUSD) + + if err := s.Hold(ctx, "u1", "bk1", "run-1", 2*money.PerUSD, now); err != nil { + t.Fatal(err) + } + check("while a run is held", 3*money.PerUSD) + + // The attempt cost less than it reserved: the difference comes back. + if err := s.Settle(ctx, "run-1", 1_200_000, now); err != nil { + t.Fatal(err) + } + check("after settlement", 5*money.PerUSD-1_200_000) + + // A second settlement of the same attempt changes nothing: the reservation is no longer open. + if err := s.Settle(ctx, "run-1", 1_200_000, now); !errors.Is(err, ErrNoReservation) { + t.Fatalf("a repeated settlement must be refused, got %v", err) + } + check("after a repeated settlement", 5*money.PerUSD-1_200_000) + + // A run that never spent gives its whole reservation back. + if err := s.Hold(ctx, "u1", "bk1", "run-2", 1*money.PerUSD, now); err != nil { + t.Fatal(err) + } + check("while the second run is held", 5*money.PerUSD-1_200_000-1*money.PerUSD) + if err := s.Release(ctx, "run-2", now); err != nil { + t.Fatal(err) + } + check("after release", 5*money.PerUSD-1_200_000) +} + +// Idempotency is what makes a retried worker safe. Mutation caught: dropping the ON CONFLICT clause +// (the insert then fails) or moving the balance update outside the "actually inserted" branch (the +// balance then doubles while the ledger does not). +func TestGrantIsIdempotentBySource(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + now := time.Now().UTC() + + for i := range 3 { + applied, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "same-key", "free tier", now) + if err != nil { + t.Fatal(err) + } + // The caller must be able to tell "credited" from "already spent": a CLI that prints + // success on the second call tells an operator money moved when it did not. + if applied != (i == 0) { + t.Fatalf("call %d reported applied=%v", i, applied) + } + } + got, err := s.Balance(ctx, "u1") + if err != nil { + t.Fatal(err) + } + if got != 5*money.PerUSD { + t.Fatalf("three identical grants credited %s", got.USD()) + } + var rows int + if err := s.pool.QueryRow(ctx, `select count(*) from credit_ledger where user_id='u1'`).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 1 { + t.Fatalf("ledger has %d rows for one grant", rows) + } +} + +// The hold is the enforcement half of the design: credit that is reserved is not available to the +// next run. Mutation caught: removing the balance check. The FOR UPDATE that serialises it is a +// CONCURRENCY property and no sequential test can see it — that one is pinned below. +func TestHoldRefusesMoreThanTheBalance(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ('bk1','u1','蛊真人','zh','ru','not_started','/srv/books/bk1','gzr')`) + now := time.Now().UTC() + if _, err := s.Grant(ctx, "u1", 1*money.PerUSD, "admin", "g1", "free tier", now); err != nil { + t.Fatal(err) + } + + if err := s.Hold(ctx, "u1", "bk1", "run-1", 2*money.PerUSD, now); !errors.Is(err, ErrInsufficientCredit) { + t.Fatalf("a hold beyond the balance must be refused, got %v", err) + } + // And the refusal left nothing behind. + got, err := s.Balance(ctx, "u1") + if err != nil { + t.Fatal(err) + } + if got != 1*money.PerUSD { + t.Fatalf("balance moved on a refused hold: %s", got.USD()) + } + var reservations int + if err := s.pool.QueryRow(ctx, `select count(*) from reservations`).Scan(&reservations); err != nil { + t.Fatal(err) + } + if reservations != 0 { + t.Fatalf("a refused hold left %d reservations", reservations) + } + + // An account with no credit at all is refused the same way, not crashed. + seedUser(t, s, ctx, "u2") + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ('bk2','u2','x','zh','ru','not_started','/srv/books/bk2','x')`) + if err := s.Hold(ctx, "u2", "bk2", "run-2", 1, now); !errors.Is(err, ErrInsufficientCredit) { + t.Fatalf("an account with no ledger must be refused, got %v", err) + } +} + +// The sign rules are DDL, so a sign error in the code that writes money fails at the write instead +// of quietly topping an account up. +func TestLedgerRefusesWrongSigns(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + t.Run("a grant is never a debit", func(t *testing.T) { + assertViolation(t, s, ctx, "credit_ledger_sign", + `insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id) + values ('u1','grant',-1,'x','1')`) + }) + t.Run("a hold is never a credit", func(t *testing.T) { + assertViolation(t, s, ctx, "credit_ledger_sign", + `insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id) + values ('u1','hold',1,'x','2')`) + }) + t.Run("a settlement never credits", func(t *testing.T) { + assertViolation(t, s, ctx, "credit_ledger_sign", + `insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id) + values ('u1','settlement',1,'x','3')`) + }) + t.Run("an adjustment carries a reason", func(t *testing.T) { + assertViolation(t, s, ctx, "credit_ledger_adjustment_has_note", + `insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id) + values ('u1','adjustment',5,'x','4')`) + }) + t.Run("a closed reservation has a closing time", func(t *testing.T) { + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`) + assertViolation(t, s, ctx, "reservations_closed_has_time", + `insert into reservations (engine_run_id, user_id, book_id, amount_micro_usd, ceiling_micro_usd, state) + values ('r1','u1','bk1',1,1,'settled')`) + }) +} + +// The payer must own the book. The database refuses it, not a check the next caller has to +// remember: charging one account for another's translation is the money shape of API1 BOLA. +// Mutation caught: inserting the reservation without the owner condition, or dropping the +// composite foreign key. +func TestHoldRefusesAnotherAccountsBook(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + seedUser(t, s, ctx, "u2") + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`) + now := time.Now().UTC() + if _, err := s.Grant(ctx, "u2", 5*money.PerUSD, "admin", "g", "", now); err != nil { + t.Fatal(err) + } + if err := s.Hold(ctx, "u2", "bk1", "run-1", money.PerUSD, now); !errors.Is(err, ErrNotOwner) { + t.Fatalf("holding against another account's book returned %v", err) + } + a, err := s.ReadAccount(ctx, "u2") + if err != nil { + t.Fatal(err) + } + if a.Balance != 5*money.PerUSD { + t.Fatalf("the refused hold moved money: %s", a.Balance.USD()) + } +} + +// A hold that debits nothing reserves nothing. If the ledger already holds this attempt id, the +// insert is a no-op and reporting success would spawn a run against credit nobody set aside. +func TestSecondHoldOnOneAttemptIsRefused(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`) + now := time.Now().UTC() + if _, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "g", "", now); err != nil { + t.Fatal(err) + } + if err := s.Hold(ctx, "u1", "bk1", "run-1", money.PerUSD, now); err != nil { + t.Fatal(err) + } + if err := s.Release(ctx, "run-1", now); err != nil { + t.Fatal(err) + } + // Same attempt id again: the reservation row is gone from `open`, but the ledger key is spent. + if err := s.Hold(ctx, "u1", "bk1", "run-1", money.PerUSD, now); err == nil { + t.Fatal("a second hold on one attempt id was accepted; it debited nothing") + } +} + +// Spending more than was reserved means the engine's ceiling did not hold. The account is not the +// place to absorb that: the settlement is capped and the row says so. +// Mutation caught: settling the reported amount unchecked (the balance then goes negative). +func TestSettlementIsCappedAtTheHold(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`) + now := time.Now().UTC() + if _, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "g", "", now); err != nil { + t.Fatal(err) + } + if err := s.Hold(ctx, "u1", "bk1", "run-1", 2*money.PerUSD, now); err != nil { + t.Fatal(err) + } + if err := s.Settle(ctx, "run-1", 500*money.PerUSD, now); err != nil { + t.Fatal(err) + } + a, err := s.ReadAccount(ctx, "u1") + if err != nil { + t.Fatal(err) + } + if a.Balance != 3*money.PerUSD { + t.Fatalf("balance = %s, want the hold and nothing more taken", a.Balance.USD()) + } + var note string + if err := s.pool.QueryRow(ctx, + `select note from credit_ledger where kind='settlement'`).Scan(¬e); err != nil { + t.Fatal(err) + } + if note == "" { + t.Fatal("a capped settlement must say so in the row") + } +} + +// A book with money reserved against it cannot be deleted. Cascading here would leave the `hold` +// row in the ledger with nothing left to release it. +func TestBookWithAnOpenHoldCannotBeDeleted(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`) + now := time.Now().UTC() + if _, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "g", "", now); err != nil { + t.Fatal(err) + } + if err := s.Hold(ctx, "u1", "bk1", "run-1", money.PerUSD, now); err != nil { + t.Fatal(err) + } + if err := s.DeleteBook(ctx, "bk1"); err == nil { + t.Fatal("a book with an open hold was deleted; its ledger debit is now unreleasable") + } + if err := s.Release(ctx, "run-1", now); err != nil { + t.Fatal(err) + } + if err := s.DeleteBook(ctx, "bk1"); err != nil { + t.Fatalf("a book with no open hold must be deletable: %v", err) + } + // The money history stays: the ledger is the financial record, the reservation was bookkeeping. + a, err := s.ReadAccount(ctx, "u1") + if err != nil { + t.Fatal(err) + } + if a.Balance != 5*money.PerUSD || a.Balance != a.LedgerSum { + t.Fatalf("deleting the book moved money: %s", a.Balance.USD()) + } +} + +// PD-26/PD-52. Every money operation takes the balance row lock FIRST, and the rule is only worth +// having if a test notices its removal. The cycle needs a settlement and a hold that touch the same +// reservation row: with the lock taken first in both, Settle waits for the balance before it touches +// the row, so Hold never waits on a row while holding what Settle wants. Take it out of +// closeReservation and the two acquire in opposite orders. +// +// Written by acceptance; re-measured here on PostgreSQL 18.4: with the lock order inverted it fails +// 5 runs out of 5, at 5-10 deadlocks per 150 rounds, and with the fix it is green. Probabilistic in +// the failing direction, which is why the round count stays high. +// Mutation caught: deleting the lockBalance call from closeReservation. +func TestHoldAndSettleOnTheSameAttemptDoNotDeadlock(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`) + now := time.Now().UTC() + if _, err := s.Grant(ctx, "u1", 100000*money.PerUSD, "admin", "g", "", now); err != nil { + t.Fatal(err) + } + + var mu sync.Mutex + var deadlocks int + note := func(err error) { + if err != nil && strings.Contains(err.Error(), "deadlock") { + mu.Lock() + deadlocks++ + mu.Unlock() + } + } + for i := range 150 { + id := fmt.Sprintf("run-%d", i) + if err := s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + wg.Go(func() { note(s.Settle(ctx, id, money.PerUSD/2, now)) }) + wg.Go(func() { note(s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now)) }) + wg.Wait() + } + + a, err := s.ReadAccount(ctx, "u1") + if err != nil { + t.Fatal(err) + } + if a.Balance != a.LedgerSum { + t.Fatalf("the cache drifted from the ledger: %s against %s", a.Balance.USD(), a.LedgerSum.USD()) + } + if deadlocks > 0 { + t.Fatalf("%d deadlocks in 150 rounds: the lock order is not the same in both paths", deadlocks) + } +} + +// PD-56. A typo in an account id is the commonest operator error, and every money entry point must +// name it the same way. Before this, Balance said "no such account" while Grant and Adjust returned +// the Postgres constraint name — which reads as a broken database, not a mistyped id. +// Mutation caught: dropping the constraint check in appendLedger. +func TestMoneyOperationsAgreeOnAMissingAccount(t *testing.T) { + s, ctx := testDB(t) + now := time.Now().UTC() + grant := func() error { _, err := s.Grant(ctx, "no-such-user", money.PerUSD, "admin", "k1", "", now); return err } + adjust := func() error { + _, err := s.Adjust(ctx, "no-such-user", money.PerUSD, "admin", "k2", "why", now) + return err + } + balance := func() error { _, err := s.Balance(ctx, "no-such-user"); return err } + read := func() error { _, err := s.ReadAccount(ctx, "no-such-user"); return err } + for name, call := range map[string]func() error{ + "grant": grant, "adjust": adjust, "balance": balance, "read account": read, + } { + t.Run(name, func(t *testing.T) { + if err := call(); !errors.Is(err, ErrNoAccount) { + t.Fatalf("got %v, want ErrNoAccount", err) + } + }) + } +} + +// The row lock, not the comparison, is what stops two runs from spending the same credit. Each hold +// here is affordable on its own and they are not affordable together, so without FOR UPDATE both +// read the same balance, both pass the check, and the account goes negative — the cache and the +// ledger drifting together, which is why the invariant assertions elsewhere cannot see it either. +// Found by review: the sequential test above claimed this and could not deliver it. +// Mutation caught: dropping `for update` from lockBalance. +func TestConcurrentHoldsCannotOvercommitAnAccount(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`) + now := time.Now().UTC() + + const rounds = 60 + for i := range rounds { + user := fmt.Sprintf("u-%d", i) + book := fmt.Sprintf("bk-%d", i) + seedUser(t, s, ctx, user) + exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id) + values ($1,$2,'x','zh','ru','not_started','/srv/books/x','x')`, book, user) + // Ten dollars, and two runs that each want six. + if _, err := s.Grant(ctx, user, 10*money.PerUSD, "admin", "g", "", now); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + var mu sync.Mutex + var granted int + for j := range 2 { + wg.Go(func() { + err := s.Hold(ctx, user, book, fmt.Sprintf("run-%d-%d", i, j), 6*money.PerUSD, now) + switch { + case err == nil: + mu.Lock() + granted++ + mu.Unlock() + case errors.Is(err, ErrInsufficientCredit): + default: + mu.Lock() + t.Errorf("round %d: unexpected hold error: %v", i, err) + mu.Unlock() + } + }) + } + wg.Wait() + + a, err := s.ReadAccount(ctx, user) + if err != nil { + t.Fatal(err) + } + if granted != 1 { + t.Fatalf("round %d: %d of two competing holds were granted from one balance; balance is now %s", + i, granted, a.Balance.USD()) + } + if a.Balance < 0 { + t.Fatalf("round %d: balance went negative (%s): two runs spent the same credit", i, a.Balance.USD()) + } + if a.Balance != a.LedgerSum { + t.Fatalf("round %d: cache %s disagrees with the ledger %s", i, a.Balance.USD(), a.LedgerSum.USD()) + } + } +} diff --git a/platform/internal/pgstore/identity.go b/platform/internal/pgstore/identity.go new file mode 100644 index 00000000..72486685 --- /dev/null +++ b/platform/internal/pgstore/identity.go @@ -0,0 +1,230 @@ +package pgstore + +import ( + "context" + "crypto/rand" + "encoding/base32" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "textmachine/platform/internal/login" + "textmachine/platform/internal/money" +) + +// newID mints an opaque identifier. Opaque on purpose: an id that encodes a row number tells a +// caller how many accounts exist and lets them guess a neighbour's. +func newID(prefix string) string { + var b [10]byte + rand.Read(b[:]) + return prefix + "_" + base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:]) +} + +// PutLoginState stores one in-flight authorization request. +func (s *Store) PutLoginState(ctx context.Context, st login.State) error { + const q = ` + insert into auth_states (state_sha256, provider, issuer, nonce, code_verifier, return_to, start_id, created_at, expires_at) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9)` + _, err := s.pool.Exec(ctx, q, st.Hash, st.Provider, st.Issuer, st.Nonce, st.Verifier, st.ReturnTo, + st.StartID, st.CreatedAt, st.ExpiresAt) + if err != nil { + return fmt.Errorf("pgstore: put login state: %w", err) + } + return nil +} + +// ErrNoLoginState is "expired", "already used" and "never issued" at once: telling them apart on +// the wire would be an oracle. +var ErrNoLoginState = errors.New("pgstore: no live login state") + +// TakeLoginState consumes the state. Deleting and returning in ONE statement is what makes it +// single-use under concurrency: a second callback with the same state deletes nothing and gets +// nothing, with no window between the check and the removal. +func (s *Store) TakeLoginState(ctx context.Context, hash []byte, now time.Time) (login.State, error) { + const q = ` + delete from auth_states + where state_sha256 = $1 and expires_at > $2 + returning provider, issuer, nonce, code_verifier, return_to, start_id, created_at, expires_at` + var st login.State + st.Hash = hash + err := s.pool.QueryRow(ctx, q, hash, now). + Scan(&st.Provider, &st.Issuer, &st.Nonce, &st.Verifier, &st.ReturnTo, &st.StartID, + &st.CreatedAt, &st.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return login.State{}, ErrNoLoginState + } + if err != nil { + return login.State{}, fmt.Errorf("pgstore: take login state: %w", err) + } + return st, nil +} + +// DeleteExpiredLoginStates is the sweep for abandoned logins. +func (s *Store) DeleteExpiredLoginStates(ctx context.Context, now time.Time) (int64, error) { + tag, err := s.pool.Exec(ctx, `delete from auth_states where expires_at <= $1`, now) + if err != nil { + return 0, fmt.Errorf("pgstore: sweep login states: %w", err) + } + return tag.RowsAffected(), nil +} + +// DeleteOldLoginEvents applies the journal's retention. /auth/callback writes a row on every +// refusal and needs no credential to do it, so a journal that only grows is a liability rather +// than an audit. +func (s *Store) DeleteOldLoginEvents(ctx context.Context, before time.Time) (int64, error) { + tag, err := s.pool.Exec(ctx, `delete from login_events where at < $1`, before) + if err != nil { + return 0, fmt.Errorf("pgstore: sweep login journal: %w", err) + } + return tag.RowsAffected(), nil +} + +// UpsertIdentity resolves (provider, subject) to an account. +// +// The pair is the ONLY key: an unknown pair always creates a new user, whatever address it arrives +// with. Attaching a second provider to an existing account is an authenticated action elsewhere, +// never a side effect of signing in. Why, in full: 00005_identity_oauth.sql. +// +// The signup grant is written in the SAME transaction as the account. A user that exists without +// their free tier — or a grant against a user that failed to commit — is not a state worth having. +func (s *Store) UpsertIdentity(ctx context.Context, in login.Identity, now time.Time, signupGrant int64) (string, error) { + // One retry: two first logins of the same brand-new identity can race, and the loser sees the + // row the winner inserted. + for attempt := range 2 { + userID, err := s.upsertIdentityOnce(ctx, in, now, signupGrant) + if err == nil { + return userID, nil + } + if !errors.Is(err, errIdentityRace) || attempt == 1 { + return "", err + } + } + return "", errIdentityRace +} + +var errIdentityRace = errors.New("pgstore: identity created concurrently") + +func (s *Store) upsertIdentityOnce(ctx context.Context, in login.Identity, now time.Time, signupGrant int64) (string, error) { + var userID string + err := s.inTx(ctx, func(tx pgx.Tx) error { + return upsertIdentityTx(ctx, tx, in, now, signupGrant, &userID) + }) + return userID, err +} + +func upsertIdentityTx(ctx context.Context, tx pgx.Tx, in login.Identity, now time.Time, signupGrant int64, out *string) error { + var userID string + err := tx.QueryRow(ctx, `select user_id from identities where provider = $1 and subject = $2 for update`, + in.Provider, in.Subject).Scan(&userID) + switch { + case err == nil: + // Known identity. The address is refreshed only when the provider says it is verified — + // an unverified one is kept on the identity and never promoted to the account. + if _, err := tx.Exec(ctx, ` + update identities set email = $3, email_verified = $4, last_login_at = $5 + where provider = $1 and subject = $2`, + in.Provider, in.Subject, nullable(in.Email), in.EmailVerified, now); err != nil { + return fmt.Errorf("pgstore: refresh identity: %w", err) + } + if in.EmailVerified && in.Email != "" { + if _, err := tx.Exec(ctx, `update users set email = $2 where id = $1`, userID, in.Email); err != nil { + return fmt.Errorf("pgstore: refresh account email: %w", err) + } + } + case errors.Is(err, pgx.ErrNoRows): + userID = newID("u") + var email any + if in.EmailVerified { + email = nullable(in.Email) + } + if _, err := tx.Exec(ctx, `insert into users (id, email, created_at) values ($1, $2, $3)`, + userID, email, now); err != nil { + return fmt.Errorf("pgstore: create user: %w", err) + } + tag, err := tx.Exec(ctx, ` + insert into identities (provider, subject, user_id, email, email_verified, created_at, last_login_at) + values ($1, $2, $3, $4, $5, $6, $6) + on conflict (provider, subject) do nothing`, + in.Provider, in.Subject, userID, nullable(in.Email), in.EmailVerified, now) + if err != nil { + return fmt.Errorf("pgstore: create identity: %w", err) + } + if tag.RowsAffected() == 0 { + return errIdentityRace + } + if signupGrant > 0 { + // A brand-new account cannot have spent this key, so "already applied" is not a case. + if _, err := appendLedger(ctx, tx, userID, "grant", money.MicroUSD(signupGrant), "signup", userID, "free tier", now); err != nil { + return err + } + } + default: + return fmt.Errorf("pgstore: find identity: %w", err) + } + *out = userID + return nil +} + +// RevokeUserSessions ends every session of a user at once. +func (s *Store) RevokeUserSessions(ctx context.Context, userID string, now time.Time) (int64, error) { + tag, err := s.pool.Exec(ctx, + `update sessions set revoked_at = $2 where user_id = $1 and revoked_at is null`, userID, now) + if err != nil { + return 0, fmt.Errorf("pgstore: revoke user sessions: %w", err) + } + return tag.RowsAffected(), nil +} + +// RecordLogin appends to the login journal. +func (s *Store) RecordLogin(ctx context.Context, ev login.LoginEvent) error { + const q = ` + insert into login_events (user_id, provider, outcome, reason, ip_prefix, client, at) + values ($1, $2, $3, $4, $5, $6, $7)` + _, err := s.pool.Exec(ctx, q, nullable(ev.UserID), ev.Provider, ev.Outcome, ev.Reason, ev.IPPrefix, ev.Client, ev.At) + if err != nil { + return fmt.Errorf("pgstore: record login: %w", err) + } + return nil +} + +// LoginEntry is one journal line as an operator reads it. +type LoginEntry struct { + Provider string + Outcome string + Reason string + IPPrefix string + Client string + At time.Time +} + +// RecentLogins backs "where have I signed in from" and the admin CLI. +func (s *Store) RecentLogins(ctx context.Context, userID string, limit int) ([]LoginEntry, error) { + const q = ` + select provider, outcome, reason, ip_prefix, client, at + from login_events where user_id = $1 order by at desc limit $2` + rows, err := s.pool.Query(ctx, q, userID, limit) + if err != nil { + return nil, fmt.Errorf("pgstore: recent logins: %w", err) + } + defer rows.Close() + var out []LoginEntry + for rows.Next() { + var e LoginEntry + if err := rows.Scan(&e.Provider, &e.Outcome, &e.Reason, &e.IPPrefix, &e.Client, &e.At); err != nil { + return nil, fmt.Errorf("pgstore: scan login: %w", err) + } + out = append(out, e) + } + return out, rows.Err() +} + +// nullable turns "" into SQL NULL: an empty string and "no address" are different facts, and a +// unique index would treat them differently too. +func nullable(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/platform/internal/pgstore/identity_test.go b/platform/internal/pgstore/identity_test.go new file mode 100644 index 00000000..275900d2 --- /dev/null +++ b/platform/internal/pgstore/identity_test.go @@ -0,0 +1,296 @@ +package pgstore + +import ( + "errors" + "fmt" + "reflect" + "sync" + "testing" + "time" + + "textmachine/platform/internal/auth" + "textmachine/platform/internal/login" + "textmachine/platform/internal/money" +) + +const signupGrant = 5 * money.PerUSD + +// THE account-model test. Identity is (provider, subject); an address is a hint. A second identity +// arriving with an address that already belongs to someone must NOT land in that account — that is +// the takeover path, and no amount of email_verified makes it safe. +// Mutation caught: resolving the account by email, or adding a unique index on users.email. +func TestIdentityNeverJoinsAccountsByEmail(t *testing.T) { + s, ctx := testDB(t) + now := time.Now().UTC() + + first, err := s.UpsertIdentity(ctx, login.Identity{ + Provider: "google", Subject: "sub-A", Email: "reader@example.org", EmailVerified: true, + }, now, signupGrant) + if err != nil { + t.Fatal(err) + } + // Same address, different subject — a different person, or the same address handed on. + second, err := s.UpsertIdentity(ctx, login.Identity{ + Provider: "google", Subject: "sub-B", Email: "reader@example.org", EmailVerified: true, + }, now, signupGrant) + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatal("two subjects with the same address were merged into one account: that is a takeover") + } + // And another provider carrying the same address is a third account, not an implicit link. + third, err := s.UpsertIdentity(ctx, login.Identity{ + Provider: "github", Subject: "sub-A", Email: "reader@example.org", EmailVerified: true, + }, now, signupGrant) + if err != nil { + t.Fatal(err) + } + if third == first || third == second { + t.Fatal("an identity from another provider was linked by address alone") + } +} + +// Signing in again is not signing up: the account, and its grant, are created exactly once. +func TestReturningIdentityKeepsItsAccountAndIsGrantedOnce(t *testing.T) { + s, ctx := testDB(t) + now := time.Now().UTC() + id := login.Identity{Provider: "google", Subject: "sub-A", Email: "reader@example.org", EmailVerified: true} + + userID, err := s.UpsertIdentity(ctx, id, now, signupGrant) + if err != nil { + t.Fatal(err) + } + balance, err := s.Balance(ctx, userID) + if err != nil { + t.Fatal(err) + } + if balance != signupGrant { + t.Fatalf("a new account starts with %s, want %s", balance.USD(), money.MicroUSD(signupGrant).USD()) + } + + for range 3 { + again, err := s.UpsertIdentity(ctx, id, now.Add(time.Hour), signupGrant) + if err != nil { + t.Fatal(err) + } + if again != userID { + t.Fatalf("a returning identity got a new account: %s then %s", userID, again) + } + } + balance, err = s.Balance(ctx, userID) + if err != nil { + t.Fatal(err) + } + if balance != signupGrant { + t.Fatalf("signing in again granted more credit: %s", balance.USD()) + } +} + +// An unverified address is kept on the identity and never promoted to the account: the account's +// address is what a person is shown and what an operator searches by. +func TestUnverifiedAddressStaysOffTheAccount(t *testing.T) { + s, ctx := testDB(t) + now := time.Now().UTC() + + userID, err := s.UpsertIdentity(ctx, login.Identity{ + Provider: "google", Subject: "sub-A", Email: "unverified@example.org", EmailVerified: false, + }, now, 0) + if err != nil { + t.Fatal(err) + } + var accountEmail *string + if err := s.pool.QueryRow(ctx, `select email from users where id = $1`, userID).Scan(&accountEmail); err != nil { + t.Fatal(err) + } + if accountEmail != nil { + t.Fatalf("an unverified address reached the account: %q", *accountEmail) + } + var identityEmail *string + if err := s.pool.QueryRow(ctx, + `select email from identities where provider='google' and subject='sub-A'`).Scan(&identityEmail); err != nil { + t.Fatal(err) + } + if identityEmail == nil || *identityEmail != "unverified@example.org" { + t.Fatal("the identity should still remember the address it arrived with") + } + + // Once the provider verifies it, the account picks it up. + if _, err := s.UpsertIdentity(ctx, login.Identity{ + Provider: "google", Subject: "sub-A", Email: "unverified@example.org", EmailVerified: true, + }, now, 0); err != nil { + t.Fatal(err) + } + if err := s.pool.QueryRow(ctx, `select email from users where id = $1`, userID).Scan(&accountEmail); err != nil { + t.Fatal(err) + } + if accountEmail == nil || *accountEmail != "unverified@example.org" { + t.Fatal("a verified address should reach the account") + } +} + +// The state is single-use and it expires. Both are clauses of one statement, so a second callback +// racing the first cannot win: it deletes nothing. +// Mutation caught: splitting the DELETE ... RETURNING into a SELECT and a later DELETE. +func TestLoginStateIsSingleUseAndExpires(t *testing.T) { + s, ctx := testDB(t) + // Truncated to what timestamptz stores, so the round trip below can be compared whole. + now := time.Now().UTC().Truncate(time.Microsecond) + st := login.State{ + Hash: auth.Digest("state-1"), Provider: "google", Issuer: "https://accounts.example.org", + Nonce: "n", Verifier: "v", ReturnTo: "/library", StartID: "REQ-ABC123", + CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute), + } + if err := s.PutLoginState(ctx, st); err != nil { + t.Fatal(err) + } + + got, err := s.TakeLoginState(ctx, st.Hash, now) + if err != nil { + t.Fatal(err) + } + // The WHOLE struct, not the three fields someone remembered: StartID had been carried by + // login.State since P1 with no column behind it, so both `login_start_id` log lines were empty + // in production while the in-memory store used by the login tests showed them filled (PD-62). + // Comparing everything is what makes the next field added without a column fail here. + got.CreatedAt, got.ExpiresAt = got.CreatedAt.UTC(), got.ExpiresAt.UTC() + if !reflect.DeepEqual(got, st) { + t.Fatalf("the state did not survive the store whole:\n got %+v\n want %+v", got, st) + } + if _, err := s.TakeLoginState(ctx, st.Hash, now); !errors.Is(err, ErrNoLoginState) { + t.Fatalf("a state must be usable once, got %v", err) + } + + expired := login.State{Hash: auth.Digest("state-2"), Provider: "google", Nonce: "n", Verifier: "v", + CreatedAt: now, ExpiresAt: now.Add(time.Minute)} + if err := s.PutLoginState(ctx, expired); err != nil { + t.Fatal(err) + } + if _, err := s.TakeLoginState(ctx, expired.Hash, now.Add(2*time.Minute)); !errors.Is(err, ErrNoLoginState) { + t.Fatalf("an expired state must be refused, got %v", err) + } + n, err := s.DeleteExpiredLoginStates(ctx, now.Add(2*time.Minute)) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("sweep removed %d abandoned logins, want 1", n) + } +} + +// "Sign out everywhere" has to reach sessions this request never saw — the property a self-verifying +// token cannot have, and the reason a session is a row. +func TestRevokeUserSessionsEndsAllOfThem(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + seedUser(t, s, ctx, "u2") + now := time.Now().UTC() + + var mine [][]byte + for range 3 { + d := auth.Digest(auth.NewToken()) + if err := s.CreateSession(ctx, d, "u1", now, time.Hour, 24*time.Hour); err != nil { + t.Fatal(err) + } + mine = append(mine, d) + } + other := auth.Digest(auth.NewToken()) + if err := s.CreateSession(ctx, other, "u2", now, time.Hour, 24*time.Hour); err != nil { + t.Fatal(err) + } + + n, err := s.RevokeUserSessions(ctx, "u1", now) + if err != nil { + t.Fatal(err) + } + if n != 3 { + t.Fatalf("revoked %d sessions, want 3", n) + } + for _, d := range mine { + if _, err := s.Lookup(ctx, d, now); !errors.Is(err, auth.ErrNoSession) { + t.Fatalf("a revoked session still resolves: %v", err) + } + } + if _, err := s.Lookup(ctx, other, now); err != nil { + t.Fatalf("another user's session was revoked too: %v", err) + } +} + +// The journal survives the session sweep and stays coarse: a prefix and a class, never an address +// or a user agent. +func TestLoginJournalRecordsAttempts(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + now := time.Now().UTC() + + if err := s.RecordLogin(ctx, login.LoginEvent{ + UserID: "u1", Provider: "google", Outcome: "success", IPPrefix: "203.0.113.0/24", Client: "browser", At: now, + }); err != nil { + t.Fatal(err) + } + // A refused attempt has no account to belong to and still leaves a line. + if err := s.RecordLogin(ctx, login.LoginEvent{ + Provider: "google", Outcome: "denied", Reason: "state_mismatch", IPPrefix: "203.0.113.0/24", At: now, + }); err != nil { + t.Fatal(err) + } + + entries, err := s.RecentLogins(ctx, "u1", 10) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Outcome != "success" || entries[0].Client != "browser" { + t.Fatalf("journal = %+v", entries) + } + var denied int + if err := s.pool.QueryRow(ctx, `select count(*) from login_events where user_id is null`).Scan(&denied); err != nil { + t.Fatal(err) + } + if denied != 1 { + t.Fatalf("a denied attempt without an account left %d rows", denied) + } +} + +// The single-use property is CONCURRENT, and only a concurrent test can see it: splitting the +// DELETE ... RETURNING into a SELECT and a later DELETE leaves sequential behaviour identical while +// two callbacks racing on one state both succeed — which is a login handed to whoever replayed it. +// The pin table claimed the sequential test above caught that split; it does not (found by review). +// Mutation caught: SELECT-then-DELETE in TakeLoginState. +func TestOnlyOneRacingCallbackCanConsumeAState(t *testing.T) { + s, ctx := testDB(t) + now := time.Now().UTC().Truncate(time.Microsecond) + + const rounds = 40 + for i := range rounds { + hash := auth.Digest(fmt.Sprintf("state-%d", i)) + if err := s.PutLoginState(ctx, login.State{ + Hash: hash, Provider: "google", Issuer: "https://accounts.example.org", + Nonce: "n", Verifier: "v", CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute), + }); err != nil { + t.Fatal(err) + } + var mu sync.Mutex + var won int + var wg sync.WaitGroup + for range 4 { + wg.Go(func() { + _, err := s.TakeLoginState(ctx, hash, now) + switch { + case err == nil: + mu.Lock() + won++ + mu.Unlock() + case errors.Is(err, ErrNoLoginState): + default: + mu.Lock() + t.Errorf("round %d: %v", i, err) + mu.Unlock() + } + }) + } + wg.Wait() + if won != 1 { + t.Fatalf("round %d: %d of four racing callbacks consumed the same state", i, won) + } + } +} diff --git a/platform/internal/pgstore/migrate.go b/platform/internal/pgstore/migrate.go index 2c01709d..e3c7b93b 100644 --- a/platform/internal/pgstore/migrate.go +++ b/platform/internal/pgstore/migrate.go @@ -32,23 +32,35 @@ func Migrations() fs.FS { // database/sql, and a one-connection handle is what the session locker needs anyway. The lock is a // Postgres advisory lock, so two instances rolling out at once serialize instead of racing. func Migrate(ctx context.Context, dsn string) error { - db, err := sql.Open("pgx", dsn) + p, closeDB, err := newProvider(dsn) if err != nil { - return fmt.Errorf("pgstore: open migration handle: %w", err) - } - defer db.Close() - db.SetMaxOpenConns(1) - - locker, err := lock.NewPostgresSessionLocker() - if err != nil { - return fmt.Errorf("pgstore: locker: %w", err) - } - p, err := goose.NewProvider(goose.DialectPostgres, db, Migrations(), goose.WithSessionLocker(locker)) - if err != nil { - return fmt.Errorf("pgstore: goose provider: %w", err) + return err } + defer closeDB() if _, err := p.Up(ctx); err != nil { return fmt.Errorf("pgstore: migrate: %w", err) } return nil } + +// newProvider builds the goose provider. Shared with the down-path test so that the rollback the +// test proves is the rollback the deployment would run, not a second implementation of it. +func newProvider(dsn string) (*goose.Provider, func(), error) { + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, nil, fmt.Errorf("pgstore: open migration handle: %w", err) + } + db.SetMaxOpenConns(1) + + locker, err := lock.NewPostgresSessionLocker() + if err != nil { + db.Close() + return nil, nil, fmt.Errorf("pgstore: locker: %w", err) + } + p, err := goose.NewProvider(goose.DialectPostgres, db, Migrations(), goose.WithSessionLocker(locker)) + if err != nil { + db.Close() + return nil, nil, fmt.Errorf("pgstore: goose provider: %w", err) + } + return p, func() { db.Close() }, nil +} diff --git a/platform/internal/pgstore/migrations.sha256 b/platform/internal/pgstore/migrations.sha256 new file mode 100644 index 00000000..df09d642 --- /dev/null +++ b/platform/internal/pgstore/migrations.sha256 @@ -0,0 +1,16 @@ +# Fingerprints of migrations that have been released. goose applies a migration by its NUMBER +# alone — it stores no name and no checksum — so a file that changes after it has run anywhere is +# a migration that silently never happens again, and a number reused for different SQL leaves that +# database permanently unable to roll back. This file is the gate: changing a listed migration +# means changing a line here, deliberately, where a reviewer sees it. +# +# Adding a migration: append its line. Changing one that is already listed: don't. + +90b29e9601ef342a7ac74ea582f309e0c4b200ba9ad2a3458c17d83accecce36 00001_identity.sql +9226b95b4d0935cf4d2b85ab153bff7452af32fc0eee387b3a6c1ae02c42b31a 00002_readmodel.sql +1c62dbe06066f17e71872d781a41a930ff86cb53204a39b0692031ec0b959666 00003_usage.sql +f2ccaa2b6d08446ad8672046a5d18a1bf9b5124fae2b5d8f1ca1e096dd920dbd 00004_readmodel_indexes.sql +7c959680cd0fe7e6c0c45325e2fcad0f92443a6717aa6eff07d0832d2e83bfa1 00005_identity_oauth.sql +bb3fc11975e515fecb1ccdb2fa7aa756d7c739741f031ddd5f86fd5617f8b84a 00006_drop_usage_draft.sql +c225e1a12bab8c62669848976096453aa84d66263fe211da0a5eb4b173ff2eff 00007_credits.sql +67c1bbdf85a4e02a610e840d539211e768fec5c0905b75529a75112c21286008 00008_auth_state_issuer_and_start_id.sql diff --git a/platform/internal/pgstore/migrations/00004_readmodel_indexes.sql b/platform/internal/pgstore/migrations/00004_readmodel_indexes.sql new file mode 100644 index 00000000..694677f5 --- /dev/null +++ b/platform/internal/pgstore/migrations/00004_readmodel_indexes.sql @@ -0,0 +1,16 @@ +-- +goose Up + +-- Indexes for the cascading foreign keys of 00002 (PD-11): deleting a chapter or a unit otherwise +-- scans the child table once per deleted row, and re-chunking a book deletes thousands. +create index notes_chapter_idx on notes (chapter_id); +create index bank_decisions_term_idx on bank_decisions (term_id); + +-- The referenced key for the reservation's composite foreign key (00007): it is what makes +-- "charging account A for account B's book" impossible in the database rather than in a check the +-- next caller has to remember. +create unique index books_id_owner_idx on books (id, owner_id); + +-- +goose Down +drop index books_id_owner_idx; +drop index bank_decisions_term_idx; +drop index notes_chapter_idx; diff --git a/platform/internal/pgstore/migrations/00005_identity_oauth.sql b/platform/internal/pgstore/migrations/00005_identity_oauth.sql new file mode 100644 index 00000000..45c84206 --- /dev/null +++ b/platform/internal/pgstore/migrations/00005_identity_oauth.sql @@ -0,0 +1,73 @@ +-- +goose Up + +-- Sign-in through OIDC (P-6). The provider supplies the EVENT of a login and nothing else: the +-- session that follows is ours, revocable in one row. + +-- Identity is the pair (provider, subject) and nothing else, so the address stops being a key. +-- +-- Google states it outright: an address can change hands and must not be a primary identifier. +-- Rules that follow: +-- * an unknown (provider, subject) always creates a NEW user, whatever address it arrives with; +-- * a second provider is attached to an existing account by an authenticated ACTION; +-- * users.email is refreshed only from a verified address. +-- The cost is two accounts for one person who signs in with two providers — a duplicate, which a +-- person can merge. The cost of the alternative is an account taken over by whoever inherits an +-- address, which nobody can undo. +alter table users alter column email drop not null; +drop index users_email_key; + +create table identities ( + provider text not null, + subject text not null, + user_id text not null references users (id) on delete cascade, + email text, + email_verified boolean not null default false, + created_at timestamptz not null default now(), + last_login_at timestamptz not null default now(), + primary key (provider, subject) +); + +create index identities_user_idx on identities (user_id); + +-- The in-flight half of a login: one row per authorization request, single-use, minutes long. +-- The state travels in a URL and in a cookie, so it is stored as a digest like any other +-- credential. The verifier is the PKCE secret; it never leaves this server. +create table auth_states ( + state_sha256 bytea primary key, + provider text not null, + nonce text not null, + code_verifier text not null, + return_to text not null default '', + created_at timestamptz not null default now(), + expires_at timestamptz not null +); + +create index auth_states_expiry_idx on auth_states (expires_at); + +-- The login journal. The sessions table is swept and is not an audit: "when did I last sign in, +-- and from what" has to survive the sweep, and it is the evidence behind "sign out everywhere". +-- +-- Coarse on purpose: an address prefix and a client class, never a full IP or user agent. +-- SET NULL rather than cascade: deleting an account must not erase the evidence of how it was +-- accessed — the row is anonymised, not destroyed. +create table login_events ( + id bigint generated always as identity primary key, + user_id text references users (id) on delete set null, + provider text not null, + outcome text not null check (outcome in ('success', 'denied')), + reason text not null default '', + ip_prefix text not null default '', + client text not null default '', + at timestamptz not null default now() +); + +create index login_events_user_idx on login_events (user_id, at desc); +-- The retention sweep deletes by age; a login journal that only grows is a liability. +create index login_events_at_idx on login_events (at); + +-- +goose Down +drop table login_events; +drop table auth_states; +drop table identities; +create unique index users_email_key on users (lower(email)); +alter table users alter column email set not null; diff --git a/platform/internal/pgstore/migrations/00006_drop_usage_draft.sql b/platform/internal/pgstore/migrations/00006_drop_usage_draft.sql new file mode 100644 index 00000000..2b54c46c --- /dev/null +++ b/platform/internal/pgstore/migrations/00006_drop_usage_draft.sql @@ -0,0 +1,19 @@ +-- +goose Up + +-- usage_windows was the subscription-shaped draft of P-5: a period and a per-period limit. The +-- owner replaced that model with a credit BALANCE on 05.08 — there is no window and no reset — so +-- the table has no reader and no writer. It is dropped rather than left as the first thing a +-- newcomer finds in the schema. The replacement is 00007. +drop table usage_windows; + +-- +goose Down +create table usage_windows ( + user_id text not null references users (id) on delete cascade, + period text not null check (period in ('day', 'week')), + started_at timestamptz not null, + ends_at timestamptz not null, + spent_micro_usd bigint not null default 0, + limit_micro_usd bigint not null, + primary key (user_id, period, started_at) +); +create index usage_windows_current_idx on usage_windows (user_id, ends_at desc); diff --git a/platform/internal/pgstore/migrations/00007_credits.sql b/platform/internal/pgstore/migrations/00007_credits.sql new file mode 100644 index 00000000..8c04c345 --- /dev/null +++ b/platform/internal/pgstore/migrations/00007_credits.sql @@ -0,0 +1,97 @@ +-- +goose Up + +-- Credits are a BALANCE, not a subscription window (owner 05.08: "not subscriptions, buying tokens +-- like OpenRouter"). There is no reset, no period and no `resets_at`; the free tier is a `grant` +-- row and nothing else, which is why no code in this repository knows what a free tier is. +-- +-- Money is whole micro-dollars everywhere. Never a float, never a decimal on the wire, and never a +-- sum in an API response, a screen or an INFO log (D39.84): the user sees a percentage of what is +-- left. These tables are private. + +-- Append-only. A row is never updated or deleted: a mistake is corrected by another row, which is +-- what makes the sum reproducible after the fact. +create table credit_ledger ( + id bigint generated always as identity primary key, + user_id text not null references users (id) on delete cascade, + -- grant — credit given (the whole of the free tier); + -- hold — reserved before a run is spawned, negative; + -- hold_release — that reservation given back, positive; + -- settlement — what the attempt actually cost, negative; + -- adjustment — a correction, either sign, always with a note. + kind text not null check (kind in ('grant', 'hold', 'hold_release', 'settlement', 'adjustment')), + -- Signed, so the balance is one SUM and cannot disagree with itself. + amount_micro_usd bigint not null, + -- Idempotency key, scoped to the ACCOUNT. Without user_id in it, one key spent on one account + -- silently swallows the same key on another — the second account is told "granted" and credited + -- nothing. An empty key is not a key: it would make every unkeyed write share one slot. + source text not null check (source <> ''), + source_id text not null check (source_id <> ''), + note text not null default '', + created_at timestamptz not null default now(), + unique (user_id, source, source_id), + -- A grant is never a debit and a settlement is never a credit: a sign error in the code that + -- writes these fails at the write instead of silently topping up an account. + constraint credit_ledger_sign check ( + (kind = 'grant' and amount_micro_usd > 0) or + (kind = 'hold' and amount_micro_usd < 0) or + (kind = 'hold_release' and amount_micro_usd > 0) or + (kind = 'settlement' and amount_micro_usd <= 0) or + (kind = 'adjustment' and amount_micro_usd <> 0) + ), + -- An adjustment without a reason is unauditable by construction. + constraint credit_ledger_adjustment_has_note check (kind <> 'adjustment' or note <> '') +); + +create index credit_ledger_user_idx on credit_ledger (user_id, id desc); + +-- ⚠ Deleting an account deletes its ledger. "Append-only" above is within the life of an account: +-- there is no payment record to keep afterwards, and keeping a spending history of a deleted user +-- would be the worse default. If selling ever starts, this cascade is the first thing to revisit. + +-- The balance cache. Written in the SAME transaction as the ledger row, never on its own; a test +-- asserts balance == sum(ledger) after every operation, because a cache that can drift from its +-- source is a second source of truth about money. +create table account_balances ( + user_id text primary key references users (id) on delete cascade, + balance_micro_usd bigint not null default 0, + updated_at timestamptz not null default now() +); + +-- One reservation per engine attempt. The hold is what PROTECTS the balance together with the +-- per-book ceiling handed to the engine before it is spawned: the engine enforces the hard stop +-- itself, so an overspend is impossible even while the platform is blind. The spend event in the +-- stream is freshness only — enforcement must never be built on it, because the stream is +-- at-least-once and a crash truncates its tail. +create table reservations ( + engine_run_id text primary key, + user_id text not null references users (id) on delete cascade, + book_id text not null, + amount_micro_usd bigint not null check (amount_micro_usd > 0), + -- What the engine was told its ceiling was. Kept because "why did this run stop" is answered + -- from here, not from the engine's config file, which the next run rewrites. + ceiling_micro_usd bigint not null check (ceiling_micro_usd > 0), + state text not null check (state in ('open', 'settled', 'released')), + opened_at timestamptz not null default now(), + closed_at timestamptz, + -- A closed reservation has a closing time and an open one does not. Without this the state and + -- the timestamps drift apart and neither can be trusted. + constraint reservations_closed_has_time check ((state = 'open') = (closed_at is null)), + -- The payer must own the book. A plain reference to books(id) proves only that the book + -- exists, which is a different question: charging one account for another's run would pass it. + -- RESTRICT, not cascade: cascading here would remove the reservation while its `hold` row stays + -- in the ledger — money debited with nothing left to release it, and the freed engine_run_id + -- then lets the next hold find its ledger key already spent and reserve nothing while + -- reporting that it did. + foreign key (book_id, user_id) references books (id, owner_id) on delete restrict +); + +create index reservations_user_open_idx on reservations (user_id) where state = 'open'; +-- Both cascading parents get an index, same rule as 00002 (PD-11): without them every account or +-- book deletion scans this table. +create index reservations_user_idx on reservations (user_id); +create index reservations_book_idx on reservations (book_id); + +-- +goose Down +drop table reservations; +drop table account_balances; +drop table credit_ledger; diff --git a/platform/internal/pgstore/migrations/00008_auth_state_issuer_and_start_id.sql b/platform/internal/pgstore/migrations/00008_auth_state_issuer_and_start_id.sql new file mode 100644 index 00000000..7774a3f7 --- /dev/null +++ b/platform/internal/pgstore/migrations/00008_auth_state_issuer_and_start_id.sql @@ -0,0 +1,25 @@ +-- +goose Up + +-- The issuer this authorization request was sent to, stored so the callback can compare what came +-- back against it. RFC 9700 §4.4.2 states the prerequisite for either mix-up defence in those +-- terms: "clients must store the issuer they sent requests to and bind this to the user agent" — +-- the binding is the state cookie this row is already keyed against. +-- +-- The `provider` column above is OUR nickname for the issuer and stays: it names which +-- configuration the callback must load. This one is the identifier the norm compares, and the two +-- are not interchangeable — a nickname is ours to change, an issuer identifier is the provider's. +-- +-- Nullable-by-default rather than backfilled: rows here live ten minutes, so any state written +-- before this migration has expired long before it could be read, and a DEFAULT '' keeps the +-- upgrade from failing on whatever is still in flight during a restart. +alter table auth_states add column issuer text not null default ''; + +-- The request id of the leg that opened this round trip. login.State has carried it since P1 and +-- the callback logs it as `login_start_id`, but the column did not exist, so the store dropped it +-- and both log lines were empty in production while the in-memory test store — which keeps the +-- whole struct — showed them populated (PD-62, reproduced against a live database). +alter table auth_states add column start_id text not null default ''; + +-- +goose Down +alter table auth_states drop column start_id; +alter table auth_states drop column issuer; diff --git a/platform/internal/pgstore/migrations_test.go b/platform/internal/pgstore/migrations_test.go index 1ef05ba6..694fccbf 100644 --- a/platform/internal/pgstore/migrations_test.go +++ b/platform/internal/pgstore/migrations_test.go @@ -1,7 +1,10 @@ package pgstore import ( + "crypto/sha256" + "fmt" "io/fs" + "os" "regexp" "strconv" "strings" @@ -44,3 +47,56 @@ func TestMigrationSetIsWellFormed(t *testing.T) { } } } + +// A released migration is immutable, and this is the check that makes that true rather than +// intended. goose applies by NUMBER alone — no name, no checksum — so a file edited after it has +// run somewhere silently never runs again, and a number reused for different SQL leaves that +// database unable to roll back at all. Both were reproduced on a live PostgreSQL before this test +// existed; the prose rule that was supposed to prevent them did not. +func TestReleasedMigrationsAreUnchanged(t *testing.T) { + manifest, err := os.ReadFile("migrations.sha256") + if err != nil { + t.Fatal(err) + } + recorded := map[string]string{} + for line := range strings.Lines(string(manifest)) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + sum, name, ok := strings.Cut(line, " ") + if !ok { + t.Fatalf("migrations.sha256: cannot read %q", line) + } + recorded[name] = sum + } + + names, err := fs.Glob(Migrations(), "*.sql") + if err != nil { + t.Fatal(err) + } + present := map[string]bool{} + for _, name := range names { + present[name] = true + body, err := fs.ReadFile(Migrations(), name) + if err != nil { + t.Fatal(err) + } + got := fmt.Sprintf("%x", sha256.Sum256(body)) + want, listed := recorded[name] + if !listed { + t.Errorf("%s is not in migrations.sha256: append its line when you add a migration", name) + continue + } + if got != want { + t.Errorf("%s changed after release (%s, recorded %s): a released migration is immutable — "+ + "add a new one instead", name, got[:12], want[:12]) + } + } + for name := range recorded { + if !present[name] { + t.Errorf("%s is listed in migrations.sha256 but gone: a released migration cannot be "+ + "deleted, and its number cannot be reused", name) + } + } +} diff --git a/platform/internal/pgstore/pg_test.go b/platform/internal/pgstore/pg_test.go index e26e17c5..8835f5c8 100644 --- a/platform/internal/pgstore/pg_test.go +++ b/platform/internal/pgstore/pg_test.go @@ -1,10 +1,13 @@ package pgstore import ( + "bytes" "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "errors" + "strings" "net/url" "os" @@ -23,6 +26,12 @@ import ( // Each run gets its OWN database, created and dropped here: a test that leaves rows behind passes // once and then lies. func testDB(t *testing.T) (*Store, context.Context) { + t.Helper() + s, ctx, _ := testDBWithDSN(t) + return s, ctx +} + +func testDBWithDSN(t *testing.T) (*Store, context.Context, string) { t.Helper() admin := os.Getenv("TM_PLATFORM_TEST_DSN") if admin == "" { @@ -67,7 +76,7 @@ func testDB(t *testing.T) (*Store, context.Context) { if err := s.Ping(ctx); err != nil { t.Fatal(err) } - return s, ctx + return s, ctx, dsn } func swapDatabase(t *testing.T, dsn, name string) string { @@ -122,12 +131,105 @@ func TestSessionLifecycle(t *testing.T) { t.Fatalf("revoked session still resolves: %v", err) } - n, err := s.DeleteExpiredSessions(ctx, now.Add(72*time.Hour)) + // The sweep also takes revoked rows: a revoked session is the one a compromised account most + // wants gone, and it used to sit until its absolute expiry ninety days later. + n, err := s.SweepSessions(ctx, now) if err != nil { t.Fatal(err) } if n != 1 { - t.Fatalf("sweep removed %d rows, want 1", n) + t.Fatalf("sweep removed %d rows, want the revoked one", n) + } +} + +// PD-1. What the database holds must be the HASH of the credential, and the property has to be +// checked by something other than the function that produces it: asserting through auth.Digest is +// self-consistent and survives a Digest that returns the plaintext. The oracle here is +// crypto/sha256 in the test, plus a search of the whole rendered row for the token itself. +// Mutation caught: `func Digest(t string) []byte { return []byte(t) }`. +func TestStoredCredentialIsAHashNotTheToken(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + now := time.Now().UTC() + + token := auth.NewToken() + if err := s.CreateSession(ctx, auth.Digest(token), "u1", now, time.Hour, 24*time.Hour); err != nil { + t.Fatal(err) + } + + var stored []byte + if err := s.pool.QueryRow(ctx, `select token_sha256 from sessions`).Scan(&stored); err != nil { + t.Fatal(err) + } + want := sha256.Sum256([]byte(token)) + if !bytes.Equal(stored, want[:]) { + t.Fatalf("stored credential is not SHA-256 of the token: %x", stored) + } + + // Broader than the column: any future column that copied the token in would fail this too. + var row string + if err := s.pool.QueryRow(ctx, `select sessions::text from sessions`).Scan(&row); err != nil { + t.Fatal(err) + } + if strings.Contains(row, token) { + t.Fatal("the plaintext token is present in the sessions row") + } + + // And the credential still resolves, so the two assertions above are about a real session. + if _, err := s.Lookup(ctx, auth.Digest(token), now); err != nil { + t.Fatalf("lookup: %v", err) + } +} + +// PD-4. Touch is only reachable after a successful Lookup, so this is depth: a query able to +// resurrect an idle-expired session is not one to leave for the next caller. +// Mutation caught: dropping `idle_expires_at > $2` from Touch's WHERE. +func TestTouchCannotResurrectAnIdleExpiredSession(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u1") + now := time.Now().UTC() + digest := auth.Digest(auth.NewToken()) + if err := s.CreateSession(ctx, digest, "u1", now, time.Hour, 24*time.Hour); err != nil { + t.Fatal(err) + } + + later := now.Add(2 * time.Hour) // past the idle window, inside the absolute one + if err := s.Touch(ctx, digest, later, time.Hour); err != nil { + t.Fatal(err) + } + if _, err := s.Lookup(ctx, digest, later); !errors.Is(err, auth.ErrNoSession) { + t.Fatalf("an idle-expired session came back to life: %v", err) + } +} + +// The rollback exists and runs. A down path that has never been executed is a claim, not a path. +func TestMigrationsRollBackAndReapply(t *testing.T) { + s, ctx, dsn := testDBWithDSN(t) // migrated up, twice, by the helper + + p, closeDB, err := newProvider(dsn) + if err != nil { + t.Fatal(err) + } + defer closeDB() + + if _, err := p.DownTo(ctx, 0); err != nil { + t.Fatalf("down: %v", err) + } + var exists bool + if err := s.pool.QueryRow(ctx, `select to_regclass('public.sessions') is not null`).Scan(&exists); err != nil { + t.Fatal(err) + } + if exists { + t.Fatal("sessions survived a full rollback") + } + if _, err := p.Up(ctx); err != nil { + t.Fatalf("re-apply: %v", err) + } + if err := s.pool.QueryRow(ctx, `select to_regclass('public.sessions') is not null`).Scan(&exists); err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("re-apply did not restore the schema") } } @@ -204,3 +306,122 @@ func assertViolation(t *testing.T, s *Store, ctx context.Context, constraint, sq t.Fatalf("violated %q, want %q", pgErr.ConstraintName, constraint) } } + +// The upgrade path from an already-released schema. This is the shape of the defect that reusing a +// migration number produced: goose records only the NUMBER, so a database that stopped at version 3 +// accepted "migrations applied" and got none of the tables the new release added. +func TestDatabaseAtAnOlderReleaseCatchesUp(t *testing.T) { + _, ctx, dsn := testDBWithDSN(t) + p, closeDB, err := newProvider(dsn) + if err != nil { + t.Fatal(err) + } + defer closeDB() + + // Back to the previous release, then forward with the current set — a deployment, not a fresh + // install. + if _, err := p.DownTo(ctx, 3); err != nil { + t.Fatalf("down to the previous release: %v", err) + } + if _, err := p.Up(ctx); err != nil { + t.Fatalf("catch up: %v", err) + } + + after, err := Open(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer after.Close() + for _, table := range []string{"identities", "auth_states", "login_events", "credit_ledger", "account_balances", "reservations"} { + var exists bool + if err := after.pool.QueryRow(ctx, + `select to_regclass('public.' || $1) is not null`, table).Scan(&exists); err != nil { + t.Fatal(err) + } + if !exists { + t.Fatalf("%s is missing after catching up: the release reported success and did nothing", table) + } + } + // And the draft that the credit model replaced is gone rather than orphaned. + var stale bool + if err := after.pool.QueryRow(ctx, + `select to_regclass('public.usage_windows') is not null`).Scan(&stale); err != nil { + t.Fatal(err) + } + if stale { + t.Fatal("usage_windows survived the upgrade") + } +} + +// Readiness has to mean "this database is the one this build was made for", not "something answered +// on port 5432". Migrate is off by default and the deploy notes make migrating a separate step, so +// "process up, schema not applied" is the ordinary middle of a rollout — and an instance that calls +// itself ready there fails every query it then serves. Found by review. +// Mutation caught: readiness reduced to Ping; comparing the wrong way round. +func TestReadinessRefusesADatabaseWithoutTheSchema(t *testing.T) { + s, ctx := testDB(t) + + if err := s.Ready(ctx); err != nil { + t.Fatalf("a migrated database must be ready: %v", err) + } + + want, err := latestMigration() + if err != nil { + t.Fatal(err) + } + // Wind the recorded version back one step without touching the tables: the shape of "the binary + // carries a migration this database has not seen". + exec(t, s, ctx, `delete from goose_db_version where version_id = $1`, want) + err = s.Ready(ctx) + if !errors.Is(err, ErrSchemaBehind) { + t.Fatalf("a database behind this build reported ready: %v", err) + } + + // And a database that has never been migrated at all — no version table. + exec(t, s, ctx, `drop table goose_db_version`) + if err := s.Ready(ctx); !errors.Is(err, ErrSchemaBehind) { + t.Fatalf("an unmigrated database reported ready: %v", err) + } + // Reachability alone still says yes, which is exactly why it is not the readiness question. + if err := s.Ping(ctx); err != nil { + t.Fatalf("ping should still succeed: %v", err) + } +} + +// The pool sizes an operator writes into the DSN must survive, and "the operator said nothing" must +// be read from the DSN rather than inferred from the value pgx happened to pick. Found by review: +// the previous form compared against pgxpool's own default, which is indistinguishable from an +// operator choosing that same number, and pgx's min-conns default of 0 made an explicit 0 impossible. +// Mutation caught: going back to a value comparison or a substring search. +func TestExplicitPoolSizesInTheDSNSurvive(t *testing.T) { + for name, tc := range map[string]struct { + dsn string + wantMax, wantMin int32 + }{ + "nothing said, ours apply": { + "postgres://u@h:5432/db?sslmode=disable", defaultMaxConns, defaultMinConns}, + "url form, both set": { + "postgres://u@h:5432/db?pool_max_conns=8&pool_min_conns=0&sslmode=disable", 8, 0}, + "keyword form, both set": { + "host=h user=u dbname=db pool_max_conns=8 pool_min_conns=0", 8, 0}, + // The case that broke the substring test it replaced. + "a password that merely contains the key name": { + "postgres://u:pool_max_conns%3D99@h:5432/db?sslmode=disable", defaultMaxConns, defaultMinConns}, + "keyword form with a quoted password containing the key name": { + `host=h user=u password='pool_max_conns=99 x' dbname=db`, defaultMaxConns, defaultMinConns}, + } { + t.Run(name, func(t *testing.T) { + s, err := Open(t.Context(), tc.dsn) + if err != nil { + t.Fatalf("open: %v", err) + } + defer s.Close() + if got := s.pool.Config().MaxConns; got != tc.wantMax { + t.Errorf("MaxConns = %d, want %d", got, tc.wantMax) + } + if got := s.pool.Config().MinConns; got != tc.wantMin { + t.Errorf("MinConns = %d, want %d", got, tc.wantMin) + } + }) + } +} diff --git a/platform/internal/pgstore/sessions.go b/platform/internal/pgstore/sessions.go index 70290d75..dab6c591 100644 --- a/platform/internal/pgstore/sessions.go +++ b/platform/internal/pgstore/sessions.go @@ -35,6 +35,10 @@ func (s *Store) Lookup(ctx context.Context, digest []byte, now time.Time) (auth. // Touch slides the idle window. It never moves the absolute expiry — that is the point of having // two clocks — and it is called only in the window's second half, so reads stay reads. +// +// Its WHERE matches Lookup's, idle clause included (PD-4): reachable only after a successful +// Lookup today, but a query that can resurrect an idle-expired session is not one to leave lying +// around for the next caller. func (s *Store) Touch(ctx context.Context, digest []byte, now time.Time, idleTTL time.Duration) error { // Deadlines are computed in Go and travel as timestamps: one clock, one place, and no interval // encoding to reason about. @@ -44,6 +48,7 @@ func (s *Store) Touch(ctx context.Context, digest []byte, now time.Time, idleTTL idle_expires_at = least($3::timestamptz, absolute_expires_at) where token_sha256 = $1 and revoked_at is null + and idle_expires_at > $2 and absolute_expires_at > $2` if _, err := s.pool.Exec(ctx, q, digest, now, now.Add(idleTTL)); err != nil { return fmt.Errorf("pgstore: touch session: %w", err) @@ -51,7 +56,7 @@ func (s *Store) Touch(ctx context.Context, digest []byte, now time.Time, idleTTL return nil } -// CreateSession stores a freshly minted token's digest. The plaintext never reaches this package. +// CreateSession stores the digest; the plaintext never reaches this package. func (s *Store) CreateSession(ctx context.Context, digest []byte, userID string, now time.Time, idleTTL, maxAge time.Duration) error { const q = ` insert into sessions (token_sha256, user_id, created_at, last_used_at, idle_expires_at, absolute_expires_at) @@ -62,8 +67,7 @@ func (s *Store) CreateSession(ctx context.Context, digest []byte, userID string, return nil } -// RevokeSession ends one session immediately — the property an opaque server-side session has and -// a self-verifying token does not. +// RevokeSession ends one session immediately. func (s *Store) RevokeSession(ctx context.Context, digest []byte, now time.Time) error { const q = `update sessions set revoked_at = $2 where token_sha256 = $1 and revoked_at is null` if _, err := s.pool.Exec(ctx, q, digest, now); err != nil { @@ -72,11 +76,15 @@ func (s *Store) RevokeSession(ctx context.Context, digest []byte, now time.Time) return nil } -// DeleteExpiredSessions is the sweep. Expired rows are deleted rather than kept: a session table is -// not an audit log, and "who was logged in last spring" is not a question we want to be able to -// answer from it. -func (s *Store) DeleteExpiredSessions(ctx context.Context, now time.Time) (int64, error) { - const q = `delete from sessions where absolute_expires_at <= $1` +// SweepSessions deletes rows nothing can authenticate with again: past either expiry, or revoked. +// A revoked row is the one a compromised account most wants gone, and it used to sit until its +// absolute expiry ninety days later. The audit lives in the login journal, not here. +func (s *Store) SweepSessions(ctx context.Context, now time.Time) (int64, error) { + const q = ` + delete from sessions + where absolute_expires_at <= $1 + or idle_expires_at <= $1 + or revoked_at is not null` tag, err := s.pool.Exec(ctx, q, now) if err != nil { return 0, fmt.Errorf("pgstore: sweep sessions: %w", err) diff --git a/platform/internal/pgstore/store.go b/platform/internal/pgstore/store.go index ac165da9..4163ff3c 100644 --- a/platform/internal/pgstore/store.go +++ b/platform/internal/pgstore/store.go @@ -2,9 +2,15 @@ package pgstore import ( "context" + "errors" "fmt" + "io/fs" + "sync" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" + "github.com/pressly/goose/v3" ) // Store is the platform's database handle. @@ -12,6 +18,13 @@ type Store struct { pool *pgxpool.Pool } +// Pool limits. Bounded explicitly: how many connections a control plane may hold is a property of +// the database's max_connections, not of the machine running the binary. +const ( + defaultMaxConns = 16 + defaultMinConns = 2 +) + // Open builds the pool. It does NOT connect: pgxpool dials lazily, so a database that is down at // boot makes the service unready rather than dead — readiness is the gate, not the process. func Open(ctx context.Context, dsn string) (*Store, error) { @@ -19,6 +32,25 @@ func Open(ctx context.Context, dsn string) (*Store, error) { if err != nil { return nil, fmt.Errorf("pgstore: parse dsn: %w", err) } + // Applied only where the operator said nothing, and "said nothing" is asked of pgx's own parser + // rather than guessed from the resulting value. pgxpool reads pool_* out of RuntimeParams and + // deletes them (pgxpool/pool.go), so a second parse still has them: that is the one place where + // "the DSN mentions this key" is answered exactly, for both DSN forms, quoting and service files. + // + // Guessing was the previous form: it compared MaxConns with pgxpool's default of max(4, NumCPU) + // and treated equality as "unset" — indistinguishable from an operator choosing that same number, + // so a replica sized to its core count had its number silently replaced by ours. pool_min_conns + // was worse: pgx's default is 0, so an explicit 0 could never survive. + set, err := pgx.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("pgstore: parse dsn: %w", err) + } + if _, ok := set.RuntimeParams["pool_max_conns"]; !ok { + cfg.MaxConns = defaultMaxConns + } + if _, ok := set.RuntimeParams["pool_min_conns"]; !ok { + cfg.MinConns = defaultMinConns + } pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { return nil, fmt.Errorf("pgstore: pool: %w", err) @@ -26,7 +58,7 @@ func Open(ctx context.Context, dsn string) (*Store, error) { return &Store{pool: pool}, nil } -// Ping reports whether the database is reachable; it backs /readyz. +// Ping reports whether the database is reachable. func (s *Store) Ping(ctx context.Context) error { if err := s.pool.Ping(ctx); err != nil { return fmt.Errorf("pgstore: ping: %w", err) @@ -34,4 +66,73 @@ func (s *Store) Ping(ctx context.Context) error { return nil } +// Ready backs /readyz, and it asks a harder question than Ping: not "is a database there" but "is +// the database THIS BUILD was made for". Reachability alone answered yes against a Postgres with no +// tables at all — which is not a corner case but the normal middle of a rollout, because Migrate is +// off by default and the deploy notes make migrating a separate step. An instance in that window +// used to report ready and fail every query it then served. +// +// Only a schema BEHIND this binary is unready. A schema ahead of it is a newer release that has +// already migrated, and refusing to serve then would take the old instance down during the rollout +// it is supposed to survive. ⚠ That case is currently SILENT — nothing logs it, because the caller +// only logs the error branch and this Store has no logger. An operator running an old binary on a +// newer schema gets no signal from here. +func (s *Store) Ready(ctx context.Context) error { + // No Ping first: the query below needs a connection and a round trip of its own, and it already + // fails when the database is unreachable. Two round trips per probe, every few seconds, bought + // nothing (found by review). + want, err := latestMigration() + if err != nil { + return err + } + // Read directly with the pool rather than through a goose Provider: a Provider needs its own + // database/sql handle, and this runs every few seconds. + // + // ⚠ goose.TableName() is the package-level legacy setting, which is NOT what goose.NewProvider + // consults — the Provider resolves its own. They agree only because newProvider never passes + // goose.WithTableName; adding it there without changing this would leave readiness querying a + // table that does not exist and the service permanently unready. + var applied int64 + err = s.pool.QueryRow(ctx, + `select coalesce(max(version_id), 0) from `+pgx.Identifier{goose.TableName()}.Sanitize()+ + ` where is_applied`).Scan(&applied) + if err != nil { + var pg *pgconn.PgError + // 42P01: the version table itself is absent, so nothing was ever applied. + if errors.As(err, &pg) && pg.Code == "42P01" { + return fmt.Errorf("%w: no migrations have been applied; this build needs version %d", ErrSchemaBehind, want) + } + return fmt.Errorf("pgstore: read schema version: %w", err) + } + if applied < want { + return fmt.Errorf("%w: schema is at version %d, this build needs %d", ErrSchemaBehind, applied, want) + } + return nil +} + +// ErrSchemaBehind is a database that answers but has not been migrated up to this binary. +var ErrSchemaBehind = errors.New("pgstore: schema is behind this build") + +// latestMigration is the highest version embedded in this binary — a build-time constant, so it is +// computed once rather than on every readiness probe. The version is read by goose's own +// NumericComponent, so "what counts as the version of this filename" has one answer in the zone. +var latestMigration = sync.OnceValues(func() (int64, error) { + entries, err := fs.ReadDir(Migrations(), ".") + if err != nil { + return 0, fmt.Errorf("pgstore: read embedded migrations: %w", err) + } + var latest int64 + for _, e := range entries { + n, err := goose.NumericComponent(e.Name()) + if err != nil { + return 0, fmt.Errorf("pgstore: migration %q: %w", e.Name(), err) + } + latest = max(latest, n) + } + if latest == 0 { + return 0, errors.New("pgstore: no migrations are embedded in this binary") + } + return latest, nil +}) + func (s *Store) Close() { s.pool.Close() } diff --git a/platform/internal/reqid/reqid.go b/platform/internal/reqid/reqid.go new file mode 100644 index 00000000..16ed873a --- /dev/null +++ b/platform/internal/reqid/reqid.go @@ -0,0 +1,65 @@ +// Package reqid stamps each request with an id and carries it into every log record made with a +// context. It is its own package so that layers below HTTP (auth, ingest) can correlate their +// errors with an access-log line without importing the HTTP layer. +package reqid + +import ( + "context" + "crypto/rand" + "encoding/base32" + "log/slog" + "net/http" +) + +// Header is where the id is echoed. It is generated here, never taken from the request: an id +// accepted from a caller lets them poison our logs and correlate other users' lines. +const Header = "X-Request-Id" + +// Key is the log attribute name. +const Key = "request_id" + +type ctxKey struct{} + +// Middleware stamps the request and the response. +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := New() + w.Header().Set(Header, id) + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, id))) + }) +} + +// New mints an id. +func New() string { + var b [10]byte + rand.Read(b[:]) + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:]) +} + +// FromContext returns the id stamped by Middleware, or "". +func FromContext(ctx context.Context) string { + id, _ := ctx.Value(ctxKey{}).(string) + return id +} + +// WithContext wraps a slog handler so that every *Context log call inside a request carries its id. +// Without it each call site has to remember the attribute, and the ones that forget are exactly the +// error paths nobody exercises. +func WithContext(h slog.Handler) slog.Handler { return &handler{h} } + +type handler struct{ slog.Handler } + +func (h *handler) Handle(ctx context.Context, r slog.Record) error { + if id := FromContext(ctx); id != "" { + r.AddAttrs(slog.String(Key, id)) + } + return h.Handler.Handle(ctx, r) +} + +func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &handler{h.Handler.WithAttrs(attrs)} +} + +func (h *handler) WithGroup(name string) slog.Handler { + return &handler{h.Handler.WithGroup(name)} +} diff --git a/platform/internal/reqid/reqid_test.go b/platform/internal/reqid/reqid_test.go new file mode 100644 index 00000000..acfd824d --- /dev/null +++ b/platform/internal/reqid/reqid_test.go @@ -0,0 +1,66 @@ +package reqid + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// The id is ours. An id echoed from the caller lets them stamp their own value on our lines and +// correlate — or collide with — someone else's. Mutation caught: reading the header from the +// request when it is present. +func TestRequestIDIsNeverTakenFromTheCaller(t *testing.T) { + var seen string + h := Middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = FromContext(r.Context()) + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(Header, "attacker-supplied") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if seen == "" { + t.Fatal("no id was stamped") + } + if seen == "attacker-supplied" || rec.Header().Get(Header) == "attacker-supplied" { + t.Fatal("the caller's id was adopted") + } + if rec.Header().Get(Header) != seen { + t.Fatalf("the echoed id %q is not the one in the context %q", rec.Header().Get(Header), seen) + } +} + +// Every *Context log call inside a request carries the id without the call site saying so — which +// is the point: the error paths that need correlating are the ones nobody remembers to annotate. +func TestLogRecordsCarryTheRequestID(t *testing.T) { + var buf bytes.Buffer + log := slog.New(WithContext(slog.NewJSONHandler(&buf, nil))) + + h := Middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + log.With("layer", "test").ErrorContext(r.Context(), "something failed") + })) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + + var rec map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rec); err != nil { + t.Fatalf("log line: %v (%s)", err, buf.String()) + } + if id, _ := rec[Key].(string); id == "" { + t.Fatalf("no %s on the record: %s", Key, buf.String()) + } + if rec["layer"] != "test" { + t.Fatalf("the wrapper dropped attributes added with With: %s", buf.String()) + } + + // Outside a request there is nothing to add, and the handler must not invent one. + buf.Reset() + log.ErrorContext(context.Background(), "background failure") + if strings.Contains(buf.String(), Key) { + t.Fatalf("an id appeared outside a request: %s", buf.String()) + } +}