diff --git a/platform/README.md b/platform/README.md index 0d5d8a84..7a740c8b 100644 --- a/platform/README.md +++ b/platform/README.md @@ -10,7 +10,7 @@ ## ⚠ Git и зона (читать ДО первой строки кода) -**Платформенная сессия не коммитит — лендит оркестратор.** Канон — `../CLAUDE.md`, §Гардрейлы; продублировано здесь, потому что зона живёт своим онбордингом. Стандарты и критерии приёмки зоны — `docs/ENGINEERING_STANDARDS.md`; дефекты и уязвимости — `docs/DEFECT_REGISTER.md` (каждая находка получает строку ДО закрытия). +**Платформенная сессия не коммитит — лендит оркестратор.** Канон — `../CLAUDE.md`, §Гардрейлы; продублировано здесь, потому что зона живёт своим онбордингом. Направление зоны (вход · деньги · стандарты · скорость) — `docs/PLATFORM_DIRECTION.md`; стандарты и критерии приёмки — `docs/ENGINEERING_STANDARDS.md`; дефекты и уязвимости — `docs/DEFECT_REGISTER.md` (каждая находка получает строку ДО закрытия). 1. **Писать только внутрь `platform/`.** Ничего за её пределами — ни `docs/`, ни `backend/`, ни `frontend/`, ни корневых файлов. Нужна правка вне зоны — пинг владельцу, её сделает оркестратор. 2. **Чужие незакоммиченные файлы в дереве не трогать**: параллельные сессии — норма. diff --git a/platform/docs/ENGINEERING_STANDARDS.md b/platform/docs/ENGINEERING_STANDARDS.md index 8c50d191..686ddadf 100644 --- a/platform/docs/ENGINEERING_STANDARDS.md +++ b/platform/docs/ENGINEERING_STANDARDS.md @@ -18,7 +18,10 @@ ## 2. Базовые стандарты (проверяются каждой приёмкой) -**Безопасность** (направление — OWASP ASVS, без карго-культа): +**Безопасность** — базовая линия OWASP **ASVS 5.0.0, целевой L2** (главы, релевантные зоне: V6 +аутентификация · V7 сессии · V10 OAuth/OIDC) + OWASP API Security Top 10 2023 (API1 BOLA = проверка +владения книгой на каждом `/v0`-маршруте; API4 = класс PD-2). Что берём и чего не берём из +инструментов — `PLATFORM_DIRECTION.md` §3. Конкретика: - Сессии: одна серверная, opaque-токен ≥256 бит из crypto/rand, в БД только хеш, немедленный отзыв, два срока (idle скользит, absolute нет). Плейнтекст-токен не персистится, не логируется и живёт в памяти только окно обработки запроса (решение владельца 04.08). diff --git a/platform/docs/PLATFORM_DIRECTION.md b/platform/docs/PLATFORM_DIRECTION.md new file mode 100644 index 00000000..c0a5593e --- /dev/null +++ b/platform/docs/PLATFORM_DIRECTION.md @@ -0,0 +1,177 @@ +# Направление платформы: аутентификация, деньги, стандарты, скорость + +> Ратифицировано оркестратором №14 (05.08.2026) по направлению владельца 04.08 и ресёрч-пакету +> из четырёх направлений (OAuth · биллинг · индустриальные стандарты · производительность фронта), +> каждое со скептик-пассом. **Все пины и цены сверены ЖИВЬЁМ 04–05.08.2026** против первоисточников +> (proxy.golang.org, go.dev/dl, npm registry, вендорские доки); пин по памяти не называется — +> перед установкой сверять заново. Что скептик опроверг — помечено ⚠ прямо в тексте. + +## 1. Вход и аккаунты (П-6) + +**Форма: BFF — вход через OIDC, сессия остаётся НАША.** Google (и любой следующий провайдер) даёт +только СОБЫТИЕ входа; его токены используются один раз в колбэке для доказательства личности и +выбрасываются — ничего вендорского не персистится и не живёт в памяти дольше запроса (требование +владельца о токенах исполнено буквально). Уже построенный слой сессий (`internal/auth` + `pgstore`) +остаётся без изменений: он же несёт учёт токенов и мгновенный отзыв, которого self-verifying токен +не даёт. Это ровно тот паттерн, который IETF BCP браузерных приложений (`draft-ietf-oauth-browser-based-apps-27`, +06.07.2026, в очереди RFC-редактора) называет строго рекомендуемым для приложений с персональными +данными и деньгами. + +**Библиотеки (протокольный риск отдаём, интеграцию пишем сами):** + +| Что | Пин | Релиз | Лицензия | Почему | +|---|---|---|---|---| +| `golang.org/x/oauth2` | v0.36.0 | 11.02.2026 | BSD-3 | код-флоу + обмен + PKCE-хелперы; сопровождает Go-команда | +| `github.com/coreos/go-oidc/v3` | v3.20.0 | 08.07.2026 | Apache-2.0 | discovery, JWKS с рефетчем по неизвестному kid, проверка подписи и claims | + +Граф прирастает на 4 модуля (go-oidc · go-jose/v4 · x/oauth2 · compute/metadata). **Отвергнуты:** +`zitadel/oidc` (тянет второй роутер + otel + cors в stdlib-first граф), `markbates/goth` (последний +тег ~год, сидит на jwx v1.2 при живом v3, свой session-store конфликтует с нашим), self-hosted IdP +(Ory/Zitadel/Keycloak — второй stateful-сервис ради одного логина; Zitadel v4 к тому же AGPL), +hosted IdP (связка PII + доступность, а свою сессию всё равно держать ради учёта токенов). + +**Решения модели аккаунта (их не делает ни одна библиотека — ратифицированы здесь):** +1. Ключ личности — пара `(provider, sub)` в таблице `identities`. Почта — только ПОДСКАЗКА для + связывания и только при `email_verified` (Google прямо предупреждает: почта меняется, primary + identifier из неё делать нельзя). +2. ⚠ **Коллизия почты — дыра, найденная скептиком:** в `00001_identity.sql` `users.email` NOT NULL + с уникальным индексом по `lower(email)`. Что происходит, когда `(google, subX)` приносит почту, + уже занятую другим пользователем, и обновляем ли мы `users.email` при каждом входе — обязано быть + решено ДО первого входа: именно здесь тихий баг связывания становится захватом аккаунта. +3. Ротация идентификатора сессии на границе входа (защита от фиксации) — обязательна. +4. ⚠ **Журнал входов** (время, провайдер, класс устройства/IP) и ручка «отозвать все мои сессии» — + день-один для сервиса, продающего токены: таблица сессий чистится свипом и аудитом не является. +5. Первая НЕаутентифицированная ручка (`/auth/login`) — первая же поверхность для ботов: лимит + на исходящие state-записи обязателен вместе с ней. + +## 2. Деньги: фри-тир и покупка токенов (П-7) + +**Форма «как у OpenRouter» — предоплаченный кредитный леджер с резервацией ДО работы.** Проверено по +публичным докам: OpenRouter — предоплаченные кредиты + фри-тир как счётчик запросов; Anthropic — +кредиты + месячные потолки; Modal — фри-тир как ПЕРИОДИЧЕСКИЙ ГРАНТ в тот же леджер (самая чистая +механика: отдельного кода фри-тира не существует вовсе). Берём модель Modal для фри-тира. + +**Минимальная схема (строить с П-7, не раньше):** к существующему `usage_windows` — три таблицы: +`credit_ledger` (append-only, знаковые целые микро-доллары, типы `grant|purchase|hold|hold_release|settlement|adjustment`, +`UNIQUE(source, source_id)` как ключ идемпотентности), `reservations` (одна открытая на +`engine_run_id`), `account_balances` (кэш баланса в ТОЙ ЖЕ транзакции, что вставка в леджер, + +тест-инвариант `balance == SUM(ledger)`). Правки строк не существует по построению: ошибка чинится +новой записью. Дробей нет нигде — только целые микро-доллары. + +**Когда резервируем:** в той же транзакции Postgres, что допускает задачу в очередь, ДО спавна +`tmctl`; движку передаём пер-книжный потолок ≤ зарезервированного, и тогда ЕГО собственный +жёсткий стоп остаётся последним рубежом, а наша резервация может быть консервативной, а не +реал-таймовой. Расчёт — на границе попытки, идемпотентно по `(engine_run_id, attempt)`. + +**⚠ Открытый вопрос владельцу №1 (продуктовый, не технический): платить из России нечем.** Paddle +собственной политикой блокирует покупателей в России и Беларуси; у западных PSP compliant-пути нет. +Для продукта, чей целевой язык — русский, это не деталь выбора вендора, а вопрос рынка и способа +оплаты. Решать до того, как строить П-7. + +**Платёжный провайдер — НЕ выбираем сейчас** (пользователей ноль). Когда дойдёт: только +merchant-of-record, шорт-лист **Paddle vs Stripe Managed Payments** — ⚠ поправка скептика: у Stripe +с февраля 2026 есть собственный MoR-продукт (Stripe — зарегистрированный продавец, налоги в 80+ +странах), что снимает прежний аргумент «Paddle или сам плати VAT OSS»; Lemon Squeezy как независимый +игрок мёртв (куплен Stripe в 2024). Покупка проектируется как ОДНА запись леджера с id события PSP +в качестве ключа идемпотентности — тогда выбор вендора остаётся дешёвым. + +**Хвосты, которые нельзя забыть** (скептик): чарджбэк приходит после того, как токены потрачены ⇒ +леджер обязан переживать отрицательный баланс и иметь политику для прогонов в полёте · MoR продаёт +в валюте покупателя, а леджер — микро-доллары ⇒ либо фиксированные долларовые паки, либо правило FX +на момент покупки · движок отдаёт `committed_usd` как float64, а леджер целочисленный ⇒ правило +округления на шве расчёта назвать явно (леджер движка = НИЖНЯЯ граница, значит округляем вверх). + +**⚠ Открытый вопрос владельцу №2: источника денег ВО ВРЕМЯ попытки не существует.** Движок держит +эксклюзивный лок (`status --json` недоступен), его stderr-INFO с ценами парсить запрещено доктриной, +словарь событий денег не несёт. Варианты: **(A)** версионированное денежное событие в словаре строки +103 — свежесть до стадии, но трогает движок и отменяет действующую доктрину «поток цифр не несёт»; +**(B)** консервативные пер-книжные потолки, расчёт на границе попытки — ноль правок движка, +пользователь не может перерасходовать сверх суммы открытых холдов, цена — пере-резервация; +**(C)** нарезка попыток. **Рекомендация оркестратора: (B) сейчас, (A) — отдельным решением, если +замер покажет, что пере-резервация душит фри-тир.** Строить П-7 можно на (B) без ожидания ответа. + +## 3. Стандарты и стек: что взять, что не брать + +**Ответ на вопрос владельца «это же писали тысячу раз, есть хорошие библиотеки — так ведь?»** — да, +и наш стек уже взял правильные куски (pgx · goose · River · stdlib ServeMux/CSRF · slog · опаковые +сессии). Менять стек не надо; надо добавить четыре вещи, пока репозиторий — скелет: + +| Добавление | Пин | Статус | +|---|---|---| +| 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) | ⚠ **ратифицирован ПОРЯДОК, не выбор**: его поддержка 3.1 свежая ⇒ сперва доказательство ИСПОЛНЕНИЕМ на нашем файле 983 строк, вердикт после. Фолбэк — overlay 3.1→3.0, НЕ ogen (тянет otel-подграф) | +| `sqlc` для денежных/квотных таблиц | v1.31.1 | взять ДО того, как эти таблицы появятся: компиляционная проверка SQL на денежных путях, рантайм-зависимостей ноль | +| `golang.org/x/time/rate` | v0.15.0 | лимиты в процессе; долговечные пер-пользовательские — в Postgres | + +**Оставляем как есть:** stdlib-роутинг (1.22+ закрыл разрыв; `Request.Pattern` — с 1.23), +pgx без ORM, goose библиотекой, River (в `go.mod` не заводить, пока не подключён), «никакого Redis», +опаковые серверные сессии (OIDC их НЕ заменяет), stdlib-CSRF, slog, свой RFC 9457 problem+json, +zonky-стенд Postgres без root, пины линтера/govulncheck, изоляция графа зависимостей от движка. +**Не берём:** ORM · OpenTelemetry (позже: один сервис, одна БД, APM-бэкенда нет) · Temporal · Vault · +feature-flag-платформы · CodeQL (на приватном репо платный) · gosec отдельным гейтом · SBOM/SLSA — +церемония на этом размере. + +**Базовые линии безопасности:** OWASP **ASVS 5.0.0**, целевой **L2** — ⚠ поправка скептика к карте +глав: в 5.0 сессии это **V7**, аутентификация **V6**, и есть НОВАЯ глава **V10 «OAuth and OIDC»** — +именно она написана под наше направление (ресёрч цитировал нумерацию 4.0 и главу V10 пропустил). +Плюс OWASP API Security Top 10 (2023) — API1 BOLA это буквально проверка владения книгой на каждом +`/v0`-маршруте, API4 — наш PD-2. Плюс уже действующие govulncheck-гейт и пины Go-модулей с sumdb. +Dependabot — да (граф крошечный, шума нет). + +**Тест-пол — тот, что поймал бы дефекты этой приёмки** (иначе стандарт бесполезен): +1. Тест на РЕАЛЬНО сконфигурированном `http.Server`, а не на mux под `httptest`: полу-кормленный POST + обязан получить закрытие по `ReadTimeout`; SIGTERM обязан дренировать запросы (сегодня падают оба — + PD-2 и PD-9; ни один mux-тест этот класс не видит). +2. Инвариант сессии НЕЗАВИСИМЫМ оракулом: SHA-256 считается ВНЕ `auth.Digest`, плюс ассерт «плейнтекст + токена в БД не находится» (PD-1: свойство истинно, но моя посадка его не уронила). +3. Нативный фаззинг `go test -fuzz` на NDJSON-декодере, оракулы — инварианты PD-10. +4. Дисциплина посадок мутаций (`ENGINEERING_STANDARDS.md` §3.3) как постоянная проверка того, что + свойства ЗАПИНЕНЫ, а не просто истинны. +5. Контрактные тесты против OpenAPI-спеки и нагрузочные (k6) — при первых живых ручках, не раньше. + +**Деплой:** одна VM + systemd-юниты, бинари артефактами CI; Postgres сперва там же, управляемый — при +первой выручке. Не Kubernetes: дети-`tmctl` живут ЧАСАМИ, держат эксклюзивный лок на файлах книги на +локальном диске, и любой оркестратор, способный переселить под посреди прогона, нам враждебен. +Юниты писать сейчас — они же честный ответ на PD-13 (осиротевшие процессы движка): прогон в +cgroup-поддереве платформы, `KillMode=mixed`, `Restart=on-failure`, `LoadCredential=` для DSN и +клиентского секрета OAuth, `MemoryMax`, песочница `ProtectSystem`/`PrivateTmp` бесплатно. + +## 4. Скорость: что это требует от платформы и контракта + +Замер снял главный страх: **пер-главный объём крошечный** — ~3.4 тыс. символов исходника + ~10.2 тыс. +перевода ≈ 27 КБ строк UTF-16 на главу, а контракт уже читает юниты ПО ГЛАВЕ, подписывает банк +частями и отдаёт экспорт ссылкой. Риск не в экране чтения, а в четырёх местах, где сегодняшняя форма +вынудила бы клиента держать книгу целиком. Требования, которые платформа обязана исполнить: + +1. **Сжатие ответов.** stdlib `net/http` НЕ сжимает — значит все выкладки «289 КБ против 46 КБ gzip» + аспирационны, пока `Content-Encoding` не назван требованием (платформа или edge). Самая дешёвая + победа для «фронт должен летать»; исполнить с первыми списочными ручками. +2. **Условные запросы (`ETag`/`If-None-Match` → 304)** на списочных чтениях. Дополняет дельта-чтения + и, в отличие от них, работает для банка, который переписывается целиком. +3. **Юниты — только пер-главно, никогда пер-книжно.** Это несущее свойство памяти всего дизайна + (рабочий набор 27 КБ против ~62 МБ); одна «удобная» ручка обнулила бы его молча — ревью-вопрос на + каждое изменение контракта. +4. **Экспорт — артефакт по ссылке, сборка текста книги на клиенте запрещена явно** (иначе та же + проблема памяти заходит с чёрного хода на экране выгрузки). +5. **События потока сервер ВПРАВЕ склеивать** (рекомендация ~4 применения/с), клиент обязан терпеть + скачки счётчиков — иначе прогон на 9500 юнитов становится неограниченным источником рендера. +6. **Заголовок главы ограничить по длине на сервере** — единственная неограниченная строка в списке + из 5000 строк, всё остальное там целые числа. +7. **Поиск.** Пер-книжного поиска в контракте нет вовсе. Развилка: серверный полнотекстовый (Postgres) + · клиентский, но ТОЛЬКО в открытой главе / по списку заголовков (безопасен по памяти) · отложить. + Решать при экране поиска, не раньше. +8. **Живой канал для до-прогонных состояний** (`uploading`/`parsing`, библиотека без прогонов) — его + нет; при этом контракт запрещает фронту опрашивать read-модель. Дыра одинаково латентностная и + продуктовая; закрывается либо строкой в спеке «до старта прогона состояние опрашивается», либо + пользовательским потоком (он же закрыл бы пуш экспорта К-12). +9. **SSE поверх HTTP/1.1** упирается в лимит ~6 соединений на источник при нескольких вкладках — + на dev-стенде это выглядит как загадочное зависание; одна строка в доке стенда снимает класс. +10. **Персист манифеста глав (строка 100 единого бэклога) — несущий для латентности чтения:** только + он держит чтения на read-модели вместо 1.4–1.5 с ре-ингеста движка на книге 23 МБ. + +Бюджеты (в батарею фронта по мере появления экранов): DOM ≤1400 узлов на тяжёлом экране · +виртуализованный список ≤60 строк независимо от размера коллекции · медиана кадра прокрутки ≤17 мс, +p95 ≤33 мс · ни одной длинной задачи >200 мс на взаимодействие · страница списка ≤300 КБ · +стартовый JS-роут ≤200 КБ gzip · ≤4 применения событий в секунду. ⚠ Инструмент — расширение уже +существующего Playwright-харнесса (CDP-метрики + longtask), библиотеку `web-vitals` НЕ брать: +RUM в MVP нет, потребителя у неё не существует. diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index de40e684..aaed269e 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -19,6 +19,16 @@ ## Открытые вопросы к владельцу/оркестратору +**⚠ ВЛАДЕЛЬЦУ, добавлено приёмкой 05.08 (разбор — `PLATFORM_DIRECTION.md`):** +0а. **Платить из России нечем.** Paddle своей политикой блокирует покупателей из РФ и Беларуси, + compliant-пути у западных PSP нет. Для продукта с русским целевым языком это вопрос рынка и + способа оплаты, а не выбора вендора — решать ДО стройки П-7. +0б. **Источник денег во время попытки** (движок держит лок, stderr-INFO парсить запрещено, поток + цифр не несёт): (A) версионированное денежное событие в словаре строки 103 — трогает движок и + отменяет действующую доктрину · (B) консервативные потолки + расчёт на границе попытки — ноль + правок движка · (C) нарезка попыток. **Рекомендация: (B) сейчас**; строить П-7 можно, не дожидаясь. +0в. **Размер и форма фри-тира** (грант в тот же леджер — механика выбрана; число — за владельцем). + 1. **⚠ ВЛАДЕЛЬЦУ (П-5).** При сбросе окна лимитов приостановленный (`paused`) перевод продолжается САМ или ждёт явного «Продолжить»? От ответа зависит, есть ли кнопка на экране и нужно ли уведомление «продолжили без вас». Технически дёшевы оба варианта.