diff --git a/docs/backlog/mcp-per-user-auth.md b/docs/backlog/mcp-per-user-auth.md new file mode 100644 index 00000000..a2fdc77f --- /dev/null +++ b/docs/backlog/mcp-per-user-auth.md @@ -0,0 +1,416 @@ +# Встроенный `/mcp`: авторизация под текущим пользователем (а не сервисным аккаунтом) + +Статус: **план, код не менялся.** Фича сервер (`apps/server` + `packages/mcp`). +Затрагивает безопасность — менять аккуратно. + +**Решение принято: основной путь — логин/пароль текущего пользователя через +HTTP Basic** (`Authorization: Basic base64(email:password)`). Токен-варианты +(Bearer access-JWT / community PAT / OAuth) описаны ниже как альтернативы и +возможные доработки, но делаем именно логин/пароль. + +## Суть + +Сейчас встроенный MCP-сервер на `/mcp` ходит в Docmost **под одним сервисным +аккаунтом** (`MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD`). Любой клиент, +подключившийся к `/mcp`, действует с правами этого аккаунта — независимо от того, +кто реально сидит за MCP-клиентом. Это значит: единые CASL-права на всех, нет +атрибуции правок конкретному человеку (в истории страниц всё — от сервисного +юзера), и без env-кредов фича вообще не поднимается (отдаёт `503 "MCP is not +configured"`). + +Хотим: чтобы `/mcp` авторизовался **под текущим пользователем** (его логином и +паролем) — тогда каждый запрос исполняется под его CASL-правами, правки +атрибутируются ему, и сервисный аккаунт перестаёт быть обязательным. + +## Почему сейчас сервисный аккаунт (контекст) + +`/mcp` — **внешний протокольный эндпоинт** (MCP Streamable-HTTP / JSON-RPC). В +сессии MCP нет личности Docmost: сессия идентифицируется случайным UUID +([http.ts:68-74](packages/mcp/src/http.ts#L68-L74), `sessionIdGenerator: () => +randomUUID()`) и заголовком `mcp-session-id`, а транспорт **не несёт JWT/куку +пользователя**. Поэтому пакет `@docmost/mcp` спроектирован как standalone-клиент: +логинится один раз по `email/password` ([auth-utils.ts:41-86](packages/mcp/src/lib/auth-utils.ts#L41-L86), +достаёт куку `authToken`) и дальше ходит в REST + collab как обычный внешний +клиент. + +Контраст — встроенный AI-чат: он крутится **внутри авторизованного NestJS-запроса**, +поэтому чеканит loopback-токен именно текущего пользователя и каждый инструмент +исполняется под его CASL ([ai-chat-tools.service.ts:54-85](apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts#L54-L85)). +Наша задача — принести эту же модель «per-user токен» во внешний `/mcp`. + +**Хорошая новость: клиентская половина уже готова.** `DocmostClient` принимает +union-конфиг — либо `{email,password}` (сервис-аккаунт, вызывает `performLogin`), +либо `{getToken}` (берёт **готовый bare-JWT** пользователя как Bearer и **не** +логинится) ([client.ts:99-160](packages/mcp/src/client.ts#L99-L160), +[client.ts:223-241](packages/mcp/src/client.ts#L223-L241)). Этот `getToken`-вариант +уже используется внутренним AI-чатом. Не хватает только **связки в самом +`/mcp`-хендлере** — он сейчас строит конфиг статически из env. + +## Где сейчас живёт код (точные места) + +### Хендлер `/mcp` (NestJS-обвязка) +- [mcp.service.ts:114-144](apps/server/src/integrations/mcp/mcp.service.ts#L114-L144) + `handle(req, res)`: (1) опц. статичный гард `MCP_TOKEN` против + `Authorization: Bearer` (стр. 118-125); (2) `isEnabled()` — тумблер воркспейса + `ai.mcp` (403 если выкл.); (3) `credsConfigured()` — наличие env-кредов (**это + и есть источник твоего `503`**, стр. 132-144); (4) `res.hijack()` и проброс + raw req/res в MCP-транспорт. +- [mcp.service.ts:47-64](apps/server/src/integrations/mcp/mcp.service.ts#L47-L64) + `getEmail/getPassword/getApiUrl/credsConfigured` — читают env. +- [mcp.service.ts:85-112](apps/server/src/integrations/mcp/mcp.service.ts#L85-L112) + `getHandler()` — лениво создаёт **один** HTTP-handler через + `createMcpHttpHandler({apiUrl,email,password})` и кэширует его. + +### MCP-пакет +- [http.ts:13](packages/mcp/src/http.ts#L13) `createMcpHttpHandler(config: + DocmostMcpConfig)` — принимает **один статический** конфиг; создаёт по + `McpServer` + транспорту **на каждую сессию** при `initialize` + ([http.ts:68-82](packages/mcp/src/http.ts#L68-L82): `createDocmostMcpServer(config)` + → `server.connect(transport)`). Идентичность сессии фиксируется здесь, на + инициализации. +- [index.ts:50-54](packages/mcp/src/index.ts#L50-L54) `createDocmostMcpServer(config)` + — пробрасывает union-конфиг в `new DocmostClient(config)`. +- [client.ts:99-160](packages/mcp/src/client.ts#L99-L160) `DocmostMcpConfig` = + `{email,password} | {getToken}` (+ опц. `getCollabToken`); конструктор + ветвится: `getToken`-вариант не логинится, использует bare-JWT как Bearer. + +### Auth / токены (сервер) +- [token.service.ts:30-54](apps/server/src/core/auth/services/token.service.ts#L30-L54) + `generateAccessToken(user, sessionId, provenance?)` → JWT `type=ACCESS`. +- [token.service.ts:119-138](apps/server/src/core/auth/services/token.service.ts#L119-L138) + `generateApiToken({apiKeyId,user,workspaceId,expiresIn})` → JWT `type=API_KEY`. +- [token.service.ts:164-176](apps/server/src/core/auth/services/token.service.ts#L164-L176) + `verifyJwt(token, type)` — проверка подписи + типа. +- [jwt.strategy.ts:26-34](apps/server/src/core/auth/strategies/jwt.strategy.ts#L26-L34) + `jwtFromRequest = cookie authToken || Bearer` — **bearer уже принимается** на + `/api`. +- [jwt.strategy.ts:80-81](apps/server/src/core/auth/strategies/jwt.strategy.ts#L80-L81) + провенанс: токен без `actor` → `'user'` (нам и нужно — правки как пользователя). +- [jwt.strategy.ts:86-109](apps/server/src/core/auth/strategies/jwt.strategy.ts#L86-L109) + `validateApiKey` — путь `type=API_KEY` **требует EE-модуль** + (`ee/api-key/api-key.service`), которого в форке нет → бросает «Enterprise API + Key module missing». То есть полноценных PAT сейчас **нет**. +- [auth.controller.ts:184-193](apps/server/src/core/auth/auth.controller.ts#L184-L193) + `POST /auth/collab-token` под `JwtAuthGuard` — выдаёт collab-токен по + bearer/cookie (этим уже пользуется и сервис-аккаунт, и AI-чат). +- [environment.service.ts:63-64](apps/server/src/integrations/environment/environment.service.ts#L63-L64) + `JWT_TOKEN_EXPIRES_IN` по умолчанию **`90d`** — access-JWT долгоживущий, годится + как «токен пользователя». +- [utils.ts:109](apps/server/src/common/helpers/utils.ts#L109) + `extractBearerTokenFromHeader(req)` — переиспользуемый парсер `Authorization`. +- [migration 20250912T101500-api-keys.ts](apps/server/src/database/migrations/20250912T101500-api-keys.ts) + — таблица `api_keys` (`id, name, creator_id, workspace_id, expires_at, + last_used_at, deleted_at`) **уже существует**, но community-сервиса под неё нет. +- [.env.example:72-79](.env.example#L72) — `MCP_DOCMOST_EMAIL/PASSWORD`, + `MCP_DOCMOST_API_URL`, `MCP_TOKEN`, `MCP_SESSION_IDLE_MS`. + +## Как именно логиниться под пользователем — варианты + +Пользователь подключает к `/mcp` внешний MCP-клиент (Claude Desktop и т.п.). +Авторизоваться «под текущим пользователем» можно несколькими путями с разной +ценой и безопасностью. Все они сводятся к одному и тому же на уровне клиента: +получить пользовательский JWT и ходить под ним; разница — **откуда** берётся +токен (приносит пользователь / логинит сервер / выдаёт OAuth). + +### Вариант L — логин/пароль пользователя через HTTP Basic ✅ ВЫБРАН +MCP-клиент шлёт `Authorization: Basic base64(email:password)`; `/mcp` декодит и +строит per-session конфиг `{email, password}` → `DocmostClient` сам делает +`performLogin` (`POST /auth/login`) и дальше ходит под этим пользователем. Это +**ровно тот же путь, что у сервисного аккаунта сегодня**, только с кредами +текущего пользователя — клиентская механика уже готова +([client.ts:99-160](packages/mcp/src/client.ts#L99-L160), +[auth-utils.ts:41-86](packages/mcp/src/lib/auth-utils.ts#L41-L86)). + +- **Плюсы:** минимум нового кода (переиспользуется `{email,password}`-ветка + `performLogin`); пользователю не надо доставать токен — привычные логин/пароль; + сервисный аккаунт становится необязательным. +- **Минусы:** **сырой пароль лежит в конфиге MCP-клиента** и уходит на сервер при + каждом коннекте (токен безопаснее — отзываем/скоупится без смены пароля); + **не работает с MFA** (статические креды не пройдут интерактивный челлендж) — + в этом форке MFA-модуль удалён (EE), поэтому сейчас вопрос моот, но при + возврате MFA или `workspace.enforceMfa` ([auth.controller.ts:64-103](apps/server/src/core/auth/auth.controller.ts#L64-L103)) + путь сломается; **SSO/OIDC**-пользователи могут не иметь локального пароля; + логин жмёт `/auth/login` throttle ([AUTH_THROTTLER](apps/server/src/core/auth/auth.controller.ts#L41), + раз на сессию + переавторизация на 401). +- **Вывод:** хорош для single-user self-host без MFA; как дефолт лучше токен. + +### Вариант A — pass-through access-JWT (альтернатива / возможна параллельно) +MCP-клиент шлёт `Authorization: Bearer `, где токен — это значение +куки `authToken` пользователя (валиден 90 дней). `/mcp` извлекает его, валидирует +как `ACCESS`-JWT и передаёт в `DocmostClient` как `getToken`. Все REST + collab +идут под CASL этого пользователя; правки атрибутируются ему (`actor='user'`). + +- **Плюсы:** минимальный диф, переиспользует уже готовый `getToken`-путь клиента; + bearer уже принимается на `/api`; сервисный аккаунт становится необязательным. +- **Минусы:** токен надо достать руками (DevTools → Cookies → `authToken`), + токен привязан к сессии (логаут/revoke сессии убивает его), он же даёт полный + доступ как у пользователя (не сужен скоупом). Приемлемо для self-host, но это + не «красивый» PAT. + +### Вариант B — community PAT / API-keys (доработка на будущее) +Реализовать сообществом то, что было в EE: пользователь создаёт в настройках +**именованный, отзываемый, с TTL** персональный токен; его и кладёт в MCP-клиент. + +- Таблица `api_keys` уже есть; `JwtApiKeyPayload`+`generateApiToken` есть; не + хватает **community `ApiKeyService`** (хранить хеш/строку ключа, валидировать + по `apiKeyId` из JWT, обновлять `last_used_at`, проверять `expires_at`/ + `deleted_at`) + CRUD-эндпоинты + UI выдачи/отзыва. +- Поправить [jwt.strategy.ts:86-109](apps/server/src/core/auth/strategies/jwt.strategy.ts#L86-L109): + путь `API_KEY` должен звать community-сервис вместо `require('./../../../ee/...')`. +- **Плюсы:** стабильный, отзываемый, именованный токен; не завязан на браузерную + сессию; виден и управляем в UI. Это «правильный» долгоживущий ответ. +- **Минусы:** заметно больше работы (сервис + контроллер + миграция типов + UI), + и это самостоятельная фича auth, шире чем сам `/mcp`. + +### Вариант C — OAuth 2.1 для MCP (доработка на будущее, «с логином» из коробки) +MCP-спека описывает авторизацию через OAuth 2.1: Docmost поднимает +authorization-server metadata + token endpoint, а MCP-клиент (Claude Desktop) +делает **интерактивный логин** и сам получает токен — это и есть «mcp с логином». + +- **Плюсы:** самый стандартный и удобный UX (логин в браузере, без копипасты + токенов), refresh из коробки. +- **Минусы:** самый большой объём (discovery-эндпоинты, согласие, refresh, + привязка к существующему JWT-стеку). Избыточно для текущего запроса. + +> **Решение:** делаем **L** (логин/пароль через HTTP Basic) основным и +> единственным путём на этот заход. Это закрывает «авторизация под текущим +> пользователем» минимальным кодом (переиспользуется `performLogin`) и привычным +> для пользователя способом — логин/пароль. **A/B/C** оставляем в доке как +> совместимые доработки на будущее: все варианты сходятся в одной точке — +> per-session `DocmostClient` под пользовательским JWT, отличается лишь источник +> токена (`performLogin` от сервера / Bearer от пользователя / PAT / OAuth), так +> что добавить их позже можно поверх той же связки без переделки. + +## Детальный дизайн выбранного пути — логин/пароль (HTTP Basic) + +Идея: вместо **одного статического** конфига хендлер получает **резолвер конфига +от запроса**, который на инициализации каждой MCP-сессии решает, под кем ходить. +Для выбранного пути резолвер читает `Authorization: Basic`, **валидирует +логин/пароль на сервере** и строит per-session `DocmostClient`, ходящий под этим +пользователем. + +### 1) `packages/mcp/src/http.ts` — принять резолвер конфига +```ts +// Accept either a static config (service-account / stdio, unchanged) OR a +// per-request resolver. The resolver runs once per MCP session, at initialize, +// so the session's DocmostClient is bound to that request's identity. +export type McpConfigResolver = ( + req: IncomingMessage, +) => DocmostMcpConfig | Promise; + +export function createMcpHttpHandler( + config: DocmostMcpConfig | McpConfigResolver, +) { /* ... */ } + +// inside handleRequest, at session init (POST initialize, http.ts:68-82): +const sessionConfig = + typeof config === "function" ? await config(req) : config; +const server = createDocmostMcpServer(sessionConfig); +``` +Обратная совместимость полная: stdio ([stdio.ts](packages/mcp/src/stdio.ts)) и +существующий вызов с объектом-конфигом работают как раньше (это не функция → +ветка `else`). + +### 2) `apps/server/.../mcp.service.ts` — разобрать Basic, провалидировать креды, выпустить токен +Креды валидируем **на сервере** через `AuthService.login` и в конфиг кладём +**уже выпущенный пользовательский JWT** (`getToken`-вариант), а не сам пароль — +тогда пароль не уходит дальше в loopback-клиент, а ошибки логина видны сразу, +чистым JSON-ответом до `res.hijack()`. +```ts +// Resolve the per-session identity from the request. Primary path: HTTP Basic +// (current user's email:password) -> validate on the server -> issue the user's +// JWT -> client acts as that user. Bearer (variant A) and the service account +// (back-compat) are accepted as fallbacks. +private async resolveSessionConfig(req): Promise { + const auth = req.headers['authorization'] as string | undefined; + + // --- chosen path: Basic login/password --- + if (auth?.startsWith('Basic ')) { + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); + const sep = decoded.indexOf(':'); // password may contain ':' + const email = decoded.slice(0, sep); + const password = decoded.slice(sep + 1); + // Single-workspace assumption (loopback) — same as the AI-chat tools path. + const workspace = await this.workspaceRepo.findFirst(); + // Throws UnauthorizedException('Email or password does not match') on bad + // creds -> surfaced as a specific 401 (never a generic error). NOTE: calling + // AuthService.login directly BYPASSES the controller's throttle + MFA gate + // (both EE/controller-level) — see Security below. + const authToken = await this.authService.login({ email, password }, workspace.id); + return { apiUrl: this.getApiUrl(), getToken: async () => authToken }; + } + + // --- fallback A: Bearer access-JWT (user-supplied token) --- + const bearer = extractBearerTokenFromHeader(req); // utils.ts:109 + if (bearer) { + await this.tokenService.verifyJwt(bearer, JwtType.ACCESS); // specific 401 + return { apiUrl: this.getApiUrl(), getToken: async () => bearer }; + } + + // --- fallback B: service account (existing behaviour, optional) --- + if (this.credsConfigured()) { + return { apiUrl: this.getApiUrl(), email: this.getEmail()!, password: this.getPassword()! }; + } + + throw new UnauthorizedException( + 'MCP requires Basic auth (email:password) or a Bearer token, ' + + 'or a configured MCP_DOCMOST_EMAIL/PASSWORD service account.', + ); +} +``` +- `getHandler()` зовёт `createMcpHttpHandler((req) => this.resolveSessionConfig(req))` + (резолвер, не статический объект). +- Auth-разбор (Basic decode + `AuthService.login` / `verifyJwt`) делать в + `handle()` **до** `res.hijack()`, чтобы на плохих кредах вернуть чистый + `401 {error: "..."}`, а не рвать hijack-нутый ответ. Резолвер тогда может + просто отдать уже посчитанный конфиг (напр. через `(req.raw as any).__mcpConfig`). +- Проверку `credsConfigured()` (стр. 132-144) **заменить** на «есть Basic ИЛИ + Bearer ИЛИ env-креды», иначе осмысленный `401/503` (не глотать). +- Инжектнуть в `McpService` `AuthService` (для `login`) и `TokenService` (для + `verifyJwt` в fallback A); `WorkspaceRepo` уже есть. Подтянуть нужные модули в + `integrations/mcp`. + +### 3) Гард `MCP_TOKEN` — развести с пользовательскими кредами +Сейчас `MCP_TOKEN` едет в `Authorization: Bearer` +([mcp.service.ts:118-125](apps/server/src/integrations/mcp/mcp.service.ts#L118-L125)). +В per-user режиме `Authorization` занят кредами/токеном пользователя. Решение: +- в per-user режиме **убрать** статичный `MCP_TOKEN`-гард на `Authorization` + (аутентификацией служат сами креды; эндпоинт по-прежнему закрыт тумблером + воркспейса и сетевой изоляцией), **или** +- если нужен доп. общий шлагбаум — перенести `MCP_TOKEN` в **отдельный заголовок** + (`X-MCP-Token`), чтобы не конфликтовал с `Authorization`. + +### 4) Collab / провенанс — ничего лишнего не нужно +`getCollabToken`-провайдер **не задаём**: `DocmostClient` сам сходит в +`POST /auth/collab-token` с выпущенным пользовательским JWT +([auth.controller.ts:184-193](apps/server/src/core/auth/auth.controller.ts#L184-L193)) +и получит обычный пользовательский collab-токен. Так правки через collab +атрибутируются пользователю (`actor='user'` по умолчанию, +[jwt.strategy.ts:80-81](apps/server/src/core/auth/strategies/jwt.strategy.ts#L80-L81)). +Никакого «AI agent»-бейджа здесь не вешаем — это живой человек. + +> **Альтернатива по объёму (если не хочется тянуть `AuthService` в McpService):** +> отдать креды как есть в конфиг `{ email, password }` — `DocmostClient` сам +> сделает `performLogin` по loopback (это буквально путь сервис-аккаунта). Минус: +> пароль идёт в loopback-клиент и ошибка логина всплывает позже, из пакета, после +> hijack. Серверная валидация (вариант выше) чище и безопаснее — её и берём. + +## Тонкие моменты / edge cases + +- **Идентичность привязана к сессии.** `DocmostClient` создаётся один раз на + MCP-сессию (на `initialize`) и кэширует токен; последующие запросы той же + `mcp-session-id` пойдут под пользователем, зафиксированным при инициализации. + Грань безопасности: на повторных запросах **проверять, что предъявленные креды/ + токен резолвятся в того же пользователя** (`email`/`sub`), что и при инициализации + сессии, иначе `401` — чтобы нельзя было «подсесть» в чужую сессию (session + fixation / подмена кред). +- **Новая Docmost-сессия на каждый логин.** `AuthService.login` → + `sessionService.createSessionAndToken` ([auth.service.ts:97](apps/server/src/core/auth/services/auth.service.ts#L97)) + создаёт **запись пользовательской сессии** на каждый MCP-логин. При частых + реконнектах сессии копятся (idle-eviction MCP-сессий их не чистит). Прикинуть: + переиспользовать токен в пределах MCP-сессии (одна сессия = один логин, уже так), + и/или TTL/чистку висящих сессий — отдельной заботой. +- **Истечение токена.** Выпущенный access-JWT живёт 90 дней — на 401 от loopback + клиент перезайдёт. Удобство Basic: креды у клиента постоянны, поэтому + переавторизация прозрачна (повторный `login`), в отличие от вручную вставленного + токена. Опционально — per-session mutable-холдер токена, чтобы переавторизация + не пересоздавала MCP-сессию. +- **Откат на сервис-аккаунт.** Сохранить как опцию (нет bearer + есть env-креды → + старое поведение). Это не ломает существующие инсталляции и даёт «безличный» + режим, где он нужен (CI, скрипты). Если откат нежелателен — сделать его + переключаемым (`MCP_REQUIRE_USER_TOKEN=true`). +- **Мульти-тенантность / loopback.** `127.0.0.1` не резолвит воркспейс по + субдомену → таргетится дефолтный воркспейс (та же single-workspace-оговорка, + что и у сервис-аккаунта и AI-чата, см. + [ai-chat-tools.service.ts:25-28](apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts#L25-L28)). + `jwt.strategy` сверяет `req.raw.workspaceId` с `payload.workspaceId` + ([jwt.strategy.ts:41-43](apps/server/src/core/auth/strategies/jwt.strategy.ts#L41-L43)); + на loopback `req.raw.workspaceId` не выставлен → проверка проходит. Для + мульти-воркспейс деплоя нужен явный workspace-скоуп (отдельная задача). +- **Idle-eviction.** Сессии чистятся по `MCP_SESSION_IDLE_MS` (30 мин) + ([http.ts:21-39](packages/mcp/src/http.ts#L21-L39)) — без изменений; protected + per-user сессии тоже истекают по бездействию, это ок. +- **Ошибки не глотать.** Невалидный/просроченный токен → `console`/logger с + полной ошибкой **и** конкретный текст в ответе (реальная причина), не «MCP + error» (CLAUDE.md «Errors must never be swallowed»). Текущее одноразовое + warning про отсутствие кредов — оставить/адаптировать. +- **Логи/PII.** Не логировать сам токен. Сейчас `auth-utils` прячет тело ответа + за `DEBUG` — сохранить этот принцип. + +## Безопасность (на ревью проверить отдельно) + +- **Прямой `AuthService.login` обходит throttle и MFA-гейт.** Контроллерный + `/auth/login` защищён `ThrottlerGuard` и (в EE) MFA-проверкой + ([auth.controller.ts:41](apps/server/src/core/auth/auth.controller.ts#L41), + [:64-103](apps/server/src/core/auth/auth.controller.ts#L64-L103)); вызывая + `authService.login` напрямую, мы их минуем. Следствия: (1) **brute-force через + `/mcp`** — добавить свой rate-limit на неудачные логины `/mcp` (по IP/почте); + (2) если MFA когда-либо вернётся/`enforceMfa` — Basic-путь должен **повторить + MFA-гейт или быть запрещён** для MFA-пользователей, а не молча пускать. +- **Креды в логах/трейсах.** Никогда не логировать `Authorization`, decoded + `email:password` и тело ответа логина (`auth-utils` уже прячет тело за `DEBUG` + — держать тот же принцип). На ошибке логина — конкретный `401`, но без эха + пароля. +- Per-user CASL: убедиться, что **все** инструменты идут только через loopback + REST/collab под пользовательским JWT и нигде не остаётся фолбэка на + сервис-аккаунт внутри уже инициализированной per-user сессии. +- Привязка к сессии (см. edge case) — анти-fixation проверка `email`/`sub`. +- `MCP_TOKEN`-развод: не оставить «дыру», где `Authorization` молча игнорируется. +- SSO/OIDC-пользователи без локального пароля: Basic для них не сработает — + вернуть понятный `401`, а не generic (и направить на токен-путь, если он есть). +- Доработка B (PAT): ключ хранить **хешем**, `last_used_at` обновлять, отзыв + (`deleted_at`) и `expires_at` проверять в `validateApiKey`. + +## Миграции / конфиг / env / docs + +- **Выбранный путь (Basic):** миграций нет. Обновить + [.env.example:72-79](.env.example#L72): пометить `MCP_DOCMOST_EMAIL/PASSWORD` + как **опциональные** (теперь это фолбэк-сервис-аккаунт, а не обязательный), + описать per-user Basic-режим и (если выбран) `X-MCP-Token`/ + `MCP_REQUIRE_USER_TOKEN`. Обновить README: как прописать в MCP-клиенте + `Authorization: Basic` (свои email:password) — у клиентов это обычно поле + «headers» в конфиге сервера. +- **Доработка B (PAT):** `api_keys` таблица уже есть; добавить типы в `db.d.ts` + (`migration:codegen`), при необходимости — индексы; новый модуль/сервис/контроллер + и клиентский UI в `apps/client/src/features/.../settings`. + +## Тесты / проверка + +- **Сервер (`pnpm --filter server test`):** + - `mcp.service` резолвер: `Basic email:password` → `AuthService.login` зовётся + с дефолтным воркспейсом → `getToken`-конфиг с выпущенным токеном; неверные + креды → `401` с конкретным сообщением (не generic); Bearer-fallback → + `verifyJwt(ACCESS)`; нет ничего + есть env-креды → сервис-аккаунт; нет ничего + → осмысленный 401/503. + - **пароль с `:`** парсится корректно (split по первому `:`). + - анти-fixation: второй запрос с кредами другого пользователя в той же сессии + → 401. +- **MCP-пакет (`pnpm --filter @docmost/mcp test`):** `createMcpHttpHandler` + принимает и статический конфиг, и резолвер; резолвер зовётся один раз на + инициализацию сессии; статический путь (stdio/сервис-аккаунт) не задет. +- **Ручная:** прописать в MCP-клиенте `Authorization: Basic base64(email:pass)` + своего юзера → проверить, что (1) видны только доступные пользователю спейсы/ + страницы (CASL), (2) правки в истории атрибутируются этому пользователю, а не + сервисному, (3) без env-кредов `/mcp` работает по логину/паролю, (4) неверный + пароль → понятная ошибка, а не generic, (5) залогировано без утечки пароля. + +## Открытые вопросы + +1. ~~Какой путь делаем~~ — **решено: логин/пароль через HTTP Basic** (вариант L). + A/B/C — совместимые доработки на будущее. +2. **Сервис-аккаунт:** оставить как откат (нет Basic/Bearer → старое поведение) + или полностью убрать в пользу обязательного per-user логина + (`MCP_REQUIRE_USER_TOKEN`)? +3. **`MCP_TOKEN`:** убрать в per-user режиме или перенести в отдельный заголовок + `X-MCP-Token` как доп. общий шлагбаум? +4. **Brute-force / throttle:** добавлять ли свой rate-limit на неудачные логины + `/mcp` (прямой `AuthService.login` минует контроллерный `ThrottlerGuard`)? +5. **Накопление сессий:** нужно ли чистить/ограничивать Docmost-сессии, создаваемые + `AuthService.login` на каждый MCP-логин, или достаточно «одна MCP-сессия = один + логин»? +6. **Серверная валидация vs pass-through:** валидировать креды через + `AuthService.login` (чище/безопаснее, тянет сервис в McpService) или отдать + `{email,password}` в `performLogin` пакета (минимум кода)? В дизайне выбрана + серверная валидация. +7. **Мульти-воркспейс:** loopback таргетит дефолтный воркспейс (как у AI-чата). + Нужен ли явный workspace-скоуп для мульти-тенант деплоя — или отдельная задача? diff --git a/docs/backlog/tree-expand-collapse-all.md b/docs/backlog/tree-expand-collapse-all.md new file mode 100644 index 00000000..0fce6da1 --- /dev/null +++ b/docs/backlog/tree-expand-collapse-all.md @@ -0,0 +1,301 @@ +# Дерево страниц: кнопки «Развернуть всё» / «Свернуть всё» + +Статус: **план, код не менялся.** Фича клиент+сервер. По решению владельца выбран +**серверный путь**: эндпоинт отдаёт **всё поддерево/всё дерево спейса разом** +(«отдать всё»), а клиент за один-два запроса разворачивает дерево целиком. От +клиентского рекурсивного обхода по одному уровню — отказались (см. «Почему так»). + +## Суть + +В сайдбаре спейса (дерево «Pages») сейчас узлы разворачиваются/сворачиваются +только поодиночке кликом по шеврону. Есть шорткат `*` (разворачивает **сиблингов** +сфокусированного узла, паттерн WAI-ARIA tree), но глобального «развернуть/свернуть +всё дерево» нет. + +Хотим: две команды в шапке дерева — **«Развернуть всё»** (раскрыть все ветки +текущего спейса) и **«Свернуть всё»** (схлопнуть до корней). Это навигационная +операция над видом — прав на запись не требует, доступна любому, кто видит спейс. + +## Почему так (выбор архитектуры) + +Дети узлов **загружаются лениво, по одному уровню**: у свёрнутой ветки +`hasChildren === true`, но `children === []`, а эндпоинт `/pages/sidebar-pages` +отдаёт **только прямых детей** одного `pageId`. «Развернуть всё» поверх такого +API = рекурсивный BFS на десятки-сотни HTTP-запросов (шторм запросов, лимиты, +долгий индикатор, защитный потолок). Это и был отвергнутый вариант. + +**Решение — отдать всё одним запросом на сервере.** У бэкенда уже есть готовые +кирпичи для рекурсивной выборки поддерева с учётом прав (используются в +`movePageToSpace`): +- `pageRepo.getPageAndDescendants(parentPageId, { includeContent: false })` + ([page.repo.ts:557](apps/server/src/database/repos/page/page.repo.ts#L557)) — + рекурсивный CTE: страница + все потомки одним запросом. +- `pageRepo.getPageAndDescendantsExcludingRestricted(parentPageId, opts)` + ([page.repo.ts:612](apps/server/src/database/repos/page/page.repo.ts#L612)) — + то же, но **обрезает закрытые (restricted) поддеревья прямо в SQL** (один + запрос, не тянет лишнее). +- `pageService.filterAccessibleTreePages(allPages, rootId, userId, spaceId)` + ([page.service.ts:1136](apps/server/src/core/page/services/page.service.ts#L1136)) + — точечная фильтрация дерева по правам с сохранением целостности (для + per-page permissions сверх restricted-спейсов). +- `pageRepo.withHasChildren(eb)` + ([page.repo.ts:539](apps/server/src/database/repos/page/page.repo.ts#L539)) — + вычисление `hasChildren` в SQL (при отдаче всего дерева `hasChildren` можно и + вывести на клиенте — у узла есть дети, если в ответе есть страница с + `parentPageId === id`). + +Плюсы серверного пути: один-два запроса вместо сотен; предсказуемо даже на +тысячах страниц; права считаются на сервере (единый источник правды); на клиенте +нет BFS/ограничителя параллелизма/защитного потолка. Минус — нужна работа на +бэкенде (новый рекурсивный режим эндпоинта) и контроль размера ответа. + +## Где сейчас живёт код (точные места) + +### Клиент — фича `apps/client/src/features/page/tree/` +- **Состояние раскрытия** — + [open-tree-nodes-atom.ts](apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts): + `openTreeNodesAtom`, тип `OpenMap = Record` (id → раскрыт ли), + **персист в localStorage**, ключ `openTreeNodes:{workspaceId}:{userId}`. + ⚠ **Карта общая для всех спейсов воркспейса.** +- **Данные дерева** — + [tree-data-atom.ts](apps/client/src/features/page/tree/atoms/tree-data-atom.ts): + `treeDataAtom: SpaceTreeNode[]`, накопительно по спейсам; на рендере + фильтруется по `spaceId`. +- **Модель узла** — + [types.ts](apps/client/src/features/page/tree/types.ts): `SpaceTreeNode` + (`id`, `spaceId`, `hasChildren`, `children`, `name`, `icon`, `position`, + `parentPageId`, `canEdit`, `slugId`). +- **Обёртка/тоггл/загрузка** — + [space-tree.tsx](apps/client/src/features/page/tree/components/space-tree.tsx): + `filteredData` (стр. 184-187, узлы текущего спейса), `handleToggle` (стр. + 164-182, ленивая загрузка уровня), `spaceIdRef` (стр. 46-47, защита от гонок). +- **Модель-операции** — + [tree-model.ts](apps/client/src/features/page/tree/model/tree-model.ts): + `find`, `appendChildren`, `visible`, `siblingsOf`. +- **HTTP-загрузка** — + [page-query.ts](apps/client/src/features/page/queries/page-query.ts) + + [page-service.ts](apps/client/src/features/page/services/page-service.ts): + `getSidebarPages` / `getAllSidebarPages` (паджинируют **один уровень**), + `fetchAllAncestorChildren`, утилиты `buildTree` / `buildTreeWithChildren` / + `mergeRootTrees` ([utils.ts](apps/client/src/features/page/tree/utils/utils.ts)). +- **Шапка дерева (куда вешать команды)** — + [space-sidebar.tsx:117-149](apps/client/src/features/space/components/sidebar/space-sidebar.tsx#L117): + `SpaceMenu` (дропдаун на `IconDots`, стр. 172-281, уже с `Menu.Item`/ + `Menu.Divider`) + кнопка «+» (Create page). + +### Сервер — фича `apps/server/src/core/page/` +- **Эндпоинт сайдбара** — + [page.controller.ts:540](apps/server/src/core/page/page.controller.ts#L540) + `POST /pages/sidebar-pages` (`SidebarPageDto`: `spaceId | pageId`), + CASL-скоуп на спейс, отдаёт **один уровень**. +- **Сервис** — + [page.service.ts:304](apps/server/src/core/page/services/page.service.ts#L304) + `getSidebarPages(spaceId, pagination, pageId?, userId?, spaceCanEdit?)`: + выборка одного уровня + `withHasChildren` + **двухветочная фильтрация прав** — + если в спейсе нет ограничений (`pagePermissionRepo.hasRestrictedPagesInSpace`) + → `canEdit = spaceCanEdit`; иначе per-page фильтр через + `filterAccessiblePageIdsWithPermissions` + корректировка `hasChildren` по + `getParentIdsWithAccessibleChildren`. **Эту же логику прав надо повторить в + рекурсивном режиме.** + +## Решение + +### Серверная часть — «отдать всё поддерево» одним запросом + +Добавить рекурсивный режим выдачи дерева. Варианты оформления (выбрать на ревью): +- флаг `recursive: true` (и опц. `depth`) к существующему `POST /pages/sidebar-pages`, **или** +- отдельный эндпоинт `POST /pages/tree` (`{ spaceId }` → всё дерево спейса; + `{ pageId }` → всё поддерево страницы). + +Контракт ответа: **плоский список элементов в точно том же shape, что и текущий +`/pages/sidebar-pages`** (`id`, `slugId`, `title`, `icon`, `position`, +`parentPageId`, `spaceId`, `hasChildren`, `canEdit`), чтобы клиентские +`buildTree`/`buildTreeWithChildren` собрали дерево без изменений. Порядок — по +`position` (collate "C"), как сейчас. + +Сервисный метод (эскиз), переиспользует существующие кирпичи: +```ts +// Whole subtree (pageId) or whole space tree (spaceId only) in a single query, +// permission-filtered, returned as a flat list matching the sidebar item shape. +async getSidebarPagesTree(spaceId, userId, spaceCanEdit, pageId?) { + const hasRestrictions = await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId); + + // Seed: a single page subtree, or all root pages of the space. + // - restricted space -> *ExcludingRestricted (prunes closed subtrees in SQL) + // - open space -> plain recursive descendants + // For the whole-space case add a space-rooted recursive CTE (seed: + // parentPageId is null AND spaceId = ? AND deletedAt is null), mirroring + // getPageAndDescendants/...ExcludingRestricted. + let pages = hasRestrictions + ? await this.pageRepo.getSpaceDescendantsExcludingRestricted(spaceId, pageId, { includeContent: false }) + : await this.pageRepo.getSpaceDescendants(spaceId, pageId, { includeContent: false }); + + // Fine-grained per-page permissions on top of restricted pruning. + if (hasRestrictions) { + pages = await this.filterAccessibleTreePages(pages, pageId ?? null, userId, spaceId); + } + + // Derive hasChildren from the returned set; stamp canEdit (per-page when + // restricted, else spaceCanEdit). Same two-branch logic as getSidebarPages(). + return shapeAsSidebarItems(pages, { hasRestrictions, spaceCanEdit /*, permissionMap */ }); +} +``` +Где `getSpaceDescendants` / `getSpaceDescendantsExcludingRestricted` — новые +тонкие обёртки над существующими рекурсивными CTE (для случая «всё дерево спейса» +— CTE, засеянный корнями спейса вместо одного `parentPageId`). + +**Важно про права:** обязательно сохранить **обе ветки** фильтрации из +`getSidebarPages` (restricted / не-restricted) и корректировку `hasChildren`, +иначе рекурсивный эндпоинт начнёт отдавать страницы, к которым у пользователя нет +доступа. Это критичная грань — на ревью проверить отдельно. + +### Клиентская часть — упрощённый `expandAll` + +Поскольку дерево приходит целиком, BFS/параллелизм/потолок не нужны. + +`page-service.ts` — новый вызов: +```ts +// Fetch the whole space tree (all roots + descendants) in one shot. +export async function getSpaceTree(params: { spaceId: string; pageId?: string }): Promise { + const req = await api.post("/pages/tree", params); // or /sidebar-pages { recursive: true } + return req.data.items; +} +``` + +`space-tree.tsx` — превратить `SpaceTree` в `forwardRef` и выставить +`useImperativeHandle`: +```ts +export type SpaceTreeApi = { + expandAll: () => Promise; + collapseAll: () => void; + isExpanding: boolean; +}; + +const expandAll = useCallback(async () => { + const startSpaceId = spaceIdRef.current; + setIsExpanding(true); + try { + // One request: the entire space tree, permission-filtered server-side. + const items = await getSpaceTree({ spaceId: startSpaceId }); + if (spaceIdRef.current !== startSpaceId) return; // space switched — abort + + const fullTree = buildTreeWithChildren(items); + setData((prev) => { + // Replace current-space nodes with the full tree; keep other spaces intact. + const others = prev.filter((n) => n?.spaceId !== startSpaceId); + return [...others, ...mergeRootTrees(prev.filter((n) => n?.spaceId === startSpaceId), fullTree)]; + }); + + // Open every branch node of the current space. + const branchIds = collectBranchIds(fullTree); // nodes with children + setOpenTreeNodes((prev) => { + const next = { ...prev }; + for (const id of branchIds) next[id] = true; + return next; + }); + } catch (err) { + // Never swallow: log full error + show the real reason (project convention). + console.error("[tree] expandAll failed", err); + notifications.show({ color: "red", + message: t("Couldn't expand the tree: {{reason}}", { reason: err?.response?.data?.message ?? err?.message ?? String(err) }) }); + } finally { + setIsExpanding(false); + } +}, [/* setData, setOpenTreeNodes, t */]); +``` + +`collapseAll` — снимать раскрытие **только у узлов текущего спейса** (карта общая): +```ts +const collapseAll = useCallback(() => { + // The open-map is shared across spaces; clearing it wholesale would drop + // other spaces' expanded state. Collapse only current-space ids. + const ids = new Set(); + const walk = (nodes: SpaceTreeNode[]) => { + for (const n of nodes) { ids.add(n.id); if (n.children?.length) walk(n.children); } + }; + walk(filteredData); + setOpenTreeNodes((prev) => { + const next = { ...prev }; + for (const id of ids) next[id] = false; + return next; + }); +}, [filteredData, setOpenTreeNodes]); +``` + +`space-sidebar.tsx` — `const treeRef = useRef(null)`, передать +в ``, и подвесить команды в шапке. **Без +`canManage`-гейта** — это операция над видом, не над данными. + +## UX-развилка по размещению + +В шапке уже два значка (`IconDots` меню + `IconPlus` создать). Варианты: +- **(1) Две `ActionIcon`** «развернуть»/«свернуть» (`IconChevronsDown` / + `IconChevronsUp`) → 4 значка в узкой шапке, явно и в один клик. +- **(2) Одна `ActionIcon`-тоггл** развернуть↔свернуть → 3 значка, компактнее, но + состояние менее очевидно. +- **(3) Два `Menu.Item`** в `SpaceMenu` (`Развернуть всё` / `Свернуть всё` + + `Menu.Divider`) → шапка не растёт, но в два клика и менее заметно. + +> **Рекомендация:** **(3)** как самый чистый по вёрстке (узкая колонка) либо +> **(1)**, если важна доступность в один клик. Тултипы/`aria-label`: +> `t("Expand all")` / `t("Collapse all")`; во время загрузки — `loading`/ +> `disabled` (`isExpanding`). + +## Тонкие моменты / edge cases + +- **Права в рекурсивном эндпоинте.** Самый важный пункт: повторить **обе** ветки + фильтрации (restricted / открытый спейс) и корректировку `hasChildren` из + `getSidebarPages`. Предпочесть `*ExcludingRestricted` (обрезает закрытые + поддеревья в SQL) + `filterAccessibleTreePages` для per-page прав. На ревью — + тест: пользователь без доступа к ветке не должен видеть её через «развернуть + всё». +- **Размер ответа.** Всё дерево спейса может быть большим. `content` **не** + тянуть (`includeContent: false`). Прикинуть потолок (число узлов) и поведение + при очень больших спейсах — отдавать всё или ограничить + честно сообщить + (конвенция: не молчать про усечение). +- **Скоуп карты раскрытия.** `openTreeNodesAtom` общая для спейсов — и + `expandAll`, и `collapseAll` работают **только по узлам текущего спейса**. +- **Гонки при смене спейса.** Запрос асинхронный; сверяться с + `spaceIdRef.current` и прерывать мёрдж/раскрытие, если спейс сменился (паттерн + уже есть в эффектах `space-tree.tsx`). +- **Мёрдж с уже загруженным.** Полное дерево вмёрджить в `treeDataAtom`, заместив + узлы текущего спейса (`mergeRootTrees`/замена ветки), **не трогая** узлы + других спейсов. +- **Ошибки не глотать.** Любой сбой — `console.error` с полным объектом **и** + уведомление с реальной причиной (`err.response?.data?.message`/`err.message`), + не «что-то пошло не так» (CLAUDE.md «Errors must never be swallowed»). +- **Индикатор.** На крупном спейсе запрос заметный — кнопку в `loading`, чтобы не + было повторных кликов/ощущения зависания. +- **Рост localStorage-карты.** `expandAll` пишет много ключей; для удалённых + страниц ключи «висят». Не критично; уборка карты — отдельная задача. +- **Пустой спейс / одни листья.** Кнопки — no-op; «развернуть» можно `disabled`. +- **Шорткат `*`** (развернуть сиблингов, + [doc-tree.tsx](apps/client/src/features/page/tree/components/doc-tree.tsx)) не + трогаем — дополняем его. +- **Виртуализация.** Дерево на `@tanstack/react-virtual` — раскрытие тысяч строк + рендер не убьёт (рисуются видимые), но резко меняет высоту скролла; проверить, + что позиция/скролл не прыгают. + +## Тесты / проверка + +- **Сервер:** `pnpm --filter server test` (unit на новый сервисный метод). + Кейсы: открытый спейс (видно всё), restricted-спейс (закрытые ветки и их + поддеревья **не** попадают в ответ), per-page права (`canEdit`), корректный + `hasChildren`, порядок по `position`, `content` не тянется. +- **Клиент:** `pnpm --filter client lint`, `pnpm --filter client test`. +- **Ручная:** глубокий спейс → «развернуть всё» раскрывает все уровни одним + запросом, индикатор работает; «свернуть всё» схлопывает до корней и **не** + теряет состояние другого спейса (переключиться туда-обратно); перезагрузка — + состояние сохраняется (localStorage); смена спейса в середине загрузки — + корректно прерывается; пустой спейс — без поломок; имитация ошибки сети — видно + конкретное уведомление, ошибка залогирована. + +## Открытые вопросы + +1. **Оформление эндпоинта:** флаг `recursive` к `/pages/sidebar-pages` против + отдельного `/pages/tree`. (Контракт ответа в обоих — плоский список в shape + текущего сайдбара.) +2. **Размещение команд:** две иконки (1) / одна-тоггл (2) / пункты меню (3). + Рекомендация — (3) или (1). +3. **Потолок размера ответа:** отдавать дерево любого размера или ограничить + (число узлов) и как сообщать про усечение.