2442 lines
115 KiB
TypeScript
2442 lines
115 KiB
TypeScript
export type DocBlock =
|
||
| { type: 'paragraph'; text: string }
|
||
| { type: 'list'; items: string[] }
|
||
| { type: 'code'; language: string; code: string; title?: string }
|
||
| { type: 'callout'; variant: 'info' | 'warning' | 'tip'; title: string; text: string }
|
||
| { type: 'table'; headers: string[]; rows: string[][] }
|
||
| { type: 'oauth-examples' }
|
||
| { type: 'one-tap-examples' }
|
||
| { type: 'one-tap-builder' }
|
||
| { type: 'auth-login-examples' }
|
||
| { type: 'auth-ldap-examples' }
|
||
| { type: 'bot-examples' }
|
||
| { type: 'api-reference' };
|
||
|
||
export interface DocSection {
|
||
id: string;
|
||
title: string;
|
||
blocks: DocBlock[];
|
||
}
|
||
|
||
export interface DocPage {
|
||
slug: string;
|
||
title: string;
|
||
description: string;
|
||
sections: DocSection[];
|
||
}
|
||
|
||
export const docPages: DocPage[] = [
|
||
{
|
||
slug: 'getting-started',
|
||
title: 'Начало работы',
|
||
description: 'Обзор экосистемы Identity Provider, порты сервисов и быстрый локальный запуск.',
|
||
sections: [
|
||
{
|
||
id: 'overview',
|
||
title: 'Обзор',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Lendry ID — enterprise Identity Provider в стиле Yandex ID: единый аккаунт, OAuth 2.0, LDAP/LDAPS, семейные группы, чат, Telegram Bot API, PIN-блокировка сессий и админ-панель. Монорепозиторий состоит из NestJS-микросервисов, Go-сервисов и Next.js-приложений.'
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'Домен',
|
||
text: 'Production-домен задаётся в админке (PROJECT_DOMAIN и PUBLIC_API_URL). Локально API — порт 3000, frontend — 3002, документация — 3003. При same-origin деплое PUBLIC_API_URL обычно https://ваш-домен/idp-api.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'requirements',
|
||
title: 'Требования',
|
||
blocks: [
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'Node.js 20.19+ и npm 10+',
|
||
'Docker и Docker Compose',
|
||
'PostgreSQL 16, Redis 7, RabbitMQ 3.13, MinIO',
|
||
'Go 1.23+ (для локальной сборки media-ws и ldap-auth)'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'quick-start',
|
||
title: 'Быстрый запуск',
|
||
blocks: [
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'Production на сервере',
|
||
text: 'Для развёртывания на Linux с Docker, Nginx и SSL используйте скрипт install.sh — подробности в разделе «Развёртывание на сервере».'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Сервер (рекомендуется)',
|
||
code: `git clone https://git.lendry.ru/lendry/IdP.git
|
||
cd IdP
|
||
chmod +x install.sh
|
||
./install.sh`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Локально через Docker',
|
||
code: `git clone <repository-url> lendry-id
|
||
cd lendry-id
|
||
docker compose up -d --build
|
||
|
||
# Проверка
|
||
curl http://localhost:3000/health
|
||
curl http://localhost:3003/docs/getting-started`
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Сервис', 'Порт', 'Назначение'],
|
||
rows: [
|
||
['api-gateway', '3000', 'REST API, Swagger'],
|
||
['sso-core', '3001 / 50051', 'Бизнес-логика, gRPC'],
|
||
['frontend', '3002', 'UI входа и профиля'],
|
||
['docs', '3003', 'Документация'],
|
||
['media-ws', '8085', 'WebSocket уведомлений и чата'],
|
||
['ldap-auth', '8086', 'LDAP/LDAPS аутентификация'],
|
||
['MinIO', '9000 / 9001', 'Хранилище медиа'],
|
||
['RabbitMQ', '5672 / 15672', 'Очереди событий']
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'first-user',
|
||
title: 'Первый пользователь',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Первый зарегистрированный пользователь автоматически получает права супер-администратора (isSuperAdmin). Race condition защищена Redis-lock и Serializable-транзакцией PostgreSQL.'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Регистрация через API',
|
||
code: `curl -X POST http://localhost:3000/auth/register \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{"displayName":"Администратор","email":"admin@example.com","password":"SecurePass123"}'`
|
||
}
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
slug: 'architecture',
|
||
title: 'Архитектура',
|
||
description: 'Схема микросервисов, потоки данных и технологический стек.',
|
||
sections: [
|
||
{
|
||
id: 'stack',
|
||
title: 'Технологический стек',
|
||
blocks: [
|
||
{
|
||
type: 'table',
|
||
headers: ['Компонент', 'Технологии'],
|
||
rows: [
|
||
['sso-core', 'NestJS, Prisma 7+, PostgreSQL, gRPC'],
|
||
['api-gateway', 'NestJS, REST, Swagger'],
|
||
['frontend / docs', 'Next.js App Router, React, Tailwind, shadcn/ui'],
|
||
['media-ws', 'Go, Gorilla WebSocket, Redis, RabbitMQ'],
|
||
['ldap-auth', 'Go, go-ldap'],
|
||
['Инфраструктура', 'Docker, Redis, RabbitMQ, MinIO']
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'flow',
|
||
title: 'Поток запросов',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Клиент (браузер или стороннее приложение) обращается к api-gateway по REST. Gateway проксирует вызовы в sso-core через gRPC. Медиафайлы загружаются в MinIO через presigned URL. Realtime-события (уведомления, чат) доставляются через media-ws по WebSocket с JWT-авторизацией.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'JWT access + refresh tokens для сессий',
|
||
'PIN-код: при включении сессия создаётся с pinVerified=false',
|
||
'SystemSetting — динамические бизнес-правила без хардкода во frontend',
|
||
'LinkedAccount — привязка LDAP, Google, Yandex и других провайдеров',
|
||
'Telegram Bot API — совместимый /bot{token}/{method} на api-gateway, BotFather REST на /bots'
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
slug: 'deployment',
|
||
title: 'Развёртывание на сервере',
|
||
description: 'Автоматическая установка через install.sh: Docker, Nginx, SSL и отдельные домены для каждого сервиса.',
|
||
sections: [
|
||
{
|
||
id: 'install-script',
|
||
title: '1. Автоматическая установка (install.sh)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Скрипт install.sh разворачивает весь стек одной командой: Docker, Nginx, генерация .env, reverse proxy. Поддерживает локальную сеть без интернета (--intranet), самоподписанный HTTPS и Let\'s Encrypt для публичных серверов. На Windows Server Nginx запускается в Docker-контейнере.'
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'Локальная сеть (AD, .lpr, .local)',
|
||
text: 'Если домены только внутри LAN и Let\'s Encrypt недоступен — используйте ./install.sh --intranet --ssl none. Доступ: http://api.idpmvk.lpr без выхода в интернет.'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Быстрый старт',
|
||
code: `git clone https://git.lendry.ru/lendry/IdP.git
|
||
cd IdP
|
||
chmod +x install.sh
|
||
./install.sh`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Production без вопросов',
|
||
code: `./install.sh --install --yes \\
|
||
--api-domain api.id.example.ru \\
|
||
--frontend-domain id.example.ru \\
|
||
--docs-domain docs.example.ru \\
|
||
--email admin@example.ru`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Установка без клонирования',
|
||
code: `curl -fsSL https://git.lendry.ru/lendry/IdP/raw/branch/main/install.sh | bash`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'intranet-network',
|
||
title: '2. Локальная сеть (без интернета)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Для корпоративной LAN, Windows Server и внутренних доменов (например idpmvk.lpr) не нужен доступ из интернета и Let\'s Encrypt. Скрипт настраивает Nginx на HTTP или HTTPS с самоподписанным сертификатом (openssl). На Windows Server Nginx работает в Docker (профиль proxy).'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Рекомендуемая команда (HTTP)',
|
||
code: `./install.sh --intranet --install --yes --offline \\
|
||
--api-domain api.idpmvk.lpr \\
|
||
--frontend-domain id.idpmvk.lpr \\
|
||
--docs-domain docs.idpmvk.lpr \\
|
||
--ssl none`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'HTTPS самоподписанный (опционально)',
|
||
code: `./install.sh --intranet --install --yes --offline \\
|
||
--api-domain api.idpmvk.lpr \\
|
||
--frontend-domain id.idpmvk.lpr \\
|
||
--docs-domain docs.idpmvk.lpr \\
|
||
--ssl selfsigned`
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['SSL_TYPE', 'Описание', 'Интернет'],
|
||
rows: [
|
||
['none', 'http://api.idpmvk.lpr — без сертификатов', 'Не нужен'],
|
||
['selfsigned', 'https://... — openssl, предупреждение браузера', 'Не нужен'],
|
||
['letsencrypt', 'Публичный HTTPS', 'Нужен']
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'warning',
|
||
title: 'DNS на клиентах',
|
||
text: 'Пропишите A-записи во внутреннем DNS Active Directory или файл hosts на каждом ПК: IP-сервера → api.idpmvk.lpr, id.idpmvk.lpr, docs.idpmvk.lpr. Windows: C:\\Windows\\System32\\drivers\\etc\\hosts'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'text',
|
||
title: 'Пример hosts (Windows / Linux)',
|
||
code: `192.168.1.10 api.idpmvk.lpr
|
||
192.168.1.10 id.idpmvk.lpr
|
||
192.168.1.10 docs.idpmvk.lpr`
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'Windows Server',
|
||
text: 'Запускайте install.sh из Git Bash или WSL. Docker Desktop / Docker Engine должен быть установлен заранее (--offline). Nginx поднимается контейнером на портах 80 и 443.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'server-requirements',
|
||
title: '3. Требования к серверу',
|
||
blocks: [
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'Ubuntu 22.04+ / Debian 12+ / Fedora / RHEL (Linux)',
|
||
'Минимум 4 GB RAM, 2 vCPU, 40 GB SSD',
|
||
'Отдельные домены или поддомены для API, Frontend и Docs (A-запись → IP сервера)',
|
||
'Открытые порты 80 и 443 (остальные сервисы доступны только через Nginx на localhost)',
|
||
'Права sudo/root для установки Docker и Nginx'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'domains',
|
||
title: '4. Отдельные домены для сервисов',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'При установке и при повторной настройке (--configure-domains) можно указать свой домен для каждого компонента. Текущие значения подставляются по умолчанию — достаточно изменить только нужные.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Сервис', 'Параметр', 'Пример', 'Nginx → порт'],
|
||
rows: [
|
||
['API / Swagger', '--api-domain', 'api.id.example.ru', '127.0.0.1:3000'],
|
||
['Frontend (UI)', '--frontend-domain', 'id.example.ru', '127.0.0.1:3002'],
|
||
['Документация', '--docs-domain', 'docs.example.ru', '127.0.0.1:3003'],
|
||
['WebSocket', '--ws-domain', 'пусто = API + /ws', '127.0.0.1:8085'],
|
||
['MinIO S3', '--minio-domain', 'опционально', '127.0.0.1:9000'],
|
||
['MinIO Console', '--minio-console-domain', 'опционально', '127.0.0.1:9001']
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'WebSocket',
|
||
text: 'Если --ws-domain не указан, WebSocket проксируется на том же домене, что и API, по пути /ws (wss://api.example.ru/ws). Для отдельного домена укажите --ws-domain ws.example.ru.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'install-menu',
|
||
title: '5. Меню управления (повторный запуск)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Если файл .env уже существует, повторный вызов ./install.sh открывает интерактивное меню — как у панелей с автоматическим reverse proxy.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Пункт', 'Действие'],
|
||
rows: [
|
||
['1', 'Полная установка / переустановка стека'],
|
||
['2', 'Настроить или изменить домены, Nginx и SSL'],
|
||
['3', 'Обновить SSL-сертификаты (Let\'s Encrypt)'],
|
||
['4', 'Перезапуск / пересборка контейнеров'],
|
||
['5', 'Статус сервисов и проверка Nginx'],
|
||
['6', 'Удалить конфиги Nginx IdP'],
|
||
['0', 'Выход']
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Смена доменов после установки',
|
||
code: `# Интерактивно
|
||
./install.sh --configure-domains
|
||
|
||
# Или через меню
|
||
./install.sh
|
||
# → пункт 2
|
||
|
||
# Пример: вынести Swagger на отдельный домен
|
||
./install.sh --configure-domains \\
|
||
--api-domain swagger.id.example.ru \\
|
||
--frontend-domain id.example.ru \\
|
||
--docs-domain docs.example.ru`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'install-commands',
|
||
title: '6. Команды и флаги',
|
||
blocks: [
|
||
{
|
||
type: 'table',
|
||
headers: ['Команда / флаг', 'Описание'],
|
||
rows: [
|
||
['./install.sh', 'Интерактивное меню (если .env есть) или первая установка'],
|
||
['./install.sh --install', 'Полная установка'],
|
||
['./install.sh --configure-domains', 'Изменить домены и пересобрать frontend/docs'],
|
||
['./install.sh --renew-ssl', 'Обновить сертификаты Let\'s Encrypt'],
|
||
['./install.sh --restart', 'docker compose up -d --build'],
|
||
['./install.sh --status', 'Статус контейнеров и доменов'],
|
||
['./install.sh --intranet', 'Локальная сеть, HTTP по умолчанию'],
|
||
['./install.sh --ssl none', 'Только HTTP через Nginx'],
|
||
['./install.sh --ssl selfsigned', 'HTTPS самоподписанный'],
|
||
['./install.sh --offline', 'Не скачивать Docker из интернета'],
|
||
['./install.sh --nginx-mode docker', 'Nginx в контейнере (Windows)'],
|
||
['./install.sh --local', 'localhost:3000/3002/3003 без Nginx'],
|
||
['./install.sh --yes', 'Без интерактивных вопросов'],
|
||
['FORCE_ENV=1 ./install.sh', 'Пересоздать .env с новыми секретами']
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Локальная разработка без доменов',
|
||
code: `./install.sh --local
|
||
# или
|
||
./install.sh --install --local --yes`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'install-automation',
|
||
title: '7. Что делает скрипт автоматически',
|
||
blocks: [
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'Устанавливает curl, git, openssl, jq (apt/dnf)',
|
||
'Устанавливает Docker и Docker Compose v2 (get.docker.com)',
|
||
'Добавляет пользователя в группу docker',
|
||
'Генерирует .env: пароли PostgreSQL, RabbitMQ, MinIO, JWT-секреты, DATA_ENCRYPTION_KEY',
|
||
'Создаёт docker-compose.override.yml — привязка портов к 127.0.0.1',
|
||
'Устанавливает Nginx и Certbot, настраивает UFW (80, 443)',
|
||
'Генерирует конфиги /etc/nginx/sites-available/lendry-id-*.conf',
|
||
'Получает SSL через certbot (webroot), включает certbot.timer',
|
||
'Запускает docker compose up -d --build и ждёт health сервисов',
|
||
'При смене доменов пересобирает frontend и docs с новыми NEXT_PUBLIC_* URL'
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'warning',
|
||
title: 'Секреты',
|
||
text: 'Файл .env не коммитьте в git. Сохраните его в надёжном месте после первой установки. FORCE_ENV=1 генерирует новые секреты и сбрасывает старые.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'clone-config',
|
||
title: '8. Переменные .env (генерируются автоматически)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'install.sh создаёт и обновляет .env на основе .env.example. При ручном редактировании не меняйте секреты без необходимости — для смены доменов используйте ./install.sh --configure-domains.'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Ключевые переменные .env',
|
||
code: `# Домены (без протокола)
|
||
DOMAIN_API=api.id.example.ru
|
||
DOMAIN_FRONTEND=id.example.ru
|
||
DOMAIN_DOCS=docs.example.ru
|
||
DOMAIN_WS=
|
||
|
||
# Публичные URL (скрипт подставляет https://)
|
||
PUBLIC_API_URL=https://api.id.example.ru
|
||
PUBLIC_FRONTEND_URL=https://id.example.ru
|
||
PUBLIC_DOCS_URL=https://docs.example.ru
|
||
PUBLIC_WS_URL=wss://api.id.example.ru/ws
|
||
|
||
USE_NGINX_SSL=true
|
||
CERTBOT_EMAIL=admin@example.ru
|
||
INSTALL_MODE=production
|
||
|
||
# Секреты — генерируются install.sh
|
||
JWT_ACCESS_SECRET=...
|
||
JWT_REFRESH_SECRET=...
|
||
DATA_ENCRYPTION_KEY=...
|
||
POSTGRES_PASSWORD=...
|
||
RABBITMQ_DEFAULT_PASS=...
|
||
MINIO_ROOT_PASSWORD=...`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'env-services',
|
||
title: '9. Переменные окружения сервисов',
|
||
blocks: [
|
||
{
|
||
type: 'table',
|
||
headers: ['Сервис', 'Ключевые переменные'],
|
||
rows: [
|
||
['sso-core', 'DATABASE_URL, JWT_*_SECRET, DATA_ENCRYPTION_KEY, REDIS_URL, RABBITMQ_URL, MINIO_*, LDAP_AUTH_URL, PUBLIC_API_URL'],
|
||
['api-gateway', 'SSO_CORE_GRPC_URL, JWT_ACCESS_SECRET, PUBLIC_API_URL, MINIO_*'],
|
||
['frontend', 'NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL (build args + runtime)'],
|
||
['docs', 'NEXT_PUBLIC_API_URL, INTERNAL_API_URL'],
|
||
['media-ws', 'REDIS_ADDR, RABBITMQ_URL, JWT_ACCESS_SECRET'],
|
||
['ldap-auth', 'PORT=8086']
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'after-install',
|
||
title: '10. После установки',
|
||
blocks: [
|
||
{
|
||
type: 'table',
|
||
headers: ['Ресурс', 'URL (production)'],
|
||
rows: [
|
||
['API / Swagger', 'https://<DOMAIN_API>/api'],
|
||
['Frontend', 'https://<DOMAIN_FRONTEND>'],
|
||
['Документация', 'https://<DOMAIN_DOCS>/docs'],
|
||
['WebSocket', 'wss://<DOMAIN_API>/ws или wss://<DOMAIN_WS>/ws'],
|
||
['MinIO Console', 'только localhost:9001 (если не задан домен)'],
|
||
['RabbitMQ UI', 'только localhost:15672']
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'Первый пользователь',
|
||
text: 'Первый зарегистрированный пользователь автоматически становится супер-администратором. Название проекта (PROJECT_NAME) и LDAP настраиваются в админке: /admin/settings.'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Полезные команды',
|
||
code: `docker compose ps
|
||
docker compose logs -f api-gateway sso-core
|
||
./install.sh --status
|
||
./install.sh --renew-ssl`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'migrations',
|
||
title: '11. Миграции и seed',
|
||
blocks: [
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
code: `# Prisma migrate внутри контейнера sso-core
|
||
docker compose exec sso-core npx prisma migrate deploy
|
||
|
||
# Seed системных настроек (PROJECT_NAME, LDAP_*, PIN_*)
|
||
docker compose restart sso-core`
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'SystemSetting',
|
||
text: 'Название проекта (PROJECT_NAME), tagline и LDAP-параметры настраиваются в админке. Frontend и docs подтягивают PROJECT_NAME через GET /settings/public.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'backup',
|
||
title: '12. Резервное копирование',
|
||
blocks: [
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'PostgreSQL: pg_dump lendry_id ежедневно',
|
||
'MinIO: mc mirror или snapshot volume minio_data',
|
||
'Redis: RDB/AOF snapshots при необходимости сохранения сессий',
|
||
'Секреты .env — хранить в vault, не в репозитории'
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
code: `docker compose exec postgres pg_dump -U lendry lendry_id > backup-$(date +%F).sql`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'monitoring',
|
||
title: '13. Мониторинг и обновления',
|
||
blocks: [
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'Health: GET /health на api-gateway и sso-core',
|
||
'Логи: docker compose logs -f --tail=200',
|
||
'Обновление кода: git pull && ./install.sh --restart',
|
||
'Смена доменов: ./install.sh --configure-domains',
|
||
'SSL: ./install.sh --renew-ssl или автоматически через certbot.timer'
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
slug: 'authentication',
|
||
title: 'Аутентификация',
|
||
description: 'Способы входа: TOTP (Google Authenticator), OTP, пароль, LDAP, PIN и refresh-сессии.',
|
||
sections: [
|
||
{
|
||
id: 'flows',
|
||
title: 'Сценарии входа',
|
||
blocks: [
|
||
{
|
||
type: 'table',
|
||
headers: ['Сценарий', 'Endpoint', 'Описание'],
|
||
rows: [
|
||
['Identifier-first', 'POST /auth/identify', 'Проверка пользователя, isTotpEnabled, otpChannels и альтернативных способов'],
|
||
['TOTP (основной)', 'POST /auth/totp/begin + verify', 'Вход через Google Authenticator, если аутентификатор подключён'],
|
||
['Passwordless OTP (альтернатива)', 'POST /auth/otp/send + verify', '6-значный код на почту/телефон вместо TOTP'],
|
||
['Пароль', 'POST /auth/login/password', 'Альтернативный вход по паролю'],
|
||
['LDAP/LDAPS', 'POST /auth/ldap/login', 'Корпоративный вход (требует LDAP_ENABLED)'],
|
||
['PIN', 'POST /auth/pin/verify', 'Разблокировка сессии после входа с PIN']
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'totp-login',
|
||
title: 'Вход через приложение-аутентификатор (TOTP)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Если пользователь подключил Google Authenticator (или аналог) в разделе «Безопасность», код из приложения становится основным способом входа вместо SMS/email OTP. SMS и email используются только как альтернатива через «Другой способ входа».'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'1. POST /auth/identify с почтой или телефоном — в ответе isTotpEnabled: true и otpChannels (доступные каналы для альтернативного OTP).',
|
||
'2. POST /auth/totp/begin — создаёт challenge (totpChallengeToken, TTL 5 минут). SMS/email на этом шаге не отправляются.',
|
||
'3. Пользователь вводит 6-значный код из приложения-аутентификатора.',
|
||
'4. POST /auth/totp/verify с totpChallengeToken и code — выдаётся JWT и refresh token (далее при необходимости PIN).'
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'TOTP и SMS/email — альтернативы',
|
||
text: 'Код из аутентификатора и код из SMS/email не комбинируются: это два разных способа пройти один и тот же шаг входа. После успешного OTP verify TOTP повторно не запрашивается.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Ситуация', 'Поведение UI / API'],
|
||
rows: [
|
||
['Аутентификатор подключён', 'После identify сразу экран TOTP (totp/begin), без автоматической отправки SMS/email'],
|
||
['Альтернатива: код на почту/телефон', 'POST /auth/otp/send с channel: email или phone, затем POST /auth/otp/verify'],
|
||
['Привязаны и почта, и телефон', 'Пользователь выбирает канал (otpChannels) перед отправкой OTP'],
|
||
['Только почта или только телефон', 'OTP отправляется сразу на единственный доступный канал'],
|
||
['Альтернатива: пароль', 'POST /auth/login/password — без дополнительного TOTP'],
|
||
['Включён PIN', 'После TOTP или OTP — POST /auth/pin/verify для полной сессии']
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
title: 'POST /auth/identify — фрагмент ответа',
|
||
language: 'json',
|
||
code: `{
|
||
"exists": true,
|
||
"hasPassword": true,
|
||
"isPinEnabled": false,
|
||
"isTotpEnabled": true,
|
||
"otpChannels": [
|
||
{ "channel": "email", "masked": "u***@example.com" },
|
||
{ "channel": "phone", "masked": "+7********42" }
|
||
],
|
||
"methods": [
|
||
{ "kind": "password", "channel": "password", "masked": "Пароль" }
|
||
]
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
title: 'Начать вход по TOTP',
|
||
language: 'bash',
|
||
code: `curl -X POST http://localhost:3000/auth/totp/begin \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{
|
||
"recipient": "user@example.com",
|
||
"fingerprint": "device-fingerprint-uuid",
|
||
"deviceName": "Chrome на Windows",
|
||
"deviceType": "WEB"
|
||
}'
|
||
|
||
# Ответ: { "totpChallengeToken": "eyJ..." }`
|
||
},
|
||
{
|
||
type: 'code',
|
||
title: 'Подтвердить код аутентификатора',
|
||
language: 'bash',
|
||
code: `curl -X POST http://localhost:3000/auth/totp/verify \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{
|
||
"totpChallengeToken": "eyJ...",
|
||
"code": "123456"
|
||
}'
|
||
|
||
# Ответ: AuthTokens (accessToken, refreshToken, sessionId, pinVerified, user)`
|
||
},
|
||
{
|
||
type: 'code',
|
||
title: 'Альтернатива: OTP на выбранный канал',
|
||
language: 'bash',
|
||
code: `# Отправить код (channel: email | phone | backupEmail | backupPhone)
|
||
curl -X POST http://localhost:3000/auth/otp/send \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{
|
||
"recipient": "user@example.com",
|
||
"channel": "phone"
|
||
}'
|
||
|
||
# Подтвердить OTP — сразу выдаёт сессию, без TOTP
|
||
curl -X POST http://localhost:3000/auth/otp/verify \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{
|
||
"recipient": "user@example.com",
|
||
"code": "654321",
|
||
"fingerprint": "device-fingerprint-uuid",
|
||
"deviceName": "Chrome на Windows",
|
||
"deviceType": "WEB"
|
||
}'`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'totp-setup',
|
||
title: 'Подключение аутентификатора',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Настройка выполняется в личном кабинете: Безопасность → Дополнительная защита → Подключить аутентификатор. Название в Google Authenticator берётся из SystemSetting PROJECT_NAME (не захардкожено).'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'GET /security/users/{userId}/totp/status — проверка, включён ли TOTP',
|
||
'POST /security/users/{userId}/totp/setup — QR-код (otpauthUrl) и секретный ключ',
|
||
'POST /security/users/{userId}/totp/enable — подтверждение первого кода и активация',
|
||
'POST /security/users/{userId}/totp/disable — отключение с проверкой текущего кода'
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'Повторная настройка',
|
||
text: 'Если настройка начата, но не завершена, повторный вызов setup возвращает тот же секрет — не нужно сканировать новый QR, пока не истёк незавершённый challenge.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'login-example',
|
||
title: 'Примеры входа по паролю',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Endpoint POST /auth/login/password принимает login (почта, телефон или username), password и метаданные устройства. Ниже — готовые примеры на разных языках.'
|
||
},
|
||
{ type: 'auth-login-examples' },
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'PIN-блокировка',
|
||
text: 'Если у пользователя включён PIN, ответ содержит requiresPin=true и ограниченную сессию. Полный JWT выдаётся после POST /auth/pin/verify.'
|
||
}
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
slug: 'oauth',
|
||
title: 'OAuth 2.0 / OIDC',
|
||
description: 'Стандартный OpenID Connect (client_id, redirect_uri, PKCE), Discovery, token endpoint и примеры для PHP, Python, Node.js.',
|
||
sections: [
|
||
{
|
||
id: 'register-app',
|
||
title: 'Регистрация OAuth-приложения',
|
||
blocks: [
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'Войдите как супер-администратор в админ-панель',
|
||
'Перейдите в RBAC → OAuth приложения',
|
||
'Создайте клиент: name, redirectUris, scopes (openid, profile, email)',
|
||
'Confidential-клиент получит client_secret один раз — сохраните его'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'flow',
|
||
title: 'Authorization Code Flow',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Lendry ID поддерживает стандартный OpenID Connect (RFC 6749 / OIDC): client_id, redirect_uri, response_type=code, scope, state, PKCE (code_challenge). Параметр userId не требуется — IdP сам определяет пользователя после входа и экрана «Разрешить доступ».'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'1) Ваше приложение перенаправляет браузер на GET /oauth/authorize с client_id, redirect_uri, response_type=code, scope',
|
||
'2) Пользователь входит в Lendry ID (если нужно) и подтверждает доступ',
|
||
'3) IdP делает HTTP 302 на redirect_uri?code=...&state=...',
|
||
'4) Backend обменивает code через POST /oauth/token (form-urlencoded: grant_type, client_id, client_secret, redirect_uri)',
|
||
'5) Ответ: access_token, id_token, refresh_token. Профиль: GET /oauth/userinfo'
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'OpenID Connect Discovery',
|
||
text: 'Метаданные: GET {PUBLIC_API_URL}/.well-known/openid-configuration. В PHP (jumbojett/openid-connect-php), Grafana, Authentik и других OIDC-клиентах укажите issuer = PUBLIC_API_URL. Дорабатывать OidcProvider.php под нестандартные параметры не нужно.'
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'Legacy camelCase',
|
||
text: 'Для внутренних интеграций по-прежнему поддерживаются clientId, redirectUri, userId (camelCase). Внешним приложениям используйте только стандарт OIDC.'
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'warning',
|
||
title: 'Безопасность',
|
||
text: 'client_secret никогда не храните во frontend. Обмен code → token выполняйте только на сервере.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'endpoints',
|
||
title: 'Endpoints и Discovery',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Issuer и все endpoints берутся из PUBLIC_API_URL (настройка в админ-панели). Локально по умолчанию: http://localhost:3000. При деплое через frontend same-origin: https://ваш-домен/idp-api.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Endpoint', 'URL'],
|
||
rows: [
|
||
['Issuer / Discovery', '{PUBLIC_API_URL}/.well-known/openid-configuration'],
|
||
['Authorization', '{PUBLIC_API_URL}/oauth/authorize'],
|
||
['Token', '{PUBLIC_API_URL}/oauth/token'],
|
||
['UserInfo', '{PUBLIC_API_URL}/oauth/userinfo'],
|
||
['JWKS', '{PUBLIC_API_URL}/.well-known/jwks.json']
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'warning',
|
||
title: 'Issuer ≠ frontend URL',
|
||
text: 'В Grafana, PHP OIDC-клиентах и других интеграциях указывайте issuer = PUBLIC_API_URL (базовый URL API), а не адрес Next.js frontend (порт 3002).'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'authorize-params',
|
||
title: 'GET /oauth/authorize — параметры',
|
||
blocks: [
|
||
{
|
||
type: 'table',
|
||
headers: ['Параметр', 'Обязательный', 'Описание'],
|
||
rows: [
|
||
['client_id', 'Да', 'Client ID из админки OAuth-приложений'],
|
||
['redirect_uri', 'Да', 'Должен точно совпадать с URI, зарегистрированным у клиента'],
|
||
['response_type', 'Да', 'Только code'],
|
||
['scope', 'Да', 'Например: openid profile email'],
|
||
['state', 'Рекомендуется', 'CSRF-защита клиента'],
|
||
['code_challenge', 'Опционально', 'PKCE S256 (публичные клиенты)'],
|
||
['code_challenge_method', 'С PKCE', 'S256 или plain']
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'text',
|
||
title: 'Пример authorize URL (стандарт OIDC)',
|
||
code: `https://id.lendry.ru/idp-api/oauth/authorize
|
||
?client_id=YOUR_CLIENT_ID
|
||
&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
|
||
&response_type=code
|
||
&scope=openid%20profile%20email
|
||
&state=random-state-value`
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'userId не нужен',
|
||
text: 'Параметр userId (и legacy clientId/redirectUri) поддерживается только для внутренних интеграций. Внешние OIDC-клиенты (PHP OidcProvider, Grafana, Keycloak) передают только стандартные поля — IdP сам определяет пользователя после входа и экрана «Разрешить доступ».'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'token-endpoint',
|
||
title: 'POST /oauth/token',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Обмен authorization code на токены выполняется только на backend. Поддерживаются application/x-www-form-urlencoded (рекомендуется для OIDC) и JSON.'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Authorization Code Grant (form-urlencoded)',
|
||
code: `curl -X POST https://id.lendry.ru/idp-api/oauth/token \\
|
||
-H "Content-Type: application/x-www-form-urlencoded" \\
|
||
-d "grant_type=authorization_code" \\
|
||
-d "code=AUTHORIZATION_CODE" \\
|
||
-d "client_id=YOUR_CLIENT_ID" \\
|
||
-d "client_secret=YOUR_CLIENT_SECRET" \\
|
||
-d "redirect_uri=https://app.example.com/oauth/callback"`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Ответ token endpoint (RFC 6749)',
|
||
code: `{
|
||
"access_token": "eyJ...",
|
||
"token_type": "Bearer",
|
||
"expires_in": 900,
|
||
"refresh_token": "eyJ...",
|
||
"id_token": "eyJ..."
|
||
}`
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'grant_type=refresh_token — обновление access token',
|
||
'Authorization: Basic base64(client_id:client_secret) — альтернатива client_secret в теле',
|
||
'GET /oauth/userinfo с заголовком Authorization: Bearer {access_token}'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'external-apps',
|
||
title: 'Подключение внешних приложений',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Стандартные OIDC-клиенты работают без кастомизации. Укажите issuer из Discovery и зарегистрированные client_id / redirect_uri.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Платформа', 'Настройка'],
|
||
rows: [
|
||
['PHP (jumbojett/openid-connect-php)', 'new OpenIDConnectClient($issuer, $clientId, $clientSecret) — библиотека сама вызывает Discovery'],
|
||
['Grafana', 'Auth → Generic OAuth → Auth URL / Token URL / API URL из Discovery'],
|
||
['Authentik / Keycloak (как client)', 'Issuer URL = PUBLIC_API_URL'],
|
||
['Любой OIDC RP', 'GET /.well-known/openid-configuration → автоконфигурация endpoints']
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'php',
|
||
title: 'PHP — без доработки OidcProvider',
|
||
code: `<?php
|
||
require 'vendor/autoload.php';
|
||
|
||
use Jumbojett\\OpenIDConnectClient;
|
||
|
||
$issuer = 'https://id.lendry.ru/idp-api'; // PUBLIC_API_URL
|
||
$oidc = new OpenIDConnectClient(
|
||
$issuer,
|
||
getenv('OAUTH_CLIENT_ID'),
|
||
getenv('OAUTH_CLIENT_SECRET')
|
||
);
|
||
$oidc->setRedirectURL('https://app.example.com/oauth/callback');
|
||
$oidc->addScope(['openid', 'profile', 'email']);
|
||
$oidc->authenticate(); // client_id, redirect_uri, response_type=code — автоматически`
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'Redirect URI',
|
||
text: 'redirect_uri в запросе authorize и token должен байт-в-байт совпадать с одним из URI, указанных при создании OAuth-клиента в админке Lendry ID (включая протокол, путь и завершающий слэш).'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'examples',
|
||
title: 'Примеры интеграции',
|
||
blocks: [{ type: 'oauth-examples' }]
|
||
},
|
||
{
|
||
id: 'one-tap',
|
||
title: 'One Tap Login',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Для входа в один клик на сторонних сайтах (FedCM + виджет sso-widget.js) см. отдельный раздел документации.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'One Tap Login — FedCM endpoints, подключение sso-widget.js, popup fallback',
|
||
'Требуется зарегистрированный OAuth client_id и redirect_uri вашего сайта'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'scopes',
|
||
title: 'Scopes',
|
||
blocks: [
|
||
{
|
||
type: 'table',
|
||
headers: ['Scope', 'Доступ'],
|
||
rows: [
|
||
['openid', 'Идентификатор пользователя (sub)'],
|
||
['profile', 'displayName, avatar, bio'],
|
||
['email', 'Основная и резервная почта'],
|
||
['phone', 'Телефоны пользователя']
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
slug: 'one-tap-login',
|
||
title: 'One Tap Login',
|
||
description: 'Вход в один клик для сайтов-клиентов: FedCM (основной) и JS SDK с popup (fallback).',
|
||
sections: [
|
||
{
|
||
id: 'overview',
|
||
title: 'Обзор',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'One Tap Login позволяет пользователям вашего сайта войти через Lendry ID без полного редиректа на страницу авторизации. Поддерживаются два режима: Federated Credential Management (FedCM) в современных браузерах и виджет sso-widget.js с popup-окном для остальных.'
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'Предварительные требования',
|
||
text: 'Создайте OAuth-приложение в админ-панели (RBAC → OAuth приложения): укажите redirect_uri вашего сайта, scopes openid profile email и сохраните client_id. Подробнее — раздел «OAuth 2.0 / OIDC».'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'FedCM — браузер показывает нативный диалог «Войти как …», если пользователь уже залогинен на домене IdP',
|
||
'Fallback — скрипт sso-widget.js рисует плашку «Войти через …» в правом верхнем углу и открывает popup с OAuth',
|
||
'Оба режима возвращают OIDC id_token (FedCM) или authorization code / токены (popup) — проверяйте на backend'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'fedcm-endpoints',
|
||
title: 'FedCM endpoints',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Endpoints размещены на PUBLIC_API_URL (issuer). Локально: http://localhost:3000. При same-origin деплое: https://ваш-домен/idp-api. CORS настроен с credentials: true для запросов с сайтов-клиентов.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Endpoint', 'Метод', 'Описание'],
|
||
rows: [
|
||
['{PUBLIC_API_URL}/.well-known/web-identity', 'GET', 'Манифест FedCM → provider_urls'],
|
||
['{PUBLIC_API_URL}/fedcm/config.json', 'GET', 'Конфигурация IdP (accounts, id_assertion, branding)'],
|
||
['{PUBLIC_API_URL}/fedcm/accounts', 'GET', 'Список аккаунтов по cookie lendry_fedcm_sess'],
|
||
['{PUBLIC_API_URL}/fedcm/id_assertion', 'POST', 'Выдача id_token для client_id + account_id'],
|
||
['{PUBLIC_API_URL}/fedcm/client_metadata', 'GET', '?client_id=… — privacy/terms для UI FedCM'],
|
||
['{PUBLIC_API_URL}/fedcm/session/sync', 'POST', 'Установить FedCM cookie по Bearer access token']
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'warning',
|
||
title: 'FedCM cookie',
|
||
text: 'FedCM использует HttpOnly cookie lendry_fedcm_sess на домене IdP (SameSite=None; Secure в production). Cookie устанавливается при входе на IdP или через POST /fedcm/session/sync. localStorage JWT с сайта клиента для FedCM не подходит.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'widget',
|
||
title: 'Подключение виджета (sso-widget.js)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Скрипт размещён на frontend IdP по адресу {PUBLIC_FRONTEND_URL}/sso-widget.js. Достаточно одного тега script — инициализация выполняется автоматически при загрузке страницы.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Атрибут data-*', 'Обязательный', 'Описание'],
|
||
rows: [
|
||
['data-client-id', 'Да', 'client_id OAuth-приложения из админки'],
|
||
['data-idp-url', 'Нет', 'PUBLIC_API_URL; по умолчанию origin скрипта + /idp-api'],
|
||
['data-idp-frontend-url', 'Нет', 'URL frontend IdP для popup; по умолчанию origin скрипта'],
|
||
['data-provider-name', 'Нет', 'Название в плашке (по умолчанию MVK ID)'],
|
||
['data-redirect-uri', 'Нет', 'redirect_uri OAuth; по умолчанию origin + /auth/callback'],
|
||
['data-scope', 'Нет', 'Scopes OAuth (по умолчанию openid profile email)'],
|
||
['data-on-success', 'Нет', 'Имя глобальной функции-callback'],
|
||
['data-auto-init', 'Нет', 'false — отключить автозапуск; вызовите LendryIdOneTap.init()']
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'Логика выбора режима',
|
||
text: 'Если браузер поддерживает IdentityCredential — скрипт вызывает FedCM. Если нет — показывается плашка в правом верхнем углу; по клику открывается popup OAuth (параметр display=popup).'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'button-builder',
|
||
title: 'Конструктор кнопок',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Интерактивный конструктор позволяет настроить кнопку входа под внешний вид вашего сайта. Выберите параметры — справа обновится живое превью, а слева сформируется готовый код виджета. Скопируйте его и вставьте на свою страницу: атрибут data-button-container указывает, в какой элемент встроить кнопку.'
|
||
},
|
||
{ type: 'one-tap-builder' },
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'Как это работает',
|
||
text: 'Кнопка рендерится скриптом sso-widget.js внутри контейнера с указанным id. Параметры размера, темы, вида, радиуса, иконки и CSS-цветов передаются через data-button-* атрибуты. Клик по превью открывает реальный popup авторизации для проверки.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'fedcm-integration',
|
||
title: 'Интеграция FedCM вручную',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Если вы не используете sso-widget.js, можно вызвать FedCM API напрямую. configURL должен указывать на /fedcm/config.json провайдера.'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'javascript',
|
||
title: 'Минимальный пример',
|
||
code: `const credential = await navigator.credentials.get({
|
||
identity: {
|
||
providers: [{
|
||
configURL: 'https://id.lendry.ru/idp-api/fedcm/config.json',
|
||
clientId: 'YOUR_CLIENT_ID'
|
||
}]
|
||
},
|
||
mediation: 'optional'
|
||
});
|
||
|
||
const idToken = credential?.token;
|
||
// Передайте idToken на ваш backend для проверки`
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'mediation: "optional" — показать диалог только при наличии сессии IdP',
|
||
'mediation: "required" — всегда показывать UI выбора аккаунта',
|
||
'mediation: "silent" — без UI; вернёт ошибку, если пользователь не залогинен'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'popup-fallback',
|
||
title: 'Popup fallback и postMessage',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'При клике на виджет открывается popup с OAuth authorize. После успешного входа IdP отправляет результат родительскому окну через window.postMessage с типом lendry-sso-onetap.'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'javascript',
|
||
title: 'Обработка на сайте клиента',
|
||
code: `window.addEventListener('message', (event) => {
|
||
if (event.data?.type !== 'lendry-sso-onetap') return;
|
||
// Проверяйте event.origin — домен вашего IdP
|
||
const { token, idToken, accessToken, code, method } = event.data;
|
||
console.log('Вход через', method);
|
||
});`
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'redirect_uri',
|
||
text: 'redirect_uri должен быть зарегистрирован у OAuth-клиента. Для SPA часто используют https://your-app.com/auth/callback — на этой странице backend обменивает code на токены, либо popup передаёт токен через postMessage.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'verify-token',
|
||
title: 'Проверка токена на backend',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'FedCM возвращает id_token (JWT). Проверьте подпись (HS256, секрет JWT_ACCESS_SECRET IdP), issuer = PUBLIC_API_URL и aud = ваш client_id. Альтернатива — обмен authorization code через POST /oauth/token и запрос GET /oauth/userinfo.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'Не доверяйте id_token только на frontend — всегда валидируйте на сервере',
|
||
'client_secret храните только на backend при обмене code → token',
|
||
'После проверки создайте локальную сессию пользователя (cookie / JWT вашего приложения)'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'examples',
|
||
title: 'Примеры интеграции',
|
||
blocks: [{ type: 'one-tap-examples' }]
|
||
},
|
||
{
|
||
id: 'local-dev',
|
||
title: 'Локальная разработка',
|
||
blocks: [
|
||
{
|
||
type: 'table',
|
||
headers: ['Сервис', 'URL'],
|
||
rows: [
|
||
['API (FedCM endpoints)', 'http://localhost:3000'],
|
||
['Frontend (sso-widget.js)', 'http://localhost:3002/sso-widget.js'],
|
||
['Документация', 'http://localhost:3003/docs/one-tap-login']
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'Тест FedCM локально',
|
||
text: 'FedCM cookie в dev использует SameSite=Lax (без Secure). Войдите на http://localhost:3002, затем откройте тестовую страницу клиента с подключённым виджетом. Chrome может требовать флаг chrome://flags/#identity-credentials-api или HTTPS для FedCM — в этом случае проверяйте popup fallback.'
|
||
}
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
slug: 'ldap',
|
||
title: 'LDAP / LDAPS',
|
||
description: 'Корпоративный вход через Active Directory.',
|
||
sections: [
|
||
{
|
||
id: 'setup',
|
||
title: 'Настройка в админке',
|
||
blocks: [
|
||
{
|
||
type: 'table',
|
||
headers: ['Ключ SystemSetting', 'Описание'],
|
||
rows: [
|
||
['LDAP_ENABLED', 'Включить кнопку «Войти через LDAP»'],
|
||
['LDAP_USE_LDAPS', 'LDAPS на порту 636'],
|
||
['LDAP_HOST', 'DC-1.domain.local,DC-2.domain.local'],
|
||
['LDAP_BIND_USERNAME', 'Логин сервисной УЗ (mvkadmin)'],
|
||
['LDAP_BASE_DN', 'OU=Users,DC=domain,DC=local'],
|
||
['LDAP_BIND_PASSWORD', 'Пароль сервисной УЗ']
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'login',
|
||
title: 'Примеры LDAP-входа',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Endpoint POST /auth/ldap/login доступен при включённой настройке LDAP_ENABLED.'
|
||
},
|
||
{ type: 'auth-ldap-examples' }
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
slug: 'sessions',
|
||
title: 'Сессии, PIN и удаление аккаунта',
|
||
description: 'Управление устройствами, PIN-блокировка, отзыв сессий и отложенное удаление профиля.',
|
||
sections: [
|
||
{
|
||
id: 'totp',
|
||
title: 'TOTP (Google Authenticator)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Двухфакторная аутентификация через TOTP заменяет SMS/email OTP при входе. Управление устройствами и отзыв сессий — через те же endpoints, что описаны ниже.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'GET /security/users/{userId}/totp/status',
|
||
'POST /security/users/{userId}/totp/setup | enable | disable',
|
||
'POST /auth/totp/begin и POST /auth/totp/verify — вход с аутентификатором'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'pin',
|
||
title: 'PIN-код',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'PIN хранится как bcrypt hash. Таймаут блокировки читается из SystemSetting PIN_LOCK_TIMEOUT_MINUTES — значение не захардкожено во frontend.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'PIN_DELETE_GRACE_MINUTES — задержка перед окончательным удалением PIN после запроса',
|
||
'PIN_REQUIRE_ON_DELETE — требовать текущий PIN при запросе удаления защиты'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'account-deletion',
|
||
title: 'Удаление аккаунта',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Пользователь может запланировать удаление профиля в личном кабинете: раздел «Данные» (/data) → «Удалить профиль». Аккаунт не удаляется мгновенно — начинается период ожидания, настраиваемый администратором.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Ключ SystemSetting', 'Описание', 'По умолчанию'],
|
||
rows: [
|
||
['ACCOUNT_DELETE_GRACE_DAYS', 'Через сколько дней после запроса окончательно удалить аккаунт', '30']
|
||
]
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'POST /profile/users/{userId}/self-delete — запланировать удаление (только свой профиль)',
|
||
'GET /profile/users/{userId}/self-delete/status — дата окончательного удаления и статус pending',
|
||
'POST /profile/users/{userId}/self-delete/cancel — отменить запрос до истечения срока',
|
||
'До истечения срока пользователь может входить и пользоваться сервисом как обычно',
|
||
'На странице /data отображается предупреждение с датой удаления и кнопкой отмены'
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'warning',
|
||
title: 'Что происходит при финализации',
|
||
text: 'Фоновый планировщик sso-core (каждые 5 минут) находит аккаунты с истёкшим сроком ожидания и выполняет soft-delete: анонимизация email/телефона/username, отзыв сессий, снятие ролей. Семьи, где пользователь — создатель, удаляются полностью (участники, чаты, медиа). Из остальных семей пользователь исключается.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'devices',
|
||
title: 'Устройства и сессии',
|
||
blocks: [
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'GET /security/users/{userId}/devices — список устройств',
|
||
'POST /security/users/{userId}/sessions/{sessionId}/revoke — выход с устройства',
|
||
'POST /security/users/{userId}/revoke-all-sessions — выход везде'
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
slug: 'family-chat',
|
||
title: 'Семья и чат',
|
||
description:
|
||
'Семейные группы, автоматические личные и бот-чаты, секретные E2E-чаты, SVG-смайлики, BotFather и realtime через WebSocket.',
|
||
sections: [
|
||
{
|
||
id: 'family',
|
||
title: 'Семейные группы',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Пользователь создаёт семью, приглашает участников по email/телефону/логину или через поиск в интерфейсе. Лимит участников задаётся в SystemSetting MAX_FAMILY_MEMBERS (по умолчанию 6). При создании семьи автоматически создаётся общий чат «Общий чат».'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'POST /family/groups — создать семью',
|
||
'GET /family/users/{userId}/groups — список семей пользователя',
|
||
'PATCH /family/groups/{groupId} — переименовать (только участники, название — создатель)',
|
||
'POST /family/groups/{groupId}/invites — отправить приглашение',
|
||
'GET /family/groups/{groupId}/invite-search?q=... — поиск пользователей для приглашения'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'family-leave',
|
||
title: 'Выход и исключение участников',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Участник может выйти из семьи самостоятельно; создатель семьи может исключить любого участника, кроме себя. При выходе или исключении пользователь удаляется из всех чатов этой семьи.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'DELETE /family/members/{memberId} — выход (если memberId свой) или исключение (если запрос от создателя семьи)',
|
||
'Создателя семьи (role: owner) удалить через этот endpoint нельзя — только удалить всю семью целиком'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'family-delete',
|
||
title: 'Удаление семьи',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Только создатель семьи может полностью удалить группу. В интерфейсе: страница семьи → «Участники семьи» → «Удалить семью». Действие необратимо.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'DELETE /family/groups/{groupId} — удалить семью (только ownerId === текущий пользователь)',
|
||
'Все участники исключаются из семьи',
|
||
'Все приглашения (FamilyInvite) удаляются',
|
||
'Все чаты семьи (ChatRoom), сообщения, опросы и голоса удаляются каскадом',
|
||
'Медиа в MinIO (аватар семьи, аватары чатов, вложения сообщений) удаляются best-effort',
|
||
'Остальным участникам отправляется уведомление family_group_deleted и событие WebSocket'
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'warning',
|
||
title: 'Без передачи владения',
|
||
text: 'Перед удалением семьи нельзя «передать» роль создателя другому участнику — только полное удаление группы или выход участников по отдельности.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'room-types',
|
||
title: 'Типы чатов семьи',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'При каждом запросе списка чатов (GET /chat/groups/{groupId}/rooms) сервер вызывает syncFamilyChats: автоматически создаёт недостающие личные и бот-чаты между участниками семьи и мигрирует устаревшие двухместные GROUP в DIRECT или BOT. Секретные E2E-чаты создаются только явно через POST /chat/groups/{groupId}/e2e-rooms.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['type', 'Описание', 'Создание', 'Шифрование'],
|
||
rows: [
|
||
['GENERAL', 'Общий чат семьи «Общий чат»', 'При создании семьи', 'Нет'],
|
||
['GROUP', 'Групповой чат (3+ участника)', 'POST /chat/groups/{groupId}/rooms', 'Нет'],
|
||
['DIRECT', 'Обычный личный чат между двумя людьми', 'Автоматически при добавлении участника', 'Нет (isE2E: false)'],
|
||
['BOT', 'Чат с Telegram-ботом участника семьи', 'Автоматически при добавлении бота в семью', 'Нет; сообщения через Bot API'],
|
||
['E2E', 'Секретный end-to-end чат', 'POST /chat/groups/{groupId}/e2e-rooms', 'Да; isEncrypted: true обязателен']
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'Поля ChatRoom',
|
||
text: 'peerUserId — id собеседника (DIRECT, BOT, E2E). botUsername — @username бота (только BOT). isE2E — true только для type: E2E. Обычные DIRECT всегда isE2E: false.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'chat-rest',
|
||
title: 'REST API чата',
|
||
blocks: [
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'GET /chat/groups/{groupId}/rooms — список чатов (с автосинхронизацией)',
|
||
'POST /chat/groups/{groupId}/rooms — создать групповой чат (минимум 3 участника)',
|
||
'POST /chat/groups/{groupId}/e2e-rooms — создать секретный E2E-чат',
|
||
'GET /chat/rooms/{roomId}/messages?limit=50&beforeMessageId=... — история',
|
||
'POST /chat/rooms/{roomId}/messages — отправить сообщение',
|
||
'PATCH /chat/messages/{messageId} — редактировать текст',
|
||
'DELETE /chat/messages/{messageId} — удалить сообщение',
|
||
'POST /chat/messages/{messageId}/vote — голос в опросе',
|
||
'POST /chat/rooms/{roomId}/read — отметить прочитанным',
|
||
'POST /chat/rooms/{roomId}/mute — { "muted": true }',
|
||
'POST /media/chat/{roomId}/media/upload-url — presigned URL для вложений'
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Пример ChatRoom в ответе',
|
||
code: `{
|
||
"id": "clx...",
|
||
"groupId": "clx...",
|
||
"type": "E2E",
|
||
"name": "Анна",
|
||
"peerUserId": "clx-peer...",
|
||
"botUsername": null,
|
||
"isE2E": true,
|
||
"unreadCount": 0,
|
||
"lastMessage": { "type": "TEXT", "content": "{\\"v\\":1,...}", "isEncrypted": true }
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'POST /chat/rooms/{roomId}/messages — тело запроса',
|
||
code: `{
|
||
"type": "TEXT",
|
||
"content": "Привет!",
|
||
"isEncrypted": false,
|
||
"replyToId": null,
|
||
"storageKey": null,
|
||
"mimeType": null,
|
||
"metadataJson": null,
|
||
"poll": null
|
||
}`
|
||
},
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Типы сообщений: TEXT, IMAGE, AUDIO, VOICE, FILE, EMOJI, POLL. Для медиа сначала загрузите файл через upload-url, затем передайте storageKey (должен начинаться с chat/{roomId}/). Поле isEncrypted: true обязательно для комнат type: E2E.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'bot-family-chat',
|
||
title: 'Чаты с ботами в семье',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Если участник семьи — Telegram-бот (у User есть linkedBotId), для каждого человека в семье автоматически создаётся комната type: BOT. Отправка сообщений через REST /chat/rooms/{roomId}/messages для BOT запрещена — используйте Bot API inbound.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'GET /bots/by-username/{botRef}/messages — история чата с ботом (в ответе composerMenuButtonJson и composerWebAppUrl)',
|
||
'POST /bots/by-username/{botRef}/messages — { "text": "..." } → Update боту (webhook/getUpdates)',
|
||
'POST /bots/by-username/{botRef}/callback — { "messageId", "callbackData" } → callback_query',
|
||
'WebSocket: bot_message, bot_message_edited, bot_callback_answer, bot_menu_button_updated'
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Написать боту из семейного чата',
|
||
code: `curl -s -X POST http://localhost:3000/bots/by-username/my_service_bot/messages \\
|
||
-H "Authorization: Bearer $TOKEN" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{"text":"/start"}'`
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'Mini App управления ботом',
|
||
text: 'Mini App для управления ботом открывается владельцем через шестерёнку в заголовке чата (не через кнопку меню). Глобальная кнопка меню (Web App) настраивается через BotFather (/setmenubutton) или setChatMenuButton Bot API. Legacy: PATCH /bots/{botId}/web-app с { "webAppUrl": "https://..." }. Подробнее — раздел «Telegram Bot API».'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'botfather-family',
|
||
title: 'BotFather в семье',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Системный бот BotFather можно пригласить в семейную группу, чтобы создавать и управлять Telegram-ботами прямо из семейного чата (команды /newbot, /mybots, настройка профиля бота и inline-кнопки Mini App). Боты не подтверждают приглашение вручную: система принимает его автоматически.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'GET /family/groups/{groupId}/invite-search?q=... — поиск пользователей, BotFather, GetMyIdBot и ботов других владельцев',
|
||
'POST /family/groups/{groupId}/invites — пригласить найденного пользователя или бота ({ "inviteeUserId": "..." })',
|
||
'Для бота приглашение сразу получает статус ACCEPTED, после чего появляется BOT-чат для участников',
|
||
'Команды профиля в чате BotFather: /setdescription, /setabouttext, /setuserpic, /setmenubutton'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'e2e-chat',
|
||
title: 'Секретные E2E-чаты',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'E2E-чаты (type: E2E) — отдельные комнаты помимо обычных DIRECT. Сервер хранит только зашифрованный ciphertext; расшифровка выполняется на клиенте. Опросы (POLL) в E2E недоступны.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'1. Сгенерировать ECDH P-256 key pair на клиенте (Web Crypto API)',
|
||
'2. PATCH /profile/users/{userId}/e2e-public-key — опубликовать publicKey (SPKI base64)',
|
||
'3. GET /profile/users/{peerUserId}/e2e-public-key — получить ключ собеседника',
|
||
'4. POST /chat/groups/{groupId}/e2e-rooms — { "peerUserId": "..." }',
|
||
'5. Шифровать payload и отправлять POST /chat/rooms/{roomId}/messages с isEncrypted: true'
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'warning',
|
||
title: 'Приватный ключ',
|
||
text: 'Приватный ключ хранится только на клиенте (localStorage в веб-приложении). Сервер не может расшифровать сообщения. При потере ключа история E2E-чата становится нечитаемой.'
|
||
},
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Алгоритм (как в apps/frontend/lib/e2e-crypto.ts): ECDH P-256 → derive AES-256-GCM. Текстовые сообщения: JSON envelope { v: 1, iv, ciphertext } в поле content. Бинарные медиа: IV (12 байт) + ciphertext, файл загружается как application/octet-stream.'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Зашифрованный payload внутри content (после расшифровки)',
|
||
code: `{
|
||
"kind": "text",
|
||
"text": "Секретное сообщение"
|
||
}
|
||
|
||
{
|
||
"kind": "emoji",
|
||
"emojiId": "e-grinning"
|
||
}
|
||
|
||
{
|
||
"kind": "image",
|
||
"fileName": "photo.jpg",
|
||
"mimeType": "image/jpeg",
|
||
"fileSize": 102400
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'Создание E2E-чата',
|
||
code: `# Опубликовать свой ключ
|
||
curl -s -X PATCH http://localhost:3000/profile/users/$USER_ID/e2e-public-key \\
|
||
-H "Authorization: Bearer $TOKEN" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{"publicKey":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."}'
|
||
|
||
# Создать секретный чат
|
||
curl -s -X POST http://localhost:3000/chat/groups/$GROUP_ID/e2e-rooms \\
|
||
-H "Authorization: Bearer $TOKEN" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{"peerUserId":"clx-peer-user-id"}'`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Отправка зашифрованного текста',
|
||
code: `{
|
||
"type": "TEXT",
|
||
"content": "{\\"v\\":1,\\"iv\\":\\"...\\",\\"ciphertext\\":\\"...\\"}",
|
||
"isEncrypted": true
|
||
}`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'emojis',
|
||
title: 'SVG-смайлики',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Вместо системных Unicode-emoji используются SVG-спрайты (как sticker packs в Telegram). Сообщение типа EMOJI хранит id смайлика в content, например e-grinning.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'Отправка: POST /chat/rooms/{roomId}/messages с { "type": "EMOJI", "content": "e-grinning" }',
|
||
'Спрайт: {FRONTEND_URL}/emojis/lendry-emojis.svg#e-grinning',
|
||
'Каталог: 121 смайлик в 8 категориях (smileys, gestures, hearts, animals, food, travel, objects, symbols)',
|
||
'Id формата e-{slug}, например e-thumbs-up, e-red-heart, e-pizza',
|
||
'В E2E: зашифровать payload { "kind": "emoji", "emojiId": "e-grinning" } и отправить type: TEXT или EMOJI с isEncrypted: true'
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'html',
|
||
title: 'Отображение смайлика в UI',
|
||
code: `<svg class="h-8 w-8" aria-hidden="true">
|
||
<use href="https://id.lendry.ru/emojis/lendry-emojis.svg#e-grinning" />
|
||
</svg>`
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'Справочник id',
|
||
text: 'Полный список id и категорий — apps/frontend/lib/emoji-catalog.ts (EMOJI_DEFINITIONS). Спрайт генерируется скриптом scripts/generate-emoji-sprite.mjs.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'chat-websocket',
|
||
title: 'WebSocket и realtime',
|
||
blocks: [
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'WebSocket: ws://localhost:8085/ws с JWT в query (?token=...) или заголовке Authorization',
|
||
'События чата: chat_message, chat_message_updated, chat_message_deleted',
|
||
'События бота: bot_message, bot_message_edited, bot_callback_answer, bot_menu_button_updated',
|
||
'Семья: family_group_deleted при удалении группы',
|
||
'Медиа: presigned upload через /media/chat/{roomId}/media/upload-url, просмотр через /media/stream/{token} с Authorization'
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
slug: 'bot-api',
|
||
title: 'Telegram Bot API',
|
||
description:
|
||
'Совместимый с Telegram Bot API прокси: Telegraf, node-telegram-bot-api, профиль бота, кнопка меню (setChatMenuButton), BotFather и Mini Apps.',
|
||
sections: [
|
||
{
|
||
id: 'overview',
|
||
title: 'Обзор',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Lendry ID включает собственный Bot API engine — drop-in замену api.telegram.org. Формат маршрутов, тела запросов и JSON-ответов совпадает с официальным Telegram Bot API, поэтому существующие боты работают без переписывания кода: достаточно указать apiRoot / baseApiUrl на ваш api-gateway.'
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'Базовый URL',
|
||
text: 'Локально: http://localhost:3000/bot{token}/{method}. В production — значение PUBLIC_API_URL из админ-панели + /bot{token}/{method}. Пример: POST https://id.lendry.ru/idp-api/bot123456789:SECRET/sendMessage'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'BotFather (REST + чат) — регистрация ботов, профиль, перевыпуск токена, кнопка меню',
|
||
'Telegram-совместимый endpoint — getMe, sendMessage, setChatMenuButton, клавиатуры, редактирование, callback, медиа',
|
||
'Mini Apps — HMAC-SHA256 валидация initData как в Telegram',
|
||
'Rate limiting через Redis и SystemSetting',
|
||
'Админ-панель — список всех ботов, блокировка, метрики (право bots.manage.all)'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'botfather',
|
||
title: 'BotFather — регистрация бота',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Любой авторизованный пользователь Lendry ID может создать до BOT_MAX_BOTS_PER_USER ботов (по умолчанию 5). Токен генерируется в формате Telegram (123456789:secret) и возвращается только при создании или revoke — в БД хранится SHA-256 hash.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Метод', 'Путь', 'Описание'],
|
||
rows: [
|
||
['GET', '/bots', 'Список ботов текущего пользователя'],
|
||
['POST', '/bots', 'Создать бота (name, username → username_bot)'],
|
||
['GET', '/bots/{botId}', 'Настройки бота (без полного токена, только tokenPrefix)'],
|
||
['PATCH', '/bots/{botId}', 'Изменить name / username'],
|
||
['DELETE', '/bots/{botId}', 'Удалить бота и все чаты/сообщения'],
|
||
['POST', '/bots/{botId}/revoke-token', 'Перевыпустить токен'],
|
||
['PATCH', '/bots/{botId}/web-app', 'Legacy: привязать URL Mini App (webAppUrl)'],
|
||
['GET', '/bots/by-username/{botRef}/messages', 'История чата + composerMenuButtonJson']
|
||
]
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Поле BotResponse', 'Описание'],
|
||
rows: [
|
||
['description', 'Описание бота — показывается перед /start'],
|
||
['aboutText', 'Краткое описание на странице профиля бота'],
|
||
['botPicUrl', 'URL аватара бота'],
|
||
['menuButtonJson', 'Глобальная кнопка меню (Web App) в формате Telegram JSON'],
|
||
['webAppUrl', 'Legacy fallback для кнопки меню, если menuButton не задан']
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'POST /bots — тело запроса',
|
||
code: `{
|
||
"name": "Сервис уведомлений",
|
||
"username": "notify_service"
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Ответ при создании',
|
||
code: `{
|
||
"bot": {
|
||
"id": "uuid",
|
||
"name": "Сервис уведомлений",
|
||
"username": "notify_service_bot",
|
||
"tokenPrefix": "482910374:AbCdEf…",
|
||
"webAppUrl": null,
|
||
"description": null,
|
||
"aboutText": null,
|
||
"botPicUrl": null,
|
||
"menuButtonJson": null,
|
||
"isActive": true
|
||
},
|
||
"token": "482910374:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789"
|
||
}`
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'warning',
|
||
title: 'Username',
|
||
text: 'Username: 5–32 символа, латиница, цифры и _. Система автоматически добавляет суффикс _bot. Передавайте имя без _bot, например my_service → my_service_bot.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'botfather-chat',
|
||
title: 'BotFather — команды в чате',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Системный бот BotFather доступен в семейном чате после приглашения через поиск семьи. Помимо /newbot и /mybots поддерживается настройка профиля каждого вашего бота прямо из переписки. Username указывайте без суффикса _bot.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Команда', 'Пример', 'Действие'],
|
||
rows: [
|
||
['/setdescription', '/setdescription my_service Описание перед стартом', 'Обновляет Bot.description'],
|
||
['/setabouttext', '/setabouttext my_service Кратко о боте', 'Обновляет Bot.aboutText'],
|
||
['/setuserpic', '/setuserpic my_service https://cdn.example.com/avatar.png', 'Обновляет Bot.botPicUrl'],
|
||
['/setmenubutton', '/setmenubutton my_service https://app.example.com|Открыть', 'Глобальная кнопка меню (Web App)'],
|
||
['/setmenubutton (JSON)', '/setmenubutton my_service {"type":"web_app","text":"App","web_app":{"url":"https://..."}}', 'Точный формат Telegram menu_button']
|
||
]
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'Команды доступны только владельцу бота',
|
||
'После обновления профиля владелец получает WebSocket bot_profile_updated',
|
||
'При смене глобальной кнопки меню все пользователи с чатами бота получают bot_menu_button_updated',
|
||
'Mini App «Создать бота» и «Управление ботом» — через inline-кнопки и /mybots'
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'Формат setmenubutton',
|
||
text: 'Поддерживаются: полный JSON Telegram ({ type: "web_app", text, web_app: { url } }), голый HTTPS URL (текст кнопки «App») или URL|Текст кнопки. Для сброса глобальной кнопки используйте Bot API setChatMenuButton без chat_id и с menu_button: { "type": "default" }.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'telegram-endpoint',
|
||
title: 'Telegram-совместимый endpoint',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Все методы принимаются по шаблону POST /bot{token}/{methodName} (также поддерживается GET с query-параметрами). Авторизация — только токен в URL, JWT не требуется.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Метод Bot API', 'Статус', 'Описание'],
|
||
rows: [
|
||
['getMe', '✓', 'Информация о боте (is_bot, username, first_name)'],
|
||
['sendMessage', '✓', 'Отправка текста с InlineKeyboardMarkup / ReplyKeyboardMarkup / ReplyKeyboardRemove'],
|
||
['editMessageText', '✓', 'Редактирование текста и/или reply_markup существующего сообщения'],
|
||
['editMessageReplyMarkup', '✓', 'Изменение только клавиатуры сообщения'],
|
||
['answerCallbackQuery', '✓', 'Ответ на нажатие inline-кнопки (toast / alert)'],
|
||
['sendPhoto', '✓', 'Отправка фото по URL или file_id (базовая реализация)'],
|
||
['sendDocument', '✓', 'Отправка документа по URL или file_id (базовая реализация)'],
|
||
['setWebhook / deleteWebhook / getWebhookInfo', '✓', 'Управление webhook URL и secret_token'],
|
||
['getUpdates', '✓', 'Long polling очередь входящих Update (offset, limit, timeout)'],
|
||
['setChatMenuButton', '✓', 'Глобальная или per-chat кнопка меню (Web App)']
|
||
]
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'reply_markup как строка',
|
||
text: 'Библиотеки вроде node-telegram-bot-api могут передавать reply_markup JSON-строкой вместо объекта. Движок автоматически парсит строку (включая двойное кодирование) в sendMessage, editMessageText, editMessageReplyMarkup, sendPhoto и sendDocument.'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Успешный ответ sendMessage',
|
||
code: `{
|
||
"ok": true,
|
||
"result": {
|
||
"message_id": 1,
|
||
"from": {
|
||
"id": 1234567890,
|
||
"is_bot": true,
|
||
"first_name": "Сервис уведомлений",
|
||
"username": "notify_service_bot"
|
||
},
|
||
"chat": {
|
||
"id": 1000000000000,
|
||
"type": "private",
|
||
"first_name": "Иван",
|
||
"username": "ivan"
|
||
},
|
||
"date": 1710000000,
|
||
"text": "Привет из Bot API!"
|
||
}
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Ошибки (формат Telegram)',
|
||
code: `// 401 — неверный или заблокированный токен
|
||
{ "ok": false, "error_code": 401, "description": "Unauthorized" }
|
||
|
||
// 429 — превышен лимит запросов (BOT_API_RATE_LIMIT_PER_SECOND)
|
||
{
|
||
"ok": false,
|
||
"error_code": 429,
|
||
"description": "Too Many Requests: retry later",
|
||
"parameters": { "retry_after": 1 }
|
||
}`
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'chat_id',
|
||
text: 'Для первого сообщения пользователю передайте chat_id = UUID пользователя Lendry ID. Система создаст BotChat и вернёт numeric chat.id в ответе — его можно использовать в последующих sendMessage, editMessageText и editMessageReplyMarkup.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'menu-button',
|
||
title: 'Кнопка меню (Menu Button / Web App)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Кнопка меню отображается слева от поля ввода в чате с ботом (как в Telegram). Конфигурация хранится в Bot.menuButton (глобально) и в ChatMenuButton (переопределение для конкретного пользователя/чата). Frontend разрешает иерархию: локальный override → глобальный menuButton → legacy webAppUrl → BotFather create URL.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Источник', 'Когда используется'],
|
||
rows: [
|
||
['ChatMenuButton (per-chat)', 'setChatMenuButton с chat_id — приоритет для этого пользователя'],
|
||
['Bot.menuButton (глобально)', 'setChatMenuButton без chat_id или /setmenubutton в BotFather'],
|
||
['Bot.webAppUrl (legacy)', 'Если menuButton не задан; bot-manage URL исключается из composer'],
|
||
['BotFather (системный бот)', 'Mini App «Создать бота» для @BotFather_bot']
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Схема menu_button (Telegram)',
|
||
code: `{
|
||
"type": "web_app",
|
||
"text": "Открыть приложение",
|
||
"web_app": { "url": "https://app.example.com/mini" }
|
||
}
|
||
|
||
// Сброс per-chat override (вернуться к глобальной):
|
||
{ "type": "default" }`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'POST /bot{token}/setChatMenuButton — глобальная кнопка',
|
||
code: `POST /bot{token}/setChatMenuButton
|
||
{
|
||
"menu_button": {
|
||
"type": "web_app",
|
||
"text": "Каталог",
|
||
"web_app": { "url": "https://shop.example.com/webapp" }
|
||
}
|
||
}
|
||
|
||
// Ответ:
|
||
{ "ok": true, "result": true }`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Per-chat override',
|
||
code: `POST /bot{token}/setChatMenuButton
|
||
{
|
||
"chat_id": 1000000000000,
|
||
"menu_button": {
|
||
"type": "web_app",
|
||
"text": "Персональное меню",
|
||
"web_app": { "url": "https://app.example.com/user/123" }
|
||
}
|
||
}
|
||
|
||
// Сброс override для чата (вернуть глобальную кнопку):
|
||
{
|
||
"chat_id": 1000000000000,
|
||
"menu_button": { "type": "default" }
|
||
}
|
||
// или omit menu_button`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'GET /bots/by-username/{botRef}/messages — фрагмент ответа',
|
||
code: `{
|
||
"botUsername": "my_service_bot",
|
||
"botDisplayName": "Мой сервис",
|
||
"composerWebAppUrl": "https://app.example.com/webapp",
|
||
"composerMenuButtonJson": "{\\"type\\":\\"web_app\\",\\"text\\":\\"Каталог\\",\\"web_app\\":{\\"url\\":\\"https://app.example.com/webapp\\"}}",
|
||
"manageWebAppUrl": "/mini-apps/bot-manage?botId=..."
|
||
}`
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'tip',
|
||
title: 'Realtime',
|
||
text: 'После setChatMenuButton клиент получает WebSocket bot_menu_button_updated с composerMenuButtonJson и composerWebAppUrl — поле ввода перерисовывается без перезагрузки страницы.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'keyboards',
|
||
title: 'Клавиатуры (Reply и Inline)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Параметр reply_markup в sendMessage, editMessageText и editMessageReplyMarkup принимает те же объекты, что и официальный Telegram Bot API. Движок нормализует их во внутренний формат для рендера в мессенджере Lendry ID и сохраняет оригинальный Telegram JSON для совместимости с библиотеками.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Telegram reply_markup', 'Назначение'],
|
||
rows: [
|
||
['InlineKeyboardMarkup', 'Кнопки под сообщением: callback_data, url, web_app'],
|
||
['ReplyKeyboardMarkup', 'Пользовательская клавиатура вместо стандартной (resize_keyboard, one_time_keyboard)'],
|
||
['ReplyKeyboardRemove', 'Скрыть reply-клавиатуру у пользователя']
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'sendMessage с inline-клавиатурой',
|
||
code: `POST /bot{token}/sendMessage
|
||
{
|
||
"chat_id": "USER_UUID",
|
||
"text": "Выберите действие:",
|
||
"reply_markup": {
|
||
"inline_keyboard": [
|
||
[
|
||
{ "text": "✅ Подтвердить", "callback_data": "confirm" },
|
||
{ "text": "❌ Отмена", "callback_data": "cancel" }
|
||
],
|
||
[
|
||
{ "text": "Открыть сайт", "url": "https://lendry.ru" },
|
||
{ "text": "Mini App", "web_app": { "url": "https://app.example.com" } }
|
||
]
|
||
]
|
||
}
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'sendMessage с reply-клавиатурой',
|
||
code: `{
|
||
"chat_id": "1000000000000",
|
||
"text": "Отправьте контакт или выберите пункт меню:",
|
||
"reply_markup": {
|
||
"keyboard": [
|
||
[{ "text": "📞 Отправить телефон", "request_contact": true }],
|
||
[{ "text": "Меню" }, { "text": "Помощь" }]
|
||
],
|
||
"resize_keyboard": true,
|
||
"one_time_keyboard": true,
|
||
"input_field_placeholder": "Введите сообщение…"
|
||
}
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Внутренний формат (replyMarkup в WebSocket-событиях)',
|
||
code: `// kind: "inline" — кнопки с callbackData / url / webAppUrl
|
||
{
|
||
"kind": "inline",
|
||
"rows": [
|
||
[
|
||
{ "text": "✅ Подтвердить", "callbackData": "confirm" },
|
||
{ "text": "❌ Отмена", "callbackData": "cancel" }
|
||
]
|
||
]
|
||
}
|
||
|
||
// kind: "reply" — пользовательская клавиатура
|
||
{
|
||
"kind": "reply",
|
||
"rows": [[{ "text": "Меню" }]],
|
||
"resizeKeyboard": true,
|
||
"oneTimeKeyboard": true
|
||
}
|
||
|
||
// kind: "remove"
|
||
{ "kind": "remove" }`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'edit-messages',
|
||
title: 'Редактирование сообщений',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Бот может обновлять уже отправленные сообщения. Система находит запись в BotMessage по chat_id и message_id, обновляет текст и/или reply_markup, выставляет editedAt и мгновенно уведомляет клиент мессенджера через WebSocket (событие bot_message_edited).'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Метод', 'Обязательные параметры', 'Описание'],
|
||
rows: [
|
||
['editMessageText', 'chat_id, message_id, text', 'Изменить текст; reply_markup опционален'],
|
||
['editMessageReplyMarkup', 'chat_id, message_id, reply_markup', 'Изменить только клавиатуру']
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'editMessageText',
|
||
code: `POST /bot{token}/editMessageText
|
||
{
|
||
"chat_id": 1000000000000,
|
||
"message_id": 42,
|
||
"text": "Статус обновлён ✅",
|
||
"reply_markup": {
|
||
"inline_keyboard": [
|
||
[{ "text": "Обновить снова", "callback_data": "refresh" }]
|
||
]
|
||
}
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Ответ (стандартный Telegram Message с edit_date)',
|
||
code: `{
|
||
"ok": true,
|
||
"result": {
|
||
"message_id": 42,
|
||
"from": { "id": 1234567890, "is_bot": true, "first_name": "Мой бот", "username": "my_bot" },
|
||
"chat": { "id": 1000000000000, "type": "private", "first_name": "Иван" },
|
||
"date": 1710000000,
|
||
"edit_date": 1710003600,
|
||
"text": "Статус обновлён ✅",
|
||
"reply_markup": {
|
||
"inline_keyboard": [[{ "text": "Обновить снова", "callback_data": "refresh" }]]
|
||
}
|
||
}
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Ошибка — сообщение не найдено',
|
||
code: `{ "ok": false, "error_code": 400, "description": "Bad Request: message to edit not found" }`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'callback-queries',
|
||
title: 'Callback Query (inline-кнопки)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'При нажатии inline-кнопки в мессенджере Lendry ID frontend отправляет событие на backend. Bot Engine формирует стандартный Telegram Update с callback_query и доставляет его боту через webhook или getUpdates. Бот отвечает методом answerCallbackQuery — клиент получает toast или alert.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'Inbound: POST /bots/by-username/{botRef}/callback — JWT пользователя, тело { messageId, callbackData }',
|
||
'Delivery: RabbitMQ → сериализация в callback_query Update → webhook / long polling',
|
||
'Outbound: POST /bot{token}/answerCallbackQuery — callback_query_id, text?, show_alert?, url?',
|
||
'Realtime: событие bot_callback_answer — снятие loading-состояния кнопки и показ уведомления'
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'POST /bots/by-username/my_service_bot/callback',
|
||
code: `Authorization: Bearer ACCESS_TOKEN
|
||
{
|
||
"messageId": 42,
|
||
"callbackData": "confirm"
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Telegram Update (callback_query) — получает бот',
|
||
code: `{
|
||
"update_id": 100002,
|
||
"callback_query": {
|
||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||
"from": { "id": 1000000000000, "is_bot": false, "first_name": "Иван", "username": "ivan" },
|
||
"message": {
|
||
"message_id": 42,
|
||
"from": { "id": 1234567890, "is_bot": true, "first_name": "Мой бот", "username": "my_bot" },
|
||
"chat": { "id": 1000000000000, "type": "private", "first_name": "Иван" },
|
||
"date": 1710000000,
|
||
"text": "Выберите действие:",
|
||
"reply_markup": {
|
||
"inline_keyboard": [[{ "text": "✅ Подтвердить", "callback_data": "confirm" }]]
|
||
}
|
||
},
|
||
"chat_instance": "bot-uuid:user-uuid",
|
||
"data": "confirm"
|
||
}
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'answerCallbackQuery — ответ бота',
|
||
code: `POST /bot{token}/answerCallbackQuery
|
||
{
|
||
"callback_query_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||
"text": "Действие выполнено!",
|
||
"show_alert": false
|
||
}
|
||
|
||
// Ответ:
|
||
{ "ok": true, "result": true }`
|
||
},
|
||
{
|
||
type: 'callout',
|
||
variant: 'info',
|
||
title: 'Telegraf',
|
||
text: 'В Telegraf обработчик bot.action("confirm", ...) и bot.on("callback_query", ...) работают без изменений — достаточно указать apiRoot на Lendry Bot API.'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'media',
|
||
title: 'Медиа (sendPhoto / sendDocument)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Базовая поддержка отправки медиа: параметр photo или document — строка с публичным URL или file_id. Файл сохраняется в mediaUrl сообщения; клиент мессенджера получает событие bot_message с messageType photo или document. Загрузка multipart/form-data напрямую в MinIO — в roadmap.'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'sendPhoto',
|
||
code: `POST /bot{token}/sendPhoto
|
||
{
|
||
"chat_id": "USER_UUID",
|
||
"photo": "https://cdn.example.com/image.jpg",
|
||
"caption": "Скриншот отчёта",
|
||
"reply_markup": {
|
||
"inline_keyboard": [[{ "text": "Скачать PDF", "callback_data": "download_pdf" }]]
|
||
}
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'sendDocument',
|
||
code: `POST /bot{token}/sendDocument
|
||
{
|
||
"chat_id": 1000000000000,
|
||
"document": "https://cdn.example.com/report.pdf",
|
||
"caption": "Ежемесячный отчёт"
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Фрагмент ответа sendPhoto',
|
||
code: `{
|
||
"ok": true,
|
||
"result": {
|
||
"message_id": 43,
|
||
"photo": [{ "file_id": "https://cdn.example.com/image.jpg", "width": 320, "height": 240 }],
|
||
"caption": "Скриншот отчёта",
|
||
"reply_markup": { "inline_keyboard": [[{ "text": "Скачать PDF", "callback_data": "download_pdf" }]] }
|
||
}
|
||
}`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'realtime-events',
|
||
title: 'Realtime-события мессенджера',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Исходящие сообщения и интерактивные обновления бота доставляются пользователю через WebSocket (RabbitMQ → notification service). Frontend мессенджера подписывается на эти события для рендера UI.'
|
||
},
|
||
{
|
||
type: 'table',
|
||
headers: ['Событие', 'Когда', 'Ключевые поля payload'],
|
||
rows: [
|
||
['bot_message', 'sendMessage / sendPhoto / sendDocument', 'messageId, text, messageType, mediaUrl, replyMarkup, telegramReplyMarkup'],
|
||
['bot_message_edited', 'editMessageText / editMessageReplyMarkup', 'messageId, text, replyMarkup, editedAt'],
|
||
['bot_callback_answer', 'answerCallbackQuery', 'callbackQueryId, text, showAlert, url'],
|
||
['bot_menu_button_updated', 'setChatMenuButton / BotFather /setmenubutton', 'composerMenuButtonJson, composerWebAppUrl, buttonText'],
|
||
['bot_profile_updated', 'BotFather /setdescription и др.', 'description, aboutText, botPicUrl, menuButtonJson']
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Пример payload bot_menu_button_updated',
|
||
code: `{
|
||
"botId": "uuid",
|
||
"botUsername": "my_service_bot",
|
||
"composerWebAppUrl": "https://app.example.com/webapp",
|
||
"composerMenuButtonJson": "{\\"type\\":\\"web_app\\",\\"text\\":\\"Каталог\\",\\"web_app\\":{\\"url\\":\\"https://app.example.com/webapp\\"}}",
|
||
"buttonText": "Каталог"
|
||
}`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Пример payload bot_message',
|
||
code: `{
|
||
"botId": "uuid",
|
||
"botUsername": "my_service_bot",
|
||
"chatId": "1000000000000",
|
||
"messageId": 42,
|
||
"text": "Выберите действие:",
|
||
"messageType": "text",
|
||
"mediaUrl": null,
|
||
"replyMarkup": {
|
||
"kind": "inline",
|
||
"rows": [[{ "text": "✅ Подтвердить", "callbackData": "confirm" }]]
|
||
},
|
||
"telegramReplyMarkup": {
|
||
"inline_keyboard": [[{ "text": "✅ Подтвердить", "callback_data": "confirm" }]]
|
||
}
|
||
}`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'inbound',
|
||
title: 'Входящие сообщения (Webhook / Long Polling)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Когда пользователь Lendry ID пишет боту, событие попадает в RabbitMQ (очередь chat.message.bot_inbound). Bot Engine сериализует его в Telegram Update и доставляет на webhook или в очередь getUpdates.'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'POST /bots/by-username/{botRef}/messages — отправить сообщение боту (JWT пользователя)',
|
||
'POST /bots/by-username/{botRef}/callback — нажатие inline-кнопки → callback_query Update',
|
||
'Webhook: POST на ваш URL с телом Update; заголовок X-Telegram-Bot-Api-Secret-Token при secret_token',
|
||
'Long polling: getUpdates без webhook; timeout до 50 секунд',
|
||
'Realtime fallback: если RabbitMQ недоступен, доставка выполняется синхронно'
|
||
]
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Telegram Update (входящее сообщение)',
|
||
code: `{
|
||
"update_id": 100001,
|
||
"message": {
|
||
"message_id": 1,
|
||
"from": { "id": 1000000000000, "is_bot": false, "first_name": "Иван", "username": "ivan" },
|
||
"chat": { "id": 1000000000000, "type": "private", "first_name": "Иван" },
|
||
"date": 1710000000,
|
||
"text": "Привет!"
|
||
}
|
||
}
|
||
|
||
// Callback query — см. раздел «Callback Query»`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'mini-apps',
|
||
title: 'Mini Apps (Web Apps)',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Mini App можно привязать тремя способами: (1) глобальная кнопка меню через menuButton / setChatMenuButton — рекомендуемый способ; (2) legacy webAppUrl через PATCH /bots/{botId}/web-app; (3) inline-кнопка web_app в reply_markup сообщения. Для проверки подлинности пользователя на backend используйте HMAC-SHA256 над initData с секретом HMAC_SHA256("WebAppData", bot_token) — как в Telegram.'
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'bash',
|
||
title: 'POST /bots/web-app/validate',
|
||
code: `curl -s -X POST http://localhost:3000/bots/web-app/validate \\
|
||
-H 'Content-Type: application/json' \\
|
||
-d '{
|
||
"initData": "query_id=...&user=%7B%22id%22%3A123%7D&auth_date=1710000000&hash=...",
|
||
"botToken": "YOUR_BOT_TOKEN"
|
||
}'`
|
||
},
|
||
{
|
||
type: 'code',
|
||
language: 'json',
|
||
title: 'Ответ при успешной проверке',
|
||
code: `{
|
||
"valid": true,
|
||
"userJson": "{\\"id\\":123,\\"first_name\\":\\"Иван\\"}",
|
||
"authDate": "1710000000"
|
||
}`
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'limits',
|
||
title: 'Лимиты и безопасность',
|
||
blocks: [
|
||
{
|
||
type: 'table',
|
||
headers: ['SystemSetting', 'По умолчанию', 'Назначение'],
|
||
rows: [
|
||
['BOT_MAX_BOTS_PER_USER', '5', 'Максимум ботов на одного пользователя'],
|
||
['BOT_API_RATE_LIMIT_PER_SECOND', '30', 'Запросов Bot API на токен в секунду (Redis)']
|
||
]
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'Токен бота хранится как SHA-256 hash; кеш валидации — Redis (TTL 5 мин)',
|
||
'При revoke или блокировке кеш токена инвалидируется',
|
||
'Сообщения бота доставляются через WebSocket: bot_message, bot_message_edited, bot_callback_answer',
|
||
'callback_query_id хранится в Redis с TTL для answerCallbackQuery',
|
||
'Администраторы с правом bots.manage.all могут просматривать все боты и блокировать злоупотребления'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'admin',
|
||
title: 'Администрирование ботов',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Endpoints ниже требуют JWT администратора и право bots.manage.all (входит в системную роль admin).'
|
||
},
|
||
{
|
||
type: 'list',
|
||
items: [
|
||
'GET /admin/bots — список всех ботов платформы (?search, ?page, ?limit)',
|
||
'GET /admin/bots/metrics — totalBots, activeBots, blockedBots, totalMessages, totalChats',
|
||
'GET /admin/bots/{botId} — карточка любого бота',
|
||
'PATCH /admin/bots/{botId}/active — { "isActive": false } для блокировки Bot API'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'examples',
|
||
title: 'Примеры интеграции',
|
||
blocks: [{ type: 'bot-examples' }]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
slug: 'api-reference',
|
||
title: 'Справочник API',
|
||
description: 'Полный список REST endpoints Lendry ID API. Swagger: /api на api-gateway.',
|
||
sections: [
|
||
{
|
||
id: 'endpoints',
|
||
title: 'Endpoints',
|
||
blocks: [{ type: 'api-reference' }]
|
||
},
|
||
{
|
||
id: 'swagger',
|
||
title: 'OpenAPI / Swagger',
|
||
blocks: [
|
||
{
|
||
type: 'paragraph',
|
||
text: 'Интерактивная документация доступна по адресу http://localhost:3000/api (Swagger UI). Все DTO и схемы генерируются автоматически из NestJS decorators.'
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
];
|
||
|
||
export function getDocPage(slug: string): DocPage | undefined {
|
||
return docPages.find((page) => page.slug === slug);
|
||
}
|
||
|
||
export function getAllDocSlugs() {
|
||
return docPages.map((page) => page.slug);
|
||
}
|