Accept and land platform P1 and P2 after adversarial acceptance with own mutations and live probes, ratifying six decisions and filing twenty-nine defect rows to PD-107

This commit is contained in:
heaven 2026-08-07 02:51:28 +03:00
parent d4725999f2
commit fcdab81a88
62 changed files with 7071 additions and 257 deletions

View file

@ -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; остальные 0120 — отчёты закрытых экспериментов (судьба — в баннерах/D-логе; 1820 — с ревью-шапками приёмки D39.108, шапка первична).
- `research/` — фактура ресёрчей 0125; у принятых — ревью-шапки, часть тел под ⚠ 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/) — веб-интерфейс: промт фронт-сессий S0S7 + [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).

View file

@ -1,4 +1,4 @@
# Журнал решений оркестратора — контракт D1D39.105 (развязки 04.07 · пакеты 0910.07 · приёмка/качество-первым/пивот/эмпирика 1112.07 · арх-ресет+стройка пере-прогонного стека 1319.07)
# Журнал решений оркестратора — контракт D1D39.109 (развязки 04.07 · пакеты 0910.07 · приёмка/качество-первым/пивот/эмпирика 1112.07 · арх-ресет+стройка пере-прогонного стека 1319.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. Строки **135144** заведены (денежный пакет шва 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 новых, миграции 0000400008) ПРИНЯТО и заленжено. ⚠ **Факт, который доки завышали:** журнал зоны объявлял «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 платформы получил баннер-исход.

View file

@ -2,9 +2,11 @@
> Ведёт зона `platform/` (решение владельца 02.08, D39.84: фронт и платформа держат СВОИ бэклоги; единый бэклог `docs/PROGRESS.md` остаётся трекером движка/полигона/доков и фронт/платформа-строк не принимает). Нормы те же: ID стабилен навсегда, каждая петля получает диспозицию. Запросы к ДВИЖКУ сюда не пишутся — они заходят строками единого бэклога через оркестратора (пример: строки 99102). Засеян оркестратором при лендинге 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 | Хвост | Вес | Источник |
|---|---|---|---|

View file

@ -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).

View file

@ -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-сверка 0405.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.

View file

@ -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 <command> [flags]
grant --user <id> --usd <amount> [--note <text>] [--key <idempotency key>]
adjust --user <id> --usd <amount> --note <text> [--key <idempotency key>]
balance --user <id>
logins --user <id> [--limit <n>]
revoke --user <id>
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
}

View file

@ -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)
}
}
}
}

63
platform/deploy/README.md Normal file
View file

@ -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/<pid>/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' '<oauth client secret>' > /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. Всё это — работа с первым реальным деплоем, не раньше.

View file

@ -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/<pid>/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

View file

