docs(spec): resolve remaining §12 open questions; expand §16 REST map
Research Docmost source (docmost/docmost@main) to close the last four §12 items. - auth: dedicated service user; API-key (EE/Cloud) vs login JWT (OSS, 90d default, no refresh, session-bound) with "401 -> re-login" - position: reuse `fractional-indexing-jittered` generateJitteredKeyBetween, siblings via /sidebar-pages, compare as raw bytes (COLLATE "C") - initial clone: canonical = sidebar-pages walk + /info via our converter; spaces/export (turndown markdown, no meta/anchors) is bootstrap-only, not baseline - attachments: v1 keeps them as links (out of scope), includeAttachments flag noted - §16: add auth/JWT/API-key facts, /sidebar-pages, bulk export endpoints, extra gotchas (collation, throttling, EE-only API keys) - park "REST vs direct Postgres" as the sole remaining open question
This commit is contained in:
66
SPEC.md
66
SPEC.md
@@ -294,15 +294,36 @@ git диффает побайтово. Если export недетерминир
|
|||||||
держать `trashRetentionDays` больше максимального окна офлайна демона и
|
держать `trashRetentionDays` больше максимального окна офлайна демона и
|
||||||
предупреждать, если разрыв превысил retention.
|
предупреждать, если разрыв превысил 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 }`. На успехе ставит
|
- `POST /api/auth/login` — тело `{ email, password }`. На успехе ставит
|
||||||
httpOnly-cookie `authToken` (JWT). `JwtAuthGuard` принимает и cookie, и
|
httpOnly-cookie `authToken` (JWT). `JwtAuthGuard` принимает и cookie, и
|
||||||
`Authorization: Bearer <jwt>` — для headless-демона используем **Bearer**.
|
`Authorization: Bearer <jwt>` — для 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? }`.
|
- `POST /api/pages/info` — тело `{ pageId, includeContent:true, includeSpace? }`.
|
||||||
@@ -384,6 +414,11 @@ collab-write), `chokidar` (watch), прямой REST Docmost для корзин
|
|||||||
**`content` не отдаётся** — за телом идём в `/info`. Это и есть «changes since
|
**`content` не отдаётся** — за телом идём в `/info`. Это и есть «changes since
|
||||||
T»: скан с конца + клиентский обрыв по `T_last`. Серверного фильтра по
|
T»: скан с конца + клиентский обрыв по `T_last`. Серверного фильтра по
|
||||||
`updatedAt` нет.
|
`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`
|
### Запись тела — через collab/Yjs, не через `/update`
|
||||||
`POST /api/pages/update` (тело `{ pageId, title?, content?, operation:
|
`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`.
|
- `POST /api/pages/restore` — тело `{ pageId }`, сбрасывает `deletedAt`.
|
||||||
- Auto-purge: фон ~24 ч, retention = `trashRetentionDays` (по умолчанию 30 дней).
|
- 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) в теле
|
1. Пагинация **курсорная** (`cursor` / `beforeCursor` / `limit` ≤ 100) в теле
|
||||||
JSON — не `page` / `offset`.
|
JSON — не `page` / `offset`.
|
||||||
2. Корзина и `recent` могут быть пер-спейс → перечисляем спейсы.
|
2. Корзина и `recent` могут быть пер-спейс → перечисляем спейсы.
|
||||||
3. `content` отдают только `/info` и `/trash`; `/recent` — без тела.
|
3. `content` отдают только `/info` и `/trash`; `/recent` — без тела.
|
||||||
4. Запись тела — collab-путь, не `/update` (см. выше).
|
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).
|
||||||
|
|||||||
Reference in New Issue
Block a user