diff --git a/SPEC.md b/SPEC.md index a269614..f441005 100644 --- a/SPEC.md +++ b/SPEC.md @@ -294,15 +294,36 @@ git диффает побайтово. Если export недетерминир держать `trashRetentionDays` больше максимального окна офлайна демона и предупреждать, если разрыв превысил retention. +### Ранее открытые вопросы — решения (продолжение) +- **Аутентификация долгоживущего демона.** ✅ Отдельный сервисный пользователь. + Если сборка Docmost — EE/Cloud: завести **API-key** (`POST /api/api-keys/create`, + `expiresAt` опускаем → бессрочный, отзывной), слать `Authorization: Bearer`. В + OSS community **`api_key` отвергается** («Enterprise API Key module missing»), + поэтому fallback — JWT логина (`POST /api/auth/login`; по умолчанию **90 дней**, + refresh-эндпойнта нет, токен привязан к серверной сессии). Универсально — + обёртка «401 → пере-логин → повтор запроса»; опц. поднять `JWT_TOKEN_EXPIRES_IN` + на сервере. Детали — §16. +- **Генерация `position` при move/reorder.** ✅ Тот же алгоритм, что у Docmost: + пакет **`fractional-indexing-jittered`**, `generateJitteredKeyBetween(prev,next)` + (первый ребёнок `(null,null)`, в конец `(last,null)`, между соседями + `(prev,next)`). Соседей и их `position` берём из `POST /api/pages/sidebar-pages`; + сравнивать/сортировать позиции как **сырые байты** (Postgres `COLLATE "C"`, т.е. + обычное строковое `<`/`>`), иначе порядок разъедется. Детали — §16. +- **Первичный полный клон пространства.** ✅ Канонический путь — обойти дерево + `POST /api/pages/sidebar-pages` (структура + `position` + `pageId`, без контента) + и на каждую страницу дёрнуть `POST /api/pages/info` → прогнать через наш + конвертер (тело+якоря) → файл с meta. Ограниченная конкурентность, возобновимо, + фиксируем максимальный `updatedAt` как стартовый `T_last`. Эндпойнты не + троттлятся, но клиент дросселируем сами. `POST /api/spaces/export` / + `POST /api/pages/export` (markdown-ZIP) — только быстрый bootstrap: их markdown + идёт через ДРУГОЙ (turndown) конвертер и без meta/якорей, поэтому **не годится + как baseline** без повторного прогона через наш конвертер (иначе §11 поедет). +- **Вложения.** ✅ Scope v1: картинки/файлы едут **ссылками** внутри контента (URL + на сервер Docmost); локальный `.md` не самодостаточен по бинарям — это принято. + Будущая фаза (флаг `includeAttachments` у export уже есть) сможет качать бинари + в vault и переписывать ссылки. Сейчас — вне scope. + ### Остаются открытыми -- [ ] Обновление/протухание JWT сервисного пользователя (рефреш токена, реакция - на 401 в долгоживущем демоне). -- [ ] Генерация ключа `position` (fractional-index, 5–12 симв.) при move/reorder - — библиотека/алгоритм LexoRank-совместимых ключей (§16, move). -- [ ] Стратегия первичного полного клона больших пространств (initial pull): - обход дерева vs курсорный скан `/recent`, дросселирование. -- [ ] Вложения как отдельный поток (сейчас едут ссылками в контенте) — вне первой - версии. --- @@ -373,6 +394,15 @@ collab-write), `chokidar` (watch), прямой REST Docmost для корзин - `POST /api/auth/login` — тело `{ email, password }`. На успехе ставит httpOnly-cookie `authToken` (JWT). `JwtAuthGuard` принимает и cookie, и `Authorization: Bearer ` — для headless-демона используем **Bearer**. + JWT живёт `JWT_TOKEN_EXPIRES_IN` (по умолчанию **90 дней**), **refresh-эндпойнта + нет**, токен привязан к серверной сессии (logout/отзыв сессии его убивает) → на + 401 пере-логиниваемся. +- **API-keys (только EE/Cloud):** `POST /api/api-keys/create` (тело + `{ name, expiresAt? }`; без `expiresAt` ключ бессрочный; raw-токен в ответе один + раз), `…/update`, `…/revoke`, листинг `POST /api/api-keys`. Шлётся тем же + `Authorization: Bearer`. **В OSS community-сборке валидация `api_key` не + включена** (EE-модуль) → запрос падает с «Enterprise API Key module missing»; + там используем JWT логина. ### Чтение - `POST /api/pages/info` — тело `{ pageId, includeContent:true, includeSpace? }`. @@ -384,6 +414,11 @@ collab-write), `chokidar` (watch), прямой REST Docmost для корзин **`content` не отдаётся** — за телом идём в `/info`. Это и есть «changes since T»: скан с конца + клиентский обрыв по `T_last`. Серверного фильтра по `updatedAt` нет. +- `POST /api/pages/sidebar-pages` — обход дерева. Тело `{ spaceId? | pageId?, + …пагинация }` (нужен `spaceId` ИЛИ `pageId`): `pageId` → прямые дети, только + `spaceId` → корни. Отдаёт `id, slugId, title, position, parentPageId, icon, + hasChildren` (без `content`), сортировка по `position COLLATE "C"`, курсорная + пагинация. Источник соседей для расчёта `position`. ### Запись тела — через collab/Yjs, не через `/update` `POST /api/pages/update` (тело `{ pageId, title?, content?, operation: @@ -418,9 +453,24 @@ append|prepend|replace, format: json|markdown|html }`) перезаписыва - `POST /api/pages/restore` — тело `{ pageId }`, сбрасывает `deletedAt`. - Auto-purge: фон ~24 ч, retention = `trashRetentionDays` (по умолчанию 30 дней). +### Экспорт (bulk, для initial clone) +- `POST /api/pages/export` — тело `{ pageId, format: 'html'|'markdown', + includeChildren?, includeAttachments? }`. Один файл или ZIP (если поддерево). +- `POST /api/spaces/export` — тело `{ spaceId, format: 'html'|'markdown', + includeAttachments? }` → ZIP всего пространства (нужно право space «Manage + Settings»). +- **Важно:** markdown отсюда идёт через turndown-конвертер Docmost (не наш) и без + meta/якорей — годится только как быстрый bootstrap, не как канонический baseline + (см. §11, §12). `position` в экспорте нет — порядок восстанавливаем через + `sidebar-pages`. + ### Подводные камни 1. Пагинация **курсорная** (`cursor` / `beforeCursor` / `limit` ≤ 100) в теле JSON — не `page` / `offset`. 2. Корзина и `recent` могут быть пер-спейс → перечисляем спейсы. 3. `content` отдают только `/info` и `/trash`; `/recent` — без тела. 4. Запись тела — collab-путь, не `/update` (см. выше). +5. `position` — `fractional-indexing-jittered`; сравнивать байтами (`COLLATE "C"`). +6. Троттлинга на page/export/sidebar/move/info нет (лимитируются только `auth` и + `ai-chat`) — клиент дросселируем сами. +7. API-keys — EE-only; в OSS только JWT логина (90 дней, без refresh).