@ -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 757 и держались, пока не закрыл КЛИЕНТ (агент-скептик независимо пинил 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 757 и держались, пока не закрыл КЛИЕНТ (агент-скептик независимо пинил 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` на ней ломается навсегда. Обоснование «до деплоя правим на месте» было допущением без механизма — **закрыто:** выпущенные 0000100003 возвращены байт-в-байт, новое приехало номерами 0000400007; гейт `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, 510 взаимоблокировок на 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 в `<jobdir>/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; версия панели опровергнута) |

View file

@ -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` чистый**; рантайм-граф прирастает одним

View file

@ -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/`,

View file

@ -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/<pid>/environ` и
наследуется каждым ребёнком-`tmctl`. Это же формат `LoadCredential=` systemd (`deploy/`).
12. **`ReadTimeout` есть, `WriteTimeout` нет.** Первый закрывает PD-2 (проверено живой пробой);
второй зарезал бы SSE на фиксированном возрасте.
Поток при этом от хендлера ничего не требует: `net/http` снимает read-дедлайн САМ
`connReader.startBackgroundRead` делает `SetReadDeadline` нулевым временем
(`server.go:687-698`), и для запроса без остатка тела это происходит ДО хендлера, иначе на EOF
тела (`:2059-2062`); по ходу хендлера дедлайн не перевзводится. Проверено исполнением на шести
комбинациях (GET без тела · POST с непрочитанным телом · POST с вычитанным).
**Снимать дедлайн руками ЗАПРЕЩЕНО, и это не стилистика.** На полу-кормленном запросе (тело
анонсировано и не дослано) дренаж внутри записи заголовка ответа — единственное, что ограничивает
соединение, и ограничен он как раз `ReadTimeout`. Снятие дедлайна до записи заголовка убирает эту
границу: замерено — хендлер остаётся внутри `WriteHeader` и через 4 с после ухода клиента, то есть
PD-2 воспроизводится тем самым вызовом, который был заведён как его исправление. Поэтому
`httpapi.ClearReadDeadline` **удалён** (PD-51): случая, где он помогает, нет — на корректном
запросе это no-op, на полу-кормленном вред. `Unwrap` в обёртках остаётся обязательным: через него
поток дотягивается до `Flush`, и это запинено проверкой ошибки `Flush` в
`TestStreamOutlivesReadTimeout`.
13. **Политика сессий: 14 суток бездействия, 30 суток абсолютных.** Раздел существует потому, что
`ENGINEERING_STANDARDS §2` объявил зоне ASVS 5.0 L2, а 7.1.1 требует не значения, а ДОКУМЕНТ:
«the user's session inactivity timeout and absolute maximum session lifetime are documented …
includes justification for any deviations from NIST SP 800-63B re-authentication requirements».
**Уровень — AAL1.** Второго фактора со своей стороны мы не проверяем; что там делает Google —
его дело и в нашу гарантию не входит.
**Сверка с NIST SP 800-63B-4 §2.1.3 (AAL1), дословно:** «A definite reauthentication overall
timeout SHALL be established, which SHOULD be no more than 30 days at AAL1. An inactivity timeout
MAY be applied but is not required at AAL1.»
- **Абсолютный срок — 30 суток. Отклонения нет.** Было 90; 90 — это отклонение от SHOULD, а
обоснования у него не нашлось: на аккаунте лежит тратимый баланс, а повторный вход у уже
залогиненного в Google человека — один клик. Абсолютный срок не рвёт ПРОГОН: прогон живёт
серверным процессом и переживает истечение сессии. Значение переопределяется
`TM_PLATFORM_SESSION_MAX_AGE`, и если владелец хочет 90 — это одна переменная и запись здесь.
- **Срок бездействия — 14 суток.** На AAL1 он не требуется вообще (MAY), так что наличие
строже нормы. Скользит только во второй половине окна — чтобы каждый запрос не писал в БД.
**7.1.2, одновременные сессии.** Ограничения нет, и это решение, а не умолчание: контракт
предусматривает две презентации одной личности одновременно (кука в браузере, Bearer в
десктопе/CLI — D39.84), поэтому лимит ломал бы штатный сценарий. Что стоит вместо лимита: своя
строка на каждый вход, мгновенный отзыв любой из них, `POST /auth/logout-all` и
`tmplatformctl revoke` как «выйти везде», журнал `login_events` как ответ на «откуда входили».
⚠ Показ пользователю списка его сессий (ASVS 7.5.2) не сделан — это работа П-1.
**7.1.3 / 7.6.1, согласование с федеративной сессией.** Наша сессия живёт СВОЕЙ жизнью:
RP-initiated logout и back-channel logout не реализованы. Следствия названы прямо: выход из
Google не завершает нашу сессию, и отзыв доступа на стороне Google — тоже. Единственные границы
— наши два срока и наш отзыв. Это и есть причина, по которой абсолютный срок выровнен по NIST, а
не растянут: пока нет канала «IdP сказал, что сессия кончилась», абсолютный срок — единственное,
что вообще ограничивает жизнь сессии после события на стороне провайдера.
**7.6.2 выполнено:** сессия создаётся только в колбэке потока, который человек начал явным
действием, и провайдер показывает свой экран согласия. Без взаимодействия сессия не появляется.
14. **Против IdP mix-up — параметр `iss` авторизационного ответа (RFC 9207), а не раздельные
redirect URI.** Решение принято ДО второго провайдера намеренно: пока провайдер один, сверка
«конфигурация против самой себя» выглядит работающей и перестаёт ею быть ровно в момент, когда
появляется второй (PD-57).
Что говорит норма. RFC 9700 §4.4.2: «When an OAuth client can only interact with one
authorization server, a mix-up defense is not required. In scenarios where an OAuth client
interacts with two or more authorization servers, however, clients MUST prevent mix-up attacks»,
и обе защиты требуют одного и того же: хранить издателя, которому ушёл запрос, и привязать это к
браузеру. §4.4.2.2 (раздельные redirect URI) — фолбэк: «SHOULD therefore only be used if other
options are not available».
Почему `iss`, а не redirect URI. Альтернатива «`iss` из ID-токена» нам не подходит: у нас чистый
code flow, ID-токен приходит от token endpoint, то есть ПОСЛЕ того, как код уже отдан — а утечка
кода не туда и есть содержание атаки. Фолбэк с раздельными URI не нужен: **Google поддерживает
RFC 9207** — в его discovery-документе `authorization_response_iss_parameter_supported: true`
(сверено живьём 05.08, `https://accounts.google.com/.well-known/openid-configuration`).
Что сделано: `auth_states.issuer` хранит издателя, которому ушёл запрос (миграция 00008), а
колбэк сверяет с ним `iss` ответа простым строковым сравнением до обмена кода (RFC 9207 §2.4) и
отказывает, если параметр СОРВАН, когда провайдер по discovery его шлёт — иначе снятие параметра
и есть обход проверки. Отдельно осталась сверка `st.Provider` с конфигурацией: это наш ключ
маршрутизации, а не идентификатор из нормы, и отвечает она на другой вопрос.
## Как поднять локально
```sh
@ -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 <id> --usd 5 [--note ...] [--key ...]` ·
`balance --user <id>` · `logins --user <id>` · `revoke --user <id>`.
### Postgres на стенде без root
```sh
# бинарники io.zonky.test.postgres с Maven Central, распакованные в скрэтчпад
pg/bin/initdb -D pgdata -U postgres -A trust --no-locale --encoding=UTF8
pg/bin/pg_ctl -D pgdata -l pg.log -o "-k /tmp -p 55432 -c listen_addresses=" start
export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55432&sslmode=disable'
```
⚠ Сокет кладём в `/tmp` (`-k`): полный путь скрэтчпада длиннее лимита Unix-сокета.
`psql` в пакете zonky НЕТ — только `initdb`/`pg_ctl`/`postgres`; проверять из Go.
⚠ Postgres на стенде отсутствует как системный пакет и sudo нет. Схема и запросы этой сессии
проверены на ЖИВОМ PostgreSQL **18.4**, поднятом без root из бинарников zonky

View file

@ -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» на этом стенде дал
510 на 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 целиком),

View file

@ -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

View file

@ -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=

View file

@ -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,
})
}

View file

@ -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 <nonsense>` 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 != ""
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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/<pid>/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

View file

@ -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)
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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)
}

View file

@ -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")

View file

@ -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)
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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)
}
})
}

View file

@ -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)
}
}
}

View file

@ -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 }

View file

@ -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 }

View file

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

View file

@ -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

View file

@ -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 }

View file

@ -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))
}

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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)
}
}
})
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}
}
}

View file

@ -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
})
}

View file

@ -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(&note); 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())
}
}
}

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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)
}
}
}

View file

@ -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)
}
})
}
}

View file

@ -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)

View file

@ -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() }

View file

@ -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)}
}

View file

@ -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())
}
}