diff --git a/platform/.golangci.yml b/platform/.golangci.yml new file mode 100644 index 00000000..e467960e --- /dev/null +++ b/platform/.golangci.yml @@ -0,0 +1,64 @@ +# golangci-lint configuration for the platform module. Pinned to 2.12.2 (Makefile enforces it): +# findings are version-dependent, so an unpinned linter is not a gate. +# +# The enable list is the backend's minus the linters whose finding classes this module does not +# have yet, plus the SQL ones, which it does. Exclusions are written out rather than taken from a +# preset: a preset also silences things we want to hear about. +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + # zero-cost bug classes for an HTTP + Postgres service + - bodyclose + - copyloopvar + - durationcheck + - errorlint + - makezero + - misspell + - nilerr + - noctx # a request without a context is a request that cannot be cancelled + - rowserrcheck + - sqlclosecheck + - nolintlint # no silent suppressions + + settings: + staticcheck: + checks: + - all + - -ST1000 # default-off in golangci-lint: package comment form + - -ST1003 # default-off: naming conventions + - -ST1016 # default-off: receiver name consistency + - -ST1020 # default-off: comment form on exported methods + - -ST1021 # default-off: comment form on exported types + - -ST1022 # default-off: comment form on exported vars + nolintlint: + require-explanation: true + require-specific: true + allow-unused: false + + exclusions: + rules: + # Close on a reader or a pool handle: nothing actionable comes back. This module has no + # flush-on-Close writer, so it cannot hide a lost write. + - linters: [errcheck] + text: 'Error return value of `[^`]*\.Close` is not checked' + # noctx exists to catch an uncancellable OUTBOUND call. httptest.NewRequest builds a request + # that is served in-process and never leaves it, so the rule fires 11 times and means nothing + # here; production code stays covered. + - linters: [noctx] + path: _test\.go + text: 'httptest\.NewRequest must not be called' + +formatters: + enable: + - gofmt + +issues: + max-issues-per-linter: 0 # never truncate: a hidden tail reads as "clean" + max-same-issues: 0 diff --git a/platform/BACKLOG.md b/platform/BACKLOG.md index 9ea6e17e..a4bc09cb 100644 --- a/platform/BACKLOG.md +++ b/platform/BACKLOG.md @@ -2,6 +2,10 @@ > Ведёт зона `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 форма предложена. Дублировать их здесь не стали — у строки один источник истины. + | ID | Хвост | Вес | Источник | |---|---|---|---| | П-1 | **HTTP/SSE-слой и сервисная обвязка** (экс-строка 96 единого бэклога): read-API поверх готовых `OpenReadOnly`-путей движка; SSE-события ПУШИТ воркер, фронт read-model не опрашивает (каждый read-вызов движка — дорогой ре-ингест, до строки 100 единого); аутентификация ратифицирована D39.84: одна серверная сессия в Postgres — `__Host`-кука браузеру · `Authorization: Bearer` десктопу/CLI · principal создаётся ТОЛЬКО в middleware, CSRF только на cookie-пути; ⚠ порядок деплоя: read-путь движка схему НЕ мигрирует (`store.go:135-143`, «schema vN … expects vM») — после апгрейда бинарника по каждой книге первой идёт write-команда; **форма потока ратифицирована D39.85 (`docs/research/23`):** воркер-обёртка платформы супервайзит процесс tmctl, ингестит NDJSON-события идемпотентным апсертом (run_id, seq) в Postgres (Reporting Database, не полный CQRS), SSE — из Postgres; ре-синк на обрыве — `tmctl status --json`; живой SQLite движка НЕ читать (анти-паттерн, аргументы в research/23 §4); ревью-гард: путь Go-модуля платформы никогда не вкладывать под `textmachine/backend/*` | Ф3, после контракта API (строка 95 единого) | D39.81, D39.84, D39.85, STACK_DECISIONS §5 | diff --git a/platform/Makefile b/platform/Makefile new file mode 100644 index 00000000..18cc13f8 --- /dev/null +++ b/platform/Makefile @@ -0,0 +1,50 @@ +# Platform battery as one command: `make check` is what CI calls and what a session runs before +# handing the tree over. Toolchain and linter are PINNED (never "latest"): a gate that changes +# under you on someone else's machine is not a gate. Shape mirrors backend/Makefile deliberately. + +GO ?= go +# go.mod's floor is 1.26.4 (the engine's), but the BUILD toolchain floor here is 1.26.5: it carries +# the crypto/tls and os security fixes, and this module is the one exposed to the network. +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 + +build: tools-check + $(GO) build ./... + +vet: + $(GO) vet ./... + +# `gofmt -l` exits 0 even when it names files, so the emptiness of its output is the assertion. +fmt: + @test -z "$$(gofmt -l .)" || { echo "gofmt: not formatted:"; gofmt -l .; exit 1; } + +tools-check: + @$(GO) version | grep -qE 'go1\.26\.([5-9]|[0-9]{2,})|go1\.(2[7-9]|[3-9][0-9])' || { \ + echo "Go $(GO_MIN_VERSION)+ required (security fixes in a network-facing module); got: $$($(GO) version)"; exit 1; } + @$(GOLANGCI_LINT) --version 2>/dev/null | grep -q " $(GOLANGCI_VERSION) " || { \ + echo "golangci-lint $(GOLANGCI_VERSION) required (findings are version-dependent)."; \ + echo "install: https://github.com/golangci/golangci-lint/releases/tag/v$(GOLANGCI_VERSION)"; exit 1; } + +lint: tools-check + $(GOLANGCI_LINT) run --timeout=10m ./... + +# -race needs cgo. If the C toolchain is missing this fails loudly rather than quietly proving less. +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 + +# 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). +vuln: + $(GO) run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... diff --git a/platform/README.md b/platform/README.md index ca6d12d9..aab12912 100644 --- a/platform/README.md +++ b/platform/README.md @@ -1,6 +1,12 @@ # platform — control plane (SaaS-слой) -Зона записи сессии «Платформа». Кода ещё нет; **активный промт — `docs/PLATFORM_SESSION_PROMPT.md` (P0, выдан 04.08, D39.100)**, зонный журнал — `docs/platform-PROGRESS.md` (весь прогресс зоны здесь, решение владельца 04.08). +Зона записи сессии «Платформа». P0 собран 04.08 (скелет: HTTP · сессии · схема read-model · +интерфейс ингеста); **активный промт — `docs/PLATFORM_SESSION_PROMPT.md`**, зонный журнал — +`docs/platform-PROGRESS.md` (весь прогресс зоны здесь, решение владельца 04.08), стек — +`docs/STACK_DECISIONS.md`. + +Батарея зоны: `make check` (build · vet · fmt · lint · test -race). Тесты со схемой требуют +`TM_PLATFORM_TEST_DSN`; без него они пропускаются, и `check` называет пропуски вслух. ## ⚠ Git и зона (читать ДО первой строки кода) @@ -42,11 +48,12 @@ ## Стек -Пины и обоснования — [`../frontend/docs/STACK_DECISIONS.md`](../frontend/docs/STACK_DECISIONS.md) §5 -(общий документ решений по обоим новым сервисам; исследование 02.08). +Пины, даты релизов и обоснования — [`docs/STACK_DECISIONS.md`](docs/STACK_DECISIONS.md) (зонный, +live-сверка 04.08); общая записка по обоим новым сервисам — `../frontend/docs/STACK_DECISIONS.md` §5. -Коротко: Go 1.26.4 · стандартный `net/http` + `ServeMux` без роутер-библиотеки · pgx v5.10.0 · -goose v3.27.3 · очередь River v0.42.0 на том же Postgres · `govulncheck` гейтом CI. +Коротко: 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` отдельной целью. **Redis не заводим нигде** — зафиксировано как архитектурное «нет». Прогресс наружу — SSE, события **пушит воркер**, а не фронт опрашивает read-model. diff --git a/platform/cmd/tmplatformd/main.go b/platform/cmd/tmplatformd/main.go new file mode 100644 index 00000000..856664ce --- /dev/null +++ b/platform/cmd/tmplatformd/main.go @@ -0,0 +1,103 @@ +// Command tmplatformd is the TextMachine control plane: HTTP for the frontend, Postgres for the +// read model, and (from P-1 onward) a worker that supervises tmctl processes. It contains no +// translation logic: the engine is spawned, never linked (D39.81). +package main + +import ( + "context" + "errors" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "textmachine/platform/internal/auth" + "textmachine/platform/internal/config" + "textmachine/platform/internal/httpapi" + "textmachine/platform/internal/pgstore" +) + +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})) + if err := run(log); err != nil { + log.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run(log *slog.Logger) error { + cfg, err := config.Load() + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + var db *pgstore.Store + if cfg.DSN == "" { + // Deliberate: the process still serves liveness so a supervisor can start it before the + // database exists. /readyz is the honest signal, and it says no. + log.Warn("no TM_PLATFORM_DSN: starting without a database, /readyz will report not ready") + } else { + if cfg.Migrate { + if err := pgstore.Migrate(ctx, cfg.DSN); err != nil { + return err + } + log.Info("migrations applied") + } + if db, err = pgstore.Open(ctx, cfg.DSN); err != nil { + return err + } + defer db.Close() + } + + authn := &auth.Authenticator{ + IdleTTL: cfg.SessionIdleTTL, + Deny: httpapi.ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"), + } + deps := httpapi.Deps{Log: log, Auth: authn, TrustedOrigins: cfg.TrustedOrigins} + if db != nil { + deps.DB = db + authn.Sessions = db + } + 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) + go func() { + log.Info("listening", "addr", cfg.Addr) + errc <- srv.ListenAndServe() + }() + + select { + case err := <-errc: + if errors.Is(err, http.ErrServerClosed) { + return 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) + } +} diff --git a/platform/docs/STACK_DECISIONS.md b/platform/docs/STACK_DECISIONS.md new file mode 100644 index 00000000..f2d28edf --- /dev/null +++ b/platform/docs/STACK_DECISIONS.md @@ -0,0 +1,64 @@ +# Стек платформы — пины и обоснования + +> Зонный документ `platform/`. Пины ниже сверены ЖИВЬЁМ 04.08.2026 (Go-прокси `@latest`, +> postgresql.org, go.dev/dl) — версии по памяти не называются. Библиотеки сессия не ратифицирует: +> таблица уходит оркестратору вместе с деревом. +> +> Общая записка по обоим новым сервисам — `frontend/docs/STACK_DECISIONS.md` §5 (02.08). Здесь — +> платформенная часть с датами релизов и сверкой. Что изменилось за два дня: три библиотечных пина +> §5 (pgx · goose · River) на 04.08 всё ещё последние; по Go последним патчем стенда идёт 1.26.5 +> (07.07) — floor `go.mod` оставлен общим с движком, а тулчейн сборки поднят до 1.26.5 (см. ниже). + +## Пины + +| Что | Пин | Релиз пина | Зачем нам | +|---|---|---|---| +| Go (язык, `go.mod`) | **1.26.4** | 02.06.2026 | Тот же floor, что у движка (`backend/go.mod`) — общий стенд собирает оба модуля одним тулчейном | +| Go (тулчейн сборки, `make tools-check`) | **≥1.26.5** | 07.07.2026 | 1.26.5 несёт security-фиксы `crypto/tls` и `os`; сетевой модуль собирается ими, а не «чем-нибудь 1.26» | +| PostgreSQL | **18.x** (проверено на 18.4), floor **16** | 18.4 — май 2026 | 18 — текущая мажорная (19 в бете, в прод не берём); floor 16, потому что River тестируется на трёх последних мажорных | +| HTTP | stdlib `net/http` + `ServeMux` | — | Роутер-библиотека не нужна: `ServeMux` с 1.22 умеет метод+wildcards, а `Request.Pattern` даёт лог по маршруту, не по пути | +| CSRF | stdlib `http.CrossOriginProtection` | Go 1.25 | Ровно тот механизм, что описан в §5 (Sec-Fetch-Site → Origin), теперь в тулчейне — свой велосипед не пишем | +| Postgres-драйвер | `github.com/jackc/pgx/v5` **v5.10.0** | 03.06.2026 | Живой pool, `pgconn.PgError` для проверки констрейнтов, `stdlib` для goose | +| Миграции | `github.com/pressly/goose/v3` **v3.27.3** | 22.07.2026 | Библиотекой + `embed.FS`; `WithSessionLocker` = advisory-лок, две реплики выкатываются по очереди | +| Очередь | `github.com/riverqueue/river` **v0.42.0** | 31.07.2026 | Пин ПОДТВЕРЖДЁН живой сверкой, но **в `go.mod` НЕ добавлен**: П-3 вне скоупа P0, а зависимость без кода — мусор в графе | +| Линтер | `golangci-lint` **2.12.2** | 06.05.2026 | Тот же пин, что у движка: находки версионно-зависимы, разъезд пинов = разные гейты в одном репо | +| Уязвимости | `govulncheck` **v1.6.0** | 09.07.2026 | Отдельная цель `make vuln`, не часть `check`: ей нужна сеть, а батарея обязана быть зелёной на голом клоне офлайн | + +**Redis нет** — архитектурное «нет» из §5 в силе: очередь, лизы и рейт-лимиты живут в том же Postgres. + +## Что решено этой сессией (сверх §5) + +1. **`/healthz` ≠ `/readyz`.** Liveness ничего не трогает (БД лежит — процесс жив), readiness пингует + пул. Пустой `TM_PLATFORM_DSN` — легальный старт: сервис поднимается и честно говорит «не готов». + Иначе супервизор убивает здоровый процесс за то, что база моргнула. +2. **Ops-эндпоинты вне версионного префикса.** `/healthz`, `/readyz` — в корне; контрактная + поверхность целиком под `/v0` (базовый путь спеки платформа ПОДТВЕРЖДАЕТ). +3. **Один mux.** Контрактные маршруты регистрируются с префиксом в паттерне, а не вложенным mux'ом + под `StripPrefix`: вложенный получает КОПИЮ запроса, и `Request.Pattern` наружу не возвращается — + лог пришлось бы писать по сырому пути с id книг. Проверено исполнением. +4. **Гард навешен на поддерево, а не на ручки.** Неизвестный путь под `/v0` отвечает 401 раньше 404: + аноним не должен картографировать поверхность. +5. **Заголовки безопасности — на каждом ответе** (`X-Robots-Tag: noindex`, `Cache-Control: no-store`, + `nosniff`, `no-referrer`): ПТ-34 нельзя оставлять на дисциплину автора следующей ручки. +6. **Батарея зоны — `make check`** (build · vet · fmt · lint · test -race), форма скопирована с + `backend/Makefile` вплоть до именования пропущенных тестов: тихий skip читается как покрытие. +7. **Тесты с БД гейтятся `TM_PLATFORM_TEST_DSN`** и создают СВОЮ базу на прогон (дропают в + `t.Cleanup`). Батарея на голом клоне зелёная и офлайн; с DSN — та же батарея плюс схема. + +## Как поднять локально + +```sh +export TM_PLATFORM_DSN='postgres://user@host:5432/tmplatform?sslmode=disable' +make check # батарея зоны +TM_PLATFORM_MIGRATE=1 go run ./cmd/tmplatformd # миграции + сервер на 127.0.0.1:8080 +curl -s localhost:8080/healthz # ok +curl -s localhost:8080/readyz # ready +``` + +Переменные: `TM_PLATFORM_ADDR` · `TM_PLATFORM_DSN` · `TM_PLATFORM_MIGRATE` · +`TM_PLATFORM_TRUSTED_ORIGINS` (через запятую) · `TM_PLATFORM_SESSION_IDLE` · +`TM_PLATFORM_SESSION_MAX_AGE`. + +⚠ Postgres на стенде отсутствует как системный пакет и sudo нет. Схема и запросы этой сессии +проверены на ЖИВОМ PostgreSQL **18.4**, поднятом без root из бинарников zonky +(`io.zonky.test.postgres`, Maven Central) в скрэтчпаде — вне репозитория и вне зависимостей модуля. diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index b50a1ead..8961784f 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -6,14 +6,200 @@ ## Текущее состояние -- Кода нет. `go.mod` заведён. Промт P0 выдан 04.08 (`PLATFORM_SESSION_PROMPT.md`, D39.100). -- Контракт API v0 ратифицирован (D39.99, `docs/architecture/14-api-contract/`) — на платформе - дизайн-ответы К-4 (ревизия) · К-7 (пагинация) · К-12 (пуш экспорта) · форма П-5 (лимиты). +- **P0 собран** (сессия 04.08): модуль компилируется, батарея зоны `make check` зелёная, + `/healthz` и `/readyz` проверены живым запуском против живого Postgres 18.4. +- Стек запинен и live-сверен — `STACK_DECISIONS.md` (зонный). +- Дизайн-ответы К-4 · К-7 · К-12 · форма П-5 — ниже, ПРЕДЛОЖЕНИЯМИ на ратификацию. +- Контрактных ручек нет намеренно: они ждут ратификации К-4/К-7 (форма ответов) — это П-1. ## Открытые вопросы к владельцу/оркестратору -_(пусто)_ +1. **⚠ ВЛАДЕЛЬЦУ (П-5).** При сбросе окна лимитов приостановленный (`paused`) перевод + продолжается САМ или ждёт явного «Продолжить»? От ответа зависит, есть ли кнопка на экране и + нужно ли уведомление «продолжили без вас». Технически дёшевы оба варианта. +2. **Оркестратору (контракт).** Поток событий привязан к ПРОГОНУ (`/runs/{runId}/events`), а + статусы `uploading`/`parsing` существуют ДО прогона, и библиотека охватывает книги без прогонов. + Живого канала у них нет вовсе. Нужна либо строка в спеке «до старта прогона состояние + опрашивается», либо пользовательский поток (он же закрыл бы К-12 пушем). Решение — не наше. +3. **Оркестратору (спека).** Третий слой CSRF из STACK §5 требует от браузерного клиента + заголовок `X-TM-Client` на небезопасных запросах cookie-пути. Это требование к ФРОНТУ, и его + место — в описании `sessionCookie` в спеке. Реализовано и проверено тестами. + +## Дизайн-ответы на ратификацию + +### К-4 — ревизия: пер-ресурсная со скоупом КНИГА; чтения её несут + +Обе половины вопроса: + +1. **Чтения ревизию несут — да.** Без неё правило «отбрось чтение старше уже применённого + события» нечем реализовать, а рефетч по возврату фокуса окна включён у `react-query` по + умолчанию — то есть гонка на каждое переключение вкладки, а не редкий случай. +2. **Счётчик — один на КНИГУ.** Все книго-скоупные чтения (карточка, главы, юниты, замечания, + банк, прогон) возвращают ОДНО и то же число — `books.revision`, +1 за материализующую + транзакцию; строки, которых она коснулась, штампуются новым значением. `id` SSE-кадра прогона — + ТО ЖЕ число, поэтому события и чтения книги полностью упорядочены между собой. + +Почему книга, а не сквозной счётчик: + +- сравнивать ревизии осмысленно только внутри скоупа, а книга — минимальный скоуп, в котором + лежит всё, что поток может протухнуть; +- единственный писатель на книгу уже гарантирован (сериализация очереди по `book_id` — П-3, плюс + EXCLUSIVE-лок движка на файл проекта), поэтому счётчику не нужны ни блокировка, ни глобальная + последовательность; +- глобальный счётчик отвергнут по двум причинам: одна горячая последовательность на всех + пользователей и утечка — по разрывам номеров любой клиент оценивает активность всей платформы; +- у библиотеки (`GET /books`) свой скоуп — счётчик на пользователя (`users.library_revision`), + потому что она охватывает книги. + +Просим внести в спеку прозой: (i) `revision` монотонна В ПРЕДЕЛАХ скоупа ресурса и между скоупами +не сравнивается; (ii) кадр потока и книго-скоупные чтения несут ОДИН счётчик; (iii) отбрасывание +устаревшего чтения — обязанность клиента. + +Побочная выгода: тот же штамп даёт докачку потока «строки книги с `revision > X`» БЕЗ журнала +событий — реплей истории остаётся запрещённым (D39.85, контракт §2.11). + +### К-7 — курсор на каждом списке, дефолт «одна страница» + +Замер (сериализация фикстур контрактной формы, случайные значения — не повторяющиеся, иначе gzip +льстит): 2284 главы = **289 КБ JSON / 46 КБ gzip**; 1200 терминов = **229 КБ / 40 КБ**. Одним +ответом влезает — но китайские вебновеллы на 5000+ глав норма, а банк растёт вместе с книгой, +поэтому «всегда одним ответом» — это отложенное молчаливое обрезание. + +Предложение: **keyset-курсор на КАЖДОМ списочном ответе**, параметры `?limit=&cursor=`, поле +`next_cursor: string|null` присутствует ВСЕГДА. Дефолты: главы 5000 (обычная книга = одна +страница), банк 1000, замечания 500; юниты и библиотека курсор тоже несут, хотя практически не +пагинируются. + +- **Keyset, не offset:** материализатор пишет параллельно чтению, а offset на пишущейся таблице + пропускает и дублирует строки; keyset по `(book_id, number)` устойчив к дозаписи. +- **Поле с первого дня у всех списков — намеренно.** Добавить его позже — минорное изменение, + которое у клиента, его не читающего, молча отрезает хвост. +- Курсор непрозрачный, кодирует последний ключ сортировки и `revision`; смена `revision` между + страницами обязывает клиента начать цикл заново, иначе он склеит два состояния. + +### К-12 — опрос; причина структурная, а не вкусовая + +Единственный поток контракта привязан к ПРОГОНУ, а экспорт делают с законченной книги — живого +прогона обычно нет. Пуш завершения потребовал бы второго потока ради одного булева. + +Предложение — индустриальный async request-reply: `POST /books/{id}/exports` → `202` + +`Location`; `GET /books/{id}/exports/{id}` → `200` c `ready:false` и заголовком `Retry-After`, пока +строится, и `ready:true` + `url`, когда готов. Интервал называет СЕРВЕР, клиент не угадывает. +Просим добавить `Retry-After` в спеку. Появится пользовательский поток (вопрос 2 выше) — пуш +поедет им, опрос останется фолбэком. + +### П-5 — форма API лимитов/использования + +`GET /v0/usage` (страница лимитов в настройках): + +```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 несёт состояние. Без поля причины второй повод для + паузы станет ломающим изменением. +- **Источник цифр.** Поток событий денег не несёт и не должен (кадр `ceiling` — только факт), + поэтому платформа метрит из `tmctl status --json` (`committed_usd`) на границах попыток и на + ре-синке; хранит целыми микро-долларами в `usage_windows`. +- **Поднятие потолка — политика платформы, не кнопка на экране.** Платформа сама владеет + `book.yaml`, поднимает `ceilings.book_usd` и перезапускает прогон. **Проверено кодом, что это + безопасно:** `Ceilings` объявлен в `backend/internal/config/book.go:106`, а в канон `BriefHash` + (`:280-297`) НЕ входит — значит поднятие потолка не двигает `brief_hash` → снапшот и не вызывает + ни дрифт, ни ре-билл. Риск «подняли лимит — переплатили книгу заново» снят фактом, не надеждой. + +## Что построено (P0) + +| Кусок | Где | Проверено | +|---|---|---| +| Модуль, layout, батарея | `go.mod` (sibling движка, гард D39.85 соблюдён), `Makefile`, `.golangci.yml` | `make check` зелёный: build · vet · gofmt · lint 0 issues · `go test -race` | +| HTTP-скелет | `internal/httpapi/` | Живой запуск: `/healthz` 200, `/readyz` 200 против живого PG, `/v0/*` 401 problem+json, graceful shutdown по SIGTERM | +| Заголовки ПТ-34 | `internal/httpapi/middleware.go` | Живой ответ несёт `X-Robots-Tag: noindex, nofollow`, `Cache-Control: no-store`, `nosniff`, `no-referrer` | +| Сессии П-1 | `internal/auth/` + `internal/pgstore/sessions.go` | Тесты: обе презентации → principal, Bearer > cookie, истечение/отзыв/свип, токен в БД не попадает (только SHA-256), скольжение окна только во второй половине | +| CSRF | `internal/auth/csrf.go` | stdlib `http.CrossOriginProtection` + обязательный `X-TM-Client` на cookie-пути; 6 кейсов тестом + живой пробой (cookie-POST без заголовка → 403, cross-site → 403) | +| Схема read-model | `internal/pgstore/migrations/` | Миграции применены на ЖИВОМ PostgreSQL 18.4 дважды (идемпотентность), все констрейнты сработали поимённо | +| Интерфейс NDJSON-ингеста | `internal/ingest/` | Тесты: хендшейк обязателен, мажор отвергается, минор и незнакомый тип толерируются, разрыв/повтор seq ловятся; супервизор проверен НАСТОЯЩИМ процессом (exit 3 → `bank_stop`, stderr движка в файл, поток материализован) | +| Ре-синк | `internal/ingest/resync.go` | Тест на фикстуре в форме `pipeline.StatusReport` (имена полей сверены по `backend/internal/pipeline/status.go:37-130`, живого прогона не было): аллоулист берёт своё, деньги/снапшоты игнорируются | + +Не построено намеренно: материализатор `Sink → Postgres` (нужен ратифицированный словарь событий, +иначе перепишется), контрактные ручки и SSE (П-1 после ратификации К-4/К-7), очередь River (П-3), +брокер лимитов (П-2). + +## Находки (грунтованные) + +1. **Ключ идемпотентности `(run_id, seq)` работает только если `run_id` — ДВИЖКОВЫЙ.** Resume + поднимает новый процесс, его `seq` стартует с 1; если ключом взять платформенный run, high-water + mark отбросит весь поток второй попытки. Заведено в схеме: `run_attempts.engine_run_id` (unique) + + `last_seq`. Просьба к строке 103: кадр `hello` обязан нести этот id; идеально — вместе со + строкой 102 (внешний trace-контекст), тогда id назначает платформа и пространство ключей наше. +2. **Дыра контракта — статусы до прогона и библиотека без канала.** См. вопрос 2 выше. +3. **Банк: канала нет — но не полностью.** Подтверждаем находку оркестратора и уточняем состав: + сегодня добываемы (а) ПРЕДЛОЖЕННЫЕ термины стопа — сайдкар/строка 101 и (б) термины, которые + промотировала сама платформа — она же ПИШЕТ mined-delta и сид. Недобываемы `auto`/`ruby`-строки, + материализованные внутри движка. То есть `GET /bank` частично реализуем уже сейчас; полностью — + после артефакта экспорта банка. +4. **Ре-синк не восстанавливает пофазный прогресс:** в `status --json` разбивки нет (строка 99). + После обрыва и до следующего события прогресса клиент увидит агрегат. Записано в коде. +5. **`status --json` — ремонтный путь, не поллинг:** каждый вызов заново ингестит и режет исходник + (1.4–1.5 с CPU на книге 23 МБ — замер фронт-сессии 02.08, не наш; строка 100). +6. **Деньги движка живут в его stderr на уровне INFO.** Поэтому супервизор пишет stderr движка в + ФАЙЛ попытки и не тейлит его в структурный лог платформы — иначе суммы попадут в наш INFO + (запрет D39.84 + норма P0-промта). +7. **Мелочи в чужой зоне (не трогали, лендить оркестратору):** в ратифицированной копии + `docs/architecture/14-api-contract/openapi.yaml` `info.description` всё ещё называет файл + черновиком S3 и ссылается на `../API_CONTRACT_DRAFT.md`; в README той же папки ссылка + «нормативная поверхность → `api-contract/openapi.yaml`» бьёт мимо (файл лежит рядом: + `./openapi.yaml`). Решения D39.100 (`paused`, `eta_seconds`) в YAML ещё не внесены — это работа + S3; схема платформы их уже держит. +8. **Стенд:** Postgres как системного пакета нет и sudo нет, поэтому схема проверена на живом + PostgreSQL 18.4, поднятом БЕЗ root из бинарников zonky в скрэтчпаде (вне репозитория и вне + зависимостей модуля). Тесты с БД гейтятся `TM_PLATFORM_TEST_DSN` и создают свою базу на прогон. + +## Диспозиции бэклога зоны + +| ID | Диспозиция | +|---|---| +| П-1 | **НАЧАТА.** Готово: каркас сессий (схема + мидлварь + 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) | ## Хроника _(записи сессий — сверху новые)_ + +### 04.08.2026 — сессия P0 (платформа №1) + +Прочитано: `CLAUDE.md`, `research/23`, контракт `14-api-contract` (README + openapi.yaml целиком), +`platform/BACKLOG.md`, `frontend/docs/STACK_DECISIONS.md` §5, D39.81/84/85/99/100 по grep. + +Сделано: стек live-сверен (три библиотечных пина §5 — pgx · goose · River — на 04.08 всё ещё +последние; по Go последний патч 1.26.5 от 07.07, floor модуля оставлен общим с движком) → +модуль наполнен → +скелет HTTP + сессии + CSRF → схема read-model тремя миграциями → интерфейс ингеста/супервизии/ +ре-синка → батарея зоны → дизайн-ответы (выше). + +Ревью исполнением: `make check` зелёный; сервер поднят живьём против живого PostgreSQL 18.4 и +опрошен curl'ом (healthz/readyz/401/CSRF-403); миграции применены дважды; констрейнты проверены +поимённо через `pgconn.PgError.ConstraintName`; супервизор проверен настоящим процессом с +контрактными кодами возврата. + +Адверсариальная самопроверка (author≠reviewer) дала четыре правки, каждая внесена: +(а) вложенный mux под `StripPrefix` терял `Request.Pattern`, из-за чего лог писался бы по сырому +пути с id книг — проверено экспериментом, переделано на один mux; (б) отклонённые запросы (401/403) +вообще не логировались, потому что лог висел на маршрутах, а гард стоял снаружи — лог поднят +наружу, добавлен тест «денайл тоже виден»; (в) **дефект, найденный запуском бинарника без БД:** +предъявленный Bearer уходил в nil-хранилище сессий и падал паникой в 500 — теперь отсутствие +хранилища это отказ 401, как и любой другой промах (регрессионный тест на месте); (г) пин тулчейна +поднят до 1.26.5 — в нём security-фиксы `crypto/tls` и `os`, а этот модуль сетевой (в `go.mod` +floor остался 1.26.4, общий с движком). Плюс снят мёртвый код: `crypto/rand.Read` по доке ошибку +не возвращает вовсе (падает), поэтому ветки её обработки убраны, а не оставлены изображать проверку. + +Дерево не коммичено — лендит оркестратор. diff --git a/platform/go.mod b/platform/go.mod index d5b23fea..ce9cfdca 100644 --- a/platform/go.mod +++ b/platform/go.mod @@ -1,3 +1,19 @@ module textmachine/platform go 1.26.4 + +require ( + github.com/jackc/pgx/v5 v5.10.0 + github.com/pressly/goose/v3 v3.27.3 +) + +require ( + 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 + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/sethvargo/go-retry v0.4.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/platform/go.sum b/platform/go.sum new file mode 100644 index 00000000..62dfbd71 --- /dev/null +++ b/platform/go.sum @@ -0,0 +1,54 @@ +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/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/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= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +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/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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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/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= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= +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= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= diff --git a/platform/internal/auth/csrf.go b/platform/internal/auth/csrf.go new file mode 100644 index 00000000..98a78977 --- /dev/null +++ b/platform/internal/auth/csrf.go @@ -0,0 +1,61 @@ +package auth + +import ( + "fmt" + "net/http" +) + +// ClientHeader is the header a browser client must send with every unsafe cookie-authenticated +// request. Any value; its PRESENCE is the assertion. +const ClientHeader = "X-TM-Client" + +// CSRF guards the cookie path and only it: a Bearer token is not ambient authority, so no +// cross-site page can make a browser attach one. +// +// Two layers, both cheap: +// +// - http.CrossOriginProtection (stdlib, Go 1.25+) — Sec-Fetch-Site with an Origin/Host fallback. +// This is the mechanism STACK_DECISIONS §5 describes, and it now ships with the toolchain, so +// we do not hand-roll it. +// - a required custom header on unsafe requests that carry the session cookie. The stdlib check +// ALLOWS a request bearing neither Sec-Fetch-Site nor Origin, on the reasoning that it is not +// a browser. A pre-2023 browser posting a cross-site