docs(mcp): document user‑specific auth and full tree toggle
Add markdown files describing the per‑user authentication mechanism and the ability to expand or collapse all nodes in the page tree, improving guidance for developers working with the MCP backlog feature.
This commit is contained in:
416
docs/backlog/mcp-per-user-auth.md
Normal file
416
docs/backlog/mcp-per-user-auth.md
Normal file
@@ -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 <access-JWT>`, где токен — это значение
|
||||
куки `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<DocmostMcpConfig>;
|
||||
|
||||
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<DocmostMcpConfig> {
|
||||
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-скоуп для мульти-тенант деплоя — или отдельная задача?
|
||||
301
docs/backlog/tree-expand-collapse-all.md
Normal file
301
docs/backlog/tree-expand-collapse-all.md
Normal file
@@ -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<string, boolean>` (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<IPage[]> {
|
||||
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<void>;
|
||||
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<string>();
|
||||
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<SpaceTreeApi | null>(null)`, передать
|
||||
в `<SpaceTree ref={treeRef} ... />`, и подвесить команды в шапке. **Без
|
||||
`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. **Потолок размера ответа:** отдавать дерево любого размера или ограничить
|
||||
(число узлов) и как сообщать про усечение.
|
||||
Reference in New Issue
Block a user