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:
parent
d4725999f2
commit
fcdab81a88
62 changed files with 7071 additions and 257 deletions
|
|
@ -15,7 +15,7 @@
|
|||
- `experiments/` — эмпирика полигона: [00-provider-quirks.md](experiments/00-provider-quirks.md) — **читать перед любым вызовом провайдера**; [08-cost-model-v2.md](experiments/08-cost-model-v2.md) — денежная модель; [09-pilot-protocol.md](experiments/09-pilot-protocol.md) — пилот Ф2.5; остальные 01–20 — отчёты закрытых экспериментов (судьба — в баннерах/D-логе; 18–20 — с ревью-шапками приёмки D39.108, шапка первична).
|
||||
- `research/` — фактура ресёрчей 01–25; у принятых — ревью-шапки, часть тел под ⚠ superseded: **читай баннер прежде содержимого**. Ключевые для навигации: 15 голос · 16 ридер-IDE · 17 внешняя критика · 18 рычаги качества · 19 нарезка · 20 банк-майнинг · 21 обзор транспорта · 22 доменные харнессы · 23 шов движок↔платформа (транспорт superseded D39.106) · 25 холодное ревью шва — форма D39.106, отвергнутые альтернативы, требования к эмиттеру (читать перед любым кодом стыка) · 24 арбитраж банка (ПРИНЯТ D39.102: консилиум закрыт классом, вход фикс-пака банка — §G).
|
||||
- [PROGRESS.md](PROGRESS.md) — журнал: CURRENT-STATE + **ЕДИНЫЙ БЭКЛОГ** (единственный трекер) + живой хвост хроники. НЕ источник решений.
|
||||
- Активные хендофф-промты сессий (состав обновляется при каждом лендинге — норма D39.80): [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) (роль/нормы; состояния не дублирует) · [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md) (полигон, отложен) · **платформа: [../platform/docs/PLATFORM_SESSION_PROMPT.md](../platform/docs/PLATFORM_SESSION_PROMPT.md) (P0 ПРИНЯТ D39.107; пул доработок P1 в работе — приёмка изменённого дерева = №15, ждёт передачи; дерево `platform/*` ЖИВОЕ, не трогать)** · фронт: `frontend/docs/S3_SESSION_PROMPT.md` (замок ОТКРЫТ — контракт ратифицирован D39.99; первый шаг — правки спеки по D39.100). Зонные журналы фронта/платформы — `frontend-PROGRESS.md` / `platform-PROGRESS.md` в их зонах (решение владельца 04.08: прогресс зон только там). **Бэкенд: [BACKEND_BANK_CLUSTER_FIXPACK_SESSION_PROMPT.md](BACKEND_BANK_CLUSTER_FIXPACK_SESSION_PROMPT.md)** (фикс-пак банка, строка 128+129; санкция D39.103) · **Полигон: [POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md](POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md)** (эксп-21 «топология ролей»: скрин моделей → бейк-офф топологий на невиданном срезе; потолки предварительные, ЗАПУСК = слово владельца; D39.108). Очередь и состояние — только CURRENT-STATE.
|
||||
- Активные хендофф-промты сессий (состав обновляется при каждом лендинге — норма D39.80): [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) (роль/нормы; состояния не дублирует) · [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md) (полигон, отложен) · **платформа: [../platform/docs/PLATFORM_SESSION_PROMPT.md](../platform/docs/PLATFORM_SESSION_PROMPT.md) — ОТРАБОТАН (P0 ПРИНЯТ D39.107; P1+P2 ПРИНЯТЫ и залендены D39.109, регистр до PD-107); следующий промт платформы — по слову владельца (реконсилятор/тейлер/юниты, D39.107 п.3(3))** · фронт: `frontend/docs/S3_SESSION_PROMPT.md` (замок ОТКРЫТ — контракт ратифицирован D39.99; первый шаг — правки спеки по D39.100). Зонные журналы фронта/платформы — `frontend-PROGRESS.md` / `platform-PROGRESS.md` в их зонах (решение владельца 04.08: прогресс зон только там). **Бэкенд: [BACKEND_BANK_CLUSTER_FIXPACK_SESSION_PROMPT.md](BACKEND_BANK_CLUSTER_FIXPACK_SESSION_PROMPT.md)** (фикс-пак банка, строка 128+129; санкция D39.103) · **Полигон: [POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md](POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md)** (эксп-21 «топология ролей»: скрин моделей → бейк-офф топологий на невиданном срезе; потолки предварительные, ЗАПУСК = слово владельца; D39.108). Очередь и состояние — только CURRENT-STATE.
|
||||
- Зоны фронта (чужие, читать при касании стыка; каждая ведёт СВОЙ зонный бэклог — единый бэклог их строк не принимает, D39.84): [../frontend/](../frontend/) — веб-интерфейс: промт фронт-сессий S0–S7 + [STACK_DECISIONS.md](../frontend/docs/STACK_DECISIONS.md) (пины версий точными числами и ловушки, сверены с вебом 02.08) + [BACKLOG.md](../frontend/docs/BACKLOG.md) · [../platform/](../platform/) — SaaS control plane: README + [BACKLOG.md](../platform/BACKLOG.md) (П-1..П-5) + `docs/` (промт P0 · зонный журнал `platform-PROGRESS.md`).
|
||||
- `archive/` — история ([правила архива](archive/README.md)): закрытые промты (`prompts/`) · отчёты с ревью-шапками (`reports/` — на них ссылаются приёмки) · исполненные арх-доки (`architecture/`) · слайсы хроники `PROGRESS-*.md`. Инструкции оттуда не исполнять.
|
||||
- Диаграммы: [../backend/docs/components.puml](../backend/docs/components.puml) · [../backend/docs/pipeline.puml](../backend/docs/pipeline.puml) — дом рядом с кодом (D39.80), правятся бэкендом одним коммитом с кодом; вручную НЕ рендерить (владелец смотрит PlantUML-расширением VS Code).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Журнал решений оркестратора — контракт D1–D39.105 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07)
|
||||
# Журнал решений оркестратора — контракт D1–D39.109 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07)
|
||||
|
||||
> **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто:
|
||||
> ⚠ **Навигация (актуализация 04.08):** два supersede-указателя эры D39.9x: **D39.88/D39.84 п.8 (право самокоммита фронта/платформы) → отозвано, коммитит ТОЛЬКО оркестратор** (D39.98 п.3; тела переписаны на месте с санкцией владельца, pre-rewrite — git `2b166b7`) · **норма 30.07 «короткая приёмка» → амендирована владельцем 03.08: приёмка всегда адверсариальная** (носитель — промт оркестратора §Анти-паттерны + D39.101 п.1) — упоминания «короткой приёмки» в телах читать через эту пометку · **двухсекционная редакторская инъекция и смягчающая роль маркера ⟨проверить⟩ → доктрина инжекта D39.104**: банк на проводе = ЗАКОН для всех ролей независимо от статуса строки, право «перевести иначе» упразднено, блок редактора — ЕДИНЫЙ, маркер с провода снимается (статусы строк и подписная таблица не меняются; внесение в движок — строка 134 после пробы 18) — упоминания двухсекционки/смягчения в телах читать через это.
|
||||
|
|
@ -1533,3 +1533,19 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу
|
|||
**3. Эксп-21 «топология ролей» — промт выдан** (`docs/POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md`); двойной аудит: слепая панель 3 линз без файловых тулов (изоляция чиста) + полноконтекстный анти-паттерн-агент. Добавки панели, принятые в промт: арм F do-nothing (потерянная норма exp15:486) · MQM-lite/error-span протокол + мощность/MDE и шумовой пол ДО бейк-оффа · guarded+routed арм G · обратная связка E · Палладий-линт транскрипции · мини-проба глоссарий-лока · факторная рамка на замороженных черновиках · декой D39.46(б) и запрет судьи семейства жильца · пере-проба маппинга dspro до Ф1 (вахта 108). Потолки ПРЕДВАРИТЕЛЬНЫЕ (≈$7.3 стадиями с ранним отсевом; пере-сверка сметы после предзамера мощности — обязательна); запуск = слово владельца. Два тезиса разжалованы в гипотезы бейк-оффа (анти-соглашательство): «экономическое оправдание черновика пало» и «черновик = опора верности».
|
||||
|
||||
**4. Закрытия и диспозиции:** **D21.4 (арм minimal-diff с тирингом) и D21.10 (поручение спеки апплая) ЗАКРЫТЫ явно** по букве гейта Q4b (exp15:147): аппликатор построен полигоном и замерен (15/15; комплаенс 0.966 на боевой единице), диффы отложены; реактивация — через строку 106/эксп-21. Строки **135–144** заведены (денежный пакет шва D39.107 п.2: пер-вызовный гейт · uncertain-эскроу · сверка с провайдером · реконсилятор · пиннинг версии; находки приёмки: банкнота-декларация 140 · дыра сносок 141 · механизм починки-по-флагу 142 · норматив судейства 143 · вахта grok-биллинга 144); строки 103/106/134/65/55/12 пере-диспозиционированы. Исполненный промт пробы 18 архивирован. PROGRESS CURRENT-STATE обновлён на №15 (долг D39.107 погашен).
|
||||
|
||||
## D39.109 — ПРИЁМКА ПЛАТФОРМЫ P1+P2: приняты и залендены одним коммитом; регистр до PD-107, одна major; два вопроса владельцу (07.08). ✅
|
||||
|
||||
**1. Вердикт.** Дерево зоны `platform/` (30 изменённых отслеживаемых файлов, 22 новых, миграции 00004–00008) ПРИНЯТО и заленжено. ⚠ **Факт, который доки завышали:** журнал зоны объявлял «P1 ПРИНЯТ и заленден» — в git код P1 не уезжал (`git ls-files platform/internal/login` = 0 до этого коммита), уехали только P0 (`eeeef89`/`954c034`), направление (`99c9cb0`) и решения владельца (`87be7b9`/`6469479`). Формулировка исправлена на месте с пометкой оркестратора; строки регистра ошибки не несли — они честно говорят `fixed(P1, дерево сессии)`. Живой уязвимости приёмка не нашла.
|
||||
|
||||
**2. Метод — исполнением, второй рубеж по своей карте.** Батарея пере-прогнана мной: офлайн зелёная (линтер 0 issues), с живым PostgreSQL 18.4 без root — **скипов ноль** (26 БД-тестов отработали), `make vuln` чист. **Свои 45 мутаций в четыре батча по СВОЕЙ карте несущих свойств (не по таблице пинов зоны): 33 поймано поимённо, 8 выжило, 4 моих посадки оказались негодными и разобраны вслух.** Мутации ставились в КОПИИ зоны вне репозитория — незакоммиченное дерево сессии не тронуто, сверено хешами диффа до и после. Живые пробы на боевом бинаре: PD-2 (10 полу-кормленных POST отпущены на 30.0 с), пять форм CSRF, ПТ-34-заголовки, RFC 9207 (Google действительно шлёт `iss`; сорванный → `issuer_missing`, чужой → `issuer_mismatch`, обмена кода нет). PD-71 пере-проверен своим прогоном на боевых данных: `DownTo(4)` падает на `users_email_key` SQLSTATE 23505, данные целы. Фаззеры: 3.0 и 3.35 млн исполнений, крэшеров нет. **Цитаты норм сверены по первоисточникам, не по пересказу** — RFC 9700 §4.4.2/§4.4.2.2, NIST SP 800-63B-4 §2.1.3, ASVS 5.0 7.1.1/7.1.2/7.1.3/7.6.1/7.6.2 дословны, номера и уровень L2 верны; это несущая проверка, на этих цитатах стоит смена боевого значения 90 → 30 суток. Плюс воркфлоу-панель: семь линз с зажатыми промтами (отчётные доки зоны запрещены) и адверсариальный опровергатель на каждую находку весом minor+ — 20 подтверждено, 2 опровергнуто.
|
||||
|
||||
**3. Ратифицировано (6 из 6):** абсолютный срок сессии **30 суток** (буква NIST AAL1; у прежних 90 обоснования не было) · контрмера mix-up = **`iss` авторизационного ответа (RFC 9207)**, миграция 00008, а не раздельные redirect URI (норма объявляет их фолбэком) · **`STACK_DECISIONS §13`** как документ соответствия ASVS 7.1.1/7.1.2/7.1.3 с прямо названным рассогласованием с федеративной сессией · **ломающие изменения зоны** (`NewServer` без `Timeouts` — устранение класса сильнее теста · `Prober.Ready` · `login.Fail` без `*http.Request`; внешних потребителей нет, фронт говорит по HTTP) · **`MemoryMax=80%` + явный `OOMPolicy=continue`** (сверено с `systemd.resource-control(5)`/`systemd.service(5)`; ⚠ под systemd не исполнялось) · **PD-71 принят риском** в форме зоны: правило append-only дороже доступности отката ниже версии 5, место записи — `deploy/README.md` у оператора.
|
||||
|
||||
**4. Найдено приёмкой: 29 строк PD-79…PD-107, одна major.** **PD-80 (major):** ведро лимитера одно на `/auth/login` и `/auth/callback`, а колбэк стирает login-куку ДО своей проверки лимитера ⇒ анонимный поток ~3 rps закрывает вход всем И добивает начатые входы невосстановимо (воспроизведено мной на бинаре и независимо панелью; фикс — порядок двух строк плюс раздельные ведра). Остальное minor/info, несущие: строковый `"null"` в `committed_usd` читается как НОЛЬ денег (PD-79, закрыть до воркера) · три «закрыто, но не запинено» по собственному правилу зоны — половина PD-3, лимитер PD-29, ветка обновления адреса (PD-83/84/85) · установка по наброску даёт нестартующий юнит и `ProtectHome` против «книги в `~/books`» (PD-91) · админ-CLI, единственный писатель денег, без единого теста (PD-106) · доккоммент `events.go` предлагает то, что PD-59 уже отклонил (PD-95) · дрейф реализованной поверхности `/auth/*`+`X-TM-Client` против контракта 14 (PD-96/остаток) · декодер и норматив зоны расходятся на дубле `seq`, и после PD-12 цена — убитый платный прогон (PD-105: разрешать ратификацией вместе с промтом эмиттера, строка 103) · удаление аккаунта обходит защиту PD-25 через каскад `users → reservations` (PD-107, гейт перед появлением такой операции).
|
||||
|
||||
**5. Опровергнуто приёмкой, включая свои промахи** (дисциплина «заявление=команда» действует и на приёмку): версия панели «удаление аккаунта падает на композитном FK при закрытых резервациях» — мой прогон удаляет · «`money` читает JSON `null` как ноль» — голый `null` даёт nil, дыра в СТРОКЕ `"null"` · «WARN на каждый отбитый вход = неограниченная запись в лог» — `AccessLog` и так пишет INFO на каждый запрос · моя посадка «грант фри-тира не запинен» НЕГОДНА (грант живёт в ветке новой личности; корректная посадка ловится тестом) · моё «падение `FuzzDecoder`» — голод по CPU от параллельных батчей, чистый прогон зелёный · PD-20 — калибровка, а не находка: пин вероятностный по природе дефекта. Отдельно названо, что `TestMigrationsRollBackAndReapply` откатывает ПУСТУЮ базу и для 00005 не доказывает ничего.
|
||||
|
||||
**6. Владельцу — два вопроса:** (а) срок сессии 30 суток означает, что не заходивший месяц человек увидит экран входа — если это против замысла, это одна переменная `TM_PLATFORM_SESSION_MAX_AGE` плюс явная запись отклонения в §13; (б) **новое (PD-104):** фри-тир печатается неаутентифицированным потоком по $5 за каждую новую подтверждённую пару `(provider, subject)`, агрегатного потолка и счётчика аномалий нет нигде — нужен ли суточный лимит грантов до открытия беты.
|
||||
|
||||
**7. Долг лендинга, названный вслух:** PROGRESS CURRENT-STATE **не обновлён** — файл занят живым полигоном (его пинг по эксп-21 лежит незакоммиченным, коммит `d472599` его же). Тот же случай, что D39.107 п.3: обновляет тот, кто лендит полигон. `docs/README.md` обновлён этим же коммитом (норма D39.80), промт P0 платформы получил баннер-исход.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
> Ведёт зона `platform/` (решение владельца 02.08, D39.84: фронт и платформа держат СВОИ бэклоги; единый бэклог `docs/PROGRESS.md` остаётся трекером движка/полигона/доков и фронт/платформа-строк не принимает). Нормы те же: ID стабилен навсегда, каждая петля получает диспозицию. Запросы к ДВИЖКУ сюда не пишутся — они заходят строками единого бэклога через оркестратора (пример: строки 99–102). Засеян оркестратором при лендинге D39.84 — дальше правит платформа-сессия.
|
||||
|
||||
> **Диспозиции после P0 (04.08)** — в журнале зоны, раздел «Диспозиции бэклога зоны»
|
||||
> (`docs/platform-PROGRESS.md`): П-1 начата (каркас), П-2/П-3 не трогали, П-4 черновая схема,
|
||||
> П-5 форма предложена. Дублировать их здесь не стали — у строки один источник истины.
|
||||
> **Диспозиции после P1 (05.08)** — в журнале зоны, раздел «Диспозиции бэклога зоны»
|
||||
> (`docs/platform-PROGRESS.md`). Коротко: П-6 и П-8 ЗАКРЫТЫ, П-7 закрыт по схеме и операциям
|
||||
> (постановка холда воркером — часть П-1), П-4 отменён и поглощён П-7, П-5 переопределён под
|
||||
> кредитную модель и ждёт правки спеки, П-1 продолжена, П-2/П-3 не трогали.
|
||||
> Дублировать их здесь не стали — у строки один источник истины.
|
||||
|
||||
| ID | Хвост | Вес | Источник |
|
||||
|---|---|---|---|
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
# platform — control plane (SaaS-слой)
|
||||
|
||||
Зона записи сессии «Платформа». P0 собран 04.08 (скелет: HTTP · сессии · схема read-model ·
|
||||
интерфейс ингеста); **активный промт — `docs/PLATFORM_SESSION_PROMPT.md`**, зонный журнал —
|
||||
интерфейс ингеста), P1 — 05.08 (вход через OIDC · кредитный леджер · админ-CLI · деплой-юнит ·
|
||||
закрытие регистра дефектов). Направление зоны — `docs/PLATFORM_DIRECTION.md`, критерии приёмки —
|
||||
`docs/ENGINEERING_STANDARDS.md`, дефекты — `docs/DEFECT_REGISTER.md`, зонный журнал —
|
||||
`docs/platform-PROGRESS.md` (весь прогресс зоны здесь, решение владельца 04.08), стек —
|
||||
`docs/STACK_DECISIONS.md`.
|
||||
|
||||
Батарея зоны: `make check` (build · vet · fmt · lint · test -race). Тесты со схемой требуют
|
||||
`TM_PLATFORM_TEST_DSN`; без него они пропускаются, и `check` называет пропуски вслух.
|
||||
`TM_PLATFORM_TEST_DSN` (как поднять Postgres без root — `docs/STACK_DECISIONS.md`); без него они
|
||||
пропускаются, и `check` называет пропуски вслух. `make vuln` и `make fuzz` — отдельными целями.
|
||||
|
||||
Бинари: `cmd/tmplatformd` (сервис) и `cmd/tmplatformctl` (админ: гранты, КОРРЕКТИРОВКИ (`adjust`), баланс, журнал входов,
|
||||
отзыв сессий). Деплой — `deploy/`.
|
||||
|
||||
## ⚠ Git и зона (читать ДО первой строки кода)
|
||||
|
||||
|
|
@ -22,13 +28,16 @@
|
|||
Сервис между фронтом и движком перевода. Всё, что относится к ПОЛЬЗОВАТЕЛЯМ и не относится
|
||||
к переводу:
|
||||
|
||||
- аутентификация и аккаунты (подписки и оплата — ПОСЛЕ MVP, решение владельца 02.08: в MVP
|
||||
оплаты нет и денежных полей в интерфейсе нет);
|
||||
- аутентификация и аккаунты — **есть (P1)**: вход через OIDC даёт только СОБЫТИЕ входа, сессия
|
||||
своя; ключ личности `(provider, subject)`, почта не ключ. Оплаты нет и в бете не будет
|
||||
(владелец 05.08): аккаунты живут на кредитном балансе, фри-тир — запись `grant` в леджер;
|
||||
- библиотека книг: чья книга, права доступа, хранение исходников и экспортов;
|
||||
- учёт токенов и денег **на пользователя** (сырьё уже считает движок: `request_log` +
|
||||
`internal/ledger`), потолки и гейт бюджета ДО старта задачи;
|
||||
- учёт денег **на пользователя** — **схема и операции есть (P1)**: append-only леджер в целых
|
||||
микро-долларах, резервации, кэш баланса с инвариантом `balance == SUM(ledger)`. Защита прогона —
|
||||
холд ДО спавна плюс пер-книжный потолок движку (жёсткий стоп исполняет движок); ждёт воркера;
|
||||
- очередь задач и запуск воркеров, статусы прогонов, ретраи;
|
||||
- SSE-поток прогресса во фронт (⚠ денежные суммы на провод и на экран НЕ идут — D39.84; пользователь видит СТАТУС использования: процент и время сброса, П-5).
|
||||
- SSE-поток прогресса во фронт (⚠ денежные суммы на провод и на экран НЕ идут — D39.84; пользователь
|
||||
видит ОСТАТОК процентом — окон со сбросом больше нет, владелец 05.08).
|
||||
|
||||
## Чего здесь НЕ будет
|
||||
|
||||
|
|
@ -49,11 +58,12 @@
|
|||
## Стек
|
||||
|
||||
Пины, даты релизов и обоснования — [`docs/STACK_DECISIONS.md`](docs/STACK_DECISIONS.md) (зонный,
|
||||
live-сверка 04.08); общая записка по обоим новым сервисам — `../frontend/docs/STACK_DECISIONS.md` §5.
|
||||
live-сверка 04–05.08); общая записка по обоим новым сервисам — `../frontend/docs/STACK_DECISIONS.md` §5.
|
||||
|
||||
Коротко: Go 1.26.4 в `go.mod` (тулчейн сборки ≥1.26.5) · стандартный `net/http` + `ServeMux` без
|
||||
роутер-библиотеки · PostgreSQL 18 · pgx v5.10.0 · goose v3.27.3 · очередь River v0.42.0 на том же
|
||||
Postgres (запинена, ещё не подключена — П-3) · `govulncheck` отдельной целью.
|
||||
Postgres (запинена, ещё не подключена — П-3) · вход `x/oauth2` v0.36.0 + `go-oidc/v3` v3.20.0 ·
|
||||
`x/time` v0.15.0 для лимита на `/auth/login` · `govulncheck` отдельной целью.
|
||||
**Redis не заводим нигде** — зафиксировано как архитектурное «нет».
|
||||
|
||||
Прогресс наружу — SSE, события **пушит воркер**, а не фронт опрашивает read-model.
|
||||
|
|
|
|||
248
platform/cmd/tmplatformctl/main.go
Normal file
248
platform/cmd/tmplatformctl/main.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
63
platform/deploy/README.md
Normal 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. Всё это — работа с первым реальным деплоем, не раньше.
|
||||
86
platform/deploy/tmplatformd.service
Normal file
86
platform/deploy/tmplatformd.service
Normal 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
|
||||
|
|
@ -8,22 +8,110 @@
|
|||
|
||||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||||
|---|---|---|---|---|---|---|
|
||||
| PD-1 | hardening | minor | `internal/pgstore/pg_test.go:89` | Свойство «в БД только SHA-256, не токен» НЕ запинено тестом: посадка «`Digest` возвращает плейнтекст» выживает — тест сверяет хранимое через тот же `auth.Digest` (self-consistent). Нужен тест с НЕЗАВИСИМО вычисленным хешом либо ассерт «плейнтекст в БД не находится» | open | приёмка P0 (посадка №1) |
|
||||
| PD-2 | vuln | **major, ЖИВАЯ (не латентная)** | `cmd/tmplatformd/main.go:73-82` | Нет `ReadTimeout` ⇒ соединения пиннятся уже СЕГОДНЯ, без единой body-принимающей ручки: `net/http` дренирует непрочитанное тело <256 КБ ВНУТРИ `chunkWriter.writeHeader` до отправки заголовка ответа (`net/http/server.go:1389-1435`), и этот чтение-шаг наследует отсутствующий дедлайн. **Репродуцировано оркестратором на собранном бинаре:** 50 полу-кормленных POST на охраняемый `/v0/*` → сервер отработал и залогировал 50×401 `ms:0`, клиенты получили НОЛЬ байт, fd 7→57 и держались, пока не закрыл КЛИЕНТ (агент-скептик независимо пинил 500 соединений). Ограничителя соединений и документированного edge-прокси в зоне нет. Фикс — одна строка (`ReadTimeout`; для будущего SSE — per-conn дедлайны через `ResponseController`). Вторая половина (`MaxBytesReader`) сегодня не эксплуатируема (ни один хендлер не читает body) — гейт P1: закрыть ДО первого POST-хендлера | open | приёмка P0 (security-линза + скептик + собственная репродукция) |
|
||||
| PD-3 | bug | minor | `internal/httpapi/middleware.go:57` | `Recover` логирует сырой `r.URL.Path` на ERROR — id книг/прогонов утекают в лог, против собственной дисциплины AccessLog (route-pattern, не путь) | open | приёмка P0 (security-линза) |
|
||||
| PD-4 | hardening | minor | `internal/pgstore/sessions.go:41` | WHERE у `Touch` слабее, чем у `Lookup` (нет `idle_expires_at > now`): прямой вызов воскресил бы idle-истёкшую сессию. Через `Require` недостижимо (Touch только после успешного Lookup) — одна строка защиты в глубину | open | приёмка P0 (security-линза) |
|
||||
| PD-5 | bug | minor | `internal/auth/middleware.go:36,45` | Ошибки стора невидимы: сбойный `Lookup` → 401 без единой строки лога (аутентификационный DB-outage выглядит как шторм 401), `Touch` глотается `_ =`. На проводе различать нельзя (оракул) — но лог обязан различать | open | приёмка P0 (security+blind линзы) |
|
||||
| PD-1 | hardening | minor | `internal/pgstore/pg_test.go:89` | Свойство «в БД только SHA-256, не токен» НЕ запинено тестом: посадка «`Digest` возвращает плейнтекст» выживает — тест сверяет хранимое через тот же `auth.Digest` (self-consistent). Нужен тест с НЕЗАВИСИМО вычисленным хешом либо ассерт «плейнтекст в БД не находится» — **закрыто:** `internal/pgstore/pg_test.go` — `TestStoredCredentialIsAHashNotTheToken`: оракул SHA-256 считается в тесте, плюс поиск плейнтекста в отрендеренной строке. Посадка «`Digest` возвращает плейнтекст» ПАДАЕТ (проверено) | fixed(P1, дерево сессии) | приёмка P0 (посадка №1) |
|
||||
| PD-2 | vuln | **major, ЖИВАЯ (не латентная)** | `cmd/tmplatformd/main.go:73-82` | Нет `ReadTimeout` ⇒ соединения пиннятся уже СЕГОДНЯ, без единой body-принимающей ручки: `net/http` дренирует непрочитанное тело <256 КБ ВНУТРИ `chunkWriter.writeHeader` до отправки заголовка ответа (`net/http/server.go:1389-1435`), и этот чтение-шаг наследует отсутствующий дедлайн. **Репродуцировано оркестратором на собранном бинаре:** 50 полу-кормленных POST на охраняемый `/v0/*` → сервер отработал и залогировал 50×401 `ms:0`, клиенты получили НОЛЬ байт, fd 7→57 и держались, пока не закрыл КЛИЕНТ (агент-скептик независимо пинил 500 соединений). Ограничителя соединений и документированного edge-прокси в зоне нет. Фикс — одна строка (`ReadTimeout`; для будущего SSE — per-conn дедлайны через `ResponseController`). Вторая половина (`MaxBytesReader`) сегодня не эксплуатируема (ни один хендлер не читает body) — гейт P1: закрыть ДО первого POST-хендлера — **закрыто:** `ReadTimeout` 30 с в `httpapi.DefaultTimeouts`; пин — `TestHalfFedRequestIsDroppedByTheServer` на РЕАЛЬНОМ `http.Server`. Живая проба: полу-кормленный POST теперь отпускается через 30.0 с (был бесконечно). Вторая половина закрыта `LimitBody` на поддереве `/v0` и `/auth`. Побочное обязательство «`ReadTimeout` рубил бы и SSE» ОПРОВЕРГНУТО в P2 (PD-51/PD-63): `net/http` снимает дедлайн сам, помощник `ClearReadDeadline` удалён как воспроизводивший ровно этот дефект; поток пинит `TestStreamOutlivesReadTimeout` | fixed(P1, дерево сессии) | приёмка P0 (security-линза + скептик + собственная репродукция) |
|
||||
| PD-3 | bug | minor | `internal/httpapi/middleware.go:57` | `Recover` логирует сырой `r.URL.Path` на ERROR — id книг/прогонов утекают в лог, против собственной дисциплины AccessLog (route-pattern, не путь) — **закрыто:** `Recover` логирует `route`, не `r.URL.Path` | fixed(P1, дерево сессии) | приёмка P0 (security-линза) |
|
||||
| PD-4 | hardening | minor | `internal/pgstore/sessions.go:41` | WHERE у `Touch` слабее, чем у `Lookup` (нет `idle_expires_at > now`): прямой вызов воскресил бы idle-истёкшую сессию. Через `Require` недостижимо (Touch только после успешного Lookup) — одна строка защиты в глубину — **закрыто:** клауза `idle_expires_at > $2` добавлена; пин — `TestTouchCannotResurrectAnIdleExpiredSession` (посадка падает) | fixed(P1, дерево сессии) | приёмка P0 (security-линза) |
|
||||
| PD-5 | bug | minor | `internal/auth/middleware.go:36,45` | Ошибки стора невидимы: сбойный `Lookup` → 401 без единой строки лога (аутентификационный DB-outage выглядит как шторм 401), `Touch` глотается `_ =`. На проводе различать нельзя (оракул) — но лог обязан различать — **закрыто:** `Authenticator.Log`: сбой `Lookup` (кроме `ErrNoSession`) и сбой `Touch` уходят в ERROR с `request_id`; на проводе по-прежнему неразличимо | fixed(P1, дерево сессии) | приёмка P0 (security+blind линзы) |
|
||||
| PD-6 | hardening | info | `internal/auth/csrf.go:51` | GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) | open | приёмка P0 (security-линза) |
|
||||
| PD-7 | bug | info | `internal/pgstore/sessions.go:78` | `DeleteExpiredSessions` никем не вызывается — свип запланировать в P1 (периодическая джоба воркера) | open | приёмка P0 |
|
||||
| PD-8 | hardening | info | `internal/auth/session.go:18` | Писателя куки ещё нет; `__Host-` требует Secure ⇒ локальный dev по HTTP куку не поставит. Решить формой в P1 (dev-профиль), префикс не ослаблять в проде | open | приёмка P0 |
|
||||
| PD-9 | bug | minor | `cmd/tmplatformd/main.go:81` | `BaseContext` возвращает signal-контекст ⇒ SIGTERM мгновенно рубит контексты ВСЕХ in-flight запросов, и 15-секундный дренаж `Shutdown` мёртв для ctx-aware хендлеров. Fix: BaseContext без signal-ctx; сигнал ведёт только Shutdown | open | приёмка P0 (faults-линза) |
|
||||
| PD-10 | bug | minor | `internal/ingest/decoder.go:42,61,67` | Три ужесточения декодера: (а) `hello` с пустым `engine_run_id` принимается — а это половина ключа идемпотентности; (б) seq самого hello не пинится к 1 — потеря пре-хендшейковых строк недетектируема; (в) mid-stream `hello` (любой версии, вкл. мажор 9.9) уходит в Sink как обычное событие — version-гейт держит только строку 1 | open | приёмка P0 (faults-линза) |
|
||||
| PD-11 | bug | minor | `internal/pgstore/migrations/00002_readmodel.sql:137,177` | Неиндексированные FK-каскады: `notes.chapter_id` и `bank_decisions.term_id` — каскадное удаление сканирует таблицы | open | приёмка P0 (faults-линза) |
|
||||
| PD-12 | bug | info | `internal/ingest/supervisor.go:82-84` | Сбой Sink в начале прогона ⇒ платформа дренирует ВЕСЬ оставшийся поток в `io.Discard` часами: ceiling/bank_stop-события выбрасываются, никто не оповещён. Нужна политика «БД платформы упала посреди прогона» (ретраи синка / деградация с алармом) — дизайн-вопрос P1 | open | приёмка P0 (faults-линза) |
|
||||
| PD-13 | bug | info | `internal/ingest/supervisor.go:64` | Краш платформы осиротляет процесс движка: ни process-group, ни pidfile, ни пути реаттача (поток невосстановим, повторный спавн упрётся в EXCLUSIVE-лок). Дизайн супервизии P1 | open | приёмка P0 (faults-линза) |
|
||||
| PD-14 | hardening | info | `internal/httpapi/server.go:79` | `readyz`: ping без собственного таймаута (WriteTimeout нет намеренно — SSE), эндпоинт неаутентифицирован и без rate-limit — задушить дешёво; таймаут на ping + прикрыть на ops-слое | open | приёмка P0 |
|
||||
| PD-15 | bug | info | `internal/ingest/resync.go:32` | Деньги в ре-синке — float64, а `usage_windows` хранит micro-USD именно против дрейфа: дрейф входит шагом раньше (JSON-парс + суммирование дельт). Принять осознанно или считать в целых | open | приёмка P0 (faults-линза) |
|
||||
| PD-16 | bug | minor | `internal/httpapi/server.go:81` | `readyz` глотает ошибку ping вопреки собственному комменту «the reason stays in the log» — лога нет | open | приёмка P0 (blind-линза) |
|
||||
| PD-17 | bug | minor | `Makefile:41-42` | Баннер «did NOT run (no database)» печатается и при ПРОГНАННЫХ БД-тестах (безусловный); батарея гоняет сьют дважды ради имён скипов (второй прогон без -race) | open | приёмка P0 (blind-линза) |
|
||||
| PD-18 | bug | info | `internal/pgstore/migrations/00002_readmodel.sql:9,139` | Коммент шапки «engine vocabulary never crosses this seam» противоречит `notes.reason` (движковая причина хранится, не проецируется); коммент переписать честно | open | приёмка P0 (canon-линза) |
|
||||
| PD-19 | bug | info | `internal/ingest/resync.go:44` | `WorstFlagReason` задокументирован «stored», а колонки в `chapters` нет — доккоммент или схема, одно из двух | open | приёмка P0 (canon-линза) |
|
||||
| PD-7 | bug | info | `internal/pgstore/sessions.go:78` | `DeleteExpiredSessions` никем не вызывается — свип запланировать в P1 (периодическая джоба воркера) — **закрыто:** свип сессий раз в час в демоне (`sweepSessions`), плюс свип брошенных логинов раз в 15 минут | fixed(P1, дерево сессии) | приёмка P0 |
|
||||
| PD-8 | hardening | info | `internal/auth/session.go:18` | Писателя куки ещё нет; `__Host-` требует Secure ⇒ локальный dev по HTTP куку не поставит. Решить формой в P1 (dev-профиль), префикс не ослаблять в проде — **закрыто:** `auth.Cookies{Insecure}` — dev-профиль меняет ИМЯ вместе с атрибутами (`tm_session` без `__Host-`), `TM_PLATFORM_INSECURE_COOKIES=1`, демон предупреждает в лог | fixed(P1, дерево сессии) | приёмка P0 |
|
||||
| PD-9 | bug | minor | `cmd/tmplatformd/main.go:81` | `BaseContext` возвращает signal-контекст ⇒ SIGTERM мгновенно рубит контексты ВСЕХ in-flight запросов, и 15-секундный дренаж `Shutdown` мёртв для ctx-aware хендлеров. Fix: BaseContext без signal-ctx; сигнал ведёт только Shutdown — **закрыто:** `BaseContext` — собственный контекст, отменяется ПОСЛЕ `Shutdown`; пин — `TestShutdownDrainsInFlightRequests` (посадка «BaseContext = сигнальный ctx» падает) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||||
| PD-10 | bug | minor | `internal/ingest/decoder.go:42,61,67` | Три ужесточения декодера: (а) `hello` с пустым `engine_run_id` принимается — а это половина ключа идемпотентности; (б) seq самого hello не пинится к 1 — потеря пре-хендшейковых строк недетектируема; (в) mid-stream `hello` (любой версии, вкл. мажор 9.9) уходит в Sink как обычное событие — version-гейт держит только строку 1 — **закрыто:** три ужесточения + `ErrBadHandshake`/`ErrRepeatedHello`; пины — `TestHandshakeMustIdentifyTheStream` и `FuzzDecoder` (4.4 млн исполнений, инварианты — оракулы) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||||
| PD-11 | bug | minor | `internal/pgstore/migrations/00002_readmodel.sql:137,177` | Неиндексированные FK-каскады: `notes.chapter_id` и `bank_decisions.term_id` — каскадное удаление сканирует таблицы — **закрыто:** `notes_chapter_idx` + `bank_decisions_term_idx`; `notes.unit_id` уже был | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||||
| PD-12 | bug | info | `internal/ingest/supervisor.go:82-84` | Сбой Sink в начале прогона ⇒ платформа дренирует ВЕСЬ оставшийся поток в `io.Discard` часами: ceiling/bank_stop-события выбрасываются, никто не оповещён. Нужна политика «БД платформы упала посреди прогона» (ретраи синка / деградация с алармом) — дизайн-вопрос P1 — **закрыто:** сбой синка ОСТАНАВЛИВАЕТ прогон (`stop()` после `Ingest`), а не дренирует его в `io.Discard`; пин — `TestFailingSinkStopsTheRun`. Политика ретраев самого синка — при постройке материализатора | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||||
| PD-13 | bug | info | `internal/ingest/supervisor.go:64` | Краш платформы осиротляет процесс движка: ни process-group, ни pidfile, ни пути реаттача (поток невосстановим, повторный спавн упрётся в EXCLUSIVE-лок). Дизайн супервизии P1 — **закрыто:** группа процессов (`Setpgid` + сигнал группе) закрывает обычную остановку; краш платформы закрывает cgroup юнита — `deploy/tmplatformd.service` (проверен `systemd-analyze verify`, живого прогона под systemd не было) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||||
| PD-14 | hardening | info | `internal/httpapi/server.go:79` | `readyz`: ping без собственного таймаута (WriteTimeout нет намеренно — SSE), эндпоинт неаутентифицирован и без rate-limit — задушить дешёво; таймаут на ping + прикрыть на ops-слое — **закрыто:** собственный таймаут 2 с на ping; rate-limit на ops-слое (edge), в зоне не строим | fixed(P1, дерево сессии) | приёмка P0 |
|
||||
| PD-15 | bug | info | `internal/ingest/resync.go:32` | Деньги в ре-синке — float64, а `usage_windows` хранит micro-USD именно против дрейфа: дрейф входит шагом раньше (JSON-парс + суммирование дельт). Принять осознанно или считать в целых — **закрыто:** деньги на шве — `money.MicroUSD` через `big.Rat`, округление ВВЕРХ; пины — `TestSpendConvertsExactlyAndRoundsUp`, `TestParseUSDIsExactAndRoundsAwayFromZero` | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||||
| PD-16 | bug | minor | `internal/httpapi/server.go:81` | `readyz` глотает ошибку ping вопреки собственному комменту «the reason stays in the log» — лога нет — **закрыто:** ошибка ping уходит в ERROR | fixed(P1, дерево сессии) | приёмка P0 (blind-линза) |
|
||||
| PD-17 | bug | minor | `Makefile:41-42` | Баннер «did NOT run (no database)» печатается и при ПРОГНАННЫХ БД-тестах (безусловный); батарея гоняет сьют дважды ради имён скипов (второй прогон без -race) — **закрыто:** один прогон сьюта под `-race`, баннер печатается только при наличии скипов | fixed(P1, дерево сессии) | приёмка P0 (blind-линза) |
|
||||
| PD-18 | bug | info | `internal/pgstore/migrations/00002_readmodel.sql:9,139` | Коммент шапки «engine vocabulary never crosses this seam» противоречит `notes.reason` (движковая причина хранится, не проецируется); коммент переписать честно — **закрыто:** шапка миграции переписана: исключение (`notes.reason`) названо там же | fixed(P1, дерево сессии) | приёмка P0 (canon-линза) |
|
||||
| PD-19 | bug | info | `internal/ingest/resync.go:44` | `WorstFlagReason` задокументирован «stored», а колонки в `chapters` нет — доккоммент или схема, одно из двух — **закрыто:** `WorstFlagReason` убран из аллоулиста — в контракте v0 у главы нет читателя для него | fixed(P1, дерево сессии) | приёмка P0 (canon-линза) |
|
||||
| PD-20 | bug | minor | `internal/ingest/supervisor.go:78` | Один сигнал остановки ТЕРЯЕТСЯ, если послан в первые миллисекунды жизни ребёнка: воспроизведено на стенде отдельным экспериментом (8 запусков, промах на нулевой задержке) и как флейк собственного теста PD-12 (1 падение из 3). Последствие серьёзнее самого промаха: единственный оставшийся механизм — SIGKILL по `WaitDelay`, а движок держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта, и после kill лок остаётся — **закрыто:** `askToStop` повторяет SIGINT на 30/120/400 мс с проверкой «процесс ещё наш» через `os.Process`; пин — `TestFailingSinkStopsTheRun` (25 прогонов подряд зелёные, до фикса падал) | fixed(P1, дерево сессии) | самопроверка P1 (флейк собственного теста) |
|
||||
| PD-21 | vuln | minor | `internal/login/login.go:safeReturnTo` | Открытый редирект в `?return_to`: `/\evil.example` проходил проверку — `url.Parse` читает это как обычный путь, а браузер нормализует `\` в `/` и получает протокол-относительный URL, то есть чужой хост. Найдено ПОСАДКОЙ мутации: ослабление проверки тест пережило, значит тест был слабый — **закрыто:** аллоулист (первый символ `/`, второй не `/`, обратных слэшей нет, `Scheme`/`Host`/`Opaque` пусты), тест переписан на «каждый враждебный вход даёт ПУСТО»; посадка теперь падает. Дефект не покидал дерево сессии | fixed(P1, дерево сессии) | самопроверка P1 (посадка мутации) |
|
||||
| PD-22 | hardening | info | `deploy/` | Ограничителя одновременных соединений нет ни в процессе, ни описанного edge-прокси: `ReadTimeout` ограничивает УДЕРЖАНИЕ одного соединения 30 секундами, но не их число. Осознанно оставлено деплой-слою (`LimitNOFILE`, edge) — строка заведена, чтобы это было решением, а не забывчивостью | accepted-risk(платформа P1, 05.08) | самопроверка P1 |
|
||||
| PD-23 | hardening | info | `internal/pgstore/migrations/00001_identity.sql` | Журнал входов растёт без ретенции и чистится только каскадом при удалении аккаунта. Нужен свип по возрасту (год?) — вопрос политики, не кода | open | самопроверка P1 |
|
||||
| PD-24 | bug | **major** | `internal/pgstore/migrations/` | Переиспользование номера миграции: удалённый `00003_usage.sql` и новый `00003_credits.sql` заняли одну версию. goose применяет ТОЛЬКО по номеру (ни имени, ни хеша), поэтому база, доехавшая до версии 3, рапортует «migrations applied» и не получает ни одной новой таблицы, вход и кредиты падают в рантайме, а `DownTo` на ней ломается навсегда. Обоснование «до деплоя правим на месте» было допущением без механизма — **закрыто:** выпущенные 00001–00003 возвращены байт-в-байт, новое приехало номерами 00004–00007; гейт `migrations.sha256` + `TestReleasedMigrationsAreUnchanged`; апгрейд со старого релиза пинится `TestDatabaseAtAnOlderReleaseCatchesUp` | fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) |
|
||||
| PD-25 | bug | **major** | `internal/pgstore/credits.go`, `00007_credits.sql` | Ключ идемпотентности леджера не содержал `user_id`: грант с ключом, потраченным на другом аккаунте, молча проглатывался, а CLI печатал «granted». Плюс каскад удаления книги уносил ОТКРЫТУЮ резервацию, оставляя строку `hold` в леджере (деньги списаны, вернуть нечем), после чего освободившийся `engine_run_id` давал холд БЕЗ списания, а его релиз печатал деньги — **закрыто:** ключ стал `(user_id, source, source_id)`, пустой ключ запрещён DDL, `book_id` перешёл на составной FK к `books(id, owner_id)` с `on delete restrict`, `appendLedger` возвращает «применилось», `Hold` падает при повторе. Пины: `TestBookWithAnOpenHoldCannotBeDeleted`, `TestSecondHoldOnOneAttemptIsRefused`, `TestGrantIsIdempotentBySource` | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) |
|
||||
| PD-26 | bug | minor | `internal/pgstore/credits.go` | Инверсия порядка блокировок Hold↔Settle/Release: 41 взаимоблокировка на 300 раундов, замерено. `Settle`/`Release` брали строку резервации раньше баланса — **закрыто:** `lockBalance` первым во всех операциях | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) |
|
||||
| PD-27 | bug | minor | `internal/pgstore/credits.go` | `Settle` принимал любую сумму: одно завышенное `committed_usd` уводило баланс в минус, дальше каждый прогон получал `ErrInsufficientCredit` без диагностики — **закрыто:** расчёт capped потолком холда, факт записан в `note`; пин `TestSettlementIsCappedAtTheHold` | fixed(P1, дерево сессии) | ревью денежного пути · ревью «вне карты» |
|
||||
| PD-28 | bug | minor | `internal/ingest/supervisor.go` | `cmd.Wait()` на отменённой команде возвращает `context.Canceled`, а не `*ExitError`, поэтому исход читался как `failed`: штатный SIGTERM пометил бы ВСЕ идущие прогоны провалившимися — **закрыто:** исход из `ProcessState`, факт остановки едет в ошибке; пин `TestStoppedRunKeepsTheEnginesOutcome` | fixed(P1, дерево сессии) | ревью стиля (клейм) + собственная проверка исполнением |
|
||||
| PD-29 | vuln | minor | `internal/login/login.go` | `GET /auth/callback` — неаутентифицированная ручка, ПИШУЩАЯ в БД, без лимита и без ретеншена: замерено 2000 строк за 2.28 с с одного хоста (~76 млн строк/сутки), строки отказов недостижимы через API и не удалялись никогда — **закрыто:** лимитер на колбэке, ретеншен журнала 180 дней свипом | fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
|
||||
| PD-30 | vuln | minor | `internal/pgstore/identity.go` | Грант фри-тира выдавался за каждую новую пару `(provider, subject)` без учёта `email_verified`: провайдер с саморегистрацией превращал каждый новый `sub` в $5, потолок задавал только глобальный лимитер (~$864k/сутки на бумаге) — **закрыто:** грант только подтверждённой личности, аккаунт создаётся с нулём, начисление руками из админки. ⚠ Продуктовое следствие — вопрос владельцу в журнале | fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
|
||||
| PD-31 | bug | minor | `internal/login/login.go` | `discover` держал мьютекс на время сетевого вызова без таймаута: шесть параллельных входов при медленном IdP заняли 4/8/12/16/20/24 с вместо ~4 — **закрыто:** запрос вне лока, свой таймаут 5 с | fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
|
||||
| PD-32 | vuln | minor | `internal/login/login.go`, `cmd/tmplatformd/main.go` | Имя провайдера захардкожено `"google"` независимо от issuer, а `State.Provider` писался и не сверялся: смена issuer тихо кладёт чужие `sub` в старое пространство имён (новые аккаунты, старые недостижимы), а при двух провайдерах стейт одного редимится колбэком другого (IdP mix-up) — **закрыто:** `TM_PLATFORM_OIDC_PROVIDER`, сверка `st.Provider` в колбэке | fixed(P1, дерево сессии) | ревью безопасности · ревью «вне карты» |
|
||||
| PD-33 | vuln | minor | `internal/auth/csrf.go` | Требование `X-TM-Client` снималось ЛЮБЫМ заголовком `Authorization`, включая мусорный: покрытие CSRF-слоя выбирал атакующий (сегодня упиралось в 401, но пережило бы любое послабление в `present`) — **закрыто:** снимает только валидный Bearer, через ту же функцию, что аутентифицирует | fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
|
||||
| PD-34 | bug | minor | `internal/httpapi/serve.go`, `cmd/tmplatformd/main.go` | Две регрессии остановки: второй SIGTERM больше не прерывал дренаж (процесс жил ровно 15 с), а просроченный дренаж возвращал ошибку и давал exit 1 — при `Restart=on-failure` штатная остановка читается systemd как крах — **закрыто:** сигнал разрегистрируется при начале дренажа, просрочка логируется WARN и даёт exit 0, добавлена строка `stopped` | fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) |
|
||||
| PD-35 | bug | minor | `internal/httpapi/middleware.go` | Лимит тела стоял самым внешним слоем, поэтому обещанное «ручка загрузки регистрирует свой, больший лимит» не работало: вложенный `MaxBytesReader` не может ослабить внешний, а контракт требует загрузку книги (23 МБ) — **закрыто:** лимит стал пер-маршрутным аргументом `guard` | fixed(P1, дерево сессии) | ревью «вне карты» |
|
||||
| PD-36 | hardening | minor | `internal/httpapi/middleware.go` | Не было HSTS, CSP и запрета фрейминга; `__Host-` защищает запись куки, а не первый навигационный запрос — **закрыто:** `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`, `X-Frame-Options: DENY`, HSTS в прод-профиле (в dev выключен: пин политики на localhost — долгая ошибка) | fixed(P1, дерево сессии) | ревью безопасности |
|
||||
| PD-37 | bug | minor | `internal/login/login.go` | `safeReturnTo` заявляла защиту, которой не давала: проверка обратного слэша работала по уже раскодированной строке, а браузер декодирует цель редиректа ещё раз (`/%5c/evil.example`). Эксплуатируемого редиректа не получено, но три проверки из четырёх держались на поведении браузера — **закрыто:** проверка обеих форм, теста добавлены процент-кодированные входы | fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
|
||||
| PD-38 | hardening | info | `internal/pgstore/sessions.go`, `00005_identity_oauth.sql` | Отозванные сессии не удалялись до абсолютного срока (90 дней); журнал входов каскадно стирался вместе с аккаунтом, хотя объявлен доказательством для расследования — **закрыто:** свип берёт отозванные и idle-протухшие, `login_events.user_id` перешёл на `on delete set null` (строка анонимизируется, не уничтожается) | fixed(P1, дерево сессии) | ревью безопасности |
|
||||
| PD-39 | bug | info | `internal/money/money.go` | Док обещал округление «от нуля», код округляет к `+∞`; отрицательные дроби не были покрыты тестом вовсе. Плюс `USD()` на `MinInt64` печатал мусор, а вход не имел ограничения длины (2 МБ → 6.1 с и сообщение об ошибке на 2 МБ) — **закрыто:** док приведён к коду, отрицательные кейсы запинены, потолок длины 64 символа, рендер без отрицания | fixed(P1, дерево сессии) | ревью денежного пути · ревью стиля |
|
||||
| PD-40 | bug | info | `internal/ingest/resync.go` | Отсутствующий/`null`/пустой `committed_usd` декодировался в `0` — неотличимо от «попытка не стоила ничего»; на пути расчёта это освободило бы холд и не списало ничего — **закрыто:** `Spend` стал указателем, пустая строка — ошибка | fixed(P1, дерево сессии) | ревью «вне карты» |
|
||||
| PD-41 | bug | info | `internal/login/login.go`, `internal/httpapi/` | Поверхность `/auth/*` отвечала stdlib-телами `text/plain` на 404/405 вопреки нормативу «ответы problem+json»; ошибки стора и сработавший лимитер не логировались; паника писалась без стека; успешный вход не оставлял следа, а недоступность провайдера классифицировалась как «токен отвергнут» — **закрыто:** метод проверяется в обёртке с problem+json, добавлены `login succeeded`, `sign-in rate limit engaged`, `provider_unreachable`, стек паники, `login_start_id` для склейки двух половин входа | fixed(P1, дерево сессии) | ревью логов (исполнением) |
|
||||
| PD-42 | hardening | info | `internal/login/login.go` | Лимит `/auth/login` глобальный: один хост держит ведро пустым и выключает вход всем (замерено: 8 отказов из 10 у «легитимного» пользователя при фоне 5 rps). Пер-адресный лимит здесь неверен, пока нет доверенного edge-прокси — за прокси RemoteAddr один на всех. Место лимита — edge | accepted-risk(платформа P1, 05.08) | ревью безопасности (исполнением) |
|
||||
| PD-43 | bug | info | `internal/pgstore/credits.go` | Денежный контур не имеет ни одного вызывающего вне тестов: `Hold`/`Settle`/`Release` не зовутся, `Sink` не реализован, `TypeSpend` не декодируется. При первом реальном прогоне баланс не изменится. Ожидаемо — воркера нет (П-1/П-3), но заведено строкой, чтобы это было решением, а не сюрпризом | open | ревью «вне карты» |
|
||||
| PD-44 | hardening | info | `internal/pgstore/` | `sqlc` не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx. Нужна ратификация: адаптировать денежный пакет под sqlc в следующей сессии либо поправить направление | open | ревью «вне карты» |
|
||||
| PD-45 | hardening | info | `internal/ingest/procgroup_unix.go` | `syscall.Kill(-pid, SIGINT)` идёт мимо `os.Process`, поэтому в узком окне между проверкой живости и сигналом ребёнок может быть пожат, и сигнал уйдёт в переиспользованную группу. Окно ~микросекунды и родитель ещё не звал `Wait`; переписывать на pidfd-путь — отдельная работа | open | ревью «вне карты» |
|
||||
| PD-46 | hardening | minor | `internal/httpapi/serve.go:33-40` | **Запинена ПРОВОДКА `ReadTimeout`, но не ЗНАЧЕНИЕ, с которым едет демон.** Тесты строят свой `Timeouts` (`fastTimeouts`), поэтому посадка «`DefaultTimeouts().Read = 0`» проходит ВСЮ батарею зелёной — а `main.go:121` берёт именно `DefaultTimeouts()`. Посадка «убрать `ReadTimeout` из `NewServer`» ловится (проверено), то есть дыра ровно в дефолтах. Это форма, в которой PD-2 пережил P0: свойство проверено не на том объекте, который едет в прод. Фикс — тест на сами значения `DefaultTimeouts` — **закрыто:** `httpapi.TestTheServerTheDaemonRunsHasEveryDeadlineSet` утверждает не литералы, а сам `*http.Server`, который строит `NewServer(…, DefaultTimeouts())`: каждый дедлайн >0, `WriteTimeout` ОБЯЗАН быть нулём (иначе резал бы SSE), `ReadHeaderTimeout <= ReadTimeout`, grace >0. Закрывает обе половины — значение и проводку. Пять посадок поймано поимённо: `DefaultTimeouts().Read=0`, снятие `ReadTimeout` из `NewServer`, снятие `IdleTimeout`, добавление `WriteTimeout` «для симметрии», снятие `Unwrap` | fixed(P2, дерево сессии) | приёмка P1 (посадка M23/M43) |
|
||||
| PD-47 | bug | minor | `internal/login/login_test.go:266` | **Закрытие PD-37 заявлено неверно:** «в тесты добавлены процент-кодированные входы» — их там нет (список: `//evil.example/`, `https://…`, `http:/…`, `/\evil.example`, `/\/evil.example`, `/\tevil`, `evil.example`, ``). Посадка «судить только сырую форму, без второго декода» батарею ПЕРЕЖИВАЕТ. Побочно: посадка «убрать обратный слэш из `ContainsAny`» тоже переживает — на тестовых входах её дублирует проверка `s[1]`. Эксплуатируемого редиректа нет; не запинена именно та защита, ради которой заведён PD-37 — **закрыто:** в таблицу добавлены процент-кодированные входы (`/%5c/`, `/%5C/`, `/%09`, `/%00`, `/%0d%0a`) — их ловит ТОЛЬКО второй декод — и `/%2f/evil.example`, который ловит ТОЛЬКО проверка `s[1]` на декодированной форме; плюс `FuzzSafeReturnTo`, который пинит СВОЙСТВО независимым оракулом (`url.URL.ResolveReference` после браузерной нормализации `\`→`/`), 3,1 млн исполнений без контрпримера. Посадки «судить только сырую форму», «убрать класс символов», «убрать protocol-relative» падают каждая. ⚠ Побочно установлено: условия `u.Scheme/u.Host/u.Opaque` НЕДОСТИЖИМЫ как отказ (при `raw[0]=='/'` схемы и Opaque не бывает, Host требует `//`), пин на них невозможен — оставлены бэкстопом, это названо в коде | fixed(P2, дерево сессии) | приёмка P1 (посадки M15/M16) |
|
||||
| PD-48 | hardening | minor | `internal/login/login.go:268-271` | **Правило PD-30 «грант только подтверждённой личности» не запинено ничем:** удаление `if !claims.EmailVerified { grant = 0 }` проходит все тесты `internal/login`. `pgstore.TestUnverifiedAddressStaysOffTheAccount` пинит другое свойство (адрес не поднимается на аккаунт), денежное — никто. По правилу шапки этого файла PD-30 закрытым не считается — **закрыто:** `login.TestSignupGrantGoesOnlyToAVerifiedIdentity` гоняет обе ветки через настоящий поток и сверяет САМ грант, дошедший до стора (`memStore` теперь его запоминает — раньше отбрасывал, потому правило и было незапинено). Посадка «убрать условие `EmailVerified`» падает | fixed(P2, дерево сессии) | приёмка P1 (посадка M14) |
|
||||
| PD-49 | hardening | minor | `internal/login/login.go:239-242` | **Вторая половина PD-32 не запинена:** удаление сверки `st.Provider != h.cfg.Provider` проходит все тесты. Сегодня провайдер один, поэтому свойство латентное — но заведено оно ровно под появление второго (IdP mix-up) — **закрыто:** `login.TestStateFromAnotherProviderIsRefused` подменяет провайдера в сохранённой строке состояния — форма, которую даёт появление второго провайдера, — и требует 400, отсутствия сессии, причины `state_from_another_provider` в журнале и НУЛЯ обращений к token endpoint. Посадка «убрать сверку» падает. Норму при этом закрывает не она, а PD-57 | fixed(P2, дерево сессии) | приёмка P1 (посадка M19) |
|
||||
| PD-50 | hardening | info | `internal/auth/csrf.go:55-60` | Закрытие PD-33 сформулировано сильнее кода: «снимает только ВАЛИДНЫЙ Bearer» — на деле `Present` только ПАРСИТ, поэтому `Authorization: Bearer <мусор>` требование `X-TM-Client` снимает. Привилегии это не даёт, проверено живой пробой (кука + мусорный Bearer + без заголовка → 401, не хендлер): безопасность держит правило «Bearer побеждает куку» в `Present`, а не «валидность». Посадка «снимать любым непустым Authorization» батарею переживает. Фикс — либо тест, либо честная формулировка доккоммента — **закрыто формулировкой + пином:** доккоммент `cookieUnsafe` переписан на то, что верно (`Present` ПАРСИТ, не валидирует; безопасность держит правило «есть `Authorization` ⇒ кука не участвует», а не валидность). `auth.TestAnAuthorizationHeaderTakesTheCookieOutOfPlay` пинит именно это на пяти формах заголовка; посадка «падать обратно на куку при неразобранном заголовке» падает | fixed(P2, дерево сессии) | приёмка P1 (посадка M30 + живая проба) |
|
||||
| PD-51 | bug | minor | `internal/httpapi/serve.go:118-127`, `STACK_DECISIONS §12` | **Механизм заявлен неверно.** Утверждение «`ReadTimeout` убил бы и поток, поэтому стриминговый хендлер ОБЯЗАН снять read-дедлайн» на Go 1.26.5 не подтверждается: `connReader.startBackgroundRead` сам делает `SetReadDeadline(time.Time{})` (`net/http/server.go:687-698`) и для запроса без тела вызывается ДО хендлера (`:2062`). Проверено исполнением на трёх формах запроса (GET без тела · POST с непрочитанным телом · POST с вычитанным телом) — поздний кадр доезжает во всех шести комбинациях, звали `ClearReadDeadline` или нет. Следствие: `TestStreamOutlivesReadTimeout` НЕ МОЖЕТ упасть от выхолащивания `ClearReadDeadline` (проверено); он пинит только `Unwrap` (эта посадка ловится). Код безвреден, ложны обоснование и строка в таблице пинов — **закрыто, и вывод приёмки уточнён исполнением:** механизм подтверждён (`startBackgroundRead` снимает дедлайн сам, `server.go:687-698`, для запроса без остатка тела — до хендлера, `:2059-2062`; по ходу хендлера не перевзводится — проверено по всем call sites). Но «код безвреден» неверно: см. PD-63. `ClearReadDeadline` УДАЛЁН, `STACK_DECISIONS §12` переписан, `TestStreamOutlivesReadTimeout` переписан на настоящее свойство (поток переживает `Read` БЕЗ действий хендлера) и пинит `Unwrap` через ошибку `Flush` | fixed(P2, дерево сессии) | приёмка P1 (посадка M24/M42 + отдельная проба) |
|
||||
| PD-52 | hardening | minor | `internal/pgstore/credits.go:233-243` | Порядок блокировок (PD-26) не запинен ни одним тестом — снятие `lockBalance` из `closeReservation` батарею переживает. Дефект воспроизведён приёмкой НЕЗАВИСИМО, в форме, которая действительно даёт цикл: конкурентные `Settle(run-1)` и повторный `Hold(run-1)` — **2 взаимоблокировки на 150 раундов с инверсией, 0 с фиксом**. Регрессионный тест написан приёмкой и лежит готовым к вставке в `docs/platform-PROGRESS.md`, раздел «Ратификация приёмкой P1». ⚠ Замер сессии «41 на 300» воспроизвести не удалось — их нагрузка не описана; принимается СО СЛОВ — **закрыто:** тест приёмки вставлен как `pgstore.TestHoldAndSettleOnTheSameAttemptDoNotDeadlock`. ⚠ Замер приёмки не копировался, а ПЕРЕПРОВЕРЕН на своём стенде (PostgreSQL 18.4): с инверсией падает 5 прогонов из 5, 5–10 взаимоблокировок на 150 раундов; с фиксом 5 прогонов из 5 зелёные. Замер сессии P1 «41 на 300» так и не воспроизведён и остаётся СО СЛОВ | fixed(P2, дерево сессии) | приёмка P1 (посадка M07 + собственная репродукция) |
|
||||
| PD-53 | hardening | info | `internal/httpapi/server.go:73-75` | `DefaultMaxBody` не запинен: поднятие лимита поддерева до 1 ГиБ батарею переживает. Пер-маршрутность лимита (PD-35) — тоже только на ревью — **закрыто:** `httpapi.TestBodyCapIsPerRouteBecauseNestingOnlyTightens` фиксирует исполнением ПРИЧИНУ пер-маршрутности — вложенный БОЛЬШИЙ лимит не поднимает внешний, — поэтому возврат общего слоя молча урезал бы аплоуд-маршрут; `TestDefaultBodyCapStaysAContractSizedNumber` держит дефолт в полосе контрактного размера (посадка «1 ГиБ» падает), не превращаясь в change-detector на точное число | fixed(P2, дерево сессии) | приёмка P1 (посадка M26) |
|
||||
| PD-54 | bug | minor | `docs/platform-PROGRESS.md:329-353` | В журнале ДВЕ несовместимые формы `GET /v0/usage`: новая кредитная (строка 172) и старая подписочная (строка 329) с `resets_at`, `windows[{period}]` и хранением в `usage_windows` — таблице, которую снесла миграция `00006`. Секция P0-эры не помечена superseded, а S3 идёт читать журнал именно за формой ручки — **закрыто:** подписочное тело ответа УДАЛЕНО из журнала, а не помечено баннером: S3 идёт туда за формой ручки и скопировал бы тело. Осталась одна форма — кредитная, в разделе «Что предлагаем в спеку (S3)»; из П-5 сохранены абзацы, не зависящие от модели денег, ссылка на хранение переведена на `credit_ledger` | fixed(P2, дерево сессии) | приёмка P1 (свип доков) |
|
||||
| PD-55 | bug | info | `deploy/tmplatformd.service` | `MemoryMax=2G` объявлен как «bounds the control plane», но ограничивает cgroup ЮНИТА — а по собственному аргументу этого же файла (закрытие PD-13) в этом cgroup живёт каждый ребёнок-`tmctl`. Значит потолок общий на платформу и все идущие прогоны, и OOM-killer выберет самый жирный процесс — движок, который держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта: ровно тот исход, ради которого запрещён SIGKILL. То же про `TasksMax=512`. Латентно до появления воркера. ⚠ Под systemd не проверялось (нет sudo) — вывод из семантики `MemoryMax=`, не из замера — **закрыто:** семантика сверена по man 5 systemd.resource-control («absolute limit on memory usage of the executed processes in this unit… out-of-memory killer is invoked inside the unit»). `MemoryMax=2G` заменён на `MemoryMax=80%` — потолок машины, а не сервиса, как «last line of defense» и без знания о железе; `TasksMax=512` оставлен с честным комментарием, что покрывает платформу и прогоны вместе; ограничение ОДНОГО прогона названо работой воркера (transient scope). Побочно найдено и закрыто следствие, которого в этой строке не было, — PD-64. ⚠ Под systemd не запускалось (нет sudo); `systemd-analyze verify` (systemd 259) — exit 0 | fixed(P2, дерево сессии) | приёмка P1 (ревью деплой-юнита) |
|
||||
| PD-56 | bug | info | `internal/pgstore/credits.go:35-63` | `Grant`/`Adjust` на несуществующий аккаунт отдают оператору сырую ошибку Postgres с именем констрейнта (`credit_ledger_user_id_fkey`), тогда как `Balance` на том же входе отдаёт `ErrNoAccount`. Живая проба CLI. Косметика админ-поверхности, но опечатка в id читается как поломка БД — **закрыто:** `appendLedger` мапит нарушение `credit_ledger_user_id_fkey` в `ErrNoAccount`; `pgstore.TestMoneyOperationsAgreeOnAMissingAccount` требует одного ответа от `Grant`/`Adjust`/`Balance`/`ReadAccount`. Посадка «убрать сверку констрейнта» падает | fixed(P2, дерево сессии) | приёмка P1 (живая проба CLI) |
|
||||
| PD-57 | hardening | minor | `internal/login/login.go:239-242` | **Защита от IdP mix-up не та, что требует действующая норма.** RFC 9700 §2.1 (OAuth Security BCP, янв. 2025) — клиент SHOULD применять параметр `iss` из авторизационного ответа (RFC 9207) либо иной контрмер НА ОСНОВЕ `iss`; MAY — различные redirect URI на провайдера. Реализована собственная сверка `st.Provider` с `h.cfg.Provider`, а внутри одного хендлера это сравнение конфигурации с самой собой: `start` пишет туда то же значение. `iss` авторизационного ответа не читается вообще (`iss` ID-токена библиотека проверяет — это другой шаг и другой момент). Сегодня не эксплуатируемо: провайдер один, код всегда редимится у него же. Заведено потому, что регистр объявляет PD-32 закрытием «класса IdP mix-up», а против нормы это неверно, и при втором провайдере выбор (`iss` или раздельные redirect URI) должен быть ОСОЗНАННЫМ, а не побочным эффектом конфигурации — **закрыто реализацией нормы, а не обещанием.** Первоисточники сверены: RFC 9700 §4.4.2 («When an OAuth client can only interact with one authorization server, a mix-up defense is not required» — то есть СЕГОДНЯ несоответствия нет, требование включается со вторым сервером), §4.4.2.2 объявляет раздельные redirect URI фолбэком («SHOULD therefore only be used if other options are not available»); альтернатива «`iss` из ID-токена» нам не подходит — при чистом code flow токен приходит уже ПОСЛЕ отдачи кода. Выбран `iss` авторизационного ответа: **Google его шлёт** (`authorization_response_iss_parameter_supported: true`, сверено живьём). Сделано: `auth_states.issuer` (миграция 00008), сверка до обмена кода, отказ на СОРВАННОМ параметре у поддерживающего провайдера (RFC 9207 §2.4). Пин — `login.TestAuthorizationResponseIssuerIsChecked` (4 случая); посадки «убрать вызов», «убрать ветку несовпадения», «убрать ветку сорванного параметра», «потерять issuer в сторе» падают | fixed(P2, дерево сессии) | приёмка P1 (сверка с RFC 9700 §2.1 / RFC 9207) |
|
||||
| PD-58 | hardening | minor | `internal/config/config.go:60-61` | **Несоответствие собственной объявленной базовой линии.** `ENGINEERING_STANDARDS §2` берёт ASVS 5.0 L2, а L2 требует ДОКУМЕНТИРОВАТЬ сроки: 7.1.1 (срок бездействия и абсолютный предел + обоснование отклонений от NIST SP 800-63B), 7.1.2 (политика одновременных сессий), 7.1.3/7.6.1 (согласование срока НАШЕЙ сессии со сроком федеративной — у нас наша живёт своей жизнью, RP-initiated/back-channel logout нет). Сроки 14 суток бездействия и 90 суток абсолютных существуют только литералами в коде, обоснования нет ни в одном доке (проверено grep). Механические требования V7 при этом ВЫПОЛНЕНЫ и проверены: 7.2.3 энтропия (256 бит при требуемых 128), 7.2.4 ротация токена на аутентификации, 7.4.1 отзыв, 7.4.2 снос сессий при удалении аккаунта. ⚠ 7.4.5 (системный отзыв админом) покрыт только пер-пользовательским `revoke`; 7.5.2 (пользователь видит свои сессии) — работа П-1 — **закрыто, и значение выровнено вместо сочинения оправдания.** Тексты сверены дословно: ASVS 5.0 7.1.1/7.1.2/7.1.3, 7.6.1/7.6.2 и NIST SP 800-63B-4 §2.1.3 («overall timeout … SHOULD be no more than 30 days at AAL1; an inactivity timeout MAY be applied but is not required»). Абсолютный срок 90 суток был отклонением от SHOULD без причины, выдерживающей проверку, — снижен до **30 суток**; бездействие 14 суток остаётся и строже нормы. Документ — `STACK_DECISIONS §13`: уровень AAL1, оба срока, политика одновременных сессий (лимита нет — контракт предусматривает куку и Bearer одновременно; вместо лимита отзыв, «выйти везде» и журнал), рассогласование с федеративной сессией названо прямо (RP-initiated/back-channel logout нет), 7.6.2 выполнено. Пин — `config.TestSessionClocksStayWithinTheDeclaredBaseline` | fixed(P2, дерево сессии) | приёмка P1 (сверка с ASVS 5.0 V7) |
|
||||
| PD-59 | bug | info | `docs/platform-PROGRESS.md`, вопрос оркестратору №4 | **Канал не меняем — но решает это не тот довод, который обсуждали.** Вопрос вынесен абстрактно (без контекста репозитория) двум независимым агентам, с доступом в сеть и без. По каналу они РАЗОШЛИСЬ, зато независимо сошлись на трёх вещах, которых не было ни в записке зоны, ни в первых трёх редакциях приёмки. **(1) SIGPIPE зависит от НОМЕРА дескриптора** (`os/signal`: обрыв на fd 1/2 убивает процесс, на любом другом — возвращает `EPIPE`). **Замерено:** поток на fd 1 → ребёнок УБИТ `broken pipe`; на fd 3 → `write` вернул EPIPE и процесс доработал до конца. Для нас это деньги: сегодня падение платформы убивает движок на следующей же записи события, а после переезда движок станет сиротой и часами будет жечь оплаченные вызовы, пока холд висит в леджере и некому его закрыть. Свойство несущее и нигде не записано. **(2) Настоящая защита — не выбор канала, а перехват на уровне дескриптора** в `main` движка: `dup(1)` в приватный fd, затем `dup3(2,1,0)`. Он герметичен там, где предложенный приёмкой `os.Stdout = os.Stderr` дыряв: переживает `var out = os.Stdout` в зависимости, cgo и унаследованный fd 1 у внуков. **(3) Дискриминатор, при котором переезд был бы прав** — «ребёнок исполняет чужой код, наследующий stdio». **Проверено: у нас нет** — `grep` по `backend/` не находит ни одного `exec.Command` вне тестов и ни одного cgo. Плюс сверено: ловушка `bufio.Scanner`, которую оба назвали самым вероятным латентным багом (переполнение строки читается как чистый EOF), у нас закрыта — `Buffer` поднят до 1 МиБ и `sc.Err()` проверяется (`decoder.go:45,115`) | **закрыт ратификацией**, работа уходит строкой 103 | приёмка P1 (четвёртая итерация: два независимых агента + замер SIGPIPE) |
|
||||
| PD-60 | bug | minor | `internal/ingest/supervisor.go`, шов | **Обратное давление не спроектировано, и канал тут ни при чём.** Пайп держит 64 КиБ; если синк платформы встанет на Postgres, движок заблокируется в `write(2)` — на часы, без контекста и дедлайна, прервать нечем. Сегодня не проявляется только потому, что материализатора ещё нет: `Ingest` кормит `Sink` синхронно, и латентность БД станет латентностью движка. Нужна ограниченная очередь у читателя и ЯВНАЯ политика на её переполнение: блокировать движок (корректно, но прогресс прогона привязан к доступности БД) или ронять события с маркером `events_dropped` (быстро, но журнал начинает врать). Выбрать и записать — обе позиции законны, молчаливой третьей нет | open | приёмка P1 (абстрактный разбор двумя агентами, оба независимо) |
|
||||
| PD-61 | bug | info | шов, строка 103 | Два свойства эмиттера, которые надо задать ДО его постройки, иначе они станут миграцией. **(а) Сброс буфера на выходе:** `bufio.Writer` вокруг потока плюс `os.Exit`/`log.Fatal` пропускает `defer` и теряет последние события — ровно те, что сообщают об окончании прогона. **(б) Хвост при падении платформы:** содержимое непрочитанного пайпа умирает вместе с читателем. Если требование «платформа перезапустилась, прогон продолжается» когда-нибудь появится, ответ — НЕ сокет (он даёт переподключение без возобновления), а журнал файлом: движок дописывает NDJSON в `<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; версия панели опровергнута) |
|
||||
|
|
|
|||
|
|
@ -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` чистый**; рантайм-граф прирастает одним
|
||||
|
|
|
|||
|
|
@ -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/`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,9 +6,33 @@
|
|||
|
||||
## Текущее состояние
|
||||
|
||||
- **P0 ПРИНЯТ и ЗАЛЕНДЕН** (`eeeef89`, приёмка оркестратора №14 04.08 — раздел «Ратификация приёмкой»
|
||||
ниже). Дизайн-ответы К-4/К-7/К-12/П-5 ратифицированы С ПОПРАВКАМИ. Найдено 19 дефектов, все
|
||||
строками в `DEFECT_REGISTER.md`; один — ЖИВАЯ уязвимость (PD-2, пиннинг соединений), гейт P1.
|
||||
- **P1+P2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ приёмкой №15 (07.08)** — раздел «Ратификация приёмкой P2» ниже:
|
||||
вердикт, метод, что ратифицировано, фикс-лист, что опровергнуто. Два вопроса ушли владельцу:
|
||||
срок сессии 30 суток и агрегатный потолок фри-тира (PD-104).
|
||||
- **Регистр после приёмки — 107 строк** (скриптом по таблице): 66 закрыто · 3 приняты риском ·
|
||||
1 закрыт ратификацией (PD-59) · **37 открыто** — из них **1 major** (PD-80), 17 minor, 19 info.
|
||||
Прежние восемь (PD-6 · PD-23 · PD-43 · PD-44 · PD-45 · PD-60/61 · PD-72) плюс 29 новых
|
||||
PD-79…PD-107. Первые в очереди зоны — фикс-лист приёмки, порядок там же.
|
||||
- **P2 отработала очередь приёмки P1 целиком плюс ДВА собственных адверсариальных ревью** (05.08) —
|
||||
разделы «Сессия P2» ниже. Риском приняты три строки (PD-22 ограничитель соединений на edge,
|
||||
PD-42 глобальный лимитер входа, PD-71 откат ниже версии 5); счёт регистра — строкой выше, здесь
|
||||
не дублируется. Открытыми на конец P2 были восемь: PD-6 (origin-чек SSE-хендшейка — строить нечего до SSE), PD-23 (ретеншен
|
||||
журнала входов — сам свип есть, строка про политику), PD-43 (денежный контур без вызывающих до
|
||||
воркера), PD-44 (`sqlc` — закрыт ратификацией, направление изменено), PD-45 (окно pid при сигнале
|
||||
группе), **PD-72 — возврат общего лимита тела не наблюдаем, пока нет маршрута со своим потолком:
|
||||
тест обязан приехать вместе с загрузкой книги**, **PD-60 и PD-61 — свойства ШВА, работа строки 103
|
||||
единого бэклога** (обратное давление и
|
||||
сброс буфера эмиттера: платформенной части у них нет, пока эмиттера нет).
|
||||
- **Закрыто в P2:** вся очередь приёмки (PD-46…PD-58), три info-строки вне очереди
|
||||
(PD-50, PD-53, PD-56), три собственные находки — PD-62 (потерянный `start_id`), PD-63
|
||||
(`ClearReadDeadline` воспроизводил PD-2), PD-64 (`OOMPolicy=stop` уронил бы контрол-плейн) — и
|
||||
**шесть находок собственного адверсариального ревью** (PD-65…PD-70), из которых одна major:
|
||||
скользящее окно бездействия для браузера не работало (PD-70).
|
||||
- **Два значения изменены, не обоснованы:** абсолютный срок сессии 90 → **30 суток** (цифра NIST
|
||||
AAL1; отклонение без причины, выдерживающей проверку, — не отклонение, а недосмотр) и
|
||||
`MemoryMax=2G` → **80%** (потолок машины вместо мнимого потолка сервиса). Оба переопределяются.
|
||||
- **Построено в P1:** вход через OIDC (П-6), кредитный леджер с резервациями (П-7), админ-CLI (П-8),
|
||||
деплой-юнит systemd, тест-пол на реальном `http.Server`, фаззинг NDJSON-декодера.
|
||||
- **Стандарты зоны заведены** (решение владельца 04.08): `ENGINEERING_STANDARDS.md` (критерии приёмки,
|
||||
индустриальные базовые линии) + `DEFECT_REGISTER.md` (отдельная колонка багов и уязвимостей).
|
||||
- **P0 собран** (сессия 04.08): модуль компилируется, батарея зоны `make check` зелёная,
|
||||
|
|
@ -17,7 +41,333 @@
|
|||
- Дизайн-ответы К-4 · К-7 · К-12 · форма П-5 — ниже, ПРЕДЛОЖЕНИЯМИ на ратификацию.
|
||||
- Контрактных ручек нет намеренно: они ждут ратификации К-4/К-7 (форма ответов) — это П-1.
|
||||
|
||||
## Открытые вопросы к владельцу/оркестратору
|
||||
## Ратификация приёмкой P2 (оркестратор №15, 07.08)
|
||||
|
||||
**Вердикт: P1 и P2 ПРИНЯТЫ и залендены — одним коммитом, 30 изменённых отслеживаемых файлов и 22
|
||||
новых.** ⚠ **Испр. оркестратором №15:** раздел «Ратификация приёмкой P1» ниже говорит «P1 ПРИНЯТ и
|
||||
заленден» — заленден он НЕ был. До этого коммита `git ls-files platform/internal/login` возвращал
|
||||
ноль: в git уехали только P0 (`eeeef89`/`954c034`), направление (`99c9cb0`) и решения владельца
|
||||
(`87be7b9`/`6469479`). Строки регистра формулировку не завышали — они честно говорят
|
||||
`fixed(P1, дерево сессии)`.
|
||||
|
||||
**Живой уязвимости приёмка не нашла.** Найденное — 29 строк регистра **PD-79…PD-107**: одна major
|
||||
(доступность входа, PD-80), остальные minor/info. Лендинг не блокирует ничего; три строки блокируют
|
||||
первый реальный деплой и постройку воркера — названы в фикс-листе. Метод панели: семь линз с
|
||||
зажатыми промтами (отчётные доки зоны им запрещены), затем адверсариальный опровергатель на КАЖДУЮ
|
||||
находку весом minor и выше — 20 подтверждено, 2 опровергнуто, плюс мои собственные замеры.
|
||||
|
||||
**Метод — исполнением, не чтением отчёта.**
|
||||
|
||||
- Батарея пере-прогнана мной: офлайн зелёная (линтер 0 issues); с живым PostgreSQL **18.4**,
|
||||
поднятым без root по рецепту `STACK_DECISIONS`, — **скипов НОЛЬ** (26 БД-тестов отработали);
|
||||
`make vuln` (govulncheck v1.6.0) — чист.
|
||||
- **Свои мутации по СВОЕЙ карте несущих свойств, не по таблице пинов зоны: 45 посадок в четыре
|
||||
батча — 33 поймано поимённо, 8 выжило, 4 моих посадки оказались негодными** (разобраны ниже).
|
||||
Мутации ставились в КОПИИ зоны вне репозитория: незакоммиченное дерево сессии не трогалось, что
|
||||
сверено хешами диффа до и после.
|
||||
- Живой бинарь: `healthz`/`readyz` против живой БД · `/v0/*` → 401 problem+json · пять форм
|
||||
CSRF-пробы (кука без `X-TM-Client` 403 · с заголовком 401 · `Sec-Fetch-Site: cross-site` 403 ·
|
||||
мусорный Bearer 401 · неразобранный `Authorization` 403) · редирект `/auth/login` с PKCE S256 и
|
||||
одноразовой кукой · ПТ-34-заголовки на каждом ответе.
|
||||
- **PD-2 на том бинаре, который едет:** 10 полу-кормленных POST отпущены на **30.0 с** (в P0
|
||||
держались, пока не уходил клиент).
|
||||
- **RFC 9207 живьём:** Google действительно шлёт `iss`
|
||||
(`authorization_response_iss_parameter_supported: true`, сверено мной у издателя); сорванный
|
||||
параметр → `issuer_missing`, чужой → `issuer_mismatch`, обмена кода в обоих случаях не было.
|
||||
- **PD-71 пере-проверен своим прогоном на боевых данных** (аккаунт с `email = NULL` + два аккаунта
|
||||
с общим подтверждённым адресом): `DownTo(4)` падает на `users_email_key` SQLSTATE 23505, три
|
||||
аккаунта целы, `Up()` возвращает схему на версию 8. Заявление зоны воспроизвелось дословно.
|
||||
- Фаззеры: `FuzzSafeReturnTo` 3.0 млн исполнений, `FuzzDecoder` 3.35 млн — крэшеров нет.
|
||||
- `systemd-analyze verify` (systemd 259, с подставленным существующим `ExecStart=`) — exit 0.
|
||||
- **Цитаты норм сверены по первоисточникам, а не по пересказу:** RFC 9700 §4.4.2 и §4.4.2.2,
|
||||
NIST SP 800-63B-4 §2.1.3, ASVS 5.0 7.1.1/7.1.2/7.1.3/7.6.1/7.6.2 — формулировки дословны,
|
||||
номера разделов верны, уровень L2 верен. Это несущая проверка: на этих цитатах стоит смена
|
||||
боевого значения 90 → 30 суток.
|
||||
- Границы зоны: `backend/` не импортируется, SQLite движка не открывается, денежных величин в
|
||||
`httpapi`/`auth`/`reqid` нет; в дереве зоны только `platform/*` (чужое — живой полигон).
|
||||
|
||||
**Ратифицировано (6 из 6).**
|
||||
|
||||
1. **Абсолютный срок сессии 30 суток — ПРИНЯТО.** Цифра — буква NIST SP 800-63B-4 §2.1.3 («A
|
||||
definite reauthentication overall timeout SHALL be established, which SHOULD be no more than 30
|
||||
days at AAL1»), и у прежних 90 обоснования не было. ⚠ Видимое следствие продуктовое — вынесено
|
||||
владельцу (ниже).
|
||||
2. **Против mix-up — `iss` авторизационного ответа (RFC 9207), миграция 00008 — ПРИНЯТО.** Норма
|
||||
сверена: §4.4.2 включает требование со ВТОРОГО сервера, §4.4.2.2 объявляет раздельные redirect
|
||||
URI фолбэком («SHOULD therefore only be used if other options are not available»). Реализация
|
||||
проверена живьём, включая отказ на сорванном параметре.
|
||||
3. **`STACK_DECISIONS §13` (политика сессий) — ПРИНЯТО как документ соответствия ASVS 7.1.1/7.1.2/
|
||||
7.1.3.** Рассогласование с федеративной сессией названо прямо — это и есть то, чего требует
|
||||
норма, а не то, что она запрещает.
|
||||
4. **Ломающие изменения зоны — ПРИНЯТЫ:** `httpapi.NewServer` без `Timeouts` (устранение КЛАССА
|
||||
PD-66 сильнее теста), `Prober.Ping` → `Ready`, `login.Fail` без `*http.Request`. Внешних
|
||||
потребителей у этих подписей нет: фронт говорит с зоной по HTTP.
|
||||
5. **`MemoryMax=80%` + явный `OOMPolicy=continue` — ПРИНЯТО.** Аргумент сверен с
|
||||
`systemd.resource-control(5)` («last line of defense», OOM-killer внутри юнита) и
|
||||
`systemd.service(5)` (системный дефолт `stop`). ⚠ Под systemd не исполнялось — вывод из доки.
|
||||
6. **PD-71 — ПРИНЯТ РИСКОМ** в форме, которую предложила зона: правило append-only дороже
|
||||
доступности отката ниже версии 5, и место такой записи — `deploy/README.md` у оператора, а не
|
||||
сноска в архитектурном доке. Пере-проверено моим прогоном (см. выше).
|
||||
|
||||
**Вынесено владельцу — два вопроса.** (1) Сроки сессии: не заходивший месяц человек увидит экран
|
||||
входа; если это против замысла — одна переменная `TM_PLATFORM_SESSION_MAX_AGE` и явная запись
|
||||
отклонения в §13. (2) **Новое, из PD-105:** фри-тир печатается НЕАУТЕНТИФИЦИРОВАННЫМ потоком по $5
|
||||
за каждую новую подтверждённую пару `(provider, subject)`, и агрегатного потолка нет нигде — нужен
|
||||
ли суточный лимит грантов и счётчик аномалий до открытия беты.
|
||||
|
||||
**Фикс-лист (порядок мой; строки регистра — носители).**
|
||||
|
||||
1. **PD-80 — доступность входа.** Единственная major. Ведро лимитера общее у `/auth/login` и
|
||||
`/auth/callback`, и 429 колбэка приходит уже ПОСЛЕ очистки login-куки: анонимный поток ~3 rps
|
||||
закрывает вход всем и добивает начатые входы. Воспроизведено мной на бинаре и независимо
|
||||
панелью. Фикс дешёвый: лимитер прежде очистки куки + раздельные ведра.
|
||||
2. **PD-79 — деньги.** Строковый `"null"` в `committed_usd` читается как ноль. Обязан быть закрыт
|
||||
ДО того, как появится вызывающий у `Settle` (то есть до воркера).
|
||||
3. **PD-83/PD-84/PD-85 — «закрыто, но не запинено»** по собственному правилу шапки регистра:
|
||||
половина PD-3, лимитер PD-29 и ветка обновления адреса.
|
||||
4. **PD-91 — деплой.** Установка по наброску даёт нестартующий юнит; и `ProtectHome=yes` против
|
||||
«книги в `~/books`».
|
||||
5. **PD-106 — админ-CLI, единственный писатель денег в дереве, не имеет ни одного теста** —
|
||||
включая правило «после коммита нельзя отчитаться провалом», которое сам же называет несущим.
|
||||
6. Остальное — по весу строк; PD-95 (доккоммент `events.go` предлагает то, что PD-59 отклонил)
|
||||
чинится вместе с промтом эмиттера, иначе эмиттер-сессия прочтёт его как задание.
|
||||
|
||||
**Опровергнуто приёмкой — включая свои промахи (дисциплина «заявление=команда» действует и на
|
||||
приёмку).**
|
||||
|
||||
- Панель: «удаление аккаунта падает на композитном FK даже при закрытых резервациях» —
|
||||
**опровергнуто моим прогоном:** `delete from users` проходит и без резервации, и с закрытой.
|
||||
- Панель: «`money.UnmarshalJSON` читает JSON `null` как ноль» — **опровергнуто в этой форме:**
|
||||
голый `null` даёт nil-указатель, защита работает; дыра — в строке `"null"` (PD-79, диагноз
|
||||
исправлен).
|
||||
- Панель: «WARN на каждый отбитый вход — неограниченная запись в лог» — **опровергнуто:**
|
||||
`AccessLog` и так пишет INFO-строку на КАЖДЫЙ запрос, так что нового канала WARN не создаёт.
|
||||
- **Своя посадка «грант фри-тира не запинен» — НЕГОДНАЯ:** грант живёт в ветке НОВОЙ личности,
|
||||
поэтому подмена его ключа на возвращающемся входе ничего не меняет. Корректная посадка
|
||||
(начислять на КАЖДОМ входе) ловится `TestReturningIdentityKeepsItsAccountAndIsGrantedOnce` —
|
||||
свойство запинено.
|
||||
- **Своё «падение `FuzzDecoder`» — артефакт моего стенда** (голод по CPU от параллельных батчей
|
||||
мутаций), не дефект: чистый прогон 3.35 млн исполнений зелёный.
|
||||
- **PD-20 — калибровка, не находка:** моя посадка «один сигнал вместо лестницы» выжила в одном
|
||||
прогоне, но дефект вероятностный (≈1 из 3 по замеру зоны), поэтому это свойство пина, а не новая
|
||||
дыра. Пин остаётся вероятностным — знать об этом важнее, чем завести строку.
|
||||
- **`TestMigrationsRollBackAndReapply` откатывает ПУСТУЮ базу,** поэтому для 00005 он не
|
||||
доказывает ничего (реальный откат невозможен по построению — PD-71). Норматив зоны «down-путь
|
||||
существует и гоняется тестом» выполнен буквой, но не смыслом; сказано здесь, чтобы это не
|
||||
читалось как покрытие.
|
||||
|
||||
## Сессия P2: что изменилось в решениях
|
||||
|
||||
Очередь приёмки P1 отработана в её порядке. Каждый пункт сначала воспроизведён посадкой на своём
|
||||
стенде (PostgreSQL 18.4, Go 1.26.5) — включая те, где вердикт приёмки в итоге уточнён.
|
||||
|
||||
1. **`ClearReadDeadline` удалён, а не оставлен «страховкой».** Приёмка проверила корректные запросы
|
||||
и заключила «код безвреден». На ПОЛУ-КОРМЛЕННОМ запросе он вреден: дренаж внутри записи заголовка
|
||||
— единственная граница соединения, снятие дедлайна до заголовка её убирает, и хендлер остаётся
|
||||
внутри `WriteHeader` через 4 с после ухода клиента (замерено). Случая, где функция помогает, нет:
|
||||
на корректном запросе `net/http` снимает дедлайн сам. PD-51 закрыт, PD-63 заведён.
|
||||
2. **Абсолютный срок сессии 90 → 30 суток.** ASVS 7.1.1 требует обосновать отклонение от NIST SP
|
||||
800-63B; у 90 суток обоснования не нашлось (баланс тратимый, повторный вход у залогиненного в
|
||||
Google — один клик, прогон истечение сессии переживает). Обосновывать нечего — цифра выровнена по
|
||||
норме. Политика целиком (оба срока, одновременные сессии, рассогласование с федеративной) —
|
||||
`STACK_DECISIONS §13`.
|
||||
3. **Против mix-up — `iss` авторизационного ответа (RFC 9207), решение принято до второго
|
||||
провайдера.** Альтернатива «`iss` из ID-токена» при чистом code flow не работает: токен приходит
|
||||
после отдачи кода. Фолбэк «раздельные redirect URI» норма разрешает только когда другого нет, а
|
||||
Google RFC 9207 поддерживает (сверено живьём). `auth_states.issuer` + сверка до обмена кода +
|
||||
отказ на СОРВАННОМ параметре.
|
||||
4. **`MemoryMax` — потолок машины, а не сервиса.** По собственному аргументу зоны дети-`tmctl` живут
|
||||
в cgroup юнита, значит «2G на контрол-плейн» — это 2G на платформу и все прогоны вместе, а
|
||||
OOM-killer внутри юнита выберет движок с эксклюзивным локом. `80%` + явный `OOMPolicy=continue`.
|
||||
5. **Пин на СВОЙСТВО там, где слои перекрываются.** У `safeReturnTo` четыре проверки, и снятие любой
|
||||
одной таблица входов переживала. Добавлены входы-различители плюс `FuzzSafeReturnTo` с
|
||||
НЕЗАВИСИМЫМ оракулом (`ResolveReference` против базового URL сайта) — 3,1 млн исполнений.
|
||||
Побочно установлено и записано в код: условия `u.Scheme/u.Host/u.Opaque` недостижимы как отказ,
|
||||
пин на них невозможен, оставлены бэкстопом.
|
||||
6. **Состояние входа сравнивается структурой целиком.** `State.StartID` не персистился — колонки не
|
||||
было, — и обе строки лога `login_start_id` в проде были пустыми, пока in-memory стор тестов
|
||||
показывал их заполненными. Тот же класс, что PD-46: свойство проверено не на том объекте.
|
||||
PD-62; теперь `reflect.DeepEqual` на всей структуре, следующее поле без колонки упадёт здесь же.
|
||||
|
||||
### Что нашло собственное ревью P2 (author≠reviewer)
|
||||
|
||||
Пять ревьюверов по разным линзам, зажатые промты, журнал зоны и регистр от четырёх из пяти скрыты;
|
||||
каждая находка потом отдана адверсариальному верификатору с установкой «опровергни, по умолчанию
|
||||
считай неподтверждённой». 34 кандидата, **11 подтверждено, 23 опровергнуты с разбором**. Из
|
||||
подтверждённых шесть потребовали правки кода:
|
||||
|
||||
7. **Обмен кода и JWKS шли без дедлайна** (PD-65), хотя discovery рядом ограничивает себя пятью
|
||||
секундами. Набор ключей у go-oidc ОБЩИЙ — значит одна зависшая загрузка паркует все параллельные
|
||||
входы, а не только свой. Замерено верификатором на боевой проводке (`httpClient` = nil).
|
||||
8. **Мой же фикс PD-46 закрывал половину и утверждал, что обе** (PD-66). Тест смотрел на сервер,
|
||||
который сам и построил; проводка демона осталась ненаблюдаемой — подмена аргумента в `main.go`
|
||||
оставляла `make check` зелёным, а бинарь снова пиннил соединения. Закрыто устранением КЛАССА:
|
||||
`NewServer` больше не принимает `Timeouts`, передавать нечего.
|
||||
9. **`FOR UPDATE` не был запинен**, а комментарий утверждал обратное (PD-67). Последовательный тест
|
||||
лока не видит, а инвариант «кэш = леджер» тоже: без лока обе величины уезжают в минус ВМЕСТЕ.
|
||||
10. **`/readyz` рапортовал «готов» на базе без схемы** (PD-68) — то есть в нормальной середине
|
||||
выката, потому что миграция по инструкции отдельный шаг.
|
||||
11. **Явный `pool_max_conns` из DSN молча отбрасывался** (PD-69): сравнение с дефолтом pgx не
|
||||
отличает «оператор промолчал» от «оператор выбрал это же число».
|
||||
12. **Скользящее окно бездействия для браузера не работало** (PD-70, major). Серверная строка
|
||||
скользила, кука — нет: `Max-Age` пишется один раз, на входе. Человек, заходящий каждый день,
|
||||
выкидывался на 14-е сутки при живой сессии, а абсолютный срок не наступал никогда. Это делало
|
||||
ложным §13 — документ соответствия ASVS, написанный в этой же сессии двумя часами раньше.
|
||||
|
||||
### Второе ревью: по коду, которым чинили первое (05.08)
|
||||
|
||||
Первое ревью смотрело дерево ДО своих же фиксов. Второй проход дан по ним — плюс отдельной линзой
|
||||
про воду. **42 кандидата, 22 подтверждено.** Что из этого меняет решения:
|
||||
|
||||
- **Мой фикс PD-65 был неполон** (PD-73). Дедлайн ограничивал ожидающего, а не саму загрузку ключей:
|
||||
`Provider.Verifier` берёт набор ключей, построенный на discovery, а go-oidc хранит его через
|
||||
`context.WithoutCancel` и ходит на `http.DefaultClient`. Зависшая загрузка держала вход и после
|
||||
выздоровления эндпоинта — до перезапуска процесса. Закрыто устранением класса: `httpClient` теперь
|
||||
никогда не nil, `New` ставит клиент с таймаутом, и его подхватывает `NewProvider`.
|
||||
- **Скольжение окна залипало** (PD-74, найдено двумя линзами независимо): `Touch` прижимает idle к
|
||||
абсолютному потолку, после чего условие «осталось меньше половины» истинно всегда — каждый запрос
|
||||
последней четверти жизни сессии становился записью в таблицу сессий и `Set-Cookie`.
|
||||
- **CLI сообщал о применённом начислении как о провале** (PD-75), а повтор без `--key` начислял
|
||||
второй раз — достаточно обрыва между записью и чтением баланса.
|
||||
- **Утверждение «провайдерский токен не персистится» не могло упасть** (PD-76): поле мока никто не
|
||||
заполнял. Это определяющее свойство пакета, названное первой строкой его доккоммента.
|
||||
- **Штатная остановка прогона поднимала тревогу о сломанном синке** (PD-77).
|
||||
- **Четыре строки таблицы пинов обещали то, чего батарея не даёт.** Две закрыты новыми тестами
|
||||
(гоночное потребление state; порядок блокировок), две — честной формулировкой: возврат общего
|
||||
лимита тела не наблюдаем до появления маршрута с бо́льшим потолком (PD-72), а вход «пароль содержит
|
||||
имя ключа» доказывает что-то только на машине, где наш дефолт не совпал с дефолтом pgxpool.
|
||||
- **Свод воды** (PD-78), каждый пункт проверен удалением: мемоизация mux в `Routes` молча
|
||||
игнорировала второй `guard`; шим `httpapi.Fail` существовал ради параметра, который никто не читал;
|
||||
`upsertIdentityOnce` дублировал `inTx`; `Deps.APIPrefix` — ручка без вызывателей; `Ready` делал два
|
||||
round trip на пробу; пять полей тестовых двойников писались и не читались.
|
||||
|
||||
### Самопроверка после ревью (владелец 05.08: «нет ли велосипедов и о чём умолчал»)
|
||||
|
||||
Перечитывание СВОЕГО кода дало ещё пять правок, три из которых — дефекты, внесённые в этой же
|
||||
сессии и доехавшие бы до лендинга:
|
||||
|
||||
- **Кука при скольжении могла пережить абсолютный срок.** Фикс PD-70 выдавал `Max-Age` = idle-TTL
|
||||
безусловно, поэтому сессия на 29-е сутки получала куку ещё на 14 — и каждый запрос после потолка
|
||||
становился 401 вместо чистого «вы вышли». Ровно то, из-за чего `MaxAge` изначально и брали по
|
||||
idle. Теперь `min(idle, остаток абсолютного)`, случай запинен, посадка падает.
|
||||
- **Фикс PD-68 ломал слой и тёк наружу.** Ради строки «схема не накачена» в теле ответа `httpapi`
|
||||
импортировал `pgstore` — при том что `Prober` интерфейсом заведён именно чтобы этого не было, — а
|
||||
сама строка сообщала состояние выката на НЕаутентифицированной ручке. Причина уехала в ERROR-лог,
|
||||
импорт снят.
|
||||
- **Два велосипеда.** Разбор DSN руками (33 строки) — при том что `pgxpool` достаёт `pool_*` из
|
||||
`RuntimeParams`, и второй `pgx.ParseConfig` отвечает на вопрос точно, вместе с кавычками и
|
||||
service-файлами. И разбор номера миграции из имени файла — при том что есть
|
||||
`goose.NumericComponent`. Оба сняты, `store.go` короче на 44 строки.
|
||||
- **`latestMigration` считался на КАЖДУЮ пробу готовности** — величина времени сборки, теперь
|
||||
`sync.OnceValues`. Имя таблицы версий отдано `goose.TableName()`, а не захардкожено.
|
||||
- **Ошибка разбора discovery больше не глотается.** Флаг `authorization_response_iss_parameter_supported`
|
||||
с неверным типом молча выключал бы защиту от срыва параметра — теперь WARN.
|
||||
|
||||
**Перепроверено, а не принято со слов:** PD-71 (`DownTo(4)` падает на `users_email_key`, SQLSTATE
|
||||
23505; `Up()` возвращает схему, все три аккаунта целы). Замеры ревью по PD-65 и PD-66 в регистре
|
||||
атрибутированы ревью — своими прогонами я их не воспроизводил.
|
||||
|
||||
Плюс PD-71 — принят риском: down-путь `00005` неисполним на данных, которые его же up-путь делает
|
||||
законными, поэтому откат ниже версии 5 недоступен. Править нельзя (append-only), поэтому записано
|
||||
оператору в `deploy/README.md`, а не спрятано.
|
||||
|
||||
**Не трогали намеренно:** PD-60 и PD-61 — обратное давление и сброс буфера эмиттера. Это свойства
|
||||
ШВА, назначать их платформе в одиночку нельзя: эмиттера нет, а выбор «блокировать движок или ронять
|
||||
события» меняет контракт. Идут строкой 103 единого бэклога вместе с транспортом (PD-59).
|
||||
|
||||
**Замеры, которые не воспроизвелись:** «41 взаимоблокировка на 300 раундов» (сессия P1) — остаётся
|
||||
со слов; действующий замер по PD-52 сделан заново. Замер приёмки «2 на 150» на этом стенде дал
|
||||
5–10 на 150 — та же величина, другой стенд, поэтому в тест записан свой.
|
||||
|
||||
## Приёмка P1: что изменилось в решениях
|
||||
|
||||
Пять независимых ревью (вход · деньги и SQL · стиль · логи · вне карты автора), четыре из пяти —
|
||||
исполнением. Находки — строками PD-24…PD-45 в регистре. Здесь только то, что поменяло РЕШЕНИЕ:
|
||||
|
||||
1. **Миграции append-only без исключений** (было: «до первого деплоя правим на месте»). goose
|
||||
применяет по НОМЕРУ — ни имени, ни хеша: база на версии 3 приняла бы новый набор как применённый
|
||||
и не получила ни одной таблицы, `DownTo` на ней ломается навсегда. Гейт — `migrations.sha256` +
|
||||
`TestReleasedMigrationsAreUnchanged`. Подробности в `STACK_DECISIONS` §8.
|
||||
2. **Ключ идемпотентности леджера — `(user_id, source, source_id)`**, пустой ключ запрещён DDL.
|
||||
Ключ без аккаунта проглатывал грант, выданный другому.
|
||||
3. **`reservations.book_id` — составной FK к `books(id, owner_id)` с RESTRICT.** Каскад делал холд
|
||||
невозвратным, а освободившийся `engine_run_id` давал холд без списания. Владение книгой теперь
|
||||
проверяет база, а не вызывающий (это денежная форма API1 BOLA).
|
||||
4. **`lockBalance` первым во всех денежных операциях** — иначе Hold↔Settle дают взаимоблокировку.
|
||||
⚠ Замер этой сессии «41 на 300 раундов» не воспроизвели ни приёмка, ни P2 — нагрузка не была
|
||||
описана; остаётся СО СЛОВ. Дефект при этом настоящий: воспроизведён независимо дважды, действующий
|
||||
замер — в PD-52.
|
||||
5. **Расчёт capped потолком холда.** Завышенное `committed_usd` уводило баланс в минус.
|
||||
6. **Грант фри-тира — только подтверждённой личности** (вопрос владельцу ниже).
|
||||
7. **Имя провайдера — конфигурация, `State.Provider` сверяется в колбэке.** Захардкоженное «google»
|
||||
при смене issuer кладёт чужие `sub` в старое пространство имён.
|
||||
8. **Исход прогона читается из `ProcessState`, а не из ошибки `Wait`** — иначе штатный SIGTERM
|
||||
помечает все идущие прогоны провалившимися.
|
||||
9. **Лимит тела — пер-маршрутный**, иначе загрузка книги не может поднять свой потолок.
|
||||
10. **Просроченный дренаж — не отказ процесса** (exit 0 + WARN): под `Restart=on-failure` штатная
|
||||
остановка читалась бы systemd как крах.
|
||||
|
||||
**Отклонено:** `user_id` в access-логе (предложение ревью логов). Норматив зоны запрещает id
|
||||
пользователей в логах; ответ на «кого задело» по дизайну живёт в `login_events`. Взят смежный
|
||||
вариант — исход аутентификации, он не PII.
|
||||
|
||||
**Проверено и дефектов не дало:** PKCE/nonce/одноразовость стейта под конкуренцией (6 колбэков с
|
||||
одним стейтом → 1 успех); провайдерские токены нигде не персистятся; параллельные первые входы
|
||||
(40 горутин → один аккаунт); инвариант `balance == SUM(ledger)`; отсутствие секретов, PII и денег
|
||||
в логах.
|
||||
|
||||
## Открытые вопросы после P1
|
||||
|
||||
**Владельцу — оба ЗАКРЫТЫ приёмкой (05.08):** грант только подтверждённой личности остаётся; два
|
||||
провайдера = два аккаунта на бете приемлемо, дефолт гранта $5. Правило гранта теперь и запинено —
|
||||
PD-48.
|
||||
|
||||
**Новый вопрос владельцу, один — из PD-58.** Абсолютный срок сессии снижен с 90 до **30 суток**:
|
||||
это цифра NIST SP 800-63B-4 для AAL1, а обоснования у 90 не нашлось (на аккаунте тратимый баланс,
|
||||
повторный вход у уже залогиненного в Google — один клик, а идущий ПРОГОН истечение сессии
|
||||
переживает — он серверный процесс). Видимое следствие: человек, не заходивший месяц, увидит экран
|
||||
входа. Если это против замысла — это одна переменная `TM_PLATFORM_SESSION_MAX_AGE` и строка в
|
||||
`STACK_DECISIONS §13`, но тогда отклонение от нормы придётся записать туда явно.
|
||||
|
||||
**Оркестратору — четыре.**
|
||||
1. **Спека не знает про `/auth/*`.** Ручки входа (`GET /auth/login`, `GET /auth/callback`,
|
||||
`POST /auth/logout`, `POST /auth/logout-all`) живут ВНЕ версионного префикса, как `/healthz`:
|
||||
это не контрактная поверхность, а механика сессии. Если фронт должен на них ссылаться — нужна
|
||||
строка в спеке или в компаньоне. Наше предложение: описать их в компаньоне, в `openapi.yaml`
|
||||
не тащить.
|
||||
2. **Требование `X-TM-Client` (пинг P0) всё ещё не в спеке.** Повторяем: реализовано, значение
|
||||
любое, несущей является ПРИСУТСТВИЕ заголовка на небезопасных запросах cookie-пути.
|
||||
3. **Форма ответа `GET /v0/usage` изменилась вместе с моделью денег** (баланс вместо окон), а
|
||||
спека этого ещё не отражает. Ручку НЕ строили намеренно — контракт первичен. Предлагаемая форма
|
||||
в разделе «Что предлагаем в спеку» ниже.
|
||||
4. **Транспорт потока событий: увести с stdout на выделенный дескриптор или сокет.** Просьба
|
||||
завести это строкой к 103 единого бэклога, пока эмиттера нет — потом правка станет миграцией.
|
||||
|
||||
> ⚠ **ОТВЕЧЕНО приёмкой (PD-59): диагноз принят, переезд канала отклонён.** Мотив прецедентов
|
||||
> (dpkg/gpg/systemd) к нам не переносится — там stdout занят, у нас платформа даёт движку
|
||||
> выделенный пайп. `ExtraFiles` задокументирован четырьмя строками, цена ошибки замерена.
|
||||
> Риск закрывается guard'ом в источнике. Триггеры пересмотра названы в разделе «Ратификация
|
||||
> приёмкой P1», подраздел про транспорт.
|
||||
|
||||
Формат менять НЕ предлагаем: NDJSON с версионным хендшейком в stdout — индустриальная норма для
|
||||
«долгая команда сообщает прогресс машине» (clig.dev: машиночитаемое — в stdout, сообщения — в
|
||||
stderr; так же `go test -json`, `cargo --message-format`, `terraform -json`, docker jsonmessage).
|
||||
Речь только о канале.
|
||||
|
||||
Причина: **stdout — общий ресурс процесса.** Один `fmt.Println` в движке или в его зависимости
|
||||
ломает протокол, и сегодня от этого защищает правило в `research/23 §2`, а не механизм. Индустрия
|
||||
этот же вывод сделала: `hashicorp/go-plugin` (Terraform, Vault, Nomad, Packer) печатает в stdout
|
||||
ОДНУ строку хендшейка `1|3|unix|/path/to/socket|grpc` и дальше уходит на unix-сокет; `runc` и
|
||||
`containerd` получают канал через `--console-socket`.
|
||||
|
||||
Предлагаемая форма для нас: платформа передаёт путь/дескриптор в argv, движок пишет поток туда,
|
||||
stdout остаётся человеку. Полный RPC (go-plugin/gRPC) сейчас НЕ нужен — управление
|
||||
однонаправленное: старт argv, стоп сигналом, досинхронизация `status --json`. Триггеры, при
|
||||
которых он окупится, называем заранее: пауза/продолжение без убийства процесса · поднятие потолка
|
||||
на живом прогоне · подпись банка в работающий процесс вместо рестарта · управление потоком.
|
||||
Два таких требования — и переезд оправдан, причём механический: хендшейк уже есть.
|
||||
|
||||
## Открытые вопросы к владельцу/оркестратору (P0, историческое)
|
||||
|
||||
**Решения владельца 05.08 (закрыли всё, что висело по деньгам):** оплаты нет и в бете не будет —
|
||||
пробные аккаунты на фри-тире, ключи предоплачены владельцем · модель лимита = БАЛАНС кредитов, а не
|
||||
|
|
@ -36,6 +386,306 @@
|
|||
заголовок `X-TM-Client` на небезопасных запросах cookie-пути. Это требование к ФРОНТУ, и его
|
||||
место — в описании `sessionCookie` в спеке. Реализовано и проверено тестами.
|
||||
|
||||
## Решения сессии P1 (аргументация)
|
||||
|
||||
### Политика коллизии почты
|
||||
|
||||
**Почта не является ключом ни в какой форме. Ключ личности — `(provider, subject)`.**
|
||||
`users.email` стала NULLABLE и потеряла уникальный индекс; неизвестная пара `(provider, subject)`
|
||||
ВСЕГДА создаёт новый аккаунт, какой бы адрес с ней ни пришёл. Присоединение второго провайдера к
|
||||
существующему аккаунту — отдельное аутентифицированное действие (его ещё нет), никогда не побочный
|
||||
эффект входа. Адрес аккаунта обновляется только из ПОДТВЕРЖДЁННОГО; неподтверждённый остаётся на
|
||||
личности и наверх не поднимается.
|
||||
|
||||
Почему не «связывать по подтверждённой почте». Связывание по адресу — это классический путь захвата
|
||||
аккаунта, и `email_verified` его не закрывает: адрес может смениться владельцем (Google предупреждает
|
||||
об этом прямым текстом), корпоративный домен может выдать освободившийся ящик другому сотруднику, а
|
||||
провайдер может пометить verified то, что он верифицировал по своим правилам, а не по нашим. Цена
|
||||
нашего решения — два аккаунта у одного человека при входе разными провайдерами. Это ДУБЛИКАТ:
|
||||
человек его видит, оператор может слить. Цена альтернативы — чужой аккаунт достаётся тому, кто
|
||||
получил адрес. Дубликат чинится, захват — нет.
|
||||
|
||||
Пин: `TestIdentityNeverJoinsAccountsByEmail` — два субъекта с одним адресом и третий с другим
|
||||
провайдером обязаны дать ТРИ аккаунта.
|
||||
|
||||
⚠ Побочное следствие для денег — вопрос владельцу выше (грант на аккаунт, аккаунтов может быть два).
|
||||
|
||||
### Форма админ-поверхности — CLI, не защищённая ручка
|
||||
|
||||
`tmplatformctl grant|balance|logins|revoke`. HTTP-ручке ради четырёх операций понадобилась бы вторая
|
||||
модель авторизации: роли, их хранение, эскалация, отзыв админской сессии, отдельный CSRF-режим —
|
||||
и каждая из этих вещей может быть сделана неправильно. Граница доверия для этих операций уже есть и
|
||||
обеспечена машиной: чтобы выполнить их, нужен шелл на VM и доступ к DSN. Браузерная панель, если
|
||||
понадобится, обернёт ровно те же вызовы стора.
|
||||
|
||||
Идемпотентность у гранта — ОПТ-ИН через `--key`: ключ по умолчанию «аккаунт+дата» схлопывал два
|
||||
законных гранта одного дня, и второй рапортовал успех, ничего не начислив. Без ключа каждый вызов
|
||||
самостоятелен, а CLI печатает «применилось» или «ключ уже потрачен» по факту.
|
||||
|
||||
### Форма журнала входов
|
||||
|
||||
Таблица `login_events`: время, провайдер, исход (`success|denied`), причина отказа, ПРЕФИКС адреса
|
||||
(/24 для IPv4, /48 для IPv6) и КЛАСС клиента (`browser|desktop|other`). Ни полного адреса, ни
|
||||
user-agent: журнал отвечает на вопрос «откуда примерно и чем», который человек и оператор реально
|
||||
задают, и не превращается в собственную базу слежки. Отказавшийся вход пишется без `user_id` —
|
||||
попытка была, аккаунта у неё нет. Ручка «отозвать все сессии» — `POST /auth/logout-all`, она же
|
||||
`tmplatformctl revoke`. ⚠ Ретенции у журнала пока нет — строка PD-23.
|
||||
|
||||
## Что предлагаем в спеку (S3)
|
||||
|
||||
`GET /v0/usage` в кредитной модели — БЕЗ сумм и без `resets_at`:
|
||||
|
||||
```yaml
|
||||
Usage:
|
||||
required: [state, remaining_percent]
|
||||
properties:
|
||||
state: { enum: [ok, low, exhausted] } # low — порог показа предупреждения
|
||||
remaining_percent: { type: integer, minimum: 0, maximum: 100 } # от последнего гранта
|
||||
paused_reason: { enum: [credit_exhausted], nullable: true } # почему стоит прогон
|
||||
```
|
||||
|
||||
Процент считается от суммы грантов аккаунта, а не от «лимита периода»: периодов больше нет.
|
||||
`Run.paused_reason` — то же значение на прогоне (колонка в схеме заводится вместе с ручкой).
|
||||
|
||||
## Ратификация приёмкой P1 (оркестратор №14, 05.08)
|
||||
|
||||
**Вердикт: P1 ПРИНЯТ и заленден.** ⚠ **Испр. оркестратором №15:** слово «заленден» здесь неверно —
|
||||
код P1 в git не уезжал, он ушёл туда вместе с P2 при приёмке 07.08 (раздел «Ратификация приёмкой
|
||||
P2» выше). Живой уязвимости приёмка не нашла. Найденное делится на три
|
||||
кучки: незапиненные свойства (строка регистра говорит «закрыто», посадка её переживает), неверные
|
||||
формулировки в доках и **два несоответствия внешней норме** — PD-57 (mix-up: реализована не та
|
||||
контрмера, которую требует RFC 9700 §2.1) и PD-58 (ASVS 5.0 L2 требует ДОКУМЕНТИРОВАТЬ сроки
|
||||
сессий; они существуют только литералами в коде). По правилу, которое сессия сама применила к PD-1
|
||||
(«свойство без пинящего теста закрытым не считается»), строки **PD-30, PD-32, PD-37, PD-26 к своим
|
||||
фиксам не привязаны** — заведены заново как PD-46…PD-59.
|
||||
|
||||
**Метод.** Батарея пере-прогнана мной: офлайн зелёная (0 issues линтера), с живым PostgreSQL 18.4 —
|
||||
скипов ноль, `make vuln` чист. Собственная посадка 43 мутаций (не по следам отчёта: по СВОЕЙ карте
|
||||
свойств несущего пути) — **31 поймана поимённо, 9 пережили, 3 не собрались**; дерево после каждой
|
||||
восстановлено, побайтовая сверка с бэкапом в конце — совпадение. Плюс шесть собственных
|
||||
тестов-проб против живой БД и четыре живые пробы на собранном бинаре. Механику см. регистр.
|
||||
|
||||
**Сверка с индустриальной нормой — отдельным проходом, по первоисточникам** (её отсутствие владелец
|
||||
поймал на первой редакции этого раздела; тогда решения были проверены только ВНУТРИ репозитория —
|
||||
механика goose, схема, поведение `net/http` — а против внешних норм не сверялись):
|
||||
|
||||
| Норма | Что требует | Как у нас |
|
||||
|---|---|---|
|
||||
| OIDC Core 1.0 §5.7 / §2 | Стабильный идентификатор — только пара `(iss, sub)`; `email`, `phone_number`, `preferred_username` **MUST NOT** использоваться как идентификатор (издатель вправе переиспользовать адрес между людьми) | ✅ решение «почта не ключ» — не наше изобретение, а буква нормы. ⚠ ключуем по НАШЕМУ имени провайдера, не по `iss`; причина названа в коде (смена URL издателя не осиротит аккаунты) — сознательное отклонение |
|
||||
| RFC 9700 §2.1.1 (BCP, янв. 2025) | PKCE; `nonce` как альтернатива для OIDC-клиентов | ✅ и то, и другое, реально проверяется настоящим издателем в тесте |
|
||||
| RFC 9700 §2.1 | Одноразовый `state` против CSRF; точное сравнение redirect URI | ✅ одноразовость в БД одним `DELETE … RETURNING` + привязка к куке браузера |
|
||||
| RFC 9700 §2.1 + RFC 9207 | Против mix-up: SHOULD — `iss` из авторизационного ответа; MAY — раздельные redirect URI | ❌ **не выполнено** — реализована собственная сверка, которая внутри одного хендлера сравнивает конфигурацию с собой: **PD-57** |
|
||||
| ASVS 5.0 V7 · 7.2.3, 7.2.4, 7.4.1, 7.4.2 (L1) | ≥128 бит энтропии; новый токен на аутентификации со сносом прежнего; отзыв прекращает использование; снос всех сессий при удалении аккаунта | ✅ все четыре, 256 бит при требуемых 128, ротация запинена тестом |
|
||||
| ASVS 5.0 V7 · 7.1.1, 7.1.2, 7.1.3/7.6.1 (L2) | Сроки бездействия и абсолютный ДОКУМЕНТИРОВАНЫ с обоснованием отклонений от NIST SP 800-63B; политика одновременных сессий; согласование с федеративной сессией | ❌ **не выполнено** — 14 суток/90 суток живут литералами в `config.go`, обоснования нет нигде: **PD-58**. Это несоответствие линии, которую зона объявила себе сама (`ENGINEERING_STANDARDS §2`) |
|
||||
| Миграции | Flyway/Liquibase хранят контрольные суммы и падают на расхождении; goose хранит только номер | ✅ `migrations.sha256` — не самодеятельность, а восполнение того, что другие инструменты дают из коробки |
|
||||
| Деньги | Резерв→захват (hold/capture) — стандарт платёжной механики | ✅ форма стандартная. Леджер знаковый однозаписный с кэшем баланса вместо двойной записи — упрощение, оправданное отсутствием продаж; инвариант `balance == SUM(ledger)` его страхует |
|
||||
|
||||
**Подтверждено ИСПОЛНЕНИЕМ (не чтением отчёта):**
|
||||
|
||||
- **PD-2 действительно закрыт на том бинаре, который едет.** 25 полу-кормленных POST → сервер
|
||||
отпустил все 25 через 29.1 с (в P0 держал, пока не уходил клиент). ⚠ Но защита от повторного
|
||||
открытия стоит только на проводке — PD-46.
|
||||
- **PD-25 в своей полной форме:** один ключ идемпотентности на двух аккаунтах — оба применяются;
|
||||
повторный `Hold` после `DeleteBook` (когда строку резервации унесло) даёт `ErrDuplicateHold`,
|
||||
баланс не двигается, резервация не остаётся. Собственный тест.
|
||||
- **PD-26 воспроизведён независимо** — 2 взаимоблокировки на 150 раундов с инвертированным
|
||||
порядком, 0 с фиксом. Фикс несущий; ⚠ замер сессии «41 на 300» не воспроизведён (см. PD-52).
|
||||
- **PD-27 держит и на переполнении:** `Settle` с `1<<62` списывает ровно холд.
|
||||
- **PD-24:** манифест закрывает все три случая — правку, новый нелистанный файл и удаление.
|
||||
- **PD-34:** штатная остановка даёт exit 0; второй SIGTERM убивает (обработчик снят).
|
||||
- **CSRF-слой живьём:** кука без `X-TM-Client` → 403; с заголовком → 401; `Sec-Fetch-Site:
|
||||
cross-site` → 403; `Origin: evil` → 403; кука + мусорный Bearer → 401 (привилегии не даёт, PD-50).
|
||||
- **Админ-CLI живьём:** грант, повтор ключа (no-op), корректировка, `balance == SUM(ledger)`,
|
||||
отказы на отрицательном гранте, корректировке без причины и неизвестном аккаунте.
|
||||
- **200 конкурентных денежных операций** — 0 ошибок, кэш не разъехался с леджером.
|
||||
|
||||
**Опровергнуто исполнением:** обоснование `ClearReadDeadline` и строка «Поток переживает
|
||||
read-дедлайн» в таблице пинов — PD-51. `net/http` снимает read-дедлайн сам, до хендлера;
|
||||
тест не может упасть от выхолащивания функции. Код безвреден, ложны обоснование и пин.
|
||||
|
||||
**Ратифицировано (4 из 4):**
|
||||
|
||||
1. **Миграции append-only без исключений — ПРИНЯТО.** Аргумент проверен: `goose_db_version` держит
|
||||
только номер. `STACK_DECISIONS §8` — норма зоны.
|
||||
2. **Почта не ключ ни в какой форме — ПРИНЯТО, и это прямо буква нормы,** а не наш вкус: OIDC Core
|
||||
§5.7 — стабильный идентификатор только `(iss, sub)`, `email` использовать как идентификатор
|
||||
**MUST NOT**, потому что издатель вправе переиспользовать адрес между людьми. Цена (дубль
|
||||
аккаунта у человека с двумя провайдерами) названа честно и меньше альтернативы (захват аккаунта
|
||||
по унаследованному адресу). ⚠ Отклонение: ключуем по НАШЕМУ имени провайдера, не по `iss`;
|
||||
причина в коде названа и принимается.
|
||||
3. **Админ-поверхность — CLI — ПРИНЯТО.** ⚠ Владельцу сказано прямо: «админка» сегодня = команда в
|
||||
шелле на машине, не веб-страница. Для беты этого достаточно; браузерная панель обернёт те же
|
||||
вызовы стора.
|
||||
4. **`sqlc` (PD-44) — направление ИЗМЕНЕНО, а не долг.** Проверено исполнением: sqlc v1.31.1 читает
|
||||
все goose-миграции зоны (на момент пробы семь) и генерирует под `pgx/v5` код, почти совпадающий с рукописным
|
||||
(`:execrows` → `RowsAffected`). Две трения: он отвергает запрос, который Postgres принимает
|
||||
(неквалифицированный `user_id` в коррелированных подзапросах), и типизует колонки как
|
||||
`pgtype`/`int64` — то есть `money.MicroUSD` на границе теряется без блока `overrides`.
|
||||
**Решение:** денежный пакет НЕ переписывать — он только что отревьюен и имеет батарею против
|
||||
живой БД, а обмен отревьюенного кода на сгенерированный без единого нового теста ничего не
|
||||
покупает. `sqlc` берётся на поверхность контрактных ручек/read-model (П-1), где запросов много
|
||||
и они меняются, — там ловится именно тот класс, ради которого он нужен (запрос ссылается на
|
||||
колонку, которую унесла миграция). Правка внесена в `PLATFORM_DIRECTION.md` §3.
|
||||
|
||||
**Вопросы владельца — ответы даны, оба «да» с одной поправкой.** Грант только подтверждённой
|
||||
личности остаётся (для Google это все настоящие аккаунты, а дыру саморегистрации закрывает);
|
||||
два провайдера = два аккаунта на бете приемлемо, дефолт гранта $5. ⚠ Само правило гранта тестом
|
||||
не защищено — PD-48, чинить независимо от ответа.
|
||||
|
||||
**Регрессионный тест к PD-52** (написан приёмкой, воспроизводит цикл; вставить в
|
||||
`internal/pgstore/`, хелперы `testDB`/`seedUser`/`exec` уже есть):
|
||||
|
||||
```go
|
||||
// PD-26 в форме, которая действительно даёт цикл: Hold, переиспользующий attempt id, ждёт строку
|
||||
// резервации по первичному ключу, уже держа лок баланса, а конкурентный Settle той же резервации
|
||||
// держит строку и хочет баланс. С lockBalance первым в ОБОИХ цикл не складывается.
|
||||
// Замерено приёмкой: с инверсией 2 взаимоблокировки на 150 раундов, с фиксом — 0.
|
||||
func TestHoldAndSettleOnTheSameAttemptDoNotDeadlock(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
seedUser(t, s, ctx, "u1")
|
||||
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
||||
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
||||
now := time.Now().UTC()
|
||||
if _, err := s.Grant(ctx, "u1", 100000*money.PerUSD, "admin", "g", "", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var deadlocks int
|
||||
note := func(err error) {
|
||||
if err != nil && strings.Contains(err.Error(), "deadlock") {
|
||||
mu.Lock()
|
||||
deadlocks++
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
for i := range 150 {
|
||||
id := fmt.Sprintf("run-%d", i)
|
||||
if err := s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); note(s.Settle(ctx, id, money.PerUSD/2, now)) }()
|
||||
go func() { defer wg.Done(); note(s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now)) }()
|
||||
wg.Wait()
|
||||
}
|
||||
a, err := s.ReadAccount(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Balance != a.LedgerSum {
|
||||
t.Fatalf("кэш разъехался с леджером: %s против %s", a.Balance.USD(), a.LedgerSum.USD())
|
||||
}
|
||||
if deadlocks > 0 {
|
||||
t.Fatalf("%d взаимоблокировок на 150 раундов", deadlocks)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Что зона обязана сделать до следующего лендинга** (порядок — мой). ✅ **Все восемь отработаны в
|
||||
P2 в этом порядке; расхождения с формулировкой пунктов 5 и 7 — в разделе «Сессия P2» выше.**
|
||||
|
||||
1. PD-46 — тест на ЗНАЧЕНИЯ `DefaultTimeouts()`. Это буквально та дыра, в которой PD-2 прожил P0.
|
||||
2. PD-58 — обосновать 14 суток/90 суток письменно (ASVS 7.1.1 требует именно документа, а не
|
||||
значения), заодно 7.1.2 и 7.1.3. Самый дешёвый пункт списка и единственное несоответствие
|
||||
базовой линии, которую зона объявила себе сама.
|
||||
3. PD-48, PD-49, PD-47 — три пина к трём «закрытым» строкам регистра.
|
||||
4. PD-52 — регрессионный тест порядка блокировок (готовый выше).
|
||||
5. PD-51 — привести §12 и таблицу пинов к тому, что делает `net/http`; решить судьбу
|
||||
`ClearReadDeadline` (оставить как страховку — законно, но с честным комментарием).
|
||||
6. PD-54 — снять устаревшую подписочную форму `/v0/usage` из журнала, пока S3 её не прочитал.
|
||||
7. PD-55 — `MemoryMax`/`TasksMax` в юните: либо потолок для платформы отдельно от детей
|
||||
(`Slice=`/отдельный юнит), либо честный комментарий, что потолок общий на прогоны.
|
||||
8. PD-57 — решение по mix-up принять ЯВНО и до второго провайдера: чтение `iss` авторизационного
|
||||
ответа (норма) либо раздельные redirect URI (допустимая альтернатива).
|
||||
|
||||
**Что НЕ блокирует лендинг, но блокирует первый реальный деплой:** PD-58 (без документа сроков зона
|
||||
не соответствует линии, которую сама объявила), PD-55 (потолок памяти общий на платформу и все
|
||||
прогоны — OOM выберет движок с эксклюзивным локом). PD-57 — до второго провайдера.
|
||||
|
||||
**Спека (мои решения как владельца контракта):** `/auth/*` — в компаньон, в `openapi.yaml` не
|
||||
тащим (согласен: механика сессии, не контрактная поверхность). `X-TM-Client` и кредитная форма
|
||||
`GET /v0/usage` — уже в списке правок промта S3, отдельного действия зоне не нужно.
|
||||
|
||||
**Транспорт потока событий (вопрос зоны №4): диагноз ПРИНЯТ, переезд канала ОТКЛОНЁН. PD-59,
|
||||
PD-60, PD-61.**
|
||||
|
||||
Этот пункт переписывался четырежды. Три первые редакции спорили о том, чьи прецеденты лучше, — это
|
||||
не инженерный аргумент. Четвёртая получена методом владельца: задача сформулирована АБСТРАКТНО (два
|
||||
Go-сервиса, родитель читает NDJSON у ребёнка, никакого контекста репозитория и никакого намёка на
|
||||
мою позицию) и отдана двум независимым чистым агентам — одному с доступом в сеть, другому только со
|
||||
своими знаниями. **По самому каналу они разошлись** (сетевой — переезжать, офлайновый — остаться), и
|
||||
именно поэтому упражнение оказалось полезным: ценность не в их вердикте, а в том, на чём они сошлись
|
||||
НЕЗАВИСИМО и чего не было ни в записке зоны, ни в трёх моих редакциях.
|
||||
|
||||
**Сошлись на трёх вещах, и первая решает вопрос.**
|
||||
|
||||
1. **SIGPIPE зависит от НОМЕРА дескриптора.** `os/signal`: обрыв пайпа на fd 1 или 2 убивает
|
||||
программу сигналом; на любом другом дескрипторе запись просто возвращает `EPIPE`. **Замерено
|
||||
мной, не со слов:** поток на fd 1 — ребёнок убит `broken pipe`; на fd 3 — `write` вернул EPIPE и
|
||||
процесс спокойно доработал до конца. Для нас это не сноска, а деньги: сегодня падение платформы
|
||||
убивает движок на следующей же записи события; после переезда движок стал бы сиротой и часами жёг
|
||||
бы оплаченные вызовы, пока холд висит в леджере и закрыть его некому. То есть stdout даёт нам
|
||||
бесплатную остановку сироты, а предложенный переезд её ЛОМАЕТ. Свойство несущее и до сих пор
|
||||
нигде не записано.
|
||||
2. **Настоящая защита — не выбор канала, а перехват на уровне дескриптора:** `dup(1)` в приватный fd,
|
||||
затем `dup3(2,1,0)`, в `main` движка. Он работает при ЛЮБОМ канале и герметичен там, где
|
||||
предложенный мной ранее `os.Stdout = os.Stderr` дыряв: переживает `var out = os.Stdout`,
|
||||
захваченный зависимостью, cgo и унаследованный fd 1 у внуков. Направлять fd 1 в `/dev/null` не
|
||||
надо — пусть посторонние записи видны в человеческом логе прогона.
|
||||
3. **Дискриминатор, при котором переезд был бы прав:** ребёнок исполняет чужой код, наследующий
|
||||
stdio (хуки, плагины, шелл-аут). Это ровно мотив `dpkg --status-fd`. **Проверено: у нас нет** —
|
||||
в `backend/` нет ни одного `exec.Command` вне тестов и нет cgo.
|
||||
|
||||
Отсюда и вывод: обсуждали не тот вопрос. Канал остаётся stdout — теперь не «потому что переезд не
|
||||
окупается», а потому что переезд активно ухудшает поведение при падении платформы.
|
||||
|
||||
**Диагноз зоны верен и не оспаривается.** stdout — общий ресурс процесса; один посторонний
|
||||
`Println` в движке или в его зависимости ломает протокол, и защищает от этого правило в
|
||||
`research/23 §2`, а не механизм.
|
||||
|
||||
**Прецеденты зоны не держат, но сильные существуют — и их мотив к нам не переносится.**
|
||||
`hashicorp/go-plugin` уходит на сокет ради ДВУНАПРАВЛЕННОГО RPC (хендшейк он как раз держит в
|
||||
stdout); `runc --console-socket` — про передачу ДЕСКРИПТОРА pty через SCM_RIGHTS. Канонические
|
||||
прецеденты паттерна — другие: **dpkg `--status-fd n`** («Send machine-readable package status and
|
||||
progress information to file descriptor _n_», man dpkg(1)), **gpg `--status-fd`**, **systemd
|
||||
`NOTIFY_SOCKET`**. Но во всех трёх stdout ЗАНЯТ полезной нагрузкой — выводом операции, шифротекстом,
|
||||
выводом сервиса, — и статус выселяют потому, что ему негде жить. У нас платформа даёт `tmctl`
|
||||
выделенный пайп через `cmd.StdoutPipe()`; на этот stdout не претендует никто. Мотива нет.
|
||||
|
||||
**Что говорит дока Go о качестве такого решения: ничего.** `os/exec.ExtraFiles` — четыре строки
|
||||
доккоммента, из качества ровно одно: «not supported on Windows». Жизненный цикл пайпа на
|
||||
вызывающем. **Цена ошибки замерена:** не закрыл родительскую копию пишущего конца после `Start()` —
|
||||
EOF не приходит НИКОГДА, читатель висит на давно завершённом прогоне (`StdoutPipe` закрывает сам).
|
||||
Идиоматичный Go для «родитель читает поток ребёнка» — `StdoutPipe`, а `ExtraFiles` — нишевый
|
||||
механизм socket-activation и контейнерной обвязки.
|
||||
|
||||
**Изъян нашёлся и в фолбэке, который приёмка предлагала ранее.** Безусловный
|
||||
`os.Stdout = os.Stderr` в `main` движка сломал бы `tmctl status --json`: ре-синк читает именно
|
||||
stdout (`supervisor.go:145`, `cmd.Output()`). Guard обязан жить в области ТОЛЬКО потоковой команды.
|
||||
|
||||
**Решение: канал не меняем.** Переезд покупает защиту от класса, который в нашем процессе почти
|
||||
пуст (cgo в движке нет, детей на потоковом пути он не спавнит), а стоит: изменение CLI движка —
|
||||
то есть запрос через шов, — ручной жизненный цикл пайпа с измеренным режимом вечного зависания,
|
||||
Windows вне игры и неидиоматичный для Go паттерн. По норме владельца «механизм строится только
|
||||
там, где несёт качество/деньги, — не ради галочки» это механизм ради галочки.
|
||||
|
||||
**Строка 103 единого бэклога заводится так:**
|
||||
|
||||
- канал остаётся **stdout** — из-за SIGPIPE-семантики, которая нам служит;
|
||||
- защита — **перехват на уровне дескриптора** в `main` движка, в области потоковой команды
|
||||
(безусловный вариант сломал бы `tmctl status --json`: ре-синк читает stdout, `supervisor.go:145`);
|
||||
- вместе с эмиттером задаются **сброс буфера на всех путях выхода** и **политика обратного
|
||||
давления** — PD-61 и PD-60; оба свойства дешевле назначить до постройки, чем мигрировать после;
|
||||
- переезд на `--events-fd` пересматривается по названным заранее триггерам: появление cgo в движке,
|
||||
спавн им собственных детей на потоковом пути, реальный инцидент порчи потока. Если когда-нибудь
|
||||
понадобится «платформа перезапустилась, прогон продолжается» — ответ не сокет, а журнал файлом с
|
||||
чекпойнтом смещения (PD-61).
|
||||
|
||||
Побочно упражнение проверило нас: ловушку `bufio.Scanner` (переполнение строки читается как чистый
|
||||
EOF, поток молча обрывается) оба агента назвали самым вероятным латентным багом такой системы —
|
||||
у нас она закрыта, `Buffer` поднят до 1 МиБ и `sc.Err()` проверяется (`decoder.go:45,115`).
|
||||
|
||||
## Ратификация приёмкой (оркестратор №14, 04.08)
|
||||
|
||||
**Вердикт: P0 ПРИНЯТ, заленден `eeeef89`.** Метод: батарея пере-прогнана мной (офлайн зелёная; с живым
|
||||
|
|
@ -179,30 +829,84 @@ research/23 §2 + запрет INFO-денег), а словарь строки
|
|||
|
||||
### П-5 — форма API лимитов/использования
|
||||
|
||||
`GET /v0/usage` (страница лимитов в настройках):
|
||||
> ⚠ **Подписочная форма ответа УДАЛЕНА отсюда (PD-54, закрыт в P2).** Она несла окна, `resets_at`
|
||||
> и `usage_windows` и отменена решением владельца 05.08 «не подписки, а баланс»; таблицу
|
||||
> `usage_windows` снесла миграция `00006`. Баннера было мало: S3 идёт в журнал ЗА ФОРМОЙ ручки и
|
||||
> скопировал бы тело, а не баннер. **Действующая форма одна — раздел «Что предлагаем в спеку (S3)»
|
||||
> выше.** Ниже осталось то, что от модели денег не зависит.
|
||||
|
||||
```json
|
||||
{"revision": 42, "state": "ok|approaching|exhausted", "used_percent": 37,
|
||||
"resets_at": "2026-08-11T00:00:00Z",
|
||||
"windows": [{"period": "day", "used_percent": 12, "resets_at": "…"},
|
||||
{"period": "week", "used_percent": 37, "resets_at": "…"}]}
|
||||
```
|
||||
|
||||
- **Сумм нет ни в каком виде.** Процент и время сброса — статус использования, а не деньги
|
||||
(D39.84 в силе, механика «как Claude Code» — D39.100/ПТ-35).
|
||||
- **Стоп по потолку:** `BookStatus: paused` + машинная причина. Предлагаем
|
||||
`Run.paused_reason: "limits_exhausted" | null`: фразу («перевод остановлен: лимиты исчерпаны»)
|
||||
рисует клиент словами владельца (В-3), API несёт состояние. Без поля причины второй повод для
|
||||
паузы станет ломающим изменением.
|
||||
- **Сумм нет ни в каком виде.** Процент — статус использования, а не деньги (D39.84 в силе,
|
||||
механика «как Claude Code» — D39.100/ПТ-35).
|
||||
- **Стоп по потолку:** `BookStatus: paused` + машинная причина, `Run.paused_reason`: фразу
|
||||
(«перевод остановлен: кредит исчерпан») рисует клиент словами владельца (В-3), API несёт
|
||||
состояние. Без поля причины второй повод для паузы станет ломающим изменением.
|
||||
- **Источник цифр.** Поток событий денег не несёт и не должен (кадр `ceiling` — только факт),
|
||||
поэтому платформа метрит из `tmctl status --json` (`committed_usd`) на границах попыток и на
|
||||
ре-синке; хранит целыми микро-долларами в `usage_windows`.
|
||||
ре-синке; хранит целыми микро-долларами в леджере (`credit_ledger`, миграция `00007`).
|
||||
- **Поднятие потолка — политика платформы, не кнопка на экране.** Платформа сама владеет
|
||||
`book.yaml`, поднимает `ceilings.book_usd` и перезапускает прогон. **Проверено кодом, что это
|
||||
безопасно:** `Ceilings` объявлен в `backend/internal/config/book.go:106`, а в канон `BriefHash`
|
||||
(`:280-297`) НЕ входит — значит поднятие потолка не двигает `brief_hash` → снапшот и не вызывает
|
||||
ни дрифт, ни ре-билл. Риск «подняли лимит — переплатили книгу заново» снят фактом, не надеждой.
|
||||
|
||||
## Что построено (P1)
|
||||
|
||||
| Кусок | Где | Проверено ИСПОЛНЕНИЕМ |
|
||||
|---|---|---|
|
||||
| Конструкция сервера вынесена из `main` | `internal/httpapi/serve.go` | Тесты гоняют РЕАЛЬНЫЙ `http.Server` на loopback-порту; без этого PD-2 и PD-9 не видит ни один тест на mux под `httptest` |
|
||||
| `ReadTimeout` + `LimitBody` (PD-2) | `serve.go`, `middleware.go` | Живая проба на бинаре: полу-кормленный POST отпускается на `ReadTimeout` (30.0 с) |
|
||||
| Поток переживает `ReadTimeout` | `serve.go` (без вспомогательной функции) | `net/http` снимает дедлайн сам; помощник `ClearReadDeadline` УДАЛЁН — на полу-кормленном запросе он воспроизводил PD-2 (PD-63) |
|
||||
| Дренаж по SIGTERM (PD-9) | `serve.go` | Тест: ctx-aware хендлер в полёте доигрывает и отдаёт 200 |
|
||||
| Вход через OIDC (П-6) | `internal/login/` | Полный флоу против НАСТОЯЩЕГО OIDC-издателя, поднятого в тесте: discovery, JWKS, RS256-подпись, реальная проверка PKCE на токен-эндпоинте. 6 негативных сценариев (чужой nonce, чужая audience, протухший токен, подмена state, отсутствие куки, реплей) |
|
||||
| Модель аккаунта | `migrations/00001`, `pgstore/identity.go` | Живой PG: три личности с одним адресом дают три аккаунта; неподтверждённый адрес не поднимается на аккаунт; state одноразовый и истекает |
|
||||
| Кредитный леджер (П-7) | `migrations/00007_credits.sql`, `pgstore/credits.go` | Живой PG: инвариант `balance == SUM(ledger)` после каждого шага grant→hold→settle→release; повторный ключ — no-op; холд сверх баланса, чужая книга и повтор attempt-id отказаны |
|
||||
| Деньги как тип | `internal/money/` | `big.Rat`, округление к `+∞`, синтаксис ограничен регуляркой и длиной (`big.Rat` иначе принимает `0x10` и `1/3`) |
|
||||
| Админ-CLI (П-8) | `cmd/tmplatformctl/` | Живая проба против живой БД: гранты, баланс с открытыми холдами, журнал входов, отзыв сессий |
|
||||
| Фаззинг декодера | `internal/ingest/fuzz_test.go` | Оракулы — инварианты PD-10; `make fuzz` для углублённого прогона |
|
||||
| Остановка прогона (PD-12/13/20) | `internal/ingest/` | Тест с настоящим процессом: сбой синка завершает прогон; сигнал повторяется до подтверждения |
|
||||
| Деплой-юнит (PD-13) | `deploy/tmplatformd.service` | `systemd-analyze verify` — exit 0. ⚠ Под systemd не запускался (нет sudo) |
|
||||
|
||||
### Какой тест что пинит (мандат приёмки §3.3)
|
||||
|
||||
| Свойство несущего пути | Пинящий тест | Посадка, которую он ловит |
|
||||
|---|---|---|
|
||||
| В БД только SHA-256 токена | `pgstore.TestStoredCredentialIsAHashNotTheToken` | `Digest` возвращает плейнтекст (посадка приёмки P0 — теперь падает) |
|
||||
| Соединение нельзя запиннить | `httpapi.TestHalfFedRequestIsDroppedByTheServer` + `TestTheServerTheDaemonRunsHasEveryDeadlineSet` | Снять любой дедлайн из `serverWithTimeouts`; обнулить `DefaultTimeouts().Read` или `.Idle`; добавить `WriteTimeout` (PD-46). Проводка демона больше не проверяется, а СДЕЛАНА невозможной: `NewServer` не принимает `Timeouts` (PD-66) |
|
||||
| Поток переживает `Read` без действий хендлера | `httpapi.TestStreamOutlivesReadTimeout` | Снять `Unwrap` (тогда `Flush` не дотягивается до соединения — проверяется ошибка `Flush`, а не игнорируется) |
|
||||
| Полу-кормленный СТРИМИНГОВЫЙ запрос всё равно отпускается | `httpapi.TestHalfFedStreamingRequestIsCutLoose` | Снять `ReadTimeout`; вернуть снятие дедлайна в хендлер (PD-63) |
|
||||
| SIGTERM дренирует, а не рубит | `httpapi.TestShutdownDrainsInFlightRequests` | `BaseContext` = сигнальный ctx |
|
||||
| Idle-истёкшая сессия не воскресает | `pgstore.TestTouchCannotResurrectAnIdleExpiredSession` | Убрать клаузу `idle_expires_at` из `Touch` |
|
||||
| Поток идентифицирован и монотонен | `ingest.TestHandshakeMustIdentifyTheStream` + `FuzzDecoder` | Пустой `engine_run_id`, `seq` хендшейка ≠ 1, hello в середине |
|
||||
| Деньги не дрейфуют | `ingest.TestSpendConvertsExactlyAndRoundsUp`, `money.TestParseUSDIsExactAndRoundsAwayFromZero` | float64 + умножение; округление к ближайшему |
|
||||
| Баланс = сумма леджера | `pgstore.TestCreditLifecycleKeepsTheCacheEqualToTheLedger` | Писать кэш вне транзакции леджера |
|
||||
| Повторный грант не кредитует дважды | `pgstore.TestGrantIsIdempotentBySource` | Снять `on conflict` / вынести обновление баланса из ветки «вставилось» |
|
||||
| Холд защищает баланс | `pgstore.TestHoldRefusesMoreThanTheBalance` | Убрать проверку баланса. ⚠ `for update` этим тестом НЕ ловится (последовательный тест лока не видит) — он запинен строкой ниже |
|
||||
| Почта не связывает аккаунты | `pgstore.TestIdentityNeverJoinsAccountsByEmail` | Резолв аккаунта по адресу; уникальный индекс на `users.email` |
|
||||
| Вход даёт НАШУ сессию и убивает прежнюю | `login.TestLoginCompletesAndCreatesOurOwnSession`, `TestLoginRevokesThePresentedSession` | Не отзывать предъявленную сессию (фиксация сессии) |
|
||||
| PKCE и nonce реально проверяются | `login.TestLoginCompletesAndCreatesOurOwnSession`, `TestCallbackRefusals` | Снять `S256ChallengeOption`; не сравнивать nonce |
|
||||
| `return_to` не уводит с сайта | `login.TestReturnToNeverLeavesThisSite` + `FuzzSafeReturnTo` | Ослабить до `HasPrefix("/")`; снять второй декод; снять класс символов; снять protocol-relative. Фаззер судит независимым оракулом — `ResolveReference` против базового URL сайта (PD-47) |
|
||||
| State одноразовый под КОНКУРЕНЦИЕЙ | `pgstore.TestOnlyOneRacingCallbackCanConsumeAState` (+ последовательные `login.TestStateCannotBeReplayed`, `pgstore.TestLoginStateIsSingleUseAndExpires`) | Разбить `DELETE ... RETURNING` на SELECT и DELETE — последовательные тесты этого не видят, гоночный ловит (3 колбэка из 4 съедали один state) |
|
||||
| Прогон не переживает свой синк | `ingest.TestFailingSinkStopsTheRun` | Убрать `stop()` после сбоя `Ingest`; убрать повтор сигнала |
|
||||
| Request-id не берётся у клиента | `reqid.TestRequestIDIsNeverTakenFromTheCaller` | Читать `X-Request-Id` из запроса |
|
||||
| Секреты можно подать файлом | `config.TestSecretsCanComeFromFiles` | Читать только переменную окружения |
|
||||
| Грант только подтверждённой личности | `login.TestSignupGrantGoesOnlyToAVerifiedIdentity` | Убрать условие `EmailVerified` (PD-48) |
|
||||
| State не redeem-ится у другого провайдера | `login.TestStateFromAnotherProviderIsRefused` | Убрать сверку `st.Provider` (PD-49) |
|
||||
| `iss` авторизационного ответа проверяется | `login.TestAuthorizationResponseIssuerIsChecked` | Убрать вызов `checkIssuer` или любую из его двух веток. ⚠ Потерю `issuer` в СТОРЕ ловит не он, а `pgstore.TestLoginStateIsSingleUseAndExpires` (сравнение структурой) — атрибуция важна ровно по причине PD-46 |
|
||||
| Состояние входа переживает стор ЦЕЛИКОМ | `pgstore.TestLoginStateIsSingleUseAndExpires` | Потерять любое поле `login.State` при записи или чтении — сравнение структурой, а не тремя полями (PD-62) |
|
||||
| Порядок блокировок один во всех денежных путях | `pgstore.TestHoldAndSettleOnTheSameAttemptDoNotDeadlock` | Убрать `lockBalance` из `closeReservation` — 5 падений из 5 (PD-52) |
|
||||
| Кука не участвует, если есть `Authorization` | `auth.TestAnAuthorizationHeaderTakesTheCookieOutOfPlay` | Падать обратно на куку при неразобранном заголовке (PD-50) |
|
||||
| Лимит тела: вложение только УЖЕСТОЧАЕТ | `httpapi.TestBodyCapIsPerRouteBecauseNestingOnlyTightens`, `TestDefaultBodyCapStaysAContractSizedNumber` | Раздуть дефолт до аплоуд-размера. ⚠ Возврат ОБЩЕГО внешнего слоя в `New` не ловится ничем: наблюдаемым он станет только когда появится маршрут со своим бо́льшим потолком — до тех пор это открытая строка PD-72, а не обещание |
|
||||
| Один ответ на несуществующий аккаунт | `pgstore.TestMoneyOperationsAgreeOnAMissingAccount` | Убрать мапинг констрейнта в `ErrNoAccount` (PD-56) |
|
||||
| Сроки сессий в пределах объявленной линии | `config.TestSessionClocksStayWithinTheDeclaredBaseline` | Поднять абсолютный срок выше 30 суток NIST AAL1 (PD-58) |
|
||||
| Зависший издатель не держит колбэк | `login.TestAStalledProviderDoesNotHoldTheCallback` | Убрать дедлайн из `identify` — обе ноги, token и keys (PD-65) |
|
||||
| Кука браузера скользит вместе со строкой | `auth.TestSlidingTheIdleWindowRefreshesTheBrowsersCookie` | Не переиздавать куку при скольжении; переиздавать её на Bearer-пути (PD-70) |
|
||||
| Два прогона не тратят один кредит | `pgstore.TestConcurrentHoldsCannotOvercommitAnAccount` | Убрать `for update` из `lockBalance` — 3 падения из 3, баланс в −$2 (PD-67) |
|
||||
| Готовность = схема, а не достижимость | `pgstore.TestReadinessRefusesADatabaseWithoutTheSchema` | Свести `Ready` к `Ping` (PD-68) |
|
||||
| Клиент go-oidc ограничен по времени | `login.TestTheDefaultProviderClientIsBounded`, `TestAHungKeyFetchDoesNotPoisonLaterSignIns` | Отдать `New` клиент без таймаута — тогда зависшая загрузка ключей держит и все последующие входы (PD-73) |
|
||||
| Скольжение не залипает на потолке | `auth.TestTheSlideStopsOnceItCannotMoveTheDeadline` | Убрать сверку `IdleExpiresAt.Before(AbsoluteExpiresAt)` — каждый запрос последней четверти жизни сессии становится записью (PD-74) |
|
||||
| Провайдерский токен не доезжает до стора | `login.TestLoginCompletesAndCreatesOurOwnSession` | Записать в стор что-либо выданное провайдером; ⚠ до P2 эта проверка не могла упасть — поле мока никто не заполнял (PD-76) |
|
||||
| Размеры пула из DSN переживают | `pgstore.TestExplicitPoolSizesInTheDSNSurvive` | Вернуть сравнение с дефолтом pgx. ⚠ Случай «пароль содержит имя ключа» ловит поиск подстроки только на машине, где наш дефолт НЕ совпал с дефолтом pgxpool (у него `max(4, NumCPU)`) — на 16-ядерном стенде он ничего не доказывает; несущее свойство даёт разбор через `RuntimeParams`, а не этот вход |
|
||||
|
||||
## Что построено (P0)
|
||||
|
||||
| Кусок | Где | Проверено |
|
||||
|
|
@ -254,16 +958,52 @@ research/23 §2 + запрет INFO-денег), а словарь строки
|
|||
|
||||
| ID | Диспозиция |
|
||||
|---|---|
|
||||
| П-1 | **НАЧАТА.** Готово: каркас сессий (схема + мидлварь + CSRF), HTTP-скелет, схема read-model, интерфейс ингеста и ре-синка. Осталось: контрактные ручки, SSE-эндпоинт, материализатор `Sink → Postgres`, воркер. Блокеры: ратификация К-4/К-7 (форма ответов), словарь событий (строка 103) |
|
||||
| П-1 | **ПРОДОЛЖЕНА (P1).** Добавлено: конструкция сервера с таймаутами, дренаж, снятие read-дедлайна для будущего SSE, вход как источник сессий. Осталось прежнее: контрактные ручки, SSE-эндпоинт, материализатор `Sink → Postgres`, воркер. Блокер тот же — словарь событий (строка 103) |
|
||||
| П-1 (P0) | **НАЧАТА.** Готово: каркас сессий (схема + мидлварь + CSRF), HTTP-скелет, схема read-model, интерфейс ингеста и ре-синка. Осталось: контрактные ручки, SSE-эндпоинт, материализатор `Sink → Postgres`, воркер. Блокеры: ратификация К-4/К-7 (форма ответов), словарь событий (строка 103) |
|
||||
| П-2 | Не трогали — гейт «до второго параллельного пользователя» в силе |
|
||||
| П-3 | Не строили. В схеме заведён гард: частичный уникальный индекс «один живой прогон на книгу» (`runs_one_live_per_book`) — то, что очередь обязана соблюдать, теперь отказывает база. River запинен, но в `go.mod` НЕ добавлен |
|
||||
| П-4 | Схема `usage_windows` заведена драфтом; источник метрик назван (дельты `committed_usd` из `status --json`). Гейт бюджета ДО старта — вместе с очередью |
|
||||
| П-5 | Форма предложена выше. Ждёт ответа владельца по авто-продолжению (вопрос 1) |
|
||||
| П-4 | **ЗАМЕНЁН П-7.** Черновик `usage_windows` удалён вместе с подписочной моделью (владелец 05.08) |
|
||||
| П-5 | **ПЕРЕОПРЕДЕЛЁН.** Окон нет, `resets_at` нет; форма ответа предложена выше («Что предлагаем в спеку»). Ручка НЕ построена: контракт первичен, ждём правки спеки |
|
||||
| П-6 | **ЗАКРЫТ (P1).** Вход через OIDC: PKCE + nonce + одноразовый state с привязкой к браузеру, наша серверная сессия, ротация на границе входа, журнал входов, «выйти везде». Токены провайдера не персистятся. Ждёт живого клиента Google (client_id/secret владельца) — код к этому готов, конфигурация проверена отказом на половинчатой настройке |
|
||||
| П-7 | **ЗАКРЫТ по схеме и операциям (P1).** Леджер, резервации, кэш баланса, инвариант `balance == SUM(ledger)`, идемпотентность по `(source, source_id)`. Не построено: постановка холда ВОРКЕРОМ перед спавном и передача потолка движку — это часть П-1/П-3, у которых нет воркера |
|
||||
| П-8 | **ЗАКРЫТ (P1).** `tmplatformctl grant/balance/logins/revoke` |
|
||||
|
||||
## Хроника
|
||||
|
||||
_(записи сессий — сверху новые)_
|
||||
|
||||
### 05.08.2026 — сессия P2 (платформа №3)
|
||||
|
||||
Отработана очередь приёмки P1 целиком (PD-46…PD-58) плюс три info-строки вне очереди
|
||||
(PD-50, PD-53, PD-56). Три собственные находки: PD-62 (`start_id` не персистился — обе строки лога
|
||||
`login_start_id` в проде пусты), PD-63 (`ClearReadDeadline` воспроизводил PD-2 на полу-кормленном
|
||||
запросе), PD-64 (`OOMPolicy=stop` уронил бы контрол-плейн из-за одного прогона).
|
||||
|
||||
Изменены два значения, а не обоснованы: абсолютный срок сессии 90 → 30 суток (NIST AAL1),
|
||||
`MemoryMax` 2G → 80%. Реализована контрмера RFC 9207 против mix-up (миграция `00008`).
|
||||
Удалён `ClearReadDeadline`. Новых зависимостей P2 не добавила.
|
||||
|
||||
Собственное адверсариальное ревью (пять линз, зажатые промты, отчёт скрыт от четырёх из пяти;
|
||||
каждая находка через верификатора-опровергателя): 34 кандидата, 11 подтверждено, 23 опровергнуты.
|
||||
Шесть потребовали кода — PD-65…PD-70, включая major PD-70 (кука не скользила вместе с сессией) и
|
||||
PD-66 (мой же фикс PD-46 закрывал половину). PD-71 принят риском и записан оператору.
|
||||
|
||||
Открытыми оставлены PD-60 и PD-61 — свойства ШВА, решать их платформе в одиночку нельзя.
|
||||
|
||||
Дерево не коммичено — лендит оркестратор.
|
||||
|
||||
### 05.08.2026 — сессия P1 (платформа №2)
|
||||
|
||||
Закрыт регистр P0 (18 из 19; PD-6 ждёт SSE). Построены: вход через OIDC с PKCE/nonce/одноразовым
|
||||
стейтом и своей серверной сессией (П-6), кредитный леджер с резервациями и кэшем баланса (П-7),
|
||||
админ-CLI (П-8), деплой-юнит systemd, тест-пол на реальном `http.Server`, фаззинг декодера.
|
||||
Приёмка пятью независимыми ревью добавила PD-24…PD-45; изменения решений — раздел «Приёмка P1».
|
||||
|
||||
Не построено намеренно: `GET /v0/usage` (форма изменилась вместе с моделью денег, спека не
|
||||
правлена — контракт первичен), материализатор и SSE (ждут словарь событий строки 103), очередь.
|
||||
|
||||
Дерево не коммичено — лендит оркестратор.
|
||||
|
||||
### 04.08.2026 — сессия P0 (платформа №1)
|
||||
|
||||
Прочитано: `CLAUDE.md`, `research/23`, контракт `14-api-contract` (README + openapi.yaml целиком),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
76
platform/internal/auth/cookie.go
Normal file
76
platform/internal/auth/cookie.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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 != ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
132
platform/internal/httpapi/serve.go
Normal file
132
platform/internal/httpapi/serve.go
Normal 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
|
||||
}
|
||||
290
platform/internal/httpapi/serve_test.go
Normal file
290
platform/internal/httpapi/serve_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
98
platform/internal/ingest/fuzz_test.go
Normal file
98
platform/internal/ingest/fuzz_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
58
platform/internal/ingest/money_test.go
Normal file
58
platform/internal/ingest/money_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
16
platform/internal/ingest/procgroup_other.go
Normal file
16
platform/internal/ingest/procgroup_other.go
Normal 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 }
|
||||
30
platform/internal/ingest/procgroup_unix.go
Normal file
30
platform/internal/ingest/procgroup_unix.go
Normal 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 }
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
171
platform/internal/login/issuer_test.go
Normal file
171
platform/internal/login/issuer_test.go
Normal 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))
|
||||
}
|
||||
568
platform/internal/login/login.go
Normal file
568
platform/internal/login/login.go
Normal 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()
|
||||
}
|
||||
693
platform/internal/login/login_test.go
Normal file
693
platform/internal/login/login_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
55
platform/internal/login/returnto_fuzz_test.go
Normal file
55
platform/internal/login/returnto_fuzz_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
109
platform/internal/login/store.go
Normal file
109
platform/internal/login/store.go
Normal 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)
|
||||
}
|
||||
86
platform/internal/money/money.go
Normal file
86
platform/internal/money/money.go
Normal 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)
|
||||
}
|
||||
56
platform/internal/money/money_test.go
Normal file
56
platform/internal/money/money_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
341
platform/internal/pgstore/credits.go
Normal file
341
platform/internal/pgstore/credits.go
Normal 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
|
||||
})
|
||||
}
|
||||
435
platform/internal/pgstore/credits_test.go
Normal file
435
platform/internal/pgstore/credits_test.go
Normal 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(¬e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if note == "" {
|
||||
t.Fatal("a capped settlement must say so in the row")
|
||||
}
|
||||
}
|
||||
|
||||
// A book with money reserved against it cannot be deleted. Cascading here would leave the `hold`
|
||||
// row in the ledger with nothing left to release it.
|
||||
func TestBookWithAnOpenHoldCannotBeDeleted(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
seedUser(t, s, ctx, "u1")
|
||||
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
||||
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
||||
now := time.Now().UTC()
|
||||
if _, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "g", "", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Hold(ctx, "u1", "bk1", "run-1", money.PerUSD, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DeleteBook(ctx, "bk1"); err == nil {
|
||||
t.Fatal("a book with an open hold was deleted; its ledger debit is now unreleasable")
|
||||
}
|
||||
if err := s.Release(ctx, "run-1", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DeleteBook(ctx, "bk1"); err != nil {
|
||||
t.Fatalf("a book with no open hold must be deletable: %v", err)
|
||||
}
|
||||
// The money history stays: the ledger is the financial record, the reservation was bookkeeping.
|
||||
a, err := s.ReadAccount(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Balance != 5*money.PerUSD || a.Balance != a.LedgerSum {
|
||||
t.Fatalf("deleting the book moved money: %s", a.Balance.USD())
|
||||
}
|
||||
}
|
||||
|
||||
// PD-26/PD-52. Every money operation takes the balance row lock FIRST, and the rule is only worth
|
||||
// having if a test notices its removal. The cycle needs a settlement and a hold that touch the same
|
||||
// reservation row: with the lock taken first in both, Settle waits for the balance before it touches
|
||||
// the row, so Hold never waits on a row while holding what Settle wants. Take it out of
|
||||
// closeReservation and the two acquire in opposite orders.
|
||||
//
|
||||
// Written by acceptance; re-measured here on PostgreSQL 18.4: with the lock order inverted it fails
|
||||
// 5 runs out of 5, at 5-10 deadlocks per 150 rounds, and with the fix it is green. Probabilistic in
|
||||
// the failing direction, which is why the round count stays high.
|
||||
// Mutation caught: deleting the lockBalance call from closeReservation.
|
||||
func TestHoldAndSettleOnTheSameAttemptDoNotDeadlock(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
seedUser(t, s, ctx, "u1")
|
||||
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
||||
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
||||
now := time.Now().UTC()
|
||||
if _, err := s.Grant(ctx, "u1", 100000*money.PerUSD, "admin", "g", "", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var deadlocks int
|
||||
note := func(err error) {
|
||||
if err != nil && strings.Contains(err.Error(), "deadlock") {
|
||||
mu.Lock()
|
||||
deadlocks++
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
for i := range 150 {
|
||||
id := fmt.Sprintf("run-%d", i)
|
||||
if err := s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { note(s.Settle(ctx, id, money.PerUSD/2, now)) })
|
||||
wg.Go(func() { note(s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now)) })
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
a, err := s.ReadAccount(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Balance != a.LedgerSum {
|
||||
t.Fatalf("the cache drifted from the ledger: %s against %s", a.Balance.USD(), a.LedgerSum.USD())
|
||||
}
|
||||
if deadlocks > 0 {
|
||||
t.Fatalf("%d deadlocks in 150 rounds: the lock order is not the same in both paths", deadlocks)
|
||||
}
|
||||
}
|
||||
|
||||
// PD-56. A typo in an account id is the commonest operator error, and every money entry point must
|
||||
// name it the same way. Before this, Balance said "no such account" while Grant and Adjust returned
|
||||
// the Postgres constraint name — which reads as a broken database, not a mistyped id.
|
||||
// Mutation caught: dropping the constraint check in appendLedger.
|
||||
func TestMoneyOperationsAgreeOnAMissingAccount(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
now := time.Now().UTC()
|
||||
grant := func() error { _, err := s.Grant(ctx, "no-such-user", money.PerUSD, "admin", "k1", "", now); return err }
|
||||
adjust := func() error {
|
||||
_, err := s.Adjust(ctx, "no-such-user", money.PerUSD, "admin", "k2", "why", now)
|
||||
return err
|
||||
}
|
||||
balance := func() error { _, err := s.Balance(ctx, "no-such-user"); return err }
|
||||
read := func() error { _, err := s.ReadAccount(ctx, "no-such-user"); return err }
|
||||
for name, call := range map[string]func() error{
|
||||
"grant": grant, "adjust": adjust, "balance": balance, "read account": read,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := call(); !errors.Is(err, ErrNoAccount) {
|
||||
t.Fatalf("got %v, want ErrNoAccount", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The row lock, not the comparison, is what stops two runs from spending the same credit. Each hold
|
||||
// here is affordable on its own and they are not affordable together, so without FOR UPDATE both
|
||||
// read the same balance, both pass the check, and the account goes negative — the cache and the
|
||||
// ledger drifting together, which is why the invariant assertions elsewhere cannot see it either.
|
||||
// Found by review: the sequential test above claimed this and could not deliver it.
|
||||
// Mutation caught: dropping `for update` from lockBalance.
|
||||
func TestConcurrentHoldsCannotOvercommitAnAccount(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
seedUser(t, s, ctx, "u1")
|
||||
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
||||
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
||||
now := time.Now().UTC()
|
||||
|
||||
const rounds = 60
|
||||
for i := range rounds {
|
||||
user := fmt.Sprintf("u-%d", i)
|
||||
book := fmt.Sprintf("bk-%d", i)
|
||||
seedUser(t, s, ctx, user)
|
||||
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
||||
values ($1,$2,'x','zh','ru','not_started','/srv/books/x','x')`, book, user)
|
||||
// Ten dollars, and two runs that each want six.
|
||||
if _, err := s.Grant(ctx, user, 10*money.PerUSD, "admin", "g", "", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var granted int
|
||||
for j := range 2 {
|
||||
wg.Go(func() {
|
||||
err := s.Hold(ctx, user, book, fmt.Sprintf("run-%d-%d", i, j), 6*money.PerUSD, now)
|
||||
switch {
|
||||
case err == nil:
|
||||
mu.Lock()
|
||||
granted++
|
||||
mu.Unlock()
|
||||
case errors.Is(err, ErrInsufficientCredit):
|
||||
default:
|
||||
mu.Lock()
|
||||
t.Errorf("round %d: unexpected hold error: %v", i, err)
|
||||
mu.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
a, err := s.ReadAccount(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if granted != 1 {
|
||||
t.Fatalf("round %d: %d of two competing holds were granted from one balance; balance is now %s",
|
||||
i, granted, a.Balance.USD())
|
||||
}
|
||||
if a.Balance < 0 {
|
||||
t.Fatalf("round %d: balance went negative (%s): two runs spent the same credit", i, a.Balance.USD())
|
||||
}
|
||||
if a.Balance != a.LedgerSum {
|
||||
t.Fatalf("round %d: cache %s disagrees with the ledger %s", i, a.Balance.USD(), a.LedgerSum.USD())
|
||||
}
|
||||
}
|
||||
}
|
||||
230
platform/internal/pgstore/identity.go
Normal file
230
platform/internal/pgstore/identity.go
Normal 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
|
||||
}
|
||||
296
platform/internal/pgstore/identity_test.go
Normal file
296
platform/internal/pgstore/identity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
16
platform/internal/pgstore/migrations.sha256
Normal file
16
platform/internal/pgstore/migrations.sha256
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
97
platform/internal/pgstore/migrations/00007_credits.sql
Normal file
97
platform/internal/pgstore/migrations/00007_credits.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
65
platform/internal/reqid/reqid.go
Normal file
65
platform/internal/reqid/reqid.go
Normal 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)}
|
||||
}
|
||||
66
platform/internal/reqid/reqid_test.go
Normal file
66
platform/internal/reqid/reqid_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue